暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

volatile关键字的介绍

Alleria Windrunner 2020-05-14
163
正如我们前面所说的,volatile 是一个非常重要的关键字,虽然看起来很简单,但是想要彻底弄清楚 volatile 的来龙去脉还是需要具备 Java 内存模型、CPU 缓存模型等知识的,在本篇中,我们将首先介绍一个最能说明 volatile 特征的例子,然后对 Java 内存模型、CPU 缓存模型等知识进行展开讲解,这样对理解 volatile 关键字是非常有帮助的。

初识volatile关键字
下面所示的这段程序分别启动了两个线程,一个线程负责对变量进行修改,一个线程负责对变量进行输出,根据之前的知识讲解,该变量就是共享资源(数据),那么在多线程操作的情况下,很有可能会引起数据不一致等线程安全的问题。
    import java.util.concurrent.TimeUnit;


    public class VolatileFoo
    {
    //init_value 的最大值
    final static int MAX = 5;
    //init_value 的初始值
    static int init_value = 0;


    public static void main(String[] args)
    {
    //启动一个 Reader 线程,当发现 local_value和init_value 不同时,则输出 init_value 被修改的信息
    new Thread(() ->
    {
    int localValue = init_value;
    while (localValue < MAX)
    {
    if (init_value != localValue)
    {
    System.out.printf("The init_value is updated to [%d]\n", init_value);
    //对 localValue 进行重新赋值
    localValue = init_value;
    }
    }
    }, "Reader").start();


    //启动 Updater 线程,主要用于对 init_value 的修改,当 local_value>=5 的时候则退出生命周期
    new Thread(() ->
    {
    int localValue = init_value;
    while (localValue < MAX)
    {
    //修改init_value
    System.out.printf("The init_value will be changed to [%d]\n", ++localValue);
    init_value = localValue;
    try
    {
    //短暂休眠,目的是为了使 Reader 线程能够来得及输出变化内容
    TimeUnit.SECONDS.sleep(2);
    } catch (InterruptedException e)
    {
    e.printStackTrace();
    }
    }
    }, "Updater").start();
    }
    }
    在运行上面的程序之前,想象一下程序的输出将会是怎样的呢?Updater 线程的每一次修改都会使得 Reader 线程进行一次输出?如果你是这样认为的,那么事实会让你大跌眼镜,输出如下:
      The init_value will be changed to [1]
      The init_value will be changed to [2]
      The init_value will be changed to [3]
      The init_value will be changed to [4]
      The init_value will be changed to [5]
      通过控制台的输出信息我们不难发现,Reader 线程压根就没有感知到 init_value 的变化而进入了死循环,这是为什么呢?我们将 init_value 的定义做一次小小的调整,代码如下:
        static volatile int init_value = 0;
        这里为 init_value 变量增加 volatile 关键字的修饰。再次运行修改后的程序,你会发现 Reader 线程会感知到 init_value 变量的变化,并且在条件不满足时退出运行,输出如下:
          The init_value will be changed to [1]
          The init_value is updated to [1]
          The init_value will be changed to [2]
          The init_value is updated to [2]
          The init_value will be changed to [3]
          The init_value is updated to [3]
          The init_value will be changed to [4]
          The init_value is updated to [4]
          The init_value will be changed to [5]
          The init_value is updated to [5]
          为什么会出现这样的情况呢,其实这一切都是 volatile 关键字所起的作用。
          注意volatile 关键字只能修饰类变量和实例变量,对于方法参数、局部变量以及实例常量,类常量都不能进行修饰,比如上面代码中的 MAX 就不能使用 volatile 关键字进行修饰。

          机器硬件CPU
          在计算机中,所有的运算操作都是由 CPU 的寄存器来完成的,CPU 指令的执行过程需要涉及数据的读取和写入操作,CPU 所能访问的所有数据只能是计算机的主存(通常是指 RAM),虽然 CPU 的发展频率不断地得到提升,但受制于制造工艺以及成本等的限制,计算机的内存反倒在访问速度上并没有多大的突破,因此 CPU 的处理速度和内存的访问速度之间的差距越拉越大,通常这种差距可以达到上千倍,极端情况下甚至会在上万倍以上。

          CPU Cache模型
          由于两边速度严重的不对等,通过传统 FSB 直连内存的访问方式很明显会导致 CPU 资源受到大量的限制,降低 CPU 整体的吞吐量,于是就有了在 CPU 和主内存之间增加缓存的设计,现在缓存的数量都可以增加到3级了,最靠近 CPU 的缓存称为 L1,然后依次是 L2,L3 和主内存,CPU 缓存模型如下图所示。

          由于程序指令和程序数据的行为和热点分布差异很大,因此 L1 Cache 又被划分成了 L1i(i 是 instruction 的首字母)和 L1d(d 是 data 的首字母)这两种有各自专门用途的缓存,CPU Cache 又是由很多个 Cache Line 构成的,Cache Line 可以认为是 CPU Cache 中的最小缓存单位,目前主流 CPU Cache 的 Cache Line 大小都是64字节,下图是一张主存以及各级缓存之间的响应时间对比图。

          通过上图,我们可以发现主内存的读写速度远远落后于 CPU Cache 的速度,更别说 CPU 本身的计算速度了。
          Cache 的出现是为了解决 CPU 直接访问内存效率低下问题的,程序在运行的过程中,会将运算所需要的数据从主存复制一份到 CPU Cache 中,这样 CPU 进行计算时就可以直接对 CPU Cache 中的数据进行读取和写入,当运算结束之后,再将 CPU Cache 中的最新数据刷新到主内存当中,CPU 通过直接访问 Cache 的方式替代直接访问主存的方式极大地提高了 CPU 的吞吐能力,有了 CPU Cache 之后,整体的 CPU 和主内存之间交互的架构大致如图所示。



          CPU缓存一致性问题

          由于缓存的出现,极大地提高了 CPU 的吞吐能力,但是同时也引入了缓存不一致的问题,比如 i++ 这个操作,在程序的运行过程中,首先需要将主内存中的数据复制一份存放到 CPU Cache 中,那么 CPU 寄存器在进行数值计算的时候就直接到 Cache 中读取和写入,当整个过程运算结束之后再将 Cache 中的数据刷新到主存当中,具体过程如下。

          • 读取主内存的 i 到 CPU Cache 中。
          • 对i进行加1操作。
          • 将结果写回到 CPU Cache 中。
          • 将数据刷新到主内存中。

          i++ 在单线程的情况下不会出现任何问题,但是在多线程的情况下就会有问题,每个线程都有自己的工作内存(本地内存,对应于 CPU 中的 Cache),变量 i 会在多个线程的本地内存中都存在一个副本。如果同时有两个线程执行 i++ 操作,假设 i 的初始值为0,每一个线程都从主内存中获取 i 的值存入 CPU Cache 中,然后经过计算再写入主内存中,很有可能 i 在经过了两次自增之后结果还是1,这就是典型的缓存不一致性问题。

          为了解决缓存不一致性问题,通常主流的解决方法有如下两种。
          • 通过总线加锁的方式。

          • 通过缓存一致性协议。


          第一种方式常见于早期的 CPU 当中,而且是一种悲观的实现方式,CPU 和其他组件的通信都是通过总线(数据总线、控制总线、地址总线)来进行的,如果采用总线加锁的方式,则会阻塞其他 CPU 对其他组件的访问,从而使得只有一个 CPU(抢到总线锁)能够访问这个变量的内存。这种方式效率低下,所以就有了第二种通过缓存一致性协议的方式来解决不一致的问题。

          在缓存一致性协议中最为出名的是 Intel 的 MESI 协议,MESI 协议保证了每一个缓存中使用的共享变量副本都是一致的,它的大致思想是,当 CPU 在操作 Cache 中的数据时,如果发现该变量是一个共享变量,也就是说在其他的 CPU Cache 中也存在一个副本,那么进行如下操作:
          • 读取操作,不做任何处理,只是将 Cache 中的数据读取到寄存器。

          • 写入操作,发出信号通知其他 CPU 将该变量的 Cache line 置为无效状态,其他 CPU 在进行该变量读取的时候不得不到主内存中再次获取。


          Java内存模型
          Java 的内存模型(Java Memory Mode,JMM)指定了 Java 虚拟机如何与计算机的主存(RAM)进行工作,如下图所示,理解 Java 内存模型对于编写行为正确的并发程序是非常重要的。在 JDK1.5 以前的版本中,Java 内存模型存在着一定的缺陷,在 JDK1.5 的时候,JDK 官方对 Java 内存模型重新进行了修订,JDK1.8 及最新的 JDK 版本都沿用了 JDK1.5 修订的内存模型。
          Java 的内存模型决定了一个线程对共享变量的写入何时对其他线程可见,Java 内存模型定义了线程和主内存之间的抽象关系,具体如下。
          • 共享变量存储于主内存之中,每个线程都可以访问。

          • 每个线程都有私有的工作内存或者称为本地内存。

          • 工作内存只存储该线程对共享变量的副本。

          • 线程不能直接操作主内存,只有先操作了工作内存之后才能写入主内存。

          • 工作内存和 Java 内存模型一样也是一个抽象的概念,它其实并不存在,它涵盖了缓存、寄存器、编译器优化以及硬件等。


          假设主内存的共享变量为0,线程1和线程2分别拥有共享变量 X 的副本,假设线程1此时将工作内存中的 x 修改为1,同时刷新到主内存中,当线程2想要去使用副本 x 的时候,就会发现该变量已经失效了,必须到主内存中再次获取然后存入自己的工作内容中,这一点和 CPU 与 CPU Cache 之间的关系非常类似。

          Java 的内存模型是一个抽象的概念,其与计算机硬件的结构并不完全一样,比如计算机物理内存不会存在栈内存和堆内存的划分,无论是堆内存还是虚拟机栈内存都会对应到物理的主内存,当然也有一部分堆栈内存的数据有可能会存入 CPU Cache 寄存器中。下图所示的是 Jave 内存模型与 CPU 硬件架构的交互图。

          当同一个数据被分别存储到了计算机的各个内存区域时,势必会导致多个线程在各自的工作区域中看到的数据有可能是不一样的,在 Java 语言中如何保证不同线程对某个共享变量的可见性?

          文章转载自Alleria Windrunner,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

          评论