【卡在Java并发上】-深入分析volatile的实现原理。
在前一章中,我们了解到synchronized是一个重量级的锁,尽管JVM已经对它进行了许多优化,而下面描述的volatile是轻量级的synchronized。如果一个变量使用volatile,它比synchronized便宜,因为它不会导致线程上下文的切换和调度。Java语言规范对volatile的定义如下:
Java编程语言允许线程访问共享变量。为了确保共享变量能够被准确和一致地更新,线程应该确保这个变量是通过排他锁单独获得的。
以上有点绕弯。通俗地说,就是说如果一个变量用volatile修饰,那么Java可以保证所有线程看到这个变量的值都是一样的。如果一个线程更新了用volatile修饰的共享变量,其他线程可以立即看到这个更新,这被称为线程可见性。
虽然volatile看起来很简单,只能通过在变量前面加volatile来使用,但是要用好并不容易(LZ承认我还是用不好,用的时候还是模棱两可)。
内存模型相关概念
理解volatile其实有点难。它与Java的内存模型有关,所以在理解volatile之前,我们需要先了解一下Java内存模型的概念。这里我们只做一个初步的介绍,后面LZ会详细介绍Java内存模型。
操作系统语义
当一台计算机运行一个程序时,每一条指令都在CPU中执行,这不可避免地涉及到数据的读写。我们知道程序的运行数据存储在主存中,所以会出现问题。在主存中读写数据不如在CPU中执行指令快。如果任何交互都需要处理主存,会大大影响效率,所以有了CPU缓存。中央处理器缓存对于中央处理器是唯一的,并且只与在该中央处理器中运行的线程相关。
CPU缓存虽然解决了效率问题,但是会带来一个新的问题:数据一致性。在程序运行期间,运行所需数据的副本将被复制到中央处理器缓存中。运行时,CPU不再处理主存,而是直接从缓存中读写数据,数据运行后才会刷新到主存中。举个简单的例子:
我
当线程运行这段代码时,它会首先从主内存中读取i(i=1),然后复制一个副本到CPU缓存,然后CPU会执行1 (2)的操作,然后将数据(2)写入到tell缓存中,最后刷新到主内存中。其实单线程没有问题,问题在多线程。如下所示:
如果两个线程A和B都执行这个操作(I),按照我们正常的逻辑思维,主内存中I的值应该等于3,但事实是这样的。分析如下:
两个线程将I (1)的值从主内存读取到各自的缓存中,然后线程A执行1操作并将结果写入缓存,最后将其写入主内存。此时主内存中的I=2,线程B做同样的操作,主内存中的I仍然是=2。所以最后的结果是2,不是3。这种现象就是缓存一致性的问题。
缓存一致性有两种解决方案:
通过向总线添加LOCK#锁
通过高速缓存一致性协议
但是方案一有一个问题,是以排他的方式实现的,就是如果用LOCK#锁定总线,只能运行一个CPU,其他CPU都要阻塞,效率比较低。
第二种方案,缓存一致性协议(MESI协议),确保每个缓存中使用的共享变量的副本是一致的。核心思想是这样的:当一个CPU在写数据时,如果发现被操纵变量是共享变量,就会通知其他CPU该变量的缓存行无效,所以其他CPU在读取该变量并发现无效时,会从主存重新加载数据。
Java内存模型
从操作系统层面,上面解释了如何保证数据一致性。接下来我们来看看Java内存模型,稍微研究一下Java内存模型为我们提供了什么保障,在做多线程编程时,Java提供了什么方法和机制来保证程序执行的正确性。
在并发编程中,我们通常会遇到这三个基本概念:原子性、可见性和顺序。我们来看看volatile。
原子性
原子性:即一个操作或多个操作要么全部执行,执行过程不会被任何因素打断,要么不执行。
原子就像数据库中的事务。他们是一个团队,生死与共。其实理解原子性很简单。让我们看看下面这个简单的例子:
I=0;- 1
j=I;- 2
我;- 3
I=j 1;- 4
以上四种操作哪一种是原子操作,哪一种不是?如果你不太了解它们,你可能会认为它们都是原子操作。其实只有1个是原子操作,其余都不是。
1-在Java中,基本数据类型的变量和赋值操作是原子操作;
2-包括
两个操作:读取i,将i值赋值给j
3---包含了三个操作:读取i值、i + 1 、将+1结果赋值给i;
4---同三一样
在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java只保证了基本数据类型的变量和赋值操作才是原子性的(注:在32位的JDK环境下,对64位数据的读取不是原子性操作,如long、double*)。要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。
volatile是无法保证复合操作的原子性
可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。
Java提供了volatile来保证可见性。
当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。当然,synchronize和锁都可以保证可见性。
有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。
在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序它不会影响单线程的运行结果,但是对多线程会有影响。
Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。这里LZ就不再阐述了。
剖析volatile原理
JMM比较庞大,不是上面一点点就能够阐述的。上面简单地介绍都是为了volatile做铺垫的。
volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。
上面那段话,有两层语义
-
保证可见性、不保证原子性
-
禁止指令重排序
第一层语义就不做介绍了,下面重点介绍指令重排序。
在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:
-
编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
-
处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么JVM是如何禁止重排序的呢这个问题稍后回答,我们先看另一个原则happens-before,happen-before原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从happens-before原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。其定义如下:
-
同一个线程中的,前面的操作 happen-before 后续的操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。
-
监视器上的解锁操作 happen-before 其后续的加锁操作。(Synchronized 规则)
-
对volatile变量的写操作 happen-before 后续的读操作。(volatile 规则)
-
线程的start() 方法 happen-before 该线程所有的后续操作。(线程启动规则)
-
线程所有的操作 happen-before 其他线程在该线程上调用 join 返回成功后的操作。
-
如果 a happen-before b,b happen-before c,则a happen-before c(传递性)。
我们着重看第三点volatile规则:对volatile变量的写操作 happen-before 后续的读操作。为了实现volatile内存语义,JMM会重排序,其规则如下:
对happen-before原则有了稍微的了解,我们再来回答这个问题JVM是如何禁止重排序的
观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。lock前缀指令其实就相当于一个内存屏障。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile的底层就是通过内存屏障来实现的。下图是完成上述规则所需要的内存屏障:
volatile暂且下分析到这里,JMM体系较为庞大,不是三言两语能够说清楚的,后面会结合JMM再一次对volatile深入分析。
总结
volatile看起来简单,但是要想理解它还是比较难的,这里只是对其进行基本的了解。volatile相对于synchronized稍微轻量些,在某些场合它可以替代synchronized,但是又不能完全取代synchronized,只有在某些场合才能够使用volatile。使用它必须满足如下两个条件:
-
对变量的写操作不依赖当前值;
-
该变量没有包含在具有其他变量的不变式中。
volatile经常用于两个两个场景:状态标记两、double check
参考资料
-
周志明:《深入理解Java虚拟机》
-
方腾飞:《Java并发编程的艺术》
-
Java并发编程:volatile关键字解析
-
Java 并发编程:volatile的使用及其原理
PS:如果你觉得文章对你有所帮助,别忘了推荐或者分享,因为有你的支持,才是我续写下篇的动力和源泉!
-
作者:chenssy。一个专注于【死磕 Java】系列创作的男人
出处:https://www.cnblogs.com/chenssy/p/15690553.html
作者个人网站:https://www.cmsblogs.com/。专注于 Java 优质系列文章分享,提供一站式 Java 学习资料
目前死磕系列包括:
1. 【死磕 Java 并发】:https://www.cmsblogs.com/category/1391296887813967872(已完成)
2.【死磕 Spring 之 IOC】:https://www.cmsblogs.com/category/1391374860344758272(已完成)
3.【死磕 Redis】:https://www.cmsblogs.com/category/1391389927996002304(已完成)
4.【死磕 Java 基础】:https://www.cmsblogs.com/category/1411518540095295488
5.【死磕 NIO】:https://www.cmsblogs.com/article/1435620402348036096
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。
内容来源网络,如有侵权,联系删除,本文地址:https://www.230890.com/zhan/146474.html