cover_image

从volatile关键字到多核CPU的线程可见性

恩佐 缦图coder
2024年01月31日 05:56


图片

点击关注上方蓝字,阅读更多干货~


图片



前言




对于volatile关键字,相信大家一定都不会陌生。它解决了java并发编程三大特性中的可见性和有序性问题。那么你有没有想过volatile关键字为什么能解决线程可见性问题?本文会从缓存架构,内存模型等方面来阐述volatile是如何解决线程可见性问题。





01

什么是可见性


可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。我们通常无法确保执行读操作的线程能适时地看到其他线程写入的值,甚至这是不可能做到的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

可见性是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上就能看到。

在Java中volatilesynchronized都可以实现可见性。


02

无法终止的循环


我们下面来看一个例子。这里有一个静态共享变量initFlag=false,我们将它作为判断条件开启一个线程进行一个死循环,同时我们开启另一个线程去修改initFLag的值,来观察一下当前线程是否会结束。
package nzo;

public class VolatilevisibilityTest {
private static boolean initFlag = false;
public static void prepareData(){ System.out.println("prepareding data..."); initFlag = true; System.out.println("prepared end..."); }
public static void main(String[] args) throws InterruptedException { //线程一 new Thread(new Runnable() { @Override public void run() { System.out.println("waiting data ...."); while (!initFlag){
} System.out.println("===========success"); } }).start();
Thread.sleep(2000); //线程二 new Thread(new Runnable() { @Override public void run() { prepareData(); } }).start();
}
}

结果,主线程并没有打印“success”表示执行结束,代表着主线程还在死循环着,initFlag的值并没有被修改。

图片



03

为什么无法终止?


大家可以先想一想,为什么无法终止呢?

static不是一个共享变量吗?线程二都已经正常输出完“prepare data end”了,按理说initFlag的值应该已经改变了,那么为什么线程一没有正常结束呢?难道是因为线程一没有感知到initFlag值的改变?那其他线程中的initFlag的值已经改变了吗?

为了解决我们的疑惑,对代码进行了一些改造。我们在主线程最后输出了initFlag的值,为了保证输出之前,确保线程二已经执行完毕,我们在线程二启动后让主线程睡了1s。
package com.nzo;
public class VolatilevisibilityTest {
private static boolean initFlag = false;
public static void prepareData(){ System.out.println("prepareding data..."); initFlag = true; System.out.println("prepared end..."); }
public static void main(String[] args) throws InterruptedException { //线程一 new Thread(new Runnable() { @Override public void run() { System.out.println("waiting data ...."); while (!initFlag){
} System.out.println("===========success"); } }).start();
Thread.sleep(2000); //线程二 new Thread(new Runnable() { @Override public void run() { prepareData(); } }).start(); //睡一秒,保证线程二已经完成了initFlag值的修改 Thread.sleep(1000); System.out.println(initFlag); }
}
下面我们来看输出结果:

图片


不出所料,主线程中的initFlag的值也已经改变了。


当前代码有三条线程,线程一,线程二,和主线程。线程二和主线程的initFLag的值都改变了,那么只有可能是线程一没有感知到initFlag值的改变。


04

volatile变量的可见性和JMM内存模型


在确保了是线程一没有感知到其他线程对变量发生了修改行为,导致循环没有结束后,我想先从java多线程内存模型的角度来分析一下这件事儿。

