本篇是对 Java 内存模型 ( JMM ) 及 volatile 关键字的深度记录,从而彻底理解 JMMvolatile 关键字的作用。

# Java 内存区域与内存模型

# Java 内存区域

Java 虚拟机在运行程序时会把自动管理的内存划分为以下几个区域,每个区域都有各自的用途以及创建和销毁的时机,其中蓝色部分代表的是所有线程共享的数据区域,绿色部分代表的是每个线程私有的数据区域。

piIY4MT.png

  • Method Area(方法区)
    • 方法区又被称为: Non-Heap 属于线程共享的内存区域,主要用于存储已被虚拟机加载的类信息、常量、静态变量、通过即时编译器编译后的代码等数据,根据 JVM 规定,当 Method Area(方法区) 无法满足内存分配需求时,将会抛出 OutOfMemoryError 错误,注意:在 Method Area(方法区) 中存在一个叫运行时常量池 (Runtime Constant Pool) 的区域,它的主要作用是存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中以便后续使用。
  • Java Heap(JVM堆)
    • Java 堆也属于线程共享内存区域,它在虚拟机启动时创建,是 Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里进行内存分配,注意: Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称为 GC 堆,如果在堆中没有内存完成实例分配并且堆无法再扩展时,将会抛出 OutOfMemoryError 错误。
  • Program Counter Register(程序计数器)
    • 程序计数器属于线程私有数据区域,它是一小块内存空间,主要代表当前线程所执行的字节码行号的指示器,字节码解释器工作时,通过改变这个计数器的值来选区下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复等基础功能都依赖于这个计数器来完成。
  • Java Virtual Machine Stacks(虚拟机栈)
    • 虚拟机栈属于线程私有数据区域,它与线程同时创建,总数与线程相关联,它代表 Java 方法执行的内存模型,每个方法执行时都会创建一个栈帧来存储方法的变量表、操作数栈、动态链接、返回值、返回地址等信息,方法的调用到结束对应一个栈帧在虚拟机栈中的入栈和出栈过程。
      piIYvQK.png
  • Native Method Stacks(本地方法栈)
    • 本地方法栈属于线程私有数据区域,这部分主要与虚拟机用到的的 Native 方法有关,在一般情况下我们不需要关心此区域。

注意: Java 内存区域和 Java 内存模型不是同一种概念,它们两种是属于不同层次的概念。因此 Java 内存区域和 Java 内存模型是完全不同的两个东西。

# Java 内存模型

Java 内存模型又被称为 JMM(Java Memory Model) ,它是一种抽象概念,描述的是一组规则或规范,通过这组规范定义了程序中的各个变量 (其中包括:实例字段、静态字段、数组等) 的访问方式,由于 JVM 运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存有时候也会被称为栈空间,用于存储线程私有数据,而 Java 内存模型中规定了所有变量都要存储在主内存中,主内存是共享内存区域,所有线程都可以进行访问,但线程对变量的操作 (读取赋值等操作) 必须在工作内存中进行才可以,首先会将变量从主内存拷贝到自己的工作内存空间中,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本数据,在前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信必须通过主内存来完成,过程如下图:

piop9xO.png

注意: JMMJava 内存区域的划分是不同的概念层次,更恰当来说 JMM 描述的是一组规则,通过这组规则来控制程序中的各个变量在共享数据区域和私有数据区域的访问方式, JMM 是围绕原子性、有序性、可见性展开的。 Java 内存模型与 Java 内存区域唯一相似的是都存在共享和私有数据区域,在 JMM 中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存和线程私有数据区域,从某个程度上讲应该包括程序计数器、虚拟机栈及本地方法栈。在某些地方我们会看到主内存会被描述为堆内存,工作内存被描述为线程栈,实际上它们表达的都是同一个含义, JMM 主内存和工作内存详细介绍如下:

  • 主内存:
    • 其主要存储的是 Java 实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是局部变量,当然也包括共享的类信息、常量、静态变量,由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。
  • 工作内存:
    • 其主要存储当前方法的所有本地变量信息 (包含从主内存中拷贝的变量副本),每个线程只能访问自己的工作内存,即:线程中的本地变量对其它线程是不可见的,就算是两个线程执行同一段代码,它们也会在各自的工作内存中创建属于当前线程的本地变量,当然也包括字节码行号指示器和 Native 相关方法信息,由于工作内存是每个线程的私有数据,线程间无法互相访问,因此存储在工作内存的数据不存在线程安全问题。

