Java 从入门到精通(十五):JVM 内存结构与类加载机制——从双亲委派到对象布局

本篇属于 进阶向硬核内容。文中所有代码均在 JDK 17(HotSpot 64-Bit Server VM) 上验证过,涉及 JDK 8 / 11 / 21 差异的地方会单独标注。想真正吃透,请把示例代码敲一遍,尤其是对象布局与自定义类加载器那两段。

写在前面:为什么要学这一块

很多同学写 Java 三五年,对 JVM 的认知停留在「堆放对象、栈放局部变量、方法区放类信息」。这三句话没错,但粗到无法支撑下面这些问题:8C16G 的容器 -Xmx 该设多大(设小了频繁 Full GC、设大了被 OOMKilled);一个 new Object() 在 64 位机器上占几个字节;为什么 -Xmx 还剩一大截却抛 Metaspace 的 OOM;Tomcat 里两个 WebApp 各引一个不同版本的 Spring 为什么不打架。答案全在运行时数据区和类加载子系统里。

一、JVM 整体架构:规范与实现的分野

1.1 一个必须厘清的前提:规范不等于实现

《Java Virtual Machine Specification》定义的是抽象行为契约:Class 文件格式、字节码指令集、运行时数据区、类加载、链接与初始化。它只说「方法区(Method Area)是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据」,完全不规定这块内存在哪、怎么实现。

HotSpot 是 Oracle/OpenJDK 的参考实现,此外还有 Eclipse OpenJ9(IBM J9 血统,主打小内存与快速启动)、GraalVM(可替换 C2 的 Graal 编译器 + Native Image 静态编译)、Azul Zing(C4 无停顿 GC)等。它们的内存区域划分各不相同,但对外都遵守同一份规范。

规范概念 HotSpot 实现(JDK 8) HotSpot 实现(JDK 17) OpenJ9 GraalVM Native Image
方法区 永久代 PermGen(堆内) 元空间 Metaspace(本地内存) 堆内 class 区 / AOT 缓存 编译期固化进镜像
堆 分代:新生代 + 老年代 分代(G1/ZGC 逻辑分代) 分代 / gencon 策略 有堆但无类加载
执行引擎 解释器 + C1/C2 解释器 + C1/C2 + Graal(可选) 解释器 + JIT + AOT AOT 全静态编译
编译后代码 Code Cache(本地内存) Code Cache Code Cache 镜像中的机器码

规范与实现混淆的典型后果:面试时说「方法区就是永久代」,这是把 JDK 7 之前 HotSpot 的一种实现当成了规范概念。正确的说法是:永久代和元空间都是方法区的实现方式,前者在 JDK 8 被彻底移除。

1.2 五大子系统如何协作

一个 .java 文件从磁盘走到屏幕输出,要穿过五个子系统:

子系统 核心职责 关键产出/区域 常见相关参数
类加载子系统 Class Loader Subsystem 加载、链接(验证/准备/解析)、初始化 方法区中的类型信息 -verbose:class、-Xlog:class+load
运行时数据区 Runtime Data Areas 程序计数器、虚拟机栈、本地方法栈、堆、方法区 字节码运行时的内存载体 -Xmx、-Xss、-XX:MaxMetaspaceSize
执行引擎 Execution Engine 解释器逐条解释 + JIT 热点编译 + GC 协作 Code Cache、内联缓存 -XX:CompileThreshold、-XX:TieredStopAtLevel
本地方法接口 JNI 打通 Java 与 C/C++ 世界 本地方法栈、直接内存 -XX:MaxDirectMemorySize
垃圾回收系统 GC 回收堆与方法区中的死对象 对象存活判定、整理/复制 -XX:+UseG1GC、-XX:+UseZGC

执行引擎还有一个常被忽略的成员——逃逸分析(Escape Analysis),它是 JIT 编译期的分析技术,决定对象能否被标量替换、能否消除同步锁,详见第四章。

1.3 JDK 各版本的内存结构演进

版本 关键变化 影响
JDK 6 永久代存放类元数据、运行时常量池、字符串常量池、静态变量 PermGen OOM 高发,-XX:MaxPermSize 必配
JDK 7 字符串常量池、静态变量、符号引用(SymbolTable)移出永久代到堆和本地内存 为移除永久代做准备;String.intern() 行为变化
JDK 8 永久代彻底取消,元空间(本地内存)登场;-XX:PermSize 系列参数废弃 类元数据理论上只受物理内存限制;-XX:MaxMetaspaceSize 取代
JDK 9 模块化(JEP 261);Extension ClassLoader 更名 Platform ClassLoader;G1 成为默认 GC 类加载委派关系微调;统一日志 -Xlog
JDK 11 ZGC 实验性引入;-XX:+UseContainerSupport 默认开启 容器感知内存;超大堆低延迟
JDK 17 ZGC/Shenandoah 转正;偏向锁默认禁用(JEP 374 弃用) Mark Word 偏向锁位实际不再生效
JDK 21 分代 ZGC 转正(GenerationalZGC);虚拟线程落地 新生代回收效率大幅提升

一句话记忆演进主线:JDK 6 把什么都往永久代塞 → JDK 7 往外搬 → JDK 8 干脆拆掉永久代,元数据交给本地内存的元空间。这条主线的驱动力是:永久代大小难预估、GC 触发条件诡异、且与 JRockit 融合时需要统一。

二、运行时数据区:线程私有与共享的边界

2.1 程序计数器:唯一不会 OOM 的区域

程序计数器(Program Counter Register)是一块很小的内存,记录当前线程正在执行的字节码指令地址。如果执行的是 Java 方法,它存的是字节码指令地址;如果是 native 方法,值为 undefined。

它是唯一一个在《Java 虚拟机规范》中没有规定任何 OutOfMemoryError 情况的区域——因为它就是个寄存器级别的地址变量,生命周期与线程一致,大小固定,不存在扩缩容。

为什么需要它?CPU 在多线程间切换,线程挂起后必须知道「上次执行到哪」,恢复时才能继续;分支、循环、跳转、异常处理都依赖它。

2.2 虚拟机栈:栈帧的四块内容

虚拟机栈(Java Virtual Machine Stack)是线程私有的,生命周期与线程相同。每个方法被调用时都会创建一个栈帧(Stack Frame)并压栈,方法结束时出栈。栈帧包含四部分:

  1. 局部变量表(Local Variable Table):以变量槽(Slot)为单位,存放方法参数和局部变量,32 位类型占 1 个 Slot,long/double 占 2 个,实例方法的第 0 个 Slot 恒为 this。
  2. 操作数栈(Operand Stack):字节码指令的「工作台」,iload/iadd/istore 都在这上面进出。
  3. 动态链接(Dynamic Linking):指向运行时常量池的方法引用,用于符号引用转直接引用。
  4. 方法返回地址(Return Address):正常返回(PC 值)或异常退出(查异常处理器表)。

