Volatile是如何保证线程可见的?详解 volatile 主内存 工作内存 jmm 缓存一致性 总线

Volatile是如何保证线程可见的?
摘要
本文以volatile最常见的2个问题为线索,基于汇编lock前缀指令和cpu原子操作等底层知识,初步对volatile的底层原理进行描述。通过本文的论述,基本上能够解释以下2个问题:1)为什么volatile能够保证线程可见性?2)为什么volatile不能实现操作原子性?

研究思路
首先分析jmm内存模型,结合cpu的8个原子操作
然后介绍CPU的缓存一致性协议
最后结合jmm和缓存一致性协议,解释为什么volatile可以保证线程可见
基本概念
首先对后文需要使用的概念做一个描述,主要概念如下

什么是主内存?
主内存是所有的线程所共享的,主要包括本地方法区和堆
所有的变量都存储在主内存中(虚拟机内存的一部分),对于所有线程都是共享的
每条线程都有自己的工作内存,工作内存中保存的是主存中某些变量的拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
线程之间无法直接访问对方的工作内存中的变量,线程间变量的传递均需要通过主内存来完成
什么是线程工作内存?
CPU不能直接操作主内存的数据,而只能操作数据的拷贝副本,这个副本就存在工作内存中,这样设计的目的是为了提高计算效率,工作内存的效率是主内存的10倍-100倍
工作内存是一个统称,包括L1~L3级缓存以及寄存器。
什么是缓存行?
CPU将变量从主内存加载到工作内存,基本的单位是一个缓存行单元,缓存行的大小是固定的,而不是需要一个int就加载4个字节。
每个缓存行有3部分内容,分别是状态、地址和数据。
缓存行状态包括M、E、S、I,分别是修改、独占、共享和失效
地址是这个缓存行映射的主内存的地址
数据是实际存储的数据值
什么是总线?
CPU要访问主内存中的数据,这部分数据需要通过物理介质进行传输,这个介质就是总线,这是实际存在的物理线路。另外,还有一点有意思的是,CPU对总线上传输的数据有监听能力,一旦有数据经过总线,CPU都能够感知,这是后文中提到的MESI缓存一致性协议的硬件基础。
后面还需要的概念将在文章中去阐述。

JMM内存模型
JMM内存模型如下图所示
在这里插入图片描述

关于内存模型,最主要的是明确一点,变量存储在主内存中,每个线程操作数据需要拷贝一个副本到工作内存,操作完成之后,再写回到主内存。

CPU原子操作
在java内存模型中,线程对数据的操作都是由8个原子操作组成的,分别如下:

read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。比如++操作
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状 。
unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
个人对上面的8个原子操作有一个小的疑惑,为什么把变量的值从工作内存写到主内存不直接write,而需要先store,再write呢?

内存模型结合原子操作的示意图如下:
在这里插入图片描述

缓存一致性协议
缓存一致性协议是硬件层面的协议,缓存一致性协议有多种,最常见的是MESI一致性协议,这是基于CPU总线嗅探机制实现的,大概的内容如下:

所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线
缓存本身是独立的,但是内存是共享资源,所有的主内存访问都要经过仲裁(同一个指令周期中,只有一个CPU缓存可以读写内存)
CPU通过嗅探总线上的数据来更新自己的缓存行状态。当一个处理器执行store操作时,数据到达总线的时候,其它处理器都会得到通知,并将缓存行的状态置为失效。
为什么Volatile能够保证线程可见?
现在,基于CPU原子操作、总线嗅探机制、MESI缓存一致性协议以及一点点汇编指令,来解释为什么volatile能够保证数据一致性。以2个线程共享一个flag为例,flag=false,其中一个线程将flag设置为true,另一个立刻能够感知。过程分析如下(1.0版):

首先,线程1和线程2分别将flag加载到各自的缓存
然后线程1在缓存中将flag设置为true,并且通过store和write将数据回写到主内存
当store时,数据经过总线,线程2感知到,立刻将flag的缓存行状态设置为失效,然后如果继续使用flag,就会重新去读取flag的值,从而保证得到了最新的数据,保证了数据一致性。
上面的过程总体没有问题,但是存在2个漏洞:

因为store和write是2个操作,要是不做特殊处理,则很可能在线程1store之后,在write之前,线程2重新读取了主内存中的flag的值,此时还是旧的值,无法保证数据一致。
当线程1执行完assign,对flag的值进行修改之后,如果不立刻回写,而是去做其他的操作,也无法保证数据一致性。
此时,volatile的另外作用就体现出来了。当对一个变量加volatile关键字修饰时,在代码中对变量进行赋值的位置,对应的汇编码会多一个lock前缀指令,示例如下:

// java 代码
public static volatile flag=true
//汇编实现
//rsp寄存器,这行代码就是assign原子操作,对寄存器中的变量赋值。并且对这一个缓存行进行加锁。
lock add dword ptr [rsp],0h  ;*putstatic flag 

资料显示,lock指令有以下几个作用:

如果处理器缓存数据被修改,则立即将当前处理器缓存行的数据写回系统内存
开启总线嗅探机制和激活缓存一致性协议,当一个线程完成回写,其他线程的缓存行状态改为失效
在执行store操作之前,对主内存中缓存行对应的内存区域加锁,执行lock原子操作,并在完成write之后,再执行unlock原子操作,对内存行解锁。
相当于一个内存屏障,确保指令重排序时不会把其后面的指令重排到内存屏障之前的位置,也不会把前面的指令排到内存屏障后面,即在执行到内存屏障这句指令时,前面的操作已经全部完成。
因此,volatile底层汇编实现的核心就是lock指令,在lock指令的加持下,完整的可见性过程修正如下(2.0版):

首先,线程1和线程2分别将flag加载到各自的缓存
然后线程1在缓存中将flag设置为true,并且通过store和write将数据回写到主内存。在执行store之前,cpu会对主内存的缓存行执行一次lock原子操作,完成write之后,cpu再执行unlock操作。
当store时,数据经过总线,线程2感知到,立刻将flag的缓存行状态设置为失效,然后如果继续使用flag,就会重新去读取flag的值,可能此时缓存行会处于加锁的状态,那么这些线程就会等待解锁,然后再读取数据,这样一来,就保证得到了最新的数据,保证了数据一致性。
简而言之,volatile关键字会触发lock前缀指令,进而触发缓存一致性协议以及想配套的锁,从而保障了线程可见。

原文链接:https://blog.csdn.net/weixin_42962086/article/details/106440424

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值