java垃圾回收详解

329 阅读32分钟

在c++中,对象的创建与回收都由程序员负责,而java语言机制中加入了垃圾回收机制,使得内存的管理变得简单。

一.垃圾回收的三个问题

垃圾回收无非存在三个问题:1.哪些内存需要回收。2.什么时候回收。3.如何回收。

对于问题1,总所周知,java的内存区域分为线程私有和线程共有两部分,如图所示,线程私有的部分与线程的生命周期保持一致,线程销毁后,相应的内存也会被回收。 而方法区和堆这两个区域则有着明显的不确定性,只有在运行期间,我们才知道程序会在这两个区域内创建哪些对象,创建多少对象。并且这两个区域不会随着线程的销毁而消失,因此它处于一直变化中。垃圾回收器关注的正是这一部分。因此我们第一个问题的答案是:方法区和堆需要回收。

对于问题2,答案也很简单,即当空间不够的时候,进行垃圾回收。

对于问题3,我们将该问题分成了两部分,首先判断哪些对象需要进行回收,即哪些对象已经死去,本文第二章将对该问题进行阐述;其次需要设计具体的垃圾回收算法,本文第三章将对垃圾回收的算法进行说明,第四章将对具体的垃圾回收器进行介绍。

图1.png

二、如何判断对象已死

判断对象是否存活主要有引用计数和可达性分析两种算法。

1. 引用计数算法

为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。

在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。

public class Test {

    public Object instance = null;

    public static void main(String[] args) {
        Test a = new Test();
        Test b = new Test();
        a.instance = b;
        b.instance = a;
        a = null;
        b = null;
        doSomething();
    }
}

2.可达性分析算法

以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。

Java 虚拟机使用该算法来判断对象是否可被回收,GC Roots 一般包含以下内容:

1.虚拟机栈中局部变量表中引用的对象

2.本地方法栈中 JNI 中引用的对象

3.方法区中类静态属性引用的对象

4.方法区中的常量引用的对象

5.java虚拟机内部的引用,如基本数据类型对应的class对象,一些常驻的异常对象,系统类加载器

6.被同步锁持有的对象

3.引用类型

考虑一种需求,如果内存空间足够的话,则不用释放该对象,反之,如果内存空间比较紧张,那么则可以抛弃这些对象,很多缓存功能就是这样。以上简单的一种引用方式,不能满足这些需求。

1. 强引用

被强引用关联的对象不会被回收。

使用 new 一个新对象的方式来创建强引用。

Object obj = new Object();

2. 软引用

被软引用关联的对象只有在内存不够的情况下才会被回收。

使用 SoftReference 类来创建软引用。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联

3. 弱引用

被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。

使用 WeakReference 类来创建弱引用。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

4. 虚引用

又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。

为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。

使用 PhantomReference 来创建虚引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;

4.finalize()

当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会再调用该方法。其详细过程如图所示。

image.png

实际上,在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。如果该对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的finalize()方法最多只会被系统自动调用一次),稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果要在finalize()方法中成功拯救自己,只要在finalize()方法中让该对象重新引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。

值得注意的是,并且该方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用,在jdk9以后,已经被抛弃。

image.png

三、垃圾收集的算法

常见的垃圾收集算法及其分类如图所示。

image.png

1. 标记 - 清除

在标记阶段,程序会检查每个对象是否为活动对象,如果是活动对象,则程序会在对象头部打上标记。

在清除阶段,会进行对象回收并取消标志位,另外,还会判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块。

在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表。

不足:

  • 标记和清除过程效率都不高;
  • 会产生大量不连续的内存碎片,导致无法给大对象分配内存。

2. 标记 - 整理

让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

优点:不会产生内存碎片

不足: 需要移动大量对象,处理效率比较低。

3. 复制

将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

主要不足是只使用了内存的一半。

现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。

HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。

4. 分代收集

现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。

一般将堆分为新生代和老年代。

  • 新生代使用:复制算法
  • 老年代使用:标记 - 清除 或者 标记 - 整理 算法

四、垃圾回收算法实现细节

本章介绍垃圾回收器面临的问题进行介绍,为后续介绍各个垃圾回收器做铺垫,可以直接跳过看第五章。

1.根节点枚举