# 主内存与工作内存的数据存储类型

在上面了解了主内存和工作内存后,下面将来看一下它们的数据存储类型及操作方式,根据 Java 虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型 (byte、short、char、int、long、float、double、boolean) ,将会直接存储在工作内存的栈帧结构中,倘若本地变量是引用类型,那么该变量的引用会存储在工作内存的栈帧中,而对象实例将存储在主内存的共享数据区域的堆中。

但是对于实例对象的成员变量,不管它是基本数据类型或引用类型还是包装类型 (Interger、Double) 等,都会被存储到堆中,至于 static 静态变量以及类本身相关信息将会存储在主内存中。需要注意:在主内存中的实例对象可以被多线程共享,如果两个线程同时调用同一个对象的同一个方法时两条线程会将要操作的数据拷贝到自己的工作内存中,在执行完成后会写回到主内存从而实现数据修改操作。

pio9O41.png

# Java 内存与硬件内存

# 硬件内存架构

pioiynO.png

上图为了方便理解,省去了南北桥并将三级缓存统称为 CPU 缓存,对于目前计算机而言,每个 CPU 都存在多个核心,多核指的是在一枚处理器中集成多个完整计算引擎 (内核) ,这样就可以支持多任务并行执行,从多线程角度来讲,每个线程都会映射到各个 CPU 核心中并行执行。

在处理器内部有一组 CPU 寄存器,它是一个临时存放数据的空间,一般处理器都会从内存取数据到寄存器,然后对其进行处理,但由于内存的处理速度远低于 CPU 这就导致处理器在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了 CPU 缓存, CPU 缓存很小但访问速度远比主内存快的多。

如果处理器总是操作主内存中的同一地址数据,就很容易影响处理器执行速度,此时 CPU 缓存就可以把内存的数据暂时存起来,如果寄存器要取内存中的同一个位置的数据,就可直接从 CPU 缓存中取出,无需再跑到主内存中去取。

注意:寄存器并不是每次数据都可以从缓存中取到,如果不是同一个内存地址中的数据,那么寄存器还必须得绕过缓存从内存中取数据,这种现象叫做缓存命中率,从缓存中可以取到数据就代表命中,则而反之就是从内存中取就代表没命中,缓存命中率的高低也会影响处理器执行效率,这就是 CPU(处理器) 、缓存、以及主内存的数据交互。

总而言之当一个 CPU 需要访问主内存时,会先读取一部分主内存中的数据到 CPU 缓存,如果 CPU 缓存中存在需要的数据就会直接从缓存中获取,进而在读取 CPU 缓存到寄存器,当 CPU 需要写数据到主内存时,同样会先刷新寄存器中的数据到 CPU 缓存,然后再把数据刷新到主内存中。

# 线程与硬件处理器

Java 线程的实现是基于一对一的线程模型,所谓的一对一模型就是通过语言级别层面程序去间接调用系统内核的线程模型 (即我们使用 Java 线程时), 在 Java 虚拟机内部是转而调用当前操作系统的 内核线程(Kernel-Level Thread,kLT) 来完成当前任务,它是由操作系统 内核(Kernel) 支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。

每个内核线程可以被视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因,由于我们编写的多线程程序属于语言层面,程序一般不会直接去调用内核线程,取而代之的是一种轻量级 进程(Light Weight Process) ,也就是所谓的线程,由于每个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程来调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间的一对一关系就被称为一对一的线程模型。