Java虚拟机规范中定义了一种Java内存模型(Java Memory Model,即JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现Java程序在各种平台下都能达到一致的并发效果。Java内存模型的主要目标就是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的细节

JMM中规定所有的变量都存储在主内存(Main Memory)中,每条线程都有自己的工作内存(Work Memory),线程的工作内存中保存了该线程所使用的从主内存中拷贝的副本。线程对于变量的读、写都必须在工作内存中进行,而不能直接读、写主内存中的变量。同时,本线程的工作内存的变量也无法被其他线程直接访问,必须通过主内存完成。

配合着下面这张JMM内存模型的图,我们就能更好理解上面的代码所发生的事。线程一和线程二都拷贝了一个initFlag共享变量的值的副本,到自己的工作内存中去,两条线程操作的都是自己工作内存中的共享副本变量。修改了initFlag值的线程二没有将变化及时写回主内存,线程一不知道当前工作缓存中的initFlag已经是个脏数据,也感知不到线程二已经将initFlag的值改变,从而没有停止循环。

图片
对于上述现象,我们很自然的想到了通过加synchronized的Java内置锁来解决这个问题。

通过在while循环外添加synchronized(this)同步块确实能解决这种问题,但是在这种只需要保证一个共享变量可见的情况下采用synchronized锁来保证同步代价太大。此时我们应该采用Java所提供的volatile关键字来保证变量的可见性,通过在initFlag前加上volatile关键字来使用即可。
private static volatile boolean intFlag = false;

以下是运行的结果:

图片

程序正常结束了,线程一成功的感知到了线程二对flag变量的改变。

有些读者看到这里可能会有个疑问,为什么只是单单加了一个volitale关键字就解决了线程的可见性问题?系统内部到底发生了什么事情?下面将会慢慢揭开它的面纱。


05

volatile如何解决可见性问题


那么volatile关键字是如何保证多线程下共享变量线程间可见的呢?

首先我们来了解一下JMM中的数据原子操作:

  • read(读取):从主内存读取数据
  • load(载入):将主内存读取到的数据写入工作内存
  • use(使用):从工作内存读取数据来计算
  • assign(赋值):将计算好的值从新赋值到工作内存中
  • store(存储):将工作内存数据写入到主内存
  • write(写入):将store过去的变量值赋值给主内存中的变量
  • lock(锁定):将主内存变量加锁,标识为线程独占状态
  • unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量

JVM通过以上原子操作来处理主内存和工作内存中的数据交互。

我们跟着下面这张图再来走一遍上面的代码(ps:内容中的CPU缓存,总线嗅探,缓存一致性等关键词我会在下面的章节进行介绍)

图片
  1. 线程一将initFlag=false从主内存中read出来并load到工作内存(CPU缓存)中;
  2. 线程一CPU使用(use)工作内存中的initFlag的值进行死循环操作;

  3. 线程二readinitFlag的值并load进工作内存,CPU拿到工作内存中的值进行assign赋值操作,将initFlag的值从false改成true并写回工作内存;

  4. 线程二将工作内存中的initFlag的新值store回主内存,并write赋值给主内存中的initFlag变量;

  5. 由于initFlag是加了volitale修饰的,汇编语言的层面上,用volatile关键字修饰后的变量在操作时,最终解析的汇编指令会在指令前加上lock前缀指令;
  6. lock指令它会锁定这块内存区域的缓存,并立即写回主内存,因为CPU是遵循缓存一致性协议的。这个写回内存的操作会引起其他CPU通过总线嗅探机制使得自己工作内存里的数据失效,CPU获取不到工作内存中的数据时,那么就只能重新回主内存中拿数据;

  7. 至此完美解决了多线程的可见性问题。



06

CPU缓存模型


大家读到这里可能会想,既然多核CPU不同线程间的操作会有很多的安全问题,那么为什么不化繁为简,大家都直接作主内存不就完可以了吗?为什么还要添加一个看起来增加了很多麻烦事的CPU缓存呢?

要解答这个问题就得先从CPU的发展开始说起。想必大家都知道计算机界的摩尔定律,摩尔定律是英特尔创始人之一戈登·摩尔的经验之谈,其核心内容为:当价格不变时,集成电路上可以容纳的晶体管数目在每经过18个月左右便会增加一倍,性能也会提升一倍。

但是相比于CPU处理效率的提升,内存处理速度的提升就相对捉襟见肘了,也就是说CPU和内存之间的处理速度是有鸿沟的,所以随着计算机的逐渐发展,在CPU和主内存之间增加了CPU的多级高速缓存。这个高速缓存相比于内存来说速度要快的多,所以CPU在处理数据的时候,会将数据读到主内存,再读到高速缓存,这样就会很大程度上解决了CPU和内存的处理速度差。

那么什么是CPU的多级缓存,如果读者有windows电脑,那么打开任务管理器就能找到它的身影。

图片

有些朋友可能在想,既然cpu高速缓存这么快,为啥做的这么小?

这主要是因为:CPU的缓存得能配合上CPU高频,所以结构比较复杂。如果L1 的Cache越大, 查找命中的时间就会越长,体积也会变大,相应的功耗也会变大。因此一块芯片顶上的缓存(一级缓存)通常比较小。

简单来说,CPU与硬盘交换数据主要是通过:外部存储设备->主内存->CPU多级缓存->CPU。

以x86架构intel的CPU为例。多核CPU,每一核心CPU的L1和L2的Cache为核心独占,L3的Cache为多核心共享,越靠近CPU计算单元的上级缓存,速度越快,延时越低。

图片

说了这么多,其实Java多线程内存模型和CPU缓存模型很类似,它是基于CPU缓存模型建立起来的


JMM内存模型里说的工作内存,其实指的就是CPU核心独占的Cache。现在的CPU基本都是多核CPU,服务器更是提供了多核CPU的支持,而每个核也都有自己独立的缓存。当多个核同时操作多个线程对同一个数据进行更新时,如果核2在核1还未将更新的数据刷回内存之前读取了数据,并进行操作,就会造成程序的执行结果产生随机性的影响,这对于我们来说是无法容忍的。所以CPU厂商基于此就定下了一个大家共同遵从的一组约定——缓存一致性协议。


07

总线嗅探


在介绍缓存一致性协议之前,首先得来谈谈什么是总线嗅探。总线(Bus)是计算机各种功能部件之间传送信息的公共通信干线,它是由导线组成的传输线束。按照计算机所传输的信息种类,计算机的总线可以划分为数据总线、地址总线和控制总线,分别用来传输数据、数据地址和控制信号。

当某个CPU核心更新了CPU高速缓存中的数据,要把该事件通过总线广播通知到其他核心。CPU需要每时每刻监听总线上的一切活动,不管别的核心的 Cache是否缓存相同的数据,都需要发出一个广播事件到总线从而使其他核心的CPU感知。有了总线的存在,多核CPU就可以通过它进行交互和状态感知。

不难看出,总线嗅探只保证了某个核心中Cache的数据更新可以被其他核心感知到,但并没有办法保证事务的串行化。于是为了实现事务的串行化,降低总线的带宽压力,大佬们基于状态机和总线嗅探实现了MESI协议不同厂商的CPU会有不同的协议去实现缓存一致性,这里默认为Intel64处理器和IA-32处理器)。


