Java内存模型详解

借用一句话:Java与C++之间有一堵内存动态分配和垃圾收集技术围成的高墙,墙外面的人想进来,墙里面的人却想出去。

一.我们为什么要了解JAVA内存

  因为虚拟机帮我们JAVA程序员管理着内存,我们在new Object()申请了内存创建对象之后,便不需要再去delete/free来释放内存。也因此不容易出现内存泄漏和内存溢出的问题,看起来一切都很美好。

  但是,如果一个程序员不了解虚拟机是怎么管理内存的,那么在排查内存相关的错误是便会成为一个巨大的难题。

二.内存区域有哪些

  内存区域分为两种,一种随着虚拟机的进程启动而存在。另一种则依赖用户进程的启动和结束而建立和销毁。

  1.程序计数器
  一块较小的线程私有的内存空间,可以看作是当前线程的所执行的字节码的行号指示器。

  如果线程正在执行的是一个JAVA方法,那么计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是native方法,那么计数器值为空(Undefined)。

  该内存区域是唯一一个在JAVA虚拟机规范中没有规定任何OutOfMemoryError(OOM)情况的区域。

  2.虚拟机栈
  线程私有的,每个Java方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  3.本地方法栈
  线程私有,同虚拟机栈,为native方法服务。在HotSpot虚拟机中,直接把虚拟机栈和本地方法栈合二为一。

  4.堆
  线程共享的区域。存放实例的区域,几乎所有的对象实例都在这里分配内存。同时,因为空间固定,而用户可能需要不断生成实例,故该区域还是垃圾收集的主要区域。垃圾收集将在后面提到。

  Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。

  5.方法区
  线程共享的区域,存储已被虚拟机加载的类信息、常量、静态变量等数据。

  很多人称之为“永生代”,因为HotSpot使用永生代来实现方法区。Java规范中对方法区的限制十分宽松,可以选择不实现垃圾收集。

  6.运行时常量池
  方法区的一部分,用于存放编译器生成的各种字面量和符号引用,在类加载完成后进入方法区的运行时常量池中存放。

  关于这快区域,有一个需要注意的地方。代码如下:

  

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class t18 {    public static void main(String[] args){

Integer a1 = 128 ;
Integer a2 = 128 ;
System.out.println(a1==a2);
Integer b1 = 127;
Integer b2 = 127;
Integer b3 = 1 + b1;
Integer b4 = a1 -1 ;
System.out.println(b1==b2);
System.out.println(b3==a1);
System.out.println(b4 == b1);
}
}

  上面代码的运行结果为 false ,true ,false ,true 。这是很多人第一次见到时都无法理解的,因为这里涉及到了常量池的知识。JVM会把一些int,String等数据进行在常量池中缓存,但是重点在于,对于int型数据,只会缓存 -128~127 范围内的数据。因此:

  a1、a2超过了127,在堆中分配内存,两者指向不同对象,返回false;

  b1、b2都指向常量池中的127,故b1、b2指向地址相同,返回true;

  第3、4个同理,Integer b3 = 1+b1 —-> Integer b3 =Integer.valueOf(1+b1)。

  

  7.直接内存
  JDK1.4后加入了NIO (new I/O)类,引入了基于通道与缓冲区的IO方式,可以使用native函数库分配机器内存,如电脑8g内存,JVM可以使用电脑的剩余内存,只需要在java堆中存储DirectByteBuffer对象作为内存的引用进行操作。这样在某些场景中提高性能。

三.在new一个对象时发生了什么

当虚拟机遇到一条new 指令时,首先回去检查能否在常量池中定位,并检查这个类是否已经被加载、解析、初始化过,如果没有,那么必须先执行类的加载过程。

类加载完成后,接下来将会为对象分配内存,即把一块确定大小的内存从java堆中划分出来。如果java堆是连续且规整的,已分配过的内存放在一边,空闲的在另一边。中间的指针作为分界点的指示器,那么分配内存就是将指针向空闲的方向移动所需要的距离,(使用Serial、PalNew等带规整过程的垃圾收集器);如果java堆是不规整的,那么虚拟机就必须维护一个记录,分配内存的同时需要更新记录,(如使用CMS这种基于标记-清除算法的收集器)。

将分配到的内存空间赋予初值,如整形变量置0,bool型置false。保证了对象字段在代码中可以不付初值就可以直接使用。然而在实际编写代码中,建议采用赋初值的形式,保持一个良好的代码习惯。另外

String s ;
System.out.println(s); //未初始化,编译器报错
该代码会报错,而不是输出null,切记切记。

初始化对象的对象头数据,每个java对象都有对象头(Object Header),里面记录了对象是哪个类的实例、如何找到类的元数据信息、哈希码、GC年龄、偏向锁等信息。

执行init方法,把对象按照程序员的一员进行初始化,这样,一个可用的对象才完成new操作。

四.一个对象在内存中有哪些部分

  以HotSpot虚拟机为例,对象在内存中存储的区域可以分为三个部分

  1.对象头(Object Header)
  对象头包括两部分,一部分用于存储对象自身的运行时数据,官方称之为“Mark Word”,包括:HashCode、GC年龄、锁状态、线程持有锁、偏向锁线程id、偏向时间戳等。占一个字长(32bit或64bit,取决于虚拟机)。

  另一部分是类型指针,对象指向的类元数据指针,通过这个来确定该对象是哪个类的实例。另外如果一个对象是一个数组,那么还有一块用于记录数组长度的数据。

  2.对象数据
  即实例中存储的,程序员设计的应该存储的数据。

  3.对齐填充
  不是必须的,仅仅起着占位的作用,HotSpot内存管理规定对象的起始地址必须是8字节的整数倍,换句话说对象的大小必须是9字节的整数倍,因此,当实例大小没有对齐时,需要通过对齐填充来补全。

五.如何访问定位对象

  创建对象是为了使用对象,java虚拟机使用栈上的reference数据来操作堆上的具体对象,目前的访问方式主流有两种:

  1.使用句柄访问

  Java堆中会划分出一块内存作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含了对象的实例数据与类型数据各自的地址信息。

  即访问时refenrence(存句柄地址) –> 句柄池(堆中,存对象地址) –> 具体对象(堆或方法区中)。

  2.使用直接指针访问

  直接访问,reference(存对象地址)–>具体对象(堆中或方法区中),一次跳转。HotSpot虚拟机使用的就是这种方式。