piojxQe.png

如上图所示,每个线程最终都会映射到 CPU 中进行处理,如果 CPU 存在多核那么一个 CPU 将可以并行执行多个线程任务。

# 内存模型与内存架构关系

在了解了前面的硬件内存架构和 Java 内存模型及 Java 多线程的实现原理后,我们已经意识到多线程的执行最终都会映射到硬件处理器上进行执行,但 Java 内存模型和硬件内存架构并不完全一致,对于硬件内存来说只有寄存器、缓存、主内存的概念,并没有工作内存 (私有数据区域) 和主内存 (堆内存) 之分,也就是说 Java 内存模型的内存划分对硬件内存并没有任何影响,因为 JMM 只是一种抽象概念,是一组规则并不存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来讲都会存储在计算机主内存中,当然也有可能存储到 CPU 缓存或寄存器中,因此总体来讲 Java 内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉,对于 Jav 内存区域划分也是同样的道理。

pioxUgS.png

# 指令重排

指令重排是什么?简单来说就是操作系统在执行代码的时候并不一定按照你的代码顺序依次执行。

  • 计算机在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序列操作,并分为以下三种方式:
    • 编译器优化重排 :编译器在不改变单线程程序的前提下,可以重新安排指令语句的执行顺序。
    • 指令并行重排 :现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性 (即:后面的执行语句不依赖于前面执行语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
    • 内存系统重排 :由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去就像是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。
  • Java 源代码会经历 编译器优化重排 ➡️ 指令并行重排 ➡️ 内存系统重排 的过程最终才变成操作系统可执行的指令顺序,另外指令重排序可以保证串行语义一致,但是没有义务保证多线程之间的语义一致,所以在多线程情况下,指令重排就会可能导致一些问题的发生。
  • 编译器和处理器的指令重排处理方式是不同的,对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。对于处理器,通过插入内存屏障 (Memory Barrier又被称为内存栅栏或Memory Fence) 的方式来禁止特定类型的处理器重排序,指令并行重排和内存系统重排都属于处理器级别的指令重排序。
    • Memory Barrier(内存屏障) :它是一种 CPU 指令,用来禁止处理器指令发生重排序 (像屏障一样),从而保障指令执行的有序性,另外为了达到屏障效果,它也会使处理器写入、读取值之前将主内存的值写入高速缓存并清空无效队列,从而保证变量的可见性。
CommandDisorder.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: 指令重排示例代码
 * @DateTime: 2023-12-22 00:21
 * @Version:1.0
 **/
/**
 * 指令重排测试
 */
public class CommandDisorder {
  /**
   * 当使用 volatile 关键词修饰变量时,则不会出现指令重排现象
   */
  private static /*volatile*/ int a = 0, b = 0, c = 0, d = 0;
  /**
   * 测试方式:一次开启两个线程,同时修改变量
   */
  public static void main(String[] args) throws InterruptedException {
    int i = 0;
    while (true) {
      i++;
      a = b = c = d = 0;
      Thread t1 = new Thread(() -> {
        // 指令重排,会先执行这行代码,导致 c = 0, d = 0
        a = 1;
        c = b;
      });
      Thread t2 = new Thread(() -> {
        // 指令重排,会先执行这行代码,导致 c = 0, d = 0
        b = 1;
        d = a;
      });
      t1.start();
      t2.start();
      t1.join();
      t2.join();
      if (c == 0 && d == 0) {
        System.err.printf("第%s次出现指令重排%n", i);
        break;
      } else {
        System.out.println(i);
      }
    }
        /*
        执行结果:
                1
                2
                3
                4
                ...
                56145
                56146
                56147
                第 56148 次出现指令重排
         */
  }
}

上述代码就是用于测试指令重排会在什么时候发生,指令重排会在多少次后进行重排操作都是不确定的,在真实业务中想要避免指令重排请加上 volatile 关键字,来保证变量在线程间的可见性。

# JMM(Java Memory Model)

# JMM 到底是什么?

Java 是最早尝试提供内存模型的编程语言,由于早期内存模型存在一些缺陷 (例如非常容易削弱编译器的优化),自 Java 5 开始, Java 开始使用新的内存模型 《JSR-133:Java Memory Model and Thread Specification》。一般来讲,编程语言也可以直接复用操作系统层面的内存模型,不过不同的操作系统内存模型是不同的。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。 Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。

这只是 JMM 存在的其中一个原因,实际上对于 Java 来说,你可以把 JMM 看做是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行命令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序的可移植性。

为什么需要遵守并发相关的原则和规范,这是因为并发编程下像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。比如指令重排就可能会让多线程程序的执行出现问题,为此 JMM 抽象了 happens-before 原则来解决指令重排问题。

JMM 简单来说就是定义了一些规范来解决这些问题,开发者可以利用这些规则更方便地开发多线程程序,对于 Java 开发者来说不需要深度了解底层原理也可以开发出并发安全的程序,使用并发相关的关键字如: volatilesynchronizedLock 等,即可实现并发安全。

# JMM 存在的必要性

在明白了 Java 内存区域和硬件内存架构及 Java 多线程实现原理与 Java 内存模型的具体关系后,来看 Java 内存模型存在的必要性,由于 JVM 运行程序的实体是线程,而每个线程创建时 JVM 都会为其创建一个工作内存,用于存储线程私有数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝到每个线程各自的工作内存中,然后对变量进行操作,操作完成后再将变量写回主内存。

如果两个线程同时对一个主内存中的实例对象变量进行操作就有可能诱发线程安全问题,请看下图,主内存中存在一个共享变量 x , 但现在有两个线程分别是 A 线程和 B 线程对该变量进行赋值操作 (如: x = 100 ), AB 每个线程各自的工作内存中都存在共享变量副本 x ,假如现在 A 线程想要修改 x 值为 150 ,而 B 线程却要读取 x 的值,那么 B 线程读取到的值是 A 线程更新后的数据还是更新前的数据呢?这谁也不知道,有可能 B 线程读取到更新后的也有可能读取到更新前的数据,这是因为工作内存是每个线程私有数据区域,而 A 线程操作变量 x 时首先将变量从主内存拷贝一份到 A 线程的工作内存中,然后对其变量进行赋值操作等,等操作完成后再将变量 x 写回到主内存,对于 B 线程也是类似的,这样就有可能造成主内存与工作内存数据存在一致性问题。

假设 A 线程修改完成后正在将数据写回主内存中,而 B 线程此时正在读取主内存,即:将 x = 100 拷贝到自己的工作内存中,这样 B 线程读取到的值就是 x=100 , 但如果 A 线程已将 x = 150 写回到主内存后, B 线程才开始读取的话那么此时 B 线程读取到的就是 x = 150 , 但到底是哪种情况会发生谁都不会知道,这就是所谓的线程安全问题。

piTsRrd.png

为了解决类似上述的问题 JVM 定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则被称为 Java 内存模型 (JMM) , JMM 是围绕着程序执行的原子性、有序性、可见性展开的。

# JMM 的三种特性

# 原子性

原子性是指一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其它线程所影响,如:对一个静态变量 ( private static int i = 0 ) 两条线程同时对它进行赋值操作,线程 A 赋值为 100 ,线程 B 赋值为 200 ,不管线程如何运行最终 i 的值要么是 100 要么是 200 ,线程 A 和线程 B 之间的操作是没有任何干扰的,这就是原子性操作不可被中断的特点。

但是对于 32 位系统来讲 long 类型和 double 类型数据它们读写并非原子性的,对于基本类型数据: byteshortintfloatbooleanchar 的读写是原子操作,也就是说两条线程同时对 long 类型或 double 类型的数据进行读写是会存在互相干扰的,因为对于 32 位虚拟机来说,每次原子读写是 32 位,而 longdouble 则是 64 位的存储单元。

这样就会导致一个线程在写时,操作完前 32 位的原子操作后,轮到 B 线程读取时,恰好只读到了后 32 位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能就是半个变量的数值,即 64 位数据呗两个线程分成了两次读取。也不必太过担心至少目前的商用虚拟机中几乎把 64 位数据读写操作作为原子操作来执行。

# 可见性

可见性是指当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值,在 Java 中可以使用 synchronizedvolatile 关键字、以及各种 Lock 实现可见性,如果我们将变量声明为 volatile 就代表我们将告诉 JVM 这个变量是共享数据且不稳定的,每次使用它都到主内存中进行读取。

# 有序性

有序性是指对于单线程的执行代码,我们总是认为代码的执行是按照顺序依次执行的,毕竟对于单线程而言确实如此,但对于多线程环境则可能会出现乱序现象,因为程序编译成机器指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白在 Java 程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句是代表单线程内保证串行语义执行的一致性,后半句代表指令重排现象和工作内存与主内存同步延迟现象。

# happens-before

# happens-before 什么是

关于 happens-before 概念最早诞生于 1978 年由 Leslie Lamport 发表的《Time,Clocks and the Ordering of Events in a Distributed System》论文中,在这篇论文中 Leslie Lamport 提出了逻辑时钟的概念,这也成了第一个逻辑时钟算法。在分布式环境中,通过一系列规则来定义逻辑时钟的变化,从而能通过逻辑时钟来对分布式系统中的事件的先后顺序进行判断,逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系,简单了解一下即可。

JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性,为什么需要 happens-beffore 原则? happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡,程序员追求的是易于理解和编程的强内存模型,遵守规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。

  • happens-before 原则设计思想非常简单:
    • 1️⃣为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果 (单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
    • 2️⃣对于会改变程序执行结果的重排序, JMM 要求编译器和处理器必须禁止这种重排序。
  • 下面的图来自于《Java 并发编程的艺术》中的一张 JMM 设计思想示意图。
    pi7UJNF.png
  • happens-before 原则的定义:
    • 如果一个操作 happens-before 另一个操作 (描述为第一个操作和第二个操作之间满足 happens-before 关系,那么我们就可以说第一个操作对于第二个操作一定是可见的),那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
    • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。
    • 如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一直,那么 JMM 也允许这样的重排序。
    int userNum = getUserNum(); 	// 1
    int teacherNum = getTeacherNum();	 // 2
    int totalNum = userNum + teacherNum;	// 3
    // 1 happens-before 2
    // 2 happens-before 3
    // 1 happens-before 3
    /*
       虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3
     */

happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来讲也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。

# happens-before 和 JMM 的关系

下图来自于《Java 并发编程的艺术》中的一张图就可以非常好的解释清楚。

pi7d8W4.png

# happens-before 原则

在程序开发中,仅靠 synchronizedvolatile 关键字来保证原子性、可见性及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是在 Java 内存模型中,提供了 happens-before 原则来辅助保证程序执行的原子性、可见性和有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据。

  • happens-before 原则分为以下八种:
    • 程序顺序原则 :一个线程内,按照代码顺序,书写在前面的操作, happens-before 于书写在后面的操作,即:在一个线程内保证语义串行性,也就是按照代码顺序执行。
    • 锁规则 :解锁 (unlock) 操作必然发生在后续的同一个锁的加锁 (lock) 之前,就是说如果对于一个锁解锁后再加锁,那么加锁的动作必须在解锁动作之后 (同一个锁)。
    • volatile规则volatile 变量的写,先发生于读,这保证了 volatile 变量的可见性,简单理解就是 volatile 变量在每次被线程访问时,都强迫从主内存中读取该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,在任何时刻不同的线程总是能看到该变量的最新值。
    • 线程启动规则 :线程的 start() 方法先于它的每一个动作,即如果线程 A 在执行线程 Bstart() 方法之前修改了关系变量的值,那么当线程 B 执行 start() 方法时,线程 A 对共享变量的修改对线程 B 可见。
    • 传递性A 优先于 BB 优先于 C ,那么 A 必然先于 C
    • 线程终止规则 :线程的所有操作先于线程的终结, Thread.join() 方法的作用是等待当前执行的线程终止,假设在线程 B 终止之前,修改了共享变量,线程 A 从线程 Bjoin() 方法成功返回后,线程 B 对共享变量的修改将对线程 A 可见。
    • 线程中断规则 :对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测线程是否中断。
    • 对象终结规则 :对象的构造函数执行,结束先于 finalize() 方法
  • 需要注意的是:如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障, JVM 可以对这两个操作进行重排序。
  • 在上述中的八条原则无需手动添加任何同步手段使用关键字 synchronizedvolatile 即可达到效果。

# 关于 volatile 关键字

# volatile 内存语义

  • volatile 关键字在并发编程中很常见,但也容易被滥用,现在我们将来深入了解一下 volatile 关键字的语义, volatileJava 虚拟机提供的轻量级同步机制,主要有如下两个作用:
    • 禁止指令重排序优化。
    • 保证被 volatile 修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被 volatile 关键字所修饰的共享变量的值,新值总是可以被其它线程所看到。
  • 当写一个 volatile 变量时, JMM 会把该线程对应的本地内存中的共享变量值立即刷新回主内存中。
  • 当读一个 volatile 变量时, JMM 会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量。

# volatile 可见性

关于 volatile 的可见性作用,我们必须意识到被 volatile 关键字所修饰的变量对所有线程总是立即可见的,对 volatile 变量的所有写操作总是能立刻反应到其它线程中,但是对于 volatile 变量运算操作在多线程环境下并不保证安全性,代码如下:

VolatileVisibility.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: volition 的可见性代码示例
 * @DateTime: 2023-12-23 16:38
 * @Version:1.0
 **/
public class VolatileVisibility implements Runnable {
    private static volatile int i = 0;
    public static void increase() {
        i++;
    }
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new VolatileVisibility());
        Thread t2 = new Thread(new VolatileVisibility());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("i = " + i);
        /*
        执行结果:i = 1140057
         */
    }
    @Override
    public void run() {
        for (int j = 0; j < 1000000; j++) {
            increase();
        }
    }
}

