JVM内存布局规定了Java在运行过程中内存申请、分配和管理的策略,保证了JVM的高效稳定运行。
结合JVM规范,来探讨一下经典的JVM内存布局,下面的内存布局基于Jdk1.8,JVM是HotSpot
1.Heap(堆区)
Heap是OOM故障主要的发源地,它存储几乎所有的实例对象,堆由垃圾回收器自动回收,堆区各子线程共享。
由图所示,堆区由新生代和老年代组成,而 新生代 = 1个Eden区 + 2个Survivor 区。
绝大部分对象在Eden区生成,当它满了之后,会触发Young GC(也成为Minor GC)。垃圾回收的时候,Eden区实现清除算法(标记-复制算法),没有被引用的对象会被直接回收,而存活下来的对象会被移送到Survivor区。
每次YoungGC的时候,这些存活的对象都会被复制到未被使用的那块空间,然后将正在使用的那块survivor区清空,即交换两块空间的使用状态。
如果YGC移送的对象survivor都放不下了,那么就直接移送到Old区。
每个对象都有一个计数器,用于记录经历YGC的次数,每次YGC都会+1。当达到了一个阈值之后,这个对象就会被移到老年代。这个阈值默认为15,可以通过-XX:MaxTenuringThreshold参数能够配置。对象分配与简要GC流程图如下:
若不同的JVM实现及不同的回收机制中,堆内存划分的方式是不一样的。
参数:
- -Xms:-X表示JVM的运行参数,ms是memory start,表示初始的堆空间大小,如-Xms128M
- -Xmx:mx是memory max,表示堆空间的最大可用内存。
- 在生产环境中,一般设置此两个参数相同,避免对空间不断扩容和回缩。
- -XX:MaxTenuringThreshold:配置对象YGC时从新生代晋升为老年代的计数器阈值。
- -XX:HeapDumpOnOutOfMemoryError:遇到OOM时能够输出堆内信息
2.Metaspace(元空间)
在jdk8中,元空间的前身Perm区已经被淘汰。在jdk7及之前的版本中,只有Hotspot才有Perm区,它启动时固定大小,很难调优,如果动态加载类过多,容易产生Perm区的OOM。
Metaspace在本地内存中分配。jdk8中,Perm区中的所有内容中字符串常量移到了堆内存,其他内容包括类元信息、字段、方法、常量等移到元空间。
其中,图中的CodeCache也是存在于元空间的
各线程共享metaspace。
3.JVM Stack(虚拟机栈)
JVM是基于栈结构的运行环境。JVM的虚拟机栈是描述Java方法执行的内存区域,它是线程私有的,每个线程都有自己独立的栈帧。
每个方法从开始调用到执行结束,就是栈帧从入栈到出栈的过程。在活动线程中,只有栈顶的帧才是有效的,成为当前栈帧。栈帧是方法运行的基本结构。
操作栈的压栈与出栈如下图所示:
虚拟机栈通过压栈和出栈的方式,堆每个方法对应的活动栈帧进行运算处理,方法正常执行结束会跳到另一个栈帧,如果出现异常,会进行异常回溯,返回地址通过异常处理表确定。
栈帧在整个JVM体系的地位颇高,包括局部变量表、操作栈、动态链接、方法返回地址等。
(1)局部变量表
存放方法参数和局部变量的区域。局部变量没有准备阶段,必须显式初始化。如果是非静态方法,在局部变量表位置第一个位置index[0]上存放的是方法所属对象的实例引用,也就是this。随后存储的是参数和局部变量。
字节码指令STORE就是将操作栈中计算完成的局部变量写回局部变量表中。
(2)操作栈
JVM执行引擎是基于栈的执行引擎,其中的栈就是指操作栈。它的初始状态为空的桶式栈结构。栈的深度可以在方法元信息的stack属性中看到。
下面通过简单的代码说明操作栈与局部变量表的交互:
public int inc() { int x = 13; int y = 14; int z = x + y; return z; }
字节码:
public int inc(); descriptor: ()I flags: ACC_PUBLIC Code: stack=2, locals=4, args_size=1 // 最大栈深度为2,局部变量表4个,参数个数1(这里指的是index[0]中的this) 0: bipush 13 // 常量压入栈 2: istore_1 // 保存到局部变量表的slot_1 3: bipush 14 // 常量压入栈 5: istore_2 // 保存到局部变量表的slot_2 6: iload_1 // 把slot_1压入栈 7: iload_2 // 把slot_2压入栈 8: iadd // 把两个上方两个数取出来,在cpu加一下,并压栈 9: istore_3 // 结果保存到局部变量表的slot_3 10: iload_3 // slot_3压入栈 11: ireturn // 返回 LineNumberTable: line 25: 0 line 26: 3 line 27: 6 line 28: 10 LocalVariableTable: Start Length Slot Name Signature 0 12 0 this Lchapter5/TestClass; 3 9 1 x I 6 6 2 y I 10 2 3 z I
局部变量表就像一个中药柜,里面有很多抽屉,依次编号为0,1,2,。。,n,字节码istore_1就是打开1号抽屉,把13放进去。
栈是一个很深的竖桶,每次只能对桶口的元素操作。像上面的字节码,每次要对变量进行操作,都要先从常量池或者变量表把数据压入栈,有些指令是可以直接在变量表操作的,不需要压入栈,比如iinc指令。
这里就给出了i++和++i在字节码层面上的解释:
a=i++ |
0:iload_1 // 从1号抽屉取编号为1的变量(局部变量表),压入栈顶 1:iinc 1,1 // 对局部变量表(抽屉)中编号为1的变量,自增1 4:istore_2 // 把栈顶元素保存到局部变量表的slot_2(2号抽屉),此时,a是i为自增前的值 |
a=++i |
0:iinc 1,1 // 对局部变量表slot_1自增1 3:iload_1 // 把slot_1压栈 4:istore_2 // 栈顶元素保存到slot_2 |
(3)动态链接
每个栈帧中包含一个在常量池中对当前方法的引用,目的是支持方法调用过程的动态链接。
(4)方法返回地址
方法执行退出有两种情况:第一,正常退出RETURN、IRETURN、ARETURN等。第二,异常退出。
无论哪种退出,都将会返回至方法当前被调用的位置。方法退出相当于弹出当前栈帧,退出可能有三种形式:
- 返回值压入上层调用栈帧
- 异常信息抛给能处理的栈帧
- PC计数器指向方法调用后的下一条指令
4.Native Method Stacks(本地方法栈)
它是线程私有的。线程开始调用本地方法时,会进入不受JVM约束的世界,本地方法可以通过JNI(Java Native Interface)访问虚拟机运行时的数据区。
当大量本地方法出现时,会削弱JVM的控制力,而且,它的出错信息比较黑盒,难以发现和调试。
5.Program Counter Register(程序计数器)
Register命名来源于CPU寄存器,CPU只有把数据装载到寄存器才可以运行,寄存器存储指令相关的现场信息,由于CPU时间片轮限制,众多线程在并发执行过程中,任一个确定时刻,一个处理器或多核处理器中的一个内核只能执行某个线程的指令,这样必然导致经常中断和恢复。
因此,每个线程创建后,都会有自己的程序计数器和栈帧,PC用于存放执行指令的偏移量和行号指示器等,线程执行和恢复都需要依赖PC。此区域不会发生内存溢出。
从线程共享的角度来看,堆内存(OutofMemoryError:Java heap space)和元空间(OutofMemoryError:Metaspace)是线程线程共享的,而PC、本地方法栈(StackOverflowError)、虚拟机栈(StackOverflowError)是线程私有的。