栈深度过大或栈帧过多会抛 StackOverflowError。注意 HotSpot 的栈是固定大小不可扩展的,因此不会出现「栈扩展导致的 OOM」,只会出现线程创建时栈空间不足的 OOM。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class StackOverflowDemo {
private static int depth = 0;

// 无限递归,每调用一次压入一个栈帧,直到超出 -Xss 限制
private static void recursion() {
depth++;
recursion();
}

// 局部变量表越大,单个栈帧越大,能容纳的栈帧数越少
private static void bigFrame() {
long a1 = 1, a2 = 2, a3 = 3, a4 = 4, a5 = 5, a6 = 6, a7 = 7, a8 = 8;
long b1 = 1, b2 = 2, b3 = 3, b4 = 4, b5 = 5, b6 = 6, b7 = 7, b8 = 8;
System.out.println(a1 + a2 + a3 + a4 + a5 + a6 + a7 + a8
+ b1 + b2 + b3 + b4 + b5 + b6 + b7 + b8);
}

public static void main(String[] args) {
// 建议运行时加:java -Xss256k StackOverflowDemo
// 同样的 -Xss,栈帧越大,递归深度越浅
new Thread(() -> {
try {
recursion();
} catch (StackOverflowError e) {
System.out.println("递归深度:" + depth + ",抛出异常:" + e.getClass().getSimpleName());
}
}, "stack-demo").start();
bigFrame();
}
}

这段代码揭示了一个反直觉的结论:-Xss 不是「能递归多少层」,而是「单个线程栈有多大」。局部变量多、参数多,栈帧就胖,能压的层数自然少。

2.3 本地方法栈与直接内存

本地方法栈(Native Method Stack)为 JNI 调用的 native 方法服务,HotSpot 直接把它和虚拟机栈合二为一,所以 -Xss 同时管两者。规范允许它抛 StackOverflowError 和 OutOfMemoryError。

直接内存(Direct Memory) 严格来说不属于运行时数据区,但极易引发 OOM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.nio.ByteBuffer;

public class DirectMemoryOOM {
// -Xmx20m -XX:MaxDirectMemorySize=64m
public static void main(String[] args) throws Exception {
int count = 0;
try {
while (true) {
// 分配 1MB 堆外内存,不受 -Xmx 限制,但受 -XX:MaxDirectMemorySize 与物理内存限制
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
count++;
if (count % 16 == 0) {
System.out.println("已分配 " + count + " MB 直接内存");
}
}
} catch (OutOfMemoryError e) {
// 典型信息:java.lang.OutOfMemoryError: Direct buffer memory
System.out.println("直接内存耗尽,已分配 " + count + " MB");
throw e;
}
}
}

直接内存的特点是:分配快(省去堆内外拷贝)、回收依赖 Cleaner 与 System.gc() 的间接触发(JDK 9+ 用 Unsafe.invokeCleaner 路径优化),极易出现「堆还很空,进程却因 RSS 过高被系统杀掉」。使用 Netty 的项目尤其要盯住这一块。

2.4 堆:分代划分与对象晋升

堆是最大的一块共享内存,几乎所有对象实例和数组都在这里分配(JIT 优化后的栈上分配和标量替换是例外)。经典分代布局:

  • 新生代(Young Generation):Eden : Survivor0 : Survivor1 = 8 : 1 : 1(由 -XX:SurvivorRatio=8 控制,表示 Eden 是单个 Survivor 的 8 倍)。新对象几乎都在 Eden 出生。
  • 老年代(Old Generation):熬过多轮 Minor GC 的对象、大对象、空间分配担保失败的对象进入这里。

对象晋升老年代的规则:

  1. 年龄计数:每熬过一次 Minor GC 年龄 +1,达到 -XX:MaxTenuringThreshold(默认 15,因为 Mark Word 里 age 只有 4 bit)晋升。
  2. 动态年龄判定:Survivor 中同年龄对象总大小超过 Survivor 一半时,年龄 ≥ 该值的对象直接晋升,不必等到阈值。
  3. 大对象直接进老年代:-XX:PretenureSizeThreshold(仅 Serial / ParNew 生效),避免大对象反复复制。
  4. 空间分配担保:Minor GC 前老年代剩余空间不足,触发 Full GC。

TLAB(Thread Local Allocation Buffer) 是解决分配并发冲突的关键:堆共享,若每个线程都在 Eden 上用 CAS 抢指针冲突会很严重。HotSpot 给每个线程在 Eden 里预分配一小块私有缓冲,线程内分配走「指针碰撞」,只有 TLAB 用完续杯时才走 CAS 同步:

分配方式 适用场景 说明
指针碰撞 堆内存规整(Serial / ParNew 等带整理的收集器) 只需把指针往空闲方向挪对象大小的距离
空闲列表 堆内存不规整(CMS 等标记-清除收集器) 维护一张可用内存块清单,挑一块足够的分出去
TLAB 几乎所有场景,默认开启 -XX:+UseTLAB、-XX:TLABSize、-XX:TLABWasteTargetPercent

2.5 方法区、元空间与两个常量池的归属

方法区存储:类型信息(类名、修饰符、父类、接口)、字段与方法描述、方法字节码、运行时常量池、静态变量、JIT 编译后的代码。注意运行时常量池在方法区(元空间),字符串常量池在堆。

JDK 8 用元空间替换永久代的三个核心理由:大小难以预估(永久代靠 -XX:MaxPermSize 手工设定,类多就 OOM)、GC 效率低(永久代与老年代绑定,只有 Full GC 才顺带清一次)、融合 JRockit(JRockit 本无永久代,统一实现降低成本)。

元空间的坑在于它用的是本地内存(Native Memory),表现与堆 OOM 完全不同:抛 OutOfMemoryError: Metaspace 时堆 dump 往往很干净。成因集中在动态生成类(CGLIB、Groovy、JSP、反射 MethodAccessor 膨胀、Lambda 大量生成)与类加载器泄漏(热部署反复新建 ClassLoader 而旧类未卸载)。排查用 jstat -gcmetacapacity、jcmd <pid> VM.metaspace 与 NMT(jcmd <pid> VM.native_memory detail)。

常量池归属的变化是面试重灾区,记住这条时间线:

内容 JDK 6 JDK 7 JDK 8 及以后
字符串常量池 永久代 堆 堆
运行时常量池 永久代 永久代 元空间
类元数据 永久代 永久代 元空间
静态变量 永久代 堆(Class 对象中) 堆
符号引用 SymbolTable 永久代 本地内存 本地内存

2.6 完整对照表

区域 线程 主要内容 是否 GC OOM 表现 关键参数
程序计数器 私有 字节码指令地址 否 规范规定无 OOM 无
虚拟机栈 私有 栈帧(局部变量表/操作数栈/动态链接/返回地址) 否 StackOverflowError;线程过多时 unable to create new native thread -Xss
本地方法栈 私有 native 方法调用状态 否 同上(HotSpot 与虚拟机栈合并) -Xss
堆 共享 对象实例、数组、字符串常量池 是(主力) Java heap space、GC overhead limit exceeded -Xms -Xmx -Xmn -XX:SurvivorRatio
方法区/元空间 共享 类元数据、运行时常量池、静态变量 是(类卸载) Metaspace(JDK 8+)/ PermGen space(JDK 7-) -XX:MetaspaceSize -XX:MaxMetaspaceSize
直接内存 进程 DirectByteBuffer、JNI 分配 间接 Direct buffer memory;或进程被系统 OOM Killer 杀掉 -XX:MaxDirectMemorySize
Code Cache 进程 JIT 编译后的本地机器码 否(有清理机制) CodeCache is full(性能骤降) -XX:ReservedCodeCacheSize

三、内存参数速查与容器化实战

3.1 参数速查表

参数 含义 建议
-Xms 堆初始大小 与 -Xmx 设成一样,避免运行时扩容触发 GC 抖动
-Xmx 堆最大大小 容器内建议不超过容器 limit 的 70%
-Xmn 新生代大小 G1 下不要显式设置,交给自适应
-Xss 单线程栈大小 默认 1M(Linux x64),线程多时可降到 512k/256k
-XX:SurvivorRatio Eden 与单个 Survivor 的比例 默认 8,即 8:1:1
-XX:MaxTenuringThreshold 晋升年龄阈值 默认 15
-XX:MetaspaceSize 元空间初始高水位(触发 GC 的阈值,不是初始大小) 256m 起步
-XX:MaxMetaspaceSize 元空间上限 必配,否则可能吃光系统内存
-XX:MaxDirectMemorySize 直接内存上限 默认等于 -Xmx,Netty 应用建议显式设
-XX:+HeapDumpOnOutOfMemoryError OOM 时自动生成堆转储 生产必开
-XX:HeapDumpPath dump 落盘路径 必须是已挂载且容量足够的卷
-XX:NativeMemoryTracking=detail 开启 NMT 有 5%~10% 性能开销,排查期开启
-XX:+UseContainerSupport 容器感知 JDK 8u191+ / JDK 11+ 默认开启
-XX:MaxRAMPercentage 堆上限占容器内存的百分比 常取 70.0 ~ 75.0

3.2 容器里最容易踩的两个坑

坑一:JVM 看不到 cgroup 限制。 JDK 8u191 之前 JVM 读的是物理机内存:容器 limit 2G 而物理机 64G,它按 64G 算出默认堆(约 16G),进程一跑就被 OOMKilled。解决靠升级 JDK 或显式写死 -Xmx。

坑二:把容器内存当堆内存。 进程的 RSS ≈ 堆 + 元空间 + Code Cache + 直接内存 + 线程栈 + GC 数据结构 + glibc 碎片,堆只是一部分,「JVM 内 OOM」与「容器 OOMKilled」是两件事:

对比项 JVM 内 OutOfMemoryError 容器 OOMKilled
触发者 JVM 自身检测到资源不足 内核 cgroup OOM Killer
进程表现 抛出可捕获的 Error,进程还活着(可能半死不活) 进程被 SIGKILL,瞬间消失,无 Java 堆栈
日志位置 应用日志 / hs_err / dump 文件 dmesg、kubectl describe pod 中 OOMKilled、退出码 137
常见原因 堆/元空间/直接内存超限、线程数超限 -Xmx 设太大、堆外内存失控、线程数过多
排查手段 MAT / jvisualvm 分析 dump NMT + pmap + 调小 -Xmx 与 -Xss

3.3 生产 8C16G 推荐模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 场景:8 核 16G 容器,JDK 17,Web 服务(Spring Boot + Netty),G1 GC
java \
-server \
-Xms8g -Xmx8g \ # 8G ≈ 容器 limit 的 50%,留给堆外与页缓存;初始=最大,避免扩容抖动
-XX:MaxRAMPercentage=50.0 \ # 若希望随容器规格弹性伸缩,可改用此参数并去掉 -Xms/-Xmx
-XX:+UseG1GC \ # JDK 17 默认即 G1,显式写出便于审计
-XX:MaxGCPauseMillis=200 \ # 软目标,不是承诺值
-XX:InitiatingHeapOccupancyPercent=45 \
-Xss512k \ # 默认 1M,线程数百级时可省出数百 MB 提交内存
-XX:MetaspaceSize=256m \
-XX:MaxMetaspaceSize=512m \ # 必配,防止类加载器泄漏吃光内存
-XX:MaxDirectMemorySize=1g \ # Netty 场景必配
-XX:ReservedCodeCacheSize=256m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/dump/ \ # 必须挂载持久卷,否则容器重启 dump 就没了
-XX:+ExitOnOutOfMemoryError \ # 让 K8s 快速重启,而不是留一个半死的实例接流量
-Xlog:gc*,gc+heap=info,safepoint:file=/data/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=64m \
-Djava.security.egd=file:/dev/./urandom \
-jar app.jar

几点说明:堆只给 50%,剩下的要留给元空间、Code Cache、直接内存、线程栈(500 线程 × 512k ≈ 250M)与 glibc 碎片,堆外膨胀才是 OOMKilled 的第一大原因;-XX:+ExitOnOutOfMemoryError 好过留一个半死的实例继续接流量;-Xss × 线程数是实打实的提交内存,一定要算账。

四、对象的一生:从字节码到内存布局

4.1 创建的完整流程

遇到一条 new 指令,HotSpot 依次做五件事:

  1. 类加载检查:常量池能否定位到该类的符号引用、该类是否已加载解析初始化,没有则先走类加载。
  2. 分配内存:对象大小在类加载完成后即可确定,规整堆用指针碰撞、不规整堆用空闲列表,并发靠 CAS + 失败重试 或 TLAB。
  3. 零值初始化:分配到的内存(不含对象头)全部置零,这保证实例字段不初始化也能直接访问。
  4. 设置对象头:写入 Mark Word(哈希、GC 年龄、锁状态)、类型指针、数组长度(数组才有)。
  5. 执行 <init>:按程序员意图真正赋初值,即构造方法。

4.2 new / dup / invokespecial 的三连

下面这段代码编译后是什么样?

1
2
3
4
5
6
7
8
public class NewObjectBytecode {
private int value = 42;

public static void main(String[] args) {
NewObjectBytecode obj = new NewObjectBytecode();
obj.value = 7;
}
}

用 javap -c -p NewObjectBytecode 反编译 main 方法,你会看到:

1
2
3
4
5
6
7
8
 0: new           #7   // class NewObjectBytecode
3: dup // 复制栈顶引用:一份给 invokespecial 消耗,一份留给后续使用
4: invokespecial #9 // Method "<init>":()V
7: astore_1 // 剩下的引用存入局部变量表 slot 1
8: aload_1
9: bipush 7
11: putfield #10 // Field value:I
14: return

