文章简介
分析volatile的作用以及底层实现原理,这也是大公司喜欢问的问题
内容导航
volatile的作用
什么是可见性
volatile源码分析
01
volatile的作用
在多线程中,volatile和synchronized都起到非常重要的作用,synchronized是通过加锁来实现线程的安全性。而volatile的主要作用是在多处理器开发中保证共享变量对于多线程的可见性。 可见性的意思是,当一个线程修改一个共享变量时,另外一个线程能读取到修改以后的值。接下来通过一个简单的案例来演示可见性问题
定义一个共享变量
在main线程中创建一个子线程 ,子线程读取到 的值做循环结束的条件
main线程中修改stop的值为
当 没有增加volatile修饰时,子线程对于主线程的 的修改是不可见的,这样将导致子线程出现死循环
当 增加了volatile修饰时,子线程可以获取到主线程对于 的值,子线程while循环条件不满足退出循环
增加volatile关键字以后,main线程对于共享变量 值的更新,对于子线程 可见,这就是volatile的作用
这段代码有些人测试不出效果,是因为JVM没有优化导致的,在cmd控制台输入java -version,如果显示的是 ,就能正常演示,如果是 ,需要设置成 模式
什么是可见性,以及volatile是如何保证可见性的呢?
02
什么是可见性
在并发编程中,线程安全问题的本质其实就是 原子性、有序性、可见性;接下来主要围绕这三个问题进行展开分析其本质,彻底了解可见性的特性
原子性和数据库事务中的原子性一样,满足原子性特性的操作是不可中断的,要么全部执行成功要么全部执行失败
有序性编译器和处理器为了优化程序性能而对指令序列进行重排序,也就是你编写的代码顺序和最终执行的指令顺序是不一致的,重排序可能会导致多线程程序出现内存可见性问题
可见性多个线程访问同一个共享变量时,其中一个线程对这个共享变量值的修改,其他线程能够立刻获得修改以后的值
为了彻底了解这三个特性,我们从两个层面来分析,第一个层面是硬件层面、第二个层面是JMM层面
从硬件层面分析三大特性
原子性、有序性、可见性这些问题,我们可以认为是基于多核心CPU架构下的存在的问题。因为在单核CPU架构下,所有的线程执行都是基于CPU时间片切换,所以不存在并发问题
CPU高速缓存
线程设计的目的是充分利用CPU达到实时性的效果,但是很多时候CPU的计算任务还需要和内存进行交互,比如读取内存中的运算数据、将处理结果写入到内存。在理想情况下,存储器应该是非常快速的执行一条指令,这样CPU就不会受到存储器的限制。但目前技术无法满足,所以就出现了其他的处理方式。
存储器顶层是CPU中的寄存器,存储容量小,但是速度和CPU一样快,所以CPU在访问寄存器时几乎没有延迟;接下来就是CPU的高速缓存;最后就是内存。
高速缓存从下到上越接近CPU访问速度越快,同时容量也越小。现在的大部分处理器都有二级或者三级缓存,分别是L1/L2/L3, L1又分为L1-d的数据缓存和L1-i的指令缓存。其中L3缓存是在多核CPU之间共享的。
原子性
在多核CPU架构下,在同一时刻对同一共享变量执行 ,这个指令涉及到两次内存操作,那么在这种情况下i的结果是无法预测的。这就是原子性问题
处理器如何解决原子性问题呢?
其实这个问题稍微提炼一下,无非就是多线程并行访问同一个共享资源的时候的原子性问题,如果把问题放大到分布式架构里面,这个问题的解决方法就是锁。所以在CPU层面,提供了两种锁的机制来保证原子性
总线锁
如果多个处理器同时对同一共享变量进行 操作,那这个操作一定不是原子的,也就是执行的结果和预期结果不一致。如下图所示,我们期望的结果是3,但是有可能结果是2
如果要解决这个问题,就需要是的CPU0在更新共享变量时,CPU1就不能操作缓存了该共享变量内存地址的缓存,所以处理器提供了总线锁来解决问题,处理器会提供一个LOCK#信号,当一个处理器在总线上输出这个信号时,其他处理器的请求会被阻塞,那么该处理器就可以独占共享内存
总线锁有一个弊端,总线锁相当于使得多个CPU由并行执行变成了串行,使得CPU的性能严重下降,所以在P6系列以后的处理器中,引入了缓存锁。
缓存锁
我们只需要保证 的原子性就行,所以只需要锁定被缓存的共享对象即可。所谓缓存锁是指被缓存在处理器中的共享数据,在Lock操作期间被锁定,那么当被修改的共享内存的数据回写到内存时,处理器不在总线上声明LOCK#信号,而是修改内部的内存地址,并通过 机制来保证操作的原子性。
什么是缓存一致性呢?
缓存一致性
所谓缓存一致性,就是多个CPU核心中缓存的同一共享数据的数据一致性,而(MESI)使用比较广泛的缓存一致性协议。MESI协议实际上是表示缓存的四种状态
M(Modify)表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
E(Exclusive)表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
S(Shared)表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
I(Invalid)表示缓存已经失效
每个CPU核心不仅仅知道自己的读写操作,也会监听其他Cache的读写操作 CPU的读取会遵循几个原则
如果缓存的状态是I,那么就从内存中读取,否则直接从缓存读取
如果缓存处于M或者E的CPU 嗅探到其他CPU有读的操作,就把自己的缓存写入到内存,并把自己的状态设置为S
只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M
可见性
CPU高速缓存以及指令重排序都会造成可见性问题,接下来从两个角度来分析
MESI优化带来的可见性问题
前面说过MESI协议,也就是缓存一致性协议。这个协议存在一个问题,就是当CPU0修改当前缓存的共享数据时,需要发送一个消息给其他缓存了相同数据的CPU核心,这个消息传递给其他CPU核心以及收到消息完成各自缓存状态的切换这个过程中,CPU会等待所有缓存响应完成,这样会降低处理器的性能。为了解决这个问题,引入了 存储缓存。
处理器把需要写入到主内存中的值先写入到存储缓存中,然后继续去处理其他指令。当所有的CPU核心返回了失效确认时,数据才会被最终提交。但是这种优化又会带来另外的问题。 如果某个CPU尝试将其他CPU占有的共享数据写入到内存,消息提交给store buffer以后,当前CPU继续做其他事情,而如果后面的指令依赖于这个被写入内存的最新数据(由于store buffer还没有写入到内存),就会产生可见性问题(也就是值还没有更新到内存中,这个时候读取到的共享数据的值是错误的)。
Store Bufferes带来的CPU内存的乱序访问导致的可见性问题
Store Bufferes中的数据何时写入到内存中是不确定的,那么意味着这个过程的执行顺序也是不确定的,比如下面这个例子 exeToCPU0和exeToCPU1分别在两个独立的cpu核心上执行,假如CPU0 缓存了 这个共享变量,并且状态为(E->独占),而value可能是(S共享状态被其他CPU核心修改以后变为I(失效状态)。 这种情况下value的缓存数据变更路径为, value将失效状态需要响应给触发缓存更新的CPU核心,接着该CPU将 写入到内存,这就会导致value会比isFinish更迟的抛弃存储缓存。那么就可能出现CPU1读取到了isFinish的值为true,而value的值不等于10的情况。 这种CPU的内存乱序访问,会带来可见性问题。
从硬件层面很难去知道软件层面上的这种前后依赖关系,所以没有办法通过某种手段自动去解决,因此CPU层面提供了 的指令,从硬件层面来看这个 就是CPU flush store bufferes中的指令。软件层面可以决定在适当的地方来插入内存屏障。
CPU层面的内存屏障
什么是内存屏障?从前面的内容基本能有一个初步的猜想,内存屏障就是将 中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。 X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)
Store Memory Barrier(写屏障)告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
Load Memory Barrier(读屏障)处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
Full Memory Barrier(全屏障)确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作
有了内存屏障以后,对于上面这个例子,我们可以这么来改,从而避免出现可见性问题
总的来说,内存屏障的作用可以通过防止CPU对内存的乱序访问来保证共享数据在多线程并行执行下的可见性
有序性
有序性简单来说就是程序代码执行的顺序是否按照我们编写代码的顺序执行,一般来说,为了提高性能,编译器和处理器会对指令做重排序,重排序分3类
编译器优化重排序,在不改变单线程程序语义的前提下,改变代码的执行顺序
指令集并行的重排序,对于不存在数据依赖的指令,处理器可以改变语句对应指令的执行顺序来充分利用CPU资源
内存系统的重排序,也就是前面说的CPU的内存乱序访问问题
也就是说,我们编写的源代码到最终执行的指令,会经过三种重排序
有序性会带来可见性问题,所以可以通过内存屏障指令来进制特定类型的处理器重排序
从JMM层面解决线程并发问题
从硬件层面的分析了解到原子性、有序性、可见性的本质以后,知道硬件层面针对这三个问题的解决办法,原子性是通过总线锁或缓存锁来实现,而有序性和可见性可以通过内存屏障来解决。那么在软件层面,如何解决原子性、有序性、可见性问题呢?答案就是: 内存模型
硬件层面的原子性、有序性、可见性在不同的CPU架构和操作系统中的实现可能都不一样,而Java语言的特性是 ,意味着JVM层面需要屏蔽底层的差异,因此在JVM规范中定义了JMM。
(JMM内存模型的抽象结构)
JMM属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范,也就是在虚拟机中将共享变量存储到内存以及从内存中取出共享变量的底层细节。 通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。 需要注意的是,JMM并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在JMM中,也会存在缓存一致性问题和指令重排序问题。只是JMM把底层的问题抽象到JVM层面,再基于CPU层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题
Java内存模型定义了线程和内存的交互方式,在JMM抽象模型中,分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。 在JMM中,定义了8个原子操作来实现一个共享变量如何从主内存拷贝到工作内存,以及如何从工作内存同步到主内存,交互如下
8个原子操作指令
lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
顺序一致性
如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。JMM只要求这两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。
JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致,因为如果想要保证执行结果一致,意味着JMM需要进制处理器和编译器的优化,这对于程序的执行性能会产生很大的影响。所以在未同步程序的执行中,由于执行顺序的不确定性导致结果无法预测。我们可以使用同步原语比如 来实现程序的同步操作来保证顺序一致性
假如有两个线程A和B并行执行,A和B线程分别都有3个操作,在程序中的顺序是 , 。 假设这两个程序没有使用同步原语,那么线程并行执行的效果可能是
(此图来自并发编程的艺术)如果这两个程序使用了监视器锁来实现正确同步,那么执行的过程一定是
(此图来自并发编程的艺术)
重排序
CPU层面的内存乱序访问属于重排序的一部分,同时我们还提到了编译器的优化执行的重排序。重排序是一种优化手段,但是在多线程并发中,会导致可见性问题。 编译器的重排序是指,在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序来优化程序的性能. 编译器的重排序和CPU的重排序的原则一样,会遵守数据依赖性原则,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,比如下面的代码,这三种情况在单线程里面如果改变代码的执行顺序,都会导致结果不一致,所以重排序不会对这类的指令做优化,也就是需要满足 语义
as-if-serial语义
as-if-serial语义的意思是不管怎么重排序,单线程程序的执行结果不能被改变,编译器、处理器都必须遵守这个语义
JMM层面的内存屏障
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器的重排序,在JMM中把内存屏障分为四类
屏障的作用这里就不重复再说了,实际上JMM层面的内存屏障就是对CPU层面的内存屏障指令做的包装,作用是通过在合适的位置插入内存屏障来保证可见性
JVM是如何在JMM层面解决原子性、有序性、可见性问题的呢?
相信通过上面的分析,基本上有了答案
原子性:Java中提供了两个高级指令 和 ,也就是对应的synchronized同步锁来保证原子性
可见性:volatile、synchronized、final都可以解决可见性问题
有序性:synchronized和volatile可以保证多线程之间操作的有序性,volatile会禁止指令重排序
03
volatile源码分析
如果你看到这个章节了,意味着你对可见性有一个清晰的认识了,也知道JMM是基于禁止指令重排序来实现可见性的,那么我们再来分析volatile的源码,就会简单很多
基于最开始演示的这段代码作为入口
通过 查看字节码指令
注意被修饰了volatile关键字的 字段,会多一个 的flag,在给 复制的时候,调用的字节码是 ,这个字节码会通过BytecodeInterpreter解释器来执行,找到Hotspot的源码 文件,搜索 指令定位到代码
其他代码不用管,直接看 这段代码,cache是 在常量池缓存中的一个实例,这段代码是判断这个cache是否是被 修饰, 方法的定义在 文件中,代码如下
是判断是否有 这个flag,很显然,通过 修饰的stop的字节码中是存在这个flag的,所以 返回true 接着,根据当前字段的类型来给 赋值,执行 方法赋值,这个方法的实现在 中
赋值的动作被包装了一层,看看 做了什么事情呢?这个方法的定义在 中,具体的实现,根据不同的操作系统和CPU架构,调用不同的实现
以 为例,找到 的实现,代码如下
可以看到其实Java的volatile操作,在JVM实现层面第一步是给予了C++的原语实现。c/c++中的volatile关键字,用来修饰变量,通常用于语言级别的 memory barrier。被volatile声明的变量表示随时可能发生变化,每次使用时,都必须从变量i对应的内存地址读取,编译器对操作该变量的代码不再进行优化
赋值操作完成以后,如果大家仔细看了前面putstatic的代码,就会发现还会执行一个 的代码,这个代码的实现是在 ,它其实就是一个storeload内存屏障,JVM层面的四种内存屏障的定义以及实现
当调用 屏障时,它会调用fence()方法
判断是否是多核,如果是单核,那么就不存在内存不可见或者乱序的问题 :禁止编译器对代码进行某些优化. Lock :汇编指令,lock指令会锁住操作的缓存行(cacheline), 一般用于read-Modify-write的操作;用来保证后续的操作是原子的 cc代表的是寄存器,memory代表是内存;这边同时用了”cc”和”memory”,来通知编译器内存或者寄存器内的内容已经发生了修改,要重新生成加载指令(不可以从缓存寄存器中取) 这边的read/write请求不能越过lock指令进行重排,那么所有带有lock prefix指令(lock ,xchgl等)都会构成一个天然的x86 Mfence(读写屏障),这里用lock指令作为内存屏障,然后利用asm volatile("" ::: "cc,memory")作为编译器屏障. 这里并没有使用x86的内存屏障指令(mfence,lfence,sfence),应该是跟x86的架构有关系,x86处理器是强一致内存模型
storeload屏障是固定调用的方法?为什么要固定调用呢?
原因是:避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率
volatile是通过防止指令重排序来实现多线程对于共享内存的可见性
总结
到目前为止,我们已经算是比较深入的分析了volatile的作用和原理,由于这个领域确实涉及太多内容,所有有些东西可以没有在文章中体现,大家如果有兴趣建议系统再看看.
领取专属 10元无门槛券
私享最新 技术干货