Java 内存模型
Java内存模型(Java Memory Model,JMM)是Java虚拟机规范中定义的一种抽象概念,它描述了Java程序中各种变量(线程共享变量)的访问规则,以及在并发环境下如何保证数据的正确性。理解JMM是编写正确并发程序的基础。
什么是内存模型?
在深入JMM之前,我们需要先理解为什么需要内存模型。
硬件层面的内存问题
现代计算机系统中,CPU的运算速度远快于内存的读写速度。为了解决这个问题,现代CPU都配备了高速缓存(Cache):
计算机硬件内存架构:
┌─────────────────┐ ┌─────────────────┐
│ CPU 核心 1 │ │ CPU 核心 2 │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │ CPU │ │ │ │ CPU │ │
│ └─────┬─────┘ │ │ └─────┬─────┘ │
│ │ │ │ │ │
│ ┌────┴────┐ │ │ ┌────┴────┐ │
│ │ L1 缓存 │ │ │ │ L1 缓存 │ │
│ │(高速) │ │ │ │(高速) │ │
│ └────┬────┘ │ │ └────┬────┘ │
│ │ │ │ │ │
│ ┌────┴────┐ │ │ ┌────┴────┐ │
│ │ L2 缓存 │ │ │ │ L2 缓存 │ │
│ │(中速) │ │ │ │(中速) │ │
│ └────┬────┘ │ │ └────┬────┘ │
└────────┼────────┘ └────────┼────────┘
│ │
└───────────┬───────────────┘
↓
┌───────────────┐
│ 主内存 │ ← 速度最慢,容量最大
│ (RAM) │
└───────────────┘
缓存层次结构说明:
- L1缓存:速度最快,容量最小(通常32KB-64KB),每个核心独立
- L2缓存:速度较快,容量中等(通常256KB-1MB),每个核心独立或共享
- L3缓存:速度较慢,容量较大(通常4MB-64MB),多核心共享
- 主内存:速度最慢,容量最大, 所有核心共享
这种架构带来的问题:
- 缓存一致性问题:当多个CPU核心同时操作同一块内存区域时,各自缓存中的数据可能不一致
- 可见性问题:一个核心修改了数据,可能还在缓存中,其他核心看不到
- 性能问题:核心间同步缓存状态需要额外开销
并发编程的三个特性
在讨论内存模型时,必须理解三个核心概念:
- 原子性(Atomicity):一个操作是不可中断的,要么全部执行成功,要么全部不执行
- 可见性(Visibility):一个线程对共享变量的修改,其他线程能够立即看到
- 有序性(Ordering):程序执行的顺序按照代码的先后顺序执行
Java 内存模型的结构
JMM定义了线程和主内存之间的抽象关系:
Java内存模型(JMM)结构:
┌──────────────────────────────────────────────────────────────────┐
│ JVM 进程 │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ 线程 1 │ │ 线程 2 │ │
│ │ │ │ │ │
│ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │
│ │ │ 工作内存 │ │ │ │ 工作内存 │ │ │
│ │ │ (Working │ │ │ │ (Working │ │ │
│ │ │ Memory) │ │ │ │ Memory) │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ │ 变量a的副本 │ │ │ │ 变量a的副本 │ │ │
│ │ │ 变量b的副本 │ │ │ │ 变量c的副本 │ │ │
│ │ └───────┬───────┘ │ │ └───────┬───────┘ │ │
│ │ │ │ │ │ │ │
│ │ 线程执行 │ │ 线程执行 │ │
│ └──────────┼──────────┘ └──────────┼──────────┘ │
│ │ │ │
│ └───────────┬───────────────────┘ │
│ ↓ │
│ ┌─────────────────────┐ │
│ │ 主内存 │ ← 所有线程共享 │
│ │ (Main Memory) │ │
│ │ │ │
│ │ 变量a = 100 │ │
│ │ 变量b = "hello" │ │
│ │ 变量c = true │ │
│ └─────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
数据流动:
1. 线程读取:主内存 → 工作内存 → 线程使用
2. 线程写入:线程修改 → 工作内存 → 同步到主内存
主内存与工作内存
- 主内存:存储所有共享变量的真实值,所有线程共享(对应堆内存)
- 工作内存:每个线程独有,存储主内存中变量的副本(对应虚拟机栈、程序计数器等),线程对变量的所有操作都在工作内存中进行
内存间的交互操作
JMM定义了8种原子操作来完成内存交互:
| 操作 | 作用范围 | 描述 |
|---|---|---|
| lock | 主内存 | 把变量标识为线程独占状态 |
| unlock | 主内存 | 释放锁定的变量 |
| read | 主内存 | 把变量值从主内存传输到工作内存 |
| load | 工作内存 | 把read得到的值放入工作内存变量副本 |
| use | 工作内存 | 把工作内存变量值传递给执行引擎 |
| assign | 工作内存 | 把执行引擎的值赋给工作内存变量 |
| store | 工作内存 | 把工作内存变量值传输到主内存 |
| write | 主内存 | 把store得到的值写入主内存变量 |
原子性
原子性保证
JMM保证以下操作具有原子性:
- 基本数据类型的简单读写操作(除long和double外)
- lock和unlock操作
public class AtomicityExample {
private int count = 0;
private long timestamp = 0L;
public void increment() {
count++; // 这不是原子操作!包含读取、修改、写入三步
}
public int getCount() {
return count; // 这是原子操作
}
}
long和double的特殊情况
对于64位的long和double,JVM规范允许将读写操作拆分为两个32位的操作。但在实际开发中,大多数JVM实现都保证了对long和double的原子性读写。
保证原子性的方法
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicSolution {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // 原子操作
}
public synchronized void incrementSync() {
count++; // synchronized保证原子性
}
public void incrementLock() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
}
可见性
可见性问题
当一个线程修改了共享变量的值,其他线程可能无法立即看到修改后的值:
public class VisibilityProblem {
private boolean running = true;
public void stop() {
running = false; // 主线程修改
}
public void doWork() {
while (running) { // 工作线程可能一直看不到修改
// 执行任务
}
}
}
volatile关键字
volatile关键字保证可见性:
public class VisibilitySolution {
private volatile boolean running = true;
public void stop() {
running = false; // 对volatile变量的写,会立即刷新到主内存
}
public void doWork() {
while (running) { // 对volatile变量的读,会从主内存重新读取
// 执行任务
}
}
}
volatile的实现原理:
- 内存屏障:JVM在volatile变量的读写操作前后插入内存屏障
- 禁止指令重排序:保证有序性
- 强制刷新:写操作强制刷新到主内存,读操作强制从主内存读取
volatile读写的内存屏障:
volatile 写的内存屏障顺序:
┌─────────────────────────────────────────┐
│ volatile 写操作 │
│ │
│ [之前的操作] → StoreStore屏障 → [写操作] → StoreLoad屏障 → [之后的操作] │
└────────────────────────────────────────┘
volatile 读的内存屏障顺序:
┌─────────────────────────────────────────┐
│ volatile 读操作 │
│ │
│ [之前的操作] → LoadLoad屏障 → [读操作] → LoadStore屏障 → [之后的操作] │
└────────────────────────────────────────┘
屏障说明:
- StoreStore屏障:禁止前面的写操作与后面的volatile写操作重排序
- StoreLoad屏障:禁止前面的volatile写操作与后面的读/写操作重排序
- LoadLoad屏障:禁止前面的volatile读操作与后面的读操作重排序
- LoadStore屏障:禁止前面的volatile读操作与后面的写操作重排序
synchronized的可见性
synchronized同样保证可见性:
public class SyncVisibility {
private int count = 0;
public synchronized void increment() {
count++; // 获得锁时,清空工作内存,从主内存读取
}
public synchronized int getCount() {
return count; // 释放锁时,将工作内存刷新到主内存
}
}
有序性
指令重排序
为了提高性能,编译器和处理器会对指令进行重排序:
public class ReorderExample {
private int a = 0;
private boolean flag = false;
public void writer() {
a = 1; // 1
flag = true; // 2
}
public void reader() {
if (flag) { // 3
int i = a; // 4
}
}
}
在多线程环境下,语句1和语句2可能被重排序,导致reader方法读取到未初始化的a。
重排序的类型
指令重排序的三个层次:
指令重排序
│
├── 编译器重排序
│ ↓
│ 编译器优化,在不改变单线程执行结果的前提下重排指令顺序
│
└── 处理器重排序
│
├── 指令级并行重排序 (ILP)
│ ↓
│ 处理器使用指令级并行技术(超标量流水线)优化执行
│
└── 内存系统重排序
↓
缓存/写缓冲区的存在导致指令执行顺序看起来被重排
- 编译器重排序:编译器在不改变单线程执行结果的前提下优化指令顺序
- 处理器重排序:处理器使用指令级并行技术来优化执行效率
- 内存系统重排序:缓存的存在导致看起来像是重排序
happens-before 原则
JMM通过happens-before原则来保证有序性。如果操作A happens-before 操作B,那么A的结果对B可见。
主要的happens-before规则:
- 程序顺序规则:同一个线程中,前面的操作happens-before后面的操作
- 监视器锁规则:一个unlock操作happens-before后续对同一个锁的lock操作
- volatile规则:对volatile变量的写操作happens-before后续的读操作
- 线程启动规则:Thread.start() happens-before该线程的任何操作
- 线程终止规则:线程中的任何操作happens-before其他线程检测到该线程终止
- 传递性规则:如果A happens-before B,B happens-before C,则A happens-before C
public class HappensBeforeExample {
private int x = 0;
private volatile boolean v = false;
public void writer() {
x = 42; // 1
v = true; // 2
}
public void reader() {
if (v) { // 3
// 由于volatile规则,2 happens-before 3
// 由于传递性,1 happens-before 3
// 所以这里能看到x = 42
System.out.println(x);
}
}
}
内存屏障
JMM通过内存屏障来限制重排序:
四种内存屏障
| 屏障类型 | 描述 |
|---|---|
| LoadLoad | 确保Load1的数据在Load2及后续Load之前被读取 |
| StoreStore | 确保Store1的数据在Store2及后续Store之前被写入 |
| LoadStore | 确保Load1的数据在Store2及后续Store之前被读取 |
| StoreLoad | 确保Store1的数据在Load2及后续Load之前被写入,开销最大 |
volatile的内存屏障插入策略
// volatile写
StoreStore屏障
volatile写
StoreLoad屏障
// volatile读
volatile读
LoadLoad屏障
LoadStore屏障
实践建议
正确使用volatile
volatile适用于以下场景:
- 状态标志位:用于指示发生了一个重要的一次性事件,如完成初始化或请求停止
public class ShutdownExample {
private volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// 执行任务
}
}
}
- 单例模式的双重检查锁定
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
- 独立观察:定期发布观察结果供程序内部使用
避免常见陷阱
public class UnsafeLazyInitialization {
private static Resource resource;
public static Resource getInstance() {
if (resource == null) { // 不安全!
resource = new Resource();
}
return resource;
}
}
正确的做法:
public class SafeLazyInitialization {
private static volatile Resource resource;
public static Resource getInstance() {
if (resource == null) {
synchronized (SafeLazyInitialization.class) {
if (resource == null) {
resource = new Resource();
}
}
}
return resource;
}
}
小结
Java内存模型是Java并发编程的基础:
- 主内存与工作内存:JMM的抽象结构
- 原子性:操作不可分割的特性
- 可见性:线程间共享变量的可见性
- 有序性:程序执行的顺序性
- happens-before:判断数据竞争和线程安全的重要依据
- 内存屏障:实现JMM的具体机制
理解JMM有助于编写正确的并发程序,避免数据竞争和内存可见性问题。