Skip to content

JVM 内存区域划分(堆、栈、元空间等)

提出问题

一次线上 Full GC 告警,JVM 参数调了大半天,结果发现是元空间(Metaspace)没设上限,类加载器泄漏把整个本地内存撑爆了。这种场景在热部署、动态代理、频繁编译的微服务架构中屡见不鲜。要理解这类问题,首先得搞清楚 JVM 运行时数据区到底有哪些区域、各自管什么、什么情况下会出事。面试官问这个题,表面是考分区记忆,实际是想看你能不能把每个区域对应到生产问题上去。

分析问题

JVM 运行时数据区全景

JVM 在运行 Java 程序时会划分出 6 个主要区域,按线程共享和线程私有分为两类:

线程私有(生命周期与线程一致,随线程创建/销毁):

  • 程序计数器:每条线程独有,记录当前执行的字节码行号。唯一的 Java 虚拟机规范里明确不会 OOM 的区域,因为它的空间是固定的——只需要存一个行号或者 native 方法的空值。
  • Java 虚拟机栈:每次方法调用创建一个栈帧,栈帧里存放局部变量表、操作数栈、动态链接、方法出口。栈深度不足抛 StackOverflowError,允许动态扩展但无法扩展时抛 OutOfMemoryError
  • 本地方法栈:为 native 方法服务,结构类似虚拟机栈。HotSpot 把本地方法栈和虚拟机栈合二为一了。

线程共享(生命周期与 JVM 一致):

  • :最大的一块,所有对象实例和数组都在这里分配。GC 的主要战场,可细分为新生代(Eden / Survivor0 / Survivor1)和老年代。
  • 方法区:存储类型元信息(类名、方法、字段声明)、常量、静态变量、JIT 编译后的代码。JDK 8 用元空间(Metaspace)替代了永久代(PermGen),关键变化是元空间使用本地内存,不再受 JVM 堆大小限制。
  • 运行时常量池:方法区的一部分,存放编译期生成的字面量和符号引用。字符串常量池(StringTable)在 JDK 7 以后从永久代移到了堆中。
java
// 验证栈深度的例子
public class StackOverflowTest {
    private int depth = 0;

    public void recurse() {
        depth++;
        // 每次递归都创建一个栈帧,不设终止条件
        // 可以使用 -Xss 参数调整栈大小
        recurse();
    }

    public static void main(String[] args) {
        StackOverflowTest test = new StackOverflowTest();
        try {
            test.recurse();
        } catch (StackOverflowError e) {
            System.out.println("栈深度达到: " + test.depth);
        }
    }
}

元空间 vs 永久代:为什么换

永久代(PermGen)在 JDK 8 中被移除,直接原因是永久代的大小固定(-XX:MaxPermSize),在热部署、动态类加载频繁的场景下极易溢出。举个例子:一个 Spring Boot 服务启用 spring-boot-devtools 的自动重启,或者用 JRebel 做热部署,每次重启会重新加载类,如果类加载器泄漏了,一次重启就多几十 MB 的元空间占用,三五个版本迭代下来 256MB 的 PermGen 就爆了。我见过一个生产案例:一个 Dubbo 微服务每天发版 2-3 次,每次热部署重启后 Metaspace 增长 80-100MB,两周后进程直接被 OOM Killer 杀掉,日志里没有任何 Java 堆 OOM 记录,只有 dmesg 里一行 Out of memory: Kill process 12345 (java) score 873

字符串常量池在 JDK 7 就已经先移到了堆中,为永久代的彻底移除铺路。

元空间使用本地内存(Native Memory),默认无上限,由操作系统管理。这就意味着元空间不受 JVM -Xmx 堆大小限制,类加载泄漏不会表现为堆 OOM,而是像开头说的那样——悄无声息地吃掉系统内存,最终被 OS OOM Killer 杀掉。生产环境永远要设置 -XX:MaxMetaspaceSize 作为兜底上限。

bash
# 查看 Metaspace 使用情况
jstat -gcutil <pid> 1000

# NMT 查看本地内存细粒度分布
# 启动时加 -XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary

实践建议:生产环境设置 -XX:MaxMetaspaceSize=256m -XX:MetaspaceSize=256m(MetaspaceSize 是触发 GC 的阈值,不是初始大小),同时配合 -XX:+TraceClassLoading -XX:+TraceClassUnloading 监控类加载行为。如果发现 Metaspace 持续增长但 GC 日志里 class unloading 很少,基本可以确认类加载器泄漏。

堆外内存:生产环境最隐蔽的问题

堆内内存泄漏很容易用 jmap -histo:live 看到,但堆外内存泄漏是另一回事。DirectByteBuffer 通过 -XX:MaxDirectMemorySize 控制,默认等于 -Xmx。直接内存分配比堆内存慢(因为需要系统调用 malloc),但避免了 GC 暂停和堆内复制(所以 Netty 大量使用)。

