探索线程安全背后的本质——volatile
目录
一、一个问题引发的思考
二、什么是可见性
2.1 硬件层面
2.1.1 CPU高速缓存
2.1.2 总线锁&缓存锁,和缓存一致性
2.1.3 既然cpu有机制可以达成缓存一致性,为什么还是会有可见性问题?
三、引出了MESI的一个优化(x86结构)
3.1 优化前cpu修改share状态缓存示意图:
3.2 Store Bufferes
3.2.1 指令重排序的过程
3.3 通过内存屏障禁止了指令重排序
四、软件层面
4.1 JMM
五、Volatile的原理
5.1 通过javap -v VolatileDemo.class查看字节指令
5.2java定义的内存屏障指令:
5.3 volatile解决可见性问题
5.4 单例模式中的可见性问题(DCL问题——双重检查锁)
六、Happens-Before模型
6.1 程序顺序规则(as-if-serial语义)
6.2 传递性规则
6.3 volatile变量规则
6.4 监视器锁规则
6.5 start规则(线程启动规则)
6.6 Join规则(线程终结规则)
参考博客
一、一个问题引发的思考
public class VolatileDemo {public static boolean flag = true;public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(()->{int i = 0;while (flag){i++;}});thread.start();Thread.sleep(1000);flag = false;}
}
执行上面一段代码,会发现虽然把flag变量改成了false,但是线程并没有停止,貌似main线程更改了值,对于thread线程来说并不知道,这就是我们常说的线程中的可见性问题,也是引起线程安全问题的根本原因。
那怎么解决这个问题呢?非常简单,java中最常见的就是通过volatile关键字解决,如下代码:
public class VolatileDemo {// 添加volatile关键字,解决可见性问题public volatile static boolean flag = true;public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(()->{int i = 0;while (flag){i++;}});thread.start();Thread.sleep(1000);flag = false;}
}
二、什么是可见性
在单线程的环境下,如果向一个变量先写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情。
但是在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性问题。
2.1 硬件层面
CPU/内存/IO设备,由于运行速度的差异,所以对cpu有一定的优化
主要体现在三个方面:
- CPU层面增加了高速缓存
- 操作系统,进程、线程、| CPU时间片来切换
- 编译器的优化 ,更合理的利用CPU的高速缓存
2.1.1 CPU高速缓存
因为高速缓存的存在,会导致一个缓存一致性问题。 下图是CPU高速缓存的一个模型图,我们可以分析出,ThreadA线程不能及时读取到ThreadB线程更改的值(数据不可见性),从而导致缓存数据不一致。
2.1.2 总线锁&缓存锁,和缓存一致性
总线锁
L1是一级缓存,L1d是数据缓存、L1i是指令缓存
L2是二级缓存,大小比一级缓存大一点
L3是三级缓存,L3缓存主要目的是为了敬意不降低内存操作的延迟问题
总线锁:简单来说就是,在多cpu下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的 。
如何优化呢?最好的方法就是控制锁的保护粒度,我们只需要保证对于被多个CPU缓存的同一份数据是一致的就行。在P6架构的CPU后,引入了缓存锁,如果当前数据已经被CPU缓存了,并且是要协会到主内存中的,就可以采用缓存锁来解决问题。
所谓的缓存锁,就是指内存区域如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执行锁操作回写到内存时,不再总线上加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作的原子性。
总线锁和缓存锁怎么选择,取决于很多因素,比如CPU是否支持、当前数据是否存在于缓存行以及存在无法缓存的数据时(比较大或者快约多个缓存行的数据,必然还是会使用总线锁。)
缓存锁
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI等。最常见的就是MESI协议。接下来给大家简单讲解一下MESI表示缓存行的四种状态,分别是:
- M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
- E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
- S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
- I(Invalid) 表示缓存已经失效
当cpu运行的时候,都会加载shop的值。以下是缓存各种状态示意图:
1.Exclusive——独占:
当stop值只存在某一个CUP0的缓存行中,这种状态叫缓存的独占状态。
2.Shared——共享
当stop值存在与多个cup中,叫共享状态。
3.Modify——修改
当stop值只存在CPU0的缓存中时,若修改stop值,会从独占状态变为修改状态。
4.Invalid——失效
当stop值同时存在CPU0和CPU1的的缓存中时,若CPU0修改stop值,此时会从CPU1的缓存行会变为失效状态。
此后CPU1会再次从主存中加载stop值。
缓存一致性
cpu就是通过缓存一致性协议或总线锁机制去达成缓存的一致性。
2.1.3 既然cpu有机制可以达成缓存一致性,为什么还是会有可见性问题?
以下文字摘自于其他博客,以供参考:
既然CPU有缓存一致性协议(MESI),为什么JMM还需要volatile关键字?
volatile是java语言层面给出的保证,MSEI协议是多核cpu保证cache一致性的一种方法,中间隔的还很远,我们可以先来做几个假设:
1.回到远古时候,那个时候cpu只有单核,或者是多核但是保证sequence consistency,当然也无所谓有没有MESI协议了。那这个时候,我们需要java语言层面的volatile的支持吗?
当然是需要的,因为在语言层面编译器和虚拟机为了做性能优化,可能会存在指令重排的可能,而volatile给我们提供了一种能力,我们可以告诉编译器,什么可以重排,什么不可以。
2.那好,假设更进一步,假设java语言层面不会对指令做任何的优化重排,那在多核cpu的场景下,我们还需要volatile关键字吗?
答案仍然是需要的。因为 MESI只是保证了多核cpu的独占cache之间的一致性,但是cpu的并不是直接把数据写入L1 cache的,中间还可能有store buffer。有些arm和power架构的cpu还可能有load buffer或者invalid queue等等。因此,有MESI协议远远不够。
3.再接着,让我们再做一个更大胆的假设。假设cpu中这类store buffer/invalid queue等等都不存在了,cpu是数据是直接写入cache的,读取也是直接从cache读的,那还需要volatile关键字吗?
你猜的没错,还需要的。原因就在这个“一致性”上。consistency和coherence都可以被翻译为一致性,但是MSEI协议这里保证的仅仅coherence而不是consistency。那consistency和cohence有什么区别呢?
下面取自wiki的一段话:
Coherence deals with maintaining a global order in which writes to a single location or single variable are seen by all processors. Consistency deals with the ordering of operations to multiple locations with respect to all processors.
因此,MESI协议最多只是保证了对于一个变量,在多个核上的读写顺序,对于多个变量而言是没有任何保证的。很遗憾,还是需要volatile~~
4.好的,到了现在这步,我们再来做最后一个假设,假设cpu写cache都是按照指令顺序fifo写的,那现在可以抛弃volatile了吧?你觉得呢?
那肯定不行啊!因为对于arm和power这个weak consistency的架构的cpu来说,它们只会保证指令之间有比如控制依赖,数据依赖,地址依赖等等依赖关系的指令间提交的先后顺序,而对于完全没有依赖关系的指令,比如x=1;y=2,它们是不会保证执行提交的顺序的,除非你使用了volatile,java把volatile编译成arm和power能够识别的barrier指令,这个时候才是按顺序的。
最后总结,答案就是:还需要~~
三、引出了MESI的一个优化(x86结构)
3.1 优化前cpu修改share状态缓存示意图:
- cpu0修改值,要执行write操作,写入之前要保持强一致,会发送invalidate到cup1,这个通知是一个失效通知,让其他缓存失效
- cpu1收到invalidate通知后,会让该值缓存失效,并通过ACK机制发送回执给cpu0
- 在这个通信过程中,cpu0会一直处于阻塞状态。在收到ACK回执后,再写入到内存
以上过程,阻塞时间会很短,但依然会造成cpu资源的浪费。所以cpu引入了Store Bufferes。
3.2 Store Bufferes
Store Bufferes是一个写的缓冲,对于上述描述的情况,CPU0可以先把写入的操作先存储到StoreBufferes中,Store Bufferes中的指令再按照缓存一致性协议去发起其他CPU缓存行的失效。而同步来说CPU0可以不用等到Acknowledgement,继续往下执行其他指令,直到收到CPU0收到Acknowledgement再更新到缓存,再从缓存同步到主内存。
3.2.1 指令重排序的过程
我们来关注下面这段代码,假设分别有两个线程,分别执行executeToCPU0和executeToCPU1,分别由两个不同的CPU来执行。
引入Store Bufferes之后,就可能出现 b==1返回true ,但是assert(a==1)返回false。很多同学肯定会表示不理解,这种情况怎么可能成立?那接下来我们去分析一下。
/*伪代码*/
executeToCPU0(){a=1;b=1;
}executeToCPU1(){while(b==1){assert(a==1);}
}
上述代码图解:
这就是cpu层面的指令重排序。
3.3 通过内存屏障禁止了指令重排序
上面的Store Bufferes 是为了提高cpu的利用率,但这样也带来了指令重排序。为了解决这个问题,cpu也提供了内存屏障的指令。
X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障):
- Store Memory Barrier(写屏障) ,告诉处理器在写屏障之前的所有已经存储在存储缓存(storebufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
- Load Memory Barrier(读屏障) ,处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
- Full Memory Barrier(全屏障) ,确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作
上述加屏障后的伪代码:
volatile int a=0;
executeToCpu0(){a=1;//storeMemoryBarrier()写屏障,写入到内存b=1;// CPU层面的重排序//b=1;//a=1;
} executeToCpu1(){while(b==1){ //trueloadMemoryBarrier(); //读屏障assert(a==1) //false}
}
volatile会自动加写屏障和读屏障。
四、软件层面
上述我们说得都是硬件层面解决可见性问题,并且是基于x86架构,但是我们的java代码是会运行在不同的cpu架构中的。
由此引出了java内存模型,它与jvm运行数据区不是一个概念。
4.1 JMM
简单来说,JMM定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。
需要注意的是,JMM并没有主动限制执行引擎使用处理器的寄存器和高速缓存来提升指令执行速度,也没主动限制编译器对于指令的重排序,也就是说在JMM这个模型之上,仍然会存在缓存一致性问题和指令重排序问题。JMM是一个抽象模型,它是建立在不同的操作系统和硬件层面之上对问题进行了统一的抽象,然后再Java层面提供了一些高级指令,让用户选择在合适的时候去引入这些高级指令来解决可见性问题。
volatile会根据不同的硬件和操作系统,生成不同的内存屏障的指令,从而达到一致性线程安全的效果。
在java层面要解决的两个问题:
- 防止指令重排序
- 禁止高速缓存
重排序的过程示意图:1是编译层面,2、3是硬件层面。
编译器优化重排序伪代码:
int a=0;
executeToCpu0(){a=1;b=1;// 编译器层面也会重排序//b=1;//a=1;
} executeToCpu1(){while(b==1){ //trueassert(a==1) //false}
}
其实通过前面的内容分析我们发现,导致可见性问题有两个因素,一个是高速缓存导致的可见性问题,另一个是指令重排序。那JMM是如何解决可见性和有序性问题的呢?
其实前面在分析硬件层面的内容时,已经提到过了,对于缓存一致性问题,有总线锁和缓存锁,缓存锁是基于MESI协议。而对于指令重排序,硬件层面提供了内存屏障指令。而JMM在这个基础上提供了volatile、final等关键字,使得开发者可以在合适的时候增加相应相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。
五、Volatile的原理
5.1 通过javap -v VolatileDemo.class查看字节指令
public static volatile boolean stop;
descriptor: Z
flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE
volatile源码多了一个ACC_VOLATILE
5.2java定义的内存屏障指令:
5.3 volatile解决可见性问题
一句话来说就是:提供防止指令重排序的机制,和内存屏障机制取解决可见性问题。
5.4 单例模式中的可见性问题(DCL问题——双重检查锁)
public class DoubleCheckSingleton {private static DoubleCheckSingleton instance = null; public static DoubleCheckSingleton getInstance(){if(instance==null){synchronized (DoubleCheckSingleton.class) {if (instance == null) {instance = new DoubleCheckSingleton();//这里由于out-of-order}}}return instance;}
}
上面的instance没有用volatile修饰,会有可见性问题:
这里说的是语句instance = new DoubleCheckSingleton()不是一个原子操作
instance = new DoubleCheckSingleton();//这里由于out-of-order 无序操作那么问题就来了:必然会做这么些事情
- 给DoubleCheckSingleton分配内存
- 初始化DoubleCheckSingleton实例
- 将instance对象指向分配的内存空间(instance为null了)
而在1,2,3中,执行顺序可能出现2,3或者3,2这种情况,如果是3,2 自然另一个线程拿到的可能是未初始化好的DoubleCheckSingleton
JDK1.5后可改为,private volatile static DoubleCheckSingleton instance = null 每次都从主内存读取instance。
六、Happens-Before模型
除了显示引用volatile关键字能够保证可见性以外,在Java中,还有很多的可见性保障的规则。
从JDK1.5开始,引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。
所以我们可以认为在JMM中:
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程。
- 程序顺序规则(as-if-serial语义)
- 传递性规则
- volatile变量规则
- 监视器锁规则
- start规则
- join规则
6.1 程序顺序规则(as-if-serial语义)
- 不能改变程序的执行结果(在单线程环境下,执行的结果不变
- 依赖问题, 如果两个指令存在依赖关系,是不允许重排序
int a=0;
int b=0;
void test(){int a=1; aint b=1; b//int b=1;//int a=1;int c=a*b; c
}
a happens -before b ; b happens before c
6.2 传递性规则
如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
a happens-before b , b happens- before c, a happens-before c
6.3 volatile变量规则
这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。
内存屏障机制来防止指令重排
public class VolatileExample{int a=0;volatile boolean flag=false;public void writer(){a=1; //1flag=true; //修改 2}public void reader(){if(flag){ //true 3int i=a; //1 4}}
}
1 happens-before 2 是否成立? 是 -> ?
3 happens-before 4 是否成立? 是
2 happens -before 3 ->volatile规则
1 happens-before 4 ; i=1成立
6.4 监视器锁规则
一个unLock操作先行发生于后面对同一个锁lock操作;(synchronized)
int x=10;
synchronized(this){//后续线程读取到的x的值一定12if(x<12){x=12;}
}
x=12;
6.5 start规则(线程启动规则)
假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。
public class StartDemo{int x=0;Thread t1=new Thread(()->{//读取x的值 一定是20if(x==20){}});x=20;t1.start();
}
6.6 Join规则(线程终结规则)
假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。
public class Test{int x=0;Thread t1=new Thread(()->{x=200;});t1.start();t1.join(); //保证结果的可见性。//在此处读取到的x的值一定是200.
}
兴趣拓展:final关键字提供了内存屏障的规则
参考博客
CPU有缓存一致性协议(MESI),为何还需要volatile
探索线程安全背后的本质——volatile相关推荐
- 并发编程-06线程安全性之可见性 (synchronized + volatile)
文章目录 线程安全性文章索引 脑图 可见性定义 导致不可见的原因 可见性 -synchronized (既保证原子性又保证可见性) 可见性 - volatile(但不保证操作的原子性) volatil ...
- volatile能保持线程安全吗_从volatile说到i++的线程安全问题
一般说来,volaTIle用在如下的几个地方: 1.中断服务程序中修改的供其它程序检测的变量需要加volaTIle: 2.多任务环境下各任务间共享的标志应该加volaTIle: 3.存储器映射的硬件寄 ...
- 全球AI技术开放日之走进美团 :探索美团外卖背后的AI大脑
全球 AI 技术开放日(系列)是 AICamp 发起的学习和练习 AI 技术的一系列技术交流学习活动.活动组织国内外 AI 技术专家学者走进优秀的 AI 技术公司,和公司的 AI.机器学习.数据分析部 ...
- 人类自主行动背后的本质和具身人工智能未来的发展
文章目录 人类自主行动背后的本质和具身人工智能未来的发展 人类自主行动的本质 外部因素 内部因素 具身人工智能未来的发展 技术层面的发展趋势 传感技术 控制技术 人工智能系统 应用层面的发展趋势 工业 ...
- 和硅谷AI专家一起走进美团,探索美团外卖背后的AI大脑
" 美团外卖目前覆盖了上百万商家和上亿菜品.如何做到实时搜索.推荐以及商家管控和智能套餐搭配等业务? 美团外卖每天有50万以上骑手穿梭在大街小巷.如何使用目前最先进语音识别.NLU.机器学习 ...
- Java线程安全 关于原子性与volatile的试验
1. 变量递增试验 1 static /*volatile*/ int shared=0;//volatile也无法保证++操作的原子性 2 static synchronized int incrS ...
- Redis高效性探索--线程IO模型,通信协议
Redis线程IO模型 Redis是单线程,这个毋庸置疑 Redis单线程能做到这么高的效率?不用怀疑,还有很多其他的服务都是单线程但是也有超高的效率,比如Node.js,Nginx也是单线程. Re ...
- 从神一到神十三,探索中国航天背后“神秘”力量
神舟十三号之后,中国空间站将全面进入建造阶段,天舟四号货运飞船.神舟十四.问天实验舱等将陆续在中国空间站会合.可以说,2022年将是中国航天承上启下的关键之年,中国将以此为起点,踏上太空探索的更遥远的 ...
- NET多线程探索-线程同步和通信
NET中各种线程同步方法 在NET多线程开发中,有时候需要多个线程协调工作,完成这个步骤的过程称为"同步". 使用同步的主要原因: 1.多个线程访问同一个共享资源. 2.多线程写入 ...
- 探索语句和表达式的本质
语句与表达式的基本定义 语句 指构成语言的基本单位,按照一定的语法规则组织,具有完整的意义. 表达式 在数学领域中是一些符号依据上下文的规则,有限而定义良好的组合. 比如数学中的加减乘除就是数学符号. ...
最新文章
- 将传统的落后WAN转换为SD-WAN
- Python+selenium 自动化-chrome驱动的下载安装
- 多线程:happens-before原则
- C++prims算法生成最小协议树(附完整源码)
- Git本地已有仓库进行连接Remote库 本地没有仓库进行本地新建仓库连接Remote库
- Oracle adviser,Oracle10g SQL tune adviser
- 恒大汽车:仍在就出售新能源汽车生活项目等资产进行磋商
- 分布式任务调度平台XXL-JOB一
- 搜索引擎网站登陆入口,提交入口
- VS2019如何修改字体大小
- 基于MATLAB步态算法仿真的六足仿生机器人
- vs2010中使用Nunit测试c#代码结果的正确性
- 第十二章 Android第三方库源码
- 【SQL】小CASE
- tensorflow,pytorch中normalize方法
- NADH二钠CAS 606-68-8的全球与中国市场2022-2028年:技术、参与者、趋势、市场规模及占有率研究报告
- 王春亮用心接待全国的调养者和学习者
- 又一个IGame的bug
- Fatal error: The slave I/O thread stops because master and slave have equal MySQL server UUIDs
- 电话号码及座机正则表达式检验
热门文章
- video-react报错pause没有被定义_qt常见报错
- 输出单向链表中倒数第k个结点
- 使用折半查找法查找数组中的元素
- 通信系统设计中的凸优化问题
- 如何找mysql8.0的rpm安装包_centos7上mysql8.0rpm方式安装
- android studio | openGL es 3.0增强现实(AR)开发 (4) 绘制简单的2D图形、显示、旋转
- 【BZOJ 1503】郁闷的出纳员【权值线段树】
- POJ1321-Chess Problem(dfs基础题)
- 利用 python 批量修改文件名
- 739.每日温度 (力扣leetcode) 博主可答疑该问题