关键在 dup:new 只是创建对象(含零值初始化)并把引用压栈,此时对象还没构造;invokespecial 调用 <init> 会消耗掉栈顶的一个引用,如果不 dup 一份,构造完就没人能拿到这个对象了。这就是字节码层面「对象创建」与「对象构造」的分离点。

4.3 对象的内存布局

HotSpot 中对象在堆里的布局分三块:对象头(Header)+ 实例数据(Instance Data)+ 对齐填充(Padding)。

组成部分 64 位(开启指针压缩) 64 位(关闭指针压缩) 说明
Mark Word 8 字节 8 字节 哈希码、GC 分代年龄、锁状态标志、偏向线程 ID
Klass Pointer 4 字节 8 字节 指向元空间中该类的元数据
数组长度(仅数组) 4 字节 4 字节 数组对象才有
实例数据 按字段类型累加 同左 含父类继承字段,默认按宽度重排序:long/double → int/float → short/char → byte/boolean → 引用
对齐填充 补齐到 8 字节倍数 同左 HotSpot 要求对象起始地址为 8 字节对齐

Mark Word 的位布局(64 位):

锁状态 25 bit 31 bit 1 bit 4 bit 1 bit(偏向位) 2 bit(标志位)
无锁 unused identity hashcode unused 分代年龄 0 01
偏向锁 线程 ID(54 bit)+ Epoch(2 bit) — unused 分代年龄 1 01
轻量级锁 指向栈中 Lock Record 的指针(62 bit) — — — — 00
重量级锁 指向 ObjectMonitor 的指针(62 bit) — — — — 10
GC 标记 空 — — — — 11

偏向锁的现状:JEP 374 在 JDK 15 弃用偏向锁并默认关闭,JDK 18 之后相关代码被移除。因此在 JDK 17 上,上表「偏向锁」那行实际不会出现,锁升级路径简化为「无锁 → 轻量级锁 → 重量级锁」。答题时务必标注版本,否则会被认为知识陈旧。

4.4 一个 new Object() 到底多大

对象 开启压缩指针 关闭压缩指针 计算过程
new Object() 16 字节 16 字节 8(Mark Word)+ 4(Klass)+ 0(无实例数据)→ 12,补齐到 16
int[0] 16 字节 24 字节 8 + 4 + 4(数组长度)+ 0 → 16;关压缩为 8+8+4=20 → 24
new Integer(1) 16 字节 24 字节 8+4+4(int value)→ 16;关压缩为 8+8+4=20 → 24
new Long(1L) 24 字节 24 字节 8+4+8=20 → 补齐 24

可以用 JOL(Java Object Layout)实测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 依赖:org.openjdk.jol:jol-core:0.17
// import org.openjdk.jol.info.ClassLayout;
// import org.openjdk.jol.vm.VM;

public class ObjectLayoutDemo {
static class Point {
int x; // 4 字节
boolean b; // 1 字节
long l; // 8 字节
Object ref; // 4 字节(压缩指针)
}

public static void main(String[] args) {
System.out.println("VM details: " + VM.current().details());
// 未加锁时,Mark Word 后 3 bit = 001(无锁)
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
// 加锁后,Mark Word 会被替换为指向 Lock Record / Monitor 的指针,标志位变为 00 或 10
Object lock = new Object();
synchronized (lock) {
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
System.out.println(ClassLayout.parseClass(Point.class).toPrintable());
}
}

synchronized 前后打印同一对象的 Mark Word,能直观看到锁状态位从 01 变化——这是理解 synchronized 底层原理最省力的实验。

4.5 指针压缩与 32G 分界线

-XX:+UseCompressedOops(Ordinary Object Pointers)默认开启(堆 < 32G 时)。它把 64 位的对象引用压缩成 32 位存储,使用时左移 3 位再加上基地址还原。

为什么是 32G?因为 HotSpot 的对象按 8 字节对齐,地址的低 3 位永远是 0,可以省掉。32 位偏移量能表示 2^32 个不同的 8 字节单元,即 2^32 × 8 = 32 GB。堆超过 32G 时压缩失效,所有 Klass Pointer 和引用都变回 8 字节,堆的有效容量反而下降——这就是「不要轻易把堆设到 32G 以上,要么 31G 要么 48G+」这条经验的由来。若确实要开大堆又想压缩,可以调 -XX:ObjectAlignmentInBytes=16,把上限抬到 64G,代价是更多对齐填充。

4.6 对象访问定位:句柄 vs 直接指针

对象访问需要「引用 → 对象实例数据 + 对象类型数据」两步定位,有两种方案:句柄池(引用存句柄地址,句柄内含实例数据与类型数据两个指针,对象被 GC 移动时只改句柄、引用不变,但多一次间接寻址);直接指针(引用直接指向对象起始地址,类型数据靠对象头里的 Klass Pointer 找)。HotSpot 选后者,因为对象访问在 Java 中是极高频操作,省掉一次内存寻址收益巨大,代价是 GC 移动对象时要修正所有引用。

4.7 逃逸分析、标量替换与栈上分配

严格来说,HotSpot 没有做传统意义上的「栈上分配」,它做的是标量替换(Scalar Replacement):对象不逃逸出方法时干脆不创建,字段被拆成独立局部变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class EscapeAnalysisDemo {
static class User {
int id;
int age;
User(int id, int age) { this.id = id; this.age = age; }
}

// 未逃逸:user 只在方法内使用,JIT 可将其标量替换为两个 int 局部变量,不产生堆分配、无需 GC
static int sumNoEscape(int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
User user = new User(i, i + 1); // 一亿次 new,但堆上几乎不增长
sum += user.id + user.age;
}
return sum;
}

// 方法逃逸:对象作为返回值被外部引用,必须真分配在堆上
static User escapeByReturn(int id) {
return new User(id, id + 1);
}

// 线程逃逸:赋值给静态字段/被其他线程可见,也属于逃逸
static User shared;
static void escapeByThread(User u) {
shared = u;
}

public static void main(String[] args) {
long start = System.currentTimeMillis();
int r = sumNoEscape(100_000_000);
System.out.println("结果=" + r + ",耗时=" + (System.currentTimeMillis() - start) + "ms");
// 对照实验:关闭逃逸分析后耗时与 GC 次数会显著上升
// java -XX:-DoEscapeAnalysis -XX:-EliminateAllocations EscapeAnalysisDemo
// 开启时可用 -XX:+PrintEliminateAllocations(debug 版 JVM)观察标量替换日志
}
}

完整的判定链是:逃逸分析(-XX:+DoEscapeAnalysis)→ 标量替换(-XX:+EliminateAllocations)→ 对象不分配在堆上 → 无须 GC。此外基于逃逸分析还能做锁消除(Lock Elision):当 synchronized 的锁对象被证明不会逃逸,JIT 会直接把同步去掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class LockElisionDemo {
// sb 不会逃逸出方法,append 内部的 synchronized 会被 JIT 消除
static String concat(int n) {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < n; i++) {
sb.append(i);
}
return sb.toString(); // 注意:toString 让 sb 仍不逃逸(其状态被拷贝进 String)
}

