likes
comments
collection
share

深入理解Java内存模型:保障多线程程序的正确执行

作者站长头像
站长
· 阅读数 36

计算机内存模型

高速缓存

计算机绝大多数运算任务,都不可能只靠一个CPU就能完成,往往还需要和内存,硬盘进行交互。我们知道 CPU 的运行速度远远快于内存的速度,因此会出现 CPU 等待内存读取数据的情况。

由于两者的速度差距实在太大,为了加快运行速度,于是计算机的设计者在 CPU 中加了一个CPU 高速缓存。这个 CPU 高速缓存的速度介于 CPU 与内存之间,每次需要读取数据的时候,先从内存读取到CPU缓存中,CPU再从CPU缓存中读取。这样虽然还是存在速度差异,但至少不像之前差距那么大了。

深入理解Java内存模型:保障多线程程序的正确执行

缓存一致性

高速缓存引入了一个新的问题:缓存一致性(Cache Coherence)。在多核CPU系统中,每个CPU都有自己的高速缓存,而它们又公用一块主内存(Main Memory)。当多个CPU的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存不一致。如果真发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?

一般来说,有两种方式解决缓存一致性问题

  • 通过在总线加LOCK#锁的方式
  • 通过缓存一致性协议

这2种方式都是硬件层面上提供的方式。

在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。

但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。

由于总线加Lock锁的方式效率低下,后来便出现了缓存一致性协议。最出名的就是Intel 的MESI协议

MESI协议

MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取

深入理解Java内存模型:保障多线程程序的正确执行

CPU乱序执行优化

除了增加高速缓存外,为了使得处理器内部的运算单元能够尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行是一致的,但不保证程序中各个语句执行的先后顺序和输入的顺序一致。Java虚拟机的即时编译器也有类似的指令重排序(Instrution Reorder)优化。

处理器内存模型的几种类型

顺序一致性内存模型

处理器的内存模型和后面要说的Java内存模型通常会以顺序一致性内存模型作为参考。这里有必要先简单介绍下顺序一致性模型。

顺序一致性是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

  • 一个线程中的所有操作必须按照程序的顺序执行。

  • (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型,每个操作都必须原子执行且立即对所有线程可见。

深入理解Java内存模型:保障多线程程序的正确执行

在概念上,顺序一致性内存模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程,同时每一个线程必须按照程序的顺序的顺序来执行内存读/写操作。

不同类型的内存模型

根据对顺序一致性内存模型不同类型的读/写操作组合的执行顺序执行放松,可以把常见处理器的内存模型划分为如下几种类型。

  • 放松程序中写-读的顺序,由此产生了Total Store Ordering内存模型(简称TSO)。

  • 在上面的基础上,继续放松程序中写-写操作的顺序,由此产生了Partial Store Ordering内存模型(简称为PSO)。

  • 在前面两条的基础上,继续放松程序中读-写和读-读操作顺序,由此产生了Relaxed Memory Order内存模型(简称为RMO)和PowerPC内存模型。

深入理解Java内存模型:保障多线程程序的正确执行

各种处理器的内存模型,从上到下,模型由强变弱。越是追求性能的处理器,内存模型设计得就越弱。因为这些处理器希望内存模型对它们的束缚越少越好,这样它们就可以做尽可能多的优化来提高性能。

Java内存模型

什么是JMM

JMM 是 Java 内存模型,与JVM 内存模型是两回事,JMM 的主要目标是定义程序中变量的访问规则,如下图所示,所有的共享变量都存储在主内存中共享。每个线程有自己的工作内存,工作内存中保存的是主内存中变量的副本,线程对变量的读写等操作必须在自己的工作内存中进行,而不能直接读写主内存中的变量。

深入理解Java内存模型:保障多线程程序的正确执行 在多线程进行数据交互时,例如线程 A 给一个共享变量赋值后,由线程 B 来读取这个值,A 修改完变量是修改在自己的工作区内存中,B 是不可见的,只有从 A 的工作区写回主内存,B 再从主内存读取自己的工作区才能进行进一步的操作。由于指令重排序的存在,这个写—读的顺序有可能被打乱。因此 JMM 需要提供原子性、可见性、有序性的保证。

JMM保证

深入理解Java内存模型:保障多线程程序的正确执行

原子性

JMM保证对除了longdouble之外的基本数据类型的读取和写入是原子性的。这意味着在多线程环境下,一个线程执行的读写操作要么完全执行,要么没有执行,不会出现中间状态。此外,Java提供的关键字synchronized也可以保证原子性。当使用synchronized关键字修饰代码块或方法时,它会将代码块或方法标记为临界区,确保同一时间只有一个线程可以执行该临界区的代码,从而保证原子性。

可见性

JMM通过使用内存屏障和缓存一致性协议来保证可见性。当一个线程对共享变量进行写操作时,JMM会将该变量的最新值刷新到主内存中,并使其他线程的工作内存失效,强制它们从主内存中重新获取最新值。这样可以确保其他线程能够看到最新的变量值,从而保证了可见性。

使用volatile关键字可以实现可见性。当一个变量被声明为volatile时,对该变量的读写操作都会直接在主内存中进行,而不会使用线程的工作内存。这样可以确保对volatile变量的修改对其他线程立即可见。

有序性

JMM通过禁止指令重排序(volatile)和使用happens-before原则来保证有序性。指令重排序是指处理器在执行指令时可能会对指令进行优化,改变其执行顺序,但不改变程序的语义。在多线程环境下,指令重排序可能导致线程之间观察到的执行顺序与程序中的顺序不一致,从而引发错误。

volatile

volatile 关键字通过使用内存屏障(memory barrier)和禁止指令重排序来保证可见性和有序性。

  1. 可见性:当一个变量被声明为volatile时,在每次对该变量的写操作完成后,JVM会强制将写入操作立即刷新到主内存中,而不是只在线程的工作内存中保留副本。同时,对于其他线程来说,在读取该变量之前,JVM会强制要求从主内存中获取最新的值,而不是使用线程的工作内存中的副本。这样可以确保在一个线程修改了volatile变量的值后,其他线程能够立即看到最新的值,从而保证了可见性。
  2. 有序性:volatile关键字还可以保证变量操作的有序性。在volatile变量的写操作之后,JVM会插入一个内存屏障,这个屏障会阻止在写操作之后的指令重排序。类似地,在volatile变量的读操作之前,JVM会插入另一个内存屏障,这个屏障会阻止在读操作之前的指令重排序。这样可以确保对volatile变量的操作按照程序的顺序进行,避免了指令重排序带来的问题,从而保证了有序性。

内存屏障是一种硬件或软件层面的机制,用于控制指令的执行顺序和内存访问的顺序。它可以阻止指令重排序,确保内存操作按照预期顺序执行。内存屏障的插入和处理由JVM和硬件共同完成,以确保volatile变量的可见性和有序性。

下面是通过volatile实现线程安全单例方法的例子。

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {
        // 私有构造函数
    }

    public static Singleton getInstance() {
        if (instance == null) {  // 检查实例是否已经创建
            synchronized (Singleton.class) {
                if (instance == null) {  // 双重检查锁定
                    instance = new Singleton();  // 创建实例
                }
            }
        }
        return instance;
    }
}