根节点枚举即寻找GC Roots,这个过程要求所有的收集器在这一步都是要暂停用户线程的(STW)。如果一个不漏的检查完所有的执行上下文和全局的引用位置,显然这样开销很大。在Hotspot的方案中,利用OopMap的数据结构来直接得到哪些地方存在着对象引用。关于他的说明可以详细见:blog.csdn.net/dyingstarAA… 总之,通过以上数据结构,我们可以快速定位到对象存在的区域,并完成Gc Roots枚举。

2.安全点与安全区域

STW操作时为了方便线程中断管理,提出了安全点的概念,所有的线程在安全点位置挂起等待。JVM在进行可达性分析时,需要枚举遍历GC Roots,如果针对引用链挨个遍历,在几百上千兆的内存大小遍历也是非常耗时间的。所以HotSpot虚拟机用OopMap(Ordinary Object Pointer map集合)来记录对象内的引用关系,只有在特殊的指令或者特定的位置才会产生或者更新OopMap,这个特定的位置即为安全点。

安全点的选择是有策略的,需要具备“是否具有长时间运行指令”的特征。我们知道,CPU资源是时间片段,如果在占用cpu时间比较小的指令位置设置安全点,线程的中断操作导致的全局暂停会效果会被放大;反之,在需要长时间运行的指令位置进行中断操作,延迟效果会被覆盖。

安全点上线程的中断策略有两种:抢先式中断和主动式中断。

抢先式中断不需要用户线程配合,在进行GC时,JVM会中断挂起所有用户线程;如果有些线程不在安全点,过段时间再尝试,直到线程在安全点的位置成功中断。抢先式中断的最大问题是时间成本的不可控,进而导致性能不稳定和吞吐量的波动,特别是在高并发场景下这是非常致命的,作为程序猿我们习惯掌控一切。目前没有虚拟机采用这种中断策略。

主动式中断不会直接中断线程,而是全局设置一个标志位,用户线程会不断的轮询这个标志位;当发现标志位为真时,线程会在最近的一个安全点主动中断挂起。

那么为什么还需要安全区域呢?因为不是所有的线程都处于running状态,当线程处于Sleep和Blocked状态时,线程无法自己走到安全点,而且虚拟机也不可能为了让其走到安全点,将所有的业务线程重新启动,这显然是不合理的。对于这种情况,需要引入安全区域的概念。

安全区域指的是在某一段代码区域内,引用关系不会发生变化,因此,在这一区域内开始垃圾回收都是安全的。相当于将安全点的概念进行了扩展。当代码要离开安全区域的时候,检查虚拟机是否已经完成了根节点枚举,如果完成了就可正常离开,否则就在安全区域等待。

3.记忆集与卡表

如上文所说,现在的垃圾回收器基本都是跨代的,而不同代之间的对象很容易存在关联。在垃圾回收的时候,要考虑非收集区域是否有指向收集区域的指针。虚拟机采用一个字节数组来表示,具体定义如下:

CARD_TABLE [this_address >> 9] = 1;

以上代表每512KB为一个区域。

4.写屏障

5.并发的可达性分析

刚刚我们谈到的可达性分析算法是需要一个理论上的前提的:该算法的全过程都需要基于一个能保障一致性的快照中才能够分析,这意味着必须全程冻结用户线程的运行。

为了不冻结用户线程的运行,那我们就需要让垃圾回收线程和用户线程同时运行。

所有我们来个反证法,先假设不并发标记,即只有垃圾回收线程在运行的流程是怎样的:

第一步是需要找到根节点,也就是我们常说的根节点枚举。

而在这个过程中,由于GC Roots是远远少于整个java堆中的全部对象的,而且在OopMap此类优化技巧的加持下,它带来的停顿时间是非常短暂且相对固定的,可以理解为不会随着堆里面的对象的增加而增加。大概变化如下图:

image.png

但是我们做完根节点枚举,只是做完了第一步。接下来,我们需要从GC Roots往下继续遍历对象图,进行"标记"过程。而这一步的停顿时间必然是随着java堆中的对象增加而增加的。大概就是下面这个图的意思:

image.png

所有,经过上面的分析,我们知道了,根节点的枚举阶段是不太耗时的,也不会随着java堆里面存储的对象增加而增加耗时。而"标记"过程的耗时是会随着java堆里面存储的对象增加而增加的。