08

缓存一致性协议


缓存一致性协议并不是无条件生效的,站在Java语言层面上是需要volatile关键字去触发CPU层面上的缓存一致性协议。

首先简单来介绍下缓存一致性协议:
  • 多核CPU的情况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱呢?这里就引出了一个一致性的协议MESI。

  • MESI(在汇编层)的主要思想:当CPU写数据时,如果该变量是共享数据,给其他CPU发送信号,就会使得其他的CPU中的该变量的缓存行无效。
  • MESI 是指4种状态的首字母。每个Cache line有4个状态,它们分别是:

状态
描述
监听任务

M 修改 
(Modified)
该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。
缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥
 (Exclusive)

该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。
缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 
(Shared)
该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。
缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 
(Invalid)
该Cache line无效。

图片
根据上面的例子,我们可以知道:
  • 当线程一读取到initFlag值的时候,数据被缓存在1号CPU核心自己的Cache 里面,此时其他CPU核心的Cache没有缓存该数据,于是标记Cache Line 状态为「Exclusive」;


  • 当线程二读取到initFlag值的时候,此时会发送消息给其他CPU核心,由于 1号CPU核心已经缓存了该数据,所以会把数据返回给2号CPU核心。在这个时候,1号和2号核心缓存了相同的数据,Cache Line的状态就会变成「Shared」,并且其Cache中的数据与内存也是一致的;


  • 当2 号CPU核心要修改initFLag的值的时候,发现当前Cache Line的的状态是「Shared」,则要向所有的其他CPU核心广播一个请求到总线,要求先把其他核心的Cache中对应的Cache Line标记为「Invalid」状态,然后2号CPU核心再更新Cache里面的数据,同时标记Cache Line为「Modified」状态,此时Cache中的数据就与内存不一致了。之后2号CPU就会将新的值从Cache中写回主内存;


  • 当1号CPU核心通过总线将对应的Cache Line中的initFlag的值标记为「Invalid」状态后,就会主动从主内存中重新拉取initFlag的值,从而确保自己工作内存中的值是最新的。