public static void main(String[] args) throws Exception {
long s = System.currentTimeMillis();
concat(5_000_000);
System.out.println("耗时=" + (System.currentTimeMillis() - s) + "ms");
// 对照:java -XX:-EliminateLocks LockElisionDemo
}
}

五、类加载机制:五个阶段与初始化时机

5.1 生命周期总览

类从被加载到卸载,经历:加载(Loading)→ 验证(Verification)→ 准备(Preparation)→ 解析(Resolution)→ 初始化(Initialization)→ 使用(Using)→ 卸载(Unloading)。其中验证、准备、解析合称链接(Linking)。

注意规范只要求初始化必须在开始前完成,加载/验证/准备必须开始,解析的时机是灵活的——既可以在初始化前完成(静态解析),也可以推迟到真正使用时(动态解析 / 晚期绑定,支持多态与 invokedynamic)。

5.2 各阶段关键细节

加载:通过类的全限定名获取二进制字节流 → 把静态存储结构转为方法区的运行时数据结构 → 在堆中生成一个 java.lang.Class 对象作为访问入口。字节流可以来自 jar、网络、运行时生成(动态代理)甚至加密文件,这是自定义类加载器的理论基础。

验证:四类检查依次是文件格式验证(魔数 0xCAFEBABE、版本号)、元数据验证(语义校验,如是否继承了 final 类)、字节码验证(数据流与控制流分析、StackMapTable)、符号引用验证(能否找到对应的类/字段/方法)。

准备:为静态变量在方法区分配内存并赋零值(int → 0,boolean → false,引用 → null)。唯一例外是 static final 的编译期常量,它在准备阶段就直接拿到真值。

解析:把常量池里的符号引用替换为直接引用(指向目标的指针、偏移量或句柄)。

初始化:执行编译器生成的 <clinit>() 方法,真正执行静态变量赋值语句和静态代码块。

下面这段代码把「准备阶段赋零值」和「初始化赋真值」的差别表现得淋漓尽致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class PrepareVsInit {
// 准备阶段:a = 0;初始化阶段:a = 1
static int a = 1;

// 静态常量:编译期就折叠进常量池,准备阶段直接就是 123,不经过 <clinit>
static final int CONST = 123;

// 运行时才能确定的常量,仍走 <clinit>,准备阶段为 null
static final String RUNTIME_CONST = System.currentTimeMillis() + "";

// 经典陷阱:这里输出的是 "a=0",因为 forward 引用在准备阶段已有零值,
// 但静态代码块按书写顺序执行,此时 a 还没被赋 1
static {
System.out.println("静态代码块中 a=" + a);
a = 2;
}

public static void main(String[] args) {
System.out.println("最终 a=" + a); // 2
System.out.println("CONST=" + CONST); // 常量传播,编译期已内联
System.out.println("RUNTIME_CONST 长度=" + RUNTIME_CONST.length());
}
}

静态常量的编译期折叠(Constant Folding) 还有个副作用:常量会被编译进调用方的常量池,导致编译后不再持有对该类的符号引用,从而不触发该类的初始化(这是被动引用的第三种情形,见下)。

<clinit> 是线程安全的:JVM 保证一个类的 <clinit> 在多线程环境下只被执行一次,其他线程会被阻塞;如果 <clinit> 里有耗时操作或死锁,多线程会同时卡住:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class ClinitDeadlock {
static class A {
static {
System.out.println("A 开始初始化");
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) { }
// 触发 B 初始化,而 B 又反过来触发 A —— 同一个线程内可重入,但另一条线程交叉触发会死锁
new B();
System.out.println("A 初始化完成");
}
}

static class B {
static {
System.out.println("B 开始初始化");
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) { }
new A();
System.out.println("B 初始化完成");
}
}

public static void main(String[] args) {
// 两条线程分别先触发 A 和 B,各自持有对方的初始化锁,形成 <clinit> 死锁
new Thread(A::new, "t1").start();
new Thread(B::new, "t2").start();
// 结论:<clinit> 里绝不要做耗时、阻塞或跨类循环依赖的事
}
}

5.3 主动引用(触发初始化)的六种场景

  1. 遇到 new、getstatic、putstatic、invokestatic 四条字节码指令时(分别对应 new 对象、读写静态字段、调用静态方法)。
  2. 反射调用(Class.forName、newInstance、方法句柄等)。
  3. 初始化一个类时,若其父类尚未初始化,先触发父类初始化。
  4. 虚拟机启动时,包含 main 方法的主类。
  5. JDK 7 动态语言支持(MethodHandle 解析结果为 REF_getStatic 等静态句柄)。
  6. JDK 8 中接口定义了 default 方法,其实现类初始化前先初始化该接口。

5.4 三种被动引用(不触发初始化)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Super {
static int value = 100;
static { System.out.println("Super 初始化"); }
}

class Sub extends Super {
static { System.out.println("Sub 初始化"); }
}

interface ConstHolder {
int N = 200; // 接口字段默认 public static final,编译期常量
}

public class PassiveReference {
public static void main(String[] args) throws Exception {
// 情形一:通过子类引用父类静态字段 —— 只初始化父类,不初始化子类
System.out.println("value = " + Sub.value);

// 情形二:定义数组 —— 只触发元素类型加载,不初始化
Super[] arr = new Super[10];
System.out.println("数组长度 = " + arr.length);

// 情形三:引用编译期常量 —— 常量已折叠进本类常量池,不触发 ConstHolder 初始化
System.out.println("N = " + ConstHolder.N);

// 对照:主动引用,反射强制初始化
Class.forName("Sub");
}
}

