对象分配、GC Roots 与安全点:为什么对象还活着,进程却突然停了
一次缓存下线已经执行完,堆里那批大对象却迟迟不回收;与此同时,GC 日志显示真正回收只用了几十毫秒,应用暂停却接近两秒。这里有两个不同问题:对象是否仍能从 Roots 到达,以及线程到达安全点花了多久。把“GC 很慢”笼统归给收集器,会同时漏掉引用链和停顿入口。
沿对象生命周期观察,分配请求先尝试线程本地缓冲,失败后进入共享分配路径;对象被栈、静态字段、JNI 或活跃线程引用时保持可达;收集器需要在合适的安全点取得一致视图。最小保留实验、类直方图、heap dump、GC 日志和 safepoint 日志会把“对象为何还活着”与“线程为何停太久”拆成两条证据链。
先把“对象还活着”和“线程停太久”分开
第一条证据链从 TLAB 与慢分配走到逃逸、标量替换、年轻代晋升、大对象、GC Roots、强软弱虚引用、Reference 处理和类卸载;第二条证据链追安全点与安全区域。若第一条最终指向收集器周期来不及或转移失败,应继续检查 GC 日志;若问题是线程间可见性,则必须回到 JMM 同步边,不能把“仍然可达”和“线程尚未停下”混成一个问题。
实验必须设置小而有界的堆、写到独立日志目录,并预留 dump 空间。生产上任何 GC.class_histogram、heap dump、强制 GC 和带 roots 的 JFR 操作都要先看官方命令标注的影响级别。
对象从 new 到可用:不只是“放到堆里”
很多内存和性能问题,根子都在对象分配路径上。接口一次返回几十 MB JSON、循环里创建临时集合、日志拼接大字符串、批量任务一次性构造大数组,都会把分配速率打高。GC 调优前先看对象怎么产生,比先改收集器更靠谱。
一个普通对象创建可以按这条链路理解:
怀疑分配速率太高时,不要只看 used heap,还要抓分配热点和对象生命周期:
# 短时间采样对象分配、锁、CPU 和 GC 事件
jcmd "$PID" JFR.start name=alloc settings=profile duration=120s filename=/var/log/myapp/app-alloc.jfr
# 看堆对象大类,适合快速判断方向
jcmd "$PID" GC.class_histogram | head -n 40
# 看 TLAB / 分配相关日志,先在压测环境验证
java -Xlog:gc+tlab=debug -jar app.jar如果 JFR 里的热点对象集中在 DTO、byte[]、char[]、String、JSON 节点、临时集合或日志参数,优先优化对象规模和创建频率。逃逸分析、标量替换、锁消除属于 JIT 优化结果,不要把它当成“所有小对象都不进堆”的承诺;对象一旦跨方法、跨线程、进入集合、被返回或被字段持有,就很可能逃逸。
分配问题里常见的三个错误方向是:
把“栈上分配”当成生产调优手段。HotSpot 里更常见的是逃逸分析支撑标量替换,不是让你依赖对象一定分配到栈上。看到 TLAB 就以为没有分配成本。TLAB 只是减少多线程同步分配开销,分配出来的对象仍然要被 GC 管理。批量接口一次性构造大列表,再指望调大堆解决。堆越大只是事故来得晚一些,响应体、分页、流式处理和背压才是根因。
高吞吐接口的压测报告里,分配速率应该和 QPS、P99、Young GC 次数、Young GC 平均停顿、老年代回收后存活量一起出现。缺少这组数据,只凭一次延迟变化,无法判断某个 JVM 参数是否真的改善了性能。
GC Roots、安全点和类卸载:对象为什么还活着
排查内存泄漏时,真正要问的不是“哪个类对象最多”,而是“谁把它从 GC Roots 上牵住了”。如果不知道 GC Roots、安全点和类卸载,MAT 的 Dominator Tree 很容易被看成对象排行榜。
常见 GC Roots 可以按这张表看:
| GC Roots 来源 | 典型例子 | 排障入口 |
|---|---|---|
| 线程栈中的引用 | 局部变量、方法参数、未返回调用链 | Thread.print、heap dump 的 Thread Overview |
| 静态字段 | 全局缓存、单例、注册表、工具类静态集合 | MAT Path To GC Roots、代码搜索 |
| JNI/native 引用 | native 方法、直接内存包装对象 | NMT、hs_err、JFR native 事件 |
| 活跃类元数据 | Class 对象、类加载器、运行时常量池引用 | VM.classloaders、VM.metaspace |
| 同步锁相关对象 | 正在持有 monitor 的对象 | jcmd Thread.print -l、JFR lock 事件 |
引用强度也会影响对象生命周期:
| 引用类型 | 回收语义 | 常见坑 |
|---|---|---|
| 强引用 | 只要可达就不会被回收 | 无界缓存、静态集合、队列积压 |
| 软引用 | 内存紧张时才倾向回收 | 拿软引用做缓存,容易在压力下抖动 |
| 弱引用 | 下次 GC 发现弱可达即可回收 | ThreadLocalMap 的 key 弱引用不代表 value 自动清理 |
| 虚引用 | 主要用于回收通知和资源跟踪 | 必须配合 ReferenceQueue,不能用来取对象 |
安全点可以理解成“JVM 能让所有线程停到可观测位置”的地方。GC、偏向/锁相关操作、类卸载、代码反优化、线程栈采样等都可能需要线程到达安全点。线上看到业务停顿,不要只看 GC;也要看 safepoint 日志。
# JDK 9+ 可以把 safepoint 打进日志
-Xlog:gc*,safepoint:file=/var/log/myapp/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
# 看类加载器和元空间,判断类是否可能卸不掉
jcmd "$PID" VM.classloaders
jcmd "$PID" VM.metaspace basic类卸载要满足一个很朴素的条件:加载它的类加载器也要不可达。插件框架、脚本引擎、热部署、动态代理、反射缓存、线程上下文类加载器、静态字段持有业务对象,都可能让类加载器活着,从而让 Metaspace 持续增长。
看到 Metaspace 持续增长,先别急着调大 MaxMetaspaceSize。先看类加载器数量、动态类生成速率、热部署生命周期、线程上下文类加载器是否清理、线程池是否持有旧应用类。修复后再验证 class loaded/unloaded、Metaspace used/committed,以及 Full GC 后 Metaspace 是否回落。
把堆、元空间、RSS 和线程数放在同一份快照里:
jcmd "$PID" GC.heap_info
jcmd "$PID" GC.class_histogram | head -n 30
jcmd "$PID" VM.metaspace basic
jcmd "$PID" VM.native_memory summary scale=MB
jcmd "$PID" Compiler.codecache
cat /proc/"$PID"/status | grep -E 'VmRSS|VmHWM|Threads'四组输出分别指向不同区域:
堆对象多,优先看 heap dump、类直方图、GC 回收后老年代是否下降。Metaspace 高,优先看类加载器、动态代理、脚本、热加载、插件生命周期。RSS 高但 heap 不高,优先看直接内存、线程栈、代码缓存、native 分配和 mmap。
线程数高,-Xss 乘以线程数就是一块不能忽略的内存预算。
JVM 的容量账可以先写成 容器内存 = heap + metaspace + direct + thread stacks + code cache + native + 系统余量。只按 -Xmx 评估,会系统性漏掉堆外占用。
用同一个 WeakReference 证明“引用类型”不等于“没有 Root”
examples/backend-development/jvm/allocation-roots-safepoint/RootRetentionDemo.java 创建 4 MiB 数组,同时用弱引用观察它,再故意把数组放进父层静态集合。第一次请求 GC 时弱引用仍能读到对象;清空真正的强 Root 后,再给 GC 多个观察窗口。
$sources = Get-ChildItem examples/backend-development/jvm -Recurse -Filter *.java
javac --release 17 -Xlint:all -Werror -d out/jvm $sources.FullName
java -Xms32m -Xmx32m -Xlog:gc=info -cp out/jvm example.jvm.roots.RootRetentionDemo在常见 HotSpot 运行中会得到:
while-rooted=true
after-clear=false第一行说明弱引用的存在不会削弱另一条强引用路径;第二行说明清除 ROOT 后对象才有机会被回收。System.gc() 没有强制收集承诺,所以生产门禁不能断言某次调用后必须立刻为 false。稳定断言应放在反向路径:只要静态集合仍持有 payload,弱引用就不应被清除;释放路径则用 ReferenceQueue、堆转储和多个收集周期形成趋势证据。
引用类型不是四档自动缓存策略
强引用保持普通可达;SoftReference 的清理受内存压力和实现策略影响,不能用来承诺缓存命中率;WeakReference 在相应可达性判定后更积极地进入清理;PhantomReference 必须配合 ReferenceQueue,用于对象终结后的资源跟踪而不能取回 referent。现代工程不应依赖 finalization 做关键资源释放,文件、连接和 native 句柄应由显式 close/try-with-resources 管理。
引用对象本身也可能被错误持有。一个 Map 以弱键缓存元数据,如果 value 强引用回 key,整条环仍能从 Map 到达;只看到 WeakHashMap 就判定“不会泄漏”是错的。验证时要画出从真正 GC Root 到 referent 的完整路径,而不是只看某一条边的引用类型。
安全点日志要和 GC phase 分开读
Stop-The-World 事件需要相关线程到达可停位置,但暂停总时长并不等于垃圾回收算法执行时间。统一日志可同时打开 safepoint 与 gc:
-Xlog:safepoint=info,gc*=info:file=logs/runtime.log:time,uptime,level,tags:filecount=8,filesize=32m
读日志时把“到达安全点”“在安全点执行操作”“恢复线程”拆开。若到达时间异常,检查长时间 native/VM 操作、极端循环、页错误或系统调度;若 VM operation 本身长,再回到 GC、deoptimization、偏向撤销的版本事实或诊断命令。不要把任何 safepoint 都叫 Full GC。
分配失败要追到容量链而不是只看 OOM
TLAB refill 频繁说明分配速率高,但不等于泄漏;老年代占用上涨且每次回收后基线不降,才更接近存活集问题。G1 巨型对象需要连续 region,可能在总空闲看似足够时仍制造转移或分配压力。直接内存分配失败则不会在普通 heap dump 中出现主要占用者。
设计实验时同时采集每秒分配量、GC 后存活集、晋升量、TLAB refill、大对象事件与业务吞吐。把请求压到相同速率,再移除怀疑缓存或缩短对象生命周期;如果只降低流量,所有内存曲线都会变好,却不能证明修复了所有权错误。
把“还活着”和“停太久”分别证实
回到开头:大对象不回收时,用 dominator/retained size 和到 GC Roots 的路径证明“谁在拥有它”;暂停长时,把 Application time、Time to safepoint 与 GC phase 分开。删除一条引用后还要再次触发同等负载、比较存活集和暂停分解。没有再验证的“可能是 ThreadLocal”不算结论。
继续追可达性和停顿时看这里
Java SE 25 java.lang.ref:Soft、Weak、Phantom 引用和 ReferenceQueue 的正式语义。
JVMS 2.5:Run-Time Data Areas:线程私有栈、堆和方法区怎样组成可达性分析的运行现场。JDK 25 Diagnostic Tools:JFR、类直方图、heap dump 和线程信息分别能回答什么问题。
对象不释放时,先找从 Root 到对象的那条路径;应用停顿时,先把到达安全点的时间和 VM operation 的执行时间拆开。两条证据链可能在同一次 GC 附近出现,却不能互相替代。只有分别回答“谁还持有它”和“线程在哪一段等了多久”,修复才不会变成盲目加堆或换收集器。
