JVM内存结构分析
栈
栈:包含局部变量表,操作数栈,动态链接等,这里主要概述以上三类。栈帧是栈执行的基本单位,其中每一个栈帧都表示一个方法的执行,每一个栈帧都具有以上的结构。
-
局部变量表 :用于存放方法参数和方法内部定义的局部变量,局部变量的容量以变量槽(Slot)为最小单位,其中Slot是可以重用的,当Slot中的变量超出了它的作用范围,拿下一次分配Slot时,该槽位将被重新利用。
例如:int a =1;{int b =1;} int c= 1;
上面代码的槽位分析为:
this -----> 0
a --------> 1
b --------> 2
c --------> 2
由于执行到int c=1
时b变量超出它的作用范围,此时b变量的槽位将被重新利用。 -
操作数栈 : 它是标准的栈操作,通过压栈和出栈来访问,虚拟机把操作数栈作为它的工作区-------大多数指令要从这里弹出数据,执行运算,然后把结果压回操作数栈。
例如:int a=1;int b=2;int c=a+b;
以上代码对应的指令为:begin ipush 1 istore_1 ipush 2 istore_2 iload_1 iload_2 iadd istore_3 end
我们分析上面的执行过程:在局部变量表中先创建了索引位置为1值为1的局部变量a,再创建了索引位置为2值为2的局部变量b,然后iload_1,iload_2加载局部变量表中索引位置为1和2的值进行依次压栈操作,有操作数栈进行运算iadd,执行iadd指令之后栈底值为3,进行istore_3在局部变量表中创建索引位置为3值为3的局部变量c。
-
动态链接 :栈帧内部包含一个指向运行时常量池中该栈帧所属方法的引用。动态链接发生在栈帧完全入栈之前,也在局部变量表形成之前。它会声明方法中所使用到的符号引用,以便可以执行。
例如:public void text(){ A a = new A(); a.eat(); }
分析上面执行过程:当执行到
a.eat();
语句时,会先去运行时常量池中找到eat方法的直接引用,即在方法区的地址。然后形成指向A类eat方法的动态链接,找到方法的入口,也就找到了对应的字节码指令,形成了eat这个方法的栈帧,并有了局部变量表和操作数栈的出入栈。字节码指令执行后,eat方法的栈帧出栈,然后根据方法的返回地址,返回到text方法对应地址继续执行。
堆
堆:1.8以后堆包括新生代,老年代,元空间,我们一般指堆为新生代和老年代。其中新生代和老年代在堆中的比例为1:2。而新生代中包括Eden区,幸存者区(S0,S1),其中Eden区和S0、S1区在新生代中比例为8:1:1。
如图:图中为jdk1.7
下面讲一讲比较重要的对象分配过程:
- 对象分配的一般过程:对象在新生代中被创建出来,随着对象的不断创建,有些对象可能已无引用成为了垃圾,此时新生代会进行YGC/Minor GC,YGC执行完后剩余的对象将被提升到幸存者区,第一次将会先将它们放在S0区,并且会为每一个对象分配一个年龄计数器,每次调动复制会让年龄计数器会加一,此时Eden区被清空又有容量了。
当对象又不断被创建时,Eden区满了又将执行YGC,此时执行完YGC后剩余对象将被提升到幸存者区,此时这些对象的年龄为1。这时S0区对象和Eden区剩余对象将被放入S1区,此时由S0到S1区的对象年龄为2。
从上面的过程我们可知,幸存者区哪个区为空,那么它便作为执行YGC后幸存者对象所存放的区。我们称为空的幸存者区为(to区),存放幸存者对象的区称为(from区)。我们把这个分配对象这个过程分析叫做可达性分析。 - 对象分配的特殊过程:当创建的对象过大时,首先会判断Eden区放得下吗?放不下则会进行YGC,倘若YGC过后,Eden区还是存放不下,则会判断Old老年代放的下吗?放得下直接晋升在老年代,放不下则会进行FGC/Major GC,倘若FGC过后还是放不下,则抛出OOM。
具体过程我们可以图解:
方法区
方法区:方法区也称为"非堆",也是一个线程共享的区域。其中主要存储了加载的类信息和运行时常量池。其中类信息包括加载的类字节码、class/method/field等元数信息、static-final常量、static变量、jit编译器编译后的代码等数据。
(1) class/method/field等元数据对象:字节码加载之后,JVM会根据其中的内容,为这个类生成Class/Method/Field等对象,它们用于描述一个类,通常在反射中用的比较多。不同于存储在堆中的java实例对象,这两种对象存储在方法区中。
(2) static-final常量、static变量:对于这两种类型的类成员,JVM会在方法区为它们创建一份数据,因此同一个类的static修饰的类成员只有一份;
-
运行时常量池:我们要了解运行时常量池可以先了解它是怎么由来的。我们将java文件编译成class文件时,其中有一部分用于存放编译器生成的各种字面量和符号引用,这部分内容在类加载器加载过后会存放在运行时常量池中,只是此时不再是常量池中的符号引用了,而是直接引用是真实地址。
-
运行时常量池具备动态性:除了在类加载的时候可以将常量池写入其中,java程序运行期间也可以向其中写入常量,例如String类的方法intern();
//使用StringBuilder在堆上创建字符串abc,再使用intern将其放入运行时常量池 String str = new StringBuilder("abc"); str.intern(); //直接使用字符串字面量xyz,其被放入运行时常量池 String str2 = "xyz";
总结
以上内存结构可以用一张图来概括:
对象创建的内存分析
对象创建有六个步骤,接下来我们进行一一分析:
-
判断对象对应的类是否加载、链接、初始化:当虚拟机遇到一条new指令时,首先会去检查这个指令的参数能否Metaspace的运行时常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析、初始化。(即判断类元信息是否存在)。如果没有,那么在双亲委派机制下,使用当前类加载器以ClassLoad+包名+类名为key进行查找对应的class文件。如果没有找到文件,则抛出ClassNotFoundExcepion异常,如果找到,则进行类加载,并生成对应的Class类对象。
-
为对象分配内存:首先会去计算对象占用空间的大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。当内存规整时,则采用指针碰撞法将对象存储到内存中。当内存不规整时,则采用空间列表分配,其实意思就是虚拟机维护了一个列表,记录了那些内存块是可用的,在分配的时候找到一块足够大的空间分配给对象实例,并更新列表。这种方法就叫做空间列表分配。
-
处理并发问题:总共有两种,第一种是采用CAS失败重试,区域加锁保证更新的原子性。第二种是线程预先分配一块TLAB,这块区域是Eden区单独为每个线程分配的独立空间,所以不会有安全问题。jdk1.8已经实现了TLAB。
-
初始化分配到的空间:所有属性设置默认值,保证对象实例字段不赋值时可以直接使用。对象的属性赋值操作依次为:属性的默认初始化、显示初始化、代码块中初始化、构造器中初始化。
-
设置对象头:将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。对象头包含两部分:(1)运行时元数据,其中运行时元数据包括哈希值(HashCode)、GC分代年龄、锁状态标志(判断这个对象是否作为了锁)、线程持有的锁、偏向线程ID、偏向时间戳。(2)类型指针,指向方法区(元空间)该对象所属类型。如果是数组对象的话还需要记录一下数组的长度。对象的内存布局我们可以用图来表示:如下
-
执行init方法进行初始化。
对象的访问定位
我们创建对象的目的就是为了使用它,JVM是如何通过栈帧中的对象引用访问到其堆内部的对象实例呢?对象访问的方式主要有两种:(1)句柄访问:在堆中开辟了一块区域叫做句柄池,一个对象对应一个句柄,句柄中有两个信息,一个是到实例数据的指针,一个是到对象类型数据的指针,如图可示:
(2)直接指针:栈帧中的对象引用直接指向了堆中的实例数据,对象的实例数据中有一个到对象类型数据的指针。如图所示: