程序计数器 · 虚拟机栈 · 堆 · 新生代 / 老年代 · 元空间 · 代码缓存 · 直接内存 · 对象头

一个 Java 进程的内存,分成了哪几块

JVM 按“谁在用、活多久”给内存分了区:每个线程自己的栈,所有线程共享的堆,存类信息的元空间,还有 JVM 自己用、但不归 -Xmx 管的代码缓存和直接内存。堆又按对象年龄分成新生代和老年代,因为绝大多数对象刚创建不久就没用了。分清这几块,才知道 OOM 报的是哪块满了、-Xmx 管的是哪块、为什么容器内存总比 -Xmx 大一截。

假设场景线上服务跑在 2 GB 内存的容器里,-Xmx1536m,隔几天就被容器杀掉重启,日志里却没有任何 OutOfMemoryError;另一个服务时不时报 OutOfMemoryError: Metaspace。面试也常问“说说 JVM 内存结构”。注意它和“Java 内存模型”是两回事,后者讲多线程下的可见性,见 Java 内存模型。标实测的数字来自 ⑤ 的程序,在 2 核 ARM 云主机上用 JDK 17 跑。

①为什么需要:-Xmx 只管了一部分

一个 Java 进程实际占的内存,是好几块加起来的。只盯着堆,就看不懂容器为什么会把它杀掉。

一个 Java 进程的内存账(示例数字,按常见服务估算) 堆 -Xmx 1536 MB 容器上限 2048 MB ✕ 元空间~150 代码缓存 ~60 线程栈~200 直接内存 ~256 GC/JIT~120 合计约 2.3 GB > 2 GB:容器直接把进程杀掉(OOMKilled),JVM 来不及抛 OutOfMemoryError,也就没有日志。
除了堆,其余几块都在堆外,-Xmx 一个字节也管不到。数字是估算的示例;自己服务的真实账,用 ⑥ 第二圈说的 NMT 查。

排查 OOM

报错里写的是 Java heap space、Metaspace 还是 direct buffer memory,对应的是完全不同的区域和原因。

配参数

-Xmx、-Xss、MaxMetaspaceSize、MaxDirectMemorySize 各管一块,容器里要给堆外留够空间。

看懂 GC

新生代、老年代、Survivor 是 GC 日志和调优的基本词汇,先知道对象在里面怎么搬家。

②没有它会怎样:程序员自己管内存

C 语言里,局部变量放在栈上,函数返回就没了;malloc 出来的放在堆上,要自己 free。只要有一处忘了或多做了一次,就出事。

1忘了 free:内存泄漏
表现
进程内存只涨不跌,跑几天被系统杀掉。
2free 早了:野指针
表现
别处还拿着这块内存的地址,读到的是别人的数据,或者直接崩溃,而且很难复现。
JVM 的做法

对象只管 new,没人引用了由 GC 回收。为此 JVM 必须知道哪些内存是线程自己的、随方法调用自动回收(栈),哪些是共享的、要靠 GC 判断死活(堆),以及类的信息放在哪(元空间)。

再进一步:既然大多数对象很快就死,就把堆按年龄分开,GC 主要扫“年轻”的那块,这就是分代。

典型误解“Java 有 GC,所以不会内存泄漏。”GC 只回收没人引用的对象;被一个静态 Map、一个没清理的 ThreadLocal、一个没卸载的类加载器一直引用着的对象,GC 永远不会动,照样能把堆或元空间撑爆(⑥ 坑 2)。

③相邻方案:别的语言怎么分

“栈放调用现场、堆放对象”几乎所有语言都一样,区别在于堆归谁管、对象放栈上还是堆上由谁决定。

语言栈堆谁决定放哪代价
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 这样的虚拟机决定的,各版本还会变。

