深度思考JMM和内存屏障

基础

内存屏障及其在 JVM 内的应用

内存屏障相关问题

使用了StoreLoad屏障后,数据写回内存后,又可能会重新被加载到缓存行,这是storeLoad耗时的原因吗?

StoreLoad内存屏障是最昂贵的内存屏障,它主要是为了解决在多处理器环境下的"Store-Load"的重排序问题,保证其一致性。

StoreLoad屏障确保了屏障之前的所有写操作在此屏障之后的所有读操作之前完成,这主要是通过刷新写缓存并阻塞后续的读操作,直到所有之前的写操作完成实现的。

这个过程可能导致处理器暂停,因为它必须等待所有之前的写操作完成,这可能涉及到将写缓存中的数据写回到主内存,并且可能需要从主内存重新加载数据。这个过程中涉及的延迟主要是由于内存操作(读/写)相比缓存操作(读/写)有更高的延迟,并且可能需要处理器与主内存之间的通信,这些都会增加处理器的等待时间。

因此,StoreLoad屏障的耗时部分确实部分源于可能需要的数据重新加载到缓存行。但更关键的是,它需要等待所有之前的写操作完成,包括将写缓存中的数据刷新到主内存,这使得处理器必须等待,直到这些操作完成为止。

那StoreStore不也是需要将缓存行中的数据刷新到主内存吗?

StoreStore内存屏障确实要求之前的所有存储(写)操作在该屏障之后的任何存储操作之前完成。这意味着在一些情况下,它可能确实需要将数据从缓存行刷新到主内存中。然而,其工作原理和性能影响与StoreLoad屏障有一些不同。

StoreStore屏障的主要目标是防止存储操作的重排序,以此来确保写入的顺序。例如,如果有两个写入操作W1和W2,那么在StoreStore屏障之后的任何写入操作都不会被重排序到W1或W2之前。

然而,StoreStore屏障通常并不需要立即将所有数据刷新到主内存中。相反,它可能只是要求在执行任何后续的存储操作之前完成之前的所有存储操作。这就意味着数据可能仍然驻留在写缓冲区中,而不是被立即刷新到主内存。

因此,虽然StoreStore屏障确实有可能涉及到将数据从缓存行刷新到主内存中,但这并不总是必需的,也不是该屏障的主要目标。因此,StoreStore屏障通常比StoreLoad屏障具有更少的性能影响。

那loadStore呢?

LoadStore 内存屏障确保在该屏障之前的所有加载(读)操作在该屏障之后的所有存储(写)操作之前完成。它主要是为了防止读操作和写操作的重排序。

LoadStore 内存屏障不需要将数据从缓存刷新到主内存,而是确保所有的读操作完成后才执行写操作。这通常意味着读操作完成并且数据已经加载到缓存中,然后才执行写操作。

因此,与 StoreStore 和 StoreLoad 内存屏障相比,LoadStore 内存屏障通常对性能的影响更小,因为它不需要等待数据从缓存刷新到主内存。

请注意,具体的实现细节和性能影响可能会根据处理器的体系结构和内存模型以及 JVM 的具体实现等因素有所不同。

就MESI的StoreLoad而言,Store表现为将其他cpu中的缓存置为无效,然后将当前缓存行中的数据写回内存中,Load则是其他cpu从主存中抓取这个刚刚写入的数据吗?

你的理解基本正确。在MESI(Modified, Exclusive, Shared, Invalid)协议中,处理器核心在写(Store)操作时,会首先将其它核心对应的缓存行状态置为无效(Invalid),然后对本地的缓存行进行修改。一旦需要把这个缓存行的数据写回到主存中,缓存行的状态就会从Modified变为Exclusive,表示这个缓存行已经与主存中的数据保持一致了。

接下来的读取(Load)操作,如果其他的处理器核心需要读取这个数据,它们会从主存中加载这个刚刚被写入的数据到它们自己的缓存行中,并将缓存行的状态置为Shared。