"标记"阶段是所有使用可达性分析算法的垃圾回收器都有的阶段。因此我们可以知道,如果能够削减"标记"过程这部分的停顿时间,那么收益将是可观的。

所以并发标记要解决什么问题呢?

就是要消减这一部分的停顿时间。那就是让垃圾回收器和用户线程同时运行,并发工作。也就是我们说的并发标记的阶段。

知道了要解决的问题,还需要明白为什么要保障快照一致性,下面我们将引入三色标记法来说明这一问题。

《深入理解Java虚拟机(第三版)》中是这样描述的:

在遍历对象图的过程中,把访问都的对象按照"是否访问过"这个条件标记成以下三种颜色:

白色:表示对象尚未被垃圾回收器访问过。显然,在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

黑色:表示对象已经被垃圾回收器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其它的对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

灰色:表示对象已经被垃圾回收器访问过,但这个对象至少存在一个引用还没有被扫描过

通俗来说,就是开始所有的对象都是白色的,然后开始扫描,假如这条路径全部扫描完,则扫过的对象变为黑色,假如某个节点扫描完,但是其引用未扫描完,则显示为灰色。 可以想象成一片白色的海洋,灰色的波浪(三色标记法)扫过去,已经扫过的地方变成了黑色(有用的对象),直到大海被扫描完毕(内存区域扫描完毕),一些与大海无关的沟壑无法被扫描到仍然为白色(未与GC Roots相连)。

即如下图所示:

image.png

综上,我们阐述了两个问题,首先扫描的时候要保证快照的一致性,其次为了保证效率,用户线程和垃圾回收线程要并发运行。那么如何保证这两个问题不冲突呢。

要解决不冲突的问题,首先就要明白会有9呕什么冲突问题,并发运行会带来浮动垃圾和漏标两个问题。浮动垃圾是把原本消亡的对象错误的标记为存活,这不是好事,但是其实是可以容忍的,只不过产生了一点逃过本次回收的浮动垃圾而已,下次垃圾回收清理就可以。

漏标是把原本存活的对象错误的标记为已消亡,有用的对象被回收了,这就是非常严重的后果了。

下面我们将详细阐述以上问题产生的原因。

首先是浮动垃圾,假设GC线程已经遍历到E(变为灰色了),此时业务线程执行了 objD.fieldE = nullimage.png

D > E 的引用断开。此刻之后,对象E/F/G是“应该”被回收的。然而因为E已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮GC不会回收这部分内存。

这部分本应该回收但是没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。

另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。

其次是漏标问题,假设GC线程已经遍历到E(变为灰色了),此时应用线程先执行了:

    obj G =  objE.fieldG;
    objE.fieldG = null;
    objD.fieldG = G;

此时,E > G 断开,D引用 G。此时切回GC线程继续跑,因为E已经没有对G的引用了,所以不会将G放到灰色集合;尽管因为D重新引用了G,但因为D已经是黑色了,不会再重新做遍历处理。 最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。   不难分析,漏标只有同时满足以下两个条件时才会发生: 条件一:灰色对象断开了白色对象的引用;即灰色对象 原来成员变量的引用 发生了变化。 条件二:黑色对象重新引用了该白色对象;即黑色对象成员变量增加了新的引用。

    objE.fieldG = null;    1.对象E 往其成员变量fieldG,写入 null值,对应条件一
    objD.fieldG = G;       2.对象D 往其成员变量fieldG,写入 对象G ,对应条件二

由于两个条件之间是当且仅当的关系。所以,我们要解决并发标记时对象消失的问题,只需要破坏两个条件中的任意一个就行。

于是产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB)。

在HotSpot虚拟机中,CMS是基于增量更新来做并发标记的,G1则采用的是原始快照的方式。

什么是增量更新呢?

增量更新要破坏的是第一个条件(赋值器插入了一条或者多条从黑色对象到白色对象的新引用),当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。

可以简化的理解为:黑色对象一旦插入了指向白色对象的引用之后,它就变回了灰色对象。 什么是原始快照呢?

原始快照要破坏的是第二个条件(赋值器删除了全部从灰色对象到该白色对象的直接或间接引用),当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。

这个可以简化理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照开进行搜索。

