JVM之垃圾收集
众所周知,Java语言的一个重要特性就是自动内存管理与垃圾回收机制。垃圾收集也被称作GC(Garbage Collection),在讲到GC的时候,我根据《深入理解Java虚拟机》中提到的内容,打算从三个方面讲述:
- 哪些内存需要回收?
- 什么时候进行回收?
- 怎样回收?
首先从理论上讲述这三个问题,然后再以HotSpot为例讲述这三点的具体实现。
一、哪些内存需要回收
1.1、哪些内存区域需要考虑GC
前面对于JVM的内存划分中讲到,JVM将其管理的内存分为程序计数器、虚拟机栈、本地方法栈、堆以及方法区。
- 程序计数器、虚拟机栈和本地方法栈随着线程而生,随线程而灭,方法的执行导致栈帧的入栈与弹栈,因此我们可以认为这三部分的内存分配和回收是确定的,不需要考虑GC问题
- 方法区中存放JVM加载时候的数据,在程序运行时依然向方法区中存放加载的类信息,这部分数据是不可预知的,它的分配和回收都是动态的,需要考虑GC问题
- 堆中存放这对象的实例,对象的产生及其内存的分配也是不可预知的,它的分配和回收也是动态的,需要考虑GC问题
因此我们说,需要考虑GC问题的内存区域有方法区和堆内存。
1.2、堆内存中哪些对象需要被回收
在堆中有很多个对象,哪些对象是需要被回收的呢?用大白话说,哪些对象是死亡的呢?这个就涉及到对象的存活判定问题,书中提到的判定方法有两种:
- 引用计数算法:对象中添加一个引用计数器,每当有引用指向它时计数器加1,引用失效则计数器减1,计数器值为0时认为对象已死。这个方法的缺点就是可能存在两个对象相互循环引用,导致它们永远不会被用到也永远不会被回收,HotSpot中并没有采用引用技术算法。
- 可达性分析算法:在JVM中定义了一系列"GC Roots"对象,从这些对象节点开始向下搜索的对象引用链,若GC Roots到某个对象不可达,则认为对象已死,可以回收。
- GC Roots对象可以是如下几种:
- 虚拟机中引用的对象
- 本地方法栈中JNI引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- GC Roots对象可以是如下几种:
上述的两种判定方法可以用来判定对象的存活与否,基本的判定方法根据对象引用判定对象存活或者死亡,只有两种状态。在Java2之后,引用的概念从两种扩充到四种:
- 强引用:代码中通过赋值符号创建的对象引用,GC永远不会回收被引用的对象。
- 软引用:用来描述一些还有用但是并非必须的对象。这部分对象在系统将发生内存溢出之前会被放入回收范围中进行第二次回收。
- 弱引用:描述非必须对象。弱引用关联的对象只能生存到下一次GC之前,无论内存是否足够,GC都会回收这部分对象。
- 虚引用:最弱的引用关系,虚引用关联的目的是对象被GC回收时收到一个系统通知。
另外,对象在被GC回收的时候,JVM会检查是否重写finalize方法,如果重载了则会执行一次,所以对象可以在这个方法中为自己恢复引用从而逃脱。但是每个对象的finalize方法只会被执行一次,下一次GC就不会执行了。
总结:
-
堆内存中已经死亡的对象是可回收的,对象存活的判断方法有引用计数法和可达性分析法;
-
Java2之后对引用的类型进行了扩展,引用强度:强引用>软引用>弱引用>虚引用;
- 如果对象重写了finalize方法,则GC时会先执行finalize,但是该方法在对象的生命周期内只会被执行一次。
1.3、方法区中哪些数据需要被回收
方法区中被回收的内容主要是:废弃的常量和无用的类:
- 废弃常量:常量的存活判定与对象类似,没有任何对象引用该常量时则判定为废弃
- 无用的类:无用的类需要被卸载。判断方法区中类的无用需要满足以下条件
- 该类的所有实例对象都被回收
- 加载该类的ClassLoader被回收
- 该类对应的java.lang.Class对象没有任何地方被引用
二、什么时候进行回收
GC的时机很重要,从理论上来讲GC执行的时机是不确定的,就算我们显式的调用System.gc()或者Runtime.getRuntime().gc(),也只是通知JVM希望进行一次垃圾回收,但是具体的JVM执行垃圾回收行为的时机是不可预测的,自动的。这里的不可预测是指我们并不知道什么时候会触发,但是我们可以了解它的条件,我将GC的条件分为了触发条件和执行条件。
2.1、触发条件
就是指当JVM达到某种状态的时候会触发一次GC,但是具体的执行还要满足执行条件。在具体的虚拟机实现中各不相同,例如在HotSpot中触发条件主要有
- 新生代Eden空间装满,触发minor GC
- minor GC时不允许冒险,且老年代可用连续空间小于新生代所有对象总空间,改为触发Full GC;minor GC时担保失败,重新触发Full GC(JDK1.6_u24之前)
- 老年代连续空间大于新生代对象总大小或者历次晋升的平均大小,触发minor GC,否则触发Full GC(JDK1.6_u24之后)
2.2、执行条件
并不是GC一触发就立刻执行的,而是程序运行到安全点或者线程执行到安全区域的时候才能够执行GC。GC在执行的时候需要标记处哪些对象是要回收的,这个过程通过可达性分析算法来完成,此时所有线程都必须停下来,否则会因为引用的动态改变影响可达性分析结果。这里线程停下来的点我们称作安全点,另外线程也可能在GC的时候发生阻塞从而无法到达安全点,这个时候就需要线程进入一个安全区域。
- 安全点:对象引用不会发生改变的点(方法调用、循环跳转、异常跳转等指令),所有线程执行到安全点的方案有两种:
- 抢先式中断:先停止所有线程,再检查是否到安全点,如果没有到就放它执行一段。
- 主动式中断:需要GC的时候先设定安全点标志,所有线程检查自己是否到标志处,到了就自己停下来。
- 安全区域:一段代码片段,线程执行到安全区域时标识自己已经进入安全区域,GC不用管该线程。如果线程要离开安全区域,先检查GC是否结束,没有结束则等待。
总结
垃圾回收的执行行为是不可预测的,它可以显式的由用户触发,也可以隐式的触发。触发GC之后还需程序满足执行条件才能开始GC。具体的触发条件和执行条件因不同的虚拟机实现而不同。
三、怎样回收
知道了什么内存需要回收,什么时候回收,GC是如何回收内存的呢?这里提到的就是垃圾收集算法,不同的虚拟机对垃圾收集算法的实现不尽相同。比较经典的三种算法的思想有“标记-清除算法”,“复制算法”,“标记-整理算法”。
本节介绍算法的图片来自于博客https://www.jianshu.com/p/5d612f36eb0b
3.1、标记-清除
- 标记阶段:标记处所有需要回收的对象
- 清除阶段:将标记过的对象内存进行回收
缺点:
- 效率低下:标记阶段和清除阶段的效率都不高
- 空间问题:产生大量内存碎片
3.2、复制
复制算法的思想是将内存划分为两块,每次使用其中的一块,GC时将使用的那一块中存活的对象复制到另一块中,然后将之前使用的内存全部抹去。HotSpot中新生代里就将内存划分为Eden空间和两块小的Survivor空间,每次只使用Eden和from Survivor,GC时将对象全部复制到to Survivor中。
优点是分配内存实现简单高效,缺点是内存使用率不高。
3.3、标记-整理
标记整理算法类似与标记清除:
- 标记阶段:将存活的对象进行标记
- 整理阶段:将存活的对象向一端移动,然后清理存活边界以外的内存
这样的好处是既使用了整块内存,又不会产生内存碎片。但是由于需要进行整理操作,适用于对象存活度高的老年代空间。
四、HotSpot中对于内存回收的实现
前面介绍了具体的内存回收过程都是基于理论上的设计。在HotSpot中针对上述理论有具体的实现。
4.1、对象存活判定
在HotSpot中采用可达性分析来判定对象的存活状况,可达性分析发生在GC的过程中,前述的四种引用划分以及finalize方法在HotSpot中都有实现。
HotSpot中对于可达性分析的实现是枚举根节点,枚举根节点的时候需要停顿所有Java执行线程,然后使用OopMap的数据结构来实现准确式内存,完成GC Roots的枚举。
4.2、垃圾收集器
HotSpot中通过分代收集算法来进行垃圾收集,将内存划分为新生代和老年代,根据不同的年代特点选择合适的收集算法。在新生代中对象死亡较多,选择复制算法;在老年代中对象存活较多,选择“标记-清除”或者“标记-整理”算法。对于不同年代下的垃圾收集,HotSpot具体实现了不同的垃圾收集器。
在新生代中实现了Serial、ParNew、Parallel Scavenge收集器,老年代中对应实现了Serial Old、Parallel Old收集器以及CMS收集器,另外还实现了同时处理新生代和老年代的G1收集器。
4.2.1、Serial
Serial收集器是用于新生代的单线程垃圾收集器。每次需要垃圾收集时,用户所有线程会全部暂停,然后一个单线程GC开始采取复制算法进行垃圾收集。
4.2.2、ParNew
ParNew收集器是用于新生代的多线程垃圾收集器,除了多条线程收集垃圾,其他的部分与Serial一样。同样的,ParNew收集器也是采用复制算法进行垃圾收集。
4.2.3、Parallel Scavenge
Parllel Scavenge是用于新生代的并行的多线程垃圾收集器,同样采取复制算法进行垃圾收集。它的特点是实现一个可以控制的吞吐量。吞吐量=用户线程时间/CPU消耗总时间;
垃圾收集器关注两个指标,一个是停顿时间,停顿时间越短用户体验越好,适用于用户交互程序;另一个指标是吞吐量,吞吐量越高,用户程序的运算效率越高,适用于后台运算等少交互的程序。Parallel Scavenge收集器通过参数-XX:MaxGCPauseMills来设置GC停顿最大时间,通过参数-XX:GCTimeRatio来控制吞吐量大小。
4.2.4、Serial Old
Serial Old是用于老年代的单线程垃圾收集器。与Serial一样,采用标记-整理算法进行垃圾收集,每次垃圾收集时都会暂停用户所有线程。
4.2.5、Parallel Old
Parallel Old收集器是Parallel Scavenge的老年代版本,也是多线程的垃圾收集器,采用标记-整理算法进行垃圾收集。在新生代和老年代可以配合使用Parallel垃圾收集器,实现吞吐量的优先考虑。
4.2.6、CMS
CMS收集器全称(Concurrent Mark Sweep),它用于老年代,是一种追求最短回收停顿时间为目标的收集器,采用标记-清除算法进行垃圾收集。
垃圾收集过程中停顿用户线程的根源是GC根枚举过程,也就是标记过程。为了缩短回收垃圾时的停顿时间,CMS收集器将标记过程拆分成3部分:初始标记、并发标记以及重新标记。
- 初始标记:仅标记GC根能够直接关联到的对象,需要短暂停顿用户线程。
- 并发标记:该过程就是根据GC根进行枚举的过程,该阶段与用户线程并发执行,不用停顿用户线程,且为主要的标记阶段。
- 重新标记:修正并发标记期间因用户程序运行导致的标记错误,也需要停顿用户线程,但是过程时间远小于并发标记。
由此可见,通过将主要的溯源标记过程设计为与用户线程并发进行,从而缩短用户线程停顿的时间。完成标记之后进入并发清理阶段,清理阶段也是与用户线程并行的。
不过这样设计也有一些缺点:
- 对CPU资源敏感。由于设计的并发标记过程与用户线程并发执行,会因为占用一部分CPU资源导致用户程序变慢。CMS收集器默认使用线程数(CPU数量+3)/4。
- 无法处理浮动垃圾。在并发清理阶段用户程序产生的垃圾只能等下一次GC才可以清理。另外,并发清理过程中需要留一部分内存供用户程序使用,因此在老年代使用超过一定比例时就会激活GC,如果CMS运行期间预留内存不够,则会出现Concurrent Mode Failure,从而启动Serial Old收集器进行垃圾收集。
- 标记-清除算法会导致大量的内存碎片。无法得到连续大内存存放对象时,会提前触发一次Full GC。
针对缺点可以设置调优的参数:
- 针对预留空间可以通过设置-XX:CMSInitiatingOccupancyFraction参数来提高触发百分比,该百分比表示使用老年代空间的比例到达阈值后触发GC,通过设置该百分比可以降低内存回收次数,但是也有可能因为预留内存不足导致上述的Failure。
- 针对内存碎片问题,可以设置-XX:+UseCMSCompactAtFullCollection参数,用于设置在CMS收集器顶不住时进行Full GC时开启内存碎片的合并整理。
- 由于开启了Full GC时的内存碎片合并整理,停顿时间会变长。可以通过-XX:CMSFullGCsBeforeCompaction参数设置执行一定次数的不整理内存碎片Full GC之后,再执行一次整理内存碎片的Full GC。
CMS收集器是一个专注于优化停顿时间的老年代垃圾收集器,虽然在设计过程中会存在一些缺点,但是针对这些缺点JVM的设计者也在不停的进行补救,用户可以合理使用参数设置,根据实际应用情况来设置最优的CMS收集器。
4.2.7、G1
G1收集器全称是Garbage-First,在HotSpot中称作将来会替换掉CMS收集器。G1收集器对堆内存的布局是采用区域的概念,将堆划分为多个大小相等的独立区域,从而可以实现收集器收集范围为整个堆内存。与CMS的目标类似,它也是以降低用户线程停顿时间为目标,运作分为以下几步:
- 初始标记:与CMS一样,仅标记与GC Roots直接关联的对象,需要短暂停顿用户线程。
- 并发标记:与CMS一样,与用户线程同时运行,进行GC Roots枚举,分析可达性,找出存活对象。
- 最终标记:目的与CMS一样,修正并发标记中因用户程序运行导致的错误,最终标记可以并行运行,需要停顿用户线程。
- 筛选回收:对各个Region的回收价值和成本进行排序,根据用户设定的GC停顿时间来制定回收计划,这个阶段可以与用户线程并发执行,但是往往是停顿用户线程并进行并行回收。
G1收集器是比较现金的收集器技术,它的主要特点是:
- 并行与并发:既可以通过并行,充分运行CPU资源缩短用户线程停顿时间,也可以通过并发使得用户线程可以同时执行。
- 分代收集:分代的概念依然保留,但是并不是以内存隔离的方式进行新生代和老年代的区分,因此G1收集器既保留分代概念,也不需要与其他收集器配合。
- 空间整合:CMS进行垃圾收集采用标记-清理,造成内存碎片,而G1收集器采用局部复制算法进行垃圾收集,不会产生内存碎片。
- 可预测的停顿:G1收集器可以设置垃圾收集的时间。
4.3、内存分配
除了内存回收GC,HotSpot对对象的内存分配同样重要,不同的收集器组合对内存的布局和分配规则不同。
对于Serial+Serial Old收集器组合,堆内存的布局就是经典的新生代+老年代,新生代中分为Eden区和两块Survivor区。新建对象优先分配在Eden区,如果Eden区空间不够分配则发起一次Minor GC,Minor GC是指发生在新生代的垃圾回收,主要过程是将Eden空间和from Survivor空间内存活的对象复制到to Survivor中,然后将两个Survivor名称互换,一旦to Survivor空间不够用,则使用担保机制直接将对象复制到老年代区中。
- 为了防止出现大量的大对象频繁触发Minor GC,可以设置参数让大对象直接进入老年代; -XX:PretenureSizeThreshold=1048576 (1mb)
- 另外,可以设置参数将长期存活在新生代的对象(年龄大)直接晋升进老年代; -XX:MaxTenuringThreshold=10 (10岁),此处年龄通过对象的年龄计数器来保存,对象在新生代经历一次Minor GC后移动到Survivor空间年龄为1,之后每经历一次Minor GC年龄加1。
- Survivor空间中相同年龄对象大小总和大于空间内存的一半,则将它们全部晋升老年代;
对于空间分配担保,前面也已经讲述过了。Minor GC的时候如果Survivor空间无法容纳存活的对象,则进行一次担保并将无法容纳的对象直接移动到老年代。
- 如果老年代的最大连续空间大于新生代所有存活对象总空间,则担保无风险
- 如果上述条件不成立,则担保可能有风险,通过-XX:+HandlePromotionFailure参数开启担保失败机制,则查看老年代连续可用空间是否大于历届晋升对象的平均大小,如果大于则可以冒险进行Minor GC并将无法容纳的对象移动到老年代,如果担保失败了则在老年代进行一次Full GC
- 如果-XX:-HandlePromotionFailure参数设置为不允许冒险,或者允许冒险但是老年代连续可用空间小于历届晋升对象平均大小,则不能冒险,改为Full GC。
五、总结
JVM的垃圾收集与内存分配是互相对应的同样重要的,内存分配时负责给对象分配内存空间,垃圾收集时负责对无用对象的内存进行回收。我们需要关注的重点有:
- 垃圾回收的时机
- 垃圾回收的算法
- HotSpot中对内存分配和垃圾回收的应用