热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

探索线程安全背后的本质——volatile

目录一、一个问题引发的思考二、什么是可见性2.1硬件层面2.1.1CPU高速缓存2.1.2总线锁&缓存锁,和缓存一致性2.1.3既然cpu有机制可以达成缓

目录

一、一个问题引发的思考

二、什么是可见性

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有一定的优化
主要体现在三个方面:


  1. CPU层面增加了高速缓存
  2. 操作系统,进程、线程、| CPU时间片来切换
  3. 编译器的优化 ,更合理的利用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表示缓存行的四种状态,分别是:


  1. M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
  2. E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
  3. S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
  4. 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状态缓存示意图:


  1. cpu0修改值,要执行write操作,写入之前要保持强一致,会发送invalidate到cup1,这个通知是一个失效通知,让其他缓存失效
  2. cpu1收到invalidate通知后,会让该值缓存失效,并通过ACK机制发送回执给cpu0
  3. 在这个通信过程中,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(全屏障):


  1. Store Memory Barrier(写屏障) ,告诉处理器在写屏障之前的所有已经存储在存储缓存(storebufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
  2. Load Memory Barrier(读屏障) ,处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
  3. 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

JAVA 编译器优化重排序伪代码:

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 无序操作

那么问题就来了:必然会做这么些事情

  1. 给DoubleCheckSingleton分配内存
  2. 初始化DoubleCheckSingleton实例
  3. 将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&#61;12;}
}
x&#61;12;

6.5 start规则(线程启动规则)

假定线程A在执行过程中&#xff0c;通过执行ThreadB.start()来启动线程B&#xff0c;那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。

public class StartDemo{int x&#61;0;Thread t1&#61;new Thread(()->{//读取x的值 一定是20if(x&#61;&#61;20){}});x&#61;20;t1.start();
}

6.6 Join规则(线程终结规则)

假定线程A在执行的过程中&#xff0c;通过制定ThreadB.join()等待线程B终止&#xff0c;那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。

public class Test{int x&#61;0;Thread t1&#61;new Thread(()->{x&#61;200;});t1.start();t1.join(); //保证结果的可见性。//在此处读取到的x的值一定是200.
}

兴趣拓展&#xff1a;final关键字提供了内存屏障的规则



参考博客

CPU有缓存一致性协议(MESI)&#xff0c;为何还需要volatile

 