StoreLoad屏障能够确保一个核心的写操作对其它核心的读操作可见,它通过让写操作先完成(包括必要的数据同步到主存),然后再进行读操作来实现。这个过程可能涉及多次的内存操作,包括写入、无效化其它核心的缓存行、同步数据到主存,以及从主存中加载数据,因此StoreLoad是最昂贵的内存屏障。

需要注意的是,这个过程是由硬件自动处理的,程序员通常无需(也无法)直接控制这个过程。同时,由于现代处理器和操作系统的优化,实际的过程可能会比上述的描述更为复杂和高效。

MESI和内存屏障之间的关系?JMM的内存屏障属于一种保持变量可见性和有序性的抽象,MESI是其具体实现的一种,应用于特定的场景。

Java 内存模型(JMM)中的内存屏障是一种抽象机制,用于确保多线程环境中的内存操作(读/写)的可见性和顺序性。而MESI(Modified, Exclusive, Shared, Invalid)协议是一种在多核处理器系统中实现缓存一致性的具体机制。

MESI协议的工作场景是多处理器(或多核)系统。在这种系统中,每个处理器(或核心)都有自己的缓存。当多个处理器同时操作同一块内存时,就可能出现一个处理器的缓存中的值已经被修改,而其他处理器的缓存中的值还是旧的,这就导致了所谓的缓存不一致问题。MESI协议就是为了解决这个问题而提出的。

在 MESI 协议中,每个缓存行都有四种状态:Modified,Exclusive,Shared,Invalid。通过这四种状态的转换,MESI协议确保了每个处理器都能看到最新的数据。

对于JMM来说,它并不关心底层是如何实现的,它只需要知道,有一种机制(即内存屏障)可以确保内存操作的可见性和顺序性。而对于底层来说,为了实现这种机制,可能会用到像 MESI 这样的协议。所以,你可以把内存屏障看作是上层(JMM)和下层(MESI)的一个“桥梁”。

也就是说,MESI协议是实现 JMM 内存屏障的一种可能的具体手段,但并不是唯一的方式,还可能有其他的硬件机制或协议。这取决于具体的硬件和操作系统的实现。

单例模式的DCL实现与volatile的内存屏障

