JVM 类加载机制详解
1. 什么是类加载
类加载(Class Loading)是指JVM将类的字节码(.class 文件)加载到内存,并为之创建Class对象的过程。Java 程序运行时,只有被加载到内存中的类才能被使用。
2. 类加载的生命周期
JVM对类的处理分为以下几个阶段:
- 加载
- 通过类的全限定名(包名+类名)查找并加载 class 文件的字节流到内存。
- 生成对应的 java.lang.Class 对象。
- 验证: 校验字节码文件的正确性、安全性。
- 准备:为类的静态变量分配内存,并设置默认初始值(不会执行静态代码块)
- 解析:将常量池中的符号引用替换为直接引用(如方法、字段等的内存地址)
- 初始化:执行类的静态初始化块和静态变量的初始化赋值
- 使用: 类被真正使用(如实例化、调用静态方法等)
- 卸载:类被垃圾回收,Class对象被回收(很少发生,通常是自定义ClassLoader加载的类才会被卸载)
3. 类加载器(ClassLoader)
JVM 通过类加载器来实现类的加载。类加载器有分层结构,主要有三种:
1. 启动类加载器(Bootstrap ClassLoader)
- 加载 JDK 的核心类库($JAVA_HOME/lib 下的类,如 rt.jar)。
- 由 C++ 实现,JVM 自己的一部分。
2. 扩展类加载器(Extension ClassLoader)
- 加载 JDK 扩展目录($JAVA_HOME/lib/ext)下的类。
3. 应用类加载器(Application ClassLoader)
- 加载用户 classpath 下的类(开发者写的代码)。
4. 自定义类加载器
- 用户可以继承 java.lang.ClassLoader 实现自己的类加载逻辑。
- 自定义类加载器默认还是会先走双亲委派模型(即先让父加载器尝试加载)。
- 只有父加载器找不到时,才会调用你重写的 findClass。
4. 双亲委派模型(Parent Delegation Model)
核心思想:
类加载请求会先委托给父加载器,只有父加载器找不到,才由当前加载器尝试加载。
流程:
- 当前类加载器收到加载请求。
- 先让父加载器尝试加载。
- 父加载器再往上递归,直到 Bootstrap ClassLoader。
- 如果父加载器都找不到,才由当前加载器加载。
优点:
- 避免重复加载。
- 保证核心类库的安全性(比如你不能伪造 java.lang.String)。
代码:
protected Class<?> loadClass(String name, boolean resolve) {
synchronized (getClassLoadingLock(name)) {
// 1. 检查类是否已加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 2. 委派给父加载器
c = parent.loadClass(name, false);
} else {
// 3. 没有父加载器则使用启动类加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {}
if (c == null) {
// 4. 父加载器无法加载时自己尝试加载
c = findClass(name);
}
}
return c;
}
}
5. 代码示例
public class Test {
static {
System.out.println("Test类被初始化");
}
public static void main(String[] args) throws Exception {
Class<?> clazz = Class.forName("Test"); // 触发类加载和初始化
}
}
6. 常见面试/考点
什么时候会触发类初始化?:
- new 对象、调用静态方法/字段、反射、子类初始化会先初始化父类等。
如何自定义类加载器?:
1. 继承java.lang.ClassLoader
2. 重写findClass()方法
3. 调用defineClass()方法将字节数组转换为Class对象
public class MyClassLoader extends ClassLoader {
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 1. 构造 class 文件的绝对路径
String fileName = classPath + "/" + name.replace('.', '/') + ".class";
try {
// 2. 读取 class 文件的字节码
byte[] classBytes = Files.readAllBytes(Paths.get(fileName));
// 3. 调用 defineClass,把字节码转换为 Class 对象
return defineClass(name, classBytes, 0, classBytes.length);
} catch (IOException e) {
throw new ClassNotFoundException("类未找到: " + name, e);
}
}
}
双亲委派模型的好处?:
1. 安全性
保证 Java 核心类库不会被篡改或伪造。
比如:你不能自己写一个 java.lang.String 类并被应用加载器加载,因为加载请求会被委托给 Bootstrap ClassLoader(引导类加载器),只有它能加载核心类库。
2. 避免类的重复加载
每个类只会被加载一次(由同一个类加载器)。
如果父加载器已经加载过某个类,子加载器不会重复加载,节省内存,避免冲突。
3. 保证类的一致性
保证同一个类在 JVM 中的唯一性(由“类的全限定名+加载它的类加载器”唯一确定)。
避免出现“同名不同类”的问题,防止类型转换异常(ClassCastException)。
4. 易于维护和扩展
类加载器之间职责分明,结构清晰。
应用开发者只需关注自己的类加载器,不用担心核心类库的加载细节。
5. 有利于模块化和隔离
不同的类加载器可以加载不同来源的类,实现模块隔离(如 Tomcat、OSGi、J2EE 容器等)
JVM 内存模型
1. 什么是 Java 内存模型?
Java 内存模型(JMM, Java Memory Model)是 Java 虚拟机规范中定义的一套关于多线程读写共享变量的规则和约定,它规定了:
- 变量的存储方式
- 线程之间如何可见和交互这些变量
- 如何保证并发下的可见性、有序性和原子性
JMM 的核心目标是屏蔽各种硬件和操作系统的内存访问差异,让 Java 程序在不同平台下有一致的并发语义。
2. JMM 的主要内容
1. 主内存与工作内存
- 主内存(Main Memory):所有线程共享的内存区域,Java 中的实例变量、静态变量都存储在这里。
- 工作内存(Working Memory):每个线程私有的内存区域,存储了该线程使用到的变量的主内存副本。
线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接操作主内存。
2. 线程间通信
- 一个线程对共享变量的修改,必须刷新回主内存,其他线程才能看到。
- 线程间变量的可见性依赖于主内存的同步。
3. JMM 的三大特性
1. 原子性(Atomicity)
- 一个操作要么全部完成,要么全部不做,不会被线程切换中断。
- Java 基本数据类型的读写是原子的(long 和 double 除外,JDK8 以后也保证了原子性)。
2. 可见性(Visibility)
- 一个线程对共享变量的修改,能及时被其他线程看到。
- 关键字 volatile、synchronized、final 都能保证可见性。
3. 有序性(Ordering)
- 程序执行的顺序按照代码的先后顺序执行(编译器和 CPU 可能会优化重排序)。
- synchronized 和 volatile 可以部分禁止重排序。
4. JMM 的“八大操作”
JMM 定义了线程与主内存之间的交互有8种原子操作:
- lock(锁定)
- unlock(解锁)
- read(读取主内存到工作内存)
- load(把read的值放入工作内存变量副本)
- use(工作内存变量用于计算)
- assign(将计算结果赋值给工作内存变量)
- store(把工作内存变量写回主内存)
- write(将store的值写入主内存变量)
5. 关键字与 JMM
- volatile:保证可见性和禁止指令重排序(部分有序性),但不保证原子性。
- synchronized:保证原子性、可见性和有序性。
- final:保证初始化后的可见性。
6. 经典问题
可见性问题:一个线程修改了变量,另一个线程看不到。
public class VisibilityDemo {
static boolean running = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while (running) {
// do something
}
System.out.println("Thread stopped.");
});
t.start();
Thread.sleep(1000);
running = false; // 主线程修改变量
System.out.println("Main thread set running = false");
}
}
/**
可能结果:
主线程已经把 running 设为 false,但子线程可能永远不会停止,因为它看不到主线程的修改(JVM 缓存了变量)。
解决方法:
用 volatile 修饰变量:static volatile boolean running = true;
**/
有序性问题:指令重排序导致并发 bug。
/**
可能结果:
按照直觉,x 和 y 至少有一个应该是 1,但实际可能都为 0。
这是因为 a=1 和 x=b 可能被重排序,b=1 和 y=a 也可能被重排序,导致两个线程都在对方赋值前读取了初始值。
解决方法:
用 volatile 或 synchronized 保证有序性。
*/
public class ReorderDemo {
static int a = 0, b = 0;
static int x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100000; i++) {
a = b = x = y = 0;
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
if (x == 0 && y == 0) {
System.out.println("出现了 x=0, y=0");
break;
}
}
}
}
原子性问题:多个线程同时操作同一个变量,导致数据不一致。
/**
期望结果:
1000 * 1000 = 1,000,000
实际结果:
通常远小于 1,000,000,因为 count++ 不是原子操作,多个线程同时读写导致丢失。
解决方法:
用 synchronized 或 AtomicInteger 替代。
*/
public class AtomicityDemo {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[1000];
for (int i = 0; i < 1000; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
count++; // 非原子操作
}
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("count = " + count);
}
}
重排序
1. 什么是重排序(Reordering)
重排序是指:为了提高程序执行效率,编译器和处理器在不影响单线程语义的前提下,可以对指令的执行顺序进行优化调整。
- 编译器优化重排序:编译器在生成字节码或机器码时改变指令顺序。
- 处理器重排序:CPU 在执行时,为了流水线、乱序执行等优化,也可能改变指令实际执行顺序。
注意:重排序不会影响单线程程序的最终结果,但在多线程环境下,可能导致线程间的可见性和有序性问题,产生并发 bug。
2. 什么是 happen-before(先行发生)原则
happen-before 是 Java 内存模型(JMM)中定义的多线程间操作的有序性规则,用来保证某些操作的结果对其他线程可见。
核心含义:
如果操作A happen-before 操作B,那么A的结果对B可见,且A的执行顺序在B之前。
happen-before 的主要规则
程序顺序规则
- 在一个线程内,代码的执行顺序,前面的操作 happen-before 后面的操作。
监视器锁规则(synchronized)
- 对一个锁的解锁 happen-before 之后对同一个锁的加锁。
volatile 变量规则
- 对一个 volatile 变量的写操作 happen-before 后面对同一个变量的读操作。
传递性
- 如果A happen-before B,B happen-before C,则A happen-before C。
线程启动规则
- 线程A启动线程B(即调用B.start()),则A中对共享变量的修改对B可见。
线程终结规则
- 线程A等待线程B结束(如B.join()),则B中对共享变量的修改对A可见。
线程中断规则
- 对线程interrupt() happen-before 检测到中断(isInterrupted())。
对象终结规则
- 对象的构造函数执行结束 happen-before 该对象的 finalize() 方法开始
3. 重排序与 happen-before 的关系
- 重排序可能导致多线程下的可见性和有序性问题。
- happen-before 规则定义了哪些操作之间必须保证“先行发生”,JVM 和编译器必须禁止这些操作之间的重排序。
4. volatile 的作用
- volatile 关键字可以禁止对该变量的读写操作与前后的普通操作发生重排序。
- 保证写入 volatile 变量之前的操作,对其他线程可见。
5. 面试常用例子
双重检查锁(DCL)单例模式:
/**
*
2. new Singleton() 实际做了什么?
instance = new Singleton(); 这行代码不是原子操作,它大致可以分为三步:
分配内存:为 Singleton 分配内存空间
调用构造方法:初始化 Singleton 对象
将 instance 指向分配的内存地址(此时 instance 不为 null)
3. 指令重排序的风险
JVM 和 CPU 为了优化性能,可能会对上述三步进行重排序,比如:
步骤1(分配内存)
步骤3(将 instance 指向内存地址)
步骤2(调用构造方法初始化)
也就是说,instance 已经不为 null,但对象还没初始化完成
4. 多线程下的危险场景
假设线程A和线程B同时进入getInstance():
线程A进入,发现instance == null,进入同步块。
线程A执行到instance = new Singleton();,发生了重排序,先把 instance 指向了内存地址(步骤3),但对象还没初始化(步骤2还没执行)。
线程B进入,发现instance != null,直接返回 instance。
线程B拿到的是一个还没初始化完成的对象,使用时就会出错(比如成员变量为默认值,甚至抛出异常)。
5. 解决办法
用 volatile 修饰 instance:
*
*/
public class Singleton {
//volatile 保证 instance 的写操作和读操作之间有 happen-before 关系,防止重排序导致的“半初始化”问题。
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 可能发生指令重排序
}
}
}
return instance;
}
}
6. 总结
- 重排序:编译器/CPU 为优化性能而调整指令顺序。
- happen-before:JMM 规定的多线程操作间的有序性约束,保证数据可见性和正确性。
- volatile/synchronized 等关键字可以建立 happen-before 关系,防止重排序带来的并发问题。
volatile
volatile 修饰的变量可以防止特定的重排序,但不是所有重排序都被禁止。
1. volatile 的“禁止重排序”语义
- 写操作:对一个 volatile 变量的写操作,之前的所有操作在内存语义上都不能被重排序到 volatile 写之后。
- 读操作:对一个 volatile 变量的读操作,之后的所有操作在内存语义上都不能被重排序到 volatile 读之前。
简化理解:
- volatile 写之前的操作不能被重排序到 volatile 写之后。
- volatile 读之后的操作不能被重排序到 volatile 读之前。
2. volatile 不能禁止的重排序
- volatile 变量之间的操作,JVM 只保证可见性和部分有序性。
- 普通变量之间的操作,如果没有和 volatile 变量的读写发生依赖,仍然可能被重排序。
3. 例子说明
int a = 0;
volatile int v = 0; //
a = 1; //这里,a = 1 不能被重排序到 v = 1 之后
v = 1;
volatile int v = 0;
int a = 0; //这里,a = 1 不能被重排序到 v = 1 之前
v = 1;
a = 1;
4. volatile 不能保证原子性
- volatile 只能保证可见性和部分有序性,不能保证原子性。
- 例如:count++ 不是原子操作,即使 count 是 volatile 修饰的,也不能保证线程安全。
5. 总结
- volatile 变量的读写会建立“内存屏障”,禁止特定的重排序。
- 但不是所有重排序都被禁止,普通变量之间的操作仍可能被重排序。
- volatile 主要用于保证多线程下的可见性和部分有序性,不是万能的并发同步工具。
- volatile 只保证与它相关的操作的有序性,普通变量之间如果没有和 volatile 变量的读写依赖,仍然可能被重排序。
- 这就是为什么在并发编程中,不能仅靠 volatile 保证所有操作的顺序。
- 普通变量之间的操作,如果没有和 volatile 变量的读写发生依赖,仍然可能被重排序。
public class VolatileReorderDemo { int a = 0; int b = 0; volatile int v = 0; /** JMM 只保证:a = 1 和 b = 2 这两句不会被重排序到 v = 3 之后。 但a = 1 和 b = 2 之间的顺序,JVM 和 CPU 仍然可以重排序(比如先执行 b = 2,再执行 a = 1),因为它们之间没有和 volatile 变量的依赖。 */ public void writer() { a = 1; // 1 b = 2; // 2 v = 3; // 3 } }
//有依赖时不会重排序 public void writer() { a = 1; v = 2; // volatile 写 b = a; // 依赖 a }