volatile基础理解与底层原理

什么是volatile(基础内容)

在java中,volatle是一个关键字,用于声明变量。需要知道的是volatile的作用是禁止指令重排序与保证数据在并发下可见,这样可以保证可见性 和一定程度的有序性 (为什么是一定程度后面会介绍)。valatile无法保证原子性!!!

ps:volatile关键字并非是Java特有的,在C里面也有,它最初的意义在于禁止使用CPU缓存,每次读写volatile变量时都需要从主存中读取。

关于volatile的可见性解读

可见性:当线程A修改了共享变量,线程B对于这个共享变量值变化是实时可见的(大白话就是当线程A修改共享变量,线程B可以立即看到修改后的值)

如果一个变量被声明为valatile,这就指示了JVM,这个变量是共享且在多个线程下不稳定的,每次操作这个变量时都需要到主存中进行读写,这里就保证了并发下的可见性。

本人当时看到这里就有点疑问了,为什么不加volatile前其他线程对共享变量的读取是不可见的,难道共享变量不都是在主存中吗?带着这个疑问我们继续讲讲JMM(Java内存模型)。

浅谈JMM

JMM全称为Java Memory Model(Java内存模型)是一种虚拟机规范,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。 (这段CV的,大概意思就是在不同硬件生产商或者操作系统下,对于内存的访问方式可能会不一样,如果没有JMM抽象成统一的话,java程序就无法在不同平台上以统一的方式访问主存,就不符合多平台性了这是哥们自己的理解,可能有点偏差)。听起来抽象的话,下面有哥们画的图(画得有点抽象)。

每个线程的工作内存(这个也是JMM抽象出来的,不是真的有哈,其实覆盖了每个cpu的寄存器,缓存器等等)都是独立的,线程一般操作数据都在工作内存(也存储共享变量的副本)中,然后刷新数据到主存中。这就是JMM定义的线程基本的工作方式。注意:JMM只是一种规范(抽象线程和主存的关系),并不是JVM有组件去和主存统一通信(后面内容也有CPU内存模型的对比以及工作内存和主存的交互协议)。

继续解读volatile的可见性

上面我们浅浅的了解了一下JMM和每个线程的工作内存和模式。就可以发现多线程下共享变量的并发风险。下面是我举的一个例子。

存在共享变量A,线程1,线程2去对A进行操作

java 复制代码
A a;//a为共享变量
//1.这里线程1是对自己工作内存中的A副本进行操作
thread1.op(a);
//2.修改后的A正在刷入主存中,还没有刷新线程2的A副本
//3.线程2读取自己工作内存的A副本
thread2.read(a);
//4.线程2的副本刷新完毕
//5.线程2再读一次A副本
thread2.read(a);
//6.寄!两次读的不一样

后面我们看看使用volatile修饰后的变量,线程是如何操作该变量的。

前面有提到经过volatile修饰后的变量会被JVM视为在多线程环境下是不稳定的,每次操作都得到主存进行,不需要在本线程的工作内存中进行,所以对于共享变量的操作对所有线程都是可见的,这就是volatile可以保证可见性。下面这张图就是volatile变量的操作示意图。

我们再看看上面的例子 存在经过volatile修饰的共享变量A,线程1,线程2去对A进行操作

java 复制代码
volatile A a;//a为共享变量
//1.这里线程1直接对主存的A进行操作
thread1.op(a);
//2.修改后的A正在刷入主存中
//3.线程2读取主存的A
thread2.read(a);
//5.线程2再读主存的A
thread2.read(a);
//6.爽了,学计算机的又爽了!两次读的一样

关于volatile禁止指令重排序解读

什么是指令重排序

站在使用高级语言进行开发的开发者的角度,我们都会希望编写的程序实际运行顺序和写代码的顺序与逻辑一致。但是事与愿违。站在开发编译器的开发者的角度,他们会希望自己编写的编译器性能要够好,编译速度要尽可能的快,所以他们会在保证编译正确的情况下尽可能的优化编译器将语句的操作简化。(这只是在编译器层面,cpu层面也会进行对于指令的重排序) 那什么是指令重排序呢?前面提到了编译器为了提高效率,在不改变结果的前提下会将操作进行简化,这样有些语句执行顺序可能会和编写顺序不一致,在单线程低处理量的环境下可能不会出现问题,但是在多线程高并发环境下会出现问题。可以看下面的例子。

java 复制代码
a = 10;
b = 1;
a = a + 1;

通常我们会以为上面的执行顺序会是 a=10 -> b=1 -> a=a+1; 但是在编译的时候并不是,首先是a = 10,执行这个操作的时候会将a从主存中读出,设置a的值设置为10,将a存储回去。所以说一条赋值语句可以分成3个指令,所以将以上代码可以分成下面指令放入

