运行时数据区与对象布局:内存上涨先判断涨在哪里
监控显示 Java 堆只用了 2 GB,容器 RSS 却逼近 4 GB 并被 OOMKilled。团队第一反应是“heap dump 看看谁泄漏了”,结果 dump 里没有异常大对象。因为进程内存从来不等于 Java 堆:线程栈、Metaspace、Code Cache、直接缓冲区、JNI/native 分配和映射文件都可能占用容器额度。
先把内存归位,再追踪对象从 new 到可用的路径。JVMS 规定的运行时数据区与 HotSpot 具体实现需要分开;JOL 用于观察当前对象布局,NMT、BufferPool 与线程数量则解释“堆稳定、RSS 上涨”。看到内存曲线时,第一步是选择与内存区域匹配的证据工具,而不是先调大 -Xmx。
动手前先把逻辑数据区和进程内存分开
沿 PC、Java 栈、堆、方法区、运行时常量池和本地方法栈建立逻辑地图后,再把它对到 HotSpot 的 Metaspace、Code Cache、直接内存、对象头、压缩指针和对齐。若现场转向共享状态可见性,应检查 JMM 同步边;若已经确认是堆对象存活或停顿,则继续追分配、GC Roots 与安全点。
实验需要完整 JDK、JOL(只作实现观察)和可选的 -XX:NativeMemoryTracking=summary。NMT 必须在进程启动时开启,开启和更高 detail 级别都有成本,不能事故发生后临时补开。
运行时数据区:看到指标时要知道它落在哪块
JVM 不是只有堆。一次线上内存事故,可能来自堆对象、元空间、直接内存、线程栈、代码缓存、JNI/native 分配、mmap 文件或容器页缓存。诊断时先把指标归位,后面才知道该抓 heap dump、线程栈、NMT 还是 JFR。
可以先按这个表建立地图:
| 区域 | 线程共享 | 常见证据 | 典型问题 |
|---|---|---|---|
| 程序计数器 | 否 | 一般不直接排查 | 当前线程执行位置 |
| Java 虚拟机栈 | 否 | jstack、Thread.print、-Xss、线程数 | 栈溢出、线程过多、递归过深 |
| 本地方法栈 | 否 | native 栈、JNI、hs_err | native 调用异常 |
| 堆 | 是 | GC.heap_info、heap dump、MAT | 堆泄漏、大对象、缓存无边界 |
| 方法区 / Metaspace | 是 | VM.metaspace、类加载器统计 | 动态类、类加载器泄漏 |
| 运行时常量池 | 是 | javap -v、类元数据 | 符号引用、动态链接 |
| 直接内存 | 进程级资源 | NMT、BufferPool、Netty 指标 | 堆外泄漏、容器 RSS 超限 |
| 代码缓存 | 是 | Compiler.codecache | JIT 编译代码缓存不足 |
运行时数据区可以画成下面这张排障地图:
每次方法调用都会创建栈帧。栈帧里最关键的东西是局部变量表、操作数栈、指向运行时常量池的动态链接和方法返回信息。局部变量表里可能放对象引用,线程栈里的这些引用也可能成为 GC Roots,所以一个看似“已经不用了”的大对象,可能因为线程局部变量、ThreadLocal 或还没返回的调用栈而活着。
对象布局也要有边界感。JVM 规范不强制规定对象在堆里的内部布局;在 HotSpot 常见实现里,对象大致由对象头、实例数据和对齐填充组成。对象头里包含 Mark Word、类型指针等信息,压缩普通对象指针和压缩类指针会影响对象大小。要看具体对象占用,优先用 JOL 这类专门工具,而不是靠“字段大小相加”拍脑袋。
用 JOL 把对象布局从猜测变成证据
容量评估、缓存对象评审和大批量 DTO 改造时,经常会有人用“字段大小相加”估算内存。这个算法漏掉对象头、引用宽度、字段重排、对齐填充、数组头和对象图引用,很容易把真实占用低估一大截。JOL 的价值不是替你背一个固定对象头大小,而是在同一套 JDK、架构和 JVM 参数下,把对象布局打印出来。
在压测或本地实验工程里加入 JOL,只测量有代表性的对象:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
<scope>test</scope>
</dependency>import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
record Money(long cents, String currency) {}
public final class JolProbe {
static final class OrderLine {
long id;
int count;
boolean gifted;
Object ref;
}
public static void main(String[] args) {
OrderLine line = new OrderLine();
System.out.println(ClassLayout.parseClass(OrderLine.class).toPrintable());
System.out.println(ClassLayout.parseInstance(line).toPrintable());
System.out.println(GraphLayout.parseInstance(new Money(9900L, "CNY")).toFootprint());
}
}mvn -q -DskipTests package
mvn -q -DincludeScope=test dependency:build-classpath -Dmdep.outputFile=cp.txt
# 对比压缩指针开关对对象头、引用字段和实例大小的影响。
java -XX:+UseCompressedOops -XX:+UseCompressedClassPointers \
-cp "target/classes:$(cat cp.txt)" JolProbe
java -XX:-UseCompressedOops -XX:-UseCompressedClassPointers \
-cp "target/classes:$(cat cp.txt)" JolProbe输出里重点看对象头、字段偏移、padding、instance size 和 footprint。ClassLayout 看单个类或实例的布局,适合解释“为什么这个对象不是字段大小之和”;GraphLayout 看从根对象可达的对象图,适合估算缓存条目、批量消息、会话对象和反序列化结果的真实 footprint。
JOL 给出的数字有明确边界:
把 JOL 输出当 JVM 规范。它反映的是当前 HotSpot、当前架构、当前参数下的实现事实,换 JDK、换平台、换压缩指针配置都可能变。只测对象本体,不测对象图。一个 DTO 本身很小,但里面的 String、数组、集合和嵌套对象可能才是大头。在生产高峰期做会附加进程、依赖 Serviceability Agent 的重型实验。容量评估应该在同版本同参数的压测环境完成。
容量评估不能只按业务字段猜对象大小。可以为核心缓存对象、批量消息对象和会话对象保留一组 JOL 样例;评审 Xmx、缓存条数和批处理大小时,再用“单对象 footprint × 峰值对象数 × 安全系数”估算实际占用。
到这里先停在“对象占多少空间、这块空间属于进程哪一部分”。new 之后怎样走 TLAB、对象为什么仍然可达、线程又怎样汇入安全点,会在下一篇沿对象生命周期继续追。把两条线拆开以后,RSS 上涨时不会一上来就把问题归给分配或 GC。
让堆对象和直接缓冲在同一进程里留下不同证据
examples/backend-development/jvm/runtime-memory-object/MemoryRegionProbe.java 同时保留 8 MiB Java 数组和 4 MiB 直接缓冲。MXBean 的 heap used 会包含前者的影响,direct.capacity() 明确给出后者容量,但 heap dump 不会把直接缓冲背后的 native 区域当成普通 byte[] 内容保存。
$sources = Get-ChildItem examples/backend-development/jvm -Recurse -Filter *.java
javac --release 17 -Xlint:all -Werror -d out/jvm $sources.FullName
java -XX:NativeMemoryTracking=summary -cp out/jvm example.jvm.memory.MemoryRegionProbe稳定输出是:
heap-blocks=8
direct-capacity=4194304
reported-heap-used-positive=true这个短程序执行很快,不适合临时附加 jcmd。若要做 NMT 对照,应让目标进程等待一段受控时间,先 VM.native_memory baseline,再触发直接内存分配并执行 summary.diff。NMT 能解释 HotSpot 跟踪到的 native 分类,但不等于容器 RSS 的完整分解;页缓存、第三方 native 库和跟踪开销仍要结合操作系统指标判断。
先确认你排查的是哪个 JVM
线上一台机器经常不止一个 Java 进程。排障第一步不是抓 dump,而是确认进程、启动参数、JDK 版本和运行边界。如果连 PID 都找错,后面的证据全是噪声。
先从进程和参数开始取证:
ps -ef | grep java | grep -v grep
PID=$(pgrep -f 'app.jar' | head -n 1)
echo "$PID"
java -version
jcmd "$PID" VM.version
jcmd "$PID" VM.command_line
jcmd "$PID" VM.flags
jcmd "$PID" VM.system_properties | grep -E 'java.version|java.home|user.timezone|file.encoding'
ps -o pid,ppid,%cpu,%mem,rss,vsz,nlwp,cmd -p "$PID"jcmd 是现代 JDK 里非常实用的诊断入口,能看启动命令、JVM flags、线程、堆、类直方图、JFR 等信息。生产镜像如果只带 JRE 或精简运行时,可能没有这些工具,所以核心服务的排障镜像、宿主机工具包或旁路诊断方案要提前准备。
这些字段要分别核对:
VM.command_line 能看到真实启动命令。VM.flags 能看到最终生效的 JVM 参数,不只看脚本里写了什么。nlwp 是进程线程数,后面判断线程膨胀会用到。
rss 是进程实际驻留内存,容器 OOM 时不能只看 Java heap。
命令能执行,不代表拿到的就是目标进程:
pgrep -f app.jar 可能命中部署脚本、备份进程或旧进程,关键环境要用端口、启动目录、进程树一起确认。容器里看到的 PID 可能是命名空间内 PID;宿主机上抓诊断要对应宿主机 PID。jcmd、jmap、jstack 通常要求同用户或具备相应权限,容器还可能受到 ptrace、只读文件系统、安全策略限制。
不要把脚本里的 JAVA_OPTS 当作最终生效参数,最终以 jcmd VM.flags 和启动命令为准。
为了让故障版本和上一稳定版本能够真正比较,每个 Java 服务都需要保留启动参数快照。发布单里至少记录 JDK 版本、镜像 tag、JVM 参数、容器 CPU/内存限制、端口、健康检查和回滚版本。
把 JVM 内存结构分成几块看
线上看到“内存高”时,很多人只看 -Xmx。但 Java 进程的 RSS 不只包含堆,还包括元空间、线程栈、直接内存、代码缓存、JIT、JNI/native 分配、mmap 文件和 GC 自身开销。容器里最常见的事故,就是 -Xmx 看起来没满,但进程 RSS 已经顶到 cgroup 限制。
先用这几条命令拆开看:
jcmd "$PID" GC.heap_info
jcmd "$PID" GC.class_histogram | head -n 30
jcmd "$PID" VM.metaspace basic
jcmd "$PID" Compiler.codecache
jcmd "$PID" VM.native_memory summary scale=MB
cat /proc/"$PID"/status | grep -E 'VmRSS|VmHWM|Threads'
pmap -x "$PID" | tail -n 1这里要注意,VM.native_memory 需要进程启动时打开 Native Memory Tracking:
-XX:NativeMemoryTracking=summary如果要看更细,可以用:
-XX:NativeMemoryTracking=detaildetail 信息更多,也有额外开销。生产上一般先用 summary,只有遇到堆外或 native 泄漏疑点时再提高粒度。
把这些证据合在一起时,先用下面这张图做 RSS 预算拆分。它的用途不是精确记账,而是帮你决定下一步抓什么证据:heap 高就抓 dump,direct 高就看 BufferPool 和 Netty,thread 高就看线程栈和 -Xss,RSS 高但 NMT 解释不了时再看 pmap、mmap、第三方 native 库和容器事件。
排障时不要把 VM.native_memory summary 当成 RSS 的完整替代品。NMT 更适合拆 JVM 自身的 native 分类;进程 RSS、系统 OOM killer、容器 working set 和 pmap 才能告诉你“这个进程实际顶到了哪条限制线”。容量评估也要按上图留系统余量,不要让 -Xmx 独占容器预算。
拿到输出后,按内存区域分流:
GC.heap_info 看 Java 堆的使用情况。class_histogram 看对象数量和类型,适合快速判断大对象方向。VM.metaspace 能看到元空间保留、提交和使用情况。
Compiler.codecache 能看到 JIT 编译代码缓存是否接近上限。/proc/$PID/status 的 Threads 能看到线程数,线程数越多,线程栈和调度成本越高。VmRSS 接近容器限制时,即使堆没满,也可能被系统 OOM killer 终止。
这一步最危险的误判,是拿堆指标解释整个进程:
Java 8 之后没有永久代,类元数据主要进入 Metaspace;还在说“调大 PermGen”通常是过时经验。直接内存、Netty buffer、文件 mmap、JNI 分配不在 Java 堆里,jmap -heap 看不全。每个线程都有栈空间,-Xss 和线程数相乘后会变成真实成本。
-XX:MaxMetaspaceSize 不设上限时,元空间会按需增长;设置太小又容易触发 OutOfMemoryError: Metaspace。
给容器分配内存时,不能把限额全部留给堆。一个常见起点是让最大堆占容器内存的 60% 到 75%,剩余给 Metaspace、直接内存、线程栈、代码缓存和系统开销。高并发 Netty、文件处理、压缩、加密或大量线程的服务,要把堆比例再压低,并显式评估 -XX:MaxDirectMemorySize、-Xss 和线程池上限。
RSS 事故最后要落到一块具体内存
回到 RSS 事故:先用 GC.heap_info 确认堆,再用 VM.native_memory summary.diff 看 native 分类变化,结合线程数、BufferPool、Metaspace 与 Code Cache 指标定位增长源。只有分类证据指向堆,heap dump 才是下一步;否则抓一个巨大的 dump 既会停顿和占盘,也回答不了 native 内存问题。
把内存曲线对回这三个入口
JVMS 2.5:Run-Time Data Areas:堆、栈、方法区、运行时常量池和本地方法栈分别承担什么语义。
JDK 25 Native Memory Tracking:怎样提前开启 NMT、建立 baseline,并用 summary.diff 比较 HotSpot 内部 native 内存。
JDK 25 jcmd:GC.heap_info、VM.metaspace、VM.native_memory 和 Compiler.codecache 的命令入口与影响级别。
JVMS 的运行时数据区回答“虚拟机要提供哪些逻辑区域”,NMT 和这些 jcmd 分类回答“当前 HotSpot 进程把内存花在了哪里”。遇到 RSS 事故时,先在同一 JDK、同一容器限额和同一启动参数下比较前后快照;否则你看到的差值可能来自运行环境变化,而不是业务对象增长。