09

volatile的适用场景


最后再给大家分享一些比较常用的volatile的使用场景。

1.状态标志
还是本文举的这个例子,volatile变量修饰一个布尔状态标志,用于标识发生了一个重要的一次性事件。这种类型的状态标记的一个公共特性是:通常只有一种状态转换,shutdowFLag标志从false转换为true,然后程序停止。
volatile boolean shutdownFlag; ...shutdown();... public void shutdown() {     shutdownFLag = true; } public void doWork() {     while (!shutdownFlag) {         // do stuff    }}

2.双重检查锁(DCL)

为了保证线程的安全性,往往要以牺牲性能为代价。为了兼得二者,前人进行了多番尝试,也确实创造出诸多有效方案,双重检查锁就是其中的一种。

下面看一个例子:
public class Singleton {    private static Singleton uniqueSingleton;
private Singleton() { }
public Singleton getInstance() { if (null == uniqueSingleton) { synchronized (Singleton.class) { if (null == uniqueSingleton) { uniqueSingleton = new Singleton(); //error } } } return uniqueSingleton; }}

果对多线程有一定了解的同学第一次看到上述这段代码一定会觉得很好。首先通过一次非同步的检查过滤掉绝大多数的线程,随后在同步块中对少数通过第一次检查的线程进行第二次检查。因为是同步操作,所以最终初始化操作只会进行一次。即满足了线程安全的要求,又达到了高并发的目的,还兼具延迟初始化的特点。


但实际上,这段完美的代码却深藏陷阱,它可能会引起一系列的异常。

实例化对象的那行代码(标记为error的那行),实际上可以分解成以下三个步骤:
  1. 分配内存空间
  2. 初始化对象
  3. 将对象指向刚分配的内存空间


但是有些编译器为了性能的原因,可能会将第二步和第三步进行重排序,顺序就变成了:
  1. 分配内存空间

  2. 将对象指向刚分配的内存空间

  3. 初始化对象

现在考虑重排序后,两个线程发生了以下调用:
Time
Thread A
Thread B
T1
检查到uniqueSingleton为空

T2
获取锁

T3
再次检查到uniqueSingleton为空

T4
uniqueSingleton分配内存空间

T5
uniqueSingleton分配内存空间

T6

检查到uniqueSingleton不为空
T7

访问uniqueSingleton(此时对象还未完成初始化)
T8
初始化uniqueSingleton

在这种情况下,T7时刻线程B对uniqueSingleton的访问,访问的是一个初始化未完成的对象

而加上了volatile关键字后,重排序将被禁止。至此,双重检查锁就可以完美工作了。(关于volatile禁止指令重排的底层原理之后会继续跟大家分享)


3.开销较低的“读-写锁”策略
如果读操作远远超过写操作,你可以结合使用内部锁和volatile变量来减少公共代码路径的开销。

如下显示的线程安全的计数器,使用synchronized确保增量操作是原子的,并使用volatile保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及volatile读操作,这通常要优于一个无竞争的锁获取的开销。

public class CheesyCounter {     //读操作,没有synchronized,提高性能    public int getValue() {         return value;     }      //写操作,必须synchronized。因为x++不是原子操作    public synchronized int increment() {        return value++;    }


总结




为什么会有上述如此复杂问题?为什么会有并发编程?为什么会产生可见性,有序性等并发安全问题?


归根结底,还是计算机硬件高速发展的原因。CPU架构的发展,多核CPU的产生以及CPU缓存的适配是导致并发问题产生的原因之一。以上就是volatile关键字到多核CPU的线程可见性的介绍,希望读完对你有所收获!





本文作者

恩佐,来自缦图互联网中心后端团队。


图片

长按识别二维码

查看更多精彩

图片


-----END-----


继续滑动看下一个
缦图coder
向上滑动看下一个