运行后你会看到只有 Super 初始化 和 Sub 初始化(来自最后的 Class.forName),中间三次引用都没触发初始化。另外补充两点:通过数组定义引用类会触发 JVM 自动生成的数组类 [LSuper;,它由引导类加载器加载,与元素类型的加载器不同;接口与类的初始化有差异——接口没有静态代码块,初始化接口时不要求父接口全部初始化,只有真正用到父接口非常量字段时才初始化,而实现类初始化时其 default 方法所属接口会先被初始化。

5.5 类的卸载

类卸载必须同时满足三个条件,且由 JVM 在 Full GC 时判定,无法手动触发:该类的所有实例都已被回收;加载该类的 ClassLoader 已被回收;该类对应的 java.lang.Class 对象没有被任何地方引用(无反射持有)。这也是为什么类隔离必须靠独立的 ClassLoader——旧加载器还活着,旧类就永远退不掉,热部署几次就 Metaspace OOM。

六、类加载器与双亲委派

6.1 三层类加载器

加载器 JDK 8 名称 JDK 9+ 名称 实现语言 加载路径 父加载器
启动类加载器 Bootstrap ClassLoader 同名 C++(HotSpot 内) $JAVA_HOME/lib、JDK 9+ 的 java.base 等核心模块 无(null)
扩展类加载器 Extension ClassLoader Platform ClassLoader Java(sun.misc.Launcher$ExtClassLoader → jdk.internal.loader.ClassLoaders$PlatformClassLoader) $JAVA_HOME/lib/ext / jre/lib/ext Bootstrap
应用类加载器 Application ClassLoader 同名(系统类加载器) Java classpath 全部内容 Platform / Extension
自定义加载器 — — Java 任意来源 默认 Application

JDK 9 引入模块系统后有三处变化:Extension ClassLoader 更名为 Platform ClassLoader(扩展机制被模块化取代);平台类加载器在委派时可以先委派给应用类加载器(用于加载应用提供的模块服务实现);Class.forName 与 ClassLoader.getSystemClassLoader 的行为在模块化下增加了可见性约束。

6.2 双亲委派的源码逻辑

核心就在 ClassLoader.loadClass 里,逻辑极简:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 摘抄自 java.lang.ClassLoader(JDK 17,省略注释与异常细节)
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) { // 1. 按类名加锁,保证并发下同一个类只加载一次
Class<?> c = findLoadedClass(name); // 2. 先查缓存:本加载器是否已加载过
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false); // 3. 有父则递归向上委派
} else {
c = findBootstrapClassOrNull(name); // 4. 无父(即 Bootstrap)则直接尝试启动类加载器
}
} catch (ClassNotFoundException e) {
// 父加载器加载失败,说明它管不了这个类,由自己尝试
}
if (c == null) {
c = findClass(name); // 5. 父都搞不定,自己动手(子类应重写 findClass)
}
}
if (resolve) {
resolveClass(c); // 6. 可选:立即链接
}
return c;
}
}

正确的自定义方式是重写 findClass 而不是 loadClass,因为重写 loadClass 会破坏委派链,也必须自己处理锁与缓存。

6.3 双亲委派的意义

  1. 安全性(沙箱):防止自定义一个 java.lang.Object 替换核心类。即使你写了全限定名相同的类,委派到 Bootstrap 时核心类已被加载,你的版本永远轮不到被加载。
  2. 避免重复加载:findLoadedClass 缓存保证同一个加载器下只有一份 Class 对象,从而保证类的唯一性判定(类全名 + 加载器)。

6.4 打破双亲委派的三种场景

场景一:SPI 与线程上下文类加载器。 java.sql.DriverManager、JNDI 等接口定义在核心库(由 Bootstrap 加载),实现类(MySQL 驱动 jar)却在 classpath,Bootstrap 看不到它们。JDK 于是提供 Thread.setContextClassLoader,让父加载器的代码反过来借用子加载器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.ServiceLoader;

public class SpiContextClassLoaderDemo {
public static void main(String[] args) throws Exception {
// 打印三层加载器,可以看到 Bootstrap 显示为 null
ClassLoader cl = SpiContextClassLoaderDemo.class.getClassLoader();
System.out.println("AppClassLoader = " + cl);
System.out.println("PlatformClassLoader = " + cl.getParent());
System.out.println("BootstrapClassLoader = " + cl.getParent().getParent());

// DriverManager 由 Bootstrap 加载,但通过 TCCL 去加载 classpath 下的驱动实现
System.out.println("DriverManager 的加载器 = " + DriverManager.class.getClassLoader());
System.out.println("当前线程 TCCL = " + Thread.currentThread().getContextClassLoader());

// ServiceLoader 的底层实现就是 TCCL + META-INF/services 扫描
ServiceLoader<Runnable> loader = ServiceLoader.load(Runnable.class);
loader.forEach(r -> System.out.println("发现服务实现:" + r.getClass().getName()));

// JDBC 4.0+ 的 SPI 自动注册,底层就是 DriverManager 通过 TCCL 找到驱动
Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "root", "pwd");
System.out.println("连接关闭前:" + (conn != null));
if (conn != null) conn.close();
}
}

场景二:Tomcat 的 WebAppClassLoader。 每个 WebApp 一个独立加载器,优先加载自己 WEB-INF/classes 与 WEB-INF/lib,加载不到才委派给父加载器——这是反向委派,目的是让两个应用用不同版本的 Spring 互不干扰;热部署时直接丢弃旧加载器,让旧类满足卸载条件。

场景三:OSGi / JDK 9 模块化。 采用网状委派而非树形,按 Import-Package / Require-Bundle 声明可见性。

6.5 自定义类加载器:完整可运行实现

下面的加载器从文件系统任意目录加载 class,并演示用两个实例实现类隔离:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

/**
* 从指定目录加载 .class 文件的自定义类加载器。
* 要点:只重写 findClass,保留双亲委派;调用 registerAsParallelCapable 以支持并行加载。
*/
public class FileClassLoader extends ClassLoader {

static {
// JDK 7+ :声明本加载器是「可并行 capable」的,
// 否则 loadClass 会对整个 ClassLoader 对象加锁,多线程加载时严重串行化
registerAsParallelCapable();
}

private final Path classDir;
private final String name;

public FileClassLoader(String name, String classDir) {
// 把父加载器设为「系统类加载器」,保证 java.* 等核心类仍能正确委派
super(FileClassLoader.class.getClassLoader());
this.name = name;
this.classDir = Paths.get(classDir);
}

@Override
protected Class<?> findClass(String className) throws ClassNotFoundException {
byte[] bytes = loadClassBytes(className);
if (bytes == null) {
throw new ClassNotFoundException(className);
}
// defineClass 是 ClassLoader 提供的 final 方法,把字节流转为 Class 对象
return defineClass(className, bytes, 0, bytes.length);
}

private byte[] loadClassBytes(String className) throws ClassNotFoundException {
String rel = className.replace('.', '/') + ".class";
Path file = classDir.resolve(rel);
if (!Files.exists(file)) {
return null;
}
try {
return Files.readAllBytes(file);
} catch (IOException e) {
throw new ClassNotFoundException(className, e);
}
}

@Override
public String toString() {
return name;
}

// 演示:同一个 class 文件被两个不同加载器加载,得到的 Class 对象互不相等
public static void main(String[] args) throws Exception {
String dir = args.length > 0 ? args[0] : "/tmp/hot-classes";
String className = args.length > 1 ? args[1] : "demo.HelloService";

FileClassLoader a = new FileClassLoader("loader-A", dir);
FileClassLoader b = new FileClassLoader("loader-B", dir);

Class<?> ca = a.loadClass(className);
Class<?> cb = b.loadClass(className);

System.out.println("ca = " + ca);
System.out.println("cb = " + cb);
System.out.println("同一个类吗?" + (ca == cb)); // false —— 类的唯一性由「类名 + 加载器」共同决定
System.out.println("ca 的加载器 = " + ca.getClassLoader());
System.out.println("cb 的加载器 = " + cb.getClassLoader());
System.out.println("assignable?" + ca.isAssignableFrom(cb)); // false,跨加载器无法强转

// 但 java.lang.String 仍由 Bootstrap 加载,两个加载器委派结果一致
System.out.println("String 类相同吗?" + (a.loadClass("java.lang.String")
== b.loadClass("java.lang.String"))); // true —— 这正是双亲委派的价值
}
}

