开篇
我们知道JVM的垃圾回收机制实际上是对JVM内存的操作,回收的目的是为了避免内存溢出和内存泄漏的问题。而JVM内存由方法区、堆、虚拟机栈、本地方法栈以及程序计数器5块区域组成,虚拟机栈、本地方法栈、程序计数器是随着Java线程建立而建立,当Java 线程完成之后这三个部分的内存就会被释放掉。
而方法区和堆属于共有线程,是随着JVM启动而建立的,而且这两个区域与另外三个区域也有所不同,一个接口中有多少个实现类(方法区)以及每次程序运行需要创建多少对象(堆)是动态的,也就是说在程序运行时才能知道。
为了让这部分动态的内存分配能够进行合理的回收,就需要垃圾回收算法和垃圾回收器来帮忙了。下面让我们进入今天的主题。
如何判断对象“存活”?
JVM 垃圾回收机制是对堆中没有使用的对象进行回收,那么判断对象是否“存活”就至关重要。在判断对象是否“存活”的方法中,我们会介绍引用计数算法和可达性分析法。
引用计数算法
Java 堆中针对每个对象都设置一个引用计数器。当一个对象被创建并初始化赋值后,该变量计数设置为1。每当有一个地方引用它时,计数器值就加1。当引用失效时,即一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时,计数器值就减1。任何引用计数为0的对象可以被当作垃圾回收。当一个对象被垃圾回收时,它引用的任何对象计数减1。
这种方法的优点很明显,引用计数回收器执行简单,判定效率高,对程序不被长时间打断的实时环境比较有利。不过缺点也很明显,对于对象循环引用的场景难以判断,同时引用计数器增加了程序执行的开销。Java语言并没有选择这种算法进行垃圾回收。
可达性分析法
可达性分析法也叫根搜索算法,通过称为 GC Roots 的对象作为起点,从上往下进行搜索。搜索所走过的路径称为引用链 (Reference Chain), 当发现某个对象与 GC Roots之间没有任何引用链相连时, 即认为该对象不可达,该对象也就成了垃圾回收的目标。
如图1 所示,从GC Roots 开始没有引用链和Obejct5、Object6 和Object7 相连,因此这三个对象对于GC Roots 而言就是不可达的,会被垃圾回收,即便他们互相都有引用。
图1 可达性分析法
在Java中,可作为GC Roots的对象包括如下四种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈 中 JNI (Native方法)引用的变量
- 方法区 中类静态属性引用的变量
- 方法区 中常量引用的变量
前面谈到的可达实际上是在判断对象是否被引用,如果没有被引用,垃圾回收器会将其进行回收。不过我们希望存在这样一些对象,当内存空间足够的情况下尽量将其保留在内存中,当内存不够的情况下,再回收这些对象。下面看看如何对如下对象进行处理:
- 强引用(Strong Reference):例如,Object obj = new Object()这类引用,只要强引用存在,垃圾回收器永远不会回收掉被引用的对象。
- 软引用(Soft Reference):在系统将要出现内存溢出之前,会将软引用对象列进回收范围之中进行二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。
- 弱引用(Weak Reference):被弱引用关联的对象只能生存到下一次垃圾回收发生之前,无论当前内存是否足够,用软引用相关联的对象都会被回收掉。
- 虚引用(Phantom Reference):虚引用也称为幽灵引用或幻影引用,是最弱的一种引用关系,为一个对象设置虚引用的唯一目的是:能在这个对象在垃圾回收器回收的时候收到一个系统通知。
垃圾回收算法
上面讲解了如何发现“存活”对象,JVM中会使用可达性分析法,说白了就是看GC Roots在引用链上是否有对应的对象被引用到了。接下来就在这个背景下看看有哪些垃圾回收的算法,这里我们列举出常见的几种:
标记清除算法
该算法分为标记和清除两个阶段,首先通过可达性分析法找到要回收的对象,也就是没有被引用的对象,对其进行标记,然后再对该对象进行清除也就是回收了。
如图2 所示,该算法会对内存空间进行扫描,发现GC Roots 对Object1 和Object2 进行引用,但是对Object2 没有引用。首先标记Object2 没有被引用。
图2
如图3 所示,算法再次对内存进行扫描,清除Object2 对象占用的空间,将其设置为空闲空间。
图3 标记清除算法
该算法的优点就是简单粗暴,没有引用的对象会被清除掉,但是缺点是效率问题。标记和清除操作会扫描整个空间两次(第一次:标记存活对象;第二次:清除没有标记的对象)才能完成清理工作。同时清理过程容易产生内存碎片,这些空闲的空间无法容纳大对象,如果此时有一个比较大的对象进入内存,由于该内存中没有连续的容纳大对象的空间,就会提前触发垃圾回收。
复制算法
为了解决标记清除法带来的问题,复制算法将内存划分为大小相等的两块,每次使用其中的一块,当这块的内存使用完毕以后,再将对象复制到另外一块上面,然后对已经使用过的内存空间进行清理。这样每次对内存的一半区域进行回收,不用考虑内存碎片的问题。
如图4 所示,上面的区域是垃圾回收之前的内存空间,我们用黑色的虚线将内存分为两个部分。左边的部分是正在使用的空间,右边是预留空间。左边区域中红色的部分是不可回收的内存,也就是说这里面有被GC Roots 引用的对象,另外灰色的部分是可回收的区域,也就是没有被GC Roots 引用的对象,白色区域是未分配的。
如果通过复制算法进行垃圾回收,顺着绿色的箭头向下,在回收后的内存区域可以看到,将左侧红色的内存对象移动到了右侧预留的区域,并且按照顺序排放。然后对左侧运行的内存区域进行清理,成为预留区域等待第二次垃圾回收的执行。
图4 复制算法
复制算法的优点是简单高效,不会出现内存碎片。缺点也明显,内存利用率低,只有一半的内存被利用。特别是存活对象较多时效率明显降低,因为需要移动每个不可回收数据的内存实际位置。
标记整理算法
该算法和标记清除算法相似,但是后续步骤并不是直接对可回收对象进行清理,而是让所有存活对象都移动到内存的前端,然后再清除掉其他可回收的对象所占用的内存空间。
如图5 所示,回收前的内存中红色为不可回收的内存空间,灰色是可回收空间,白色是未分配空间。执行标记整理算法的垃圾回收之后,将不可回收的内存空间整理到内存的前端,同时清除掉可回收的内存空间,此时不可回收空间之后存放的都是白色的未分配空间,供由新对象存放。
图5 标记整理算法
标记整理算法优点是解决了标记清理算法存在的内存碎片问题。缺点也是非常明显,需要进行局部对象移动,一定程度上降低了效率。
分代收集算法
分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块,然后定义回收规则。如图6所示,从左到右分别是年轻代(Young Generation)、老年代(Old Generation) 和 永久代(Permanent Generation),另外年轻代又分为了Eden Space(伊甸空间) 、Survivor Space(幸存者空间)。分代收集的算法在当前商业虚拟机算法中被广泛采用。
图6 分代收集法
上面对分代收集法做了字面的解释,现将该算法的执行过程描述如下:
1)新产生的对象优先分配在Eden区(除非配置了-XX:PretenureSizeThreshold,大于该值的对象会直接进入老年代)。有这样一种情况,当对象刚刚在新生代创建就被回收了,对象从这个区域消失的过程我们称之为 minor GC。
2)当Eden区满了或放不下了,这时候其中存活的对象会复制到from区。如果此时存活下来的对象在from 区都放不下,就会放到老年代,之后Eden 区的内存会全部回收掉。
3)之后产生的对象继续分配在Eden区,当Eden区又满了或放不下了,这时候将会把Eden区和from区存活下来的对象复制到to区,此时如果存活下来的对象to区也放不下,会将其移动到年老代,同时会回收掉Eden区和from区的内存。
4)如果按照如上操作将对象在几个区域中移动,会出现对象被多次复制的情况,对象被复制一次,对象的年龄就会+1。默认情况下,当对象被复制了15次(通过:-XX:MaxTenuringThreshold来配置),该对象就会进入老年代了。
5)当老年代满了的情况下,就会发生一次Full GC。
备注:Minor GC指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。Full GC指发生在老年代的GC,出现了Full GC,经常会伴随至少一次的Full GC,Full GC的速度一般会比Minor GC慢10倍以上。
垃圾回收器
如果垃圾回收算法是内存回收的方法论的话,那么垃圾回收器就是内存回收的具体实现了。下面会针对JDK1.7 Update 14 之后的HotSpot虚拟机给大家做介绍。
如图7所示,这里将内存分为新生代和老年代,将7种不同垃圾回收器分布于其间,垃圾回收器之间存在连线,说明它们可以搭配使用。
虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。Hotspot实现了如此多的收集器,正是因为目前并无完美的收集器出现,只是选择对具体应用最适合的收集器。
图7垃圾回收器的分类
下面就来介绍这几个垃圾回收器:
Serial回收器
Serial(串行)回收器采用复制算法的新生代收集器,它是一个单线程回收器,针对一个CPU或一条收集线程去完成垃圾收集工作,它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止,这个做法也称为 “Stop The World”。
如图8 所示,左边多个应用程序线程在执行, 当Serial 回收器的GC线程(虚线部分)执行的时候,应用程序线程(左边多个实线)都会暂停,只有在回收器线程执行完毕以后,应用程序线程(右边多个实线)才会继续执行。
图 8 串行垃圾回收器
该回收器的问题就是在进行垃圾回收时其他工作线程必须停顿,不过它在HotSpot虚拟机运行的Client模式下可以为新生代回收器服务。它的简单而高效对于限定单个CPU的环境来说,Serial回收器没有线程交互的开销。在用户的桌面应用场景中,分配给虚拟机管理的内存不大,停顿时间可以控制在几十毫秒以内,还是可以接收。它对于运行在Client模式下的虚拟机来说是一个很好的选择。
ParNew 回收器
ParNew回收器是Serial回收器的多线程版本,它也是一个新生代回收器。除了使用多线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等。
如图9 所示,与Serial 不同的是ParNew 使用多个线程(中间多条虚线)的方式进行垃圾回收。
图9 ParNew 并行回收器
ParNew 回收器在多CPU环境下垃圾回收的效率会有明显提高。它默认开启的收集线程数与CPU的数量相同,在CPU非常多的情况下可使用-XX:ParallerGCThreads参数设置。反过来,如果针对单个CPU的环境 ParNew 和Serial 回收器的效果就难分伯仲了。
Serial Old回收器
Serial Old 是 Serial回收器的老年代版本,是单线程收集器,使用标记整理(Mark-Compact)算法。它可以给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:在JDK1.5 以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。
Parallel Old回收器
Parallel Old回收器是Parallel Scavenge的老年代版本,使用多线程的标记整理算法。在JDK 1.6中才开始提供,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old以外别无选择。Parallel Old回收器的工作流程与Parallel Scavenge相同。
Parallel Scavenge 回收器
Parallel Scavenge回收器是并行的多线程新生代回收器,它使用复制算法。Parallel Scavenge回收器的目标是达到一个可控制的吞吐量(Throughput)。
这里稍微说明一下, 吞吐量就是CPU运行用户代码时间与CPU总消耗时间的比值,表现成工时是:吞吐量 = 用户代码运行时间 /(用户代码运行时间 + 垃圾回收时间)。用户代码运行时间95 分钟,垃圾回收时间为5分钟,那吞吐量就是95/(95+5)=95%。
高吞吐量说明垃圾回收器高效率地利用CPU时间,尽快完成程序的运算任务。从而让垃圾回收造成的停顿时间变短,适合与用户交互的程序提升用户体验。
Parallel Scavenge会提供精确控制吞吐量的参数,此外还通过对参数-XX:+UseAdaptiveSizePolicy 设置来自动调节新生代的大小(-Xmn)、Eden和Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等信息。
此外Parallel Scavenge 回收器还有一个特点就是,会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,我们称之为GC自适应的调节策略(GC Ergonomics)。
CMS收集器
CMS(Concurrent Mark Sweep)收集器是以获取最短回收停顿时间为目标的回收器,它适用于重视响应速度的应用场景,它是基于标记清除算法而实现的。
如图10 所示,从左往右CMS工作流程分为以下4个步骤:
- 初始标记(CMS initial mark):标记GC Roots直接关联到的对象,需要执行“Stop The World”,也就是让工作线程暂停。
- 并发标记(CMS concurrent mark):从GC Roots 查找所有可达的对象,这个过程耗时比较长,此时用户线程依旧在运行。
- 重新标记(CMS remark):修正并发标记期间,用户程序继续运作而导致标记的对象,并且调整标记记录,此阶段也需要“Stop The World”,因为不暂停工作线程的话还可能有标记不准确的情况发生。
- 并发清除(CMS concurrent sweep):对于标记不可用的对象进行并发清除操作,这个过程耗时会比较长,此时工作线程依旧可以运行。
所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的时间:
图10 CMS 垃圾回收器
CMS的优点明显,并发收集、低停顿。不过他对CPU资源非常敏感,在并发阶段虽然不会导致用户线程暂停,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。
CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时(比如2个),CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%。
无法处理浮动垃圾(Floating Garbage) 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。在垃圾回收阶段,用户线程还在运行,还需要预留有足够的内存空间给用户线程使用,因此CMS需要预留一部分空间提供并发收集时的程序运作使用。标记清除算法本身也会导致产生大量的空间碎片。
G1回收器
G1(Garbage-First)回收器是面向服务端应用的垃圾回收器,它具备如下特点:
- 充分利用多CPU缩短“Stop The World”停顿时间,可以通过并发的方式让Java程序继续执行。
- 不需要其他回收器配合就能独立管理整个GC堆,采用不同方式去处理新创建的对象和已存活一段时间、对于经历过多次GC的旧对象来说会有更好的回收效果。
- G1基本上是基于标记整理算法实现的,在局部(两个Region之间)是基于复制算法实现的。这意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
- 可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒。
与其他垃圾回收器不同的是,G1回收的范围横跨整个堆内存。
如图11所示,G1将堆划分为多个大小相等的区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,而是Region的集合。
图11 G1 将堆进行分Region
前面提到G1回收器可以预测的停顿时间,是因为它避免在整个Java堆中进行全区域的垃圾回收。G1会跟踪各个Region的垃圾堆积的价值大小(回收的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的回收时间,优先回收价值最大的Region。
虽然G1把Java堆分为多个Region,在某个Region中的对象可以与位于其他Region中的任意对象发生引用关系。在做可达性分析时仍然需要扫描整个堆,显然这样做效率是不高的。为了避免全堆扫描, G1为每个Region维护了一个记忆集(Remembered Set)。当发现程序在对引用(Reference)类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作。然后检查引用(Reference)对象是否处于不同的Region之中,如果是便通过CardTable把相关引用信息记录到被引用对象所属的Region的记忆集(Remembered Set)之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。说白了就是通过Remembered Set 记录跨Region引用的对象,其目的是避免全堆扫描。
如图12所示,Region2 中的两个对象分配被Region1 和Region3 中的对象引用,因此在Region2中的记忆集(Remembered set)就会记录这两个引用的信息,在垃圾回收的时候只需要收集记忆集的信息就知道对象在每个Region 的引用关系了,并不需要扫描所有堆的Region。
图12 跨Region的对象引用
说了G1 的特点和机制,下面通过图13 来看看它的执行过程:
- 初始标记(Initial Marking):标记GC Roots 能直接引用的对象,让下一阶段用户程序并发运行时,能在正确的Region中创建对象,此阶段需要停顿线程,但耗时很短。
- 并发标记(Concurrent Marking) :从GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行。
- 最终标记(Final Marking):为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
- 筛选回收(Live Data Counting and Evacuation) :首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
总结
今天给大家介绍了垃圾回收的算法和JVM的垃圾回收器,算法作为思路和方法论的指导,而垃圾回收器是方法论的最佳实践,这里通过一张表格将两者进行一个总结:
作者介绍
崔皓,51CTO社区编辑,资深架构师,拥有18年的软件开发和架构经验,10年分布式架构经验。曾任惠普技术专家。乐于分享,撰写了很多热门技术文章,阅读量超过60万。《分布式架构原理与实践》作者。