同一个什么都不干的 JVM,-Xmx256m,启动 4 秒后(本机 JDK 17 实测) 虚拟地址空间(VmSize)2796 MB进程能用的地址范围,不等于内存 JVM 预留(reserved)1688 MB先把地址圈下来:堆 256、压缩类空间 1024、代码缓存 242…… 已提交(committed)256 MBJVM 向系统要了、保证能用,但还没真正写过 实际占物理内存(RSS)35 MB真正写过、占着物理内存的部分;容器按这个(cgroup 的内存用量)判断超没超
实测 VmSize、RSS 来自 /proc/<pid>/status,reserved、committed 来自 jcmd <pid> VM.native_memory summary(开了 -XX:NativeMemoryTracking=summary)。四个数差了近 80 倍:看 JVM 占了多少内存,要看 RSS;预留得多不代表用得多。服务跑起来以后,RSS 会随着堆被写满、线程变多而逐步涨上去。

机制 1五块区域:规范怎么规定,HotSpot 怎么放

JVM 规范规定了五块运行时数据区:三块每个线程一份,两块所有线程共享。下面先看规范里的五块,再看 HotSpot 实际怎么放(编号对应),最后看各版本怎么变。

线程私有:每个线程一份,随线程生灭 线程共享:所有线程用同一份 1 程序计数器 放:当前线程执行到哪一条字节码 HotSpot:每个线程一个 唯一不会 OOM 的区域 2 虚拟机栈 放:每次方法调用一个栈帧(局部变量、操作数栈、返回地址) -Xss StackOverflowError 3 本地方法栈 放:调用 native方法(C 代码)时的调用现场 HotSpot:和 ② 合成同一个栈 4 堆 放:所有 new 出来的对象和数组 -Xms / -Xmx GC 主要管这里,再分代 Java heap space 5 方法区 放:类的结构、方法字节码、运行时常量池 HotSpot:7 及以前是永久代,8 起是元空间(压缩类空间是元空间里的一部分) Metaspace(8 起)PermGen space(≤7) 规范之外,HotSpot 还有几块“堆外”内存:直接内存、代码缓存、GC 和 JIT 自己的数据结构。它们不算在五块里,但都占进程内存(下一张图)。

HotSpot(JDK 17)里实际怎么放

线程私有(每个线程一份) 线程 1线程 2、3 … 程序计数器:执行到哪一行 虚拟机栈(-Xss) 栈帧 calc() 栈帧 order() 栈帧 main() 本地方法栈:native 方法用 所有线程共享:堆(-Xms / -Xmx) 新生代 Eden新对象先放这 S0 S1 老年代 字符串常量池(7 起在堆) 类的静态变量(7 起在堆) 熬过多次 GC 的对象、大对象 堆外:本地内存(-Xmx 管不到) 元空间(8 起)= 方法区 非类部分:方法字节码、运行时常量池、注解等 压缩类空间:类的结构(Klass),默认上限 1 GB 代码缓存 JIT 编译出的机器码,默认 240 MB 直接内存 allocateDirect、Netty;默认上限 = 堆上限 还有:线程栈本身、GC 和 JIT 自己的数据结构 1 2 3 4 5 引用 JVM 规范里叫“方法区”的那块:HotSpot 7 及以前用堆里的“永久代”实现,8 起换成本地内存里的元空间。
圆圈里的编号对应上一张图的五块:① 程序计数器、② 虚拟机栈、③ 本地方法栈、④ 堆、⑤ 方法区(HotSpot 8 起叫元空间)。没有编号的代码缓存、直接内存是规范之外的部分。左边线程栈里的三个栈帧,是同一个线程在“main 调 order、order 又调 calc”那一刻的样子(机制 2 有完整过程)。数字是本机 JDK 17 的默认值(⑤ 实测打印出来的):压缩类空间 1 GB、代码缓存 240 MB、元空间不设上限、直接内存上限不设时等于堆上限。
元空间的两部分放什么上限参数⑤ 里看到的内存池
压缩类空间每个类的结构(HotSpot 里叫 Klass:字段布局、方法表、父类等),单独放在一段连续地址里,这样对象头里的类指针只要 4 字节CompressedClassSpaceSize,默认 1 GBCompressed Class Space
非类部分方法的字节码、运行时常量池、注解等其余的类元数据没有单独的参数不单独列出
合计:元空间上面两部分加起来,就是 HotSpot 8 起的“方法区”MaxMetaspaceSize 管两部分的总和,默认不限Metaspace(用量是两部分合计)

