运行时数据区
开篇:Java 程序运行时,内存里发生了什么?
想象你走进一栋办公大楼。
前台(程序计数器)记录着每位员工当前在哪一层办公;每个员工有自己的工位隔间(虚拟机栈),桌上摆着正在处理的文件,文件处理完就扔掉,绝不会跟别人共用桌面;会议室(堆)是公共空间,所有人都可以把资料放进去,但需要有保洁阿姨(GC)定期清理过期资料;档案室(方法区)保存着公司的制度文件、组织架构图和公章——这些东西很少变动,但偶尔也会更新;大楼旁边还有一间直通外部仓库的快递收发室(直接内存),不走大楼内部的电梯系统,速度更快但需要你自己签收和归还。
JVM 的运行时数据区就是这样一栋大楼——不同区域各司其职,协同完成 Java 程序的每一次运行。
接下来,我们逐层参观。
一、JVM 内存全景图
先用一张图建立整体印象,后面逐个区域深入。
核心分界线:程序计数器、虚拟机栈、本地方法栈随线程而生、随线程而灭,是每个线程独享的;堆和方法区在 JVM 启动时创建,所有线程共享。
记住这条线,很多并发问题和 GC 问题都从这里出发。
还有一个容易被忽略的角色——本地方法栈。功能与虚拟机栈类似,只不过它服务的是 Native 方法(通常用 C/C++ 实现)。在 HotSpot 中,本地方法栈和虚拟机栈被合并为同一个栈实现,所以后面我们只讨论虚拟机栈即可。
此外,一个 Java 进程的内存远不止上面这些。堆外内存还包括元空间、压缩类空间(Compressed Class Space,用 32 位指针引用类元数据以节省空间)、代码缓冲区(Code Cache,存放 JIT 编译后的机器码)和直接缓冲区。再往外是操作系统层面的本地运行库和 JNI 占用的内存。理解全貌有助于排查线上内存问题——有时候 JVM 堆没满,但进程 RSS 已经很高,原因往往就在堆外。
二、程序计数器:最小但最重要
把程序计数器想象成导航 App 里的 GPS 定位点——它告诉当前线程"你正执行到哪条字节码指令"。
几个关键特征:
- 线程私有。每个线程都有自己的一个,互不干扰。CPU 在多个线程之间来回切换时,靠它把线程恢复到正确的执行位置。
- 如果当前执行的是 Java 方法,计数器记录的是正在执行的字节码指令地址。
- 如果当前执行的是 Native 方法,计数器值为 Undefined——因为 Native 方法不走 Java 字节码。
- 它是 JVM 规范中 唯一没有规定任何 OutOfMemoryError 的区域——因为它实在太小了,小到只需要存一个地址值。
虽然只是一个寄存器级别的微小空间,但没有它,线程在被挂起后就再也找不到回来的路了。可以说,程序计数器是多线程执行的基石。
三、虚拟机栈与栈帧
3.1 栈帧结构
函数调用就像往桌上 叠盘子:调用一个方法,就往栈顶放一个盘子(栈帧);方法返回,就把最上面的盘子拿走。在任意时刻,一个线程只有最顶层的栈帧是"活跃"的,叫做当前栈帧,对应的方法叫当前方法。执行引擎运行的所有字节码指令只针对当前栈帧操作。
需要注意:不同线程的栈帧之间不允许相互引用。这是线程隔离的一个体现。
方法返回时(无论正常 return 还是抛异常),当前栈帧会把执行结果传回前一个栈帧,然后被丢弃,前一个栈帧重新成为当前栈帧。
每个栈帧内部由以下四个部分组成:
局部变量表
定义为一个数字数组,存放方法参数和方法体内定义的局部变量,包括 8 种基本数据类型、对象引用和 returnAddress 类型。
最基本的存储单元是 Slot:32 位类型(int、float、引用等)占一个 Slot,64 位类型(long、double)占两个 Slot。JVM 会为每个 Slot 分配一个访问索引,通过索引即可定位到对应的局部变量。
如果是实例方法(非 static),index 0 永远是 this 引用,后面依次排列方法参数,再排列方法体内的局部变量。局部变量表的容量大小在编译期就已确定,保存在方法的 Code 属性中。
public int add(int a, int b) {
int sum = a + b;
return sum;
// 局部变量表: [this, a, b, sum]
// 索引: 0 1 2 3
}局部变量表是线程私有的,天然没有数据安全问题。
一个有趣的细节:Slot 是可以 复用 的。如果一个局部变量过了它的作用域,后面声明的新变量可以占用它的 Slot,从而节省空间。
public void demo() {
{
int x = 1; // 占用 Slot 1
}
// x 已过作用域
int y = 2; // 可以复用 Slot 1
}局部变量表中的变量也是重要的 GC Roots 之一——只要还被局部变量表直接或间接引用着,对象就不会被 GC 回收。
操作数栈
计算的"草稿纸",用于保存计算过程的中间结果,同时作为变量的临时存储空间。
例如执行 a + b:先把 a 的值从局部变量表加载并压入操作数栈(iload_1),再把 b 的值压栈(iload_2),然后一条 iadd 指令弹出两个值、算出结果再压回去。
操作数栈不像局部变量表那样可以通过索引随机访问——它只能通过标准的入栈和出栈操作来存取数据。
如果被调用的方法有返回值,返回值也会被压入调用者栈帧的操作数栈中,同时更新 PC 寄存器指向下一条需要执行的字节码指令。
HotSpot 还做了一个优化——栈顶缓存(Top-of-Stack Caching):把栈顶元素缓存到 CPU 的物理寄存器中,减少对内存的读写次数,提升执行效率。
动态链接
每个栈帧持有一个指向运行时常量池中该方法的引用。在 Java 源文件被编译为字节码后,所有变量和方法引用都以符号引用的形式保存在 class 文件的常量池中。动态链接的作用就是在运行期间把这些符号引用转换为调用方法的直接引用。
字节码文件中的是"常量池",运行时方法区中的是"运行时常量池"——前者是静态数据,后者是活的。
方法返回地址
保存调用该方法的 PC 寄存器的值。方法正常结束后(通过 return 指令),PC 恢复到调用者的下一条指令继续执行;异常退出时,返回地址由异常表确定,栈帧中通常不保存这部分信息。
无论哪种方式退出,当前栈帧都会被弹出。退出的过程需要:恢复上层方法的局部变量表和操作数栈、将返回值(如果有)压入调用者的操作数栈、设置 PC 寄存器值。
正常退出和异常退出的区别:异常退出不会给上层调用者返回任何值。
3.2 StackOverflowError 什么时候发生?
每调用一个方法就压一个栈帧,栈的深度是有限的。如果递归层数太深或方法调用链太长,栈空间用完了,就抛出 StackOverflowError。
// 经典反面教材:没有终止条件的递归
public void infinite() {
infinite(); // 永远不会返回,栈帧不断堆积
}可以用 -Xss 参数调整线程栈大小(HotSpot 默认一般为 512K ~ 1M)。调大可以支持更深的递归,但也意味着能创建的线程总数会减少——因为每个线程都要分配独立的栈空间,总内存是有限的。
注意区分:HotSpot 的栈大小固定、不能动态扩展,所以只会抛 StackOverflowError,不会在栈上抛 OutOfMemoryError。但 JVM 规范中提到,如果实现允许动态扩展栈且申请不到内存,则抛 OOM——在 HotSpot 中这不会发生。
另外,虚拟机栈不存在垃圾回收的问题。栈帧随方法调用入栈、随方法返回出栈,内存的分配和释放是完全确定的。但虚拟机栈存在 OOM 的可能——如果栈大小可动态扩展且无法申请到足够内存(非 HotSpot 场景)。
3.3 方法调用:静态链接与动态链接
理解这两个概念的关键在于一个问题:调用目标能否在编译期确定?
静态链接(早期绑定):编译期就能确定目标方法。包括 static 方法、private 方法、final 方法、构造器以及父类方法。对应字节码指令 invokestatic 和 invokespecial。这些方法也叫非虚方法——它们没有"多态"一说。
动态链接(晚期绑定):运行期才能确定目标方法——Java 的多态就靠它。对应 invokevirtual(虚方法调用)和 invokeinterface(接口方法调用)。
四条方法调用指令对比:
| 字节码指令 | 调用目标 | 举例 |
|---|---|---|
invokestatic | 静态方法 | Math.abs() |
invokespecial | 构造器、私有方法、父类方法 | super.toString() |
invokevirtual | 虚方法(实例方法) | obj.toString() |
invokeinterface | 接口方法 | list.size() |
为了提高多态调用的效率,JVM 在类加载的链接阶段会为每个类建立一张 虚方法表(vtable),用索引查表代替沿继承链逐层搜索。每个类的虚方法表中,被子类重写的方法槽位指向子类的实现,未重写的方法则继续指向父类的实现。
方法重写的查找过程:
- 找到操作数栈顶的引用所指向的对象的实际类型 C
- 在 C 的方法表中查找与常量池中描述符和名称都匹配的方法
- 通过访问权限校验后返回直接引用;不通过则抛
IllegalAccessError - 找不到则按继承链从下往上搜索各个父类
- 始终没找到则抛出
AbstractMethodError
四、堆:对象的家
4.1 新生代与老年代
把堆想象成一个 大学宿舍区:
- 新生代(Young Generation) 是大一新生宿舍,人来人去,流动性极高。它又分为三块:
- Eden 区(约 80%):新入学的同学先住这里,几乎所有 Java 对象都是在 Eden 区被 new 出来的
- Survivor From / To(各约 10%):经历过一次"期末考"(Minor GC)还活着的同学搬到这里
- 老年代(Old Generation) 是教职工公寓,住进来的都是"长期居民",搬进搬出的频率很低。
默认比例:新生代 : 老年代 = 1 : 2(-XX:NewRatio=2)。
Eden : S0 : S1 = 8 : 1 : 1(-XX:SurvivorRatio=8)。
堆的几个基本特征:
- 一个 JVM 实例只有一个堆,在 JVM 启动时创建并确定大小
- 堆可以处于物理上不连续的内存空间中,但在逻辑上被视为连续
- 所有线程共享堆,但可以划分线程私有的 TLAB 缓冲区
- 堆是 GC 执行垃圾回收的重点区域。方法结束后堆中的对象不会马上被移除,只有在垃圾收集时才会被回收
用
-Xms设置堆起始大小,-Xmx设置堆最大大小。实践中通常把两者设为相同值,避免 GC 后堆的动态伸缩带来额外开销。用-Xmn可以直接设置新生代大小。
4.2 对象创建的完整过程
从写下 new MyObject() 到拿到一个可用的引用,JVM 在幕后做了这些事:
分配内存的两种方式
| 方式 | 适用场景 | 原理 |
|---|---|---|
| 指针碰撞 | 堆内存规整(使用复制或标记-整理算法的收集器如 Serial、ParNew) | 用一个指针把已用和空闲内存一分为二,分配时指针向空闲方向前移,简单高效 |
| 空闲列表 | 堆内存不规整(使用标记-清除算法的收集器如 CMS) | 维护一张表记录哪些内存块可用,分配时找到合适大小的块,需要额外的空间来维护列表 |
对象在内存中的三层结构
一个 Java 对象在 HotSpot 中由三部分组成:
| 部分 | 内容 | 备注 |
|---|---|---|
| 对象头 | Mark Word(锁状态、GC 分代年龄、哈希码等)+ 类型指针(指向方法区的类元信息) | 数组对象还会额外记录数组长度 |
| 实例数据 | 各字段的实际值 | 按继承关系和声明顺序排列 |
| 对齐填充 | 无实际含义 | 保证对象起始地址是 8 字节的整数倍 |
其中 Mark Word 是一个非固定的数据结构,在不同的对象状态下(无锁、偏向锁、轻量级锁、重量级锁、GC 标记),会复用同一块比特位来存储不同的信息——非常像网络协议的报文头设计。
class Clazz {
public static int a = 1; // 类变量,存在方法区
public int b; // 实例变量,存在堆上的实例数据中
public Clazz(int b) { this.b = b; }
}
// new Clazz(2) 在堆上创建的结构:
// [对象头 | b=2 | 对齐填充]HotSpot 内部使用 OOP-Klass 模型 来表示 Java 对象:OOP(Ordinary Object Pointer)负责描述实例数据,Klass 负责描述类型信息。每个加载的 Java 类在方法区都有一个 instanceKlass 对象与之对应。这样设计的原因是:HotSpot 不想让每个对象都带一个虚函数表(vtable),所以把类型信息和实例数据拆开,由 Klass 统一持有虚函数表来实现多态。
4.3 对象内存分配策略
TLAB(Thread Local Allocation Buffer)
堆是共享的,多线程同时在 Eden 区分配对象就需要同步——太慢了。TLAB 的思路是在 Eden 区给每个线程划一小块"自留地"(默认仅占 Eden 的 1%,可通过 -XX:TLABWasteTargetPercent 调整),线程在自己的地盘上分配,无需任何锁。
对象分配流程:
1. 尝试在 TLAB 中分配 → 成功则结束
2. TLAB 不够 → 判断剩余空间是否值得保留(refill_waste 阈值)
- 剩余空间 > refill_waste → 直接在 Eden 公共区 CAS 分配
- 剩余空间 <= refill_waste → 废弃旧 TLAB,申请新 TLAB 再分配
3. Eden 满 → 触发 Minor GC需要注意:TLAB 只是在分配行为上线程独享,读取和 GC 仍然是线程共享的。对象在 TLAB 中被分配后,仍然可以被其他线程访问,仍然会被 GC 回收或移动到 Survivor、老年代。
所以,"堆是线程共享的"这句话基本正确,但不够精确。
尽管不是所有对象都能在 TLAB 中成功分配,但 JVM 确实将 TLAB 作为内存分配的首选策略。一旦 TLAB 分配失败,JVM 才会用加锁机制确保原子性,在 Eden 公共区域分配。
对象晋升老年代的条件
满足以下任一条件即可晋升:
年龄达到阈值:每熬过一次 Minor GC,对象年龄 +1。达到阈值(默认 15,可通过
-XX:MaxTenuringThreshold设置)时晋升到老年代。为什么默认是 15?因为对象头 Mark Word 中 GC 年龄字段只有 4 个 bit,最大值就是 15。动态年龄判断:把 Survivor 中所有对象按年龄从小到大累加大小,当累加到年龄 N 时超过 Survivor 空间的 50%(
TargetSurvivorRatio),则年龄 >= N 的对象全部直接晋升。这比固定阈值更灵活,能更好地适应不同应用的内存特征。注意:很多资料(包括某些经典书籍)对动态年龄判断的描述是"相同年龄对象大小的总和大于 Survivor 的一半",这是不准确的。HotSpot 源码中的逻辑是从年龄 1 开始累加,找到那个使累加值超过阈值的年龄 N。
大对象直接进老年代:可通过
-XX:PretenureSizeThreshold设置阈值(默认 0 表示不启用),超过的对象跳过新生代直接分配到老年代,避免大对象在 Eden 和 Survivor 之间反复复制。
4.4 逃逸分析与栈上分配
"所有对象都在堆上分配"——这句话并不绝对。JIT 编译器会对代码做逃逸分析,判断对象的作用域:
- 无逃逸(NoEscape):对象只在方法内部使用,从未被外部引用
- 参数逃逸(ArgEscape):对象作为参数传给了其他方法,但不会全局可见
- 全局逃逸(GlobalEscape):对象被赋值给静态变量、或作为方法返回值、或存入堆中的其他对象
// sb 没有逃逸出方法——它只在方法内部使用
private static String concat(String a, String b) {
StringBuffer sb = new StringBuffer();
sb.append(a);
sb.append(b);
return sb.toString(); // 返回的是 toString 创建的新字符串,sb 本身没逃逸
}
// 对比:sb 逃逸了——直接作为返回值暴露出去
private static StringBuffer concatEscaped(String a, String b) {
StringBuffer sb = new StringBuffer();
sb.append(a);
sb.append(b);
return sb; // sb 逃逸到方法外
}对于无逃逸的对象,JIT 可以做三种优化:
| 优化手段 | 说明 | 适用逃逸状态 |
|---|---|---|
| 栈上分配 | 对象分配在栈帧中,方法结束自动回收,无需 GC | 无逃逸 |
| 标量替换 | 把对象拆成若干基本类型变量,直接在栈上或寄存器中使用 | 无逃逸 |
| 锁消除 | 对象不会被其他线程访问,同步锁直接去掉 | 无逃逸、参数逃逸 |
HotSpot 中的"栈上分配"实际上就是通过标量替换实现的。什么是标量?无法再分解的数据就是标量(如 int、long),能分解的叫聚合量(如 Java 对象)。标量替换就是把聚合量拆散成标量,这些标量就可以住在栈上或寄存器里了。
// 标量替换示例
Point point = new Point(1, 2);
System.out.println(point.x + point.y);
// 经过逃逸分析 + 标量替换后,等价于:
int x = 1;
int y = 2;
System.out.println(x + y);
// Point 对象根本不会在堆上创建!JDK 7+ 默认开启逃逸分析(-XX:+DoEscapeAnalysis)。实验表明,创建 100 万个不逃逸的对象,关闭逃逸分析后堆上有 100 万个实例;开启后堆上只剩约 8 万个——其余的被优化到栈上了,GC 次数也明显减少。
不过逃逸分析并非银弹。它本身也需要消耗时间做复杂的分析,如果分析完发现所有对象都逃逸了,这个分析过程就白白浪费了。尽管如此,它仍然是 JIT 优化中最重要的手段之一。
4.5 Minor GC、Major GC 与 Full GC
| 类型 | 回收区域 | 触发条件 | 特点 |
|---|---|---|---|
| Minor GC | 新生代 | Eden 区满(注意:Survivor 满不直接触发) | 频繁、速度快、会 STW |
| Major GC | 老年代 | 老年代空间不足 | 速度比 Minor GC 慢约 10 倍 |
| Full GC | 整个堆 + 方法区 | 老年代不足、方法区不足、System.gc()、空间担保失败等 | 最慢,应尽量避免 |
STW(Stop The World):GC 发生时,除 GC 线程外,所有 Java 线程暂停执行。Minor GC 的 STW 通常很短暂(几毫秒到几十毫秒),Full GC 的 STW 可能长达数秒甚至更久,在生产环境中是要重点关注的性能杀手。
Full GC 的触发条件汇总:
- 调用
System.gc()(仅是建议执行,不是强制,但大多数情况下 JVM 会响应) - 老年代空间不足
- 方法区(元空间)空间不足
- Minor GC 后进入老年代的对象大小 > 老年代可用内存
- 空间分配担保失败
空间分配担保
Minor GC 前,JVM 会做一次"风险评估":老年代最大可用连续空间是否大于新生代所有对象总大小,或者大于历次晋升到老年代的平均大小?
- 如果够 → 放心执行 Minor GC
- 如果不够 → 直接触发 Full GC
JDK 7 以后简化了逻辑:不再检查 HandlePromotionFailure 参数(该参数已被移除),只要老年代连续空间大于历次晋升平均值或新生代对象总大小,就执行 Minor GC。
Minor GC 完成后可能出现三种情况:
- 存活对象 < Survivor 空间 → 进入 Survivor
- 存活对象 > Survivor 但 < 老年代可用空间 → 直接进入老年代
- 存活对象 > Survivor 且 > 老年代可用空间 → 触发 Full GC
为什么需要两个 Survivor 区?
新生代使用标记-复制算法,这个算法的前提是必须有一块空闲区域作为复制目标。如果只有一个 Survivor,Minor GC 后 Eden 和 Survivor 都有存活对象,下次 GC 时就没有空闲区域可以复制了——要么退化为标记-清除(产生碎片),要么两块区域 1:1 等分(浪费 50% 空间)。
两个 Survivor 交替使用,每次 GC 把 Eden 和正在用的 Survivor 中的存活对象复制到另一个空的 Survivor 中。同一时刻只浪费一个 Survivor(10%),内存利用率可以达到 90%。
五、方法区与元空间
5.1 从永久代到元空间的演进
方法区是 JVM 规范中的逻辑概念——规范只说"需要这么一块区域来存储类信息",但没规定怎么实现。HotSpot 对它的实现经历了一次重大"搬家":
为什么要废弃永久代?
核心原因:永久代大小受 -XX:MaxPermSize 限制,很难设对。在大量使用动态代理(Spring AOP、CGLIB)、频繁加载类(Tomcat 热部署、Groovy 脚本、大量 JSP)的场景下,很容易撑爆并抛出 OOM:PermGen space。
元空间使用本地内存,默认不设上限,能根据操作系统可用内存动态扩展,大幅降低了 OOM 的概率。同时也简化了不同 JVM 实现之间的移植和维护成本——永久代的管理逻辑过于复杂且与其他 JVM 实现不兼容。
方法区存储的主要内容:
- 类型信息:类的完整名称、父类、实现的接口、修饰符
- 域信息:字段的名称、类型、修饰符及声明顺序
- 方法信息:方法名、返回类型、参数、字节码指令、异常表
- 运行时常量池
- non-final 的类变量(静态变量)随类加载而加载,JDK 7+ 实际存在堆上
- 被
static final修饰的全局常量在编译期就完成了赋值
元空间相关参数:
-XX:MetaspaceSize:初始高水位线(默认约 21M),触及后触发 Full GC 并卸载无用类,然后根据释放情况自动调整水位线-XX:MaxMetaspaceSize:最大值(默认 -1 即不限制,但仍受系统可用内存约束)
元空间满了怎么办? 常见原因有三个:
- 类加载太多(最常见)——动态代理、CGLIB、JSP 编译等运行时生成大量类
- 类加载器泄漏——热部署时旧的类加载器没被释放,它加载的类元信息就无法回收
- MetaspaceSize 设得太小——手动限制了上限
5.2 运行时常量池
每个 .class 文件都有一个常量池表(Constant Pool Table),里面存着编译期产生的字面量(数字、字符串)和符号引用(类名、方法名、字段名的文本描述)。
为什么需要常量池?因为字节码文件中的数据支撑(类名、方法名、各种引用)通常很大,不能直接内联到字节码指令中。于是存到常量池,字节码只包含指向常量池的索引引用。
类被加载后,常量池表中的内容进入方法区的运行时常量池。与 class 文件中的常量池相比,运行时常量池有两个重要特征:
- 符号引用会被解析为直接引用——不再是文本描述,而是内存中的真实地址
- 动态性——运行期间也可以往里面放新常量,最典型的就是
String.intern()方法
常量池的索引从 1 开始(不是 0),0 是保留索引。可以用
javap -v MyClass.class查看 class 文件中的常量池内容。
5.3 字符串常量池的位置变迁
字符串常量池是 HotSpot 为了复用字符串对象而设计的缓存机制,从逻辑上可以看作运行时常量池的一个子区域。它的物理位置随版本发生了变化:
| 版本 | 字符串常量池位置 | 回收效率 |
|---|---|---|
| JDK 1.6 | 永久代 | 只有 Full GC 才会回收——效率低 |
| JDK 1.7+ | 堆 | Minor GC 和 Full GC 均可回收 |
搬到堆上的好处很明显:字符串和普通对象一样朝生夕死,放在堆里能被更高效、更及时地回收。
字符串进入常量池的两个途径:
- 字面量:代码中直接用双引号写的字符串(如
"hello"),编译后进入 class 常量池,运行时进入字符串常量池 String.intern():手动将字符串放入常量池。如果池中已有相同内容的字符串,直接返回池中的引用;否则在池中创建新的字符串
5.4 方法区的垃圾回收
JVM 规范说"可以不在方法区实现垃圾收集",但 HotSpot 确实会回收。回收目标有两类:
1. 废弃常量:常量池中没有任何地方引用的常量(包括字面量和符号引用),直接回收。判断标准和堆中对象一样——没有引用就是垃圾。
2. 不再使用的类:条件非常严格,必须同时满足三条——
- 该类的所有实例都已被回收(堆中无该类及其任何子类的实例)
- 加载该类的类加载器已被回收
- 该类对应的
java.lang.Class对象没有在任何地方被引用(无法通过反射访问)
即使三个条件都满足,也只是"允许回收",不是立即回收。这也解释了为什么频繁动态生成类时需要格外关注元空间的使用量。
六、直接内存
直接内存(Direct Memory)不属于 JVM 运行时数据区的规范定义,但在实际开发中经常用到。Java 程序可以通过 NIO 的 DirectByteBuffer 使用它。
// 分配 1KB 的堆外内存
ByteBuffer buf = ByteBuffer.allocateDirect(1024);
buf.put("hello".getBytes());
// 切换到读模式并读取
buf.flip();
byte[] data = new byte[buf.remaining()];
buf.get(data);
System.out.println(new String(data));为什么要用直接内存?
传统 IO 中数据要从操作系统内核缓冲区复制到 JVM 堆,再从堆传给应用程序——中间多了一次拷贝。直接内存省去了这一步(零拷贝思想),在 IO 密集型场景下性能更好。
代价:直接内存不受 GC 直接管理。HotSpot 通过 Cleaner 机制间接回收——堆上的 DirectByteBuffer 对象创建时会注册一个 Cleaner,当这个堆上对象被 GC 回收时,Cleaner 被触发、释放对应的堆外内存。
如果使用不当(比如 DirectByteBuffer 对象一直被强引用持有),堆外内存就无法释放,最终导致 OutOfMemoryError: Direct buffer memory。
直接内存大小可通过
-XX:MaxDirectMemorySize设置,默认与-Xmx相同。虽然不在堆上,它仍受系统可用物理内存的约束。
七、常用参数速查表
在实际开发和调优中,以下参数经常用到,集中整理方便查阅:
| 参数 | 说明 | 典型值 |
|---|---|---|
-Xms | 堆空间起始大小 | -Xms4g |
-Xmx | 堆空间最大大小 | -Xmx4g(建议与 Xms 相同) |
-Xmn | 新生代大小 | -Xmn1g |
-Xss | 线程栈大小 | -Xss512k |
-XX:NewRatio | 新生代与老年代的比例 | 2(新生代占 1/3) |
-XX:SurvivorRatio | Eden 与 Survivor 的比例 | 8(Eden 占 80%) |
-XX:MaxTenuringThreshold | 对象晋升老年代的年龄阈值 | 15 |
-XX:MetaspaceSize | 元空间初始高水位线 | 256m |
-XX:MaxMetaspaceSize | 元空间最大值 | -1(不限制) |
-XX:+UseTLAB | 是否开启 TLAB | 默认开启 |
-XX:TLABWasteTargetPercent | TLAB 占 Eden 空间的百分比 | 1 |
-XX:+DoEscapeAnalysis | 是否开启逃逸分析 | JDK 7+ 默认开启 |
-XX:+PrintGCDetails | 输出详细的 GC 日志 | 调优时使用 |
-XX:MaxDirectMemorySize | 直接内存最大值 | 默认与 Xmx 相同 |
-XX:PretenureSizeThreshold | 大对象直接进入老年代的阈值 | 0(默认不启用) |
八、遇到 OOM 怎么办?
线上出现 OutOfMemoryError 时,排查思路可以分三步走:
第一步:定位是哪种 OOM
不同的 OOM 子类型指向不同的问题区域:
| OOM 类型 | 问题区域 | 常见原因 |
|---|---|---|
Java heap space | 堆 | 对象创建过多、内存泄漏 |
Metaspace | 元空间 | 动态类加载过多、类加载器泄漏 |
Direct buffer memory | 直接内存 | NIO 缓冲区未释放 |
GC overhead limit exceeded | 堆 | GC 占用大量 CPU 但回收效果极差 |
Unable to create new native thread | 操作系统 | 线程数超出系统限制 |
Requested array size exceeds VM limit | 堆 | 尝试创建的数组超出 JVM 限制 |
第二步:抓堆转储(Heap Dump)分析
建议在启动参数中加上 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump,OOM 发生时自动生成堆转储文件。然后用内存分析工具(如 Eclipse MAT、VisualVM)打开,重点看:
- 哪些对象占了最多内存?
- 它们的 GC Roots 引用链是什么?
- 是否存在大量同类型对象未被回收?
第三步:判断是泄漏还是不足
内存泄漏:对象不该存活却无法被 GC 回收(比如被静态集合持有、被未关闭的流引用等)。顺着引用链找到泄漏点并修复。
内存不足:对象确实都需要存活。检查
-Xmx和-Xms是否可以调大,或者优化代码减少内存占用——缩短对象生命周期、使用更紧凑的数据结构、避免不必要的大对象创建等。线程私有
程序计数器
当前字节码地址
唯一无 OOM 的区域
虚拟机栈
栈帧 = 局部变量表 + 操作数栈 + 动态链接 + 返回地址
Slot 复用 与 栈顶缓存
-Xss 设置大小
StackOverflowError
本地方法栈
服务 Native 方法
HotSpot 中与虚拟机栈合一
线程共享
堆
新生代 Eden + S0 + S1
老年代 Tenured
TLAB 线程私有缓冲
逃逸分析 + 标量替换
Minor / Major / Full GC
空间分配担保
方法区
JDK7 永久代 -> JDK8 元空间
运行时常量池 具备动态性
字符串常量池 JDK7 移到堆
堆外
直接内存
NIO DirectByteBuffer
零拷贝
Cleaner 间接回收
## 九、常见面试题精选
> **面试官**:JVM 运行时内存区域有哪些?哪些是线程私有的?
>
> **思路**:先总后分,按"私有 / 共享"两类梳理,最后补充直接内存。
>
> **参考答案**:JVM 运行时数据区包括程序计数器、虚拟机栈、本地方法栈、堆和方法区(运行时常量池是方法区的一部分)。其中程序计数器、虚拟机栈、本地方法栈是线程私有的,随线程创建而分配、随线程结束而释放;堆和方法区是线程共享的,生命周期与 JVM 进程一致。此外还有不属于 JVM 规范但实际使用的直接内存(堆外内存)。
---
> **面试官**:Java 中的对象一定在堆上分配内存吗?
>
> **思路**:从逃逸分析和 JIT 优化切入,给出反例。
>
> **参考答案**:不一定。HotSpot 的 JIT 编译器会做逃逸分析,如果发现对象没有逃逸出方法,就可能通过标量替换将对象拆散为基本类型变量,分配在栈上或寄存器中,方法结束自动回收,不需要 GC。JDK 7+ 默认开启逃逸分析(`-XX:+DoEscapeAnalysis`)。但逃逸分析本身也有开销,如果分析后发现所有对象都逃逸了,这次分析就白做了——所以它并不是万能的。
---
> **面试官**:为什么 JDK 1.8 要用元空间替换永久代?
>
> **思路**:从永久代的固有缺陷和元空间的优势两方面对比。
>
> **参考答案**:永久代大小固定、受 `-XX:MaxPermSize` 限制,动态加载类多时容易 `OOM:PermGen space`。元空间使用本地内存,默认不设上限,能动态扩缩容,从根本上解决了这个问题。同时也简化了 GC 逻辑——永久代的管理逻辑复杂且与其他 JVM 实现不兼容,移除后降低了维护和移植成本。
---
> **面试官**:堆是线程共享的,那多线程分配对象怎么保证线程安全?
>
> **思路**:TLAB 机制 + CAS 兜底,两层保障。
>
> **参考答案**:HotSpot 默认开启 TLAB,在 Eden 区为每个线程划分一小块私有缓冲区(约占 Eden 的 1%),线程先在自己的 TLAB 上分配,无需同步。TLAB 用完或对象太大时,用 CAS + 失败重试的方式在 Eden 公共区域分配。所以严格来说,"堆完全是线程共享的"不够精确——TLAB 在分配行为上是线程独享的,但在读取和 GC 层面仍然是共享的。
---
> **面试官**:StackOverflowError 和 OutOfMemoryError 的区别是什么?
>
> **思路**:发生位置、触发条件、HotSpot 的具体行为。
>
> **参考答案**:StackOverflowError 发生在栈上,通常是递归过深导致栈帧超出栈容量。OutOfMemoryError 更常见于堆上,表示没有足够内存分配新对象。在 HotSpot 中栈大小固定、不能动态扩展,所以栈上只会抛 SOF 不会抛 OOM。OOM 还可能出现在元空间(`Metaspace`)、直接内存(`Direct buffer memory`)、GC 效率过低(`GC overhead limit exceeded`)、创建线程(`Unable to create new native thread`)等场景。
---
> **面试官**:新生代为什么需要两个 Survivor 区?
>
> **思路**:从标记-复制算法的前提条件出发,对比只有一个 Survivor 的问题。
>
> **参考答案**:新生代使用标记-复制算法,这个算法要求必须有一块空闲区域作为复制目标。如果只有一个 Survivor,Minor GC 后 Eden 和 Survivor 都有存活对象,下次 GC 时就没有空闲区域可以复制了。两个 Survivor 交替使用——每次 GC 把 Eden 和正在用的 Survivor 中的存活对象复制到另一个空的 Survivor 中,同一时刻只浪费一个 Survivor 的空间(10%),内存利用率可以达到 90%。如果只有两个区域做 1:1 等分来回复制,利用率只有 50%。如果 Survivor 也放不下,就通过空间分配担保机制让存活对象直接进入老年代。
## 小结
```mermaid
mindmap
root((运行时数据区))
线程私有
程序计数器
当前字节码地址
唯一无 OOM 的区域
虚拟机栈
栈帧 = 局部变量表 + 操作数栈 + 动态链接 + 返回地址
Slot 复用 与 栈顶缓存
-Xss 设置大小
StackOverflowError
本地方法栈
服务 Native 方法
HotSpot 中与虚拟机栈合一
线程共享
堆
新生代 Eden + S0 + S1
老年代 Tenured
TLAB 线程私有缓冲
逃逸分析 + 标量替换
Minor / Major / Full GC
空间分配担保
方法区
JDK7 永久代 -> JDK8 元空间
运行时常量池 具备动态性
字符串常量池 JDK7 移到堆
堆外
直接内存
NIO DirectByteBuffer
零拷贝
Cleaner 间接回收