对象分配、GC Roots 与安全点
对象从创建到回收,会经历分配、初始化、被其他对象引用,以及最终失去可达路径。垃圾收集器据此识别存活对象;需要检查或修改全局运行状态时,JVM 还会协调线程进入安全状态。
对象:创建 → 建立引用 → 业务使用 → 移除持有关系 → 等待适用的回收阶段
线程:运行 → 响应安全点请求 → 等待 VM 操作完成 → 继续运行对象已经超过业务生命周期,却仍被静态集合或线程任务引用,会形成无效保留。一次暂停过长,则需要拆开线程汇合与 VM 操作耗时。这两类问题使用不同的观测工具。
可达性与安全状态
对象保留和线程暂停可以分别从以下位置开始检查:
| 现象 | 真正的问题 | 第一份证据 | 不能据此下的结论 |
|---|---|---|---|
| 关闭缓存后存活集仍不下降 | 谁仍能从 Root 到达这些对象 | Path to GC Roots、dominator、retained size | “对象多”就等于泄漏 |
| GC 事件很短,应用暂停却很长 | 时间花在线程汇合还是安全点内操作 | Java 17 safepoint 与对应 GC 日志 | “发生 safepoint”就等于 Full GC |
Java 程序把某个局部变量设为 null,只删除了这一条边;静态集合、线程任务、JNI handle 或其他对象仍可能沿另一条边持有目标。同理,给目标再套一个 WeakReference 也不会把别处的强引用变弱。
安全点解决的是另一个约束。某些 VM operation 需要各线程处于 JVM 能安全检查栈、引用或代码状态的位置。请求发出后,要先等相关线程进入安全状态,再执行操作,最后才恢复运行。因此同一次 GC 附近可以同时暴露保留问题与停顿问题,但 heap dump 不能解释线程为何迟到,safepoint 日志也不能告诉你谁持有某个业务对象。
后文统一使用 64 位 Temurin 17.0.20+8 的真实日志格式。更换发行商、JDK 大版本或垃圾收集器后,机制判断仍适用,具体日志字段和行为必须重新验证。
对象分配与 TLAB
源码里的 new 表达了“创建一个具有对象语义的实例”,却不能反推出最终机器码一定保留一次真实堆分配。解释执行或未消除分配的编译代码需要物化对象;JIT 若证明对象不逃逸,可能用标量值替代对象,反优化时也可能重新物化。逃逸分析发生在编译优化阶段,不是对象完成构造后再决定是否把它从堆里拿走。
对需要物化的普通对象,HotSpot 常优先从当前线程的 TLAB 中移动指针取得空间。TLAB 让大多数线程不必为每个小对象争用共享分配位置;空间不足时可能申请新的 TLAB、在 TLAB 外分配,或进入与收集器和堆状态有关的慢路径。一次 TLAB miss 不等于“立刻进入老年代”,更不等于发生泄漏。
TLAB 是线程独占使用的一段堆空间,并未把对象移出共享堆。典型快路径检查 top 加上对齐后的对象大小是否越过 end,成功后推进 top;新对象还需要获得默认零值语义、安装对象头,并执行构造器初始化。实现可能提前清零整段内存,不能把这些动作一一对应成固定机器指令。
TLAB 内:已分配区域 | top → 可分配区域 → end
└─ 本次对象大小(含对齐)能否放下?
够用 → 推进 top,完成必要的初始化
不足 → refill / TLAB 外分配 / 收集器慢路径对象的空间已经取得,不代表构造器已正常返回。构造失败、构造期间 this 逃逸、跨线程发布与字段可见性属于不同阶段;安全发布应遵守Java 内存模型。
观察分配时还需要区分:
allocation rate:单位时间创建了多少对象
live set:一次可达性判定后仍存活多少对象
retained set:某个 owner 被移除时可能随之释放的对象图高 allocation rate 可能只是短命临时对象;低分配速率也可能因为一个长期 Root 持续积累而泄漏。used heap 上涨同样不能单独区分“正在分配”与“回收后仍存活”。TLAB 日志只证明分配快路径的使用情况,不证明对象寿命,更不能替代 Root path。
公开实验会在 -XX:+UseTLAB 和 -XX:-UseTLAB 下执行相同业务计算:两次语义结果必须一致,只有分配路径和日志不同。JIT 如何决定消除分配由《JIT、逃逸分析与 Code Cache》继续展开;年轻代、晋升和收集器慢路径由《GC 收集器与日志》负责。
GC Roots 与引用类型
把对象想成图中的节点,把字段、数组元素、局部变量和 native handle 想成有方向的边。垃圾收集器从一组 Roots 出发追踪引用;沿强引用边仍能到达的对象不会因为“业务上已经不用”就自动回收。
排查业务保留时,常见入口包括:
| Root 或所有权入口 | 典型保留链 | 应验证什么 |
|---|---|---|
| 活跃线程的栈与任务 | worker → task → request context → payload | 任务是否结束,ThreadLocal 是否按所有路径清理 |
| 静态字段 | class → static registry → entry → payload | 缓存关闭是否真的移除 entry 和 listener |
| JNI local/global handle | native library → JNI handle → Java object | handle 是否按约定释放,生命周期是否跨请求 |
| JVM 内部 handle 或同步状态 | VM/runtime structure → object | 当前工具展示的具体 Root 类型与持有原因 |
类加载器故障要画出外部 Root 到 loader、Class 或实例的正向路径;“类有静态字段”本身不会从图的另一端反向创造 Root。旧 loader、TCCL 与注册表清理已经在《类加载器与委派边界》中展开,这里不重复 Metaspace 和类卸载。
Java 17 java.lang.ref区分了以下几种可达性:
| 引用 | 语义边界 | 工程含义 |
|---|---|---|
| 强引用 | 普通可达路径存在时保留 referent | 首先找完整强 Root path |
SoftReference | GC 可按内存需求清除 softly reachable 对象 | 清除时机和命中率不可作为业务承诺 |
WeakReference | GC 发现 weakly reachable 后会原子清除相关弱引用 | 它不削弱另外一条强引用路径 |
PhantomReference | get() 永远返回 null,配合队列观察清理阶段 | native 资源仍应优先显式 close |
注册到 ReferenceQueue 后仍要保留观察对象:队列不会替应用强持有已注册的 Reference 对象,所以观察者本身必须继续可达。引用被清除与入队之间也不存在可写进 SLA 的固定时限。
Cleaner在对象达到虚可达状态后安排清理动作,也允许通过 Cleanable.clean 显式执行。资源有明确生命周期时,仍优先在 close 中释放。清理动作必须只持有独立的清理状态,不能捕获被观察对象的 this,否则它会反过来保活目标。动作还应尽快返回,避免阻塞同一 Cleaner 的后续清理。
WeakHashMap 的弱 key 同样不是免泄漏证明。如果 map → value → key 存在强回指,map 这个 Root 仍能绕过弱 key 到达 key。真正的问题始终是完整路径,而不是某一条边的名字。
对象数量、所有权和释放收益要用不同视角回答:
| 证据 | 能回答什么 | 不能回答什么 |
|---|---|---|
| class histogram | 某时刻各类型的实例数和浅大小方向 | 哪个 owner 使对象长期可达 |
| Path to GC Roots | 从目标回溯到 Root 的具体持有链 | 移除一个 owner 总共能释放多少 |
| dominator tree | 哪个节点支配后续对象图 | 业务上是否允许删除这个 owner |
| retained size | owner 消失时可能随之释放的近似对象图大小 | 精确 RSS 或容器内存下降量 |
heap dump 可能造成停顿、磁盘压力并包含敏感数据;GC.class_histogram 在 JDK 17 jcmd中也标为高影响。先用低成本趋势缩小范围,再在受控窗口取证,而不是把它们当作每次告警的第一条命令。
安全点与线程局部握手
全局安全点可以按四个时刻理解:
t0 发出 safepoint request
└─ Reaching safepoint:等待相关线程进入安全状态
t1 所有相关线程已安全
└─ Cleanup:HotSpot 的安全点清理工作
t2 cleanup 完成
└─ At safepoint:执行本次 VM operation 的主要窗口
t3 安全点结束,线程重新运行
Total:从 t0 到 t3 的总时间,等于上述三个分段字段之和执行编译代码的线程通常通过 safepoint poll 响应;解释器、阻塞状态和 native 状态有各自的协作方式。处于某些阻塞或 native 状态的线程可能已经被视为安全,并非每个线程都必须跑到同一种字节码位置。调度延迟、页错误或某些难以及时响应的执行路径可能放大 reaching 时间。看到“线程在 native”不能直接判定它就是迟到者;在 JDK 17 中,JNI critical 区间还可能通过 GCLocker 影响部分 GC 的启动,需要另查 GC 日志。JDK 22 起 G1 引入 region pinning,处理方式发生变化,见 JDK 22 官方变更说明。不能把旧版 GCLocker 路径套到所有版本和收集器。
全局安全点要求相关 Java 线程共同进入安全状态。线程局部握手允许 JVM 只与指定线程协调部分工作,具体操作能否使用它由实现决定。因此,一次线程栈采集或其他运行时检查未必都对应一条全局 safepoint 日志。可从 OpenJDK Handshake 实现查看局部协调接口。
Java 17 可以在启动时同时记录 safepoint 与 GC。下列参数附加到服务启动命令中;运行账号必须能写入目标日志目录:
-Xlog:safepoint=info,gc*=info:file=/var/log/myapp/runtime.log:time,uptime,level,tags:filecount=8,filesize=32mJDK 17 java 命令文档说明了 unified logging 的标签、级别与文件轮转语法。gc* 会匹配带 gc 的全部 tag set;safepoint 是另一条观察轴。先按时间戳对齐同一窗口,再比较字段:
| 日志形态 | 判断方向 |
|---|---|
Reaching safepoint 高 | 找哪类线程迟到,并同时检查 CPU 饱和、系统调度、缺页和线程状态转换 |
Cleanup 高 | 查看 safepoint+cleanup 明细,定位符号表、字典、inline cache 或 lazy roots 等实际 task |
At safepoint 高 | 确认 safepoint 行中的 operation 名称,再检查对应的 GC、反优化、类卸载或诊断操作 |
GC 事件很短但 Total 很长 | 先比较 reaching、cleanup、at 三段,不能直接归因于收集器或线程汇合 |
| 没有 GC 行却出现 safepoint | 并不矛盾,safepoint 不等于 Full GC |
日志没有独立的“线程恢复耗时”字段,因此不能凭空把 Total - At safepoint 全叫作 resume。需要更深的线程、JFR、heap dump 和线上权限设计时,再进入《JVM 诊断》。
分步验证保留、释放和分配
下载完整实验包,在 Linux amd64/x86_64 的 Bash 中以普通用户解压,进入 allocation-roots-safepoint。设置实际 JDK 路径后编译探针源码:
export JDK17_HOME=/opt/jdk-17
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
"$JDK17_HOME/bin/java" -version
LAB_OUT=$(mktemp -d /tmp/allocation-roots-safepoint.XXXXXX)
"$JDK17_HOME/bin/javac" --release 17 -Xlint:all -Werror \
-d "$LAB_OUT" AllocationRootsSafepointProbe.java版本应显示 Temurin 17.0.20+8,编译成功时没有错误输出。以下使用 -Xint 避免编译器消除分配,以 Serial GC 和固定小堆建立可读日志。
保留对象,再移除持有关系
"$JDK17_HOME/bin/java" -Xint -Xms32m -Xmx32m -XX:+UseSerialGC \
'-Xlog:safepoint=info:stderr:none' -cp "$LAB_OUT" \
example.roots.AllocationRootsSafepointProbe root-retained \
2>"$LAB_OUT/safepoint.log"
cat "$LAB_OUT/safepoint.log"
"$JDK17_HOME/bin/java" -Xint -Xms32m -Xmx32m -XX:+UseSerialGC \
-cp "$LAB_OUT" example.roots.AllocationRootsSafepointProbe root-released保留模式输出 root-size=1、weak-refers-to-root=true、queue-empty=true;释放模式输出 root-size=0、observer-enqueued=true、weak-cleared=true。若后一模式在有界尝试内失败,先核对 JDK、收集器和外部启动参数,保留结果继续检查引用链,不能无限循环等到“成功”为止。
实验先让静态 ROOT 通过 List 持有 4 MiB payload,同时由一个被强持有的 WeakReference 观察。Root 未清除时,显式 GC 后仍必须满足 owner 存在、weak 可见、queue 为空;这是一条确定性反断言。
随后清除 ROOT,在固定 SerialGC 与有界压力下观察同一个弱引用。本次环境会要求 referent 变为 null 且 reference 入队;这个结果证明实验确实走过释放路径,但 README 明确不把有限轮次和时限推广成所有 JVM 的规范承诺。
比较 TLAB 开关
"$JDK17_HOME/bin/java" -Xint -Xms32m -Xmx32m -XX:+UseSerialGC \
-XX:+UseTLAB '-Xlog:gc+tlab=debug:stderr:none' \
-cp "$LAB_OUT" example.roots.AllocationRootsSafepointProbe allocation \
2>"$LAB_OUT/tlab.log"
cat "$LAB_OUT/tlab.log"
"$JDK17_HOME/bin/java" -Xint -Xms32m -Xmx32m -XX:+UseSerialGC \
-XX:-UseTLAB -cp "$LAB_OUT" \
example.roots.AllocationRootsSafepointProbe allocation两次都会完成 10000 次、每次 128 字节的数组分配,checksum=1274888;use-tlab 分别为 true 和 false。启用模式的日志中应找到 TLAB totals,其中 refills 表示重新取得 TLAB 的次数,waste 是统计窗口中的浪费比例。此实验用于比较路径和计算结果,不作为吞吐基准。
前面的 safepoint.log 应有 GenCollectFull,以及 Reaching safepoint、Cleanup、At safepoint、Total 四个字段。它们的具体纳秒值由当时运行环境决定。
自动复核与清理
实验包的 run.sh 会重新编译并检查全部模式:
bash run.sh成功输出以实际观察值收尾:
root-retained=PASS
root-released-fixed-hotspot-observation=PASS
tlab-enabled-accounting=PASS
tlab-disabled-same-semantics=PASS
safepoint-jdk17-fields=PASS
PASS allocation-roots-safepointSystem.gc() 在这里是制造固定实验事件的手段,不是生产释放方案。若生产必须依靠它才能压低存活集,真正的 owner 生命周期仍未修好。
保留需要的日志后清理本次临时目录:
case "$LAB_OUT" in
/tmp/allocation-roots-safepoint.*) rm -rf -- "$LAB_OUT" ;;
*) printf '拒绝清理未知路径\n' >&2 ;;
esac定位长期保留与过长暂停
对象长期保留时,先固定相同业务负载和观察窗口,确认 GC 后存活基线持续上升;再从异常对象查到完整 Root path,判断 owner 为什么超出业务生命周期。临时止损可以限制缓存条目、拒绝新任务或滚动隔离实例,永久修复则应移除注册、清理 ThreadLocal、结束任务、释放 JNI handle,或切断 value 到 weak key 的回指。修复后重放相同负载,要求 Root path 消失、owner 数量受控且 GC 后存活基线不再爬升。
暂停过长时,保留同一窗口的 safepoint、GC、CPU、调度和缺页证据。reaching 高就定位迟到线程及其系统状态,cleanup 高就展开具体 cleanup task,at safepoint 高就回到实际 VM operation;三段都不高则不要继续把延迟归因于安全点。修复后要在相同吞吐和数据规模下比较对应字段的分布,而不是只挑一条更快的日志。
扩大 heap 可能推迟收集,却不会删除 Root path;换收集器可能改变 GC phase,却不会让一个被调度饿死的线程更快响应 safepoint。复测分别检查对象持有链与暂停分段的变化。
权威资料与规范地址
引用语义以 Java API 和语言规范为准;安全点日志与 JNI 协调按具体 JVM 版本查阅。
