内存是非常重要的系统资源,是硬盘和CPU的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异。结合JVM虚拟机规范,来学习一 下经典的JVM内存布局。
话不多说,先来一图(截图来至阿里的<码出高效:java开发手册>)。上图就是jdk8之后的jvm经典布局,接下来主要详细分析各个分区的功能及作用。
栈( Stack )是一个先进后出的数据结构,就像子弹的弹夹,最后压入的子弹先发射,压在底部的子弹最后发射,撞针只能访问位于顶部的那一颗子弹。相对于基于寄存器的运行环境来说,JVM 是基于栈结构的运行环境。栈结构移植性更好,可控性更强。JVM中的虚拟机栈是描述Java方法执行的内存区域,它是线程私有的(每一条Java虚拟机线程都有自己私有的Java虛拟机栈,这个栈与线程同时创建)。
栈中的元素用于支持虚拟机进行方法调用,每个方法从开始调用到执行完成的过程,就是栈帧从入栈到出栈的过程。在活动线程中,只有位于栈顶的帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。StackOverflowError表示请求的栈溢出,导致内存耗尽,通常出现在递归方法中。JVM能够横扫千军, 虚拟机栈就是它的心腹大将,当前方法的栈帧,都是正在战斗的战场,其中的操作栈是参与战斗的士兵。
每个栈帧(见上图)内部都包含一组称为局部变量表的变量列表。栈帧中局部变量表的长度由编译期决定,并且存储于类或接口的二进制表示之中,即通过方法的code属性保存及提供给栈帧使用。
一个局部量可以保存一个类型为boolean、byte、char、short、int、float、reference或returnAddress的数据。两个局部变量可以保存一个类型为long或double的数据。
局部变量使用索引来进行定位访问。首个局部变量的索引值为0。局部变量的索引值是个整数,它大于等于0,且小于局部变量表的长度。
long和double类型的数据占用两个连续的局部变量,这两种类型的数据值采用两个局部变量中较小的索引值来定位。例如,将一个double类型的值存储在索引值为n的局部变量中,实际上的意思是索引值为n和n+1的两个局部变量都用来存储这个值。然而,索引值为n+1的局部变量是无法直接读取的,但是可能会被写人。不过,如果进行了这种操作,那将会导致局部变量n的内容失效。前面提及的局部变量索引值n并不要求一定是偶数,Java虚拟机也不要求double和long类型数据采用64位对齐的方式连续地存储在局部变量表中。虛拟机实现者可以自由地选择适当的方式,通过两个局部变量来存储一个double或long类型的值。
Java虚拟机使用局部变量表来完成方法调用时的参数传递。当调用类方法时,它的参数将会依次传递到局部变量表中从0开始的连续位置上。当调用实例方法时,第0个局部变量一定用来存储该实例方法所在对象的引用(即Java语言中的this关键字)。后续的其他参数将会传递至局部变量表中从1开始的连续位置上。
每个栈帧(见上图)内部都包含一个称为操作数栈的后进先出( Last-In-First-Out,LIFO)栈。栈帧中操作数栈的最大深度由编译期决定,并且通过方法的code属性保存及提供给栈帧使用。
栈帧在刚刚创建时,操作数栈是空的。Java虚拟机提供一些字节码指令来从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中(如:iload,aload,getfield等),也提供了一些指令用于从操作数栈取走数据(将一个数值从操作数栈存储到局部变量表。如:istore,astore等)、操作数据(iadd,isub等)以及把操作结果重新人栈。在调用方法时,操作数栈也用来准备调用方法的参数以及接收方法返回结果。
例如,iadd字节码指令的作用是将两个int类型的数值相加,它要求在执行之前操作数栈的栈顶已经存在两个由前面的其他指令所放人的int类型数值。在执行iadd指令时,两个int类型数值从操作栈中出栈,相加求和,然后将求和结果重新入栈。在操作数栈中,一项运算常由多个子运算( subcomputation) 嵌套进行,一个子运算过程的结果可以被其他外围运算所使用。
static class VmStacks { /******************方法下方字节码是通过javap -v class文件获得*********************/ /** * 该方法主要演示jvm对方法调用过程 */ public int directInvoke() { return addI(1, 2); } public int directInvoke(); descriptor: ()I flags: ACC_PUBLIC Code: stack=3, locals=1, args_size=1 // 最大栈深度为3,局部变量表个数为1 0: aload_0 // 将this从局部变量表压入操作数栈,因为该方法为实例方法,故局部变量表slot为0的就是实例本身(this) 1: iconst_1 // 将常量值1压入操作数栈 2: iconst_2 // 将常量值2压入操作数栈 3: invokevirtual #2 // Method addI:(II)I 调用addI(int x, int y)方法 6: ireturn 返回int类型的值 LineNumberTable: line 86: 0 LocalVariableTable: Start Length Slot Name Signature 0 7 0 this Lmayfly/core/util/CodeByteTest$VmStacks; 1. 因为该方法为对象实例方法,故方法调用的第一步是将当前实例的自身引用this压人操作数栈中 (如果是类方法,即static方法则没有该步骤)。传递给方法的int类型参数值1和2随后人栈。 2. 当调用addI方法(即invokevirtual #2 指令)时,Java虚拟机会创建一个新的栈帧, 传递给addI方法的参数值会成为新栈帧中对应局部变量的初始值。即由directInvoke 方法推人操作数栈的this和两个传递给addI方法的参数1与2,会作为addI方法栈帧的 第0、1、2个局部变量。 3. 当addI方法执行结束、方法返回时,int类型的返回值被压入方法调用者的栈帧的操作数栈, 即directInvoke方法的操作数栈中。而这个返回值又会立即返回给directInvoke的调用者。 directInvoke方法的返回过程由directInvoke方法中的ireturn指令实现。由addI方法所返回的 int类型值会压入当前操作数栈的栈顶,而ireturn指令则会把当前操作数栈的栈顶值(此处就是addI的返回值) 压人directInvoke方法的调用者的操作数栈。然后跳转至调用directInvoke的那个方法的下一条指令继续执行, 并将调用者的栈帧重新设为当前栈帧。Java虚拟机对不同数据类型(包括声明为void,即没有返回值的方法) 的返回值提供了不同的方法返回指令,各种不同返回值类型的方法都使用这一组返回指令来返回。 /**********************************************************************************/ public int addI(int x, int y) { int z = y++; // 纯粹为了演示 i++与++i之间字节码的区别,无其他意义 return ++x + y; } public int addI(int, int); descriptor: (II)I flags: ACC_PUBLIC Code: stack=2, locals=4, args_size=3 0: iload_2 1: iinc 2, 1 4: istore_3 5: iinc 1, 1 8: iload_1 9: iload_2 10: iadd 11: ireturn LineNumberTable: line 71: 0 line 72: 5 LocalVariableTable: Start Length Slot Name Signature 0 12 0 this Lmayfly/core/util/CodeByteTest$VmStacks; 0 12 1 x I 0 12 2 y I 5 7 3 z I 1. 方法调用者(如上个方法directInvoke)将操作栈上的变量出栈并传递给该方法栈帧的局部变量表中的的 0,1,2位置的slot上。 2. 在0~4索引(指令操作码在数组中的下标,该数组以字节形式来存储当前方法的java虚拟机代码, 也可以认为是相对于方法起始处的字节偏移量)上的三条字节码表示的是int z = y++ 该行代码; iload_ 2 从局部变量表的第2号抽屉里取出一个数,压入栈顶,下一步直接在抽屉(局部变量表中的slot) 里实现+1的操作,而这个操作对栈顶元素的值没有影响。所以istore_ 3只是把栈顶元素赋值给z,即z == y而不是y+1后的值; 3. 索引5~8表示++x, iinc 1, 1先在第1号抽屉里执行+1操作,然后通过iload_ 1 把第1号抽屉里的数压入栈顶, 所以操作数栈中存入的是+1之后的值。 4. 接着将2号抽屉的数值(即y)压入栈顶,随后执行iadd指令,将栈顶的两个元素弹出栈相加后, 并将相加后的结果重新压入栈顶并return给调用者。 /**********************************************************************************/ } 复制代码
每个栈帧内部都包含一个指向当前方法所在类型的运行时常量池的引用,以便对当前方法的代码实现动态链接。在class文件里面,一个方法若要调用其他方法,或者访问成员变量,则需要通过符号引用(symbolic reference) 来表示,动态链接的作用就是将这些以符号引用所表示的方法转换为对实际方法的直接引用。类加载的过程中将要解析尚未被解析的符号引用,并且将对变量的访问转化为变量在程度运行时,位于存储结构中的正确偏移量。由于对其他类中的方法和变量进行了晚期绑定(latebinding),所以即便那些类发生变化,也不会影响调用它们的方法。
方法执行时有两种退出情况:第一,正常退出,即正常执行到任何方法的返回字节码指令,如RETURN、IRETURN、ARETURN等;第二,异常退出。无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:
Java虚拟机可以支持多条线程同时执行,每一条Java虚拟机线程都有自的pc ( program counter) 寄存器。在任意时刻,一条Java 虚拟机线程只会执行一个方法的代码,这个正在被线程执行的方法称为该线程的当前方法。如果这个方法不是native的,那pc寄存器就保存Java虚拟机正在执行的字节码指令的地址,如果该方法是native的,那pc寄存器的值是undefined。pc寄存器的容量至少应当能保存一个returnAddress类型的数据或者一个与平台相关的本地指针的值。
Heap是OOM故障最主要的发源地,它存储着几乎所有的实例对象,堆由垃圾收集器自动回收,堆区由各子线程共享使用。通常情况下,它占用的空间是所有内存区域中最大的,但如果无节制地创建大量对象,也容易消耗完所有的空间。堆的内存空间既可以固定大小,也可以在运行时动态地调整,通过如下参数设定初始值和最大值,比如-Xms256M -Xmx1024M, 其中-X表示它是JVM运行参数,ms是memory start的简称,mx是memory max的简称,分别代表最小堆容量和最大堆容量。但是在通常情况下,服务器在运行过程中,堆空间不断地扩容与回缩,势必形成不必要的系统压力,所以在线上生产环境中,JVM的Xms和Xmx设置成一样大小,避免在GC后调整堆大小时带来的额外压力。
Java内存运行时区域的各个部分,其中 程序计数器 、 虚拟机栈 、 本地方法栈 三个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由JIT编译器进行一些优化,但在基于概念模型的讨论中,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内不需要过多考虑回收的问题,因为方法结束或线程结束时,内存自然就跟随着回收了。因此堆是垃圾收集的最主要内存区域。
如最开始那个布局图,为何要将堆空间分为新生代、老年代,以及新生代又为何要划分为一个Eden区和两个Survivor(幸存者)区,结合GC讲解可更好地理解为何要酱紫划分。
堆中几乎存放着Java世界中所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象有哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1 ;当引用失效时,计数器值就减1 ;任何时刻计数器都为0的对象就是不可能再被使用的。引用计数算法(Reference Counting)的实现简单,判定效率也很高,但是,Java语言中没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间的相互循环引用的问题。举个简单的例代码如下:
public class ReferenceCountingGC { public Object instance; public ReferenceCountingGC(String name){} } public static void testGC(){ ReferenceCountingGC a = new ReferenceCountingGC("objA"); ReferenceCountingGC b = new ReferenceCountingGC("objB"); a.instance = b; b.instance = a; a = null; b = null; } 复制代码
我们可以看到,最后这2个对象已经不可能再被访问了,但由于他们相互引用着对方,导致它们的引用计数永远都不会为0,通过引用计数算法,也就永远无法通知GC收集器回收它们。
在主流的商用程序语言中(Java和C#,甚至包括前面提到的古老的Lisp),都是使用根搜索算法(GC Roots Tracing)判定对象是否存活的。这个算法的基本思路就是通过一系列的名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一一个对象到GC Roots没有任何引用链相连(用图论的话来说就是从GCRoots到这个对象不可达)时,则证明此对象是不可用的。如下图所示,对象object 5、object 6、object 7虽然互相有关联,但是它们到GCRoots是不可达的,所以它们将会被判定为是可回收的对象。
通过根搜索算法,成功解决了引用计数所无法解决的问题“循环依赖”,只要你无法与 GC Root 建立直接或间接的连接,系统就会判定你为可回收对象。那这样就引申出了另一个问题,哪些属于 GC Root。在Java语言里,可作为GCRoots的对象包括下面几种:
最基础的收集算法是“标记-清除”(Mark-Sweep) 算法,如它的名字一样,算法分为"标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象,之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高,另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集 动作。标记-清除算法的执行过程如下图所示。
为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,未免太高了一点。复制算法的执行过程如下图所示。
现在的商业虚拟机都采用这种收集算法来回收新生代,IBM的专门研究表明,新生代中的对象98%是朝生夕死的,所以并不需要按照1: 1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虛拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90% ( 80%+10%),只有10%的内存是会被“浪费”的。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,就需要依赖其他内存(这里指老年代)。
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,“标记-整理”算法的示意图如下图所示。
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记一清理”或者“标记一整理” 算法来进行回收。
堆分成两大块:新生代和老年代。对象产生之初在新生代,步入暮年时进入老年代,但是老年代也接纳在新生代无法容纳的超大对象。新生代= 1个Eden区+ 2个Survivor区。绝大部分对象在Eden区生成,当Eden区装填满的时候,会触发YoungGarbage Collection, 即YGC(也叫MinorGc)。垃圾回收的时候,在Eden区实现清除策略,没有被引用的对象则直接回收。依然存活的对象会被移送到Survivor区,这个区真是名副其实的存在。Survivor 区分为S0和S1两块内存空间,送到哪块空间呢?每次YGC的时候,它们将存活的对象复制到未使用的那块空间,然后将当前正在使用的空间完全清除,交换两块空间的使用状态。如果YGC要移送的对象大于Survivor区容量的上限,则直接移交给老年代。假如一些没有进取心的对象以为可以一直在新生代的Survivor区交换来交换去,那就错了。每个对象都有一个计数器,每次YGC都会加1。 -XX:MaxTenuringThreshold参数能配置计数器的值到达某个阈值的时候,对象从新生代晋升至老年代。如果该参数配置为1,那么从新生代的Eden区直接移至老年代。默认值是15,可以在Survivor区交换14次之后,晋升至老年代。对象分配及晋升流程图如下图所示。
早在JDK8版本中,元空间的前身Perm区(永久代)已经被淘汰。在JDK7及之前的版本中,只有Hotspot才有Perm区,译为永久代,它在启动时固定大小,很难进行调优,并且FGC时会移动类元信息。在某些场景下,如果动态加载类过多,容易产生Perm区的0OM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多的类,经常出现致命错误:" java.lang.OutOfMemoryError: PermGenspace"为了解决该问题,需要设定运行参数-XX:MaxPermSize= 1280m,如果部署到新机器上,往往会因为JVM参数没有修改导致故障再现。不熟悉此应用的人排查问题时往往苦不堪言,除此之外,永久代在垃圾回收过程中还存在诸多问题。所以,JDK8使用元空间替换永久代。在JDK8及以上版本中,设定MaxPermSize参数,JVM在启动时并不会报错,但是会提示: Java HotSpot 64Bit Server VM warning:ignoring option MaxPermSize- =2560m; support was removed in 8.0。区别于永久代,元空间在本地内存中分配。在JDK8里,Perm 区中的所有内容中字符串常量移至堆内存,其他内容包括类元信息、字段、静态属性、方法、常量等都移动至元空间内,比如下图中的Object类元信息、静态属性System.out、整型常量1000000等。图中显示在常量池中的String,其实际对象是被保存在堆内存中的。
注:以上大部分内容(除代码示例)摘抄整理自《深入理解java虚拟机》、《Java虚拟机规范 JavaSE 8版本》、《码出高效:Java开发手册》