为什么不带volatile关键字的DCL的实现有重排序的风险?

  1. DCL(双重检查锁定)的代码示例如下:
 private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {   // first check
            synchronized(Singleton.class) {
                if (instance == null) {   // second check
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
    
    private Singleton() {
        // initialize
    }
}

该代码的jvm指令大致如下:

0: getstatic     #2      // Field instance:LSingleton;
3: ifnonnull     32     // if instance is not null, jump to 32
6: ldc           #3     // Class reference to Singleton
8: dup           // Duplicate the class reference
9: astore_1      // Store the reference in local variable 1
10: monitorenter  // Enter synchronized block
11: getstatic     #2    // Field instance:LSingleton;
14: ifnonnull     22   // if instance is not null, jump to 22
17: new           #3   // Create new Singleton
20: putstatic     #2   // Assign to instance
21: aload_1       // Load the reference from local variable 1
22: monitorexit   // Exit synchronized block
23: goto          32   // Jump to 32
24: astore_2      // Exception handling: store the exception
25: aload_1       // Load the reference from local variable 1
26: monitorexit   // In case of an exception, exit synchronized block
27: aload_2       // Load the exception
28: athrow        // Re-throw the exception
29: getstatic     #2  // Field instance:LSingleton;
32: areturn       // Return the instance

在这个字节码中,new指令用于创建一个新的Singleton对象,putstatic指令用于将这个新创建的对象的引用赋值给instance字段。然而,这里有一个重要的问题。这个问题是Java内存模型中的一个特性,即在没有适当的同步的情况下,一个线程对一个对象的写入(这里是Singleton的实例化)可能对其他线程不可见,或者其他线程可能看到部分初始化的对象。

**在上述字节码中,主要的问题出现在new和putstatic两条指令之间。这里有一个称为“构造函数穿越”的问题,也就是说,new和putstatic两条指令的顺序可能会被JVM重新排序。具体来说,JVM可能先执行putstatic指令,将一个尚未完全初始化的Singleton对象赋值给instance字段,然后再执行new指令进行对象的初始化。在多线程环境中,如果此时另一个线程执行getInstance()方法,那么它可能得到一个尚未完全初始化的Singleton

像DCL机制使用volatile关键字时,一般会使用哪种屏障?(字节面试题: ll ls放在前后的效果,其实对应的就是读一个volatile变量时的内存屏障使用方式)

在Java中,双检查锁定(DCL, Double Checked Locking)模式经常被用于实现延迟初始化和单例模式。在这种模式中,通常会使用volatile关键字来保证对象的初始化的原子性和可见性。具体来说,这是因为Java的内存模型为volatile变量的读写提供了特殊的内存屏障:

  • 对volatile变量的写操作,会在写操作后插入一个StoreStore屏障,防止后续的写操作被重排序到volatile写操作之前,并且还会插入一个StoreLoad屏障,防止后续的读写操作被重排序到volatile写操作之前。

  • 对volatile变量的读操作,会在读操作后插入一个LoadLoad屏障,防止前面的读操作被重排序到volatile读操作之后,并且还会插入一个LoadStore屏障,防止前面的读操作被重排序到后续的写操作之前。

在DCL模式中,volatile关键字确保当单例对象被初始化时,对象的所有字段都已经被正确地初始化,并且这个初始化过程对所有线程都是可见的。这主要是通过上述的StoreStore和StoreLoad内存屏障实现的。

同时,volatile关键字也确保当检查单例对象是否已经被初始化时,如果对象已经被初始化,那么这个检查操作(即读volatile变量)之后的所有操作都能看到这个对象的正确状态。这主要是通过上述的LoadLoad和LoadStore内存屏障实现的。

为什么这几个内存屏障都在volatile对变量读写之后插入,而不是之前呢?(重要)

对于Java内存模型中的volatile关键字的语义,内存屏障的设置是有着特定的逻辑的。

对于volatile变量的写操作,内存屏障是在写操作后面插入的。原因是,我们想要保证当volatile变量写入后,其他线程能够看到这个最新的值。也就是说,插入的StoreStore和StoreLoad屏障保证了写入的volatile变量不会和后面的操作重排序,以确保其他线程可以看到最新的值。

对于volatile变量的读操作,内存屏障是在读操作后面插入的。原因是,我们希望能读取到最新的值。插入的LoadLoad和LoadStore屏障阻止了在读操作之前的任何读写操作被重排到读操作之后,保证了读取的是最新的值(为什么防止读操作重排到后面写操作之前就能读取到最新内容呢,不都是读吗,而且没有发生写啊?这是因为Load是从主存中加载到缓存的真实数据,一般的普通读取的数据可能是缓存中的旧数据)。

总的来说,对于volatile关键字,内存屏障的插入位置是为了保证其语义:当一个线程对一个volatile变量进行写操作后,其他线程能够立即看到这个写操作的效果,即实现了volatile变量的可见性。同时,volatile关键字还阻止了编译器对volatile变量相关代码的重排序,从而保证了指令的执行顺序符合程序员的预期,即实现了volatile变量的有序性。

volatile变量之间的操作依然可能会被重排吗?

虽然volatile变量本身的读/写操作是不会被重排的,但这并不意味着所有的操作都不会被重排。例如,volatile变量之间的操作依然可能会被重排。只有当对volatile变量的读/写操作与另一volatile变量的写/读操作之间存在数据依赖性时,这两个操作才不会被重排。

为什么volatile不支持原子化操作?

volatile为什么不能保证原子性?

volatile修饰变量a,多线程调用i++时会插入几个内存屏障呢?

volatile关键字保证了变量的可见性和顺序性,但并不保证原子性。在多线程环境下对volatile修饰的变量进行i++操作(包括读取值,增加值,写回值三个步骤)时,这三个步骤并非原子操作,可能会被其他线程打断。

关于内存屏障,Java内存模型规定对于volatile变量的写操作,JVM会在写操作后插入一个StoreStore屏障和一个StoreLoad屏障,这两个屏障确保了写操作的有序性和可见性;对于volatile变量的读操作,JVM会在读操作后插入一个LoadLoad屏障和一个LoadStore屏障,这两个屏障确保了读操作的有序性和可见性。

对于多线程环境下对volatile修饰的变量进行i++操作:

  1. 首先,进行读取操作,读取当前的值,会在读操作后插入一个LoadLoad屏障和一个LoadStore屏障。

  2. 然后,进行增加值的操作,这个操作本身并不会插入内存屏障,因为增加值操作并没有涉及到对volatile变量的直接操作。

  3. 最后,进行写回值的操作,将增加后的值写回volatile变量,会在写操作后插入一个StoreStore屏障和一个StoreLoad屏障。

所以,对于volatile修饰的变量进行i++操作,可能会插入四个内存屏障。但是这仍然不能解决并发问题,因为i++操作并不是原子操作,在多线程环境下仍然可能会出现数据不一致的问题。如果需要解决这个问题,可以考虑使用AtomicInteger或者synchronized关键字来实现i++操作的原子性。

你能从cpu、缓存、主存的数据读写方面,举一个volatile修饰的i++无法保证原子性例子吗?

当然可以。假设我们有两个线程A和B,它们都试图执行 volatileVariable++ 操作。下面是可能的执行过程:

  1. 线程A从主内存中读取volatileVariable变量到自己的工作内存,假设值为1。

  2. 同时,线程B也从主内存中读取volatileVariable变量到自己的工作内存,也是值为1。

  3. 线程A在自己的工作内存中执行+1操作,volatileVariable现在是2。

  4. 线程A将volatileVariable的新值2写回主内存。

  5. 线程B在自己的工作内存中也执行+1操作,volatileVariable现在是2(但是应该是3)。

  6. 线程B将volatileVariable的新值2写回主内存。

尽管volatile确保了线程B在第3步能看到线程A在第1步读取的最新值,但由于线程A还没来得及写回新值,线程B就已经开始执行它的操作,这就导致了i++操作的非原子性。

因此,即使volatileVariable被volatile修饰,我们也在并发环境下遇到了一个非原子性的问题。理论上volatileVariable应该是3,但实际上它是2。虽然volatile可以保证变量的可见性和有序性,但它无法保证复合操作(如volatileVariable++)的原子性。

对于这种情况,我们通常会使用原子类如AtomicInteger或者synchronized关键字来确保操作的原子性。

注意这里的线程工作内存是一个抽象概念,它并不直接对应到硬件上的寄存器或者缓存。它实际上是用来表示线程所使用的内存区域。这个区域可能包含了CPU缓存、寄存器,也可能还包含了其他硬件和编译器优化等。

当我们说一个线程从主内存中读取一个变量到它自己的工作内存时,这表示的是这个线程获取了这个变量的最新值,并将这个最新值保存在它自己可以访问的内存区域。具体这个内存区域在硬件上是怎么实现的,对于Java程序来说,是透明的。Java内存模型关心的是读写操作的顺序和可见性,而不是这些操作在硬件上是怎么实现的。

也就是说即使volatile写被立即刷新到内存,也有可能在刷新之前(比如还在寄存器中进行自增的时候,另一个线程就已经取到了值),存在线程读取到内存中的旧值,是吗?比如在cpu修改变量之前就有线程读取到旧值

是的,你的理解是正确的。volatile关键字确保了一个线程在写入一个volatile变量之后,这个变化会被立即刷新到主内存。但是,如果在这个刷新操作完成之前,另一个线程已经读取了这个变量的值,那么这个线程读取到的仍然是旧的值。

这就是为什么"i++"这种复合操作(读取-修改-写回)在多线程环境中并不是线程安全的,即使操作的变量是volatile的。在"i++"的读取和写回之间,另一个线程可能会读取到旧的值并进行操作,导致结果不正确。为了避免这种问题,需要通过其他同步机制(如锁或者原子变量)来确保这种复合操作的线程安全性。

由于线程A还没来得及写回新值,线程B就已经开始执行它的操作,为什么会发生?

因为现代处理器都是多核,多cpu,每一个cpu都有自己的寄存器,缓存行,所以线程的执行状态是不同的。

你可能感兴趣的:(java,JMM,内存屏障)