码农翻身

深入Java内存模型

- by MRyan, 2022-10-05



文章已收录至精进并发编程系列 此系列其它文章 https://www.wormholestack.com/tag/%E5%B9%B6%E5%8F%91/


0.序

在单 CPU 时代,单个 CPU 同时只能执行一个任务,多任务时共享一个 CPU,CPU 通过给每个线程分配时间片不停地切换线程执行来实现机制。当一个任务占用了 CPU,其它任务就会被挂起,当 CPU 执行了一个时间片任务时间,会把 CPU 让给其他任务执行,但切换前会保存上一个任务的状态,以便下次切回这个任务时能加载任务的状态,这个过程称为上下文切换。但线程间的频繁上下文切换,会带来额外资源开销,影响执行效率。

在多核 CPU 时代,CPU 高速缓存的诞生,多进程多线程的出现,多线程程序可以同时在多个处理器上执行,每个线程可以使用自己的 CPU 运行,多 CPU 并发执行,虽然减少了线程上下文切换带来的开销,充分利用了 CPU 的空闲时间片,提高进程整体运行效率,但并发场景随之而来的问题也出现了。

CPU 高速缓存诞生的原因

计算机在执行程序时,每条指令都是在 CPU 中执行的,而执行指令过程中,会涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,而 CPU 执行速度很快,从内存读取数据和向内存写入数据的过程跟 CPU 执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。所以 CPU 高速缓存诞生了。

可能会引发线程安全问题

共享一块内存地址空间,如果没有明确的数据同步机制来协同对共享数据的访问,那么很容易会引发一致性问题,以多线程举例,当一个线程正在使用某个变量时,另一个线程也可能会同时访问这个变量,则会造成数据不一致,引发线程安全问题。

本文将通过对多线程场景下可能会发生数据不一致性问题分析原因并提出处理方案,同时加深Java内存模型的理解。

1.何为线程

操作系统在分配资源的时候是讲资源分配给进程的,但 CPU 资源特殊,它是被分配给线程的,因为真正要占用 CPU 的是进程,所以说线程是 CPU 分配资源的基本单位。

进程是系统进行资源分配和调度的基本单位,而线程是进程中的一个实体,是进程的一个执行路径,一个进程中至少有一个线程,进程中的线程共享进程的资源。

例如,在执行 Java 程序时,main 函数的执行,实际启动了一个 JVM 进程,而 main 函数所在的线程是进程其中一个线程。

下图是线程和进程的关系:

image-20221005131552228

一个进程中有多个线程,多个线程共享进程的堆和方法区资源,但是每个线程有自己的程序计数器和栈空间。

程序计数器是一块内存区域,用来记录线程当前要执行的指令地址。那么为何要将程序计数器设计为线程私有的呢? 因为线程是占用 CPU 执行的基本单位,而 CPU 一般是使用时间片轮转方式让线程轮询占用的,所以当前线程 CPU 时间片用完后,要让出 CPU,等下次轮到自己的时候再执行。

那么如何知道之前程序执行到哪里了呢? 其实程序计数器就是为了记录该线程让出 CPU 时的执行地址的,待再次分配到时间片时线程就可以从自己私有的计数器指定地址继续执行。

栈空间用于存储该线程的局部变量,这些局部变量是该线程私有的,其他线程是访问不了的,除此之外栈还用来存放线程的调用栈帧。
是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时分配的,堆里面主要存放使用 new操作创建的对象实例。

方法区则用来存放JVM加载的类、常量及静态变量等信息,也是线程共享的。

并发编程需要处理的核心问题是:线程的通信线程的同步

线程通信

是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种共享内存和消息传递。

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,对开发者来说无感知。

线程同步

是指程序用于控制不同线程之间操作发生相对顺序的机制。

在共享内存的并发模型里,同步是显式进行的。必须显式指定某个方法或某段代码需要在线程之间互斥执行。

2.数据不一致的原因

