JMM

0 阅读8分钟

通过synchronized和ReentrantLock加锁之后,可以保证访问共享变量时临界区代码的原子性,但这并不意味着就万事大吉了。还存在共享变量在多线程之间的可见性问题以及多条指令执行时的有序性问题。

Java内存模型(JMM)

JMM是一个抽象的概念,并不是真实存在的物理内存结构(如堆、栈、方法区)。可以把它理解为Java虚拟机定义的一组规则或协议

这套规则的核心目的是定义程序中各种变量(主要指实例字段、静态字段、数组对象元素,不包含局部变量和方法参数)的访问方式,并关注变量从内存中读取、写入的底层细节

JMM主要围绕多线程的并发展开,它规定了:

  • 一个线程如何以及何时可以看到由其他线程修改过的共享变量的值。
  • 一个线程如何以及何时可以同步访问共享变量。

使得保证了以下性质:

  • 原子性:保证指令不会受到线程上下文切换的影响
  • 可见性:保证指令不会受到cpu缓存的影响
  • 有序性:保证指令不会受到cpu指令并行优化的影响

可见性问题

    static boolean run=true;
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Thread thread=new Thread(()->{
           while(run) {
           }
        });
        thread.start();
        TimeUnit.SECONDS.sleep(1);
        run=false;
    }

别使用System.out.println输出信息,该方法内部包含同步机制,会强制刷新内存可见性。

上面这段代码,在主线程中,把run改成false之后,线程thread依然不会停止。 为什么会产生这种情况?过程如下:

  1. thread线程开始时,从主内存中读取了run的值到工作内存

image.png 2. 因为每一次循环都要读取run,因此JIT编译器会将run值缓存到线程的工作内存的高速缓存中,减少对主存的访问

image.png 3.而主线程虽然修改了run的值,并且同步到主存,但是thread线程是从自己的工作内存中的高速缓存中读取的值,因此永远都是true

image.png

解决办法

使用volatile关键字,它强制让线程从主存中获取变量的值或者往主存写变量,而不是从工作内存中获取或者往工作内存。

    static volatile boolean run=true;
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Thread thread=new Thread(()->{
           while(run) {
           }
        });
        thread.start();
        TimeUnit.SECONDS.sleep(1);
        run=false;
    }

volatile保证的是:一个线程对volatile变量的修改对另一个线程可见,不能保证原子性。

有序性

JVM会在不影响正确性的前提下,可以调整语句的执行顺序。 这种特性称之为指令重排

编译器和处理器(CPU)在不改变单线程程序执行结果的前提下使用指令重排的核心目的是提升性能,现代CPU采用多级缓存和流水线架构,如果指令必须严格按代码顺序执行,会导致大量的流水线停顿和缓存等待,浪费CPU资源。

  • 编译器优化重排序:编译器在不改变单线程语义的情况下,可以重新安排语句的执行顺序。
  • 指令级并行重排序:现代处理器采用了指令级并行技术,将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变机器指令的执行顺序。
  • 内存系统重排序:由于处理器使用了读写缓冲区(Load/Store Buffer),这使得加载和存储操作看上去可能是乱序执行的。

这些优化在单线程环境下完全正确且高效,但在多线程并发环境下,就可能引发问题,因为一个线程观察不到另一个线程中指令的真实执行顺序。

下面给出一个单例模式的例子:

public class UnsafeSingleton {
    private static /* 这里没有 volatile */ UnsafeSingleton instance;

    private UnsafeSingleton() {}

    public static UnsafeSingleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (UnsafeSingleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new UnsafeSingleton(); // 问题根源在这里!
                }
            }
        }
        return instance;
    }
}

产生的问题:

instance = new UnsafeSingleton();这行代码,在JVM中并不是一个原子操作。它可以被分解为三个步骤(伪指令):

  1. memory = allocate();// 1. 分配对象的内存空间
  2. ctorInstance(memory);// 2. 初始化对象(调用构造函数)
  3. instance = memory;// 3. 将instance引用指向分配好的内存地址

由于步骤2和3不存在数据依赖,它们可能被重排序。实际执行顺序可能变成 1 -> 3 -> 2。