推荐阅读
  • JVM 学习总结(三)——对象存活判定算法的两种实现
    本文介绍了垃圾收集器在回收堆内存前确定对象存活的两种算法:引用计数算法和可达性分析算法。引用计数算法通过计数器判定对象是否存活,虽然简单高效,但无法解决循环引用的问题;可达性分析算法通过判断对象是否可达来确定存活对象,是主流的Java虚拟机内存管理算法。 ... [详细]
  • Java太阳系小游戏分析和源码详解
    本文介绍了一个基于Java的太阳系小游戏的分析和源码详解。通过对面向对象的知识的学习和实践,作者实现了太阳系各行星绕太阳转的效果。文章详细介绍了游戏的设计思路和源码结构,包括工具类、常量、图片加载、面板等。通过这个小游戏的制作,读者可以巩固和应用所学的知识,如类的继承、方法的重载与重写、多态和封装等。 ... [详细]
  • 本文讨论了使用差分约束系统求解House Man跳跃问题的思路与方法。给定一组不同高度,要求从最低点跳跃到最高点,每次跳跃的距离不超过D,并且不能改变给定的顺序。通过建立差分约束系统,将问题转化为图的建立和查询距离的问题。文章详细介绍了建立约束条件的方法,并使用SPFA算法判环并输出结果。同时还讨论了建边方向和跳跃顺序的关系。 ... [详细]
  • 深入理解Kafka服务端请求队列中请求的处理
    本文深入分析了Kafka服务端请求队列中请求的处理过程,详细介绍了请求的封装和放入请求队列的过程,以及处理请求的线程池的创建和容量设置。通过场景分析、图示说明和源码分析,帮助读者更好地理解Kafka服务端的工作原理。 ... [详细]
  • 本文讨论了一个数列求和问题,该数列按照一定规律生成。通过观察数列的规律,我们可以得出求解该问题的算法。具体算法为计算前n项i*f[i]的和,其中f[i]表示数列中有i个数字。根据参考的思路,我们可以将算法的时间复杂度控制在O(n),即计算到5e5即可满足1e9的要求。 ... [详细]
  • 李逍遥寻找仙药的迷阵之旅
    本文讲述了少年李逍遥为了救治婶婶的病情,前往仙灵岛寻找仙药的故事。他需要穿越一个由M×N个方格组成的迷阵,有些方格内有怪物,有些方格是安全的。李逍遥需要避开有怪物的方格,并经过最少的方格,找到仙药。在寻找的过程中,他还会遇到神秘人物。本文提供了一个迷阵样例及李逍遥找到仙药的路线。 ... [详细]
  • Android工程师面试准备及设计模式使用场景
    本文介绍了Android工程师面试准备的经验,包括面试流程和重点准备内容。同时,还介绍了建造者模式的使用场景,以及在Android开发中的具体应用。 ... [详细]
  • 重入锁(ReentrantLock)学习及实现原理
    本文介绍了重入锁(ReentrantLock)的学习及实现原理。在学习synchronized的基础上,重入锁提供了更多的灵活性和功能。文章详细介绍了重入锁的特性、使用方法和实现原理,并提供了类图和测试代码供读者参考。重入锁支持重入和公平与非公平两种实现方式,通过对比和分析,读者可以更好地理解和应用重入锁。 ... [详细]
  • 本文介绍了Codeforces Round #321 (Div. 2)比赛中的问题Kefa and Dishes,通过状压和spfa算法解决了这个问题。给定一个有向图,求在不超过m步的情况下,能获得的最大权值和。点不能重复走。文章详细介绍了问题的题意、解题思路和代码实现。 ... [详细]
  • 本文整理了Java面试中常见的问题及相关概念的解析,包括HashMap中为什么重写equals还要重写hashcode、map的分类和常见情况、final关键字的用法、Synchronized和lock的区别、volatile的介绍、Syncronized锁的作用、构造函数和构造函数重载的概念、方法覆盖和方法重载的区别、反射获取和设置对象私有字段的值的方法、通过反射创建对象的方式以及内部类的详解。 ... [详细]
  • 本文介绍了在Android开发中使用软引用和弱引用的应用。如果一个对象只具有软引用,那么只有在内存不够的情况下才会被回收,可以用来实现内存敏感的高速缓存;而如果一个对象只具有弱引用,不管内存是否足够,都会被垃圾回收器回收。软引用和弱引用还可以与引用队列联合使用,当被引用的对象被回收时,会将引用加入到关联的引用队列中。软引用和弱引用的根本区别在于生命周期的长短,弱引用的对象可能随时被回收,而软引用的对象只有在内存不够时才会被回收。 ... [详细]
  • 流数据流和IO流的使用及应用
    本文介绍了流数据流和IO流的基本概念和用法,包括输入流、输出流、字节流、字符流、缓冲区等。同时还介绍了异常处理和常用的流类,如FileReader、FileWriter、FileInputStream、FileOutputStream、OutputStreamWriter、InputStreamReader、BufferedReader、BufferedWriter等。此外,还介绍了系统流和标准流的使用。 ... [详细]
  • 在Android开发中,使用Picasso库可以实现对网络图片的等比例缩放。本文介绍了使用Picasso库进行图片缩放的方法,并提供了具体的代码实现。通过获取图片的宽高,计算目标宽度和高度,并创建新图实现等比例缩放。 ... [详细]
  • Java容器中的compareto方法排序原理解析
    本文从源码解析Java容器中的compareto方法的排序原理,讲解了在使用数组存储数据时的限制以及存储效率的问题。同时提到了Redis的五大数据结构和list、set等知识点,回忆了作者大学时代的Java学习经历。文章以作者做的思维导图作为目录,展示了整个讲解过程。 ... [详细]
  • 纠正网上的错误:自定义一个类叫java.lang.System/String的方法
    本文纠正了网上关于自定义一个类叫java.lang.System/String的错误答案,并详细解释了为什么这种方法是错误的。作者指出,虽然双亲委托机制确实可以阻止自定义的System类被加载,但通过自定义一个特殊的类加载器,可以绕过双亲委托机制,达到自定义System类的目的。作者呼吁读者对网上的内容持怀疑态度,并带着问题来阅读文章。 ... [详细]
author-avatar
天堂寨旅游2013_668
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有