实际造成数据不一致的原因可能有 CPU 缓存导致了可见性问题;指令重排导致了有序性问题;线程竞争触发线程上下文切换导致了原子性问题。

2.1 可见性

下图是多核 CPU 高速缓存架构

image-20221004213412366

如图所示每个 CPU 都有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辑运算,L1 Cache,L2 Cache属于每个 CPU 内核独享的缓存,缓存主内存共享变量的数据作为副本,而副本与副本之间是完全不可见的,L3 Cache为多 CPU 共享缓存。

CPU 有了高速缓存之后,在程序运行时,会将运算需要的数据从主内存复制一份到CPU的高速缓存中,接着在高速缓存中进行读取与写入操作,当运算结束后,会将高速缓存中的数据刷新到主内存中。

当 CPU 要读取一个数据时,首先从 L1 Cache存中查找,如果没有找到再从 L2 Cache缓存中查找,如果还是没有就从 L3 Cache或主内存中查找。

因此可以将高速缓存理解为上面说到的线程私有工作内存。

同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。

下面将举几个例子说明

例1:

下面程序中,有全局变量 M 初始值为 0,修改全局变量的方法 change(),获取全局变量 M 的方法 get()

分别有 2 个线程,线程 1 和线程 2,任务执行顺序如下

  • 线程 1 调用 get()方法获取全局变量 M 的值,然后调用了 chagne()方法将全局变量M的值设置为1024,再次调用 get()方法获取全局变量 M 的值。
  • 线程 2 调用 get()方法获取全局变量 M 的值
public class Test01 {

    public int M;

    @Test
    public void test() {
        Thread thread1 = new Thread(() -> {
            System.out.println("线程1:change before get:" + get());// 1
            change(1024);// 2
            System.out.println("线程1:change after get:" + get());// 3
        });
        Thread thread2 = new Thread(() -> {
            System.out.println("线程2:change get:" + get());// 4
        });
        thread1.start();
        thread2.start();
    }


    public int get() {
        return M;
    }

    public void change(int temp) {
        M = temp;
    }
}

程序执行结果如下:

线程1:change before get:0
线程2:change get:0
线程1:change after get:1024

会发现明明线程 1 修改了共享变量 M 的值,线程 2 却获取不到最新修改后的值。

我们来深入分析下上面程序的流程

假设线程 1 线程 2 在不同的 CPU 被执行。

线程 1 首先获取共享变量M的值,由于三级缓存都没有命中,所以加载主内存中 M 的值。然后把 M = O 的值缓存到两级缓存,线程1将M的值修改为1024, 然后将其写入 L1 L2Cache,并且刷新到 L3 Cache以及主内存。线程 1 操作完毕后,线程1所在的CPU的三级缓存内和主内存里面 M 的值都是 1024。

线程 2 获取M的值,L1 L2 Cache缓存未命中,L3 Cache缓存命中,所以返回 M 的值为 1024。

但事实并非如此,线程 2 获取 M 的值为 0,出现了数据不一致

这种不一致情况产生的可能性如下:

线程 1 修改 M 值后未写入 L3 Cache与主内存中,CPU 切换线程 2 执行,线程 2 获取 L1 L2 L3 Cache 均未命中,去主内存中获取值,由于线程 1 修改的 M 值未刷新到主内存中,当前获取到 M 的值为 0。

例2:

依旧假设线程1线程2在不同的CPU被执行。

执行步骤如下:

  1. 线程 1 首先获取共享变量M的值,由于三级缓存都没有命中,加载主内存中 M 的值,然后把 M=0 的值缓存到两级缓存中。线程 1 修改 M 的值改为 1024,然后将其写入三级缓存,并且刷新到主内存。线程 1 操作完毕后。线程 1 所在的CPU的两级缓存和 L3 Cache 及主内存里面的 M 的值都是 1024。
  2. 线程 2 获取共享变量M的值,L1 L2 Cache未命中,L3 Cache命中,获取 M 的值为 1024,此时主内存中值也为 1024,数据一致没有问题。此时线程 2 修改M的值改为 2048,然后将其写入三级缓存,并刷新到主内存。线程 2 操作完毕后线程的两级缓存和 L3 Cache 及主内存里面的 M 的值都是 2048。
  3. 线程 1 获取共享变量 M 的值,L1 Cache 命中,此时获取到 M 的值为 1024。问题出现了,此时主内存中共享变量 M 的值为 2048,数据不一致。

