背景
Java 在诞生之初就喊出了“Write Once, Run Anywhere”的宏伟口号,这一愿景的实现全靠 Java 虚拟机(JVM)。它如同一台抽象的计算机,在字节码和真实硬件/操作系统之间架起桥梁,让程序员无需关心底层差异。然而随着多核CPU的普及,并发编程的噩梦也随之而来:同样的多线程代码,在 x86 服务器上平安无事,换到 ARM 架构的手机上却可能莫名其妙地崩溃。于是,Java 内存模型(JMM) 应运而生,为混乱的并发世界制定了一套统一的行为规范。
目的
- JVM 的目标:屏蔽不同操作系统和 CPU 指令集的差异,确保同一个 .class 字节码在 Windows、Linux、macOS 上产生完全相同的运行结果。
- JMM 的目标:屏蔽不同硬件内存架构(缓存、乱序执行等)带来的不确定性,确保多线程程序的并发逻辑在任何平台上都有可预测的、正确的行为。
两者协同工作,让 Java 真正做到“一次编写,到处正确运行”。
目录
一、JVM 整体架构
可以把 JVM 看作一个精密运转的工厂,它主要由三个子系统构成:
┌─────────────────────────────────────────────┐
│ JVM 架构 │
├───────────────┬─────────────┬───────────────┤
│ 类加载器 │ 运行时数据区 │ 执行引擎 │
│ (ClassLoader)│ (Runtime │ (Execution │
│ │ Data Area) │ Engine) │
└───────────────┴─────────────┴───────────────┘
| 类加载器 | 读取 .class 文件,将其转化为 JVM 内部的类元数据。 |
| 运行时数据区 | 存放程序运行过程中的所有数据,包括对象、方法调用链等。 |
| 执行引擎 | 负责解释执行字节码,并利用即时编译器(JIT)将热点代码编译为本地机器码。 |
二、运行时数据区:堆、栈与方法区
运行时数据区是 JVM 最核心的部分,也是 GC 调优和 OOM 排查的主战场。它分为线程共享区和线程隔离区两大类:
运行时数据区
├─ 线程共享
│ ├─ 堆 (Heap) ← 存放对象实例、数组,GC 发生在这里
│ └─ 方法区 (Method Area)← 存放类信息、常量、静态变量(JDK 8 后为元空间)
└─ 线程私有
├─ 虚拟机栈 (VM Stack) ← 方法调用对应的栈帧,存局部变量等
├─ 本地方法栈 (Native) ← 为 native 方法服务
└─ 程序计数器 (PC Register)← 记录线程当前执行指令的地址
问:堆、栈、年轻代、老年代到底什么关系?
答:堆是存放对象的整块内存区域,而年轻代和老年代是堆内部按分代假说划分的两个子区域。栈是线程私有的另一块区域,完全独立于堆。
- 对象在堆中的流转路径:Eden → Survivor → 老年代。
- 栈中的变量(局部变量表)持有对象的引用,指向堆中的实例。
- 栈用完自动释放,堆中的对象由 GC 负责回收。
三、类加载与双亲委派模型
问:什么是双亲委派模型?
答:当一个类加载器收到加载请求时,它首先不会自己尝试加载,而是把请求向上委托给父加载器,只有在父加载器无法完成时,自己才会加载。 三层核心加载器:
| Bootstrap ClassLoader | JAVA_HOME/lib/rt.jar 等核心类库 |
| Extension ClassLoader | JAVA_HOME/lib/ext/ 目录下的扩展库 |
| Application ClassLoader | 用户类路径(classpath)下的类 |
委托顺序:Application → Extension → Bootstrap。
问:为什么要这样设计?
答:主要有两个目的:
四、包名校验的双重防线
问:我可以自定义类加载器去加载 java.lang.String 吗?
答:不可以。 JVM 设置了两道硬防线来阻止这种行为。
- 第一道防线:双亲委派机制。在 loadClass() 方法中,加载请求层层向上委派,Bootstrap ClassLoader 在顶层就会捕获 java.lang.String 的加载请求并直接处理,自定义加载器根本没有机会执行 findClass()。
- 第二道防线:defineClass() 中的包名校验。即使你打破委派(例如重写 loadClass 直接调用 findClass),JVM 也会在 defineClass() 的 preDefineClass() 中检查类名,若发现以 java. 开头,直接抛出 SecurityException。 更深一层,JVM 的 C++ 源码中还维护了一个硬编码的禁止包名列表(例如 java/, javax/, sun/ 等),任何类定义最终都会经过此检查,完全无法绕过。
五、JMM 的设计初衷
JMM 要解决的核心矛盾是:现代 CPU 的多级缓存和指令重排序虽然提升了性能,却让多线程程序的行为变得不可预测。JMM 的使命就是在这片混乱之上建立秩序。
问:JMM 到底解决了什么问题?
答:主要解决并发编程的三大痛点:
| 可见性 | 一个线程修改了共享变量,另一个线程能否立即看到? |
| 有序性 | 代码的执行顺序是否和书写顺序一致?(指令重排序会打破) |
| 原子性 | 诸如 i++ 这样的复合操作是否会被线程切换打断? |
JMM 通过定义一套 happens-before 规则,告诉 JVM 和编译器:在哪些关键位置必须禁止重排序、刷新缓存,从而保证上述三点的正确性。
问:JVM 和 JMM 是什么关系?
答:它们是相互协作的关系,都服务于“屏蔽底层差异”的统一哲学。
- JVM 负责在纵向屏蔽不同操作系统的差异,让字节码到处运行。
- JMM 负责在横向屏蔽不同 CPU 内存架构的差异,让多线程代码到处正确。 打个比方:JVM 是“联合国同声传译”,让各国代表都能听懂同一句话;JMM 则是“交通规则”,保证各国车辆在同一条路上不会撞车。
六、happens-before 规则与实战案例
问:什么是 happens-before 规则?
答:如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,且执行顺序上 A 看起来在 B 之前。它是判断线程安全的最高准则。
核心规则速览
| 程序次序规则 | 一个线程内,前面的代码 happens-before 后面的。 |
| volatile 规则 | 对 volatile 变量的写 happens-before 后续对该变量的读。 |
| 锁规则 | 对锁的解锁 happens-before 后续对同一个锁的加锁。 |
| 传递性 | A happens-before B,B happens-before C ⇒ A happens-before C。 |
案例1:被重排序破坏的“双重检查锁定”单例
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 危险!
}
}
}
return instance;
}
}
instance = new Singleton() 内部可分解为:
步骤2和3可能被重排序,导致另一个线程拿到一个尚未初始化完成的对象,造成严重错误。 修复:将 instance 声明为 private static volatile Singleton instance,利用 volatile 的 happens-before 规则禁止重排序。
案例2:永远停不下来的线程
public class VisibilityTest {
private static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (!stop) { /* 忙等 */ }
System.out.println("线程结束");
}).start();
Thread.sleep(1000);
stop = true; // 主线程修改,子线程可能永远看不到
}
}
由于缺乏 happens-before 关系,子线程可能一直读取到自己工作内存中缓存的旧值 false,导致程序永不退出。 修复:将 stop 声明为 volatile。
总结
- JVM 架构以运行时数据区为核心,堆栈协作支撑对象生命周期,类加载器与双亲委派保障安全。
- JMM 通过一系列 happens-before 规则,在性能和正确性之间取得平衡,让多线程代码在不同硬件上表现一致。
- 两者共同构成了 Java “一次编写,到处正确运行” 的基石,理解它们是深入 Java 世界的必经之路。
本文由AI整理生成