堆外内存泄漏的典型场景:

  • ByteBuffer.allocateDirect() 分配后未释放,或者 cleaner 没有及时触发
  • Unsafe.allocateMemory() 直接操作内存,忘调用 freeMemory()
  • 框架内部缓存(如 Netty 的 PooledDirectByteBuf)未归还到对象池

真实案例:一个 Spring Cloud Gateway 网关服务,基于 Netty,流量从 1k QPS 涨到 5k QPS 后,RES 内存从 2GB 涨到 12GB,但 jmap -heap 显示堆只有 4GB 使用。最终通过 pmap -x <pid> 发现大量 anon 映射,定位到 Netty 的 PoolArena 没有正确配置 -Dio.netty.maxDirectMemory,默认值留得太高。

排查手段:堆内 jmap -histo:live 看不到,只能用 pmap -x <pid> 看进程内存映射,或者启动时开启 NMT 追踪。

bash
# 堆外内存排查三板斧
# 1. 看进程级别内存
ps -o rss,vsz -p <pid>  # RSS 远大于堆大小就中招了

# 2. 看内存映射详情
pmap -x <pid> | sort -k2 -nr | head -20

# 3. NMT 细粒度(需要启动时开启)
jcmd <pid> VM.native_memory summary scale=MB

逃逸分析:栈上分配

不是所有对象都在堆上分配。JDK 6u23+ 默认开启 -XX:+DoEscapeAnalysis,逃逸分析可以判断对象是否只在一个方法内使用(不逃逸出方法),如果满足条件,JVM 会把对象拆散到栈上分配,随栈帧弹出自动销毁,减少 GC 压力。

java
// 逃逸分析示例
public class EscapeAnalysisTest {
    private static class Point {
        private int x;
        private int y;
        public Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }

    // 对象只在方法内使用,不逃逸 → 可能栈上分配
    public static int sum(int a, int b) {
        Point p = new Point(a, b);  // 不会逃逸出方法
        return p.x + p.y;
    }

    // 对象被返回,逃逸 → 必须堆上分配
    public static Point create(int a, int b) {
        return new Point(a, b);  // 逃逸
    }
}

关闭逃逸分析(-XX:-DoEscapeAnalysis)后,同样的代码会多出大量堆分配和 GC 压力,对比可见效果。实际压测数据:在 200 并发下,一个频繁创建中间对象的业务逻辑,开启逃逸分析后 Young GC 频率从每秒 3 次降到每秒 0.2 次,吞吐量提升约 15%。

各区域异常对比

区域线程私有/共享存储内容异常类型常见生产场景
程序计数器私有字节码行号不会 OOM
虚拟机栈私有局部变量表、操作数栈等StackOverflowError / OOM递归深度过大(如 JSON 递归解析未设最大深度)
本地方法栈私有native 方法服务StackOverflowError / OOMJNI 调用深层 native 方法
共享对象实例、数组OOM缓存未设上限、连接池泄漏、大对象直接进入老年代
方法区(元空间)共享类元信息、常量、静态变量OOM热部署类加载器泄漏、CGLIB 动态代理频繁生成新类
运行时常量池共享字面量、符号引用OOMString.intern() 无限塞入字符串

面试追问:这个题面试官会怎么往下挖

第一轮(基础):"JVM 内存分哪几块?哪些是线程私有的?"—— 答出 6 区 + 线程私有/共享划分即可。

第二轮(深度):"元空间为什么换永久代?你遇到过元空间 OOM 吗?"—— 答出类加载器泄漏 + 热部署场景 + MaxMetaspaceSize 设置。

第三轮(实战):"有一个 Java 进程,ps 看 RSS 8GB,jmap -heap 看堆只用了 4GB,另外 4GB 去哪了?"—— 答出堆外内存 + NMT/pmap 排查流程。

第四轮(原理):"栈上分配的条件是什么?逃逸分析一定能栈上分配吗?"—— 答出逃逸分析 + 标量替换 + 锁消除,以及栈上分配受 JIT 编译层级、方法内联的影响,不是所有逃逸对象都能栈上分配。

面试话术示例:先答 6 区划分,重点展开堆的分代结构和元空间替换永久代的原因,然后主动提逃逸分析、堆外内存、NMT 排查,表明你不仅有静态知识还有生产排查经验。如果面试官接话,就继续讲那个 Netty 堆外内存泄漏的真实案例。

生产避坑

  • 永远设置 -XX:MaxMetaspaceSize,配合 -XX:+TraceClassLoading 监控类加载
  • 堆外内存泄漏用 NMT 排查,别在 jmap 输出上浪费时间
  • 逃逸分析在方法内联优化后效果更显著,别只看 +DoEscapeAnalysis 是否开启
  • -Xss 默认值各 JDK 版本不同:JDK 8 默认 1MB,JDK 11+ 改为依赖平台(Linux x64 默认 1MB,但 macOS 可能是 2MB),不要依赖默认值

参考:《深入理解 Java 虚拟机》周志明(第 3 版)· Oracle JVM 规范 Java SE 20 · JDK 源码 openjdk/src/hotspot/share/oops/markWord.hpp · NMT 文档 https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescrid0022.html

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。