所以元空间和压缩类空间不是两块并列的区域,而是整体和部分:压缩类空间是元空间里专门放类结构的那一块。关掉压缩类指针(-XX:-UseCompressedClassPointers)时就没有这一块,类结构也放在非类部分里。按 jdk17u 的 memoryPool.cpp 和 globals.hpp 核对(CompressedClassSpaceSize 的说明原文是 “Maximum size of class area in Metaspace”)。

不同 JDK 版本里,这几块放在哪

JDK 6 默认 Parallel GC 堆(-Xmx) 新生代 Eden + 2 个 Survivor 老年代 只有普通对象 永久代(MaxPermSize) 紧挨着堆,GC 管,大小固定 类元数据、运行时常量池 字符串常量池、静态变量 (类相关的都在永久代里) JDK 7 默认 Parallel GC 堆(-Xmx) 新生代 Eden + 2 个 Survivor 老年代 字符串常量池(7 起搬进来) 类的静态变量(7 起搬进来) 永久代(MaxPermSize) 紧挨着堆,GC 管,大小固定 类元数据、运行时常量池 (类相关的都在永久代里) JDK 8 默认 Parallel GC 堆(-Xmx) 新生代 Eden + 2 个 Survivor 老年代 字符串常量池 类的静态变量 永久代:删除 元空间(默认不限) 类元数据、运行时常量池 其中压缩类空间默认 1 GB JDK 9~25 默认 G1(9 起) 堆(-Xmx) E E S O O O E O O H O E G1:一格一个 Region,E/S/O 随时切换 字符串常量池 类的静态变量 永久代:删除 元空间(默认不限) 类元数据、运行时常量池 其中压缩类空间默认 1 GB ↑ JVM 管理的堆内存(受 -Xmx、MaxPermSize 限制) ↓ 本地内存(堆外)。每个版本都还有:线程栈、代码缓存、直接内存
琥珀色是“从哪搬到哪”的那几样。7 先把字符串常量池和静态变量从永久代搬进堆,8 再把剩下的类元数据搬到本地内存,永久代就没了。9 起默认 G1,堆不再是两段连续内存,而是一格格的 Region(E = Eden,S = Survivor,O = 老年代,H = 大对象),代码缓存也分成了三段。为什么要这样搬,见 ⑥ 第三圈“永久代为什么换成元空间”。
  • 线程私有的几块随线程生、随线程死,方法返回时栈帧直接弹掉,不需要 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 模式)。
1 main() 进入 main:压入第 1 个栈帧 2 main() order() main 调 order:再压一个 3 main() order() calc() order 调 calc:栈顶就是正在执行的 4 main() order() calc 返回:弹掉,结果带回 order 5 main() order() save() order 调 save:压入 save 的栈帧 6 (空) 全部返回:线程结束,栈也释放 全景图里画的就是这一刻
同一个线程的栈,从左到右按时间变化;蓝色是栈顶,也就是当前正在执行的方法。上面全景图里的“calc、order、main”三个栈帧,就是第 3 步那一刻。

放大第 3 步里 calc 的栈帧

栈帧:calc(Order o) 局部变量表 this o ● total=99 操作数栈:算到一半的中间值 返回地址:算完回到 order() 的哪一行 long total 这样的基本类型,值就在栈帧里 Order 对象(堆) id = 1001 status = 2 user ● User 对象(堆) name ●→ String age = 30 方法返回:栈帧弹掉,o 这个引用没了;Order 有没有别人引用,交给 GC 判断

所以“栈溢出”(StackOverflowError)说的是调用太深、栈帧太多,和对象多不多无关;“堆溢出”说的是活着的对象太多。

机制 3一个对象在堆里长什么样:对象头 + 字段 + 对齐

