细说 Java GC 垃圾收集器
一、GC目标
业务角度,我们需要追求2个指标:
- 低延迟(Latency):请求必须多少毫秒内完成响应;
- 高吞吐(Throughput):每秒完成多少次事务。
两者通常存在权衡关系,即提高吞吐量可能会导致延迟增加,反之亦然。两者的平衡本质是资源利用率与响应速度的取舍。
同理,GC 角度我们也追求这两个指标:
-
低延迟(Latency): GC时一次 STW (Stop the World) 的最长时间,越短越好;
-
高吞吐(Throughput): 一个时间周期内,用户程序执行时间占系统总运行时间的百分比,例如系统运行了 100 min,GC 耗时 1 min,则系统吞吐量为 99%;
二、GC过程和算法
2.1、具体过程
-
扫描堆内存对象,标记对象存活
-
清理垃圾
- 清理:
- 直接清理垃圾,会产生内存碎片;
- 复制:
- 复制存活对象到空闲内存,清理剩余空间,需要部分内存一直空闲;
- 适用于存活少,对象小的情况,否则耗时;部分空间浪费;
- 需要处理对象复制后的新地址;
- 整理(压缩):
- 将存活对象移动到内存一侧,其余空间清理掉,没有内存碎片;
- 需要处理对象移动后的新地址;
- 清理:
2.2、 算法分类
按照清理阶段的3种方式分为:
- 清理算法(mark-sweep)
老年代CMS垃圾收集器采用;
- 复制算法(copying)
- 新生代一般采用;
- 复制对象的成本要远高于扫描成本,所以,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。
- 整理算法(mark-compact)
老年代一般采用;
项目 | 清理算法 | 整理算法 | 复制算法 |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不堆积碎片) | 通常需要活对象的2倍大小(不堆积碎片) |
移动对象 | 否 | 是 | 是 |
2.2.1、 分代算法
分代算法是基于一个分代假设理论(Generational Hypothesis):绝大多数对象都是朝生夕死的。
- 复制算法适合管理短生命周期对象,一次清理后,存活对象少,复制的就少。
- 清理和整理算法适合管理长生命周期对象,一次清理后存活对象多,复制开销大。
为了发挥各自算法的优点,我们基于对象的生命周期引入了分代垃圾回收算法。
-
清理空间大小:一整块内存或者划分region
- 清理几十MB和几十GB内存时,清理策略需要调整。
- 将几十GB内存划分为一些小的内存区域,记为region。
2.2.2、 GC触发时机
2.2.2.1、YGC/ Minor GC的触发时机
当Eden区空间不足时,就会触发YGC。结合新生代对象的内存分配看下详细过程:
1、新对象会先尝试在栈上分配(对象没有逃逸),如果不行则尝试在TLAB(Thread Local Allocation Buffer: 每一个线程预先在 Eden 区分配一块儿内存,来保证线程安全,通过
-XX:-UseTLAB
可以关闭)分配,否则再看是否满足大对象条件要在老年代分配,最后才考虑在Eden区(通过CAS + 失败重试 机制来保证证线程安全)申请空间。
2、如果Eden区没有合适的空间,则触发YGC。
3、YGC时,对Eden区和From Survivor区的存活对象进行处理,如果满足动态年龄判断的条件或者To Survivor区空间不够则直接进入老年代,如果老年代空间也不够了,则会发生晋升失败(promotion failed),触发老年代的回收。否则将存活对象复制到To Survivor区。
4、此时Eden区和From Survivor区的剩余对象均为垃圾对象,可直接回收。
2.2.2.1、Full GC的触发时机
1、 老年代空间不足
-
晋升失败
- Minor GC前检查:若老年代剩余空间 < 历史晋升对象的平均大小,触发Full GC(防止本次Minor GC后晋升对象无法容纳)。
- Minor GC后检查:若存活对象超过Survivor且老年代空间不足,直接触发Full GC。
- 阈值触发:老年代内存使用率超过阈值(如默认92%,可通过参数调整)。
-
大对象直接分配
大对象(如长数组)直接进入老年代,若老年代无足够连续空间,触发Full GC。
2、 元空间/永久代空间不足
-
JDK 1.7及以前:永久代(存放类信息、常量池等)满时触发Full GC,若回收后仍不足则抛出PermGen space的OOM。
-
JDK 8+:元空间(本地内存管理)不足时触发Full GC,但默认元空间动态扩展,需显式设置限制才会触发。
3、 显式调用System.gc()
- 调用
System.gc()
会建议JVM执行Full GC,但实际执行由虚拟机决定(可通过-XX:+DisableExplicitGC
禁用)。
4.、空间分配担保失败
-
担保条件
- 每次晋升对象的平均大小 > 老年代剩余空间。
- Minor GC后存活对象 > 老年代剩余空间。
-
典型场景
- Promotion Failed:Minor GC时Survivor不足且老年代空间不足。
- Concurrent Mode Failure(CMS):CMS并发清理期间新对象进入老年代失败,退化为Serial Old收集器触发Full GC。
5、 堆内存配置不当
- 未指定堆大小:未设置
-Xmx
和-Xms
时,堆内存动态伸缩可能频繁触发Full GC。 - 堆内存分配不均:年轻代过小导致对象快速晋升,或老年代过小无法容纳正常晋升对象。
6、垃圾回收器特定行为
-
Parallel Scavenge的Full GC机制
默认在Full GC前执行一次Young GC(通过
-XX:+ScavengeBeforeFullGC
控制),可能导致误判触发条件。 -
CMS周期性检查
老年代使用率周期性触发CMS并发标记,若并发清理失败则触发Full GC。
7、其他边缘场景
-
永久代/元空间配置错误:如反射类频繁生成未卸载,或动态代理类未回收。
-
堆外内存影响:若堆外内存(如
NIO DirectBuffer
,具体细节查阅细说Java 引用(强、软、弱、虚)和 GC 流程(一) 1.5.3.2 小节)未及时释放,间接导致堆内存压力增大。
2.3、 内存分配方式
-
指针碰撞
- 使用场景:内存没有内存碎片,换言之,采用复制和整理算法的GC 收集器可以使用指针碰撞来分配内存,如Serial, ParNew。
- 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
-
空闲列表
- 使用场景:内存有内存碎片,换言之,采用清理算法的GC 收集器可以使用空闲列表来分配内存,如CMS。
- 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
三、常用的垃圾回收器
HotSpot 的垃圾收集器是随着内存发展而不断演进的:
- 几十MB的内存,使用Serial+Serial Old单线程进行回收;
- 几百MB的内存,使用Parallel Scavenge+Parallel Old多线程的GC线程来回收;
- 几个GB的内存,多线程也忙不过来,得使用并发的CMS+ParNew收集器;
- 几十个GB内存,传统的垃圾收集器每次GC都需要对新生代或老年代或整个堆回收,这种STW时长是无法忍受的,此时就需要使用现代垃圾收集器了(如G1, ZGC),它们将内存划分为多个Region,每次回收计算ROI(return on investment,投资回报率)高的 Region 进行回收处理;
3.1 垃圾收集器
收集器 | 目标 | 新生代、老年代标识 | 触发参数 | 备注 |
---|---|---|---|---|
串行(Serial) | 低延迟 | 新:DefNew 老:Tenured | -XX:+UseSerialGC | 单线程,简单、易实现、效率高;STW时间长; 当CMS并发收集失败时触发Serial Old进行Full GC; 新生代用Serial,老年代用Serial Old; |
并行(ParNew) | 低延迟 | 新:ParNew 老: Tenured | -XX:+UseParNewGC | Serial的多线程版,充分的利用CPU资源,减少回收的时间; ParNew中的Par指的就是Parallel; 新生代的垃圾回收器,需搭配CMS; |
吞吐量优先(Parallel Scavenge) | 高吞吐 | 新:PSYoungGen 老:ParOldGen | -XX:+UseParallelGC | 侧重于高吞吐量(CPU利用率优先)的控制; 适用于后台计算型任务(如批处理); JDK8+默认新生代收集器; 通过 -XX:+UseAdaptiveSizePolicy 启用自适应调节(默认开启)即,根据本次GC耗时动态调整堆分区比例,这是与ParNew新生代收集器最大的不同;通过 -XX:+UseParallelOldGC 配合老年代Parallel Old收集器工作,默认是Serial Old |
并发标记清除 (CMS,Concurrent Mark Sweep) | 低延迟 | 新:ParNew 老:concurrent mark-swleep generation | -XX:+UseConcMarkSweepGC | 以获取最短 STW 时间为目标,基于“标记-清除”算法实现; 通过 -XX:+UseCMSCompactAtFullCollection 在Full GC时压缩内存,解决碎片问题;通过 -XX:CMSFullGCsBeforeCompaction=5 设置为每5次Full GC压缩1次内存;通过 -XX:+CMSScavengeBeforeRemark 在Remark前强制 Young GC 来减少跨代引用;CMS是老年代收集器,年轻代默认搭配 ParNew 收集器(Java 8及之前版本) |
G1 (Garbage-First) | 平衡吞吐与延迟 | 新: Eden regions 老:Old regions | -XX:+UseG1GC | G1收集器的设计目标是取代CMS收集器; 与CMS最大的不同是,用户可以设定整个GC过程的期望停顿时间,通过 -XX:MaxGCPauseMillis 指定一个G1收集过程目标停顿时间,默认值200ms通过 -XX:G1HeapRegionSize 设定一个Region的大小,默认根据堆大小分配;新生代:动态Region(2MB/32MB),无需连续内存; 老年代:大Region(N×32MB),允许跨代引用追踪; 应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求; |
The Z Garbage Collector | 低延迟 | 新:Y: Young Generation 老: O: Old Generation | -XX:+UseZGC | 仅支持64位系统(着色指针技术 导致,详情查阅本文 6.3 小节);适用于大内存(超过32G,原因查阅本文 6.3 小节)低延迟服务的内存管理和回收; JDK21正式支持分代( -XX:+ZGenerational ) |
3.2 垃圾收集细节
为方便后面叙述,我们准备了如下代码示例,用于根据不同垃圾收集器截取GC日志。
void printGcLog() throws InterruptedException {
final Byte[] bytes = new Byte[1024 * 1024 * 5]; // 5MB
final WeakHashMap<Object, Object> objectObjectWeakHashMap = new WeakHashMap<>();
for (int i = 0; i < 10; i++) {
Thread.sleep(1000);
objectObjectWeakHashMap.put(i, new Byte[1024 * 1024]);
}
}
3.2.1、Serial收集器
3.2.1.1、JVM启动参数
// 新生代和老年代都用单线程的串行回收器。适合单核并发能力差得处理器。
-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+UseSerialGC -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
3.2.1.2、GC日志
3.2.1.3、GC分析
- 单线程高效:无多线程上下文切换开销,单核CPU利用率高。
- 内存占用低:无额外数据结构(如G1的Remembered Set,详见本文6.6小节),适合小内存环境(<100MB)
新生代Serial收集器(全程STW)
- 标记
- 单线程标记GC Roots关联的对象。
- 复制存活对象
- 单线程扫描Eden区和Survivor From区,将存活对象复制到Survivor To区。
- 清空原区域
- 清空Eden区和Survivor From区(原数据直接丢弃)。
- 年龄计数
- 存活对象年龄+1,若年龄超过阈值(默认15),则晋升到老年代。
老年代Serial Old收集器(全程STW)
- 标记存活对象;
- 计算新对象地址;
- 调整对象指针;
- 移动对象;
3.2.2、并行(ParNew)
3.2.2.1、JVM启动参数
// 新生代用并行的ParNew回收期,老年代用单线程的串行回收器。适合多核,并发能力强的处理器。
-Xms50m -Xmx50m -XX:+PrintGCDetails -XX:+UseParNewGC
3.2.2.2、GC日志
新生代Serial收集器的多线程版
全程STW
- 与CMS共用卡表(详见本文6.2小节)维护跨代引用(约1%堆内存开销)
- 无额外数据结构(如G1的Remembered Set)
3.2.3、吞吐量优先(Parallel Scavenge)
3.2.3.1、JVM启动参数
// 新生代使用ParallelGC回收器,老年代使用串行回收器。
-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+UseParallelGC
// -XX:+UseParallelOldGC:新生代使用ParallelGC回收器,老年代使用ParallelOldGC回收器。
3.2.3.2、GC日志
3.2.3.3、GC分析
全程STW
并行标记:
- 多线程快速标记存活对象。
复制存活对象:
- 并行将对象复制到Survivor To区,年龄+1。
空间清理:
- 清空Eden和Survivor From区,恢复用户线程。
自适应调整:
- 根据本次GC耗时动态调整堆分区比例。
3.2.4、CMS
3.2.4.1、JVM启动参数
-Xms50m -Xmx50m -XX:+PrintGCDetails -XX:+UseConcMarkSweepGC -XX:+UseCMSCompactAtFullCollection
3.2.4.2、GC日志
3.2.4.3、GC分析
- 初始标记(CMS initial mark, STW)
- 初始标记仅仅只是标记一下GCRoots能直接关联到的对象,速度很快。
- 并发标记(CMS concurrent mark)
- 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与GC线程一起并发运行。
- 重新标记(CMS remark, STW)
- 重新标记阶段是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录;采用三色标记算法(详见本文6.5小节)和增量更新(详见本文6.5小节)避免漏标。
- 并发清除(CMS concurrent sweep)
- 清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发执行的。
3.2.5、G1
3.2.5.1、JVM启动参数
-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+UseG1GC
// -XX:G1NewSizePercent 新生代最小值,默认值5%
// -XX:G1MaxNewSizePercent 新生代最大值,默认值60%
3.2.5.2、GC日志
3.2.5.3、GC分析
G1提供了两种GC模式,Young GC和Mixed GC,两种都是STW的。
- Young GC:选定所有年轻代里的Region(具体信息查阅本文 2.2.1 小节)。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。
- Mixed GC:选定所有年轻代里的Region,外加根据
global concurrent marking
(执行过程类似CMS)统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region。
- 初始标记(initial mark,STW)
- 它标记了从GC Root开始直接可达的对象。
- 并发标记(Concurrent Marking)
- 这个阶段从GC Root开始对heap中的对象标记,标记线程与应用程序线程并行执行,并且收集各个Region(具体信息查阅本文 2.2.1 小节)的存活对象信息。
- 最终标记(Remark,STW)
- 标记那些在并发标记阶段发生变化的对象,将被回收。
- 清除垃圾(Cleanup)
- 清除空Region(没有存活对象的),加入到free list。
3.2.6、ZGC
3.2.6.1、JVM启动参数
-Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+UseZGC -XX:+ZGenerational
3.2.6.2、GC日志
ZGC触发原因:
- Allocation Stall (阻塞内存分配请求触发)
当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞;应当避免。
- Allocation Rate (基于分配速率的自适应算法)
最主要的GC触发方式,原理:ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC。通过
ZAllocationSpikeTolerance
参数控制阈值大小,该参数默认2,数值越大,越早的触发GC。 - Timer(基于固定时间间隔):
通过
-XX:ZCollectionInterval=0.xx
控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。 - Proactive(主动触发规则)
类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,可以通过参数
-XX:-ZProactive
将该功能关闭,以免GC频繁,影响服务可用性。 - Warmup预热规则:
服务刚启动时出现,一般不需要关注。
- System.gc() (显示触发)
代码中显式调用
System.gc()
触发。 - Metadata GC Threshold(元数据分配触发):
元数据区不足时导致,一般不需要关注。
3.2.6.3、GC分析
- 初始标记阶段(Pause Mark Start)
- 短暂STW(通常<1ms),扫描GC Roots(线程栈、静态变量等),标记GC Roots直接引用的对象。
- JDK16优化后根节点扫描效率更高,停顿时间与GC Roots数量无关。
- 并发标记阶段(Concurrent Mark)
- GC线程与应用线程并发执行,遍历对象图标记所有可达对象。
- 染色指针:利用指针高4位标记对象状态(Marked0/Marked1/Remapped视图,具体细节查阅本文 6.3 小节)。
- 读屏障:应用线程读取对象引用时触发屏障,检查对象是否被标记或转移,动态更新视图(具体细节查阅本文 6.4 小节)。
- 视图切换:标记阶段全局视图切换为Marked0/Marked1,区分新旧周期活跃对象。
- 再标记阶段(Pause Mark End)
- 短暂STW(<1ms),修正并发标记期间因应用线程操作导致的标记不一致。
- 重新扫描GC Roots的变更(如新增引用)
- 引用处理(Weak/Soft/PhantomReference,具体细节查阅 细说 Java 引用(强、软、弱、虚)和 GC 流程(二))
- 并发转移准备(Concurrent Prepare for Relocate)
- 并发确定需回收的Region集合(重分配集),准备对象转移。
- 初始转移阶段(Pause Relocate Start)
- 短暂STW(<1ms),转移根对象直接引用的存活对象到新Region,建立转发表(Forward Table, 具体细节查阅本文 6.8 小节)
- 并发转移阶段(Concurrent Relocation)
- GC线程与应用线程并发执行,逐步转移重分配集中的存活对象。
- 应用线程访问旧对象时,通过读屏障查询转发表自动重定向到新地址,并更新引用,即指针自愈(Self-Healing, 具体细节查阅本文 6.9 小节)技术。
- 全局清理与视图切换
- 释放已转移Region的物理内存(可配置延迟归还OS)
- 切换全局视图为Remapped,为下一次GC准备。
四、总结
4.1、一图胜千言
足迹(FootPrint): 一个程序使用了多少硬件的资源,也称作程序在硬件上的足迹。GC 里面说的足迹,通常就是应用对内存的占用情况。比如说应用运行需要 2G 内存,但是好的 GC 算法能够帮助我们减少 500MB 的内存使用,满足足迹这个指标
4.2、GC选择
五、GC优化
5.1、应用类型
-
IO 交互型: 比如分布式 RPC、MQ、HTTP 网关服务等,对内存要求并不大,大部分对象在很快就会消亡, 新生代越大越好。
-
MEM 计算型: 主要是分布式数据计算 Hadoop,分布式存储 HBase、Cassandra,自建的分布式缓存等,对内存要求高,对象存活时间长,Old 区越大越好。
5.2、GC优化措施
-
保持堆内存为物理内存的70%以下(避免Swap)
-
禁用偏向锁: 偏向锁在只有一个线程使用到该锁的时候效率很高,但是在竞争激烈情况会升级成轻量级锁,此时就需要先消除偏向锁,这个过程是 STW 的。如果每个同步资源都走这个升级过程,开销会非常大,所以在已知并发激烈的前提下,一般会禁用偏向锁
-XX:-UseBiasedLocking
来提高性能。 -
虚拟内存: 启动初期 Linux 并没有真正分配物理内存给 JVM ,而是在虚拟内存中分配,使用的时候才会在物理内存中分配内存页,这样也会导致 GC 时间较长。这种情况可以添加
-XX:+AlwaysPreTouch
参数,让 VM 在 commit 内存时跑个循环来强制保证申请的内存真的 commit,避免运行时触发缺页异常。在一些大内存的场景下,有时候能将前几次的 GC 时间降一个数量级,但是添加这个参数后,启动的过程可能会变慢。
5.3、新生代老年代大小计算分配规则
5.3.1、 活跃对象
应用程序稳定运行时,Full GC 后堆中老年代占用空间的大小。
可以多次获取GC日志中Full GC之后老年代数据大小通过取平均值的方式计算活跃数据的大小。
5.3.2、 分配规则
- 总大小 3-4 倍活跃对象的大小
- 新生代 1-1.5 活跃对象的大小
- 老年代 2-3 倍活跃对象的大小
- 永久代 1.2-1.5 倍Full GC后的永久代空间占用(JDK7之前)
一般情况下老年代的大小应当为活跃对象的 2~3 倍左右,考虑到浮动垃圾问题最好在 3 倍左右,剩下的都可以分给新生代。
Full GC 后 老年代释放大量空间,很有可能是过早晋升
案例:给祖传系统做了点 GC调优,暂停时间降低了 90% | 京东云技术团队
5.4、GC调优
5.4.1、GC调优时机
5.4.1.1、最简单的判断办法是看GC日志是否在频繁的打印
5.4.1.2、OOM (java.lang.OutOfMemoryError)
-
内存加载数据量过大
举例:不受行数限制的数据库查询语句、不限制字节数的文件读取等。
-
内存泄漏(资源未关闭/无法回收)
当系统存在大量未关闭的 IO 资源,或者错误使用ThreadLocal(具体细节查阅细说Java 引用(强、软、弱、虚)和 GC 流程(一) 1.5.2 小节)等场景时也会发生OOM。
-
系统内存不足
系统内存不足以支撑当前业务场景所需要的内存,过小的机器内存或者不合理的JVM内存参数。
生产环境黑匣子,生产环境一旦挂了,可以保留事故现场:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/logs/oom_dump/xxx.hprof -Xloggc:<filename>
5.4.2、JDK工具的利用
jstat
查看 JVM 的 GC 统计信息,确定 JVM 的运行状态jinfo
查看并确定当前 JVM 参数配置是否合理jmap
查看堆使用情况,确定占用内存过大的对象(jmap -dump:format=b,file=heap pid
、jmap -heap pid
)jstack
查看线程堆栈,确定类、方法调用过程
具体使用细节请查阅 细说JVM 的启动参数(- -X -XX)和 java调优命令 4.1 小节。
5.4.3、调优的两种策略
-
代码优化
集合无限添加元素:
- YGC问题排查,又让我涨姿势了!
单次加载大量数据:
- 一次线上OOM问题分析
资源泄漏:
- 实战案例:记一次dump文件分析历程
- 一次大量 JVM Native 内存泄露的排查分析(64M 问题)
-
JVM参数优化
新生代参数错误导致占用整个堆空间(
-Xms8g -Xmx8g -Xmn8g
):
生产事故-记一次特殊的OOM排查新生代太小:
从实际案例聊聊Java应用的GC优化 案例一 Major GC和Minor GC频繁CMS参数调优,添加
-XX:+CMSScavengeBeforeRemark
参数,用来保证Remark前强制进行一次Minor GC:
从实际案例聊聊Java应用的GC优化 案例二 请求高峰期发生GC,导致服务可用性下降永久代内存震荡:
从实际案例聊聊Java应用的GC优化 案例三 发生Stop-The-World的GC
六、附:GC中各种概念和细节
6.1、浮动垃圾
应用线程和GC线程并发运行,因此不断有新的垃圾产生,而这些垃圾不在这次清理标记的范畴里,无法在本次 GC 被清除掉。
CMS 、G1都会产生;
6.2、卡表(card table)
经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现避免Minor GC时扫描全堆。
卡表的具体策略是将老年代的空间分成大小为512B的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。
举例:并发标记清除(CMS,Concurrent Mark Sweep)就采用了卡表;
6.3、着色指针
-
将对象存活信息存储在指针中;
-
64位地址使用;
-
状态标识:无需访问对象头即可判断对象是否存活、是否需要转移。
-
并发标记与转移:
- 标记阶段:通过标志位区分新旧对象(如Marked0和Marked1交替使用)。
- 转移阶段:Remapped标志指示对象是否已完成转移。
-
自愈(Self-Healing):读屏障检测到旧指针(如未Remapped)时,自动修正为最新地址并更新标志位,后续访问无需再次触发屏障。
// 伪代码:读屏障处理 if (指针标志位 == Marked0 || Marked1) { 触发标记或转移操作; 更新指针为Remapped并修正地址; } return 修正后的地址;
ZGC 使用着色指针,ZGC之前的垃圾收集器(不包括G1)均使用对象头标识(具体细节查阅本文6.7小节)。
- 传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;
- ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。
- 指针压缩与着色指针无法共存
- 着色指针需要使用空闲Bit来存储信息;
- 指针压缩已经把空闲的Bit(低3位)使用了,没有空闲Bit了;
- 实际使用时,ZGC本来就是适用于大堆内存的,超过32GB时,压缩指针也就失效了(具体细节可以查阅Java 引用是4个字节还是8个字节?)。
6.4、读屏障、写屏障
JVM向应用代码插入一小段代码的技术。
-
读屏障
当应用线程从堆中读取对象引用时,就会执行这段代码。
ZGC 使用
-
写屏障
对象引用关系变更前/后插入特定逻辑,破坏漏标条件,就会执行这段代码。
三色标记算法中用于处理漏标场景
6.5、三色标记算法
- 白:对象没有被标记到,标记阶段结束后,会被当做垃圾回收掉。
- 灰:对象被标记了,但是它的field还没有被标记或标记完。
- 黑:对象被标记了,且它的所有field也被标记完了。
用于并发标记存活对象的核心技术,如CMS、G1
多标(浮动垃圾)
- 标记的是存活的对象,所以多标会产生浮动垃圾,下次GC清理了就好。
漏标(存活对象被误删)
标记的是存活的对象,漏标意味着本应该存活的对象被GC了,即存活对象被误删,解法便是通过写屏障处理,具体如下:
- 增量更新:记录黑色对象新增的引用,重新标记时通过写屏障将黑色对象降级为灰色,如CMS;
- 以更长STW换取更少浮动垃圾;
- 原始快照(SATB,Snapshot-At-The-Beginning):记录引用断开前的快照,确保标记期间仍处理旧引用,如G1;
- 以更多浮动垃圾换取更短STW;
6.6、RSet(Remembered Set)
类似卡表,每个Region都有一个RSet,RSet记录了其他Region中的对象引用本Region中对象的关系
G1使用
6.7、对象存活标记位置
-
JDK 8及之前:部分GC(如CMS)仍依赖对象头存储标记,但并发场景需STW
CMS的并发标记阶段可能复用偏向锁标志位(需暂停应用线程保证一致性)
多线程并发标记时需STW保证原子性,无法适应低延迟需求
-
JDK 11+:主流GC(G1/ZGC)已转向外部数据结构,仅对象头保留分代年龄等非并发敏感信息
ZGC使用指针着色技术
G1使用 独立BitMap + Remembered Set
并发标记无需锁定对象头(减少STW)
6.8、Forwarding机制
在 复制式GC(如Copy GC)或标记-整理式GC(如Serial Old) 中,存活对象需被移动到新内存区域以实现内存压缩或分代回收。
复制算法将Eden区存活对象复制到Survivor区,老年代整理时移动动对象到堆起始位置。
对象移动后,所有指向旧地址的引用需更新为新地址,否则后续访问会导致内存错误。
JVM 为此通过 Forwarding 指针来实现,即:在旧对象位置记录新地址,作为引用更新的桥梁。
6.8.1、Forwarding的实现方式
-
基于对象头(Mark Word)
将新地址直接写入旧对象的对象头(mark word),覆盖原有信息(如锁状态、分代年龄)。
-
独立转发表(Forwarding Table)
维护全局哈希表(如ZGC的转发表),记录旧地址到新地址的映射关系。
垃圾收集器 | Forwarding实现 | 备注 |
---|---|---|
Serial/Copy GC/Parallel Scavenge | 对象头写入新地址 | CMS基于标记清理算法,不涉及对象移动,也就不涉及Forwarding机制。 |
ZGC | 独立转发表 | 通过染色指针+读屏障实现并发转发,避免STW; 大堆场景下通过分片转发表优化哈希表结构。 |
G1 | 混合模式(Region内对象头,跨Region转发表) | 并发标记阶段使用写屏障记录跨Region引用。 |
注:多个线程同时修改同一对象的Forwarding指针,需同步机制(如CAS操作)
6.8.2、Forwarding的关键流程
-
移动前准备:
- 确定存活对象,计算新地址并写入Forwarding指针;
- 保存原始对象头。
-
引用更新阶段:
- 遍历修正:GC线程遍历所有对象引用,通过Forwarding指针更新地址;
- 读屏障辅助(如ZGC):应用线程访问旧对象时,自动触发引用修正。
-
内存回收:
- 所有引用更新完成后,旧内存区域可安全释放。
6.9、指针自愈(Self-Healing)
应用线程访问旧对象时,通过读屏障查询转发表自动重定向到新地址,并更新引用。
GC线程移动存活对象,比如CMS并发清理阶段,应用线程也在运行;
6.10、垃圾判断方法
6.10.1、引用计数法
- 给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;
- 当引用失效,计数器就减 1;
- 任何时候计数器为 0 的对象就是垃圾。
引用计数法方法实现简单,实时回收,回收操作分散在程序运行中,无集中式 STW(Stop-The-World),但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题;
循环引用解决方案:
- 可以通过强、弱引用计数结合来解决;
- Recycler算法;
Python、Swift、Objective-C采用引用计数法;
6.10.2、可达性算法
通过 “GC Roots” 作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则此对象是不可达,需要被回收(具体细节查阅 细说 Java 引用(强、软、弱、虚)和 GC 流程(二)1.1 小节)。
Java、C#、Go采用可达性算法;
6.10.2.1、哪些对象能成为 GC Root
- 栈(包括虚拟机栈、本地方法栈)中引用的对象;
- 类的静态属性引用的对象;
- 常量引用的对象;
- 虚拟机内部引用的对象;
6.11、GC Root 如何快速找到
GC Roots 枚举的过程中,是需要暂停用户线程的,对栈进行扫描,找到哪些地方存储了对象的引用。为了避免直接对整个栈进行全量扫描,HotSpot 采取了空间换时间的方法,使用 OopMap (Ordinary Object Pointer Map)
来存储栈上的对象引用的信息。在 GC Roots 枚举时,只需要遍历每个栈桢的 OopMap
,通过 OopMap
存储的信息,快捷地找到栈上的 GC Roots。
6.12、安全点(Safepoint)
前文提到了OopMap
来存储栈上的对象引用的信息,为了避免为每条指令都生成对应的OopMap
造成大量存储空间的浪费,只在“特定的位置”生成对应的OopMap
,这些位置被称为安全点。
JVM会在方法调用、循环跳转、异常跳转等处放置安全点(安全点位置的选择标准是:是否能让程序长时间执行),当GC需要中断线程时,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时不停地主动去轮询这个标志,一旦发现中断标志为True
就自己在最近的安全点上主动中断挂起。
通过
-XX:+PrintSafepointStatistics
可以查阅安全点信息;
通过-XX:+SafepointTimeout
和-XX:SafepointTimeoutDelay=2000
可以进一步看等待哪些线程进入安全点。
6.12.1、进入安全点时机
进入安全点意味着STW,所以什么情况下需要STW呢?
- GC,这个众所周知;
- 采集堆栈信息的命令,如
jstack
、jmap
、jstat
; - 取消偏向锁的时候,需要获取每个线程使用锁的状态以及运行状态;
- 涉及到类重定义,需要修改栈上和这个类相关的信息,如Java Instrument 导致的 Agent 加载以及类的重定义;
- 发生 JIT 编译优化或者去优化时,需要读取线程执行的方法和改变线程执行的方法;
- 定时进入 SafePoint,通过
-XX:GuaranteedSafepointInterval
配置时间,配置为0
时会关闭定时机制;
6.12.2、安全区(Safe Region)
线程到达安全点意味着线程正在执行,如果线程没有执行(线程没有分配到 CPU 片,比如线程处于 Sleep 状态或者 Blocked 状态,具体细节查阅 Java线程状态详解 ),那么线程就无法达到安全点。
其实,想想我们设立安全点的初衷,就是为了避免对象引用关系发生变化,线程没有执行时,天然满足这个条件;
问题是这些没有执行的线程后续一旦有机会执行,还是会改变对象引用关系,此时我们可能正在GC,所有这些线程依然不可以执行。为此我们引入安全区(Safe Region) 的概念,来描述前面的操作。
我们定义线程一旦进入安全区后,后续想离开安全区之前,必须检查是否满足离开条件,比如此时正处在STW 阶段,那就不能离开。
6.12.3、安全点执行的四个阶段
-
Spin阶段
当JVM在决定进入全局安全点t的时候,有的线程在安全点上,而有的线程不在安全点上,这个阶段是等待不在安全点上的应用线程进入安全点。
-
Block阶段
即使进入安全点,用户线程这时候仍然是running状态,保证用户不在继续执行,需要将用户线程阻塞。
-
Cleanup阶段
JVM做一些内部的清理工作。
-
VM Operation阶段
JVM执行的一些全局性工作,例如GC。
6.13、日志解析
6.13.1、GC日志
[GC (Allocation Failure) [ParNew: 1044K->320K(3072K), 0.0006744 secs][Tenured: 4501K->4818K(6848K), 0.0031082 secs] 5140K->4818K(9920K), [Metaspace: 2935K->2935K(1056768K)], 0.0042489 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [Tenured: 4818K->4768K(6848K), 0.0035525 secs] 4818K->4768K(9920K), [Metaspace: 2935K->2935K(1056768K)], 0.0038888 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
-
GC (Allocation Failure)
Young GC原因:内存分配失败
-
Full GC (Allocation Failure)
Full GC原因:内存分配失败
-
[ParNew: 1044K->320K(3072K), 0.0006744 secs]
- ParNew 新生代(结合后面 Tenured,可知垃圾收集器为ParNew)
- Young-GC前新生代使用1044K,GC后使用320K,新生代大小为3072K
- GC时间为0.0006744 secs
-
[Tenured: 4501K->4818K(6848K), 0.0031082 secs]
- Tenured 老年代
- Young-GC前老年代使用4501K,GC后4818K,老年代大小为6848K
- GC时间为0.0031082 secs
- 老年代使用增加,应该是新生代发生晋升了
-
5140K->4818K(9920K)
- Young-GC前堆使用5140K,GC后使用4818K,堆大小为9920K
-
[Metaspace: 2935K->2935K(1056768K)], 0.0042489 secs]
- Young-GC前元空间使用2935K,GC后使用2935K,元空间大小为1056768K,花费0.0042489 secs
-
[Times: user=0.00 sys=0.00, real=0.00 secs]
CPU花费时间:
- 用户态0.00secs(多线程总和,若GC线程数为4,且每个线程运行0.002秒,则user=0.008秒)
- 内核态0.00secs
- GC事件从开始到结束的实际耗时0.00 secs(墙钟时间),包含等待和阻塞
场景 | User/Sys/Real关系 | 典型原因 |
---|---|---|
user+sys > real | 多线程并行GC(如ParNew、G1) | 并行回收加速(CPU核数充足) |
user+sys ≈ real | 单线程GC(如Serial) | 单线程串行处理无并发加速 |
user+sys < real | 系统资源竞争(IO/CPU等待) | 磁盘IO阻塞、进程调度延迟 |
6.13.2、堆日志
Heap
par new generation total 3072K, used 82K [0x00000000ff600000, 0x00000000ff950000, 0x00000000ff950000)
eden space 2752K, 3% used [0x00000000ff600000, 0x00000000ff614bc8, 0x00000000ff8b0000)
from space 320K, 0% used [0x00000000ff900000, 0x00000000ff900000, 0x00000000ff950000)
to space 320K, 0% used [0x00000000ff8b0000, 0x00000000ff8b0000, 0x00000000ff900000)
-
par new generation total 3072K, used 82K
新生代使用ParNew垃圾收集器,大小为 3072K,已使用82K;
-
[0x00000000ff600000, 0x00000000ff950000, 0x00000000ff950000)
内存使用起始地址,内存使用结束地址,内存最大结束地址