JVM 内存模型与 GC
把 Java 内存模型中的可见性与 happens-before 和 JVM 堆、垃圾收集区分开,再用 GC 日志建立诊断路径。
“JVM 内存模型”在日常讨论里常指两件不同的事:一是 Java Memory Model(JMM)规定多线程读写何时可见;二是 JVM 运行时内存区域、对象分配与垃圾回收。前者关乎并发正确性,后者关乎容量和停顿。把两者混成一个概念,容易用 volatile 去解决堆不足,或者以为 GC 能修复数据竞争。
两个模型,各自回答一个问题
JMM 回答“线程 A 的写入,线程 B 什么时候必须看见?”;GC 回答“哪些对象仍然可达、堆如何回收和整理?”。对象在堆里,并不代表线程之间自动有可见性保证。
flowchart LR
subgraph jmm[Java Memory Model:正确性]
a[线程 A 写共享状态] --> hb[happens-before 关系]
hb --> b[线程 B 读到有序且可见的值]
end
subgraph gc[JVM 堆与 GC:回收与停顿]
roots[GC Roots] --> live[可达对象]
dead[不可达对象] --> collect[回收 / 整理]
live --> heap[堆中存活对象]
end
用 happens-before 建立安全发布
普通字段的写读如果没有 happens-before 关系,读线程可能读到旧值,也可能观察到不符合预期的状态顺序。JMM 规定了同步锁、volatile 写读、线程启动/结束等同步关系。设计时先找“谁写、谁读、通过什么同步”,而不是先加锁再祈祷。
final class StopSignal {
private volatile boolean stopped;
void requestStop() {
stopped = true; // volatile 写
}
void runLoop() {
while (!stopped) { // 后续的 volatile 读可观察到写入
doOneUnitOfWork();
}
}
}
volatile 保证这个标志的可见性与特定顺序,但不让复合操作自动原子化。下面的计数器仍可能丢失更新:
volatile int count;
void increment() { count++; } // 读-改-写不是一个原子动作
要用 AtomicInteger.incrementAndGet()、锁,或把状态交给单线程 actor/队列串行处理。安全发布不可变对象时,优先用构造完成后不再修改的 final 字段,并通过 volatile、锁、并发容器或线程安全队列发布引用。
堆与垃圾回收的工作方式
对象通常分配在 Java heap;GC 从线程栈、静态字段、JNI 引用等 GC Roots 出发追踪可达对象。不可达对象的内存可以回收。具体分代、region 和并发标记实现由所选 collector 决定,不应把“年轻代、老年代”当成每个 GC 都完全相同的物理布局。
在 Java 21 的常见服务器环境里 G1 是默认 collector。它将堆划为 regions,按年轻代/老年代角色组织对象,周期性选取 regions 回收,并尝试满足暂停时间目标。应用可以从默认设置开始,根据真实 GC 日志和服务 SLO 诊断;盲目调大新生代、调低暂停目标可能导致更频繁的回收或更高 CPU 消耗。
先开日志,再谈调参
生产 JVM 可以先使用统一日志记录 GC 事件,并将堆上限设在容器内存预算允许的范围内:
java \
-Xms2g -Xmx2g \
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m \
-jar app.jar
固定 Xms/Xmx 只是示例,并非适合所有服务的默认值。容器总内存还要容纳 metaspace、线程栈、直接内存、代码缓存、JNI/native 分配和进程本身;堆上限等于容器 limit 时,进程可能先被 cgroup OOM killer 杀掉,而不是抛 Java OutOfMemoryError。
初步诊断可以按这个顺序:
- 确认症状和时间线:GC pause 是否与 P99 变差、吞吐下降或 OOM 同时发生?同时查看 CPU、容器内存、请求量和发布变化。
- 看分配速率和存活集:短命对象过多通常反映分配率高;Full GC 后老年代仍高,可能是存活集增大、缓存无界或对象泄漏。
- 定位最长阶段:区分 evacuation、并发标记、引用处理、safepoint 等耗时,不要只盯总 pause 数字。
- 选最小干预:先减少不必要分配或修复生命周期,再评估堆大小、吞吐/延迟目标和 collector 选择。
- 压测复核:比较请求延迟分位数、吞吐、GC CPU、分配率、容器 RSS 和 OOM 风险,确认改变没有把成本挪到别处。
常见踩坑
- 以为
volatile能替代锁:它不提供count++这种复合操作原子性,也不保护多个字段共同构成的不变量。 - 把
System.gc()当成修复手段:显式 GC 请求不解决对象仍被引用的问题,还可能引入长停顿。 - 只看堆,不看进程内存:direct buffer、线程栈、native 库和 metaspace 都可能造成容器内存耗尽。
- 看到老年代高就立刻扩大堆:若是无界缓存或引用泄漏,扩大堆只是推迟故障;先观察 Full GC 后存活曲线和对象引用链。
- 复制其他服务的 GC 参数:吞吐、延迟、堆大小和分配模式各异;以默认值做基线,在同一压测下逐项比较。
- 忘记日志轮转和敏感信息:GC 日志持续写入,应限制文件数量和大小,并纳入统一运维采集策略。