java 复制代码
//1.a = 10;
load a;
set to 10;
store a;
//2.b = 1;
load b;
set to 1;
store b;
//3.a = a + 1;
load a;
set to 11;
store a;

我们可以看到例子a语句与b语句没有依赖关系且a语句重复的将数据从主存中读取,存入等操作(例如load a,store a),存在可以优化的空间

java 复制代码
//1.a = 10; a = a + 1 
load a;
set to 10;
set to 11;
store a;
//2.b = 1;
load b;
set to 1;
store b;

可以发现结果并没有改变,但是执行顺序改变了。这就是指令的重排序带来的优化

指令重排序的必须遵循as-if-serial原则,也就是在单线程环境下无论如何重排序都不能够改变程序的执行结果,也就是上面a重排与未重排的结果是一样的

我们看看两种重排序的情况

(1)编译器优化

编译器(包括 JVM、JIT 编译器等)出于优化的目的,例如当前有了数据 a,把对 a 的操作放到一起效率会更高,避免读取 b 后又返回来重新读取 a 的时间开销,此时在编译的过程中会进行一定程度的重排。不过重排序并不意味着可以任意排序,它需要需要保证重排序后,不改变单线程内的语义,否则如果能任意排序的话,程序早就逻辑混乱了。(可以参考上面的例子)

(2)CPU 重排序

CPU 同样会有优化行为,这里的优化和编译器优化类似,都是通过乱序执行的技术来提高整体的执行效率。 所以即使之前编译器不发生重排,CPU 也可能进行重排,我们在开发中,一定要考虑到重排序带来的后果。

解读volatile禁止指令重排

volatile可以禁止指令的重排序来避免程序在多线程下出现乱序。底层原理就是使用了内存屏障(Memory Barrier)。

简单了解一下内存屏障,他是一个cpu的指令,作用有两个:

1.保证特定操作的执行顺序

2.保证某些变量的内存可见性(其实volatile也是通过该特性实现可见性的)

第一个作用解读:被volatile修饰的变量会在操作时(读写),在指令间插入一条Memory Barrier指令告诉编译器和cpu,不管什么指令都不可以越过Memory Barrier指令,也就是说通过插入内存屏障就可以禁止屏障前后的语句重排。

第二个作用解读:内存屏障会强制的刷出cpu的缓存数据,因此cpu上的所有线程都可以读到该数据的最新版。

4种内存屏障

LoadLoad:

load1;LoadLoad;load2

保证了load1的数据在前会优先于load2的加载
LoadStore:

load1;LoadStore;store2

保证load1加载的数据优先于store2以及后面store指令对于数据的写入
StoreStore:

store1;StoreStore;store2

保证了store1的数据写入要优先于store2以及后面store指令对于数据的写入,那就是马上刷新到主存
StoreLoad(具有其他三种屏障的功能,开销也是最大的):

store1;StoreLoad;load2

保证store1的写入要在load2以及之后的所有读取操作之前

我们接着看看volatile中内存屏障的使用场景:

1.对volatile变量进行写操作:

1⃣️在对volatile变量进行写操作前 ,会施加一条StoreStore屏障 ,禁止之前的写操作 与下面即将进行的volatile写操作进行重排(可以理解为将必须将工作内存中的共享变量刷新回主存中才可以进行写操作)。

2⃣️在对volatile变量进行写操作后 ,会施加一条StoreLoad屏障 ,禁止之后的读操作 与上面的volatile写操作进行重排(可以理解为后续读该变量读必须等该变量写入内存中才能读)。

2.对volatile变量进行读操作:

在对volatile变量进行写操作后同时会施加Loadstore屏障和loadLoad屏障,禁止后续的读写操作与前面进行的volatile读操作进行重排(可以理解为volatile读之后该共享变量才可以被读或者写,确保本次volatile读的是最新值

这里附一张图,是国外一位技术博主分析的,我本人是不太理解,原文地址:gee.cs.oswego.edu/dl/jmm/cook...

因为不需要禁止所有的指令重排,所以说volatile只可以保证一部分的有序性。

volatile底层原理(硬件到软件)

volatile的实现,硬件到软件

硬件层面(CPU架构)

在多核cpu架构中,产生数据不一致问题一般在于多核缓存架构中,每个cpu都有本cpu的缓存体系,但是主存是共享的,在读写数据的时候会优先在本cpu的缓存中读写,所以会发生数据不一致问题。所以就引入了缓存一致性协议来解决这个问题,下面附图cpu缓存一致性架构图。

MESI是缓存协议的一种

MESI协议原理

MESI协议原理是在CPU的缓存行中保存一个标志位,每个缓存行有4个状态,4个状态就2两个bit位表示。MESI就是4种状态

M:Modofied 被修改

这个状态表示当前缓存行只是被缓存在该cpu中,并且是被修改过的脏数据,就是和主存中的数据不一样,这个缓存行会在将来写入主存

当这行缓存行写入主存后,该缓存行的状态会变成E独享状态
E:Exlusive 独享

这个状态表示当前缓存行只是被缓存在该cpu中,其他cpu没有缓存他,没有被修改过的数据,就是和主存中的数据一模一样。

当别的cpu读取该数据后,该缓存行的状态会变成S共享状态 当CPU修改该行数据,状态变为M 被修改状态
S:Share 共享

这个状态表示当前缓存行被缓存在多个cpu中,并且与各个缓存中的数据与主存数据一致

当其他一个cpu修改该缓存数据时,其他cpu中的缓存行可以被作废,变成I状态 无效状态
I:Invalid 无效

表示该缓存行失效,因为被其他CPU修改过,所以不能使用了,读取该数据需要重新到主存中读取

CPU的读取会遵循以下几点:

1.如果缓存状态是i,表示该缓存行失效,如果要读取的话直接从主存中读取

2.如果缓存状态是m或者e的cpu探测到其他cpu有读的操作,就把自己的缓存写入主存中,并且设置状态为s

MESI协议实现思路

1.如果有一个CPU修改了共享变量的数据,就需要广播给其他CPU

2.缓存中没有这个数据的CPU直接丢弃这个广播消息,无需处理

3.缓存中有这个数据的CPU收到该消息后,去将对应的缓存行设置为I状态

4.这些设置为i状态的CPU下次读取的时候直接从主存中读取

MESI协议存在的问题

因为上述所提及的广播是由总线嗅探机制 实现的,所以他的过程是串行的。可能会发生阻塞

就比如一个cpu在本地缓存中修改了标志位为S的共享变量A,需要先广播给其他拥有A缓存的CPU,在接收到其他CPU的确认回执之后再进行修改,从发出广播再到收到确认回执这段时间,这个cpu都是阻塞的。

写缓冲区和失效队列

为了解决缓存一致性阻塞的问题而提出

加入了写缓冲区与失效队列的cpu结构图

写缓冲区(Store Buffer):

CPU如果写入共享数据的时候,直接把数据写入Store Buffer中 ,然后发送invalidate(将其他cpu缓存失效的信息)消息给其他的CPU,然后不会阻塞,而是直接去处理其他的指令,也就是异步操作。当接收到其他CPU发送了Invalidate acknowledge(确认回执)消息,才会将Store Buffer中的数据写入缓存行中,最后再写入主存中。

失效队列(Innvalid Queue):

在每个CPU中都存在着失效队列,使用失效队列之后,在接收到invalidate消息时候不会马上将缓存失效然后才发送Invalidate acknowledge消息。而是会将Invalidate消息存入失效队列中,然后马上发送Invalidate acknowledge消息,等cpu有空闲时间再去处理失效队列中的消息,然后在将缓存失效。

ps:可以发现在没有引入Store Buffer和Innvalid Queue的时候,进行缓存失效的操作是同步串行的,效率比较低。引入之后,进行的失效操作是异步的,效率较高,但是这样发生数据安全性问题。

引入了写缓冲区和失效队列可能会发生的问题

引入了Store Buffer之后,共享数据的修改值,首先要先进入Store Buffer,接收到确认回执后,才会进入缓存行修改,最后写入主存。可以发现中间多了步骤,且写入主存的时机并不确定,在这段时间中有可能会有其他操作去主存或者缓存中读写该数据,造成数据安全性问题。
引入了Innvalid Queue之后,CPU在接收到Innvalid消息之后并不会直接失效缓存,然后发送确认回执并将失效消息入队,最后在空闲时间再处理失效缓存。如果在失效队列中的失效信息没有及时的处理,且在这个时候该cpu又读取了数据,这样就会发生读到过时的数据。

引入CPU内存屏障指令解决可见性问题

作用:

1.禁止屏障两边的指令重排序

2.强行把Store Buffer的数据刷入主存,强行处理Innvalid Queue的消息,让缓存行的数据失效

写屏障(Store Barrier):针对写缓冲区

写屏障前所有的store buffer(写缓冲区)的数据全部刷入主存,就是写屏障前的写指令,对于屏障后的读操作都是可见的

读屏障(Load Barrier):针对失效队列

在cpu加载任何数据之前都需要进行对Innvalid Queue(失效队列)进行处理。将缓存行的数据失效,确保下次读取的是主存的数据。

再谈JMM

前面提到了一下JMM,在这里与前面的cpu缓存架构做对比

CPU缓存

JMM

可以发现两者在设计上其实是高度相似的

Happens-Before规则

JMM本质上包含了一些规则,如果不符合这个规则,JMM并不可以保证一个线程的可见性和有序性,其实就是Happens-Before规则

简单理解就是如果A线程发生在B线程前面,那么A线程对B线程可见,A Happens-Before B。

以下为Happen-Before的6个原则:

1.书写顺序原则

在同一个线程里,书写的逻辑需要和操作的顺序逻辑一致,也就是前面书写的操作对后面书写的操作可见。

2.volatile变量规则

这个通篇都在讲,volatile的变量的写操作,对后续所有这个变量的读操作可见

3.传递性

A Happens-Before B;B Happens-Before C

可以得出

A Happens-Before C

4.锁的规则

就是获得锁的线程的操作对后续获得锁的线程可见

5.线程start()方法

在主线程中启动子线程,主线程启动子线程前的操作对子线程可见

6.线程join()方法

当线程A使用了join方法,线程A中的操作,对于其他后续线程可见。

JMM的8种内存交互

关于主内存与工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存。如何从工作内存同步到主内存中的实现细节。java内存模型定义了8种操作来完成,每一种都是原子操作:

lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;

read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;

load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;

use(使用):作用于工作内存,它把工作内存中的值传递给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作;

assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;

store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;

write(写入):作用于主内存,它把store传送值放到主内存中的变量中。

unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定;

定义这8种内存操作之后还需要遵循以下规则

Java内存模型还规定了执行上述8种基本操作时必须满足如下规则:

(1)不允许read和load、store和write操作之一单独出现(即不允许一个变量从主存读取了但是工作内存不接受,或者从工作内存发起会写了但是主存不接受的情况),以上两个操作必须按顺序执行,但没有保证必须连续执行,也就是说,read与load之间、store与write之间是可插入其他指令的。

(2)不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。

(3)不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中。

(4)一个新的变量只能从主内存中"诞生",不允许在工作内存中直接使用一个未被初始化(load或assign)的变量,换句话说就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。

(5)一个变量在同一个时刻只允许一条线程对其执行lock操作,但lock操作可以被同一个条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。

(6)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。

(7)如果一个变量实现没有被lock操作锁定,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定的变量。

(8)对一个变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)

