首页/文章/Java / Spring
Java / Spring

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。

初步诊断可以按这个顺序:

  1. 确认症状和时间线:GC pause 是否与 P99 变差、吞吐下降或 OOM 同时发生?同时查看 CPU、容器内存、请求量和发布变化。
  2. 看分配速率和存活集:短命对象过多通常反映分配率高;Full GC 后老年代仍高,可能是存活集增大、缓存无界或对象泄漏。
  3. 定位最长阶段:区分 evacuation、并发标记、引用处理、safepoint 等耗时,不要只盯总 pause 数字。
  4. 选最小干预:先减少不必要分配或修复生命周期,再评估堆大小、吞吐/延迟目标和 collector 选择。
  5. 压测复核:比较请求延迟分位数、吞吐、GC CPU、分配率、容器 RSS 和 OOM 风险,确认改变没有把成本挪到别处。

常见踩坑

  1. 以为 volatile 能替代锁:它不提供 count++ 这种复合操作原子性,也不保护多个字段共同构成的不变量。
  2. 把 System.gc() 当成修复手段:显式 GC 请求不解决对象仍被引用的问题,还可能引入长停顿。
  3. 只看堆,不看进程内存:direct buffer、线程栈、native 库和 metaspace 都可能造成容器内存耗尽。
  4. 看到老年代高就立刻扩大堆:若是无界缓存或引用泄漏,扩大堆只是推迟故障;先观察 Full GC 后存活曲线和对象引用链。
  5. 复制其他服务的 GC 参数:吞吐、延迟、堆大小和分配模式各异;以默认值做基线,在同一压测下逐项比较。
  6. 忘记日志轮转和敏感信息:GC 日志持续写入,应限制文件数量和大小,并纳入统一运维采集策略。

参考资料

文章反馈

如果这篇文章对你有帮助,欢迎发邮件交流。

通过邮箱联系 ↗继续阅读 →