如上述代码所示,共享变量 i 的任何改变都会立马反应到其它线程中,但是如果此时存在多条线程同时调用 increase() 方法的话,此时就会出现线程安全问题,毕竟 i++ 操作并不是原子性,该操作是先读取值然后写回一个新值,相当于在原来的基础上 +1 分为两步来完成。

如果第二个线程在第一个线程读取旧值和写回新值期间读取 i 的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的 +1 操作,这也就造成了线程安全问题。

因此对于 increase() 方法必须使用 synchronized 来修饰,以确保证线程安全,需要注意的一点是一旦使用 synchronized 修饰方法后,由于 synchronized 本身就具备与 volatile 相同特性 (可见性),因此在这种情况下就可以省去 volatile 关键字了。

VolatileVisibility.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: volition 的可见性代码示例
 * @DateTime: 2023-12-23 16:38
 * @Version:1.0
 **/
public class VolatileVisibility implements Runnable {
  private static int i = 0;
  public synchronized static void increase() {
       i++;
  }
  public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(new VolatileVisibility());
    Thread t2 = new Thread(new VolatileVisibility());
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println("i = " + i);
        /*
        执行结果:i = 2000000
         */
  }
  @Override
  public void run() {
    for (int j = 0; j < 1000000; j++) {
      increase();
    }
  }
}