这就是共享变量的内存不可见问题,也就是线程 2 写入的值对线程1不可见。

2.2 有序性

例如:

如下代码:


/**
 * @description: Test03
 * @Author MRyan
 * @Version 1.0
 */
public class Test03 {

    private static boolean flag = false;

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            while (!flag) {
            }
            System.out.println(count);
        });

        Thread thread2 = new Thread(() -> {
            count = 2;
            flag = true;
        });

        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
    }
}

上述代码的执行结果不确定,有可能是 0,也有可能是 2。

因为 thread2 线程执行的两行代码并没有依赖关系,指令会进行重排序

在单线程环境下当前正在运行的线程是 thread2 线程,两行语句哪条先执行哪条后执行,对结果没有任何影响。

但如果在多线程环境下,对于线程 thread2 的代码来说,CPU 就有可能对其重排序,先执行第二条语句(flag = true),再执行第一条语句(count = 2)。在 flag 变为 true 但 count 仍为 0 时,

1456100_1657209436

在代码编译执行的过程中,会发生指令重排序,下文会有详细介绍。

2.3 原子性

原子性操作指的是不可分割的操作,原子性操作执行过程中的中间状态不会被访问到。非原子性操作在多线程环境下并发竞争执行会存在问题。

举例如下:

线程 thread1 和线 thread2 共享变量 count,线程 thread1 执行 100000 次 count++,线程thread2 执行 100000 次 count++,最终 count 的值是多少呢

/**
 * @description: Test02
 * @Author MRyan
 * @Version 1.0
 */
public class Test02 {

    private static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                count++;
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 100000; i++) {
                count++;
            }
        });

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println(count);
    }
}

事实上最终的 count 值并不确定,每次运行结果都不一样,但绝大部分情况下都小于 200000。

之所以有这样的运行结果,主要是因 为 count++ 是非原子操作,经过编译之后,count++ 这条语句对应3 条CPU 指令

  1. 首先是读取数据到寄存器
  2. 然后在寄存器上执行自增操作
  3. 最后是将寄存器中的数据写入内存。

如果两个线程并行(多核)或并发(单核多线程)执行 count++,count++ 对应的 3 条CPU指令有可能会交叉执行,从而产生不可预期的结果。

我们拿单核多线程来进一步举例分析。

每个线程独享一组寄存器。

假设线程 t1 将 count 的值 0 读取到寄存器,接着就发生了线程切换

线程 t2 将 count 的值 0 也读取到寄存器,线程 t2 将寄存器中的 count 值自增 1 ,然后写入内存,此时内存中的 count 值变为了 1。这时又发生了线程切换。CPU 切换为执行线程 t1。

线程 t1 的寄存器中的 count 值仍然为0,线程 t1 将寄存器中的 count 值自增 1,然后写入内存,这时内存中的 count 值还是 1。两个线程分别对 count 执行了自增 1 的操作,预期 count 值变为 2,但最终结果却是 1。

70859200_1657209436

3.重排序

为提升性能而设计的优化-重排序,会导致并发程序出现有序性问题。

什么是重排序

为提升CPU的性能和利用率,将指令不按照程序代码的顺序执行,重排序过程不会影响到单线程程序的执行结果,却会影响到多线程并发执行的正确性。

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三类:

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

从 Java 源代码到最终实际执行的指令序列,会分别经历下面重排序过程:

image-20221004201114511

上面的这些重排序都可能导致多线程程序出现内存可见性问题。

  • 对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
  • 对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

3.1 处理器重排序

