JVM(三):类加载机制与 Java 内存模型
JVM(三):类加载机制与 Java 内存模型
导语:本篇回答「一个 .class 文件是如何变成可运行的类」与「多线程为什么需要内存模型」两个问题:类加载生命周期、双亲委派与打破(含 JDK 9 类加载器变化、TCCL、Tomcat 隔离),以及 JMM、重排序、内存屏障与伪共享,共 15 题。
一、类加载机制
1. 类加载的全过程(生命周期)是什么?
答: 一个类从被加载到卸载,经历 加载 → 连接(验证 → 准备 → 解析)→ 初始化 → 使用 → 卸载。
- 加载(Loading):通过类的全限定名获取二进制字节流(不限定来源:class 文件、jar、网络、动态生成),在方法区生成类的运行时数据结构,并在堆中生成对应的
Class对象作为方法区数据的访问入口; - 验证(Verification):校验字节码合法性——文件格式、元数据语义、字节码指令、符号引用等,防止恶意代码破坏 JVM;
- 准备(Preparation):为类静态变量分配内存并赋零值(如
static int x = 5,此时 x = 0;而static final编译期常量会直接赋初值); - 解析(Resolution):将常量池中的符号引用替换为直接引用(可在初始化后延迟进行,称为「动态解析」以支持动态绑定);
- 初始化(Initialization):执行类构造器
<clinit>()(静态变量赋值 + 静态代码块,由编译器按源码顺序合并生成)。JVM 保证<clinit>的线程安全:多线程同时初始化同类时,只有一个线程执行,其余线程阻塞等待(这也是「静态内部类单例」线程安全的底层依据); - 卸载:满足条件后由 GC 回收(见第 8 题)。
注意区分:「加载」只是「类加载过程」的一个阶段,日常口语中「类加载」常指整个生命周期,答题时要明确粒度。
2. 类加载器有哪几种?
答:
- 启动类加载器(BootstrapClassLoader):C++ 实现(JDK 9 后改为 Java 实现但逻辑上仍是「最顶层」),加载
JAVA_HOME/lib下的核心类库(JDK 8 为rt.jar,JDK 9+ 为java.base等核心模块)。无法被 Java 程序直接引用,getClassLoader()返回null; - 扩展类加载器(ExtClassLoader,JDK 8):加载
JAVA_HOME/lib/ext或java.ext.dirs指定目录下的类; - 应用程序类加载器(AppClassLoader):加载 classpath 上的用户类,是程序默认的类加载器,可通过
ClassLoader.getSystemClassLoader()获取; - 自定义类加载器:继承
ClassLoader,重写findClass()(推荐)或loadClass()。
重要纠正(JDK 9 版本变化):JDK 9 起扩展类加载器被「平台类加载器(PlatformClassLoader)」取代。模块化后:
- Bootstrap → 加载
java.base等核心模块;- Platform(
PlatformClassLoader)→ 加载 JDK 中非核心的平台模块(如java.sql、java.xml、javax.*);- App(
AppClassLoader)→ 加载 classpath 与模块路径上的应用类。三者父关系为
App → Platform → Bootstrap。回答「有哪几种类加载器」时若只答「启动/扩展/应用」而不提 JDK 9 的 Platform 变化,会被认为对版本不敏感。
3. 什么是双亲委派模型(Parents Delegation Model)?
答: 类加载请求到来时,加载器先委派给父加载器加载,父加载器无法完成(在自己的搜索范围内找不到该类)时,才由子加载器自己尝试加载。层层向上直到 Bootstrap,再由上到下依次尝试——「向上委派、向下查找」。
注意:双亲委派的父子关系不是继承关系,而是组合(持有 parent 引用)。
4. 双亲委派的好处?
答:
- 避免重复加载:父加载器加载过的类,子加载器不会重复加载,节省内存;
- 安全 / 沙箱:防止核心类库被用户自定义类篡改——例如自定义的
java.lang.String会被委派给 Bootstrap 加载,用户写的同名类永远不会被加载,保证 Java 类型体系的唯一性与安全; - 保证类型一致:核心 API 总由 Bootstrap 加载,程序中所有地方拿到的都是同一个
Class对象,行为一致。
5. 类初始化的时机(主动引用 vs 被动引用)?
答: JVM 严格规定只有主动引用才触发初始化。主动引用包括:
new实例化、读取或设置类的静态字段(非final编译期常量)、调用静态方法;- 反射调用(
Class.forName等); - 初始化子类时,先初始化其父类;
- 包含
main()的主类启动; - 接口定义了
default方法,实现类初始化时需先初始化该接口。
被动引用不触发初始化:
- 通过子类访问父类的静态字段 → 只初始化父类;
- 定义数组(
MyClass[] arr = new MyClass[10])→ 不初始化MyClass,只产生数组类型; - 访问
static final编译期常量(已内联到调用方常量池)→ 不触发类初始化。
6. Class.forName() 与 ClassLoader.loadClass() 有什么区别?
答: 两者都能「加载类」,但是否触发生命周期中的初始化阶段不同:
| 对比 | Class.forName() | ClassLoader.loadClass() |
|---|---|---|
| 阶段 | 加载 + 连接 + 初始化 | 加载 + 连接,不初始化 |
| 类加载器 | 默认使用调用者的类加载器 | 使用自身(this)类加载器 |
| 灵活性 | 有重载可控制初始化(forName(name, false, loader)) | 需手动 Class.forName(name, true, loader) 补初始化 |
| 典型用途 | 加载并初始化类(如 JDBC 注册驱动) | 只要类信息(如判定类型、SPI、字节码增强) |
// JDBC 经典写法:必须初始化,才能执行静态代码块完成驱动注册
Class.forName("com.mysql.cj.jdbc.Driver");
// 只加载不初始化
ClassLoader.getSystemClassLoader().loadClass("com.mysql.cj.jdbc.Driver");记忆:
forName会执行<clinit>,loadClass只把类「搬进来但不启动」。这正是「单例的静态代码块什么时候执行」类问题的判断依据。
7. JVM 如何判断两个类是否「相同」?
答: JVM 判定两个类相同的条件是:类的全限定名相同,且由同一个类加载器加载。两者缺一不可。
- 即使来自同一个 class 文件,被两个不同的类加载器加载后,得到的也是两个互不兼容的
Class对象:clazz1 == clazz2→false;clazz1.isAssignableFrom(clazz2)→false;- 按
clazz2创建的对象不能强转为clazz1类型(抛ClassCastException);
- 这是类加载隔离的基础:Tomcat 的 WebApp 隔离、OSGi 模块隔离、热部署(换一个类加载器重新加载同名类)都依赖这一机制。
8. 类会被卸载吗?条件是什么?
答: 会。类被卸载需要同时满足三个条件:
- 该类的所有实例都已被回收;
- 加载该类的 ClassLoader 已被回收;
- 该类的
Class对象没有被任何地方引用(无静态字段引用、无反射引用、无 JNI 引用)。
- 由 Bootstrap 加载的核心类(
java.*)几乎不可能满足条件 2,因此基本不会被卸载; - 自定义类加载器加载的、动态生成的类(CGLIB/ASM 代理类、JSP 编译类、字节码增强类)在满足条件时可被回收;
- 实践意义:动态生成类而不释放类加载器引用,会导致元空间持续增长直到
OOM: Metaspace(热部署场景的经典故障)。
二、双亲委派与它的打破
9. 如何打破双亲委派模型?
答: 双亲委派是 HotSpot 的推荐实践而非强制约束(loadClass() 的逻辑可被覆盖),常见的三种打破方式:
- 重写
loadClass():完全自定义加载逻辑,不走委派(如 Tomcat 的WebAppClassLoader先自己找、找不到再委派); - 推荐做法:重写
findClass():保留双亲委派流程,只在「父加载器找不到」时按自定义路径查找——这是扩展而不是破坏,绝大多数自定义加载器只需这样做; - 线程上下文类加载器(TCCL):通过
Thread.setContextClassLoader()把「子加载器」挂到线程上,让父加载器(如 Bootstrap)在运行期回调使用子加载器,实现「父加载器请求子加载器」的反向委派(SPI 机制的核心,见第 10 题); - 模块化(JPMS,JDK 9+):模块系统用「模块描述符 + 可读性」重写了可见性规则,Bootstrap 之外还引入了
jdk.internal.loader体系,本质上也调整了委派与可见性。
10. 为什么 JDBC 需要打破双亲委派?
答: JDBC 的核心接口(java.sql.Driver、DriverManager)由 Bootstrap 加载,而具体驱动实现(如 com.mysql.cj.jdbc.Driver)在用户 classpath,只能由 AppClassLoader 加载。
Bootstrap 无法加载应用层的类(它的搜索范围不含 classpath),于是 JDBC 采用 SPI(Service Provider Interface)机制:DriverManager(由 Bootstrap/Platform 加载)在初始化时通过
ServiceLoader.load(Driver.class); // 内部用 Thread.currentThread().getContextClassLoader()拿到线程上下文类加载器(通常是 AppClassLoader)去加载 META-INF/services/java.sql.Driver 中声明的驱动实现,从而绕过了「父加载器不能加载子加载器可见的类」的限制。
结论:TCCL 是「父加载器反向委托子加载器」的通道,SPI(JDBC、JNDI、JAXP、Spring 的
SpringFactoriesLoader)都基于它打破双亲委派。
11. Tomcat 为什么要自定义类加载器(打破双亲委派)?
答: Web 容器需要同时解决两个矛盾诉求:
- 应用间隔离:不同 WebApp 可能依赖同一个类的不同版本(如 A 应用用 Spring 4、B 应用用 Spring 5)。若都用 AppClassLoader 加载,同名类只会有一个版本,必然冲突;
- 容器与公共库共享:Tomcat 自身的类、公共依赖应被所有应用共享,避免重复加载与类型不一致。
因此 Tomcat 构建了一套分层类加载器(Common / Catalina / Shared / WebApp,其中 WebApp 每个应用一个):
WebAppClassLoader优先加载 WebApp 自身WEB-INF/classes与WEB-INF/lib中的类(即不先委派父加载器),只有找不到时才委派给 Common 等父加载器;- 同时强制排除
java.*等核心类必须走 Bootstrap(保证核心类不被应用覆盖); - 效果:应用之间同名类互不影响(隔离),容器库与应用共享(复用),这也是 Tomcat 支持热部署(重建 WebAppClassLoader 即可重新加载整个应用)的基础。
三、JMM 与内存屏障
12. JVM 内存模型(JMM)与 JVM 运行时数据区是一回事吗?
答: 不是,这是极易混淆的一对概念:
- JVM 运行时数据区(本篇前文与《JVM(一)》)是内存结构,描述堆、栈、方法区等区域的物理划分与职责;
- JMM(Java Memory Model)是并发语义规范,定义了多线程下主内存/工作内存的抽象关系、变量的读写规则、happens-before 与内存屏障,屏蔽不同硬件与操作系统的内存访问差异。
面试中若被问「内存模型」,需要根据上下文判断:并发语境通常指 JMM,JVM 语境指运行时数据区。答题时可以先做澄清,再按语境展开。
13. 什么是重排序?as-if-serial 语义是什么?
答: 重排序指编译器和处理器为了提升并行度,在不改变单线程执行结果的前提下调整指令执行顺序。分三类:
- 编译器优化重排序:JIT 在编译期调整指令顺序(如把无关的字段读取提前);
- 指令级并行重排序:CPU 流水线乱序执行、猜测执行;
- 内存系统重排序:写缓冲区(Store Buffer)/缓存导致「写入对其他 CPU 可见」的顺序与程序顺序不一致。
as-if-serial 语义:不管怎么重排序,单线程程序的执行结果不能被改变。因此存在数据依赖的操作不会被重排——数据依赖包括写后读(RAW)、写后写(WAW)、读后写(WAR)。但控制依赖(if 分支)可能被重排(投机执行),只要不改变结果。
关键点:as-if-serial 只保证单线程。多线程下重排序会破坏可见性与有序性(例如 DCL 单例中「引用赋值先于对象初始化」导致半初始化对象逸出,见《Java并发(二)》),因此才需要 volatile 与内存屏障来显式禁止特定重排。
14. 内存屏障(Memory Barrier)有哪些?volatile 如何借助它?
答: JMM 定义了四种屏障:
| 屏障类型 | 作用 |
|---|---|
| LoadLoad | 禁止前面的读与后面的读重排 |
| StoreStore | 禁止前面的写与后面的写重排 |
| LoadStore | 禁止前面的读与后面的写重排 |
| StoreLoad | 禁止前面的写与后面的读重排,开销最大(通常需要清空写缓冲区) |
volatile 的屏障插入策略(JMM 保守实现):
- 写
volatile变量:写前插StoreStore,写后插StoreLoad; - 读
volatile变量:读后插LoadLoad和LoadStore。
这组屏障保证 volatile 变量不会被其前后的读写「越过」,等价于 acquire/release 语义:写相当于 release(之前的操作全部生效后才发布),读相当于 acquire(之后的操作必然看到写前的所有结果)。
注意区分:JMM 的内存屏障是「指令重排屏障」(编译器/CPU 层面,JMM 用其实现语义),与《JVM(二)》中维护卡表的写屏障(Write Barrier,JVM 在引用赋值处插入的埋点代码)完全不是同一个东西——同名不同义,面试常被追问。
15. 什么是伪共享(False Sharing)?如何避免?
答: CPU 与内存之间以 缓存行(Cache Line,通常 64 字节)为最小单位交换数据。当两个线程分别修改位于同一缓存行中的不同变量时,虽然逻辑上互不相干,但任一方的写入都会使对方的缓存行失效,迫使对方重新从主存/L3 加载——这就是伪共享,表现为「明明没有共享变量却性能骤降」。
避免手段:
- 缓存行填充(Padding):在变量前后填充无用字段,把变量「顶」到独立缓存行;
@sun.misc.Contended(JDK 8+):给字段/类加注解,JVM 自动填充缓存行(需-XX:-RestrictContended放开用户类使用);- 线程本地累加再汇总:
LongAdder的Cell数组正是典型应用——用@Contended填充解决伪共享,从而在高并发计数场景碾压AtomicLong; ThreadLocal/ 局部变量:从根本上避免多线程写同一块内存区域。
关联考点:
ConcurrentHashMap的CounterCell、Thread的threadLocalRandomSeed等都用到了@Contended填充。
下一篇:《JVM(四)》聚焦工程落地——常见 OOM 与内存泄漏、调优参数与诊断工具、线上 CPU 飙高/频繁 Full GC 的排查链路,以及 JDK/JRE/JVM 与常量池体系。
