热门标签 | 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

 


推荐阅读
  • 兆芯X86 CPU架构的演进与现状(国产CPU系列)
    本文详细介绍了兆芯X86 CPU架构的发展历程,从公司成立背景到关键技术授权,再到具体芯片架构的演进,全面解析了兆芯在国产CPU领域的贡献与挑战。 ... [详细]
  • 线程能否先以安全方式获取对象,再进行非安全发布? ... [详细]
  • Java高并发与多线程(二):线程的实现方式详解
    本文将深入探讨Java中线程的三种主要实现方式,包括继承Thread类、实现Runnable接口和实现Callable接口,并分析它们之间的异同及其应用场景。 ... [详细]
  • 高端存储技术演进与趋势
    本文探讨了高端存储技术的发展趋势,包括松耦合架构、虚拟化、高性能、高安全性和智能化等方面。同时,分析了全闪存阵列和中端存储集群对高端存储市场的冲击,以及高端存储在不同应用场景中的发展趋势。 ... [详细]
  • 解决Only fullscreen opaque activities can request orientation错误的方法
    本文介绍了在使用PictureSelectorLight第三方框架时遇到的Only fullscreen opaque activities can request orientation错误,并提供了一种有效的解决方案。 ... [详细]
  • 单片微机原理P3:80C51外部拓展系统
      外部拓展其实是个相对来说很好玩的章节,可以真正开始用单片机写程序了,比较重要的是外部存储器拓展,81C55拓展,矩阵键盘,动态显示,DAC和ADC。0.IO接口电路概念与存 ... [详细]
  • 本文总结了一些开发中常见的问题及其解决方案,包括特性过滤器的使用、NuGet程序集版本冲突、线程存储、溢出检查、ThreadPool的最大线程数设置、Redis使用中的问题以及Task.Result和Task.GetAwaiter().GetResult()的区别。 ... [详细]
  • 深入解析C语言中结构体的内存对齐机制及其优化方法
    为了提高CPU访问效率,C语言中的结构体成员在内存中遵循特定的对齐规则。本文详细解析了这些对齐机制,并探讨了如何通过合理的布局和编译器选项来优化结构体的内存使用,从而提升程序性能。 ... [详细]
  • 阿里巴巴终面技术挑战:如何利用 UDP 实现 TCP 功能?
    在阿里巴巴的技术面试中,技术总监曾提出一道关于如何利用 UDP 实现 TCP 功能的问题。当时回答得不够理想,因此事后进行了详细总结。通过与总监的进一步交流,了解到这是一道常见的阿里面试题。面试官的主要目的是考察应聘者对 UDP 和 TCP 在原理上的差异的理解,以及如何通过 UDP 实现类似 TCP 的可靠传输机制。 ... [详细]
  • 本文深入解析了JDK 8中HashMap的源代码,重点探讨了put方法的工作机制及其内部参数的设定原理。HashMap允许键和值为null,但键为null的情况只能出现一次,因为null键在内部通过索引0进行存储。文章详细分析了capacity(容量)、size(大小)、loadFactor(加载因子)以及红黑树转换阈值的设定原则,帮助读者更好地理解HashMap的高效实现和性能优化策略。 ... [详细]
  • 本文探讨了如何通过编程手段在Linux系统中禁用硬件预取功能。基于Intel® Core™微架构的应用性能优化需求,文章详细介绍了相关配置方法和代码实现,旨在帮助开发人员有效控制硬件预取行为,提升应用程序的运行效率。 ... [详细]
  • 本文深入探讨了Java多线程环境下的同步机制及其应用,重点介绍了`synchronized`关键字的使用方法和原理。`synchronized`关键字主要用于确保多个线程在访问共享资源时的互斥性和原子性。通过具体示例,如在一个类中使用`synchronized`修饰方法,展示了如何实现线程安全的代码块。此外,文章还讨论了`ReentrantLock`等其他同步工具的优缺点,并提供了实际应用场景中的最佳实践。 ... [详细]
  • PHP-Casbin v3.20.0 已经发布,这是一个使用 PHP 语言开发的轻量级开源访问控制框架,支持多种访问控制模型,包括 ACL、RBAC 和 ABAC。新版本在性能上有了显著的提升。 ... [详细]
  • 本文整理了一份基础的嵌入式Linux工程师笔试题,涵盖填空题、编程题和简答题,旨在帮助考生更好地准备考试。 ... [详细]
  • Java Socket 关键参数详解与优化建议
    Java Socket 的 API 虽然被广泛使用,但其关键参数的用途却鲜为人知。本文详细解析了 Java Socket 中的重要参数,如 backlog 参数,它用于控制服务器等待连接请求的队列长度。此外,还探讨了其他参数如 SO_TIMEOUT、SO_REUSEADDR 等的配置方法及其对性能的影响,并提供了优化建议,帮助开发者提升网络通信的稳定性和效率。 ... [详细]
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社区 版权所有