程序计数器 · 虚拟机栈 · 堆 · 新生代 / 老年代 · 元空间 · 代码缓存 · 直接内存 · 对象头
一个 Java 进程的内存,分成了哪几块
JVM 按“谁在用、活多久”给内存分了区:每个线程自己的栈,所有线程共享的堆,存类信息的元空间,还有 JVM 自己用、但不归 -Xmx 管的代码缓存和直接内存。堆又按对象年龄分成新生代和老年代,因为绝大多数对象刚创建不久就没用了。分清这几块,才知道 OOM 报的是哪块满了、-Xmx 管的是哪块、为什么容器内存总比 -Xmx 大一截。
假设场景线上服务跑在 2 GB 内存的容器里,-Xmx1536m,隔几天就被容器杀掉重启,日志里却没有任何 OutOfMemoryError;另一个服务时不时报 OutOfMemoryError: Metaspace。面试也常问“说说 JVM 内存结构”。注意它和“Java 内存模型”是两回事,后者讲多线程下的可见性,见 Java 内存模型。标实测的数字来自 ⑤ 的程序,在 2 核 ARM 云主机上用 JDK 17 跑。
①为什么需要:-Xmx 只管了一部分
一个 Java 进程实际占的内存,是好几块加起来的。只盯着堆,就看不懂容器为什么会把它杀掉。
排查 OOM
报错里写的是 Java heap space、Metaspace 还是 direct buffer memory,对应的是完全不同的区域和原因。
配参数
-Xmx、-Xss、MaxMetaspaceSize、MaxDirectMemorySize 各管一块,容器里要给堆外留够空间。
看懂 GC
新生代、老年代、Survivor 是 GC 日志和调优的基本词汇,先知道对象在里面怎么搬家。
②没有它会怎样:程序员自己管内存
C 语言里,局部变量放在栈上,函数返回就没了;malloc 出来的放在堆上,要自己 free。只要有一处忘了或多做了一次,就出事。
- 表现
- 进程内存只涨不跌,跑几天被系统杀掉。
- 表现
- 别处还拿着这块内存的地址,读到的是别人的数据,或者直接崩溃,而且很难复现。
对象只管 new,没人引用了由 GC 回收。为此 JVM 必须知道哪些内存是线程自己的、随方法调用自动回收(栈),哪些是共享的、要靠 GC 判断死活(堆),以及类的信息放在哪(元空间)。
再进一步:既然大多数对象很快就死,就把堆按年龄分开,GC 主要扫“年轻”的那块,这就是分代。
③相邻方案:别的语言怎么分
“栈放调用现场、堆放对象”几乎所有语言都一样,区别在于堆归谁管、对象放栈上还是堆上由谁决定。
| 语言 | 栈 | 堆 | 谁决定放哪 | 代价 |
|---|---|---|---|---|
| C / C++ | 局部变量,函数返回自动回收 | 程序员 malloc / new,自己释放 | 程序员写在代码里 | 泄漏、野指针,全靠人 |
| Go | 每个 goroutine 一个可增长的栈 | GC 管理,不分代 | 编译器做逃逸分析,不逃逸的对象放栈上 | GC 要扫整个堆 |
| Java(HotSpot) | 每个线程一个固定大小的栈(-Xss) | GC 管理,按年龄分代 | 语言上对象一律在堆;运行时 JIT 发现不逃逸的,可以拆成局部变量(⑥ 第三圈) | 要配好几块的大小 |
本页讲 HotSpot(OpenJDK、Oracle JDK 用的虚拟机)的内存结构:JVM 规范规定了有哪几块,HotSpot 决定了每块具体怎么实现、在各版本里怎么变。
④原理:规范里的五块区域,HotSpot 怎么放,一个对象的一生
先看 JVM 规范规定的五块区域,再看 HotSpot 在不同版本里把它们放在哪;然后看栈和堆怎么配合、一个对象在堆里长什么样,最后看堆为什么要分代。
先知道这些
RSS 是什么
RSS(Resident Set Size,常驻内存)是进程实际占着的物理内存。JVM 启动时会先“预留”一大段地址、再“提交”一部分,真正写过的才算进 RSS。下面的图是同一个 JVM 的四个数。
引用和对象
Order o = new Order():new 出来的对象在堆里,变量 o 里只存一个指向它的引用(地址)。
GC 做什么
从“根”(各线程栈里的引用、静态变量等)出发,顺着引用找到所有还能用到的对象,其余的就是垃圾,可以回收。
JVM 规范和 HotSpot
JVM 规范只规定“要有哪几块、各放什么”;具体怎么实现(比如方法区放哪),是 HotSpot 这样的虚拟机决定的,各版本还会变。
/proc/<pid>/status,reserved、committed 来自 jcmd <pid> VM.native_memory summary(开了 -XX:NativeMemoryTracking=summary)。四个数差了近 80 倍:看 JVM 占了多少内存,要看 RSS;预留得多不代表用得多。服务跑起来以后,RSS 会随着堆被写满、线程变多而逐步涨上去。机制 1五块区域:规范怎么规定,HotSpot 怎么放
JVM 规范规定了五块运行时数据区:三块每个线程一份,两块所有线程共享。下面先看规范里的五块,再看 HotSpot 实际怎么放(编号对应),最后看各版本怎么变。
HotSpot(JDK 17)里实际怎么放
| 元空间的两部分 | 放什么 | 上限参数 | ⑤ 里看到的内存池 |
|---|---|---|---|
| 压缩类空间 | 每个类的结构(HotSpot 里叫 Klass:字段布局、方法表、父类等),单独放在一段连续地址里,这样对象头里的类指针只要 4 字节 | CompressedClassSpaceSize,默认 1 GB | Compressed Class Space |
| 非类部分 | 方法的字节码、运行时常量池、注解等其余的类元数据 | 没有单独的参数 | 不单独列出 |
| 合计:元空间 | 上面两部分加起来,就是 HotSpot 8 起的“方法区” | MaxMetaspaceSize 管两部分的总和,默认不限 | Metaspace(用量是两部分合计) |
所以元空间和压缩类空间不是两块并列的区域,而是整体和部分:压缩类空间是元空间里专门放类结构的那一块。关掉压缩类指针(-XX:-UseCompressedClassPointers)时就没有这一块,类结构也放在非类部分里。按 jdk17u 的 memoryPool.cpp 和 globals.hpp 核对(CompressedClassSpaceSize 的说明原文是 “Maximum size of class area in Metaspace”)。
不同 JDK 版本里,这几块放在哪
- 线程私有的几块随线程生、随线程死,方法返回时栈帧直接弹掉,不需要 GC。每个线程的栈大小固定(
-Xss,本机默认 2 MB 预留),所以线程越多,堆外占得越多。 - 堆是唯一要靠 GC 回收的大块,也是
-Xmx唯一管得到的地方。
- 元空间存的是“类本身”,随类加载器一起卸载。动态生成类的框架(代理、脚本引擎、热部署)用得最多。
- 直接内存不经过堆,网络 IO 时少拷贝一次(见 网络 IO 模型),但它的回收要等堆里那个小小的
ByteBuffer对象被 GC 掉。
机制 2栈和堆怎么配合:栈帧里只放引用
一个线程只有一个栈。每调用一个方法,就在栈顶压入一个栈帧;方法返回,这个栈帧就弹掉。所以栈里同时有几个栈帧,取决于调用链有多深,而不是方法里调用了几个方法。
public static void main(String[] args) { // 线程 main 从这里开始
var svc = new OrderService();
svc.order(new Order(1001, 2)); // main 调 order
}
class OrderService {
void order(Order o) {
long total = calc(o); // order 调 calc
save(o, total); // calc 返回后,order 再调 save
}
long calc(Order o) {
long total = o.price() * o.count();
return total;
}
void save(Order o, long total) { ... }
}
- main 只直接调用了 order;calc 和 save 是 order 调用的。
- order 先调 calc、再调 save:两次调用是先后发生的,calc 的栈帧弹掉以后,save 的栈帧才压进来,所以栈里最多同时有 3 个栈帧(下图第 3、5 步)。
- 如果 main 里依次调用了 10 个方法、每个都直接返回,栈里也始终最多只有 2 个栈帧。决定栈深度的是“一层套一层”有多深,这也是递归会把栈撑爆的原因(⑤ 的 stack 模式)。
放大第 3 步里 calc 的栈帧
所以“栈溢出”(StackOverflowError)说的是调用太深、栈帧太多,和对象多不多无关;“堆溢出”说的是活着的对象太多。
机制 3一个对象在堆里长什么样:对象头 + 字段 + 对齐
new Object()也要 16 字节:12 字节对象头再补齐到 8 的倍数。所以一个只装几个字段的小对象,对象头可能比数据还大,这正是紧凑对象头要省的部分。- “压缩指针”让引用只占 4 字节而不是 8 字节,前提是堆小于约 32 GB;堆一旦超过,所有引用都变成 8 字节(⑥ 坑 5)。
机制 4为什么要分代:大多数对象活不过一次 GC设计对比
被放弃的:整个堆一起回收
- 每次 GC 都要从根出发遍历整个堆,4 GB 的堆就处理 4 GB。
- 新对象和用了几小时的缓存混在一起,明明垃圾主要是新对象,却要连老对象一起检查。
选中的:按年龄分开,常回收年轻的
- Young GC 只处理新生代,而且用“复制”:把少数活对象搬走,Eden 整块清空。代价只和活对象的多少有关。
- 老对象引用新对象的情况,用“卡表”记下来,Young GC 时不用扫整个老年代。
决定这个设计的约束是一条经验规律,叫弱分代假说:绝大多数对象朝生夕死,熬过几次 GC 的对象往往会活很久。业务代码里一次请求创建的 DTO、字符串、集合,请求结束就没用了,正好符合。所以新生代要够大,让短命对象死在 Eden 里;Survivor 要够大,别让中等寿命的对象被“挤”进老年代。⑥ 的实验台可以拖参数看后果。
不同 GC 的分法不一样:Parallel GC 的新生代、老年代是两段连续的内存;G1 把堆切成很多大小相同的 Region,每块临时扮演 Eden、Survivor 或老年代;ZGC 21 起才分代。“新生代、老年代、Survivor”是概念,具体形状看用哪个 GC。
参数每块区域归哪个参数管
-Xms2g -Xmx2g # 堆的初始和最大值;不写时最大值默认是物理内存(或容器限制)的 1/4
-XX:MaxRAMPercentage=75.0 # 容器里按比例给堆,比写死 -Xmx 灵活
-Xss1m # 每个线程的栈,本机默认约 2 MB
-XX:MaxMetaspaceSize=512m # 元空间上限,默认不限
-XX:MaxDirectMemorySize=512m # 直接内存上限,默认等于堆上限
-XX:ReservedCodeCacheSize=240m # 代码缓存,默认 240 MB
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump # 堆 OOM 时留下现场
-XX:NativeMemoryTracking=summary # 打开后用 jcmd <pid> VM.native_memory summary 看每块各占多少
⑤亲眼看到:列出每块内存,再挨个撑爆
一个不依赖任何库的程序:pools 模式把 JVM 当前的内存池全部打印出来;另外四个模式分别往堆、元空间、直接内存、栈里塞东西,直到报错。
import java.io.*;
import java.lang.management.*;
import java.nio.ByteBuffer;
import java.util.*;
/** 看 JVM 的内存分成哪几块,再把其中几块撑爆。JDK 8 / 17 / 21 都能跑。
* java MemDemo pools | heap | meta | direct | stack */
public class MemDemo {
public static void main(String[] a) throws Exception {
String mode = a.length > 0 ? a[0] : "pools";
System.out.println("Java " + System.getProperty("java.version") + ",模式 " + mode);
switch (mode) {
case "pools": pools(); break;
case "heap": heap(); break;
case "meta": meta(); break;
case "direct": direct(); break;
case "stack": stack(); break;
default: System.out.println("未知模式");
}
}
// 1. 列出所有内存池:哪些在堆里,哪些不在
static void pools() {
for (MemoryPoolMXBean p : ManagementFactory.getMemoryPoolMXBeans()) {
MemoryUsage u = p.getUsage();
System.out.printf("%-8s %-32s 已用 %6.1f MB 已提交 %6.1f MB 上限 %s%n",
p.getType() == MemoryType.HEAP ? "[堆]" : "[非堆]", p.getName(),
mb(u.getUsed()), mb(u.getCommitted()), u.getMax() < 0 ? "不限" : String.format("%.0f MB", mb(u.getMax())));
}
System.out.printf("堆最大值 Runtime.maxMemory() = %.0f MB%n", mb(Runtime.getRuntime().maxMemory()));
com.sun.management.HotSpotDiagnosticMXBean hs =
ManagementFactory.getPlatformMXBean(com.sun.management.HotSpotDiagnosticMXBean.class);
for (String f : new String[]{"UseG1GC", "UseCompressedOops", "UseCompressedClassPointers", "ThreadStackSize",
"MaxMetaspaceSize", "CompressedClassSpaceSize", "ReservedCodeCacheSize", "MaxDirectMemorySize", "MaxTenuringThreshold"})
System.out.println(" -XX:" + f + " = " + hs.getVMOption(f).getValue());
}
// 2. 堆:一直 new 1 MB 的数组并留着
static void heap() {
List<byte[]> keep = new ArrayList<>();
try { while (true) keep.add(new byte[1 << 20]); }
catch (OutOfMemoryError e) { int n = keep.size(); keep = null; System.out.println("留住了 " + n + " MB 后:" + e); }
}
// 3. 元空间:每次用一个新的类加载器,把同一个类再定义一遍(每次都是一个新类)
static class Dummy { int x; void hello() { } }
static class OneShotLoader extends ClassLoader {
OneShotLoader() { super(null); }
Class<?> define(byte[] b) { return defineClass(Dummy.class.getName(), b, 0, b.length); }
}
static void meta() throws IOException {
byte[] bytes;
try (InputStream in = MemDemo.class.getResourceAsStream("MemDemo$Dummy.class")) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[4096]; int r;
while ((r = in.read(buf)) > 0) out.write(buf, 0, r);
bytes = out.toByteArray();
}
List<Class<?>> keep = new ArrayList<>();
try { while (true) keep.add(new OneShotLoader().define(bytes)); }
catch (OutOfMemoryError e) { int n = keep.size(); keep = null; System.out.println("定义了 " + n + " 个类后:" + e); }
}
// 4. 直接内存:一直 allocateDirect 1 MB 并留着
static void direct() {
List<ByteBuffer> keep = new ArrayList<>();
try { while (true) keep.add(ByteBuffer.allocateDirect(1 << 20)); }
catch (OutOfMemoryError e) { int n = keep.size(); keep = null; System.out.println("分配了 " + n + " MB 直接内存后:" + e); }
}
// 5. 栈:无限递归,看能递归多深
static int depth;
static void down() { depth++; down(); }
static void stack() {
try { down(); } catch (StackOverflowError e) { System.out.println("递归 " + depth + " 层后:" + e); }
}
static double mb(long b) { return b / 1024.0 / 1024.0; }
}
$ java -Xmx256m MemDemo pools [非堆] CodeHeap 'non-nmethods' 已用 1.0 MB 已提交 2.4 MB 上限 6 MB [非堆] Metaspace 已用 0.7 MB 已提交 0.9 MB 上限 不限 [非堆] CodeHeap 'profiled nmethods' 已用 0.3 MB 已提交 2.4 MB 上限 117 MB [非堆] Compressed Class Space 已用 0.1 MB 已提交 0.1 MB 上限 1024 MB [堆] G1 Eden Space 已用 1.0 MB 已提交 10.0 MB 上限 不限 [堆] G1 Old Gen 已用 0.9 MB 已提交 180.0 MB 上限 256 MB [堆] G1 Survivor Space 已用 0.0 MB 已提交 0.0 MB 上限 不限 [非堆] CodeHeap 'non-profiled nmethods' 已用 0.1 MB 已提交 2.4 MB 上限 117 MB -XX:ThreadStackSize = 2040 -XX:MaxMetaspaceSize = -1 -XX:CompressedClassSpaceSize = 1073741824 -XX:ReservedCodeCacheSize = 251658240 -XX:MaxDirectMemorySize = 0 -XX:MaxTenuringThreshold = 15 $ java -Xmx32m MemDemo heap 留住了 14 MB 后:java.lang.OutOfMemoryError: Java heap space $ java -XX:MaxMetaspaceSize=24m MemDemo meta 定义了 4031 个类后:java.lang.OutOfMemoryError: Metaspace $ java -XX:MaxDirectMemorySize=16m MemDemo direct 分配了 16 MB 直接内存后:java.lang.OutOfMemoryError: Cannot reserve 1048576 bytes of direct buffer memory (allocated: 16777216, limit: 16777216) $ java MemDemo stack 递归 47116 层后:java.lang.StackOverflowError $ java -Xss256k MemDemo stack 递归 3544 层后:java.lang.StackOverflowError
实测 2 核 ARM 云主机,JDK 17.0.20.1,每个模式跑一次。pools 的输出为了排版删掉了两行默认值。
| 报错 | 满的是哪块 | 常见原因 | 先查什么 |
|---|---|---|---|
Java heap space | 堆 | 活对象太多:一次查出几十万行、缓存只进不出、集合当全局变量 | OOM 时的堆转储,用 MAT 看谁占得最多 |
Metaspace | 元空间 | 类太多或类加载器泄漏:动态代理、Groovy 脚本反复编译、热部署 | jcmd <pid> VM.classloader_stats、类的数量是不是一直涨 |
… direct buffer memory | 直接内存 | Netty、NIO 的堆外缓冲没释放,或者堆很空、GC 不来,堆外缓冲迟迟不回收 | NMT、Netty 的泄漏检测 |
StackOverflowError | 某个线程的栈 | 递归没有出口、两个方法互相调用、调用链太深 | 异常栈里重复出现的那几行 |
unable to create native thread | 系统给进程的线程数或内存 | 线程池无界、每个请求开线程 | 线程数(jstack)、ulimit -u |
| 进程被杀、没有任何报错 | 进程总内存超过容器限制 | 堆开得太满,没给堆外留空间 | 容器事件里的 OOMKilled、NMT |
- 32 MB 的堆只留住了 14 个 1 MB 的数组:堆小时 G1 的 Region 是 1 MB,超过半个 Region 的对象算“大对象”,一个 1 MB 的数组(再加对象头)要占两个 Region,堆一下就被占满了。
- 直接内存的报错文字随版本变过:JDK 8 是
Direct buffer memory,17 上是Cannot reserve … bytes of direct buffer memory。按关键词direct buffer memory搜日志最稳。 - 栈从默认的约 2 MB 调到 256 KB,递归深度从 47116 层降到 3544 层,大致按比例缩小(每层栈帧大小一样)。
⑥细节:哪块会满、怎么配、各版本怎么变
第一圈是线上最常见的几种内存事故,每条标了根源在 ④ 的哪一条;第二圈是配置规范;第三圈是逃逸分析、TLAB、版本差异和分代实验台。
注意事项
- 现象
- Kubernetes 里 Pod 状态是 OOMKilled,应用日志戛然而止,没有 OutOfMemoryError,也没有堆转储。
- 根源
- 进程总内存 = 堆 + 元空间 + 代码缓存 + 线程栈 + 直接内存 + GC/JIT 自己的内存。
-Xmx设到容器限制的 90% 以上,堆外一涨就超了;容器是直接杀进程,JVM 来不及做任何事。 - 做法
- 堆用
-XX:MaxRAMPercentage=75左右,给堆外留 25%;给元空间和直接内存设上限,让它们超了在 JVM 里报错,而不是被容器杀;用 NMT 看清每块各占多少。
- 现象
- 服务跑得越久,加载的类越多,最后元空间报 OOM;或者元空间没设上限,进程内存一直涨。⑤ 实测
- 根源
- 类的元数据只有在它的类加载器整个被回收时才会卸载。每次都新建类加载器去生成类(Groovy 脚本每次重新编译、每次请求都 new 一个 CGLIB 增强器、反复热部署),旧的加载器又被别处引用着,类就越积越多。
- 做法
- 脚本编译结果、动态代理类要缓存复用;设
MaxMetaspaceSize让问题早暴露;用jcmd <pid> VM.classloader_stats看哪种类加载器最多。
- 现象
- Netty 或 NIO 服务报直接内存 OOM,堆监控里却很空闲。⑤ 实测
- 根源
- 直接内存要等堆里那个对应的
ByteBuffer小对象被 GC 掉才释放。堆很空闲时 GC 很少发生,堆外的缓冲就一直不还;Netty 的池化缓冲则是忘了release()就永远不还。 - 做法
- 显式设
MaxDirectMemorySize;Netty 的缓冲用完要release,测试环境打开泄漏检测;别用-XX:+DisableExplicitGC关掉分配直接内存时那次兜底的System.gc()。
- 现象
- GC 日志里很多 “Humongous” 字样,或者 ⑤ 里那样:32 MB 的堆只放下 14 MB 数据就满了。⑤ 实测
- 根源
- G1 里超过半个 Region 的对象直接按整块 Region 分配,尾巴上用不完的部分也浪费掉;它们直接进老年代,回收也更麻烦。
- 做法
- 避免一次性的大数组、大字符串(比如整个文件读进内存、一次查出几十万行);确实需要时调大
-XX:G1HeapRegionSize,让它们不再算大对象。
- 现象
- 机器内存多了,把
-Xmx从 31g 调到 33g 甚至 40g,GC 压力没降,堆用量还更高。 - 根源
- 压缩指针(引用占 4 字节)只在堆小于约 32 GB 时生效,超过后每个引用变成 8 字节。对象里引用多的应用,同样的数据要多占不少空间。
- 做法
- 堆要么留在 31 GB 以内,要么加到明显更大(多出来的部分要先抵掉引用变长的损失);用
-XX:+PrintFlagsFinal看UseCompressedOops是否还是 true。
- 现象
- 前者:递归或互相调用的方法一跑就崩。后者:并发一高,新线程起不来。
- 根源
- 每个线程的栈大小固定,调用太深就溢出(⑤:默认约 47000 层,
-Xss256k只有约 3500 层);而每个线程的栈都要占堆外内存,线程开得太多,内存或系统的线程数上限先到。 - 做法
- 递归改循环,或者给出口条件;线程池有界;线程数确实要很多的,看 网络 IO 模型 里的虚拟线程。
使用规范
-XX:MaxRAMPercentage=75 左右;直接内存用得多的(Netty、Kafka 客户端)再少给一点。
OOMKilled 且没有日志(坑 1)
-Xms 等于 -Xmx启动就把堆要满,运行时不再扩缩。
堆扩张时的停顿和抖动
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=…,路径放在有空间的持久化盘上;GC 日志一直开着。
出了事只能猜
默认一个不限、一个等于堆上限。
泄漏时把整个进程拖垮,而不是在 JVM 里早早报错
每个线程都带着一个固定大小的栈。
unable to create native thread、堆外内存暴涨(坑 6)
-XX:NativeMemoryTracking=summary,再 jcmd <pid> VM.native_memory summary,每块各占多少一目了然。开着有一点性能开销。
对着 RSS 瞎猜是哪块涨了
更深一层
TLAB:为什么 new 对象这么快
所有线程都往 Eden 里分配,如果每次都抢同一把锁会很慢。HotSpot 给每个线程在 Eden 里划一小块私有的分配缓冲(TLAB),线程在自己的那块里分配只要把指针往后挪一下,不用加锁;用完了再去要一块新的。
逃逸分析:有的对象根本不在堆上
JIT(C2)编译时如果发现一个对象只在方法内部用、不会被别处拿到(不逃逸),可以把它拆成几个局部变量放进寄存器或栈帧,堆上根本不分配,叫标量替换。所以“循环里 new 了很多小对象”不一定慢;但这是 JIT 的优化,没被编译的代码、逃逸了的对象都享受不到。
永久代为什么换成元空间
永久代在堆里、大小固定(-XX:MaxPermSize),类一多就 PermGen space 报错,开大了又浪费堆。8 把类的元数据挪到本地内存,按需增长,随类加载器卸载;字符串常量池和静态变量在 7 已经先挪进了堆。代价是默认没有上限,所以要自己设 MaxMetaspaceSize(坑 2)。
Survivor 为什么有两块
复制算法需要一块“空的目标区”:每次 Young GC 把 Eden 和其中一块 Survivor 里的活对象,一起复制到另一块空的 Survivor,然后整块清空原来的两块。两块轮流当目标,所以任何时刻总有一块是空的,这是复制算法的代价。G1 里 Survivor 也是一组 Region,原理相同。
版本差异
| 点 | JDK 8 | JDK 17 | JDK 21 / 25 |
|---|---|---|---|
| 方法区 | 元空间(7 及以前是永久代) | 元空间 | 元空间 |
| 默认 GC 和堆的形状 | Parallel:新生代、老年代两段连续内存;内存池叫 PS Eden Space 等 | G1(9 起):堆切成 Region | G1;ZGC 21 起有分代模式,23 起默认分代 |
| 代码缓存 | 一整块 | 9 起分成三段(⑤ 里的三个 CodeHeap) | 同 17 |
| 容器感知 | 8u191 起 | 有 | 有 |
| 对象头 | 12 字节(压缩类指针) | 12 字节 | 25 可开紧凑对象头(8 字节),27 起默认 |
| 直接内存的报错文字 | Direct buffer memory | Cannot reserve … direct buffer memory | 同 17 |
版本改动的来龙去脉见 JDK 8、17、21、25。直接内存报错文字按 jdk8u 和 jdk17u 的 java.nio.Bits 源码核对。
分代实验台先猜,再运行
模型:对象按寿命分三种。短命的活不过下一次 Young GC;中等寿命的平均活 τ 秒(指数分布);长寿的一直活着。Young GC 间隔 = Eden ÷ 分配速度;每次 GC 时还活着的对象进 Survivor、年龄 +1,到阈值进老年代;Survivor 装不下时,像 JVM 的动态年龄那样把晋升年龄降低。为了好懂做了简化:不算 TargetSurvivorRatio(JVM 默认只把 Survivor 用到一半),不算老年代里对象的死亡,只算“老年代从空到满要多久”。逻辑在 node 里随机测过 2 万组:Survivor 调大,晋升不会变快;晋升速度不会超过对象存活下来的速度。
下一圈
- G1 和 ZGC 怎么回收:Region、记忆集、混合回收、并发标记(已做成下一页)
- 堆转储分析:用 MAT 找出谁占了内存、谁引用着它
- 类加载器和元空间泄漏的排查
- NMT 深入:每块本地内存都是谁在用
- JIT、代码缓存和代码缓存满了以后的“编译停止”
- 对象分配全过程:TLAB、逃逸分析、大对象
↺回到第⑤步:改 MemDemo
预期是按 ④ 推出来的,没有在本机跑过,你跑出来的数字应该落在附近。
练习 1 · 先猜,再换成 256 KB 的数组
把 heap 模式里的 new byte[1 << 20] 改成 new byte[1 << 18](256 KB),还是 -Xmx32m。先猜能留住多少 MB。
预期:明显多于 14 MB,接近 30 MB:256 KB 小于半个 Region(512 KB),不再算大对象,不会一个占两块。
用到:坑 4
练习 2 · 用 NMT 看清每一块
pools 模式最后加一句 Thread.sleep(60000),用 -XX:NativeMemoryTracking=summary 启动,另开终端 jcmd <pid> VM.native_memory summary。
预期:能看到 Java Heap、Class、Thread、Code、GC、Internal 等每一项的 reserved 和 committed;reserved(预留)远大于 committed(真正占用)。
用到:坑 1、第二圈“查堆外先开 NMT”
练习 3 · 栈和递归深度
分别用 -Xss512k 和 -Xss1m 跑 stack 模式,再给 down() 加两个 long 型局部变量再跑一次。
预期:512k 约 7000 层、1m 约 14000 层(按 256k 时约 3500 层成比例估算);加了局部变量后每层栈帧变大,同样的 -Xss 下层数变少。
用到:④ 机制 2、坑 6
1. 用一句话说出 JVM 内存结构的要点。
线程私有的栈和程序计数器、共享的堆、堆外的元空间、代码缓存和直接内存;堆按对象年龄分代,因为大多数对象很快就死;-Xmx 只管堆,进程总内存是各块之和。
← 开头 + ④ 机制 1
2. 没有自动内存管理的语言痛在哪?JVM 为此付出了什么?
手动释放会泄漏或野指针。JVM 改成 GC 自动回收,代价是要把内存按用途分区、让 GC 知道从哪找根,并且要为堆、元空间等分别配大小。
← ②
3. 容器被杀却没有 OOM 日志,根源是 ④ 的哪一条?
机制 1:进程总内存包括堆外的元空间、代码缓存、线程栈、直接内存等,-Xmx 管不到;总数超过容器限制,容器直接杀进程,JVM 没机会抛错。
← ④ 机制 1 + 坑 1
4. 预测:分配速度不变,把 Eden 调大一倍、Survivor 不动,Young GC 的频率会怎样?晋升会不会跟着变少?
频率大约减半(间隔 = Eden ÷ 分配速度)。晋升不一定变少:间隔变长,每次 GC 时还活着的中等寿命对象变多,Survivor 装不下,晋升年龄就被迫降低,对象提前进老年代。所以新生代要 Eden 和 Survivor 一起调,JVM 默认也是按 SurvivorRatio=8 一起缩放的。实验台第一、二个剧情可以对照着看。
← ④ 机制 4 + 实验台
5. 预测:一个服务的本地缓存只进不出,每秒新增 1 MB 长期存活的对象。把新生代调大能救吗?
救不了。长寿对象迟早会熬到晋升年龄进老年代,新生代多大都只是推迟;老年代被它们填满的速度就是每秒 1 MB。根本办法是给缓存设上限和过期。实验台第三个剧情就是这个。
← ④ 机制 4 + 实验台
6. 预测:一个 Netty 服务堆开了 4 GB、几乎是空的,MaxDirectMemorySize 没设,会不会报直接内存 OOM?
可能会,而且上限是 4 GB(不设时等于堆上限)。堆空闲意味着 GC 很少,已经没用的 ByteBuffer 对象迟迟不被回收,它们占着的堆外内存就不释放;用池化缓冲又忘了 release 的,更是永远不还。
← ④ 机制 1 直接内存 + 坑 3