读完这篇,你能做什么
能阅读连续 GC 日志,区分正常回收、分配压力与回收后存活集增长。
学习理由、准备要求与知识点
为什么学它
GC 调整应服务于延迟、吞吐和内存目标,日志比单个参数或单次停顿更有解释力。
先读这些内容
知识点清单
- 可达性
- 分配速率
- 存活集
- 回收器目标
- 统一 GC 日志
- 停顿
- 吞吐
- 容器内存预算
运行前先确认JDK17 HotSpot与G1文档;ReferenceQueue源码固定17.0.12+7。标记算法是静止强引用图模型,不实现真实GC、对象移动、并发屏障或弱引用处理。
01先弄懂它是什么
GC 能不能回收一个对象,关键看运行中的程序是否还可以从有效起点找到它,而不是业务是否已经用完。对象互相引用成环,只要从根出发都找不到,仍可能回收;反过来,旧对象如果还被静态缓存或活线程保留,GC 不知道你已经不需要它,不能替你按业务意思删除。
回收既要找出还活着的对象,也要腾出可再次分配的空间。标记清除不移动存活对象,但可能留下零散空隙;复制和压缩会整理空间,同时要移动对象并调整引用。并发回收让部分工作与应用一起进行,但仍需处理引用变化,也常有暂停阶段。选收集器要一起考虑处理量、延迟、剩余内存和实现成本。
GC 日志能告诉你回收发生了什么,但请求慢还可能来自数据库、锁、线程调度或网络。要判断 GC 影响了多少,应把暂停时刻与慢请求及其他指标对起来。调参数前,先看对象每秒新建多少、回收后还留下多少、进入老年代的情况,以及暂停通常多长、最长有多长。只看到内存上涨,还不足以决定该加大堆或更换收集器。
文中这几个词,先认一认
- GC Roots
- JVM 判断可达性时开始查找的根来源,包括运行时规定的线程、类和原生引用等。
- 存活集 Live Set
- 在当前观察或回收规则下,从根仍然可以找到的对象集合。
- 停顿 STW
- 回收某些阶段让应用线程暂时停下,以完成需要一致状态的工作。
- 分配速率
- 每单位时间新建对象占用多少字节,与这些对象最终长期留下多少不是同一指标。
02看看它怎么工作
先从根出发沿强引用遍历,标记可达对象,再把未标记对象视为候选可回收集合。简单引用计数难以识别纯环,追踪式可达性可以处理。真实GC还要区分软、弱、虚引用与其他运行时约束,因此学习模型先固定所有边都是强引用,防止把特殊引用语义混入基础证明。
G1 把堆分成许多 Region,也就是小块区域,每个阶段选择其中一部分来回收,并在应用继续运行时标记老年代中还活着的对象。若一块区域被其他区域里的对象引用,记忆集等记录能帮助找到这些跨区域引用,减少局部回收时扫描整个堆的需要。应用同时还会修改引用,因此并发标记要配合屏障等记录或维护动作。配置的暂停目标只是优化方向,不能保证每次都不超时;活着的数据和新对象的压力都会影响结果。
JDK17可先创建日志目录,再在启动参数加入 -Xlog:gc*,safepoint:file=logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20M。gc*包含带gc及其他标签的日志,日志轮转限制磁盘占用。阅读时按GC编号关联阶段,区分暂停与并发耗时,查看回收前后使用量、堆容量、Region变化与CPU时间。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart TD
R["根集合"] --> A["标记可达对象"]
A --> E["沿引用展开未扫描对象"]
E --> A
A --> F["没有新的可达对象"]
F --> L["保留已标记存活对象"]
F --> D["未标记对象成为回收候选"]
D --> C["清除、复制或压缩等空间回收策略"]
W["应用并发修改引用"] -.-> B["真实并发GC需要屏障与额外协议"]
B -.-> A按顺序看,每一步做了什么
- 确认收集器、JDK版本、堆配置与容器资源限制。
- 开启有轮转的GC与必要安全点日志,保留时间关联。
- 区分分配峰值、存活集增长、晋升压力和暂停阶段。
- 结合业务延迟与CPU判断是否由GC主导。
- 每次只调整有证据支持的关键变量,并用相同负载复测。
03对照实现代码
OpenJDK17.0.12+7 · ReferenceQueue#enqueue / reallyPoll / remove
原创Java队列消费模型。ReferenceQueue保存引用对象的通知,GC可达性发现本身在VM中;此代码不承诺GC触发时间,也不替代资源close。
import java.lang.ref.*;
import java.util.Set;
final class ReferenceCleanupModel {
static int drain(ReferenceQueue<Object> queue, Set<Reference<?>> tracked) {
int count=0;
Reference<?> reference;
while((reference=queue.poll())!=null) { // 非阻塞地取得已经入队的引用对象。
tracked.remove(reference); // 清除业务登记,不再保留处理完的引用包装。
count++;
}
return count; // 空队列只是暂时无通知,不证明没有可回收对象。
}
}这里查看的 ReferenceQueue 实现用私有 lock 保护入队、出队时的修改,head 声明为 volatile,并用特殊标记区分尚未关联队列、已经入队等状态。enqueue 会先检查状态,避免同一个引用重复入队。一次入队会修改不止一个字段,还要配合并发检查和 poll 的快速检查路径,因此关键不只是“每个字段能被看见”,还要让这些字段一起表示正确的队列状态。
reallyPoll必须持锁,取出头节点后更新引用队列状态与链指针。内部使用自环表示链尾等状态,不能把它误认为业务对象引用环导致内存泄漏。阅读容器源码时应先确定每个链接字段指向的是引用包装还是被引用对象,两者生命周期不同。
带超时的 remove 会反复等待,并重新计算还剩多少时间,避免每次醒来又等满一轮;poll 则不等待,直接取当前已有的引用通知。还要注意:给 Reference 指定了一个队列,不代表这个队列会一直替你保存 Reference 包装对象。若以后还要接收它的通知,程序通常需要在这段时间里保留包装对象本身,让它仍然可达。
弱引用和Cleaner适合特定清理协作,但资源关闭仍应优先用显式close与try-with-resources。GC发现对象不再可达、引用入队、清理动作执行和操作系统资源释放可能处于不同时间点。单元测试不应要求System.gc调用后立刻收到通知,因为触发与处理时机没有这种保证。
代码先调用谁,再调用谁
- 业务创建Reference并登记目标
ReferenceQueue - VM或显式引用操作按相应协议形成入队条件
- Reference内部入口调用队列enqueue
- Java处理方poll或remove获得引用包装
- 清理登记或后处理,独立资源仍遵守显式关闭协议
04动手写一个例子
静止对象图的标记与回收候选计算
对象编号0到n-1,给定非负对象大小、强引用边和根列表,返回不可达对象编号及其总字节数。允许环、共享子对象和重复根;边或根越界拒绝,总大小溢出报错。
- 输入 1
大小[10,20,30,40];边0→1、1→0、2→3、3→2;根[0]- 输出 1
不可达[2,3],候选字节70
- 输入 2
根为空,所有边只在对象间相连- 输出 2
全部对象不可达,即使内部有环
从每个根开始入队,入队时立即标记,防止多个父对象重复安排同一节点。每次取出一个对象,遍历它的强引用目标,未标记目标继续入队。环会遇到已经标记节点而停止展开,所以不会无限循环,共享子对象也只计算一次。
队列清空时,所有从根可达的节点均已标记。证明分两面:被标记节点要么是根,要么由已可达节点的边到达,所以没有误标不可达对象;任意根到目标路径可按长度归纳,路径上的每个节点最终都会被展开,所以不会漏掉可达对象。
最后按编号扫描未标记对象,累加候选大小并返回列表。使用Math.addExact暴露溢出,避免总字节绕成负数。整个过程只处理数据模型,不实际释放Java对象,也不把对象大小相加等同于真实回收器马上能提供的连续可用空间。
为什么这样写是对的已标记对象都从根可达,队列包含已发现但尚未展开的对象;每个对象最多入队一次。
V对象、E引用、R根;邻接表保存E条边,标记与队列O(V),最终扫描V对象,输出包含在同阶空间内。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
record Candidates(List<Integer> objects,long bytes) {}
static Candidates mark(long[] sizes,int[][] edges,int[] roots) {
Objects.requireNonNull(sizes); Objects.requireNonNull(edges); Objects.requireNonNull(roots);
int n=sizes.length; List<List<Integer>> graph=new ArrayList<>();
for(long size:sizes) { if(size<0) throw new IllegalArgumentException("size"); graph.add(new ArrayList<>()); }
for(int[] edge:edges) {
if(edge==null || edge.length!=2 || edge[0]<0 || edge[0]>=n || edge[1]<0 || edge[1]>=n)
throw new IllegalArgumentException("edge");
graph.get(edge[0]).add(edge[1]);
}
boolean[] live=new boolean[n]; ArrayDeque<Integer> queue=new ArrayDeque<>();
for(int root:roots) {
if(root<0 || root>=n) throw new IllegalArgumentException("root");
if(!live[root]) { live[root]=true; queue.add(root); } // 入队即标记,重复根不重复安排。
}
while(!queue.isEmpty()) {
int node=queue.remove();
for(int target:graph.get(node)) if(!live[target]) {
live[target]=true; queue.add(target); // 环与共享引用通过标记去重。
}
}
List<Integer> dead=new ArrayList<>(); long bytes=0;
for(int i=0;i<n;i++) if(!live[i]) {
dead.add(i); bytes=Math.addExact(bytes,sizes[i]); // 候选字节溢出时显式失败。
}
return new Candidates(List.copyOf(dead),bytes);
}
static void check(boolean ok) { if(!ok) throw new AssertionError(); }
public static void main(String[] args) {
int[][] graph={{0,1},{1,0},{2,3},{3,2}};
check(mark(new long[]{10,20,30,40},graph,new int[]{0}).equals(new Candidates(List.of(2,3),70)));
check(mark(new long[]{1,2},new int[][]{{0,1},{1,0}},new int[0]).bytes()==3);
check(mark(new long[]{1,2},new int[][]{{0,1}},new int[]{0,0}).objects().isEmpty());
check(mark(new long[0],new int[0][],new int[0]).bytes()==0);
check(mark(new long[]{0},new int[0][],new int[0]).objects().equals(List.of(0)));
try { mark(new long[]{Long.MAX_VALUE,1},new int[0][],new int[0]); throw new AssertionError(); }
catch(ArithmeticException expected) { }
try { mark(new long[]{1},new int[0][],new int[]{1}); throw new AssertionError(); }
catch(IllegalArgumentException expected) { }
System.out.println("PASS 7");
}
}这些特殊情况也要试一试
- 无根时内部引用环也不可达。
- 重复根和共享子对象不重复展开。
- 零字节对象仍保留独立对象身份。
- 非法引用与总大小溢出明确报错。
进阶选读 · 并发标记中的三色不变量与屏障
场景收集器已经扫描某对象后,应用又改变引用,静止图遍历可能漏掉仍然需要保留的对象。
做法学习白灰黑三色模型,再比较SATB记录被覆盖旧引用与增量更新维护新引用关系的思路。G1并发标记采用SATB相关机制,而跨Region记忆集维护解决另一类定位问题,不能把两者混为同一屏障职责。
复杂度基础遍历O(V+E),并发屏障额外成本随引用写入与日志缓冲处理量增长;真实成本取决于收集器与负载。
好处与代价并发阶段降低部分暂停压力,却把工作分散到应用运行期并需要更多元数据和余量;纯暂停模型简单但集中停顿更明显。
05项目里怎么用
读 GC 日志时,先看一段时间内发生了什么。频繁的小暂停可能拖低总处理量,少数长暂停则可能让最慢的一部分请求等很久。程序分配对象很快,但每次回收后占用都回到稳定水平,与回收后占用持续上涨是两种情况。日志里的使用量和括号中的堆容量也要分开看:堆有多大,不等于其中还活着的对象有多大;具体格式以对应 JDK 版本为准。
对G1重点观察年轻代回收、并发标记、混合回收及大对象相关Region变化,结合CPU与堆余量寻找原因。应用分配突增、长期存活缓存或大数组可能比收集器参数更值得先处理。暂停目标调得更小也可能增加其他开销,不能只优化一个数字而忽略吞吐和内存。
收集器选型应使用相同业务负载、JDK版本与资源限制比较,记录吞吐、p95/p99延迟、CPU、RSS和回收后占用。JDK17与更新版本的ZGC等实现能力不同,发行版也可能影响可用选项;先核对目标环境支持,再做有明确假设的实验。
容易踩的坑
- 用引用计数直觉判断环一定无法回收。
- 把
System.gc当立即完成回收的命令。 - 只看堆使用峰值,不看回收后存活趋势。
- 将G1暂停目标当硬实时保证。
- 把所有安全点暂停都归因于同一次GC。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 标记清除思路 | 无需搬移所有存活对象 | 可能留下碎片 | 理解基础追踪式回收 |
| 复制或压缩思路 | 整理连续空间与局部性 | 移动与引用更新需要成本 | 需要控制碎片的回收设计 |
| 并发回收思路 | 把部分工作分散到应用运行期间 | 屏障、元数据与CPU余量成本 | 重视尾延迟的负载 |
| 显式资源close | 释放时机可控 | 必须管理生命周期 | 文件、连接与其他外部资源 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01对象互相引用为什么仍可能被GC回收?
展开参考答案与推导
追踪式分析从根判断可达性,内部成环但没有根路径的对象仍不可达。
02并发GC为什么需要屏障?
展开参考答案与推导
遍历期间应用会修改引用,静止图假设失效,需要记录或维护关系以避免遗漏应该保留的对象。
03GC日志能证明请求慢完全由GC造成吗?
展开参考答案与推导
不能,需要把暂停与请求时间以及其他依赖信号关联,日志只提供回收活动证据。
换个情况,还会处理吗
01堆呈锯齿但每次回收后基线稳定,是泄漏吗?
展开参考答案与推导
未必,可能是正常分配与回收;应结合负载、存活集趋势和引用保留确认,而不是只看峰值。
02换收集器后暂停减少但CPU升高,如何评价?
展开参考答案与推导
按业务SLO同时比较吞吐、尾延迟、CPU与内存余量,明确资源代价是否可接受。
03弱引用清理测试偶尔超时,怎么改?
展开参考答案与推导
不要断言GC必须在固定短时间发生;把队列消费逻辑与GC触发分离,采用显式入队验证处理流程,并单独说明GC时机不可控。
写代码试一试
01实现对象图标记。
- 输入 / 条件
0↔1,2↔3,根0- 输出 / 预期
不可达2、3
提示 入队时就标记。
展开参考答案与推导
按正文BFS,每节点最多展开一次,候选大小精确累加。
02统计GC暂停占观察窗口比例。
- 输入 / 条件
窗口1000ms,暂停10、20、30ms且不重叠- 输出 / 预期
6%
提示 统一单位并验证窗口与事件范围。
展开参考答案与推导
先过滤真实暂停阶段,必要时合并重叠区间,再除以观察时长,不能把并发阶段直接相加。
03为引用队列清理写确定性测试。
- 输入 / 条件
保留WeakReference包装并显式enqueue- 输出 / 预期
drain处理一次,再次为空
提示 不要依赖System.gc时间。
展开参考答案与推导
验证登记移除、重复处理和空队列;说明显式入队只测试消费协议,不证明GC发现行为。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能阅读连续 GC 日志,区分正常回收、分配压力与回收后存活集增长。
本篇重点加练
存活集与停顿必须放在完整观察窗口里理解,不能把短期曲线直接解释成泄漏或服务延迟。
- 存活集
- 停顿
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
请求不断创建短命缓冲区,同时缓存保留一批长期对象。用两个独立变量描述分配速度与仍被引用的数据量,并解释各自的测量方式。
- 提交内容
- 对象生命周期图与指标定义
- 怎样算完成
- 区分累计分配和当前存活
- 包含清除引用后的观察边界
练到的知识点可达性 · 分配速率
- 作业 02设计评审
批处理工具优先吞吐,交互 API 优先尾延迟,两者存活数据规模不同。制定收集器评估目标与约束,避免只按暂停时间排序。
- 提交内容
- 评估矩阵
- 怎样算完成
- 列出堆和 CPU 预算
- 明确服务目标而非只列收集器名
练到的知识点存活集 · 回收器目标
- 作业 03原理问答
两个进程当前堆占用相同,一者多数对象即将不可达,另一者多数由缓存长期持有。列出区分其存活集的观察方法与成本。
- 提交内容
- 证据及解释边界表
- 怎样算完成
- 区分当前占用和回收后可见存活
- 不能要求无代价精确测量
练到的知识点存活集
综合应用
- 作业 04排错实验
以小堆和有上限的对象分配运行本地演示,采集统一 GC 日志,按时间线标注回收事件、暂停及实验阶段。
- 提交内容
- 命令、日志片段和事件表
- 怎样算完成
- 注明 JDK 与日志参数
- 实验有时长和内存上限
练到的知识点统一 GC 日志 · 停顿
- 作业 05代码推演
容器预算为 512
MiB,应用还需要线程栈、元数据与其他本地内存。建立预算表,并讨论提高 Java 堆对吞吐与容器退出风险的影响。- 提交内容
- 内存预算与测量清单
- 怎样算完成
- 每项区分估计和实测
- 不将容器上限等同于堆上限
练到的知识点吞吐 · 容器内存预算
- 作业 06编程实践
写本地对象分配实验,分别控制“高分配低保留”和“低分配高保留”,记录操作吞吐与 GC 事件,重复运行对照。
- 提交内容
- 源码、参数及对照报告
- 怎样算完成
- 固定总工作量和采样窗口
- 说明日志证据不足以推断的因果关系
练到的知识点可达性 · 分配速率 · 存活集 · 统一 GC 日志 · 停顿 · 吞吐
- 作业 07代码推演
请求耗时窗口与多次 GC 事件部分重叠,日志还包含并发阶段。制定停顿归因规则,说明哪些时间不能直接相加到请求延迟。
- 提交内容
- 重叠时间图与归因口径
- 怎样算完成
- 区分并发阶段和暂停
- 注明时钟与窗口对齐方式
练到的知识点停顿
- 作业 08编程实践
写本地小型 GC 日志分析器,读取脱敏事件表与请求窗口,输出重叠停顿及回收后占用趋势;加入缺失和截断日志。
- 提交内容
- 程序与分析报告
- 怎样算完成
- 未知区间明确显示
- 不把趋势报告当成泄漏定位结果
练到的知识点存活集 · 停顿
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
上线后 P99 变差同时 GC 暂停增加,数据库延迟也上升。制定能区分因素的实验,并说明每次只改一个参数时如何保持业务负载可比。
- 提交内容
- 假设与对照实验计划
- 怎样算完成
- 包含非 GC 的竞争解释
- 预先定义停止及回退条件
练到的知识点存活集 · 回收器目标 · 统一 GC 日志 · 停顿 · 吞吐 · 容器内存预算
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 开放探究
研究“更小的最大暂停时间一定改善交互体验”是否成立,设计同时改变请求队列与分配速率的本地小规模实验。
- 提交内容
- 假设与对照设计
- 怎样算完成
- 包含吞吐及排队指标
- 不以一次观测肯定普遍命题
练到的知识点存活集 · 停顿
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读实践指南英文
Java 17:Garbage Collection Tuning (新标签页)↗
阅读吞吐、延迟与占用的目标,依据日志而非固定参数调整 GC。
- 实践参考文档英文
Java 17:java launcher (新标签页)↗
阅读堆参数、统一日志和诊断选项,在隔离进程复现运行配置。
- 深入参考文档英文
Java 17:jcmd (新标签页)↗
阅读 VM、Thread、GC 与 JFR 命令及影响提示,按证据需求选择诊断操作。
- 深入参考文档英文
JDK17 G1原理 (新标签页)↗
理解Region、并发标记与暂停目标边界。
- 深入参考文档英文
JDK17 GC日志解释 (新标签页)↗
核对回收前后占用、阶段与日志版本差异。
- 深入参考文档英文
ReferenceQueue固定源码 (新标签页)↗
连接Java引用通知与GC发现过程。
- 深入参考文档英文
JDK17引用类型 (新标签页)↗
区分强、软、弱与虚引用的可达性语义。