Java内存区域详解(重点)
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 // 生产环境堆设为定值,避免动态伸缩抖动 |
各区域 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。