导致的后果:

假设线程A执行到了重排序后的步骤3,但还没执行步骤2(对象未初始化)。此时线程B进入getInstance()方法,在第一次检查if (instance == null)时,会发现instance不为null(因为线程A已经执行了步骤3),于是线程B直接返回了这个尚未被完全初始化的对象实例,并使用它,从而导致程序出错。

解决办法

内存屏障是一种CPU指令,用于控制特定操作之间的内存可见性和执行顺序。 确保屏障前的某些操作(读/写)先于屏障后的某些操作完成,并且其内存效果对其他CPU核心可见。

主要有四种屏障,用于限制不同类型的重排序组合:

  • LoadLoad屏障:确保该屏障之前的读操作先于之后的读操作完成。
  • StoreStore屏障:确保该屏障之前的写操作先于之后的写操作完成,并且之前的写操作结果对其他处理器可见。
  • LoadStore屏障:确保该屏障之前的读操作先于之后的写操作完成。
  • StoreLoad屏障:确保该屏障之前的写操作先于之后的读操作完成。并且对之后的读操作都可见,这是一个“全能型”屏障,开销也最大,需要同时完成“清空存储缓冲区”和“使缓存失效”这两件耗时的事情。

volatile禁止指令重排序的原理是: 通过 JVM 在编译后生成的内存屏障指令

JVM规范为 volatile变量的读写操作规定了特定的内存屏障插入策略:

  • 在每个 volatile写操作之前,插入一个 StoreStore​ 屏障。
  • 在每个 volatile写操作之后,插入一个 StoreLoad​ 屏障。
  • 在每个 volatile读操作之后,插入一个 LoadLoad​ 屏障和一个 LoadStore​ 屏障。
// 伪代码展示JVM插入的内存屏障
public class VolatileBarrierExample {
    private int a = 0;
    private volatile int v = 0;
    private int b = 0;
    
    public void write() {
        a = 1;          // 普通写
        // StoreStore屏障:确保a=1对所有处理器可见 在 v=2之前
        v = 2;          // volatile写
        // StoreLoad屏障:确保v=2立即对所有处理器可见
    }
    
    public void read() {
        int localV = v; // volatile读
        // LoadLoad屏障:确保后续的读操作在 volatile读之后
        // LoadStore屏障:确保后续的写操作在 volatile读之后
        int localA = a;  // 普通读
        b = 3;           // 普通写
    }
}

对于volatile写操作:

// 伪汇编示意
普通写操作...        // 之前的操作
StoreStore屏障      // 禁止上面的普通写与下面的volatile写重排序
volatile写
StoreLoad屏障       // 确保volatile写立即对其他CPU可见

对于volatile读操作:

// 伪汇编示意
volatile读
LoadLoad屏障        // 禁止下面的普通读与volatile读重排序
LoadStore屏障       // 禁止下面的普通写与volatile读重排序
普通读/写操作...     // 后续操作

屏障工作过程

当线程A执行 instance = new SafeSingleton();时,由于 instancevolatile的:

  1. StoreStore屏障​ 阻止了上面普通写(对象初始化)和下面volatile写(赋值给instance)的重排序。这保证了对象完全构造好之后,才会把引用发布出去(给instance赋值) 。(阻止了指令重排序)
  2. StoreLoad屏障​ 保证写操作完成后,立即将写缓冲区的数据刷回主内存,并使其他CPU的缓存行失效。(保证了可见性

当线程B第一次读取 volatileinstance时:

  1. LoadLoad屏障​ 和 LoadStore屏障​ 保证了线程B能看见线程A在 volatile写之前的所有操作结果。也就是说,线程B一定能拿到一个被线程A完全初始化好的对象。

happens-before

Happens-Before​ 是 Java 内存模型(JMM)中定义的一套规则,用于明确多线程环境下操作的可见性顺序。它不直接描述代码的执行顺序,而是规定了"如果一个操作happens-before另一个操作,那么第一个操作的结果对第二个操作可见"。

  1. 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
  2. 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
  3. 线程 start 前对变量的写,对该线程开始后对该变量的读可见
  4. 线程结束前对变量的写,对其它线程得知它结束后的读可见