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)OpenJ9GraalVM Native Image
方法区永久代 PermGen(堆内)元空间 Metaspace(本地内存)堆内 class 区 / AOT 缓存编译期固化进镜像
堆分代:新生代 + 老年代分代(G1/ZGC 逻辑分代)分代 / gencon 策略有堆但无类加载
执行引擎解释器 + C1/C2解释器 + C1/C2 + Graal(可选)解释器 + JIT + AOTAOT 全静态编译
编译后代码Code Cache(本地内存)Code CacheCode 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 11ZGC 实验性引入;-XX:+UseContainerSupport 默认开启容器感知内存;超大堆低延迟
JDK 17ZGC/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。

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

2.3 本地方法栈与直接内存

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

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

直接内存的特点是:分配快(省去堆内外拷贝)、回收依赖 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 VM.metaspace 与 NMT(jcmd VM.native_memory detail)。

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

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

2.6 完整对照表

区域线程主要内容是否 GCOOM 表现关键参数
程序计数器私有字节码指令地址否规范规定无 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:SurvivorRatioEden 与单个 Survivor 的比例默认 8,即 8:1:1
-XX:MaxTenuringThreshold晋升年龄阈值默认 15
-XX:MetaspaceSize元空间初始高水位(触发 GC 的阈值,不是初始大小)256m 起步
-XX:MaxMetaspaceSize元空间上限必配,否则可能吃光系统内存
-XX:MaxDirectMemorySize直接内存上限默认等于 -Xmx,Netty 应用建议显式设
-XX:+HeapDumpOnOutOfMemoryErrorOOM 时自动生成堆转储生产必开
-XX:HeapDumpPathdump 落盘路径必须是已挂载且容量足够的卷
-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 分析 dumpNMT + pmap + 调小 -Xmx 与 -Xss

3.3 生产 8C16G 推荐模板

几点说明:堆只给 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. 执行 :按程序员意图真正赋初值,即构造方法。

4.2 new / dup / invokespecial 的三连

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

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

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

4.3 对象的内存布局

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

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

Mark Word 的位布局(64 位):

锁状态25 bit31 bit1 bit4 bit1 bit(偏向位)2 bit(标志位)
无锁unusedidentity hashcodeunused分代年龄001
偏向锁线程 ID(54 bit)+ Epoch(2 bit)—unused分代年龄101
轻量级锁指向栈中 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)实测:

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):对象不逃逸出方法时干脆不创建,字段被拆成独立局部变量。

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

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

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 的编译期常量,它在准备阶段就直接拿到真值。

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

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

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

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

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

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 三种被动引用(不触发初始化)

运行后你会看到只有 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 ClassLoaderPlatform ClassLoaderJava(sun.misc.Launcher$ExtClassLoader → jdk.internal.loader.ClassLoaders$PlatformClassLoader)$JAVA_HOME/lib/ext / jre/lib/extBootstrap
应用类加载器Application ClassLoader同名(系统类加载器)Javaclasspath 全部内容Platform / Extension
自定义加载器——Java任意来源默认 Application

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

6.2 双亲委派的源码逻辑

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

正确的自定义方式是重写 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,让父加载器的代码反过来借用子加载器。

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

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

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

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

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

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

七、常见 JVM 层错误与排查

7.1 StackOverflowError 的定位

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

定位口诀:看栈、数重复、找出口。另外它是 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 memoryByteBuffer.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 定位案例

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

配套的排查流程:

对应的修复代码:

内存泄漏的五类典型写法,排查时可以逐个对照:① 静态集合(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 编译期常量除外,直接赋真值);初始化阶段执行 赋真值并跑静态代码块。
  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 的染色指针与读屏障。

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论