下方代码使用 volatile 修饰变量达到线程安全目的,代码如下:

package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description:
 * @DateTime: 2023-12-23 20:49
 * @Version:1.0
 **/
public class VolatileSafe {
    
    public static volatile boolean flag = true;
    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (flag) {
                }
                System.out.println(Thread.currentThread().getName() + "线程停止,死循环被打开");
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                flag = false;
                System.out.println(Thread.currentThread().getName() + "修改 flag 为" + flag);
            }
        }).start();
        Thread.sleep(Integer.MAX_VALUE);
        
        /*
         使用 volatile 的执行结果:
         Thread-0 线程停止,死循环被打开
         Thread-1 修改 flag 为 false
      --------------------------------
         不使用 volatile 的执行结果:
         Thread-1 修改 flag 为 false
         */
    }
}

在上述代码中使用 volatile 和不使用 volatile 关键字的结果显然不同,这也就表明了使用 volatile 关键字来修饰变量确实可以让其它线程所看到修改的结果,因此可以通过使用 volatile 关键字来达到线程安全目的。

那么 JMM 是如何实现让 volatile 变量对其它线程立即可见的呢?当写一个 volatile 变量时 JMM 会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个 volatile 变量时, JMM 会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量, volatile 变量正是通过这种 写/读 方式实现对其它线程可见 (但其内存语义实现则是通过内存屏障)。

