Java内存模型-JMM

说明

Java内存模型(Java Memory Model,JMM)是Java编程语言中用于管理多线程并发访问共享内存的规范。它定义了多线程程序中内存访问的行为和规则,以确保程序在不同的计算机体系结构和JVM实现中的一致性和可预测性。

主内存与工作内存

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。为了获得更好的执行效能,Java内存模 型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器是否要进行调整代码执行顺序这类优化措施。

Java内存模型规定了所有的变量都存储在主内存(Main Memory,也是虚拟机内存的一部分)中。每条线程还有自己的工作内存(Working Memory,可以理解为高速缓存),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,如下图

image-20230922135419207

内存间交互操作

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从 工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。Java虚拟机实 现时必须保证下面提及的每一种操作都是原子的、不可再分的

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的 变量副本中。

  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

可以将Java内存模型的操作简化为read、write、lock和unlock四种,但这只是语言描述上的等价化简,Java内存模型的基础设计并未改变。

volatile关键字

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制,Java内存模型为volatile专门定义了一些特殊的访问规则。

当一个变量被定义成volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见,第二个语义是禁止指令重排序优化,这里进行说明。

可见性

在多线程编程中,当一个线程修改了一个共享变量的值时,其他线程可能不会立即看到这个变化。因为每个线程都有自己的工作内存,都有一份共享变量的副本,每个线程可能会在自己的工作内存中缓存共享变量的值,而不是直接从主内存中读取。

使用volatile关键字可以解决工作多线程先共享变量的可见性问题

volatile 读写操作如下:

  • 写入操作:每次对 volatile 变量的写入操作都会立即刷新到主内存中,而不会在本地线程缓存中保留副本。这确保了其他线程在读取时可以看到最新的值。

  • 读取操作:每次对 volatile 变量的读取操作都会从主内存中获取最新的值,而不是使用本地缓存的旧值。这确保了读操作能够看到写操作的效果。

因此,volatile 关键字确保了在多线程环境下对变量的读写都与主内存同步,从而实现了可见性。这使得任何一个线程对 volatile 变量的修改都会被立即反映到主内存,而其他线程在读取该变量时可以看到最新的值,而不需要担心线程间的数据不一致性问题。

禁止指令重排序

下面先说一个指令重排序出现的问题

假设有一个包含两个整数字段的类:

class ReorderExample {
    
    
    private int x = 0;
    private volatile boolean flag = false;

    public void writer() {
    
    
        x = 42; // 写操作1
        flag = true; // 写操作2
    }

    public void reader() {
    
    
        if (flag) {
    
     // 读操作1
            System.out.println(x); // 读操作2
        }
    }
}

在上述示例中,writer 方法将 x 设置为 42,然后将 flag 设置为 truereader 方法检查 flag 是否为 true,如果是,则输出 x 的值。

在多线程环境下,如果发生指令重排序,可能会导致 reader 方法在 flagtrue 时读取到 x 的值为 0。这是因为编译器和处理器可以将写操作1和写操作2进行重排序,使得写操作2先于写操作1执行,即使在代码中它们是按顺序执行的。这将导致 reader 方法看到 flagtrue,但 x 的值仍然是 0。

在 Java 中,编译器和处理器可能会对指令进行重排序,以提高程序的执行效率。然而,指令重排序可能会导致多线程程序中的意外行为,因为多线程程序的正确性通常依赖于指令的执行顺序。我们可以使用volatile关键字来解决这个问题。

volatile 关键字可以通过在编译器和运行时中插入内存屏障(Memory Barrier)来禁止指令重排序。读写屏障是一种指令或操作,用于确保内存操作的顺序和可见性。以下是一些关于读写屏障的重要概念:

  1. 内存屏障的作用:内存屏障是一种同步机制,它用于控制内存操作的执行顺序和可见性。它可以确保特定操作之前和之后的内存访问按照程序的顺序来执行,而不会发生重排序。内存屏障可以分为读屏障和写屏障。

  2. 读屏障(Read Barrier):读屏障确保后续的普通读写操作不会被重排序到该读操作之前,以维护操作的有序性。这确保了在执行读操作之前的所有普通写入操作都已经完成,从而保证了读操作看到的是最新的数据。

  3. 写屏障(Write Barrier):写屏障确保在写入某个变量的值之前,所有之前的内存普通写操作都已经完成,并且在写入之后,后续的 volatile 读写操作不会被重排到写操作之前。这保证了写操作的有序性,同时也确保了 volatile 读操作可以看到最新的值。

  4. volatile关键字和屏障:在Java中,使用volatile关键字声明的变量会在读写操作时插入相应的内存屏障。读volatile变量时会插入读屏障,确保读操作不会重排到前面的写操作之前。写volatile变量时会插入写屏障,确保写操作不会重排到后面的读操作之后。这就确保了volatile变量的可见性和有序性。

总之,读写屏障是用于确保内存操作的有序性和可见性的机制,volatile关键字在Java中使用了这些屏障来确保对volatile变量的读写操作不会发生重排序,以及读操作能够看到最新的值。这有助于编写多线程程序时避免意外的行为。

下面是关于读写屏障更详细的解释

屏障类型如下

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障;
  • 在每个volatile写操作的后面插入一个StoreLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadLoad屏障;
  • 在每个volatile读操作的后面插入一个LoadStore屏障;

读操作如下

image-20230922160646009

写屏障如下

image-20230922160726135

针对long和double型变量的特殊规则

Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性, 但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有 被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”(Non-Atomic Treatment of double and long Variables)。

如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取 和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变 量”的数值。但是这种情况出现概率极低,在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的long和double变量专门声明为volatile。

原子性、可见性与有序性

Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的

  • 原子性(Atomicity):原子性指的是一个操作是不可分割的,要么全部执行成功,要么全部不执行,不存在中间状态。在多线程环境中,原子性确保多个线程同时访问共享资源时不会导致不一致或损坏的情况。Java提供了synchronized关键字和java.util.concurrent包中的原子操作类来实现原子性。
  • 可见性(Visibility):可见性指的是一个线程对共享变量的修改能够被其他线程及时地看到。在多核处理器系统中,不同线程可能在不同的核上执行,因此一个线程对共享变量的修改可能不会立即被其他线程看到。为了确保可见性,Java使用了volatile关键字和synchronized关键字等同步机制。
  • 有序性(Ordering):有序性指的是程序执行的顺序要符合程序员的预期。在多线程环境中,编译器和处理器可能会对指令进行重排序,这可能会导致操作的执行顺序与程序员预期的不一致。为了确保有序性,Java内存模型定义了一组规则,例如happens-before关系,来确保操作按照正确的顺序执行。

先行先发生原则

先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B 观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

  • 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作先行 发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循 环等结构。
  • 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这 里必须强调的是“同一个锁”,而“后面”是指时间上的先后。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量 的读操作,这里的“后面”同样是指时间上的先后。
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检 测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止 执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程 的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize()方法的开始。
  • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出 操作A先行发生于操作C的结论。

"先行先发生原则"定义了不同线程之间操作的执行顺序关系,如果一个操作在时间上发生在另一个操作之前,那么它们之间将建立 “happens-before” 的关系。

猜你喜欢

转载自blog.csdn.net/m0_51545690/article/details/133176877