JVM 把内存划分为程序计数器、虚拟机栈、本地方法栈、堆、方法区五大区域,前三者随线程生灭,后两者被所有线程共享。弄清每块存什么、归谁所有、何时抛 OOM,是排查内存问题的地图。

它解决什么问题

写 C/C++ 时每次 malloc 都要配一次 free,内存管理的责任全在程序员肩上。Java 把这份责任交给虚拟机,代价是:一旦出现泄漏或溢出,若不知道 JVM 内部如何划分和使用内存,排查就如大海捞针。运行时数据区的设计本质上回答两个问题:哪些数据线程私有(随线程创建销毁,天然线程安全),哪些数据进程共享(需要统一回收策略)。理解这个划分,后面谈 OOM、谈调参才有落点。

核心原理

线程私有区域

程序计数器是当前线程执行字节码的行号指示器,解释器靠它取下一条指令,分支、循环、线程切换后恢复现场都依赖它。执行 Java 方法时记录字节码地址,执行 native 方法时值为空。它是规范中唯一不会 OOM 的区域——每条线程只存一个地址,所需空间固定。

Java 虚拟机栈是方法调用的载体:每调用一个方法压入一个栈帧,方法结束(正常返回或抛异常)就弹出。栈帧内有局部变量表(基本类型和对象引用)、操作数栈(计算中转站)、动态链接(符号引用转直接引用)、返回地址。

本地方法栈职责相似,只是服务对象换成 native 方法。HotSpot 直接把两者合并,同样会抛栈溢出和 OOM。

线程共享区域

堆是 JVM 管理的最大一块内存,虚拟机启动时创建,唯一使命是存放对象实例和数组,也是垃圾收集的主战场。逻辑上分为新生代(Eden + 两个 Survivor)和老年代:新对象优先在 Eden 分配,熬过几次 Minor GC 后晋升老年代。注意 JDK 8 之后永久代已移除,不要再把方法区画进堆里。

方法区是规范定义的逻辑概念,存类加载后的元数据:类名、父类、接口、字段与方法描述、方法字节码、运行时常量池。规范只要求「有这块地方」,实现由虚拟机定。需要澄清:JDK 7 起静态变量和字符串常量池已搬到堆中,方法区里剩下的是真正的类元数据。

深入机制

为什么用元空间替换永久代

JDK 7 及之前 HotSpot 用永久代实现方法区,受 -XX:MaxPermSize 硬上限约束,默认只有几十 MB。这带来一串麻烦:大量使用动态代理、字节码生成、热部署时容易抛 PermGen space;永久代与堆绑定,给 GC 增加复杂度且回收效率低;Oracle 合并 JRockit 代码时对方根本没有永久代。JDK 8 改用元空间实现方法区,直接使用本地内存:类元数据上限不再被 JVM 参数卡死,改由机器可用内存约束,元数据管理也从 GC 堆剥离。但「用本地内存」不等于不溢出,类加载失控时照样抛 OutOfMemoryError: Metaspace。

对象一定在堆上分配吗

规范说「几乎所有对象在堆分配」,「几乎」留给 JIT。JIT 会做逃逸分析:若对象被证明不逃出当前方法,虚拟机可以不在堆上分配,而是用标量替换把对象拆成若干成员变量放到栈帧或寄存器中,随方法结束销毁。这是优化手段而非规范承诺,编码时不应依赖。

直接内存:数据区之外的「第六区」

直接内存不属于运行时数据区,但出镜率极高。NIO 的 DirectByteBuffer 在堆外分配缓冲区,I/O 时省掉一次堆到本地内存的复制。它不受 -Xmx 限制,由 -XX:MaxDirectMemorySize 控制。回收方式较绕:堆里的 DirectByteBuffer 对象很小,关联的堆外内存要等它被 GC 后由 Cleaner 释放;若堆内存宽裕、GC 迟迟不触发,堆外内存可能一直挂着,最终堆外 OOM。

工程实践

-Xms2g -Xmx2g                 // 生产环境堆设为定值,避免动态伸缩抖动
-Xss512k // 每线程栈大小,线程多时要算总账
-XX:MetaspaceSize=128m // 触发元空间 GC 的初始水位,不是初始容量
-XX:MaxMetaspaceSize=512m // 元空间上限,不设可能吃光本地内存
-XX:MaxDirectMemorySize=1g // 堆外缓冲区上限

各区域 OOM 画像与排查路径:

  • Java heap space / GC Overhead Limit Exceeded:加 -XX:+HeapDumpOnOutOfMemoryError 拿 dump,用 MAT 看支配树,区分内存泄漏还是堆确实不够。
  • StackOverflowError:几乎全是无出口递归,顺着重复的方法名看即可。若是线程过多导致栈总量不足,报的是 unable to create new native thread,要减线程数或调小 -Xss。
  • Metaspace:典型是动态生成类失控或 ClassLoader 泄漏,用 jcmd <pid> GC.class_stats 或 NMT 观察类元数据增长。
  • 堆外溢出:堆 dump 正常但进程 RSS 远超 -Xmx,用 jcmd <pid> VM.native_memory 查直接内存,Netty 用户优先检查 ByteBuf 是否 release。

对比与权衡

区域 线程可见性 存什么 可能的异常 关键参数
程序计数器 线程私有 当前字节码指令地址 无(唯一不 OOM) 无
虚拟机栈 线程私有 栈帧 SOF / OOM -Xss
本地方法栈 线程私有 native 调用状态 SOF / OOM 与虚拟机栈合并
堆 线程共享 对象实例、数组 heap space -Xms / -Xmx
方法区(元空间) 线程共享 类元数据、常量池 Metaspace -XX:MaxMetaspaceSize
直接内存 非数据区 NIO 堆外缓冲 堆外 OOM -XX:MaxDirectMemorySize

面试视角

JDK 8 为什么用元空间替代永久代? 永久代有固定上限,类多就溢出;与堆绑定给 GC 增加复杂度且回收低效;JRockit 没有永久代,合并代码时顺势砍掉。元空间用本地内存,上限由物理内存决定,元数据管理更独立。

程序计数器为什么不会 OOM? 它只存一个「下一条指令地址」,每条线程所需空间固定且极小,生命周期与线程同步,不存在越用越多的可能,规范不为它定义任何 OOM 情形。

字符串常量池现在在哪? JDK 6 及之前在永久代,JDK 7 起与静态变量一起移到堆中。动机是永久代只在 Full GC 时回收,大量临时字符串回收不及时,挪进堆可以跟上正常 GC 节奏。

堆 dump 正常但进程内存持续上涨,查哪里? 优先怀疑堆外:直接内存、元空间、线程栈总量、JNI 分配的本地内存,用 NMT 逐项对比。

小结

  • 五大区域按线程可见性切分:程序计数器、虚拟机栈、本地方法栈线程私有,堆与方法区线程共享。
  • JDK 8 用本地内存的元空间替换永久代,解决固定上限与 GC 复杂度的老问题,但元空间和直接内存照样会 OOM。
  • 排障口诀:heap space 看 dump,StackOverflow 看递归,Metaspace 查 ClassLoader,堆外泄漏用 NMT。