在这个示例中,使用了双重检查锁定(double-checked locking)的方式来实现延迟加载的线程安全单例模式。关键点是将instance声明为volatile,以保证多线程环境下对instance的可见性。

getInstance()方法中,首先检查instance是否已经创建,如果尚未创建,才会进行同步块的操作。在同步块内部,再次检查instance是否为null,这是为了防止在多个线程通过第一次检查后,其中一个线程已经创建了实例,其他线程不需要再次创建。只有在第二次检查时,如果instance仍然为null,才创建实例。

通过使用volatile关键字,可以确保在一个线程对instance进行写操作后,其他线程能够立即看到这个修改,从而避免了多个线程同时创建实例的问题,保证了线程安全性。

happens-before

happens-before是Java内存模型(Java Memory Model,JMM)中的一个概念,它用于定义多线程程序中操作之间的偏序关系,确保操作按照预期顺序执行。

happens-before包括一系列规则:

  1. 程序顺序规则(Program Order Rule):在一个线程内,按照程序的顺序,前面的操作"happens-before"于后续的操作。也就是说,一个线程内的操作按照代码的顺序执行,后续的操作可以看到前面的操作的影响。

  2. volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作"happens-before"于后续对该变量的读操作。这个规则确保了对volatile变量的写操作对于后续的读操作是可见的。

    在这个示例中,通过将flag声明为volatile变量,保证了写操作的"happens-before"于后续的读操作。这意味着在reader()方法中,如果flag的值为true,那么它一定能够看到writer()方法设置的flag的更新。

public class HappensBeforeExample {
    private volatile boolean flag = false;

    public void writer() {
        flag = true; // 写操作
    }

    public void reader() {
        if (flag) { // 读操作
            System.out.println("Value is true");
        }
    }
}

  1. 传递性规则(Transitive Rule):如果操作A"happens-before"于操作B,并且操作B"happens-before"于操作C,则操作A"happens-before"于操作C。这个规则保证了操作之间的传递性,即如果A先于B,B先于C,那么A必然先于C。

    在下面示例中,假设有两个线程,一个线程执行writer()方法,另一个线程执行reader()方法。 根据传递性规则,在示例代码中,当writer()方法执行写操作A(x = 42)之后,紧接着执行写操作B(flag = true)。然后,当reader()方法执行读操作C(if (flag))时,它能够看到在写操作B之前对flag的修改。因此,根据传递性规则,reader()方法执行读操作D(System.out.println("x = " + x))时,它也能够看到在写操作A之前对x的修改,打印出更新后的值42。

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

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

    public void reader() {
        if (flag) {    // 读操作 C
            System.out.println("x = " + x);   // 读操作 D
        }
    }
}

  1. 监视器锁规则(Monitor Lock Rule):对一个锁的解锁操作"happens-before"于后续对该锁的加锁操作。这个规则确保了对监视器锁的解锁操作对于后续的加锁操作是可见的,即保证了线程之间的同步顺序。这个规则中说的锁其实就是Java里的 synchronized。

  2. 线程启动规则(Thread Start Rule):一个线程的启动操作"happens-before"于其后续的所有操作。这个规则保证了一个线程启动后的操作对于其他线程是可见的。

    在下面示例中,startThread()方法创建一个新的线程并启动它,而新线程中执行的代码对变量x进行写操作。在主线程中,调用printValue()方法执行对变量x的读操作并打印其值。当调用thread.start()启动新线程时,新线程中的写操作(x = 42)在主线程的读操作之前发生。因此,根据线程启动规则,主线程中的读操作printValue()能够看到新线程中对x的修改,输出更新后的值42。

public class HappensBeforeExample {
    private int x = 0;

    public void startThread() {
        Thread thread = new Thread(() -> {
            x = 42;  // 写操作,在新线程中执行
        });
        thread.start();  // 启动新线程
    }

    public void printValue() {
        System.out.println("x = " + x);  // 读操作
    }
}

  1. 线程终止规则(Thread Termination Rule):一个线程的所有操作"happens-before"于其终止操作。这个规则保证了一个线程的所有操作对于其他线程是可见的。
转载自:https://juejin.cn/post/7242312506064207930
评论
请登录