Java内存模型
Java内存模型,即JMM(Java Memory Model)本身是一种抽象的概念,并不真实存在。它定义了Java程序中多线程间如何通过内存进行交互的规则和规范。屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。JMM规定了变量的读取和写入如何在主内存和各线程的工作内存之间进行,保证了并发编程的原子性、可见性及有序性。内存模型解决并发问题主要采用两种方式,限制处理器优化和使用内存屏障。
原子性
原子性指的是一个操作或一组操作在执行时不可被中断,即这些操作要么全部完成,要么全部不完成。在Java中,为了保证原子性,提供了两个高级的字节码指令 monitorenter
和 monitorexit
。对应的就是Java中的关键字 synchronized
,在Java中只要被synchronized
修饰就能保证原子性。
public synchronized void increment() {
count++;
}
可见性
可见性指的是一个线程对共享变量的修改,能够及时被其他线程看到。Java提供了volatile
关键字和synchronized
关键字来保证变量的可见性。
public class SharedData {
private volatile boolean flag = false;
public void setFlag(boolean value) {
flag = value;
}
public boolean getFlag() {
return flag;
}
}
有序性
有序性指的是程序的执行顺序按照代码的顺序执行,编译器和处理器可能会进行优化,但这些优化不会影响单线程的语义。在Java中,可以使用synchronized
和volatile
来保证多线程之间操作的有序性。其中volatile
关键字会禁止编译器指令重排,来保证。synchronized
关键字保证同一时刻只允许一条线程操作,而不能禁止指令重排,指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性,从而保证了有序性。
public class Example {
private int a = 0;
private boolean flag = false;
public synchronized void write() {
a = 1; // 1
flag = true; // 2
}
public synchronized void read() {
if (flag) { // 3
System.out.println(a); // 4
}
}
}
在多线程环境下,Java语句可能会不按照顺序执行,所以要注意数据的依赖性。计算机在执行程序时,为了提高性能,编译器和处理器常常会做指令重排,一把分为以下两种:
- 单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。处理器在进行重新排序是必须要考虑指令之间的数据依赖;
- 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,结果无法预测;
限制处理器优化
处理器和编译器为了提高执行效率,会对指令进行优化重排序。虽然这种优化不会影响单线程程序的执行结果,但在多线程环境下可能导致意外的行为。Java 内存模型通过以下方式限制处理器和编译器的优化:
volatile
关键字:声明为volatile
的变量会被直接写入主内存,并且在读取时直接从主内存中读取。volatile
禁止了指令重排序,保证了变量的可见性和有序性。private volatile boolean flag = true;
synchronized
关键字:进入同步块时,会触发获取锁的操作,这会刷新线程的工作内存,从主内存中读取最新值;退出同步块时,会触发释放锁的操作,这会将工作内存中的值写回主内存。
synchronized
也禁止了指令重排序,保证了变量的原子性和可见性。public synchronized void increment() { count++; }
内存屏障
内存屏障,也称为内存栅栏,是一种用于防止处理器和编译器对内存操作进行重排序的指令。内存屏障通过插入特殊的指令来强制某些操作的顺序执行,从而确保多线程环境下的正确性。Java内存模型在底层实现中使用了内存屏障来保证内存操作的有序性和可见性。
内存屏障主要分为四种类型,在Java中内存屏障被隐式地应用于某些关键字和类中,用来确保线程安全和内存可见性。
LoadLoad
屏障:确保在该屏障之前的所有load
操作都完成后,才能执行该屏障后面的load
操作。这种屏障保证了前面的load
操作对后面的load
操作的可见性。SharedData data = ...; // 获取共享对象的引用 while (!data.flag) { // 使用 LoadLoad 屏障保证可见性 // 在这里插入 LoadLoad 屏障确保读取到最新的 flag 值 } // 使用 LoadLoad 屏障保证可见性 int result = data.x; // 3. Load 操作
StoreStore
屏障:保证在该屏障之前的所有store
操作都完成后,才能执行该屏障后面的store
操作。这确保了前面的store
操作对后面的store
操作的可见性。data.x = 42; // 1. Store 操作 // 使用 StoreStore 屏障确保顺序性 data.flag = true; // 2. Store 操作
LoadStore
屏障:确保在该屏障之前的所有load
操作都完成后,才能执行该屏障后面的store
操作。这种屏障保证了前面的load
操作对后面的store
操作的可见性。while (!data.flag) { // Spin until flag is true } // 使用 LoadStore 屏障保证顺序性 int result = data.x; // 3. Load 操作
StoreLoad
屏障:保证在该屏障之前的所有store
操作都完成后,才能执行该屏障后面的load
操作。这确保了前面的store
操作对后面的load
操作的可见性。data.x = 42; // 1. Store 操作 // 使用 StoreLoad 屏障保证可见性 data.flag = true; // 2. Store 操作 // 在另一个线程 B 中 while (!data.flag) { // Spin until flag is true } int result = data.x; // 3. Load 操作
Happens-Before
"Happens-Before"原则是Java内存模型中的一个核心概念,用来定义多个线程之间操作的执行顺序和内存可见性。如果一个操作A在另一个操作B之前,那么在内存模型中,A的所有操作结果对于B是可见的,并且A的执行顺序在B之前。
public class ProgramOrderExample {
public void example() {
int a = 1; // 1. Happens-Before
int b = a + 1; // 2. Happens-Before
}
}
需要注意的是两个操作之间存在Happens-Before关系,并不意味着Java的具体实现必须要按照Happens-Before关系指定的顺序来执行。如果重排序之后的执行结果,与按Happens-Before关系来执行的结果一致,那么JMM允许这种重排序。JMM只要求在最终的执行结果上保持与Happens-Before关系一致的语义。
public class HappensBeforeExample {
private static int x = 0;
private static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
x = 1; // Statement 1
flag = true; // Statement 2
});
Thread thread2 = new Thread(() -> {
if (flag) { // Statement 3
System.out.println("x = " + x); // Statement 4
} else {
System.out.println("flag is false");
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
}
根据Happens-Before规则:
- Statement 1 在 Statement 2 之前执行,因此
x = 1
的操作 Happens-Beforeflag = true
的操作。 - Statement 2 在 Statement 3 之前执行,因此
flag = true
的操作 Happens-Beforeif (flag)
的操作。
如果thread2
观察到flag
的值为true
,则说明 Happens-Before 原则保证了在此之前的操作结果对于其他线程是可见的。但是只要不改变程序的最终执行结果和Happens-Before关系,Java内存模型允许编译器和处理器进行指令重排序。thread1
可能会将flag
设置 true
之后才设置 x
的值为1
。这种情况下,thread2
在检查flag
之后,可能会观察到 x = 1
。这种情况仍然满足Happens-Before关系,尽管发生了重排序。
"Happens-Before"原则在Java内存模型中包含8条具体的规则:
- 程序顺序规则。在一个线程内,按照程序代码的顺序执行,前面的操作总是先于后面的操作。
int a = 1; // 1. Happens-Before int b = 2; // 2. Happens-Before
- 监视器锁规则。一个线程在同步块内部对一个锁的解锁操作,一定早于另一个线程对同一个锁的加锁操作。
synchronized(lock) { // 操作 A } // 锁的释放 Happens-Before 后续的加锁 synchronized(lock) { // 操作 B }
volatile
变量规则。对一个volatile
变量的写操作,一定早于随后对这个变量的读操作。volatile boolean flag = false; flag = true; // 写操作 Happens-Before if (flag) { // 读操作 // flag 的写操作 Happens-Before flag 的读操作 }
- 线程启动规则。主线程启动一个子线程,子线程中的操作一定在主线程中启动该子线程的操作之后执行。
Thread t = new Thread(() -> { // 操作 B }); t.start(); // 启动操作 Happens-Before
- 线程终止规则。一个线程中的所有操作一定早于另一个线程检测到这个线程已经终止。
Thread t = new Thread(() -> { // 操作 A }); t.start(); t.join(); // A Happens-Before join 返回
- 线程中断规则。对线程的中断操作一定早于被中断线程检测到中断事件。
Thread t = new Thread(() -> { // 检测中断 if (Thread.interrupted()) { // 中断事件发生 } }); t.start(); t.interrupt(); // Happens-Before 检测中断
- 对象的构造函数规则。一个对象的构造函数执行结束一定早于该对象的 finalize 方法开始执行。
class MyObject { @Override protected void finalize() { // 构造函数 Happens-Before finalize 方法 } }
- 传递性规则。如果操作 A 发生在操作 B 之前,操作 B 发生在操作 C 之前,那么操作 A 一定早于操作 C。
Thread t1 = new Thread(() -> { // 操作 A }); Thread t2 = new Thread(() -> { // 操作 B }); t1.start(); t1.join(); // A Happens-Before join 返回 t2.start(); t2.join(); // join 返回 Happens-Before B
as-if-serial
为了提高并行度,优化程序性能,编译器和处理器会对代码进行指令重排序。但为了不改变程序的执行结果,尽可能地提高程序执行的并行度,编译器、必须遵守as-if-serial
语义。
"as-if-serial"最初来自于计算机科学领域中的编译优化和程序行为的讨论。这个概念的核心思想是,编译器和计算机系统在进行优化时,可以重新排列和改变指令的执行顺序,只要最终程序的执行结果与按照程序顺序执行时的结果一致即可。这个原则确保了编译器和硬件系统在优化时不会改变程序的语义和行为。就是不管怎么重排序,单线程程序的执行结果不能被改变。
编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。
int a=1;
int b=2;
int c=a+b;
a和c之间存在数据依赖关系,同时b和c之间也存在数据依赖关系。因此在最终执行的指令序列中,c不能被重排序到A和B的前面,c如果排到a和b的前面,程序的结果将会被改变。a和b之间没有数据依赖关系,编译器和处理器可以重排序a和b之间的执行顺序。