需要注意的是,上面的介绍中无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的

增量更新用的是写后屏障(Post-Write Barrier),记录了所有新增的引用关系。

原始快照用的是写前屏障(Pre-Write Barrier),将所有即将被删除的引用关系的旧引用记

image.png

五、经典垃圾收集器

下图是当前常用的垃圾回收器,连线代表可以搭配使用。不同的垃圾回收器没有优劣之分,仅仅是适用于不同的场景。
并行:多条垃圾收集器线程之间的关系,即同一时间多个垃圾收集器在同时工作,此时一般用户线程在等待状态。因此,下表中的线程数>1,且STW=是,其实就是并行
并发:垃圾收集器线程与用户线程之间的关系。同一时刻,两种线程都在同时运行,显然,此时吞吐量会受到影响。 元古代:Serial(Serial|Serial Old),Parallel(Parallel Scavenge|Parallel Old) 中古代:CMS(Serial,不能用Para,因此开发了PerNew|CMS) 现代:G1

首先是单线程的Serial,然后是关注吞吐量的多线程版本的Parallel,然后发展到注重停顿时间的CMS,

image.png

回收器种类特点工作区域回收算法线程数是否STW虚拟机参数应用场景
Serial单线程,简单高效,在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率新生代复制1-XX:+UseSerialGC内存不大的虚拟机,例如客Client模式
PerNewSerial 收集器的多线程版本,可以与CMS搭配使用,且已经融入CMS,并被淘汰新生代复制-XX:+UseConcMarkSweepGCServer
Parallel Scavenge吞吐量优先处理器新生代复制• -XX:MaxGCPauseMillis;-XX:GCTimeRatio吞吐量敏感的后台运算中
Serial Old单线程,是 Serial 收集器的老年代版本老年代整理1-大部分Client
Parallel OldParallel Scavenge的老年代版本,吞吐量优先老年代整理-吞吐量敏感的后台运算中
CMS最短回收停顿时间虽然目的是好的,但是也有诸多缺点:内存碎片化,无法处理浮动垃圾,降低了吞吐量老年代清除初始重新标记阶段XX:+UseConcMarkSweepGC:手动开启CMS收集器关注用户交互体验的互联网网站

| G1| 单线程,简单高效,在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率 | 新生代 | 复制 |1 |是 |XX:+UseConcMarkSweepGC:手动开启CMS收集器 |适用于服务端较大的堆(>4-6G)|

1.Serial收集器

Serial 翻译为串行,也就是说它以串行的方式执行。

它是单线程的收集器,只会使用一个线程进行垃圾收集工作,且运行时需要STW

它的优点是简单高效,在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。

它是 Client 场景下的默认新生代收集器,因为在该场景下内存一般来说不会很大。它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的。(内存资源受限环境中)

相关参数:-XX:+UseSerialGC

image.png

2.ParNew收集器

它是 Serial 收集器的多线程版本。

它是 Server 场景下默认的新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合使用

相关参数:
-XX:+UseConcMarkSweepGC:指定使用CMS后,会默认使用ParNew作为新生代收集器
-XX:+UseParNewGC:强制指定使用ParNew
-XX:ParallelGCThreads:指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同

image.png

3.Parallel Scavenge收集器

与 Parallel New 一样是多线程收集器。

其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,因此它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户程序的时间占总时间的比值。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。(理解:停顿时间短就是集中所有资源快点垃圾回收完毕,而高吞吐量就是综合利用资源,达到最高的资源利用率)
缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。

可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

• -XX:MaxGCPauseMillis:是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过设定值。不过大家不要异想天开地认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集300MB新生代肯定比收集500MB快吧,这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
• -XX:GCTimeRatio:一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率。如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(即1 /(1+19)),默认值为99,就是允许最大1%(即1 /(1+99))的垃圾收集时间。
• -XX:+UseAdaptiveSizePolicy:一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)

image.png

4.Serial Old收集器

是 Serial 收集器的老年代版本,也是给 Client 场景下的虚拟机使用。如果用在 Server 场景下,它有两大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用

5.Parallel Old收集器

是 Parallel Scavenge 收集器的老年代版本。
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

6.CMS收集器

CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。

