1 对象存活判定算法
1.1 引用计数器
def (使用较少)经引用变量操作,判断对象是否还有使用价值
-
每个对象都包含一个 引用计数器,用于存放被引用的计数
-
每当有一个地方引用此对象时,引用计数 +1;当引用失效时,引用计数 -1
- i.e. 当前离开了局部变量的作用域,引用计数 -1
- i.e. 引用被设为
null
时,引用计数 -1
-
当引用计数为 0 时,表示此对象为不可能再被使用的已死对象,即已无任何方法得到该对象的引用
点击折叠
Q:相互引用的情况是怎样的?
A:当两个对象相互引用时,两对象均不会被回收。即使设为 null
后,a, b 两对象的引用计数仍为 1
1 | public class Main { |
1.2 可达性分析算法
def (主流 JVM 采用)类似树结构的搜索机制,用于判断对象是否存活。每个对象的引用都有机会成为树的根节点 (GC Roots) 节点,条件如下:
-
位于虚拟机栈的栈帧中的本地变量表中所引用到的对象(i.e. 方法中的局部变量),同样也包括本地方法栈中 JNI 引用的对象。
-
类的静态成员变量引用的对象
-
方法区中常量池引用的对象,i.e. String 类型对象
-
被添加了锁的对象,i.e. synchronized 修饰的对象
-
虚拟机内部需要使用的对象
则对于之前的问题,由于 GCRoots 已被回收,虽然 a、b 两对象之间仍存在相互引用,但由于两者跟 GCRoots 均无关联,故此时 a、b 均会被 GC 回收
1.3 Java 引用类型
def (JDK1.2 前)如果 reference 类型的数据中,存储的数值代表的是另外一块内存的起始地址,就称该 reference 数据是代表某块内存、某个对象的引用。(JDK1.2 及之后)Java 将引用的概念扩充至下述四种类型(引用强度依次递减):
-
强引用(Strong Reference):即传统定义上的引用,被强引用关联的对象不会被回收
1 | Object obj = new Object(); |
-
软引用(Soft Reference):有用但非必须的对象,只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常
1 | // 添加虚拟机选项-XX:+PrintGCDetails -Xms10M -Xmx10M |
-
弱引用(Weak Reference):非必须对象,被弱引用关联的对象只能生存到下一次 GC 发生为止,即只要进行 GC 一定回收
1 | WeakReference<Object> wf = new WeakReference<>(new Object()); |
-
虚引用(Phantom Reference):虚引用关联的对象会在被收集器回收时收到一个系统通知(仅此而已),该对象在任何情况下都可能被回收。虚引用与对象的存活时间无关,且无法通过虚引用得到关于对象的一个实例
1 | ReferenceQueue<Object> q = new ReferenceQueue<>(); |
1.4 对象死亡判定
要真正宣告一个对象死亡,至少要经历两次标记过程:
-
若对象进行可达性分析后,没有与 GC Roots 相连接的引用链,将被第一次标记并筛选,筛选条件为此对象是否有必要执行
finalize()
方法- 对象没有覆盖
finalize()
方法:无需执行 finalize()
方法已经被虚拟机调用过:无需执行
- 对象没有覆盖
-
若有必要执行,则将对象放入 F-Queue,并在稍后交由一个虚拟机自己建立的、低优先级的 Finalizer 线程执行;稍后 GC 将对 F-Queue 中的对象进行第二次小规模的标记,仍未被引用的对象会被回收
1.4.1 finalize () 方法
1 | // 一般用于释放对象的一些额外资源,目前已不推荐使用,相比cpp的析构函数运行代价过大 |
-
finalize ():对象的最终判定方法,若子类重写了该方法,会先暂时进入 F-Queue 队列,并在 GC 判定子类对象为可回收时,进行二次确认。
- 不由主线程执行,而是由 VM 创建的低优先级的 Finalizer 线程
- 对每个对象 finalize () 只能生效 1 次。下一次 GC 进行回收时,不会再次调用此方法
-
此方法执行过程中,当前对象可重新建立 GCRoots,即可不被回收,并从 F-Queue 队列中移除。
1 | public class Main { |
1.5 方法区的回收
由于方法区回收的性价比较低,java 虚拟机规范中未强制要求对方法区实现垃圾收集(i.e. JDK 11 时期的 ZGC 收集器就不支持类卸载)。在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义类加载器的场景中,通常需要 JVM 具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。方法区(永久代)主要回收两部分内容:
-
废弃常量
- e.g. 常量池中字面量回收:字符串 “java” 曾进入常量池中,但当前系统又没有任何一个字符串对象的值是 “java”,即无任何字符串对象引用常量池中的 “java” 常量、虚拟机中也没有其他地方引用这个字面量,此时若发生垃圾回收,垃圾收集器在判断确有必要的情况下,会将 “java” 常量清理出常量池
-
无用的类:
-Xnoclassgc
参数控制是否对类进行卸载。判定一个类型是否属于不再被使用的类,需同时满足:- 该类所有的实例都已经被回收,即 Java 堆中不存在该类的任何实例
- 加载该类的
ClassLoader
已经被回收(通常很难达成) - 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
2 垃圾回收算法
2.1 分代收集机制
2.1.1 Java 堆内存的划分
新生代、老年代和永久代
-
区分:JDK8 前方法区采用永久代实现,JDK8 后为元空间,使用本地内存
-
新生代 = Eden + Survivor () + Survivor (),默认比例 8 : 1 : 1
-
GC 频率:新生代 > 老年代 > 永久代
2.1.2 垃圾回收策略
-
新生代的垃圾回收过程:次要垃圾回收( Young GC / Minor GC)
- Trigger:Eden 区满
-
老年代的垃圾回收过程:主要垃圾回收(Old GC / Major GC)
- 只有 CMS GC 有单独收集老年代的行为
-
对整个 Java 堆内存和方法区进行垃圾回收:完全垃圾回收(Full GC)
- Trigger 1:老年代剩余空间不足,每次晋升到老年代的对象平均大小大于老年代剩余空间
- Trigger 2:老年代剩余空间不足,Minor GC 后新生代存活的对象超过老年代剩余空间
- *Trigger 3:永久代空间不足(JDK8 前)
- Trigger 4:手动调用
System.gc()
方法 - *Trigger 5:老年代剩余空间不足,CMS GC 过程中同时有对象要放入老年代,而此时 GC 过程中浮动垃圾过多导致暂时性的空间不足,便会报
Concurrent Mode Failure
错误并触发 Full GC
-
* 对新生代和部分老年代进行垃圾回收:混合垃圾回收(Mixed GC)
- 只有 G1 GC 有这种行为
2.1.3 Minor GC 的基本原理
假设此时 Survivor () 为 From 区,Survivor () 为 To 区,有:
-
Step 1:新创建的小对象进入新生代的 Eden 区、大对象直接进入老年代。
-
Step 2:Eden 区满时,JVM 会触发一次 Minor GC,对新生代区域内所有对象进行扫描,在 Eden 区和 Survivor 区中的对象会进行存活性分析,无法达到存活条件的对象会被回收
-
Step 3:上述过程中未被回收的对象需要被复制到新的位置:
- 对于原来位于 Eden 区且存活的对象,复制到 To 区()
- 对于原来位于 From 区()且存活的对象,复制到 To 区(),且这些对象的年龄会增加 1
-
Step 4:原来的 From 区()将被清空,Minor GC 结束,From 区和 To 区交换角色:To 区成为新的 From 区、之前的 From 区成为新的 To 区,即变为新的 From 区,变为新的 To 区。
-
Step 5:重复上述步骤。如果某个对象的年龄达到一定的阈值,该对象从 To 区晋升到老年代,不再在新生代中移动
点击折叠
Q1:为什么新生代默认的分配比例为 8 : 1 : 1?
A:新生代的对象生命周期较短,Eden 区中绝大多数对象都会在几轮回收后被清除;方便优化标记 - 复制算法的内存利用效率,减少 GC 开销
Q2:多大的对象算大对象?
A:大对象是指大于 JVM 设置的 -XX:PretenureSizeThreshold
值的对象,将直接在老年代分配连续内存空间,i.e. 长字符串、数组等。-XX:PretenureSizeThreshold
默认置 0
Q3:存活多久的对象会进入老年代?
A:-XX:MaxTenuringThreshold
用来定义年龄的阈值,作为年龄计数器:对象在 Eden 出生,经过 Minor GC 依然存活,将移动到 Survivor 中,年龄增加 1 岁,增加到一定年龄则移动到老年代中。-XX:MaxTenuringThreshold
默认置 15
Q4:新生代中年龄没达到阈值的对象就不会进入老年代吗?
A:存在动态对象年龄判定机制:如果 Survivor 中,相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可直接进入老年代,无需等到 -XX:MaxTenuringThreshold
指定的值
2.1.4 空间分配担保
Survivor 的 To 区空间是有限的。如果 Minor GC 后新生代的 Eden 区中仍存在大量的对象,就需要将一部分过多的对象晋升到老年代。工作流程如下:
-
Step 1:Minor GC 前,JVM 检查最大可用的连续空间是否大于新生代所有对象总空间,即老年代的可用空间是否足够容纳即将晋升的新生代对象,并根据之前 GC 的晋升情况和新生代中对象的存活率,预估出本次 GC 可能需要晋升的对象数量
-
Step 2:
- 如果老年代有足够的空间容纳这些晋升对象,则直接进行 Minor GC
- 如果老年代空间不足以容纳这些晋升对象,JVM 将查看
HandlePromotionFailure
设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;否则,如果小于或HandlePromotionFailure
设置不允许冒险,则将触发 Full GC,回收老年代中未使用的对象,以释放空间。在极端情况下,如果 Full GC 后老年代依然没有足够的空间,则抛出OutOfMemoryError
-
Step 3:JVM 根据多次 GC 的结果动态调整担保策略。如果新生代对象的晋升比例较低,JVM 可能在后续 Minor GC 时减少对老年代空间的需求预测;如果晋升对象过多,JVM 可能会更加保守地预留老年代空间
* 注:JDK 6 Update 24 后,-XX:HandlePromotionFailure
参数不再影响虚拟机的空间分配担保策略。只要老年代的连续空间大于新生代对象总大小 / 历次晋升的平均大小,就会进行 Minor GC;否则进行 Full GC
2.2 标记 - 清除算法
-
标记 - 清除(Mark-Sweep),有两类形式:
- 标记出所有需回收的对象,完成后统一回收已标记的对象
- 标记出所有仍存活的对象,完成后统一回收未标记的对象
-
缺点:
- 执行效率不稳定:标记、清除两过程的执行速度均和内存中的对象数目成反比
- 内存空间碎片化:这种内存分配方式会产生大量不连续的内存碎片,可能导致连续内存的空间利用率降低,i.e. 无法为大对象分配足够的连续内存
2.3 标记 - 复制算法
-
def 半区复制(Semispace Copying):将可用内存等分为两块,每次仅使用其中一块。当这块内存空间满时,将其中的所有存活对象复制到另一块上
-
优点:实现简单,运行高效。仅需要复制占少数的存活对象,这种内存分配方式不会产生空间碎片,每次复制时时按照堆顶指针的移动来顺序分配存活对象
-
缺点:可用内存减半
-
应用:商用 JVM 常用此方法的变种,i.e. Appel 式回收
2.4 标记 - 整理算法
-
标记 - 整理(Mark-Compact):针对老年代的移动式回收算法。标记出所有仍存活的对象,完成后将这些对象向内存空间一端移动(整理),并直接清理掉边界以外的内存
-
优点:内存分配和访问快,赋值器(Mutator)的吞吐量更高。赋值器为使用垃圾收集的用户程序
-
缺点:内存回收耗时增加,移动存活对象并更新所有引用较为耗时,且需全程暂停用户应用程序(“Stop The World”)才能进行
3 HotSpot 的算法实现
3.1 根节点枚举
-
目前主流 JVM 为准确式垃圾收集,因需确保在一致性的快照中进行可达性分析,GC 时必须停顿所有线程
-
在 HotSpot 的解决方案里,是使用一组称为
OopMap
的数据结构来记录存放对象引用的位置 -
一旦类加载动作完成,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在 JIT 编译过程中会在特定的位置记录下栈和寄存器中哪些位置是引用,这样收集器在扫描时就可以直接得知这些信息,无需真正一个不漏地从方法区等 GC Roots 开始查找
e.g. HotSpot 虚拟机客户端模式下,生成的一段 String::hashCode () 方法的本地代码。OopMap 位于 0x026eb7a9,指明了 EBX 寄存器和栈中偏移量为 16 的内存区域中各有一个普通对象指针(Ordinary Object Pointer,OOP)的引用,有效范围为从 call 指令开始直到 0x026eb730(指令流的起始位置)+142(OopM ap 记录的偏移量)=0x026eb7be,即 hlt 指令为止
3.2 安全点
def HotSpot 没有为每条指令都生成 OopMap
,而是只在特定位置记录了这些信息,这些位置称为安全点
-
程序执行时,只有到达安全点时才能暂停并开始 GC
-
安全点的选取:是否具有让程序长时间执行的特征,e.g. 方法调用、循环跳转、异常跳转
在 GC 前让所有线程跑到安全点的方法:
-
抢先式中断(Preemptive Suspension,几乎不使用):系统首先中断全部用户线程,将不在安全点上中断的用户线程恢复执行,直到跑到安全点上后重新中断
-
主动式中断(Voluntary Suspension):不直接对线程操作,仅设置一个标志位(与安全点重合,加上创建对象分配内存的地方),各个线程执行时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起
e.g. 由于轮询操作在代码中频繁出现,HotSpot 使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令。当需要暂停用户线程时,虚拟机把 0x160100 的内存页置为不可读,线程执行到 test 指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起等待,完成安全点轮询并触发线程中断
3.3 安全区域
def 用户线程处于 Sleep/Blocked 状态时,无法响应虚拟机的中断请求,无法走到安全点再中断;需要确保在某一段代码片段之中的引用关系不会发生变化,即在这个区域中任意地方开始 GC 都是安全的,这个区域称为安全区域
-
当用户线程执行到安全区域中的代码时,首先会标识进入了安全区域,虚拟机 GC 时不会处理已声明在安全区域内的线程
-
当线程将要离开安全区域时,将检查虚拟机是否已经完成了根节点枚举(或垃圾收集过程中其他需要暂停用户线程的阶段),若完成则继续执行;否则,等待直到收到离开安全区域的信号为止
3.4 记忆集与卡表
def 记录集是记录从非收集区域指向收集区域的指针集合的抽象数据结构,用于缩减 GC Roots 扫描范围
1 | // 用非收集区域中所有含跨代引用的对象数组实现 |
上述方式记录了非收集区域中的所有含跨代引用对象,维护成本较高;而通过记忆集,只需判断某一块非收集区域是否存在指向收集区域的指针,无需了解这些跨代指针的全部细节。记忆集有三类主流设计思路:
-
字长精度:每个记录精确到一个机器字长(处理器的寻址位数,i.e. 32/64 位,该精度决定机器访问物理内存地址的指针长度),该字包含跨代指针
-
对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针
-
卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针
- 使用卡表(Card Table)实现,其中的每个元素对应其标识的内存区域中一块特定大小(i.e. 如下为 512 字节)的内存块,称卡页(Card Page)
- 单个卡页中常包含多个对象,存在两类情况:(1)至少一个对象的字段中存在跨代指针,将卡页的标识置为 1,称为这个元素变脏(Dirty),当前卡页称为脏卡,将于 GC 时被加入 GC Roots 中一并扫描;(2)若不存在跨代指针,则标识为 0
1 | // HotSpot虚拟机中,卡表为一个字节数组 |
点击折叠
Q:记忆集和卡表是什么关系,两者在 JVM 中分别起什么作用?
A:记忆集作为一种抽象数据结构,记录了从非收集区域指向收集区域的指针集合;而卡表是实现记忆集的一种方式,定义了记忆集的记录精度、与堆内存的映射关系等。记忆集和卡表的关系类似 Map 与 HashMap 的关系,卡表是记忆集的一种具体实现
3.5 写屏障
def 写屏障是在其他分代区域对象引用本区域对象并赋值时,用于维护卡表的操作。基本流程包括:
-
其他分代区域对象引用本区域对象,赋值时产生一个环形(Around)通知,供程序执行额外的动作
-
写前屏障(Pre-Write Barrier):顾名思义,在赋值前进行
-
写后屏障(Post-Write Barrier):更常用,G1 以前的收集器均只使用写后屏障
1 | void oop_field_store(oop* field, oop new_value) { |
点击折叠
Q:什么是卡表的 “伪共享” 问题?如何避免这个问题?
A:伪共享是指当下 CPU 中的缓存系统是以缓存行(Cache Line)为单位存储的,考虑高并发场景下,若多个线程同时修改两相互独立的变量,而两者恰好位于同一个缓存行,共享会导致性能降低。解决方式是使用有条件的写保障,即先检查卡表标记,仅在卡页未被标记为过时时记为脏卡。JDK 7 后这一操作可通过 -XX:+UseCondCardMark
参数开启
1 | if (CARD_TABLE [this address >> 9] != 0) { |
3.6 并发的可达性分析
3.6.1 三色标记(Tri-color Marking)
-
白色:尚未被垃圾收集器访问过的对象
- 可达性分析开始时,所有对象均为白色
- 分析结束时,仍为白色的对象是不可达的
-
黑色:已经被 GC 访问过、所有引用均已扫描过的对象
- 黑色的对象是安全存活的
- 如果有其他对象引用指向黑色对象,则无须重新扫描一遍
- 黑色对象不可能直接(不经过灰色对象)指向某个白色对象
-
灰色:已被 GC 访问过、至少存在一个引用未被扫描过的对象
3.6.2 “对象消失” 问题
def 用户线程与收集器并发工作,收集器在对象图上标记颜色,同时用户线程在修改引用关系(修改对象图的结构),则会导致两种后果:
-
把原本消亡的对象错误标记为存活:可容忍的,下次 GC 时清理
-
把原本存活的对象错误标记为已消亡:严重,会导致程序出错,即对象消失问题
- 情况 1:赋值器插入了一条或多条从黑色对象到白色对象的新引用
- 情况 2:赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
为解决该问题,需要破坏这两种情况的任意一个,由此分别产生了两种解决方案:
-
增量更新(Incremental Update):当情况 1 发生时,记录新插入的引用;等待并发扫描结束后,以这些引用关系中的黑色对象为根重新扫描一次,即将这些新插入引用关系的黑色对象视作灰色对象处理
-
原始快照(Snapshot At The Beginning,SATB):当情况 2 发生时,记录要删除的引用;等待并发扫描结束后,以这些引用关系中的灰色对象为根重新扫描一次,即按照刚开始扫描(最原始)的对象图快照进行搜索
4 经典垃圾收集器
-
新生代垃圾收集器:Serial、ParNew、Parallel Scavenge、G1
-
老年代垃圾收集器:Serial Old、Parallel Old、CMS、G1
* 注:JDK 9 中已完全取消对 Serial+CMS、ParNew+Serial Old 的支持
垃圾收集器 | 支持多线程 | 垃圾回收策略 | 垃圾回收算法 |
---|---|---|---|
Serial | × | Minor GC | 标记 - 复制 |
ParNew | √ | Minor GC | 标记 - 复制 |
Parallel Scavenge | √ | Minor GC | 标记 - 复制 |
Serial Old | × | Major GC | 标记 - 整理 |
Parallel Old | √ | Major GC | 标记 - 整理 |
CMS | √ | Major GC | 标记 - 清除 |
G1 | √ | Mixed GC | 标记 - 复制(新生代)标记 - 整理(部分老年代) |
4.1 Serial 收集器
def 单线程的串行收集器
-
优点:
- 简单高效:无线程交互的开销,单线程收集效率最高
- 适用内存资源受限环境:在所有收集器中,消耗最少的额外内存(Memory Footprint),这是指为保证垃圾收集能够顺利高效地进行而存储的额外信息
-
缺点:垃圾收集时,必须由虚拟机在后台自动发起并暂停其他所有工作线程,直到收集结束。这个过程对用户而言是不可知、不可控的
-
应用:
- HotSpot 虚拟机运行在客户端模式下的默认新生代收集器
- 用户桌面应用、部分微服务应用:此类环境下,虚拟机管理的内存通常较小(i.e. 20M~200M),垃圾收集的停顿时间一般是可接受的(i.e. 10ms~50ms)
4.2 ParNew 收集器
def Serial 收集器的多线程并行版本
-
优点:适用多核处理器环境,其默认开启的收集线程数等同于处理器的核心数量
-
缺点:存在线程交互的开销,在单核心处理器的环境表现不佳,即便在开启超线程后也无法保证对 Serial 收集器的优势
-
应用:激活 CMS 后的默认新生代收集器,且只有 ParNew 收集器能与 CMS 收集器配合工作,通过
-XX:+UseConcMarkSweepGC
选项开启
4.3 Parallel Scavenge 收集器
def 吞吐量优先收集器,类似 ParNew 收集器,但其主要目的是为了达到一个可控制的吞吐量(Throughput),即处理器用于运行用户代码的时间与处理器总消耗时间的比值越高越好,有
-
优点:垃圾收集的自适应调节策略(GC Ergonomics):开启
XX:+UseAdaptiveSizePolicy
选项,即无需人工指定新生代的大小(-Xmn
)、Eden 与 Survivor 区的比例(-XX:SurvivorRatio
)、晋升老年代对象大小(-XX:PretenureSizeThreshold
)等细节参数。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量
* 注:Parallel Scavenge 收集器架构中本身有 PS MarkSweep 收集器进行老年代收集,但其实现与 Serial Old 几乎完全相同,故资料中常以 Serial Old 直接代替 PS MarkSweep
4.4 Serial Old 收集器
def Serial 收集器的老年代版本
-
应用:
- HotSpot 虚拟机运行在客户端模式下的老年代收集器
- (JDK 5 及之前)在服务端模式下,搭配 Parallel Scavenge 收集器使用
- 在服务端模式下,作为 CMS 收集器发生发生
Concurrent Mode Failure
时的后备预案
4.5 Parallel Old 收集器
def Parallel Scavenge 收集器的老年代版本,JDK 6 开始提供
-
优点:适用注重吞吐量、处理器资源稀缺的环境
4.6 Concurrent Mark Sweep(CMS)收集器
def 并发低停顿收集器(Concurrent Low Pause Collector),其主要目的是为了获取最短的回收停顿时间,JDK 5 开始提供,JDK 9 起不推荐使用(Deprecate)。运作过程分为四个步骤:
Step 1:初始标记(CMS initial mark),标记 GC Roots 能直接关联的对象,需要停顿用户线程
Step 2:并发标记(CMS concurrent mark),进行可达性分析,从 GC Roots 的直接关联对象开始遍历整个堆中的对象图,但无需停顿用户线程,即收集器线程可同用户线程一起工作
Step 3:重新标记(CMS remark),修正 Step 2 中因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿用户线程
Step 4:并发清除(CMS concurrent sweep),清理标记对象,无需停顿用户线程
各阶段的执行速度对比:
-
优点:并发收集、低停顿
-
缺点:
- 吞吐量较低:CMS 不会导致用户线程停顿,但因占用部分线程导致应用程序变慢,从而降低总吞吐量,i.e. 当处理器核心数量不足四个时,CMS 对用户程序的影响就可能变得很大,高处理器负载时甚至可能导致用户程序的执行速度忽然大幅降低
- 无法处理 “浮动垃圾”(Floating Garbage):可能出现
Concurrent Mode Failure
,进而导致另一次 “Stop The World” 的 Full GC。浮动垃圾是指在并发标记和并发清理阶段,用户线程继续运行产生的新的垃圾对象。由于这些对象出现在标记过程结束以后,CMS 无法在当次收集中处理,只能保留至下一次垃圾收集时再做清理 - 内存空间碎片问题
4.7 Garbage First(G1)收集器
def 全功能的垃圾收集器(Fully-Featured Garbage Collector),其主要目的是为了建立起 “停顿时间模型”(Pause Prediction Model)的收集器,即能够支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒。因此,G1 收集器开创了基于 Region 的堆内存布局,不再隔离新生代和老年代,把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。JDK 8 Update 40 后正式提供,用于替代 CMS
在不考虑用户线程运行过程中的动作时(如收集器对记忆集的维护等),G1 收集器的运作过程分为四个步骤:
Step 1:初始标记(Initial Marking),标记 GC Roots 能直接关联到的对象,修改 TAMS 指针的值,需要停顿用户线程
Step 2:并发标记(Concurrent Marking),进行可达性分析,从 GC Roots 的直接关联对象开始遍历整个堆中的对象图,无需停顿用户线程
Step 3:最终标记(Final Marking),再次短暂暂停用户线程,处理遗留的少量 SATB 记录,即修正 Step 2 中因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
Step 4:筛选回收(Live Data Counting and Evacuation),更新 Region 的统计数据,将各个 Region 的回收价值和成本排序,再自由选择任意个 Region 构成回收集,把回收集中 Region 包含的的存活对象复制到空的 Region 中,最后清理旧 Region 的全部空间。需要停顿用户线程
* 注:虽然 G1 整体上基于 “标记 - 整理” 算法实现,但从 Step 4 可知,局部(两个 Region 之间)上是基于 “标记 - 复制” 算法实现的
-
优点
- 并发收集、可预测的低停顿:可由用户指定
-XX:MaxGCPauseMillis
参数以设置期望的停顿时间(默认为 200ms),从而动态地在吞吐量和延迟之间寻求平衡 - 收集效率高,现在新生代和老年代是一系列(无需连续的)Region 的动态集合,每次收集到的内存空间都是 Region 大小的整数倍,从而有计划地避免在整个 Java 堆中进行全区域的垃圾收集
- 可按收益动态确定回收集
- 不会产生空间碎片
- 并发收集、可预测的低停顿:可由用户指定
-
缺点:
- 更高的内存占用(Footprint)负担:相较于其他经典收集器,一般至少需要耗费大约相当于 Java 堆容量 10%~20% 的额外内存来维持 G1 收集器的工作
- 相较于 CMS 收集器,程序运行时带来更高的额外执行负载(Overload)
-
应用:HotSpot 虚拟机运行在服务端模式下的默认垃圾收集器
点击折叠
Q1:G1 收集器是如何存储大对象的?
A:G1 收集器有一个特殊的区域 Humongous Region,该区域专用于大对象的存储,被视为老年代的一部分。大对象的判定依据为:其大小是否超过单个 Region 一半的容量;大小已超过单个 Region 容量的对象,将被 G1 存放在 N 个连续的 Humongous Region 之中
Q2:Garbage First 思想在 G1 收集器上是如何体现的?
A:G1 收集器会在后台维护一个优先级列表,列表维护了各个 Region 中回收所获得的空间大小、回收所需时间的经验值,每次优先处理回收价值收益最大的那些 Region
Q3:如何处理 G1 收集器中的跨 Region 引用?
A:每个 Region 都有一个记忆集,用来记录该 Region 对象的引用对象所在的 Region,避免在可达性分析时将全堆作为 GC Roots 扫描。记忆集实际上通过哈希表存储,Key 是别的 Region 的起始地址,Value 是一个卡表索引号的集合,这些双向卡表同时记录了指向和被指向关系。G1 收集器使用写后屏障维护卡表,并使用队列异步处理对卡表的一系列操作
Q4:在并发标记阶段,G1 收集器如何保证收集线程、用户线程能够并发运行?
A:先通过原始快照(SATB)算法,使用写前屏障跟踪并发时的指针变化情况,解决用户线程改变对象引用关系,从而打破原本的对象图结构、使标记结果出现错误的问题;对于程序运行时不断创建的新对象,G1 为每一个 Region 设计了两个名为 TAMS(Top at Mark Start)的指针,将 Region 中的部分空间划给这些新对象,其地址必须要在两个 TAMS 指针的位置以上,G1 收集器将默认 TAMS 指针以上的对象是被隐式标记过的,即这些对象为不纳入回收范围的存活对象。但如果此时 Region 中剩余的空间不足,将冻结用户线程并进行 “Stop The World” 的 Full GC
Q5:G1 收集器是如何做到可预测的停顿的,为什么 G1 收集器建立的停顿预测模型是可靠的?
A:基于衰减均值(Decaying Average)理论,即相比普通的平均值更容易受到新数据的影响,能更准确地代表 “最近的” 平均状态,即 Region 的统计状态越新越能决定其回收的价值。垃圾收集时,G1 收集器会记录每个 Region 的回收耗时、每个 Region 记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。在此基础上,预测当前如果进行垃圾回收,哪些 Region 组成的回收集才能在不超过设定停顿时间的约束下获得最大收益
参考
[1] 深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第 3 版)
[2] Java JVM 虚拟机 已完结(IDEA 2021 版本)4K 蓝光画质
[4] Java 全栈知识体系
[6] Garbage First Garbage Collector Tuning
[7] Getting Started with the G1 Garbage Collector
[8] 《深入理解 Java 虚拟机》(三)垃圾收集器与内存分配策略
[9] Hosking A L, Hudson R L. Remembered sets can also play cards[J]. OOPSLAGC OOP93]. Available for anonymous FTP from cs. utexas. edu in/pub/garbage/GC93, 1993.