跳到主要内容

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核心同时操作同一块内存区域时,各自缓存中的数据可能不一致
  • 可见性问题:一个核心修改了数据,可能还在缓存中,其他核心看不到
  • 性能问题:核心间同步缓存状态需要额外开销

并发编程的三个特性

在讨论内存模型时,必须理解三个核心概念:

  1. 原子性(Atomicity):一个操作是不可中断的,要么全部执行成功,要么全部不执行
  2. 可见性(Visibility):一个线程对共享变量的修改,其他线程能够立即看到
  3. 有序性(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的实现原理

  1. 内存屏障:JVM在volatile变量的读写操作前后插入内存屏障
  2. 禁止指令重排序:保证有序性
  3. 强制刷新:写操作强制刷新到主内存,读操作强制从主内存读取

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)
│ ↓
│ 处理器使用指令级并行技术(超标量流水线)优化执行

└── 内存系统重排序

缓存/写缓冲区的存在导致指令执行顺序看起来被重排
  1. 编译器重排序:编译器在不改变单线程执行结果的前提下优化指令顺序
  2. 处理器重排序:处理器使用指令级并行技术来优化执行效率
  3. 内存系统重排序:缓存的存在导致看起来像是重排序

happens-before 原则

JMM通过happens-before原则来保证有序性。如果操作A happens-before 操作B,那么A的结果对B可见。

主要的happens-before规则

  1. 程序顺序规则:同一个线程中,前面的操作happens-before后面的操作
  2. 监视器锁规则:一个unlock操作happens-before后续对同一个锁的lock操作
  3. volatile规则:对volatile变量的写操作happens-before后续的读操作
  4. 线程启动规则:Thread.start() happens-before该线程的任何操作
  5. 线程终止规则:线程中的任何操作happens-before其他线程检测到该线程终止
  6. 传递性规则:如果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适用于以下场景:

  1. 状态标志位:用于指示发生了一个重要的一次性事件,如完成初始化或请求停止
public class ShutdownExample {
private volatile boolean shutdownRequested;

public void shutdown() {
shutdownRequested = true;
}

public void doWork() {
while (!shutdownRequested) {
// 执行任务
}
}
}
  1. 单例模式的双重检查锁定
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;
}
}
  1. 独立观察:定期发布观察结果供程序内部使用

避免常见陷阱

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并发编程的基础:

  1. 主内存与工作内存:JMM的抽象结构
  2. 原子性:操作不可分割的特性
  3. 可见性:线程间共享变量的可见性
  4. 有序性:程序执行的顺序性
  5. happens-before:判断数据竞争和线程安全的重要依据
  6. 内存屏障:实现JMM的具体机制

理解JMM有助于编写正确的并发程序,避免数据竞争和内存可见性问题。

参考资料