分为以下四个流程:

  • 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  • 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
  • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。CMS垃圾收集器通过写屏障+增量更新记录了并发标记阶段新建立的引用关系,重新标记就是去遍历这个记录。这部分扫描漏标问题。
  • 并发清除:GC线程与用户线程并发运行,清理未被标记到的对象
    默认启动的回收线程数 = (处理器核心数 + 3) / 4

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。因此,CMS理论上停顿时间最短。

具有以下缺点:

  • 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
  • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
  • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。 相关参数:
    -XX:+UseConcMarkSweepGC:手动开启CMS收集器
    -XX:+CMSIncrementalMode:设置为增量模式
    -XX:CMSFullGCsBeforeCompaction:设定进行多少次CMS垃圾回收后,进行一次内存压缩
    -XX:+CMSClassUnloadingEnabled:允许对类元数据进行回收
    -XX:UseCMSInitiatingOccupancyOnly:表示只在到达阀值的时候,才进行CMS回收
    -XX:CMSInitiatingOccupancyFraction:设置CMS收集器在老年代空间被使用多少后触发
    -XX:+UseCMSCompactAtFullCollection:设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片的整理

7.Garbage First(G1)收集器

软实时 G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。G1名字的由来 回收某个region的价值大小 = 回收获得的空间大小 + 回收所需时间 G1收集器会维护一个优先级列表,每个region按价值大小排序存放在这个优先级列表中。收集时优先收集价值更大的region,这就是G1名字的由来。

堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

G1收集器与之前的所有收集器都不一样,它将堆分成了一个一个Region,这些Region用的时候才被赋予角色:Eden、from、to、humongous。一个region只能是一个角色,不存在一个region既是Eden又是from。

每个region的大小可通过参数-XX:G1HeapRegionSize设置,取值范围是2-32M。 一个对象的大小超过region的一半则被认定为大对象,会用N个连续的region来存储。 四个步骤:

1、初始标记 会STW。 做了两件事: 1、修改TAMS的值,TAMS以上的值为新创建的对象,默认标记为存活对象,即多标(初始标记要保证一致性快照,因此都需要STW) 2、标记GC Roots能直接关联到的对象

2、并发标记 耗时较长。GC线程与用户线程并发运行。 从GC roots能直接关联到的对象开始遍历整个对象图

3、最终标记 遍历写屏障+SATB记录下的旧的引用对象图

4、筛选回收 更新region的统计数据,对各个region的回收价值进行计算并排序,然后根据用户设置的期望暂停时间的期望值生成回收集。 然后开始执行清除操作。将旧的region中的存活对象移动到新的Region中,清理这个旧的region。这个阶段需要STW。

**相关参数: -**XX:G1HeapRegionSize:设置region的大小 -XX:MaxGCPauseMillis:设置GC回收时允许的最大停顿时间(默认200ms) -XX:+UseG1GC:开启g1 -XX:ConcGCThreads:设置并发标记、并发整理的gc线程数 -XX:ParallelGCThreads:STW期间并行执行的gc线程数

缺点: 1、需要10%-20%的内存来存储G1收集器运行需要的数据,如不cset、rset、卡表等 2、运行期间会与用户线程抢夺CPU资源。当然,这是所有并发收集器的缺点 查看默认收集器 java -XX:+PrintFlagsFinal -version | grep GC

image.png G1能管理的最大堆空间是 4G - 64G 到了64G,指针压缩就没法用了。

大对象如何存储 会申请几个连续的region,叫做humongous

G1非常耗时间 20% - 30%的内存空间存储一些数据结构,使用空间换时间

现在垃圾回收器的发展趋势 模块化(region)+ 支持并发

JDK 8 不调优的话,默认使用的垃圾回收器是: Parallel Scavenge Parallel Old

image.png

8.低延迟垃圾收集器

内存分配策略

1. 对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。

2. 大对象直接进入老年代

大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。

经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。

-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 和 Survivor 之间的大量内存复制。

3. 长期存活的对象进入老年代

为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。

-XX:MaxTenuringThreshold 用来定义年龄的阈值。

4. 动态对象年龄判定

虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

5. 空间分配担保

在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。

如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。

参考文献: 1.《深入理解Java虚拟机 第二版》 2.juejin.cn/post/684490… 3.blog.csdn.net/qq_43631716…