Java中的Java内存模型(JMM)与指令重排序
描述
Java内存模型(JMM)是Java虚拟机规范中定义的一种抽象模型,用于屏蔽各种硬件和操作系统的内存访问差异,确保Java程序在多线程环境下能够正确、一致地访问共享数据。JMM的核心目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量的底层细节。它解决了由于多级缓存、CPU指令重排序等硬件优化带来的内存可见性问题,为Java的并发编程提供了一组内存一致性保证。
指令重排序是编译器和处理器为了优化程序性能而对指令执行顺序进行重新排列的一种手段。在单线程环境下,重排序不会影响最终结果(遵守as-if-serial语义),但在多线程环境下,如果不加以控制,重排序可能导致线程看到不一致的内存状态,从而引发程序错误。JMM通过happens-before规则和内存屏障来禁止某些重排序,为开发者提供清晰的内存可见性保证。
解题过程/知识点讲解
让我们循序渐进地理解JMM和指令重排序。
步骤1:为什么需要Java内存模型?
在多核CPU架构下,每个CPU都有自己的高速缓存(L1、L2缓存),并与主内存进行交互。当一个线程修改了共享变量时,该修改可能仅写入当前CPU的缓存,而其他CPU的缓存中仍然是旧值,导致其他线程无法立即看到修改,这就是内存可见性问题。
此外,编译器和处理器可能会对指令进行重排序以优化性能。例如:
int a = 1; // 语句1
int b = 2; // 语句2
语句1和语句2之间没有数据依赖,处理器可能先执行语句2再执行语句1,这在单线程下不会影响结果。但在多线程环境下,如果另一个线程读取这些变量,就可能观察到不一致的状态。
JMM就是为了解决这类问题而定义的,它规定了线程如何与主内存交互,以及何时线程的写操作会对其他线程可见。
步骤2:JMM的核心结构
JMM将内存分为两类:
- 主内存(Main Memory):所有线程共享的内存区域,存储了所有的实例字段、静态字段和数组元素。
- 工作内存(Working Memory):每个线程私有的内存区域,存储了该线程使用到的变量的主内存副本。
JMM定义了以下操作:
- read:从主内存读取变量到工作内存。
- load:将read得到的值放入工作内存的变量副本中。
- use:当虚拟机执行到需要使用变量的值的字节码指令时,将工作内存中的变量值传递给执行引擎。
- assign:当虚拟机执行到给变量赋值的字节码指令时,将执行引擎接收到的值赋给工作内存中的变量副本。
- store:将工作内存中变量的值传送到主内存。
- write:将store操作从工作内存得到的值放入主内存的变量中。
这些操作必须满足一定的规则,例如:不允许read/load、store/write操作单独出现;不允许一个线程丢弃它最近的assign操作(即变量在工作内存中改变了之后必须同步回主内存);不允许一个线程无原因地(无assign操作)将数据从工作内存同步回主内存等。
步骤3:指令重排序的三种类型
- 编译器优化的重排序:编译器在不改变单线程语义的情况下,可以重新安排语句的执行顺序。
- 指令级并行的重排序:现代处理器采用指令级并行技术(ILP),将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变机器指令的执行顺序。
- 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,使得加载和存储操作看上去可能是乱序执行。
步骤4:happens-before原则
JMM通过happens-before关系来阐述操作之间的内存可见性。如果操作A happens-before操作B,那么A的结果对B可见。happens-before规则包括:
- 程序顺序规则:在同一个线程中,按照控制流顺序,前面的操作happens-before后续的任意操作。
- 监视器锁规则:对一个锁的解锁操作happens-before随后对这个锁的加锁操作。
- volatile变量规则:对一个volatile变量的写操作happens-before后续对这个变量的读操作。
- 线程启动规则:线程的
Thread.start()调用happens-before该线程中的任何操作。 - 线程终止规则:线程中的所有操作happens-before其他线程检测到该线程已经终止(如通过
Thread.join()或Thread.isAlive()返回false)。 - 传递性:如果A happens-before B,且B happens-before C,则A happens-before C。
happens-before关系并不意味着前一个操作必须在后一个操作之前完成,它仅要求前一个操作的结果对后一个操作可见。
步骤5:内存屏障(Memory Barrier)
内存屏障是JMM禁止特定类型重排序的关键工具。它是一种CPU指令,用于控制特定操作之间的顺序和内存可见性。JMM将内存屏障分为四类:
- LoadLoad屏障:确保Load1的数据装载在Load2及后续所有装载操作之前。
- StoreStore屏障:确保Store1的数据刷新到主内存(对其他处理器可见)在Store2及后续所有存储操作之前。
- LoadStore屏障:确保Load1的数据装载在Store2及后续所有存储操作之前。
- StoreLoad屏障:确保Store1的数据刷新到主内存(对其他处理器可见)在Load2及后续所有装载操作之前。这是一个全能屏障,开销最大。
在Java中,volatile关键字的实现就依赖内存屏障。例如,对一个volatile变量的写操作会插入StoreStore屏障(禁止上面的普通写与volatile写重排序)和StoreLoad屏障(防止volatile写与后面可能的volatile读/写重排序)。
步骤6:实际案例——双重检查锁定(DCL)
一个经典的案例是双重检查锁定实现单例模式的问题:
public class Singleton {
private static Singleton instance; // 没有volatile修饰
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 问题所在!
}
}
}
return instance;
}
}
问题在于instance = new Singleton();这行代码不是一个原子操作,它分为:
- 分配对象内存空间
- 初始化对象(调用构造函数)
- 将引用指向分配的内存地址
步骤2和3可能被重排序。如果一个线程执行到步骤3但步骤2还未完成(对象未初始化),此时另一个线程进入第一次检查,发现instance不为null,就会返回一个未完全初始化的对象,导致程序出错。
解决方案:使用volatile修饰instance变量,禁止步骤2和3之间的重排序。
总结
Java内存模型是一个复杂的规范,它通过定义内存操作、happens-before关系和内存屏障,为多线程编程提供了一套可预测的内存可见性保证。理解JMM和指令重排序有助于编写正确、高效的多线程程序,避免出现难以调试的内存一致性问题。在实际开发中,应优先使用java.util.concurrent包中的并发工具类,它们已经内置了正确的内存语义,可以简化并发编程。