内存占用升高时,先分清现象,再选工具:堆增长、进程 RSS 增长与跨线程可见性问题各走各的排查路径。
先分清现象、再选工具:这篇把进程内存构成 + JMM 跨线程约束 + 内存增长诊断分支梳理一遍,堆、RSS 与可见性问题各走各的排查路径。
一、进程内存从哪里来
Java 进程的内存由多块区域组成,堆只是其中一块。方法调用与局部变量存放在栈上,每个线程拥有独立的栈;创建的对象存放在堆上,由垃圾回收器清理;类元数据存放在元空间;即时编译生成的机器码存放在代码缓存;直接内存与原生库开销另行计算。
Java 进程包含堆、线程栈、元空间、代码缓存、直接内存以及原生库等开销。堆是 GC 的主要管理区域,-Xmx 控制堆上限。RSS、容器记账和堆使用量的统计范围不同,应并排观察。
方法执行结束时对应栈帧自动清除,不需要垃圾回收介入;堆上的对象保留到垃圾回收识别为未引用后移除。方法区是规范中的逻辑区域,HotSpot 使用元空间保存类元数据;静态引用指向的对象仍可能占用堆。JIT 的逃逸分析等优化也会改变对象的实际分配,不能把语言对象与一次堆分配完全等同。
二、JMM 定义跨线程约束
跨线程可见性由 JMM 描述:volatile 提供发布顺序保证,不提供复合操作的原子性;丢失更新或发布错误不能通过增大堆修复。
public class Main {
static volatile boolean stop = false;
public static void main(String[] args) throws Exception {
var worker = new Thread(() -> {
long n = 0;
while (!stop) {
n++;
if (n > 100_000_000L) break;
}
System.out.println("worker-exit:" + n); // worker-exit:100000001
});
worker.start();
Thread.sleep(50); // 演示主线程等待后发布停止标志
stop = true;
worker.join(5000);
System.out.println("alive=" + worker.isAlive()); // alive=false
}
}JMM 通过 happens-before 等规则描述允许观察到的行为。例如对某个 volatile 变量的写,与随后对同一变量的读建立同步关系;这为此前写入的发布提供顺序保证。它没有禁止程序中的全部重排序,也不赋予 count++ 原子性。JLS 17是这些规则的来源。
并发错误应从共享状态、发布方式和同步关系排查。增加堆内存、切换 GC 无法修复丢失更新或未正确发布对象的问题。
警告:加堆修不好并发 bug:丢失更新或未正确发布对象的问题,要从共享状态、发布方式和同步关系排查,增加堆内存、切换 GC 没有用。
三、先判断内存增长的性质
诊断先采集现状:确认启动参数与堆状态,再统计线程数,然后进入下面的分支。
# 将 PID 设置为当前用户可诊断的 Java 进程
: "${PID:?设置 Java 进程 PID}"
jcmd "$PID" VM.command_line
jcmd "$PID" GC.heap_info
jcmd "$PID" Thread.print上面三条只做现状采集,不直接下结论:先确认启动参数与堆状态,再数线程,最后进下面的分支。命令的权限、参数与执行代价见 JDK 21 jcmd 手册。
flowchart TD
A[内存占用高] --> B{GC 后存活集还在涨吗}
B -->|涨| C[查引用持有与缓存淘汰]
B -->|不涨但 RSS 涨| D[查线程数与直接内存]
D --> E[查映射文件与原生分配]
C --> F[联查容器限制与事件]
E --> F| 现象 | 可能方向 | 优先检查 |
|---|---|---|
| GC 后存活对象持续增长 | 堆内泄漏或缓存未淘汰 | 引用持有、缓存淘汰策略 |
| 堆稳定但 RSS 增长 | 堆外内存 | 线程数、直接内存、映射文件、原生分配 |
| 容器 OOM | 配额与事件 | cgroup 限制及事件,不只看 GC 日志 |
GC 后存活对象持续增长,可继续检查引用持有和缓存淘汰;堆稳定但 RSS 增长,则检查线程数、直接内存、映射文件和原生分配。容器 OOM 需要联查 cgroup 限制及事件,不能只看 GC 日志。
Native Memory Tracking 需要在启动时启用,如 -XX:NativeMemoryTracking=summary,并有额外开销。随后可用 jcmd <pid> VM.native_memory summary 观察 JVM 跟踪范围内的原生内存;它无法覆盖所有第三方原生分配。
提示:NMT 要在启动时打开:加
-XX:NativeMemoryTracking=summary才有数据,且有额外开销,也覆盖不了所有第三方原生分配。
四、GC 选择由目标决定
选型先确定目标是吞吐还是停顿,再谈收集器:停顿目标不能表述为任意负载下的延迟保证。堆按对象年龄划分:新创建的对象进入新生代,清理新生代的次要回收频繁运行,多次回收后仍存活的对象转入老年代,清理老年代的主要回收运行频率低。
G1 常用于平衡吞吐和停顿,它把堆划分为多个固定大小的区域,优先回收垃圾密度高的区域;串行收集器单线程推进,并行收集器用多线程缩短回收时间,并发收集器把大部分回收工作与应用线程同时推进以减少停顿。具体默认收集器与能力要核对目标 JDK、平台和启动参数。停顿目标不能写成任意负载下的延迟保证。
java -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar观察分配速率、GC 后存活集、暂停分布与 CPU,同时记录业务延迟。增大堆可能降低回收频率,也可能延长某些回收或恢复过程;每次修改都用同一工作负载比较。
注意:增大堆不是免费的:可能降低回收频率,也可能延长某些回收或恢复过程,每次修改都用同一工作负载比较。
五、保存诊断依据与收口
收尾先确认采集代价:堆转储可能带来暂停、大量磁盘写入并包含用户数据,采集前先确认磁盘与访问范围。
堆转储可能带来暂停和大量磁盘写入,也可能包含用户数据。采集前先确认磁盘与访问范围;高频线上问题优先选择可控时长的 JFR 或指标采样,再决定是否需要转储。
注意:堆转储可能含用户数据:采集前先确认磁盘与访问范围,高频线上问题优先用可控时长的 JFR 或指标采样,再决定是否转储。
复盘应记录“观察到什么、支持哪些原因、还缺什么证据”。通过一次请求或看到 RSS 下降都不足以证明内存泄漏已解决,需要覆盖原来触发增长的负载和运行周期。
练习
按分支确认排查方向。补全方向,让堆外排查指向正确检查项:
public class Main {
public static void main(String[] args) {
// TODO: 把 ___ 换成堆稳定但 RSS 增长时的优先检查项(提示:堆外先看线程数与直接内存)
String first = "线程数与直接内存"; // TODO
System.out.println(first.equals("线程数与直接内存") ? "测试通过" : "测试失败"); // 测试通过
}
}小结
- 先分清现象再选工具,内存占用、GC 频繁与并发读写错误是三类问题,各走各的排查路径;
- RSS、容器记账和堆使用量并排观察,三者统计范围不同,只看其中一个容易误判;
- 堆涨查引用与缓存,RSS 涨查堆外,堆稳定但 RSS 增长时看线程数、直接内存、映射文件与原生分配;
- 容器 OOM 联查配额与事件,不能只看 GC 日志,还要看 cgroup 限制及事件;
- 改 GC 与堆参数用同一负载比较,同时记录分配速率、存活集、暂停分布、CPU 与业务延迟;
- 复盘写清证据链,记录观察到什么、支持哪些原因、还缺什么证据,覆盖原触发负载和运行周期才算解决。
分清“数据放哪里、线程能看到什么、垃圾怎么回收”,诊断才有方向。