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

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)并压栈,方法结束时出栈。栈帧包含四部分:
- 局部变量表(Local Variable Table):以变量槽(Slot)为单位,存放方法参数和局部变量,32 位类型占 1 个 Slot,
long/double占 2 个,实例方法的第 0 个 Slot 恒为this。 - 操作数栈(Operand Stack):字节码指令的「工作台」,
iload/iadd/istore都在这上面进出。 - 动态链接(Dynamic Linking):指向运行时常量池的方法引用,用于符号引用转直接引用。
- 方法返回地址(Return Address):正常返回(PC 值)或异常退出(查异常处理器表)。
栈深度过大或栈帧过多会抛 StackOverflowError。注意 HotSpot 的栈是固定大小不可扩展的,因此不会出现「栈扩展导致的 OOM」,只会出现线程创建时栈空间不足的 OOM。
1 | public class StackOverflowDemo { |
这段代码揭示了一个反直觉的结论:-Xss 不是「能递归多少层」,而是「单个线程栈有多大」。局部变量多、参数多,栈帧就胖,能压的层数自然少。
2.3 本地方法栈与直接内存
本地方法栈(Native Method Stack)为 JNI 调用的 native 方法服务,HotSpot 直接把它和虚拟机栈合二为一,所以 -Xss 同时管两者。规范允许它抛 StackOverflowError 和 OutOfMemoryError。
直接内存(Direct Memory) 严格来说不属于运行时数据区,但极易引发 OOM:
1 | import java.nio.ByteBuffer; |
直接内存的特点是:分配快(省去堆内外拷贝)、回收依赖 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 的对象、大对象、空间分配担保失败的对象进入这里。
对象晋升老年代的规则:
- 年龄计数:每熬过一次 Minor GC 年龄 +1,达到
-XX:MaxTenuringThreshold(默认 15,因为 Mark Word 里 age 只有 4 bit)晋升。 - 动态年龄判定:Survivor 中同年龄对象总大小超过 Survivor 一半时,年龄 ≥ 该值的对象直接晋升,不必等到阈值。
- 大对象直接进老年代:
-XX:PretenureSizeThreshold(仅 Serial / ParNew 生效),避免大对象反复复制。 - 空间分配担保: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 | # 场景:8 核 16G 容器,JDK 17,Web 服务(Spring Boot + Netty),G1 GC |
几点说明:堆只给 50%,剩下的要留给元空间、Code Cache、直接内存、线程栈(500 线程 × 512k ≈ 250M)与 glibc 碎片,堆外膨胀才是 OOMKilled 的第一大原因;-XX:+ExitOnOutOfMemoryError 好过留一个半死的实例继续接流量;-Xss × 线程数是实打实的提交内存,一定要算账。
四、对象的一生:从字节码到内存布局
4.1 创建的完整流程
遇到一条 new 指令,HotSpot 依次做五件事:
- 类加载检查:常量池能否定位到该类的符号引用、该类是否已加载解析初始化,没有则先走类加载。
- 分配内存:对象大小在类加载完成后即可确定,规整堆用指针碰撞、不规整堆用空闲列表,并发靠 CAS + 失败重试 或 TLAB。
- 零值初始化:分配到的内存(不含对象头)全部置零,这保证实例字段不初始化也能直接访问。
- 设置对象头:写入 Mark Word(哈希、GC 年龄、锁状态)、类型指针、数组长度(数组才有)。
- 执行
<init>:按程序员意图真正赋初值,即构造方法。
4.2 new / dup / invokespecial 的三连
下面这段代码编译后是什么样?
1 | public class NewObjectBytecode { |
用 javap -c -p NewObjectBytecode 反编译 main 方法,你会看到:
1 | 0: new #7 // class NewObjectBytecode |
关键在 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 | // 依赖:org.openjdk.jol:jol-core:0.17 |
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 | public class EscapeAnalysisDemo { |
完整的判定链是:逃逸分析(-XX:+DoEscapeAnalysis)→ 标量替换(-XX:+EliminateAllocations)→ 对象不分配在堆上 → 无须 GC。此外基于逃逸分析还能做锁消除(Lock Elision):当 synchronized 的锁对象被证明不会逃逸,JIT 会直接把同步去掉。
1 | public class 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 | public class PrepareVsInit { |
静态常量的编译期折叠(Constant Folding) 还有个副作用:常量会被编译进调用方的常量池,导致编译后不再持有对该类的符号引用,从而不触发该类的初始化(这是被动引用的第三种情形,见下)。
<clinit> 是线程安全的:JVM 保证一个类的 <clinit> 在多线程环境下只被执行一次,其他线程会被阻塞;如果 <clinit> 里有耗时操作或死锁,多线程会同时卡住:
1 | public class ClinitDeadlock { |
5.3 主动引用(触发初始化)的六种场景
- 遇到
new、getstatic、putstatic、invokestatic四条字节码指令时(分别对应 new 对象、读写静态字段、调用静态方法)。 - 反射调用(
Class.forName、newInstance、方法句柄等)。 - 初始化一个类时,若其父类尚未初始化,先触发父类初始化。
- 虚拟机启动时,包含
main方法的主类。 - JDK 7 动态语言支持(
MethodHandle解析结果为REF_getStatic等静态句柄)。 - JDK 8 中接口定义了
default方法,其实现类初始化前先初始化该接口。
5.4 三种被动引用(不触发初始化)
1 | class Super { |
运行后你会看到只有 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 | // 摘抄自 java.lang.ClassLoader(JDK 17,省略注释与异常细节) |
正确的自定义方式是重写 findClass 而不是 loadClass,因为重写 loadClass 会破坏委派链,也必须自己处理锁与缓存。
6.3 双亲委派的意义
- 安全性(沙箱):防止自定义一个
java.lang.Object替换核心类。即使你写了全限定名相同的类,委派到 Bootstrap 时核心类已被加载,你的版本永远轮不到被加载。 - 避免重复加载:
findLoadedClass缓存保证同一个加载器下只有一份Class对象,从而保证类的唯一性判定(类全名 + 加载器)。
6.4 打破双亲委派的三种场景
场景一:SPI 与线程上下文类加载器。 java.sql.DriverManager、JNDI 等接口定义在核心库(由 Bootstrap 加载),实现类(MySQL 驱动 jar)却在 classpath,Bootstrap 看不到它们。JDK 于是提供 Thread.setContextClassLoader,让父加载器的代码反过来借用子加载器。
1 | import java.sql.Connection; |
场景二:Tomcat 的 WebAppClassLoader。 每个 WebApp 一个独立加载器,优先加载自己 WEB-INF/classes 与 WEB-INF/lib,加载不到才委派给父加载器——这是反向委派,目的是让两个应用用不同版本的 Spring 互不干扰;热部署时直接丢弃旧加载器,让旧类满足卸载条件。
场景三:OSGi / JDK 9 模块化。 采用网状委派而非树形,按 Import-Package / Require-Bundle 声明可见性。
6.5 自定义类加载器:完整可运行实现
下面的加载器从文件系统任意目录加载 class,并演示用两个实例实现类隔离:
1 | import java.io.IOException; |
再补一个从内存字节数组(可用于网络/加密/解密场景)加载的版本,它同时是热加载的基础:
1 | import java.nio.file.Files; |
自定义类加载器的三条铁律:① 只重写 findClass,别动 loadClass,否则双亲委派被破坏;② 构造函数里显式传入父加载器(super(parent)),避免默认把应用类加载器丢了;③ JDK 7+ 一定要 registerAsParallelCapable(),否则多线程加载类时会在加载器对象上全局串行阻塞。
七、常见 JVM 层错误与排查
7.1 StackOverflowError 的定位
成因只有两类:递归没有出口(或出口条件在实际数据下永远不成立)、调用链过深(深层嵌套的 JSON 解析、复杂正则回溯、超长责任链)。
1 | import com.fasterxml.jackson.databind.ObjectMapper; |
定位口诀:看栈、数重复、找出口。另外它是 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 | import java.util.ArrayList; |
配套的排查流程:
1 | # 1. 实时观察:老年代一路涨、Full GC 频繁但回收甚微 —— 典型泄漏特征 |
对应的修复代码:
1 | import java.util.Map; |
内存泄漏的五类典型写法,排查时可以逐个对照:① 静态集合(static List/Map)无上限地 add;② ThreadLocal 在线程池中未 remove();③ 数据库连接、文件流、HttpClient 响应未 close();④ 事件监听器/回调注册后未在销毁时反注册;⑤ 缓存无容量上限、无过期策略(自建 HashMap 做缓存是最常见的坑)。
八、高频面试题精解
- 运行时数据区有哪些?哪些线程私有? 程序计数器、虚拟机栈、本地方法栈线程私有;堆、方法区(元空间)线程共享。
- 为什么程序计数器不会 OOM? 它只存一个指令地址,大小固定、无扩缩容,规范未对其规定任何
OutOfMemoryError。 - Eden 与 Survivor 默认比例? 8 : 1 : 1,由
-XX:SurvivorRatio=8控制(Eden 是单个 Survivor 的 8 倍)。 - 对象什么时候进入老年代? 年龄达到
-XX:MaxTenuringThreshold(默认 15);Survivor 中同年龄对象总和超过其一半时动态晋升;大对象走-XX:PretenureSizeThreshold;空间分配担保失败。 - 一个
new Object()占多少字节? 开启指针压缩 16 字节(Mark Word 8 + Klass Pointer 4 + 实例数据 0,补齐到 8 的倍数)。 - 指针压缩为什么是 32G 这条线? 对象按 8 字节对齐,低 3 位恒为 0 可省略,32 bit 偏移 × 8 字节 = 32GB;越过 32G 压缩失效,引用退回 8 字节,有效容量反而下降。
- HotSpot 真有「栈上分配」吗? 严格说没有,它做的是逃逸分析 + 标量替换:对象不逃逸时干脆不创建,字段拆成局部变量。参数
-XX:+DoEscapeAnalysis、-XX:+EliminateAllocations,另有锁消除-XX:+EliminateLocks。 - 元空间和永久代的区别?为什么换掉? 永久代在堆内、靠
-XX:MaxPermSize手工预估、GC 条件苛刻;元空间在本地内存、按需伸缩、类卸载更可控。JDK 7 迁走字符串常量池与静态变量,JDK 8 正式移除永久代。 - 字符串常量池在哪? JDK 6 在永久代,JDK 7 起移到堆;而运行时常量池随方法区进了元空间。
- 准备阶段和初始化阶段分别做什么? 准备阶段给静态变量分配内存并赋零值(
static final编译期常量除外,直接赋真值);初始化阶段执行<clinit>赋真值并跑静态代码块。 - 哪些情况不触发类初始化? ① 子类引用父类的静态字段,只初始化父类;② 定义该类型的数组;③ 引用编译期已折叠进本类常量池的
static final常量。 - 双亲委派怎么实现?为什么要它?
loadClass中先查缓存 → 递归委派给父 → 父都失败才findClass。意义是安全性(防止替换java.lang.*核心类)与避免重复加载。 - 什么情况下必须打破双亲委派? SPI 用线程上下文类加载器;Tomcat 的
WebAppClassLoader反向优先加载自身以实现类隔离与热部署;OSGi 与 JDK 9 模块化用网状委派。 - 类什么时候会被卸载? 三个条件同时成立:所有实例已回收、加载它的 ClassLoader 已回收、
Class对象无引用;由 JVM 在 Full GC 时判定,无法手动触发。
学习建议:本篇概念不要死记,动手验证胜过读十遍——① 用 JOL 打印 synchronized 前后的对象头;② 用 -XX:-DoEscapeAnalysis 对照跑一亿次 new;③ 用两个自定义类加载器加载同一个 class 文件,观察 ca == cb 为 false。
系列下一篇进入垃圾回收正题:可达性分析、三色标记与漏标、G1 的 Region 与 Remembered Set、ZGC 的染色指针与读屏障。