每格 = 1 字节;示意按本机默认(开启压缩指针)。字段的实际顺序由 JVM 决定 Order32 字节 mark word 8 类指针 4 status 4 id(long)8 user 引用 4 补齐 4 int[3]32 字节 mark word 8 类指针 4 长度 4 3 个 int,12 补齐 4 Order紧凑对象头 24 对象头 8(含类指针) status 4 user 4 id(long)8 省 8 mark word 里有:哈希码、GC 年龄(4 位,所以最多 15)、锁状态。紧凑对象头 25 要手动开,27 起默认。
  • new Object() 也要 16 字节:12 字节对象头再补齐到 8 的倍数。所以一个只装几个字段的小对象,对象头可能比数据还大,这正是紧凑对象头要省的部分。
  • “压缩指针”让引用只占 4 字节而不是 8 字节,前提是堆小于约 32 GB;堆一旦超过,所有引用都变成 8 字节(⑥ 坑 5)。

机制 4为什么要分代:大多数对象活不过一次 GC设计对比

Edennew 出来先放这大多数在这里就死了 Survivor S0年龄 +1 Survivor S1年龄 +1 老年代满了才做老年代回收(G1 的混合回收或 Full GC) Young GC:只复制活的 每次 GC 来回搬 年龄到 15 Survivor 放不下 大对象(G1 里超过半个 Region)直接进老年代 晋升越快,老年代回收越频繁

被放弃的:整个堆一起回收

  • 每次 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。

参数每块区域归哪个参数管

JVM 启动参数默认值是本机 JDK 17 实测打印出来的;参数怎么调放到第⑥步
-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 当前的内存池全部打印出来;另外四个模式分别往堆、元空间、直接内存、栈里塞东西,直到报错。

1MemDemo.javaJDK 8 / 17 / 21 都能跑
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; }
}
2运行本机 JDK 17.0.20.1,javac --release 8 编译
$ 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
到这里已经能交差了。能画出五块区域、说出每块放什么、由哪个参数管;看到一条 OOM 报错,能马上说出是哪块满了、该先查什么。
已知还没处理的细节

⑥细节:哪块会满、怎么配、各版本怎么变

第一圈是线上最常见的几种内存事故,每条标了根源在 ④ 的哪一条;第二圈是配置规范;第三圈是逃逸分析、TLAB、版本差异和分代实验台。

第一圈

注意事项

