最近找了两本Java虚拟机方面的书,看了看其中对于Java自动内存管理的章节,写的都大同小异,在此总结一下,主要是三个方面:内存划分、内存分配、内存回收。
内存划分(运行时数据区)
JVM运行时数据区
从线程的角度来分,可分为线程私有和线程共享的,上图中左边的灰色区域就是线程共享的区域,包括堆、方法区、运行时常量池。而右边的区域则是线程私有的,包括程序计数器、虚拟机栈。
堆
堆是虚拟机管理的内存中最大的一块,是被线程共享的一块区域,主要用于存放对象实例,但并不是所有对象都是在堆上分配的。同时堆也是垃圾收集器管理的主要区域。
方法区
方法区与堆一样,也是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区虽然逻辑上与堆独立,但物理上属于堆。
运行时常量池
运行时常量池属于方法区的一部分,用于存放class文件中的常量池信息,主要是各种字面值和符号引用。另外,运行时常量池并不要求常量一定只能在编译期产生,运行期间也可能将新的常量放入池中,例如String类的intern()方法。
程序计数器
类似于操作系统中的程序计数器,不过这里的程序计数器指示的是正在执行的字节码指令的地址。字节码解释器的执行完一条指令后,会改变程序计数器的值,指向下一条需要执行的指令地址。之所以需要每个线程都使用一个独立的程序计数器,是因为能够让多线程程序正确执行,各条线程之间的计数器互不影响。
虚拟机栈
虚拟机栈描述的是Java方法执行的内存模型,其基本单位是栈帧,每个方法执行的时候都会创建一个栈帧。虚拟机一直在执行栈顶的栈帧所对应的方法,当一个方法中调用另一个方法时,就会新建一个被调用方法的栈帧,push进虚拟机栈,被调用方法执行结束,会将返回值写入调用他的栈帧,并将自己的栈帧从栈中弹出。
而方法的栈帧中,存放了局部变量表、操作数栈、动态链接、方法出口等信息。局部变量表所需的内存空间都是在编译器就能够确定的,用于存放方法内部的本地变量;操作数栈则是用来进行运算操作,将两个操作数从栈顶弹出,计算结果,压入栈。
还有一个没有提及的是本地方法栈,与虚拟机栈相似,不过是为本地方法服务的,虚拟机规范中对其没有强制规定,可由虚拟机具体实现。
内存分配
java堆分代
上面说到,堆是内存管理的主要区域,堆中存放了各种各样的对象,进一步可以划分为新生代和老年代。其中,新生代里有Eden空间、From Survivor空间、To Survivor空间。这样划分主要是为了方便内存回收。具体各个空间的用途,到内存回收就会知道。
从Java代码中new一个对象说起,JVM首先会检查这个new指令的参数能够在常量池中定位到一个类的符号引用,然后检查与这个符号引用相对应的类是否已经成功经历过加载、解析、初始化等步骤,当类完成装载之后,就可以完全确定创建该类实例所需要的空间大小。然后JVM就会为该实例进行内存分配。
下面就是分配在哪的问题。一般会分配在堆中的Eden空间,如果启动了本地线程分配缓冲,会优先在TLAB(Tread Local Allocation Buffer,即本地线程分配缓冲区)中分配,TLAB是Eden空间中线程私有的部分,大约占据Eden总空间的1%。 如果分配到Eden空间失败,就会进行一次新生代的垃圾收集工作。对于需要大量连续内存的大对象,会直接分配到老年代。
另外涉及到的一个概念是逃逸分析。上文也提到,并不是所有的对象都在堆中分配,其中有一部分对象是在栈上分配的,这里说的栈就是指虚拟机栈帧中的局部变量表部分。逃逸分析是JVM执行性能优化之前的一种分析技术,具体目标是分析出对象的作用域。如果一个对象的作用于仅限于方法体内部,就会在栈上为其分配内存,栈帧随着方法退出而销毁,不需要参与到垃圾收集中去。但一旦方法内部的对象被外部对象引用,这个对象就因此发生了逃逸,就不会在栈上分配。
内存回收
内存回收涉及到几个方面:哪些内存需要回收?什么时候回收?如何回收?
可回收对象判定
常用的有引用计数算法和根搜索算法。
引用计数就是为每一个对象添加一个引用计数器,每当有一个地方引用它时,就将计数器的值加1,当引用失效时,计数器的值减1。任何时刻计数器值为0,说明对象不再被使用。此方法的缺陷在于,很难解决对象之间相互循环引用,如果两个需要回收的对象分别引用彼此,就无法被垃圾收集器回收。
根搜索算法通过一系列名为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,就证明此对象是不可用的。GC Roots对象包括栈帧中本地变量表中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中引用的对象。
垃圾收集算法
主要有标记-清除算法、复制算法、标记-压缩算法。
标记-清楚算法:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。标记和清除过程的效率都不高,而且标记清除之后会产生大量不连续的内存碎片,碎片太多会导致需要分配大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集操作。空闲的内存碎片可以用空闲列表来表示,从而提供下一次分配对象的内存地址。
复制算法:将内存划分为大小相等的两块,每次只使用其中的一块,当一块内存用完了,就将还活着的对象复制到另外一块上面,然后把已使用过的内存空间一次清理掉。运行高效,代价是损失了一般的内存空间。在堆中的新生代垃圾收集算法中,就使用了复制算法。将Eden空间、From Survivor空间中存活的对象复制到To Survivor空间,然后将From Survivor空间和To Survivor空间互换。(如果Eden空间和From Survivor空间的存活对象的分代年龄大于一定阈值或者To Survivor空间已满,会直接被分配到老年代)Eden空间和两个Survivor空间的缺省比例是8:1:1。之所以可以在新生代使用复制算法,是因为大多数新生代对象的生命周期都非常短暂。
标记-整理算法:与标记-清除算法差不多,不过此算法将所有存活对象向内存的一端移动,然后直接清理掉另一端的内存。此算法应用于老年代的垃圾收集。由于能够整理出一大块连续的空闲内存区域,所以用一个指针指向空闲内存区域的起点,用于指向下一次内存分配的位置。
垃圾收集器
垃圾收集器有很多,而且虚拟机里整合了很多种垃圾收集器,本文不再赘述,值得一提的是Stop-the-World机制,通俗来说,垃圾收集进行的时候,工作线程必须停止一段时间,无论以哪种收集器进行垃圾收集,都会有或多或少的Stop-the-World时间。
另外一个是程序吞吐量与低延迟的权衡。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值。可通过-XX:MaxGcPauseMillis设置垃圾收集造成的Stop-the-World的时间,但为了低延迟而将该值调小之后,会导致相应的新生代内存空间变小,内存空间越小越容易被耗尽,会导致GC更加频繁,总的用于GC的时间可能反而会变多,导致程序吞吐量下降。