JVM 内存结构
JVM 内存结构是 Java 基础架构的核心知识点。理解每个区域存什么、何时分配、何时回收,是深入理解 GC 原理、进行 OOM 排查和性能调优的前提。
运行时数据区总览
JVM 在运行 Java 程序时,会将管理的内存划分为以下几个区域:
┌─────────────────────────────────────────────┐
│ JVM 运行时数据区 │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ 线程共享区域 │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ 堆 (Heap) │ │ │
│ │ │ ┌────────┐ ┌──────────────┐ │ │ │
│ │ │ │ 新生代 │ │ 老年代 │ │ │ │
│ │ │ │Eden|S0|S1│ │ │ │ │ │
│ │ │ └────────┘ └──────────────┘ │ │ │
│ │ └────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────┐ │ │
│ │ │ 方法区 / 元空间 │ │ │
│ │ │ 类信息、常量、静态变量 │ │ │
│ │ └────────────────────────────────┘ │ │
│ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ 线程私有区域 │ │
│ │ ┌──────────┐ ┌─────────────────┐ │ │
│ │ │ 虚拟机栈 │ │ 本地方法栈 │ │ │
│ │ │ (VM Stack)│ │(Native Stack) │ │ │
│ │ └──────────┘ └─────────────────┘ │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ 程序计数器 (PC) │ │ │
│ │ └──────────────────────────────┘ │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
程序计数器(PC Register)
最简单的区域。每个线程一个,记录当前执行的字节码指令地址。
- 线程私有
- 执行 Java 方法:记录字节码指令地址
- 执行 native 方法:为空(Undefined)
- 唯一不会 OutOfMemoryError 的区域
为什么需要程序计数器? 线程切换后,需要知道"我执行到哪了",才能恢复执行。
虚拟机栈(VM Stack)
每个线程一个,描述 Java 方法的执行过程。每调用一个方法,就压入一个栈帧(Stack Frame);方法返回或抛异常,栈帧弹出。
线程的虚拟机栈
┌──────────────────┐ ← 栈顶(当前方法)
│ 栈帧: methodC │
│ ┌──────────────┐│
│ │ 局部变量表 ││ 存储方法参数和局部变量
│ │ 操作数栈 ││ 方法执行的工作区(压栈、弹栈)
│ │ 动态链接 ││ 指向运行时常量池的方法引用
│ │ 返回地址 ││ 方法结束后回到哪里继续执行
│ └──────────────┘│
├──────────────────┤
│ 栈帧: methodB │
├──────────────────┤
│ 栈帧: methodA │
└──────────────────┘ ← 栈底
局部变量表
存储基本类型和对象引用(不是对象本身)。编译时就确定了大小。
| 类型 | 占用 Slot 数 |
|---|---|
| boolean, byte, char, short, int, float | 1 |
| long, double | 2 |
| 对象引用(reference) | 1 |
操作数栈
方法执行的"工作台"。字节码指令通过操作数栈来传递数据:
int a = 1;
int b = 2;
int c = a + b;
对应字节码:
iconst_1 // 将常量 1 压入操作数栈
istore_1 // 弹出栈顶,存入局部变量表 slot 1 (a=1)
iconst_2 // 将常量 2 压入操作数栈
istore_2 // 弹出栈顶,存入局部变量表 slot 2 (b=2)
iload_1 // 加载 slot 1 (a) 到操作数栈
iload_2 // 加载 slot 2 (b) 到操作数栈
iadd // 弹出两个值相加,结果压栈
istore_3 // 弹出栈顶,存入 slot 3 (c=3)
栈的异常
- StackOverflowError:栈深度超过限制(如无限递归)
- OutOfMemoryError:动态扩展时内存不足
// 常见问题:什么情况下抛 StackOverflowError?
public void recursive() {
recursive(); // 无终止条件的递归
}
可以通过 -Xss 设置线程栈大小(默认通常是 512KB - 1MB)。
本地方法栈(Native Method Stack)
与虚拟机栈类似,但为 native 方法(如 C/C++ 实现)服务。HotSpot VM 直接将虚拟机栈和本地方法栈合二为一。
堆(Heap)
最大的区域,GC 的主战场。几乎所有对象实例都在堆上分配(JIT 逃逸分析可能优化到栈上)。
堆的分代结构
┌─────────────────────────────────────────────┐
│ Heap │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 新生代 (1/3) │ │ 老年代 (2/3) │ │
│ │ ┌────┬────┬────┐ │ │ │ │
│ │ │Eden│ S0 │ S1 │ │ │ 长期存活的对象 │ │
│ │ │8/10│1/10│1/10│ │ │ 大对象 │ │
│ │ └────┴────┴────┘ │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────┘
新生代
- Eden:新对象首先分配在这里(约占新生代 80%)
- Survivor 0 / Survivor 1:Minor GC 后存活的对象在这两个区来回复制
老年代
存放长期存活的对象。对象在新生代中 GC 后存活次数达到阈值(默认 15),晋升到老年代。
对象进入老年代的条件
- 年龄达标:经过 N 次 Minor GC 仍存活(
-XX:MaxTenuringThreshold,默认 15) - 大对象直接分配:超过阈值的大对象直接进入老年代(
-XX:PretenureSizeThreshold) - 动态年龄判断:Survivor 中某年龄的对象总大小超过 Survivor 空间的 50%,该年龄及以上的对象直接晋升
- Survivor 放不下:Minor GC 后存活对象太多,Survivor 不够用,直接进老年代
堆的关键参数
| 参数 | 作用 |
|---|---|
-Xms |
堆初始大小 |
-Xmx |
堆最大大小 |
-Xmn |
新生代大小 |
-XX:NewRatio |
老年代/新生代比例(默认 2,即老年代 2/3) |
-XX:SurvivorRatio |
Eden/Survivor 比例(默认 8) |
-XX:MaxTenuringThreshold |
晋升老年代的年龄阈值 |
最佳实践:
-Xms和-Xmx设为相同值,避免运行时频繁扩容/缩容带来的性能波动。
方法区 / 元空间
存储已加载的类信息、常量池、静态变量、JIT 编译后的代码。
从永久代到元空间
| 版本 | 实现 | 内存位置 |
|---|---|---|
| JDK 7 及之前 | 永久代(PermGen) | 堆内存 |
| JDK 8+ | 元空间(Metaspace) | 本地内存(Native Memory) |
为什么要用元空间替代永久代?
- 永久代大小固定,容易 OOM(
java.lang.OutOfMemoryError: PermGen space) - 元空间使用本地内存,大小受物理内存限制,更灵活
- 简化了 GC——不再需要专门回收永久代
元空间参数
| 参数 | 作用 |
|---|---|
-XX:MetaspaceSize |
初始大小(触发 Full GC 的阈值) |
-XX:MaxMetaspaceSize |
最大大小(默认无限制) |
运行时常量池
方法区的一部分,存储编译期生成的各种字面量和符号引用。
String s1 = "hello"; // 字面量在常量池中
String s2 = "hello"; // s1 == s2,指向同一个常量池对象
String s3 = new String("hello"); // 堆上创建新对象,s1 != s3
String s4 = s3.intern(); // intern() 返回常量池中的引用,s1 == s4
JDK 7 开始,字符串常量池从永久代移到了堆中。
intern()不再复制字符串到永久代,而是在常量池中记录堆中字符串的引用。
直接内存(Direct Memory)
直接内存经常被俗称为堆外内存。它不是 JVM 运行时数据区的一部分,不受《Java 虚拟机规范》的限制,而是利用 NIO(New I/O)机制直接在操作系统的物理内存上分配出来的空间。
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024); // 分配 1MB 直接内存
为什么需要它(传统 I/O 痛点)
在传统的 Java BIO 中,读写数据必须经过冗余的拷贝流水线:
- 操作系统将数据读到内核缓冲区(Native Memory)。
- JVM 无法直接处理内核数据,必须将数据复制到 Java 堆内存(如
byte[])。
这种在堆内外频繁来回复制拷贝(涉及用户态与内核态的交互)的开销,极大限制了系统 I/O 的吞吐量。
它是用来做什么的(核心应用的场景)
-
提升高并发 I/O 性能(零拷贝的基石): 使用
DirectByteBuffer后,JVM 可以直接利用映射机制在系统真实的 Native 内存中读写数据,完美避免了堆内空间与堆外空间的数据来回拷贝。这对于需要在网卡和磁盘间高速传输数据的中间件(如 Netty、Kafka、RocketMQ)至关重要。 -
极大减轻 GC 的扫描压力: 如果把海量的、长期存活的缓冲大对象放到 Java 堆内,不仅占空间,还会严重拖慢垃圾回收器的扫描标记过程。把它们迁移到直接内存中,因为不直接受 JVM GC 内存分配管辖,能有效减少 Full GC 发生的频率和 STW 停顿时间。
避坑要点
- 分配成本较高: 直接向系统原生申请和释放内存涉及繁琐的系统底层操作。因此,它通常会被复用池化,适用于长时间存活、高频读写缓冲,非常不适合用在频繁创建与销毁的小块内存上。
- 内存回收机制: 它虽然不受 GC 清理范围管辖,但当堆内代表它的壳子对象(
DirectByteBuffer)失去引用被 GC 回收时,JVM 内置的Cleaner(基于虚引用机制)会收到通知并触发底层的物理释放操作。 - OOM 的风险: 它不受堆容量
-Xmx的限制,但总额必须遵从物理机极限。开发者可以通过-XX:MaxDirectMemorySize单独设置上限。一旦超量,照样会抛出诡异的异常:OutOfMemoryError: Direct buffer memory。
对象的内存布局
HotSpot VM 中,一个 Java 对象在堆中由三部分组成:
┌──────────────────────────────────────┐
│ 对象头 (Header) │
│ ┌─────────────────────────────────┐ │
│ │ Mark Word (64 bit) │ │ 哈希码、GC 年龄、锁标志、锁指针
│ │ Klass Pointer (32/64 bit) │ │ 指向类元信息
│ │ Array Length (仅数组对象) │ │
│ └─────────────────────────────────┘ │
├──────────────────────────────────────┤
│ 实例数据 (Instance Data) │ 字段值
├──────────────────────────────────────┤
│ 对齐填充 (Padding) │ 补齐到 8 字节的倍数
└──────────────────────────────────────┘
Mark Word
64 位 JVM 中,Mark Word 占 8 字节,内容随锁状态变化:
| 锁状态 | 存储内容 | 标志位 |
|---|---|---|
| 无锁 | 对象哈希码、GC 分代年龄 | 01 |
| 偏向锁 | 线程 ID、Epoch、GC 年龄 | 01 |
| 轻量级锁 | 指向栈中锁记录的指针 | 00 |
| 重量级锁 | 指向 Monitor 的指针 | 10 |
| GC 标记 | - | 11 |
压缩指针(Compressed Oops)
在 64 位的 JVM 中,指向内存中对象的指针(如前面提到的 Klass Pointer)原本必须是 64 位(8 字节)。压缩指针是一种内存优化机制,它能将 8 字节的对象指针“压缩”回 4 个字节。可以通过参数 -XX:+UseCompressedOops 控制,默认开启。
32 位架构 vs 64 位架构
理解压缩指针的由来,需要先明白计算机架构演进的必然性。
- 32 位的局限: 在 32 位的 CPU 及操作系统中,其内存地址指针天然是 32 位(4 字节),这意味着它最多只能表示 $2^{32}$ 个独立地址。因此,无论物理机插了多大的内存条,32 位系统最多只能寻址 4GB 的物理内存空间。这也解释了为什么 32 位 JVM 不存在“压缩指针”的概念——因为它的指针原本就是最紧凑的 4 字节容量,本身就没有被进一步压缩的空间了。
- 64 位的突破: 为了打破 4GB 的内存储存天花板,硬件和操作系统整体向 64 位演进。由于指针变长到了 64 位(8 字节),系统理论的寻址能力指数级地飙升到了极其庞大的 $2^{64}$ 空间。
为什么需要压缩指针?(64 位机器的痛点)
当我们从 32 位环境跨入 64 位环境,虽然获得了海量内存的支持,但也伴随着巨大的副作用:
- 极度的空间消耗: 不开启压缩的情况下,所有对象的引用指针大小直接翻倍。这导致程序刚刚迁移到 64 位服务器上,即使一行逻辑代码都不改,其内存占用也会大幅飙升。
- CPU 缓存命中率雪崩: 大量参数变胖的指针导致堆内对象整体体积变大。这将会导致极其昂贵且面积受限的各级 CPU 缓存(L1/L2/L3 Cache)所能装载的对象数量锐减,进而引发频繁的 Cache Miss 和主存访问,严重拖慢程序的执行效率。
压缩指针的生效边界(32GB 阈值限制)
压缩机制默认是开启的,但它有一个非常关键的边界条件:JVM 的堆内存(Heap Size)必须严格小于 32 GB。
- 32GB 阈值是如何推导出来的: 压缩后的 32 位指针理论上最多只能表示 4GB 的独立寻址空间。然而,Java 在底层规定了所有对象都必须是 8 字节对齐的。这意味着这紧凑的 32 位数据记录的不再是“第 N 个单独的字节”,而是“第 N 个 8 字节(通过偏移量记录)”。借助这个逻辑技巧,32 位指针的极限寻址空间就被放大了 8 倍:$4GB \times 8 = 32GB$。
- 容量设定的边界效应: 如果在生产环境将 JVM 堆内存(
-Xmx)设置得大于或等于 32GB(实际操作中为保障安全通常建议留有一定的余量),指针的寻址需求就会超出压缩机制的数学极限,从而导致该功能强制失效。此时,JVM 会被动退化为使用 8 字节的原始胖指针。这会带来一个极端的反面案例——如果给服务器扩容把堆从 31GB 大手笔提升到了 33GB,这新增的区区几十个吉位空间根本填不满全服指针全部翻倍所带来的庞大开销。最终不仅能存放的业务对象数量反而降低了,还会因为整体对象异常膨胀而击穿 CPU 级的高速缓存,导致系统性能断崖式下跌。因此,单实例 JVM 堆大小通常不会被建议超过 32GB 的红线。
对象的创建过程(new Object 的一生)
当我们在代码里写下 new Object() 时,JVM 底层其实经历了一套极其严密且高效的流水线作业。整个流程可以简要概括为 5 个核心步骤。
为了方便理解,我们可以把这个底层过程形象地看作是在**“寸土寸金的城市里批地盖一栋别墅”**。
1. 类加载检查(看看有没有图纸)
当 JVM 遇到 new 指令时,首先会去方法区(元空间)内的运行时常量池中,检查能不能定位到这个类的符号引用,并判断该类有没有被加载、解析和初始化过。
- 比喻: 包工头盖房子前,先去住建局(方法区)的建筑目录库(运行时常量池)里,查一下有没有这款房子的设计图纸备案。如果没有图纸,那就必须先跑一趟外部“类加载流程”,把图纸给引进来并盖上章才能开工。
2. 分配内存(在堆区划拨地皮 —— 最核心与最复杂的环节)
图纸核对无误后,接下来就是要根据图纸计算出房子需要占多大面积,然后在茫茫的堆内存(Heap)里划出这一块地。 此时 JVM 面临两个极其棘手的核心问题:我要去哪找这块地皮? 以及 高并发下大家都来抢同一块地皮怎么办?
问题一:怎么找地皮?(由你的 GC 收集器性格决定)
如果你的垃圾收集器(如 Serial、ParNew)在回收垃圾后自带强迫症般的内存整理压缩功能,那么堆内存里的一侧全是房子,另一侧全是一望无际的空地,中间放着一个指针作为分界线。此时 JVM 会采用**“指针碰撞(Bump the Pointer)”**分配法:想要多大的地,就把指针往空地那头平移多长距离。
- 比喻(指针碰撞): 在一片广阔无垠且未开发的平整荒地上,你需要 100 平方,包工头拿着尺子向前量 100 平并插个牌子,剩下的荒地依旧连成一片,十分高效。
如果你的收集器(如早期的 CMS)不能进行内存碎片整理,堆内存就像被流星雨砸过一样,建好的房子和坑坑洼洼的废弃空地相互交错。此时 JVM 就只能采用**“空闲列表(Free List)”**分配法:它必须在后台死死维护一张巨型表格,记录哪些零碎的地块是空缺的。
- 比喻(空闲列表): 像是在拥挤破旧的老城找停车位,空位东一个西一个。保安(JVM 管理器)手里必须抱着一个登记本写着“2号、19号是空的”,新车一来,只能根据所需车位大小去翻本子匹配再划拨。
问题二:几万人同时抢地皮怎么办?(内存分配的并发安全)
在大型业务里,同一瞬间可能冒出上千个线程同时想拨动“分界指针”来抢这块地皮,如果不加以严格管控,绝对会导致多座别墅盖重叠发生内存踩踏。JVM 给出了两套防线:
- CAS + 失败重试(散客拼手速): 使用极致乐观锁机制。每次线程企图更新指针划地时,都使用底层的 CAS 原子操作,万一在更新的零点几毫秒内发现指针已经被别的线程抢先推走了,它只得认输,然后重新计算偏移量再继续抢下个空位。
- TLAB(大买办提前圈地): 这是应对高并发最逆天、性能最高的利器。与其大家挤在公有地盘头破血流地抢,不如我提前按人头给你们每人发一块“私有地”。详细的底层运转机制请直接看下方的专项解析。
3. 内存初始化为零值(毛坯房清零扫除)
地皮划好后,除了即将写名字的对象头以外,JVM 会将这一整块刚刚拿到手的内存空间里的数据统统初始化为“零值”(比如引用类型变为 null,数值变为 0 或 false)。这就是为什么即使 Java 的实例变量没有手动赋初值,运行也不会像报 C++ 那样读取到胡乱的野指针数据的原因。
- 比喻: 施工队把刚刚圈好的地皮接手后,无论上面以前留下了什么建筑废料或杂草,统统推平扫除,铺设上一层雪白干净的混凝土垫块进行物理净空。
4. 设置对象头(钉上门牌与房产证)
内存扫除干净后,JVM 会立刻对这块肉身进行“行政登记造册”,把这个对象最核心的户籍信息烫印在**对象头(Object Header)**里。它内部囊括了:这是哪个类的实例(老东家图纸地址)、Hash 码是多少(房产证号)、它熬过了几次垃圾清理(GC 房龄)以及当前的偏向锁或轻量级锁状态。
- 比喻: 别墅搭起框架后,市政人员在外墙上钉死了一块不可磨灭的专属门牌号和户籍身份说明。
5. 执行 <init> 构造(打软装与正式精装修)
到前面第 4 步为止,站在底层 JVM 的冷酷视角来看,一个能存活的对象其实已经“被生产出来了”(毕竟血肉地基和身份籍贯都有了)。但从 Java 应用代码的视角看,它此刻还只是个傻不拉几的空壳。
所以最后这极其重要的一步,就是紧接着执行程序员在代码里手写的构造函数逻辑(字节码层面的 <init> 方法),按照业务场景,对里头的各项字段塞入真正的初值。
- 比喻: 最后由主人的软装团队拿着个性化图纸入场,买来特定颜色的沙发、床垫塞满一间间房子。直到此时此刻,一座可以直接拎包入住的豪华别墅,才真正宣告大功告成并交付到你的手里。
TLAB(线程本地分配缓冲区)
这是什么(What is TLAB?): TLAB 全称是 Thread Local Allocation Buffer。它是 JVM 在堆的新生代(Eden 区)内,为每个线程预先分配的一小块绝对私有的内存空间。
为什么需要它?(共享内存的分配痛点):
在 Java 程序运行时,我们几乎每时每刻都在 new 对象,而堆内存是所有线程公共共享的区域。如果千军万马的并发线程都在同一块 Eden 区里争抢着划定边界分配空间,为了确保两个线程不至于把新对象塞进同一个内存地址里,JVM 就必须在每一次发生内存分配时,都强制加粗粒度的锁(底层是 CAS + 失败重试循环)。
在庞大并发和海量对象创建的背景下,这种全局层面的无休止加锁与争抢,会造成极其恐怖的性能损耗。
它是怎么工作的(运行机制): TLAB 的出现就是为了将“加锁分配”降维:从全局打架变成自给自足。
- 无锁化分配: 既然打架是因为都在公用区域捞内存,那就给每个线程独立预支一小块。线程在创建小对象时,会优先看自己的 TLAB 里还有没有余量。如果有,直接用自己内部的指针在 TLAB 里圈拉一下就分配完毕了,全程无锁,速度与执行方法栈帧类似,快到起飞。
- 耗尽与退化补偿: 当线程把自己持有的 TLAB 空间给装满了,或者突然遇到了一个极其庞大的对象,自己的 TLAB 压根放不下时,JVM 才会退回到堆的核心地带,重新走全局加锁同步的逻辑去申请新的一块 TLAB 预支空间,或是把大对象直接扔进公共 Eden 甚至是老年代区。
避坑要点与参数指引:
- 极易混淆的概念(核心): TLAB 名字里带有“私有”,仅仅是指在“分配划定内存动作发生时”是线程私有的! 取消加锁是为了加速圈地。但在 TLAB 里创建好的对象,其本质依然是安安静静地躺在堆内存里。如果有其余线程拿到了这个对象的引用,任何线程都能去读写它,它不是数据隔离!千万不要将这和进行数据上下文隔离的
ThreadLocal混为一谈。 - 占比极小: 它虽然好用,但是默认策略中 TLAB 仅仅占据了整个 Eden 区空间的 1%(依靠不断动态申请释放维持平衡)。因此,只有源源不断的短命小对象才能发挥 TLAB 的神威。
- 参数控制: 热点服务器 JVM 毫无例外都是默认开启它的(
-XX:+UseTLAB)。哪怕把它关掉,也是一种愚蠢的反向优化。
核心知识点自测
| 核心问题 | 知识点索引 |
|---|---|
| JVM 运行时数据区有哪些? | 堆、方法区/元空间、虚拟机栈、本地方法栈、程序计数器 |
| 哪些区域线程私有? | 虚拟机栈、本地方法栈、程序计数器 |
| 堆的分代结构? | 新生代(Eden + S0 + S1)+ 老年代 |
| 永久代和元空间的区别? | 永久代在堆中,元空间在本地内存 |
| 对象的内存布局? | 对象头(Mark Word + Klass Pointer)+ 实例数据 + 对齐填充 |
| 对象创建的步骤? | 类加载检查 → 分配内存 → 零值初始化 → 设置对象头 → 构造函数 |
| 什么是 TLAB? | 线程本地分配缓冲区,避免分配内存时加锁 |
| StackOverflowError 何时发生? | 虚拟机栈深度超过限制(如无限递归) |