JAVA · Vol.III · DAY 34 · JVM 原理与调优
JVM 运行时数据区全景:堆、栈、方法区、程序计数器
开场:一张全景图 + 两个维度
《Java 虚拟机规范》把 JVM 运行时内存切成几个区域:程序计数器、Java 虚拟机栈、本地方法栈、堆、方法区、运行时常量池;规范之外的直接内存(NIO 的堆外内存)也常被一并讨论。记这堆区域,先用两个维度切,比死背名字清晰得多。
维度一:线程私有 vs 线程共享。每个线程一出生,JVM 就给它配一套"私有"的空间(程序计数器 + JVM 栈 + 本地方法栈),随线程的生命周期创建和销毁;而堆和方法区是全进程共享的,所有线程共用。
| 区域 | 私有 / 共享 | 存什么 | 溢出错误 |
|---|---|---|---|
| 程序计数器 | 线程私有 | 当前执行的字节码指令地址 | 永不 OOM(规范唯一) |
| JVM 栈 | 线程私有 | 栈帧(方法调用) | StackOverflowError / OOM |
| 本地方法栈 | 线程私有 | native 方法调用 | 同 JVM 栈 |
| 堆 | 线程共享 | 对象实例、数组 | OOM: Java heap space |
| 方法区 | 线程共享 | 类元信息、常量池 | OOM: Metaspace(JDK8+) |
| 直接内存 | 进程内(规范外) | NIO 堆外缓冲 | OOM: Direct buffer memory |
维度二:哪块内存溢出抛什么错。这个维度是"面试题 + 事故现场"的双料考试点:堆溢出是 Java heap space,栈要么"太深"抛 StackOverflowError、要么"太宽"(线程太多建不起栈)抛 OutOfMemoryError: unable to create new native thread,方法区是 Metaspace,直接内存是 Direct buffer memory,而程序计数器——规范明说它是唯一不会 OOM 的区域。
还有个常被追问的"数数题":规范里的运行时数据区,严格说是五个(程序计数器、JVM 栈、本地方法栈、堆、方法区),运行时常量池在规范上挂在方法区之下;不同教材把"本地方法栈"或"运行时常量池"单列与否,口径各不一样。考官的意图不是考你数数,是看你能不能说清每块干嘛——把上面的表吃透,口径问题自然免疫。
六个区域按"私有/共享"一条线切开:PC、JVM 栈、本地方法栈是线程私有;堆、方法区线程共享;直接内存是规范外的第"七区"。每块内存都有自己的 OOM 签名。
程序计数器:线程的"书签"
程序计数器(PC Register)是六区里最小的一个,职责一句话:记录当前线程正在执行的字节码指令的编号(行号指示器)。它的存在意义在于线程切换——CPU 分时调度下,一个线程随时会被挂起、稍后又恢复执行,恢复时就得靠 PC 知道"我上次执行到哪了"。所以 PC 是线程私有的:每个线程一份,互不干扰,否则切换回来就串味了。
规范的说法是 undefined。别答成"指向 native 方法入口"——native 方法已经不在 JVM 字节码层面,没有对应的字节码指令编号,所以 PC 置为 undefined(可近似理解为"暂不存在")。这是规范里唯一一处用 undefined 描述的值,值得记住。
PC 还有两个身份:① 字节码解释器的工作依据——解释执行时,PC 指向哪条指令,解释器就取下一条来执行;② 唯一不会 OOM 的区域——它只是一小块内存(存一个指令地址/字长),而且生命周期严格跟着线程走,规范明确定义它不抛任何 OutOfMemoryError。
既然 PC 只是"一个数",为什么它也算一个"区域"?
因为它确实是规范定义的数据结构的一部分,且随线程创建/销毁有独立生命周期。记它的价值不在大小,而在两点:线程切换靠它恢复现场;以及它是面试里"哪块内存不会 OOM"的标准答案。
程序计数器 = 线程的"书签",记录当前字节码指令编号,线程私有,支持线程切换恢复现场;执行 native 方法时为 undefined;是唯一不会 OOM 的区域。
JVM 栈:栈帧四块结构
JVM 栈也是线程私有,生命周期和线程相同。它存的不是"变量",而是一摞栈帧:每调用一个方法压入一个栈帧,方法返回时弹出。一个栈帧内部又分成四块,这是本篇第一处核心:
// 源码:int add(int a, int b) { return a + b; } // javap -c 反编译出的字节码(关键指令): 0: iload_1 // 把局部变量表 slot1 的 a 压入操作数栈 1: iload_2 // 把局部变量表 slot2 的 b 压入操作数栈 2: iadd // 从操作数栈弹出两个 int,相加,结果压回栈顶 3: ireturn // 把栈顶结果作为返回值返回 // 注释:iload_* 读局部变量表,iadd 在操作数栈上运算——两块结构一目了然。
- 局部变量表:以 slot 为单位存方法参数和局部变量。
int、float、reference、returnAddress各占 1 个 slot,long、double占 2 个 slot;表的大小在编译期就确定了。 - 操作数栈:字节码的"工作台",大小(max_stack)编译期确定。比如
iadd就从栈顶弹两个、压一个;JVM 的运算几乎都发生在这块栈上。 - 动态链接:指向运行时常量池里"本栈帧所属方法的引用",多态调用(虚方法分派)靠它解析到实际的方法。
- 方法返回地址 + 异常表:返回地址记录调用后回到哪条指令;异常表记录
start_pc ~ end_pc + handler——try-catch 的真相。
局部变量表的 slot 是"逻辑单元"(long/double 占 2 slot),它和对象头/字段的内存对齐(补齐到 8 字节等)是两套完全不同的概念。一个在栈帧里、服务于方法执行;一个在堆对象里、服务于内存布局(字段对齐细节见「对象创建全过程」)。
JVM 里 try-catch 不是"判断异常类型再跳转"的 if,而是靠异常表:抛异常时,JVM 顺序查异常表,看哪一行的 start_pc~end_pc 覆盖了当前 PC,就跳到对应 handler。这才是字节码层面的真相。
把栈帧放大到字节码层面,能看到编译产物里的两个"预算"数字:方法 Code 属性里的 max_stack(操作数栈最大深度)和 max_locals(局部变量表最大 slot 数)——javac 阶段就算好,JVM 运行时据此给每个栈帧分配固定大小的空间,而不是动态伸缩。所以"方法里局部变量越多、运算越复杂,栈帧越占地方"是编译期定死的事。
顺带把"本地方法栈"补一句:它就是"JVM 栈的 native 版",专门服务 native 方法调用。HotSpot 里它和 JVM 栈合二为一(共用一套栈),规范上分列两区、实现上常常一体——面试知道这层关系即可,不必纠结它"独立"与否。
栈上两种错误要分清楚:StackOverflowError 是"太深"——递归无终止、或每个栈帧压了大数组局部变量,栈深度超过 -Xss 允许的深度;OOM 是"太宽"——线程创建太多,每个线程建栈都要吃 native 内存,系统内存耗尽就抛 unable to create new native thread。一个在"垂直方向"爆掉,一个在"水平方向"爆掉。
栈帧四块:局部变量表(slot)、操作数栈(max_stack,运算工作台)、动态链接(常量池引用)、返回地址 + 异常表(try-catch 真相)。栈深了抛 StackOverflowError,栈开太多了抛 OOM。
堆:对象的大本营,GC 的主战场
堆是 JVM 里最大的一块内存,线程共享,所有线程的 new 都往这放——对象实例、数组,几乎全在这。也正因为它最大、最热闹,才成为 GC 的主战场(引用关系、分代回收全发生在这里)。
堆内部的分代结构(Eden / Survivor 0 / Survivor 1 / 老年代)、对象如何在里面出生和晋升,这些细节在「JVM 堆内存」和「对象创建全过程」里有完整展开,本篇只把它定位到全景图里:记住堆的三个核心参数 -Xms(初始大小)、-Xmx(最大大小)、-Xmn(年轻代大小),完整参数见「JVM 调优参数手册」。
定位:对象太多 / 泄漏(该回收的没回收)→ 下一步 dump 堆内存分析(见「OOM 排查实战」)
需要点破的一点:堆 OOM 不代表堆本身设小了。九成场景里是"该被回收的对象没被回收"——要么代码里留着不必要的强引用(泄漏),要么 GC 配置不合理。所以看到 Java heap space,第一反应不是盲目调大 -Xmx,而是先看是不是泄漏(「内存泄漏排查」的 MAT 分析流程)。
参数层有个小坑值得记:-Xms 与 -Xmx 不一致时,堆是"按需扩容"的——初始只分配 -Xms,不够了再向 -Xmx 涨,而扩容常伴随一次 GC 甚至向 OS 重新申请内存的开销。所以生产里常把两者设成相等,省去运行期扩容抖动;-Xmn 设年轻代大小,配小了年轻代对象晋升太快、Full GC 频繁,配大了单次 Minor GC 变久,是需要实测的平衡题。
堆 = 线程共享、对象大本营、GC 主战场。参数 -Xms/-Xmx/-Xmn;溢出是最高频的 Java heap space。先查泄漏,再考虑调参。
方法区:从永久代到元空间的演进
方法区存的是类级别的元信息:已加载类的方法字节码、字段与方法签名、注解、以及运行时常量池。注意它和堆的区别——堆存"实例",方法区存"关于类的模板与常量"。这一段是本篇第二处核心:它在 JDK 8 经历了一次大搬家。
// JDK 7 及以前:方法区的物理实现是"永久代"(PermGen),在堆里划一块 -XX:MaxPermSize=64m # 默认 64MB(Client)/ 8x(Server),改小容易 OOM: PermGen space // 痛点:大小难预测(类多就爆)、Full GC 时类元信息整理慢、OSGi 场景类共享难 // JDK 8(JEP 122):永久代移除,方法区改用本地内存的"元空间"(Metaspace) -XX:MetaspaceSize=21m # ⚠️ 不是"初始大小"!是"首次触发 Full GC 的阈值" -XX:MaxMetaspaceSize # 默认无上限(unlimited)——只受物理内存/系统限制 // 查看:jstat -gcmetacapacity <pid> 或 jcmd <pid> VM.metaspace
-XX:MetaspaceSize(默认约 21MB)的意思是:元空间占用超过这个值,才触发一次可以卸载类的 Full GC,GC 后再动态调整这个阈值。它不是一开始就申请的容量——初始实际分配很小,按需增长。面试答"初始大小 21M"必被追问。
这场搬家的收益:① 彻底消灭了 OOM: PermGen space(元空间用本地内存,上限默认是物理内存);② 类元数据按需分配、动态增长,不再受一个固定小池子的约束;③ 类可以更干净地卸载,OSGi/热部署的类共享更好做。代价是:由于默认没有上限,动态生成大量类(反射代理、CGLIB)时会把本地内存吃光——所以生产里往往显式设 -XX:MaxMetaspaceSize 兜底。
更本质地说,换的是"分配介质的性质":永久代在堆里,受堆 GC 管,类元信息的整理要和对象回收挤在同一条 Full GC 里跑,类一多 STW 就被拖长;元空间用本地内存独立管理,按块向 OS 申请,类的加载/卸载不再和堆 GC 强耦合——这正是 OSGi 反复装卸 bundle、JSP 反复重编译这类场景在 JDK 8 之后终于不再"永久代撑爆"的根因。报错名从 PermGen space 变成 Metaspace,但"类没卸载、元数据只涨不落"的本质没变。
方法区存"运行时常量池",但字符串常量池不是在堆里吗?
这是两个不同东西。JDK 7 挪到堆里的是字符串常量池(StringTable);运行时常量池一直在方法区。二者关系下一站拆开讲,先记住结论:别把"字符串常量池"和"运行时常量池"混成一个。
方法区存类元信息 + 运行时常量池。JDK 7 用永久代(堆内,默认小),JDK 8 换成元空间(本地内存,默认无限)。MetaspaceSize 是"首次 Full GC 阈值"而非初始大小。
运行时常量池 vs 字符串常量池
这两个池名字太像,是常年失分点,拆开看就清楚:
运行时常量池(在方法区):每个 .class 文件里都有一张"常量池表"(Constant Pool),存两类东西——字面量(字符串、数字常量)和符号引用(类名、方法名、字段名的文本描述)。类加载后,这张表进入方法区变成"运行时常量池",且可以在运行期动态追加新常量(比如 String.intern())。它的生命周期和它所属的 Class 一致,类卸载了它才消失。符号引用里的"符号"指的是文本形式(如 java/lang/Object),经过链接阶段的"解析"才会变成直接引用(真正的指针/偏移量)。
举个映射例子:字节码里的 invokevirtual #5,那个 #5 就是符号引用——一个编号,指向常量池表第 5 项,内容是一段"方法名 + 描述符"的文本;链接的解析阶段才把它换成指向实际方法入口的直接引用。同一个符号引用,解析前是"文本名字",解析后是"内存地址"——这恰是"类文件常量池表"和"运行时常量池"的分界:文件里是静态表,加载后变成可解析、可动态追加的池。
字符串常量池(StringTable,在堆里):JDK 7 起从永久代挪到了堆里,是一张全局的哈希表(-XX:StringTableSize 控制桶数)。所有 "abc" 字面量在编译后会进入这个池;运行期 intern() 也能入池——规则是"池里没有就复制/记录一份引用,有就返回池里的那个"。
String a = "hello" 直接把字面量放进字符串常量池,返回池里的引用;String b = new StringBuilder("he").append("llo").toString() 则在堆里 new 了个新对象(不在池里)。于是 a == b 为 false(不同对象),a.equals(b) 为 true(内容相同);若对 b 调 intern(),会把池里的 "hello" 引用返回,b.intern() == a 为 true。记死这条:字面量入池,new 出来的不入池,intern 强制入池/返池引用。
运行时常量池在方法区(存字面量 + 符号引用,随类走);字符串常量池在堆里(全局 StringTable,字面量/intern 入池)。前者是"类的常量库",后者是"字符串共享缓存"。
直接内存:规范之外,却必考
直接内存不在 JVM 规范的运行时数据区之列,但 NIO 让它在实战里绕不开:DirectByteBuffer 在堆外分配 native 内存,读写时操作系统可以直接 DMA(绕过 JVM↔Native 的一次拷贝),所以高性能网络库(Netty)大量用它。
关键在它的释放机制:堆外的内存,JVM 的 GC 管不着,于是 DirectByteBuffer 这个堆对象身上挂了一个 Cleaner(基于虚引用,见「对象存活判定」的虚引用节)。当这个堆对象被 GC 时,Cleaner 线程触发,调用 DirectByteBuffer 的 cleaner.clean() 去 unmap 并释放那块堆外内存。一句话:堆对象的死,连带堆外内存的释放。
为什么网络层(Netty 等)偏爱它?NIO 读堆外内存时,数据可以直接 DMA 进内核缓存再进套接字,省掉"堆内缓冲 → 内核缓冲"那把数据从 JVM 堆拷到 native 的一次拷贝;而 HeapByteBuffer 则要多出这一次拷贝。这一省,在高吞吐网关上就是数量级的吞吐差距。
堆没满,直接内存却爆了,常见两类原因:① 池化泄漏——Netty 的 PooledByteBufAllocator 拿到缓冲区忘记 recycle,池子里不断新分配、旧的不还,直接内存只涨不降;② 限额独立——直接内存受 -XX:MaxDirectMemorySize 限制(默认与 -Xmx 相同),它和堆是两份账,堆空不空跟它无关。排查思路见「内存泄漏排查」。
既然直接内存要等堆对象被 GC 才释放,堆里对象很多时会不会"憋住"?
会。堆里存着大量 DirectByteBuffer 对象、却迟迟不触发 GC 时,堆外内存就干等。所以重度使用 NIO 的应用,要留意堆的 GC 节奏和直接内存用量的配合——这也是 Netty 提供池化和显式释放(ReferenceCountUtil.release)的原因之一。
直接内存是规范外的堆外内存,靠 DirectByteBuffer 身上的 Cleaner(虚引用)在对象被 GC 时释放。受 MaxDirectMemorySize 独立限额;堆空不代表它不 OOM。
总结:一张大表 + 面试模板
知识地图:六个区域沿两条线记忆——私有(程序计数器 / JVM 栈 / 本地方法栈)vs 共享(堆 / 方法区),加上规范外的直接内存。每块内存 = 存什么 × 溢出签名 × 关键参数三件套。
- 程序计数器:线程书签,native 时为 undefined,唯一不 OOM。
- JVM 栈:栈帧四块(局部变量表 / 操作数栈 / 动态链接 / 返回地址+异常表);深→StackOverflowError,宽→OOM。
- 堆:对象大本营,分代 GC 主战场,溢出 = Java heap space。
- 方法区:JDK7 永久代 → JDK8 元空间(本地内存,MetaspaceSize 是 GC 阈值)。
- 两个池:运行时常量池在方法区,字符串常量池在堆。
- 直接内存:NIO 堆外,靠 Cleaner(虚引用)随堆对象回收而释放。
面试回答模板
"JVM 运行时数据区按线程可见性分成两半:私有的是程序计数器、Java 栈、本地方法栈,共享的是堆和方法区。程序计数器是线程书签、唯一不 OOM;栈存方法调用的栈帧,深了抛 StackOverflowError、线程太多抛 OOM;堆是对象大本营,溢出就是 Java heap space;方法区存类元信息,JDK8 从永久代换到了本地内存的元空间。此外 NIO 还有块规范外的直接内存。"
逐块展开:栈帧四块讲到"局部变量表 slot(long/double 占 2)+ 操作数栈 max_stack + 动态链接 + 异常表(catch 不是 if)";方法区重点讲 PermGen→Metaspace 的演进和三个动机(大小难调、Full GC 慢、类共享难),点出 MetaspaceSize 是"首次 Full GC 阈值"这个易混点;再把运行时常量池(方法区)和字符串常量池(堆)拆开,用 intern() 的 == 例子收尾;最后补一句直接内存(netty、Cleaner 释放、独立限额)。如果时间够,落在"每块内存的 OOM 签名"上收束,展示排障意识。
高频追问表
| 追问 | 答案要点 |
|---|---|
| 为什么 JDK8 要移除永久代? | 大小难调、Full GC 类整理慢、类共享难(JEP 122);改用本地内存元空间,默认无限上限 |
| MetaspaceSize 是什么? | 触发首次可卸载类的 Full GC 的阈值(约 21MB),不是初始分配大小 |
| 字符串池和运行时常量池什么关系? | 字符串池在堆(StringTable,字面量/intern 入池);运行时常量池在方法区(字面量+符号引用,随类走),两者不是一回事 |
| StackOverflowError 和 OOM 都是栈的问题吗? | 同根源不同方向:栈"深"(递归/大局部变量)抛 SOE;栈"宽"(线程太多建不起栈)抛 OOM unable to create new native thread |
| 程序计数器为什么不会 OOM? | 只存一个指令地址/字长,随线程生命周期,规范定义其为唯一不 OOM 区域 |
| Direct buffer OOM 但堆很空,为什么? | 直接内存独立限额(MaxDirectMemorySize);池化未 recycle;堆对象未及时 GC 导致 Cleaner 没触发释放 |
记运行时数据区,别背区域名,背"每一块:谁用(私有/共享)、存什么、爆了抛什么、配什么参数"。把这条问句套到七块内存上,这张全景图就长在你脑子里了。
Comments · 评论