volatile可见性的实现原理

底层实现主要是通过汇编lock前缀指令,它会锁定这块区域内的缓存(缓存行锁定)并会回写到主内存。

图片来自volatile的可见性实现原理分析_volatile可见性原理-CSDN博客

IA-32架构开发者对lock前缀指令的解释:

1.会将当前处理器缓存行的数据立即写回系统主存

2.这个写回主内存的操作要通过总线,cpu总线嗅探机制监听到,会引起在其他cpu里缓存了该内存地址的数据无效(MESI协议)

结束语

这是哥们的第一篇博客,可能有些知识点表达得不是很清楚和通透,希望大家海涵。同时也借鉴了不同平台的文章,后续会在文章末尾补全链接,感谢前者在技术分享上的支持。

volatile底层原理:从CPU架构到内存屏障之旅 - 掘金 (juejin.cn)

JMM:内存模型以及8种原子操作_jmm内存模型8个操作-CSDN博客

相关推荐
Xiao Fei Xiangζั͡ޓއއ1 小时前
一觉睡醒,全世界计算机水平下降100倍,而我却精通C语言——scanf函数
c语言·开发语言·笔记·程序人生·面试·蓝桥杯·学习方法
NMBG221 小时前
[JAVAEE] 面试题(四) - 多线程下使用ArrayList涉及到的线程安全问题及解决
java·开发语言·面试·java-ee·intellij-idea
刘艳兵的学习博客7 小时前
刘艳兵-DBA033-如下那种应用场景符合Oracle ROWID存储规则?
服务器·数据库·oracle·面试·刘艳兵
用户31574760813518 小时前
成为程序员的必经之路” Git “,你学会了吗?
面试·github·全栈
布川ku子19 小时前
[2024最新] java八股文实用版(附带原理)---Mysql篇
java·mysql·面试
有趣的杰克1 天前
移动端【01】面试系统的MVVM重构实践
面试·职场和发展·重构
saturday-yh1 天前
性能优化、安全
前端·面试·性能优化
前进别停留2 天前
206面试题(71~80)
面试
不二人生2 天前
SQL面试题——飞猪SQL面试 重点用户
数据库·sql·面试
dream_ready2 天前
四万字长文SpringBoot、Spring、SpringMVC等相关面试题(注:该篇博客将会持续维护 最新维护时间:2024年11月12日)
java·spring boot·后端·spring·面试·1024程序员节