# volatile 禁止重排

volatile 关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化在前面提到过,这里主要简单说明一下 volatile 是如何实现禁止指令重排优化的。

内存屏障 (Memory Barrier) 又被称为内存栅栏,它是 CPU 指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性 ( volatile 的内存可见性就是利用该特性实现的)。

由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条 Memory Barrier 则会告诉编译器和 CPU ,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。

Memory Barrier 的另一个作用是强制刷出各种 CPU 缓存数据,因此任何 CPU 上的线程都能读取到这些数据的最新版本,总之 volatile 变量正是通过内存屏障实现其在内存中的可见性和禁止重排优化。

DoubleCheckLock.java
package top.rem.rain.synchronized_example;
/**
 * @Author: LightRain
 * @Description: 双重检查锁
 * @DateTime: 2023-12-23 23:29
 * @Version:1.0
 **/
public class DoubleCheckLock {
  private static DoubleCheckLock instance;
  private DoubleCheckLock() {
  }
  public static DoubleCheckLock getInstance() {
    // 第一次检测
    if (instance == null) {
      // 同步
      synchronized (DoubleCheckLock.class) {
        if (instance == null) {
          // 多线程环境下可能会出现问题的地方
          instance = new DoubleCheckLock();
        }
      }
    }
    return instance;
  }
}