坑 1容器隔几天被杀一次,没有任何 OOM 日志← ④ 机制 1 堆外
现象
Kubernetes 里 Pod 状态是 OOMKilled,应用日志戛然而止,没有 OutOfMemoryError,也没有堆转储。
根源
进程总内存 = 堆 + 元空间 + 代码缓存 + 线程栈 + 直接内存 + GC/JIT 自己的内存。-Xmx 设到容器限制的 90% 以上,堆外一涨就超了;容器是直接杀进程,JVM 来不及做任何事。
做法
堆用 -XX:MaxRAMPercentage=75 左右,给堆外留 25%;给元空间和直接内存设上限,让它们超了在 JVM 里报错,而不是被容器杀;用 NMT 看清每块各占多少。
坑 2OutOfMemoryError: Metaspace,类的数量一直涨← ④ 机制 1 元空间
现象
服务跑得越久,加载的类越多,最后元空间报 OOM;或者元空间没设上限,进程内存一直涨。⑤ 实测
根源
类的元数据只有在它的类加载器整个被回收时才会卸载。每次都新建类加载器去生成类(Groovy 脚本每次重新编译、每次请求都 new 一个 CGLIB 增强器、反复热部署),旧的加载器又被别处引用着,类就越积越多。
做法
脚本编译结果、动态代理类要缓存复用;设 MaxMetaspaceSize 让问题早暴露;用 jcmd <pid> VM.classloader_stats 看哪种类加载器最多。
坑 3堆很空闲,却报 direct buffer memory← ④ 机制 1 直接内存
现象
Netty 或 NIO 服务报直接内存 OOM,堆监控里却很空闲。⑤ 实测
根源
直接内存要等堆里那个对应的 ByteBuffer 小对象被 GC 掉才释放。堆很空闲时 GC 很少发生,堆外的缓冲就一直不还;Netty 的池化缓冲则是忘了 release() 就永远不还。
做法
显式设 MaxDirectMemorySize;Netty 的缓冲用完要 release,测试环境打开泄漏检测;别用 -XX:+DisableExplicitGC 关掉分配直接内存时那次兜底的 System.gc()。
坑 4堆还有很多空间,却频繁 GC 甚至 OOM← ④ 机制 4 大对象
现象
GC 日志里很多 “Humongous” 字样,或者 ⑤ 里那样:32 MB 的堆只放下 14 MB 数据就满了。⑤ 实测
根源
G1 里超过半个 Region 的对象直接按整块 Region 分配,尾巴上用不完的部分也浪费掉;它们直接进老年代,回收也更麻烦。
做法
避免一次性的大数组、大字符串(比如整个文件读进内存、一次查出几十万行);确实需要时调大 -XX:G1HeapRegionSize,让它们不再算大对象。
坑 5堆从 31 GB 加到 33 GB,能放的对象反而少了← ④ 机制 3 压缩指针
现象
机器内存多了,把 -Xmx 从 31g 调到 33g 甚至 40g,GC 压力没降,堆用量还更高。
根源
压缩指针(引用占 4 字节)只在堆小于约 32 GB 时生效,超过后每个引用变成 8 字节。对象里引用多的应用,同样的数据要多占不少空间。
做法
堆要么留在 31 GB 以内,要么加到明显更大(多出来的部分要先抵掉引用变长的损失);用 -XX:+PrintFlagsFinal 看 UseCompressedOops 是否还是 true。
坑 6StackOverflowError,或者 unable to create native thread← ④ 机制 2 栈
现象
前者:递归或互相调用的方法一跑就崩。后者:并发一高,新线程起不来。
根源
每个线程的栈大小固定,调用太深就溢出(⑤:默认约 47000 层,-Xss256k 只有约 3500 层);而每个线程的栈都要占堆外内存,线程开得太多,内存或系统的线程数上限先到。
做法
递归改循环,或者给出口条件;线程池有界;线程数确实要很多的,看 网络 IO 模型 里的虚拟线程。
第二圈

使用规范

容器里用比例给堆,给堆外留够

-XX:MaxRAMPercentage=75 左右;直接内存用得多的(Netty、Kafka 客户端)再少给一点。

OOMKilled 且没有日志(坑 1)

延迟敏感的服务 -Xms 等于 -Xmx

启动就把堆要满,运行时不再扩缩。

堆扩张时的停顿和抖动

OOM 一定要留现场

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=…,路径放在有空间的持久化盘上;GC 日志一直开着。

出了事只能猜

元空间、直接内存都设上限

默认一个不限、一个等于堆上限。

泄漏时把整个进程拖垮,而不是在 JVM 里早早报错

线程池有界,别为每个请求开线程

每个线程都带着一个固定大小的栈。

unable to create native thread、堆外内存暴涨(坑 6)

查堆外先开 NMT

-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 8JDK 17JDK 21 / 25
方法区元空间(7 及以前是永久代)元空间元空间
默认 GC 和堆的形状Parallel:新生代、老年代两段连续内存;内存池叫 PS Eden Space 等G1(9 起):堆切成 RegionG1;ZGC 21 起有分代模式,23 起默认分代
代码缓存一整块9 起分成三段(⑤ 里的三个 CodeHeap)同 17
容器感知8u191 起有有
对象头12 字节(压缩类指针)12 字节25 可开紧凑对象头(8 字节),27 起默认
直接内存的报错文字Direct buffer memoryCannot reserve … direct buffer memory同 17

版本改动的来龙去脉见 JDK 8、17、21、25。直接内存报错文字按 jdk8u 和 jdk17u 的 java.nio.Bits 源码核对。

分代实验台先猜,再运行

分配速度
Eden 大小
每个 Survivor
中等寿命对象占比
它们平均活多久
长寿对象占比(如缓存)
晋升年龄阈值
老年代大小
选一个剧情先猜;之后拖滑块,结果会一直和基线对比。
先选一个剧情,猜一下,再点“运行”。

模型:对象按寿命分三种。短命的活不过下一次 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