好的,我们开始今天的讲座,主题是深入理解Java中的JMM(Java Memory Model):解决多线程下的内存可见性。
在多线程编程中,我们经常会遇到一些看似“莫名其妙”的问题,比如一个线程修改了变量的值,另一个线程却迟迟无法看到最新的值。这些问题往往与Java内存模型(Java Memory Model,简称JMM)有关。JMM定义了Java程序中各个变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量的底层细节。理解JMM是编写正确、高效并发程序的关键。
一、为什么需要JMM?
要理解JMM存在的必要性,我们需要考虑以下几个因素:
-
CPU缓存: CPU的运行速度远快于主内存的访问速度。为了平衡这种差异,CPU引入了高速缓存(Cache)。每个CPU核心都有自己的高速缓存,用于存储频繁访问的数据。
-
指令重排序: 为了优化性能,编译器和处理器可能会对指令进行重排序。指令重排序是指在不改变程序执行结果的前提下,调整指令的执行顺序。
-
多处理器架构: 现代计算机通常是多处理器架构,每个处理器都有自己的CPU和高速缓存。
这些因素结合在一起,就可能导致多线程程序出现内存可见性问题。
二、内存可见性问题
考虑以下示例代码:
public class VisibilityExample {
private static boolean ready = false;
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (!ready) {
// 自旋等待
}
System.out.println("number = " + number);
});
t1.start();
Thread.sleep(100); // 保证t1先启动
number = 42;
ready = true;
}
}
这段代码的预期行为是:线程t1等待ready变为true,然后打印number的值,应该是42。但是,实际运行结果可能会出乎意料:
- 程序可能永远循环下去,无法打印任何内容。
- 程序可能打印出
number = 0。
这两种情况都源于内存可见性问题。
原因分析:
-
缓存不一致性: 主线程修改了
ready和number的值,这些修改可能只发生在主线程的CPU缓存中,而线程t1可能从自己的CPU缓存中读取ready的值,或者从过期的主内存副本中读取,导致它无法看到ready的最新值,从而永远循环。 -
指令重排序: 编译器或处理器可能会对
number = 42;和ready = true;进行重排序,先执行ready = true;,再执行number = 42;。这样,线程t1可能看到ready变为true,但number仍然是0。
三、JMM的抽象模型
为了解决上述问题,JMM定义了一个抽象的内存模型,它描述了线程如何与主内存交互。JMM将内存划分为两部分:
- 主内存 (Main Memory): 所有线程共享的内存区域,存储着Java程序中所有变量的实例。
- 工作内存 (Working Memory): 每个线程独有的内存区域,存储着该线程使用的变量的副本。工作内存是JMM的一个抽象概念,实际可能对应CPU的寄存器和高速缓存。
线程不能直接访问主内存中的变量,必须将变量从主内存复制到自己的工作内存中,然后才能对变量进行操作。操作完成后,再将变量的修改写回主内存。
JMM定义了线程与主内存之间的交互协议,包括以下操作:
| 操作 | 描述 |
|---|---|
read |
从主内存读取变量的值到线程的工作内存。 |
load |
将read操作从主内存中读取到的变量值放入工作内存中的变量副本。 |
use |
将工作内存中的变量值传递给执行引擎。 |
assign |
将执行引擎处理的结果赋值给工作内存中的变量。 |
store |
将工作内存中的变量值写入主内存。 |
write |
将store操作从工作内存中得到的变量值放入主内存中的变量副本。 |
lock |
作用于主内存的变量,它把一个变量标识为一条线程独占的状态。 |
unlock |
作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。 |
JMM的同步规则:
JMM还定义了一些同步规则,用于保证程序的正确性。这些规则规定了线程何时可以读取和写入共享变量。这些规则包括:
-
程序次序规则 (Program Order Rule): 在一个线程中,按照代码的顺序,前面的操作先行发生于后面的操作。
-
管程锁定规则 (Monitor Lock Rule): 对一个锁的解锁操作先行发生于后续对这个锁的加锁操作。
-
volatile变量规则 (Volatile Variable Rule): 对一个
volatile变量的写操作先行发生于后续对这个volatile变量的读操作。 -
线程启动规则 (Thread Start Rule):
Thread.start()方法先行发生于该线程的任何操作。 -
线程终止规则 (Thread Termination Rule): 线程的所有操作先行发生于该线程的终止检测。可以通过
Thread.join()方法结束、isAlive()的返回值等手段检测到线程已经终止执行。 -
线程中断规则 (Thread Interruption Rule): 对线程
interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。 -
对象终结规则 (Finalizer Rule): 一个对象的初始化完成(构造函数执行结束)先行发生于它的
finalize()方法的开始。 -
传递性 (Transitivity): 如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。
四、解决内存可见性问题:volatile关键字
volatile关键字是解决内存可见性问题的一种常用方法。当一个变量被声明为volatile时,JMM会保证:
-
可见性: 对
volatile变量的写操作会立即刷新到主内存,并且其他线程对volatile变量的读操作会从主内存中读取最新的值。 -
禁止指令重排序:
volatile可以防止指令重排序,保证程序的执行顺序与代码的顺序一致。
修改之前的VisibilityExample代码,使用volatile关键字:
public class VolatileVisibilityExample {
private static volatile boolean ready = false;
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (!ready) {
// 自旋等待
}
System.out.println("number = " + number);
});
t1.start();
Thread.sleep(100); // 保证t1先启动
number = 42;
ready = true;
}
}
现在,程序可以正确地打印出number = 42。因为ready变量被声明为volatile,主线程对ready的修改会立即刷新到主内存,并且线程t1可以从主内存中读取到ready的最新值。同时,volatile禁止了指令重排序,保证了number = 42;一定在ready = true;之前执行。
volatile的实现原理:
volatile的实现原理基于内存屏障(Memory Barrier)。内存屏障是一种CPU指令,用于强制刷新缓存,保证内存的可见性。当编译器遇到volatile关键字时,会在生成代码时插入内存屏障。不同平台和JVM实现可能使用不同的内存屏障指令。
volatile的局限性:
volatile只能保证单个变量的原子性操作。对于复合操作,volatile无法保证原子性。例如:
public class VolatileNotAtomic {
private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
count++; // count++ 不是原子操作
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("count = " + count); // 结果可能小于 10000
}
}
即使count被声明为volatile,程序的结果仍然可能小于10000。因为count++操作实际上包含了三个步骤:
- 读取
count的值。 - 将
count的值加1。 - 将结果写回
count。
这三个步骤不是原子性的,在多线程环境下,可能会发生竞态条件。
五、解决原子性问题:synchronized和Lock
要解决复合操作的原子性问题,可以使用synchronized关键字或Lock接口。
synchronized:
synchronized关键字可以用来修饰方法或代码块,保证同一时刻只有一个线程可以访问被synchronized修饰的代码。
修改之前的VolatileNotAtomic代码,使用synchronized关键字:
public class SynchronizedAtomic {
private static volatile int count = 0;
public static synchronized void increment() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
increment();
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("count = " + count); // 结果总是 10000
}
}
现在,程序的结果总是10000。因为increment()方法被声明为synchronized,保证了count++操作的原子性。
Lock:
Lock接口提供了比synchronized更灵活的锁定机制。Lock接口的常用实现类包括ReentrantLock。
修改之前的VolatileNotAtomic代码,使用ReentrantLock:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockAtomic {
private static volatile int count = 0;
private static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("count = " + count); // 结果总是 10000
}
}
同样,程序的结果总是10000。lock.lock()获取锁,lock.unlock()释放锁,保证了count++操作的原子性。
synchronized和Lock的区别:
| 特性 | synchronized | Lock |
|---|---|---|
| 灵活性 | 较差,隐式锁 | 更好,显式锁,可以实现更复杂的锁定策略。 |
| 可中断性 | 不可中断,除非抛出异常或正常结束。 | 可以中断,可以通过lockInterruptibly()方法响应中断。 |
| 公平性 | 非公平锁(默认),也可以设置为公平锁。 | 可以设置为公平锁或非公平锁。 |
| 性能 | 在JDK 1.6之后,性能得到了很大提升,甚至优于Lock。 | 在某些情况下,性能可能优于synchronized,特别是在竞争激烈的情况下。 |
| 异常处理 | 自动释放锁,即使发生异常。 | 需要手动释放锁,必须在finally块中释放锁,防止死锁。 |
六、Happens-Before原则
Happens-Before原则是JMM的核心概念,它定义了两个操作之间的happens-before关系,如果一个操作happens-before另一个操作,那么第一个操作的结果对第二个操作可见。
Happens-Before关系并不意味着第一个操作必须在第二个操作之前执行,它仅仅意味着第一个操作的结果对第二个操作可见。编译器和处理器可以对指令进行重排序,只要不违反Happens-Before原则即可。
例如,根据程序次序规则,在一个线程中,前面的操作happens-before后面的操作。这意味着,如果在一个线程中,先执行number = 42;,再执行ready = true;,那么number = 42;的结果对ready = true;可见。
七、总结
JMM定义了Java程序中各个变量的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量的底层细节。理解JMM是编写正确、高效并发程序的关键。通过使用volatile关键字、synchronized关键字或Lock接口,可以解决多线程环境下的内存可见性和原子性问题。Happens-Before原则是JMM的核心概念,它定义了两个操作之间的happens-before关系,如果一个操作happens-before另一个操作,那么第一个操作的结果对第二个操作可见。