上述代码是一个经典的单例双重检查锁的代码,这段代码在单线程环境下并没什么问题,但如果是在多线程情况下就可能出现线程安全问题,原因在于某一个线程执行到第一次检测,读取到 instance 不为 null 时, instance 的引用对象可能没有完成初始化,因为 instance = new DoubleCheckLock() 可以分为以下三步完成 (伪代码)。

memory = allocate(); // 第一步:分配对象内存空间
instance(memory);    // 第二步:初始化对象
instance = memory;   // 第三步:设置 instance 指向刚分配的内存地址,此时 instance != null

由于第二步和第三步之间可能会重排序,重排序后如下:

memory = allocate(); // 第一步:分配对象内存空间
instance = memory;   // 第三步:设置 instance 指向刚分配的内存地址,此时 instance != null
instance(memory);    // 第二步:初始化对象

由于第二步和第三步不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有任何改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义执行的一致性 (单线程),但并不会关心多线程间的语义一致性,所以当一条线程访问 instance 不为 null 时,由于 instance 实例未必初始化完成,也就造成了线程安全问题,此时我们就可以使用 volatile 关键字来禁止 instance 变量被执行指令重排优化。

// 使用 volatile 来禁止指令重排优化
private volatile static DoubleCheckLock instance;

最后我们应该清除知道 JMM 就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案 (happens-before原则) 及其外部可以使用的同步手段: synchronizedvolatile 等,确保了程序执行在多线程中的应有的原子性、可见性和有序性。