现代处理器使用缓存区来临时保存向内存写入的数据,这样可以避免由于处理器停顿等待向内存写入数据而产生的延迟,也可以以批处理的方式刷新写缓存区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。但它的缺点也可能会发生内存实际发生的读/写执行顺序与处理器对内存读/写执行顺序的不一致,这是因为每个处理器的写缓冲区仅对自己可见。

举例说明:

image-20221004224804354

假设处理器 A 和处理器 B 按程序的顺序并行执行内存访问,最终却可能得到 x = y = 0。具体的原因如下图所示:

image-20221004224924492

处理器A和B同时把共享变量写入在写缓冲区中(A1、B1),然后再从内存中读取另一个共享变量(A2、B2),最后才把自己写缓冲区中保存的脏数据刷新到内存中(A3、B3)。当以这种时序执行时,程序就可以得到 x = y = 0 的结果。

从内存操作实际发生的顺序来看,直到处理器 A 执行 A3 来刷新自己的写缓存区,写操作 A1 才算真正执行了。虽然处理器A执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器A的内存操作顺序被重排序了。

4.Java内存模型抽象

Java内存模型(简称为JMM)

在Java中,局部变量、方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享。

Java 提供了一些解决方案,由程序员按需使用,保证多线程下代码的正确运行。Java 提供的解决方案被定义为 Java 内存模型,对应的规范为 JSR-133。之所以叫做 Java 内存模型,是因为要解决的问题,也就是多线程的三大问题,都跟内存数据读写有关。

JMM 规定所有的变量都存放在主内存内,每个线程都有一份属于自己的私有工作内存空间,当线程使用变量时会将主内存内的变量复制一份共享变量副本到线程的本地私有内存空间中,线程读写变量时操作的都是在自己的本地内存,处理完之后将变量刷回主内存。

JMM 规定了线程的工作内存和主内存的交互关系,以及线程之间的可见性和程序的执行顺序。Java 线程之间的通信由 JMM 控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。

image-20221004190815752

从上图来看,如果线程 1 和线程 2 要通信的话,要如下两个步骤:

1、线程 1 需要将本地内存 1 中的共享变量副本刷新到主内存去。

2、线程 1向 B 发送通信信息让线程 2 去主内存读取线程 1 之前已更新过的共享变量。

整体来看,线程通信过程必须经过主内存。JMM 通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证。

在 JMM 中 java 定义了一些关键字,限制内存中多线程共享数据的读写方式,从而解决可见性,有序性,原子性问题。

5.如何保证数据一致性

5.1 总线加锁(硬件层面)

在早期的 CPU 当中,是通过在总线上加 LOCK# 锁的形式来解决缓存不一致的问题。

因为 CPU 和其他部件进行通信都是通过总线来进行的,如果对总线加 LOCK# 锁的话,也就是说阻塞了其他CPU 对其他部件访问(如内存),从而使得只能有一个 CPU 能使用这个变量的内存。

例如在一个线程在执行 M = M + 1 这段代码的过程中,在总线上发出了 LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他 CPU 才能从变量M所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

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

于是 MESI 缓存一致性协议诞生了。

5.2 MESI缓存一致性协议(硬件层面)

同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作。

MESI 是把 CPU 的内核中的高速缓存分成了四种状态,CPU 中每个缓存行使用的4种状态进行标记

分别是:

  1. M(Modify) 表示共享数据只缓存在当前 Cache 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
  2. E(Exclusive) 表示缓存的独占状态,数据只缓存在当前 Cache 存中,并且没有被修改
  3. S(Shared) 表示数据可能被多个 Cache 缓存,并且各个缓存中的数据和主内存数据一致
  4. I(Invalid) 表示缓存已经失效

简单的来说:

MESI 协议保证了每个缓存中使用的共享变量的副本是一致的。

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

MESI工作原理

缓存一致性协议通过监控独立的 loads 和 stores 指令来监控缓存同步冲突,在硬件层面提供的方法,并确保不同的处理器对于共享内存的状态有一致性的看法。当一个处理器 loads 或 stores 一个内存地址时,它会在bus 总线上广播该请求,其他的处理器和主内存都会监听总线(也称为snooping)