再补一个从内存字节数组(可用于网络/加密/解密场景)加载的版本,它同时是热加载的基础:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;

/**
* 内存类加载器:只要拿到 byte[],来源可以是网络、数据库、加密文件甚至运行时生成。
* 每次热加载都 new 一个新的 ReloadableClassLoader,旧加载器失去引用后,
* 其加载的类在下次 GC 时可被卸载,从而实现「不重启换代码」。
*/
public class ReloadableClassLoader extends ClassLoader {

static { registerAsParallelCapable(); }

private final Map<String, byte[]> bytecode = new HashMap<>();

public ReloadableClassLoader(ClassLoader parent) {
super(parent);
}

/** 预先塞入字节码,模拟从网络/数据库取到的 class 字节流 */
public void put(String className, byte[] bytes) {
bytecode.put(className, bytes);
}

public void putFromDisk(String className, String classFile) throws Exception {
put(className, Files.readAllBytes(Paths.get(classFile)));
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] bytes = bytecode.get(name);
if (bytes == null) {
// 兜底:交给父加载器体系处理(虽然 loadClass 已经委派过,这里保持健壮)
throw new ClassNotFoundException(name);
}
// 可选:在此处做解密 / 字节码增强(ASM、Javassist 插桩通常就加在这一步)
return defineClass(name, bytes, 0, bytes.length);
}

/** 模拟热加载:文件内容变化后,用新加载器重新加载,通过反射调用 */
public static void main(String[] args) throws Exception {
Path file = Paths.get("/tmp/hot-classes/demo/HelloService.class");
String className = "demo.HelloService";

ReloadableClassLoader v1 = new ReloadableClassLoader(
ReloadableClassLoader.class.getClassLoader());
v1.putFromDisk(className, file.toString());
Object s1 = v1.loadClass(className).getDeclaredConstructor().newInstance();
System.out.println("版本1 输出:" + s1.getClass().getMethod("hello").invoke(s1));

// 假设此时磁盘上的 class 已被重新编译(改动了 hello() 的返回值)
System.out.println("== 请重新编译 HelloService 后回车继续 ==");
System.in.read();

ReloadableClassLoader v2 = new ReloadableClassLoader(
ReloadableClassLoader.class.getClassLoader());
v2.putFromDisk(className, file.toString());
Object s2 = v2.loadClass(className).getDeclaredConstructor().newInstance();
System.out.println("版本2 输出:" + s2.getClass().getMethod("hello").invoke(s2));

// 两个版本的 Class 不同,说明热替换生效;v1 失去引用后可被 GC 回收
System.out.println("版本是否不同:" + (s1.getClass() != s2.getClass()));
System.out.println("是否为同一个实例类型:" + s1.getClass().isAssignableFrom(s2.getClass()));
}
}

自定义类加载器的三条铁律:① 只重写 findClass,别动 loadClass,否则双亲委派被破坏;② 构造函数里显式传入父加载器(super(parent)),避免默认把应用类加载器丢了;③ JDK 7+ 一定要 registerAsParallelCapable(),否则多线程加载类时会在加载器对象上全局串行阻塞。

七、常见 JVM 层错误与排查

7.1 StackOverflowError 的定位

成因只有两类:递归没有出口(或出口条件在实际数据下永远不成立)、调用链过深(深层嵌套的 JSON 解析、复杂正则回溯、超长责任链)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import com.fasterxml.jackson.databind.ObjectMapper;

public class DeepChainDemo {
// 1. 隐式递归:toString/hashCode/equals 之间互相调用,或 JSON 序列化双向引用
static class Node {
String name;
Node parent;
Node child;
@Override
public String toString() {
return "Node{name=" + name + ", parent=" + parent + "}"; // parent 的 toString 又会打回来
}
}

// 2. 深调用链:解析 10 万层嵌套的 JSON 数组
static void deepJson(String json) throws Exception {
new ObjectMapper().readTree(json);
}

public static void main(String[] args) throws Exception {
Node a = new Node();
Node b = new Node();
a.child = b;
b.parent = a;
try {
System.out.println(a);
} catch (StackOverflowError e) {
System.out.println("双向引用导致 toString 无限递归");
}

// 排查手段总结:
// a) 异常栈里如果同一个方法出现 N 次 → 直接看那个方法的退出条件
// b) 栈很长但每个方法只出现一次 → 调用链过深,考虑增大 -Xss 或改迭代
// c) 用 jstack <pid> 抓取线程栈,或用 Arthas 的 thread -n 5 看最深的栈
}
}

定位口诀:看栈、数重复、找出口。另外它是 Error 而非 Exception,栈已耗尽,catch 里不要做复杂操作(可能二次溢出),只做最小限度的日志记录。

7.2 六种 OutOfMemoryError

类型 直接成因 排查方向 快速缓解
Java heap space 堆中存活对象确实放不下;或内存泄漏 MAT 看 Dominator Tree、Histogram → 找 Retained Heap 最大的对象 先扩堆、再加 HeapDumpOnOutOfMemoryError;根治靠修泄漏
GC overhead limit exceeded 超过 98% 时间在做 GC 却只回收不到 2% 的堆 同上,通常是泄漏的晚期表现 别用 -XX:-UseGCOverheadLimit 硬扛,那只是把错误延后
Metaspace 类加载器泄漏、CGLIB/JSP/Lambda 动态类爆炸 jstat -gcmetacapacity、jcmd VM.metaspace、NMT;dump 里搜 ClassLoader 调大 -XX:MaxMetaspaceSize;根治靠复用加载器
Direct buffer memory ByteBuffer.allocateDirect / Netty 堆外未释放 NMT detail、BufferPoolMXBean 监控 调大 -XX:MaxDirectMemorySize;检查 release() 调用
unable to create new native thread 线程数达到 OS 限制(ulimit -u、cgroup pids limit) `ps -eLf \ wc -l、jstack` 统计线程 减小 -Xss、改线程池、升级虚拟线程
Requested array size exceeds VM limit 申请的数组长度超过 Integer.MAX_VALUE - 8 或堆放不下 看栈里是哪个数组;常来自 toArray、反序列化 校验输入,改流式处理

7.3 一个完整的 OOM 定位案例

先写一个能复现泄漏的坏味道代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
* 内存泄漏全家桶:静态集合 + ThreadLocal 未清理 + 无界缓存。
* 复现命令:java -Xmx64m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./ LeakDemo
*/
public class LeakDemo {

// 泄漏点1:静态集合持有对象,生命周期与 ClassLoader 等长,永远不会被 GC
private static final List<byte[]> STATIC_CACHE = new ArrayList<>();

// 泄漏点2:无界 HashMap 缓存,没有淘汰策略
private static final Map<String, Object> UNBOUNDED_CACHE = new HashMap<>();

// 泄漏点3:ThreadLocal 在线程池场景下,线程复用导致 value 一直挂在 Thread.threadLocals 上
private static final ThreadLocal<byte[]> TL = new ThreadLocal<>();

public static void main(String[] args) throws Exception {
var pool = Executors.newFixedThreadPool(4);

for (int i = 0; i < 4; i++) {
pool.submit(() -> {
TL.set(new byte[1024 * 512]); // 每个线程挂 512KB,不 remove 就一直留着
});
}

int count = 0;
while (true) {
STATIC_CACHE.add(new byte[1024 * 1024]); // 每次 1MB
UNBOUNDED_CACHE.put("key-" + count, new byte[64 * 1024]);
count++;
if (count % 16 == 0) {
System.out.println("已缓存 " + count + " MB");
TimeUnit.MILLISECONDS.sleep(50); // 放慢一点,方便观察 jstat
}
}
}
}

配套的排查流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. 实时观察:老年代一路涨、Full GC 频繁但回收甚微 —— 典型泄漏特征
jstat -gcutil <pid> 1000

# 2. 立刻抓堆转储(若启动时忘了加参数,可补救)
jmap -dump:live,format=b,file=/data/dump/heap.hprof <pid>

# 3. 打开 NMT 看堆外(需要启动时加 -XX:NativeMemoryTracking=detail)
jcmd <pid> VM.native_memory detail | head -60

# 4. 统计线程数,排除 unable to create new native thread
jstack <pid> | grep '^"' | wc -l

# 5. 用 MAT 打开 heap.hprof:
# Leak Suspects → Dominator Tree → 按 Retained Heap 排序
# 本例会看到 LeakDemo 的 STATIC_CACHE / UNBOUNDED_CACHE 以及 Thread.threadLocals 排在最前

对应的修复代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;

public class LeakFixDemo {
// 修复1:用有界、带淘汰策略的缓存,明确容量上限
private static final Map<String, Object> BOUNDED_CACHE =
new java.util.LinkedHashMap<>(1024, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Object> eldest) {
return size() > 1024; // 超过 1024 条淘汰最久未访问的
}
};

// 修复2:ThreadLocal 用完必须 remove,尤其是线程池场景
private static final ThreadLocal<byte[]> TL = new ThreadLocal<>();

// 修复3:try-with-resources 确保连接/流被关闭;监听器在销毁时反注册
public static void main(String[] args) throws Exception {
var pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < 4; i++) {
pool.submit(() -> {
try {
TL.set(new byte[1024 * 512]);
// ... 业务逻辑
} finally {
TL.remove(); // 关键:归还线程前清理,否则线程复用会串数据 + 泄漏
}
});
}
((ThreadPoolExecutor) pool).shutdown();
// 生产建议用 Caffeine:Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(...).build()
}
}

内存泄漏的五类典型写法,排查时可以逐个对照:① 静态集合(static List/Map)无上限地 add;② ThreadLocal 在线程池中未 remove();③ 数据库连接、文件流、HttpClient 响应未 close();④ 事件监听器/回调注册后未在销毁时反注册;⑤ 缓存无容量上限、无过期策略(自建 HashMap 做缓存是最常见的坑)。

八、高频面试题精解

  1. 运行时数据区有哪些?哪些线程私有? 程序计数器、虚拟机栈、本地方法栈线程私有;堆、方法区(元空间)线程共享。
  2. 为什么程序计数器不会 OOM? 它只存一个指令地址,大小固定、无扩缩容,规范未对其规定任何 OutOfMemoryError。
  3. Eden 与 Survivor 默认比例? 8 : 1 : 1,由 -XX:SurvivorRatio=8 控制(Eden 是单个 Survivor 的 8 倍)。
  4. 对象什么时候进入老年代? 年龄达到 -XX:MaxTenuringThreshold(默认 15);Survivor 中同年龄对象总和超过其一半时动态晋升;大对象走 -XX:PretenureSizeThreshold;空间分配担保失败。
  5. 一个 new Object() 占多少字节? 开启指针压缩 16 字节(Mark Word 8 + Klass Pointer 4 + 实例数据 0,补齐到 8 的倍数)。
  6. 指针压缩为什么是 32G 这条线? 对象按 8 字节对齐,低 3 位恒为 0 可省略,32 bit 偏移 × 8 字节 = 32GB;越过 32G 压缩失效,引用退回 8 字节,有效容量反而下降。
  7. HotSpot 真有「栈上分配」吗? 严格说没有,它做的是逃逸分析 + 标量替换:对象不逃逸时干脆不创建,字段拆成局部变量。参数 -XX:+DoEscapeAnalysis、-XX:+EliminateAllocations,另有锁消除 -XX:+EliminateLocks。
  8. 元空间和永久代的区别?为什么换掉? 永久代在堆内、靠 -XX:MaxPermSize 手工预估、GC 条件苛刻;元空间在本地内存、按需伸缩、类卸载更可控。JDK 7 迁走字符串常量池与静态变量,JDK 8 正式移除永久代。
  9. 字符串常量池在哪? JDK 6 在永久代,JDK 7 起移到堆;而运行时常量池随方法区进了元空间。
  10. 准备阶段和初始化阶段分别做什么? 准备阶段给静态变量分配内存并赋零值(static final 编译期常量除外,直接赋真值);初始化阶段执行 <clinit> 赋真值并跑静态代码块。
  11. 哪些情况不触发类初始化? ① 子类引用父类的静态字段,只初始化父类;② 定义该类型的数组;③ 引用编译期已折叠进本类常量池的 static final 常量。
  12. 双亲委派怎么实现?为什么要它? loadClass 中先查缓存 → 递归委派给父 → 父都失败才 findClass。意义是安全性(防止替换 java.lang.* 核心类)与避免重复加载。
  13. 什么情况下必须打破双亲委派? SPI 用线程上下文类加载器;Tomcat 的 WebAppClassLoader 反向优先加载自身以实现类隔离与热部署;OSGi 与 JDK 9 模块化用网状委派。
  14. 类什么时候会被卸载? 三个条件同时成立:所有实例已回收、加载它的 ClassLoader 已回收、Class 对象无引用;由 JVM 在 Full GC 时判定,无法手动触发。

学习建议:本篇概念不要死记,动手验证胜过读十遍——① 用 JOL 打印 synchronized 前后的对象头;② 用 -XX:-DoEscapeAnalysis 对照跑一亿次 new;③ 用两个自定义类加载器加载同一个 class 文件,观察 ca == cb 为 false。

系列下一篇进入垃圾回收正题:可达性分析、三色标记与漏标、G1 的 Region 与 Remembered Set、ZGC 的染色指针与读屏障。