举例说明:

假使有一个数据int X=1,这个数据被两个线程读取到了,线程 1 在 CPU 内核 1 上面执行,线程 2 在CPU 内核 2 上面执行。

执行流程及 MESI 状态流转说明如下

  1. CPU 内核 1 从内存中将变量 X 加载到缓存中,并将变量 X 的状态改为 E 状态(独享),并通过总线嗅探机制对内存中变量 X 的操作进行嗅探
  2. 此时,CPU 内核 2 读取变量 X,总线嗅探机制会将 CPU 内核 1 中的变量 X 的状态置为 S 状态(共享),并将变量 X 加载到 CPU2 的缓存中,状态为 S 状态(共享)
  3. 线程 1 执行指 “X = X+1 ”,此时 CPU 内核 1 中的变量 X 会被置为 M 状态(修改),而 CPU 内核 2 中的变量 X 会被通知,改为 I 状态(无效),此时CPU内核2中的变量X做的任何修改都不会被写回内存中(高并发情况下可能出现两个 CPU 内核同时修改变量 X,并同时向总线发出将各自的缓存行更改为 M 状态的情况,此时总线会采用相应的裁决机制进行裁决,将其中一个置为 M 状态,另一个置为 I 状态,且 I 状态的缓存行修改无效)
  4. CPU 内核 1 将修改后的数据写回内存,并将变量 X 置为 E 状态(独占)
  5. 此时,CPU 内核 2 通过总线嗅探机制得知变量 X 已被修改,会重新去内存中加载变量 X,同时 CPU 内核1和CPU2 内核中的变量 X 都改为 S 状态(共享)

MESI 协议只能保证并发编程中的可见性,并未解决原子性和有序性的问题,所以只靠 MESI 协议是无法完全解决多线程中的所有问题。

5.3 happens-before

JSR-133 内存模型使用 happens-before 的概念来阐述操作之间的内存可见性。在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

一个 happens-before 规则对应于一个或多个编译器和处理器重排序规则。

JMM 呈现给开发者相关的 happens-before 规则如下:

  • 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器的解锁,happens-before 于随后对这个监视器的加锁。
  • volatile 变量规则:对一个 volatile 域的写,happens-before于任意后续对这个 volatile 域的读。
  • 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。

注意,两个操作之间具有 happens-before 关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before 仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

Happens-before 规则易于理解(专门给程序员看的),可以理解为是对复杂的重排序规则的抽象简化,可以帮助开发者依照规则检查自己编码在多线程下的执行顺序是否符合预期。

5.4 数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称代码示例说明
写后读a = 1; b = a;写一个变量之后,再读这个位置。
写后写a = 1; a = 2;写一个变量之后,再写这个变量。
读后写a = b; b = 1;读一个变量之后,再写这个变量。

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。

前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。

注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

5.5 AS-IF-SERIAL

as-if-serial 把单线程程序保护起来了,它可以保证在重排序的前提下程序的最终结果始终都是一致的,换句话说重排序要在不改变程序执行结果的前提下,尽可能提高程序的运行效率

为了遵守 as-if-serial 编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是如果操作之间没有数据依赖关系,这些操作就可能被编译器和处理器重排序。

举例说明:

int X = 1; //1
int Y = 2; //2
int Z = X * Y; //3

可以看出 Z 的值依靠于 X 和 Y,所以在最终执行指令中 3 不可排在 1 和 2 命令之前,而 1 和 2 执行顺序的更改不会影响最终结果,所以可能会被重排序,最终执行顺序可能为 1->2->3 或者 2->1->3。

5.6 内存屏障

下面会提到volatile实现原理其实就是编译器在指定序列插入指定类型的内存屏障。

为保证内存可见性,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

JMM 把内存屏障指令分为下列四类:

屏障类型指令示例说明
LoadLoad BarriersLoad1; LoadLoad; Load2确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载。
StoreStore BarriersStore1; StoreStore; Store2确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储。
LoadStore BarriersLoad1; LoadStore; Store2确保 Load1 数据装载,之前于 Store2 及所有后续的存储指令刷新到内存。
StoreLoad BarriersStore1; StoreLoad; Load2确保 Store1 数据对其他处理器变得可见(指刷新到内存),之前于 Load2 及所有后续装载指令的装载。StoreLoadBarriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

StoreLoad Barriers 是一个“ 全能型” 的屏障, 它同时具有其他 3 个屏障的效果。 现代的多处理器大多支持该屏障( 其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵, 因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中。

5.7 同步机制-Volatile

一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:

  1. 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这个新值对(任意)线程来说是立即可见的,解决了可见性问题。
  2. 禁止进行指令重排序,解决了有序性问题。
例1:
public class Test03 {

    /**
     * 使用 volatile 声明
     */
    public volatile int M = 0;

    /**
     * 单个 volatile 变量的写
     *
     * @param l
     */
    public void set(int l) {
        M = l;
    }

    /**
     * 单个 volatile 变量的读
     *
     * @return
     */
    public long get() {
        return M;
    }

    /**
     * 复合(多个) volatile 变量的读 /写
     */
    public void getAndIncrement() {
        M++;
    }

    @Test
    public void test() {
        Thread thread1 = new Thread(() -> {
            System.out.println("线程1:change before get:" + get());
            set(1024);
            System.out.println("线程1:change after get:" + get());
        });
        Thread thread2 = new Thread(() -> {
            System.out.println("线程2:change before get:" + get());
            getAndIncrement();
            System.out.println("线程2:change after get:" + get());
        });
        thread1.start();
        thread2.start();
    }
}

执行结果如下:

线程1:change before get:0
线程1:change after get:1024
线程2:change before get:1024
线程2:change after get:1025
例2
public class Test04 {

    private static boolean flag = true;

    public static void main(String[] args) throws InterruptedException {
        new Thread1().start();
        // 暂停1秒 保证线程1启动并运行
        Thread.sleep(1000);
        new Thread2().start();
    }


    /**
     * 线程1 如果flag为false跳出循环
     */
    static class Thread1 extends Thread {
        @Override
        public void run() {
            while (flag) {
            }
            System.out.println("thread1-run");
        }
    }

    /**
     * 线程2 2秒后将flag改成false
     */
    static class Thread2 extends Thread {
        @Override
        public void run() {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread2-run");
            flag = false;
            System.out.println("flag 被改成false");
        }
    }
}

执行结果如下:

Thread2-run
flag 被改成false

也就是说程序一直处于运行中,线程 2 将 flag 更改为 false后,线程 1 并不知道

将 flag 用 volatile 修饰,更改代码如下:

private volatile static boolean flag = true;

再次运行程序

执行结果如下:

Thread2-run
flag 被改成false
thread1-run

此时线程 1 感知到 flag 变成了 false,跳出循环,输出 thread1-run

说明 volatile 修饰变量在线程中的可见性。

Volatile语义规则

锁的 happens-before 规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入

锁的语义决定了临界区代码的执行具有原子性。这意味着即使是 64 位的 long 型和 double 型变量,只要它是 volatile 变量,对该变量的读写就将具有原子性。如果是多个 volatile 操作或类似于volatile++ 这种复合操作,这些操作整体上不具有原子性

简而言之,volatile 修饰的变量具有下列特性:

  • 可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
  • 原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。
Volatile读写的内存定义
  • 一个 volatile 修饰的变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
  • 一个 volatile 修饰的变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
Volatile实现原理

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

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

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

下面是保守策略下,volatile 写操作插入内存屏障后生成的指令序列示意图:

上述 volatile 写操作和 volatile 读操作的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile 读写的内存语义,编译器可以根据具体情况省略不必要的屏障。

volidate能解决原子性问题吗?

首先我们简单了解下什么是线程竞争导致的原子性问题

对于原子操作,即便是多线程执行,也不会出现问题。我们拿赋值语句(例如 count = 10000)举例。因为 CPU 不需要将 count 值读取到寄存器再修改,直接更改内存中的count值即可,所以,count = 10000 这个赋值语句是原子操作。多线程下并行执行赋值语句,每条赋值语句都不可分割,执行结束之后,才会执行下一条赋值语句,因此,也就不存在像 count++ 那样的交叉执行的问题了。

这里提一嘴,实际上并不是你以为的原子操作都是原子操作,例如 long,double 基本类型是64位的(不论 java 运行 32 位计算机还是 64 位计算机上)

对于 32 位计算机,对 long 或 double 类型数据赋值,需要两次内存写操作才能完成,这就相当于执行了两条CPU 指令,因此,不是原子操作。同理,读取 long 或 double 类型数据,也需要执行两次内存的访问,因此,不是原子操作。

回归正题,validate能解决原子性问题吗?

在 32 位计算机上,读写 64 位的 long 或 double 类型数据,会执行两次内存读写操作。如果我们使用volatile 关键词修饰 long 或 double 类型变量,那么,编译器将代码编译成机器指令时,会在两次读或写之前添加锁定总线的指令,在完成两次读或写之后再释放总线,这样就可以保证 64 位 long 或 double 类型数据在 32 位计算机上读写的原子性。

不过,现在大部分计算机都已经是 64 位的了,因此,我们也就不需要为 long 或 double 类型变量特殊照顾了。

但对于 count++ 自增语句,虽然 validate 可以解决可见性问题,当两个线程同时对 volatile 修饰的变量进行自增操作时,一个线程对变量的修改,会立刻写入内存,并让另一个线程的CPU缓存失效,另一个线程从内存读取新的值进行自增,但自增语句的原子性问题,不是出在CPU缓存上,而是出在寄存器上。

上文提到自增语句可以分解为 3 个指令

  1. 首先是读取数据到寄存器
  2. 在寄存器上执行自增操作
  3. 将寄存器的数据写入内存。

假设线程 t1 和线程 t2 分别运行在 CPU A 和CPU B 上,当线程 t1 和线程 t2 都将变量值读取到寄存器之后,尽管变量被 volatile 修饰,线程 t1 对变量的修改,能够立即写入内存,并且同步给线程 t2 所使用的 CPU 缓存,但并不会同步更新线程 t2 所使用的寄存器,线程 t2 中的寄存器保存的仍然是老值,对老值自增 1 ,然后写入内存,就会导致覆盖掉线程 t1 更新之后的结果。

6.总结

本文首先简单介绍了线程的定义,通过分析导致线程安全问题原因,得出保证线程安全的解决方案。

简单总结下

我们将可能引起线程不安全的局部代码块称为临界区,将两个线程竞争执行临界区的这种状态称为竞态。两个线程处于竞态执行临界区,就很有可能引发线程安全问题。

临界区包含访问共享资源,非原子性的复合操作等。

对于线程不安全的问题细分了 3 类(可见性,有序性,原子性)

引出了 Java 内存模型,以及介绍了 JMM 如何解决可见性,有序性,原子性问题。

抽象出来其实是通过互斥和同步解决。

对于原子性问题,我们可以通过互斥的方案解决线程问题:通过对临界区加锁,让临界区变为原子操作。一个线程执行完临界区代码之后,才允许下一个线程执行,例如使用synchronized可以解决可见性,有序性,原子性问题,后面会有专门的文章来介绍。

也可以通过同步的方式协同多个线程,例如让一个线程等待另一个线程执行完成之后再执行。

对于解决内存可见性,有序性问题,Java提供了一种弱形式的同步,也就是使用volatile关键字,并解答了经典问题:validate能解决原子性问题吗?

至此本篇完。

参考:

《深入Java内存模型》

《深入理解Java虚拟机》

《Java并发编程之美》

《Java编程之美》

作者:MRyan


本文采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。
转载时请注明本文出处及文章链接。本文链接:https://wormholestack.com/archives/601/
2025 © MRyan 130 ms