Java 并发编程基础 --volatile

用户头像
Java收录阁
关注
发布于: 2020 年 05 月 06 日

在多线程并发编程中synchronized和volatile都扮演着重要角色,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的"可见性"。如果volatile修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。



初识volatile

下面这段代码演示了一个使用volatile以及没有使用volatile关键字,对于变量结果的影响:

无volatile修饰符:

public class VolatileDemo {
public static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
}
}, "VolatileDemoThread") ;
thread.start();
System.out.println("start Thread");
TimeUnit.SECONDS.sleep(1);
stop = true;
}
}



运行结果:



使用volatile修饰符:

public class VolatileDemo {
public volatile static boolean stop = false;
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (!stop) {
i++;
}
}, "VolatileDemoThread") ;
thread.start();
System.out.println("start Thread");
TimeUnit.SECONDS.sleep(1);
stop = true;
}
}



运行结果:



由上面可以看到,在没有使用volatile修饰符的情况下,即使我们将stop改成true,线程也并没有终止,而是一直在运行。当我们使用了volatile修饰符的时候,线程很快就终止了。由此可以看出,volatile可以使得在多处理器环境下保证了共享变量的可见性。



到底什么是可见性呢?我们可以思考一个问题,在单线程环境下,如果向一个变量写入一个值,然后在没有写干涉的情况下读取这个变量的值,那这个时候读取到的这个变量的值应该是之前写入的那个值。这本来是一个很正常的事情,但是在多线程环境下,读和写操作发生在不同线程中的时候,可能会出现读线程不能及时读取到其它线程写入的最新的值。这就是所谓的可见性问题,可见性的意思是当一个线程修改一个共享变量时,另外的线程能够读取到这个修改的值。为了实现跨线程写入的内存可见性,必须使用到一些机制来实现,而volatile就是这样一种机制。



volatile关键字如何保证可见性

我们通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作时,CPU做了什么:

Java代码如下:

instance = new Singleton();



转换成汇编指令:

0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);



在输出结果中我们可以发现,成员变量带有volatile时,会多一条lock指令。lock是一种控制指令,在多处理器环境下,lock会引发下面两件事:

  1. 将当前处理器缓存行中的数据写回到系统内存

  2. 这个写回内存的操作会使在其它CPU里缓存了该内存地址的数据无效



为了更好的理解可见性的本质,我们需要从硬件层面进行梳理。

一台计算机中最核心的组件是CPU、内存以及I/O设备。在整个计算机的发展历程中,除了CPU、内存以及I/O设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者在处理速度上的差异。CPU的计算速度是非常快的,内存次之,最后是I/O设备。而在绝大部分程序中,一定会存在内存访问,有可能有些还会存在I/O设备的访问。

为了提升性能,CPU从单核升级到了多核甚至用到了超线程技术来最大化提高CPU的处理性能,但是仅仅提升CPU性能还不够,如果后面两者的处理性能没有跟上,意味着整体的计算效率取决于最慢的设备。为了平衡三者的速度差异,最大化的利用CPU提升性能,从硬件、操作系统、编译器等方面都做了很多优化:

  1. CPU增加了高速缓存

  2. 操作系统增加了进程、线程。通过CPU的时间片切换最大化的提升CPU的使用率

  3. 编译器的指令优化,更合理的利用好CPU告诉缓存



然而,每一种优化都会带来相应的问题,这些问题也是导致线程安全性问题的根源。为了了解前面提到的可见性问题的本质,我们必须要了解这些优化的过程。



CPU高速缓存

线程是CPU调度的最小单元,线程设计的目的仍然是更充分的利用计算机处理的效能,但是绝大部分的运算任务不能只依靠处理器就能完成,处理器还需要于内存进行交互,比如读取运算数据、存储运算结果,这些I/O操作很难消除的。而由于计算机存储设备与处理器的运算速度差距很大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中, 让运算能快速的进行,当运算结束后在从缓存同步到内存中。



通过高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更好的复杂度,因为它引入了一个新的问题:缓存一致性



什么叫缓存一致性呢?

首先,有了高速缓存的存在以后,每个CPU的处理过程是先将计算需要用到的数据缓存到高速缓存中,在CPU进行计算时,直接从高速缓存中读取数据并且在计算完成后写到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。

由于在多CPU环境中,每个线程可能运行在不同的CPU内,并且每个线程拥有自己的高速缓存数据,同一份数据可能会缓存到多个CPU中,如果在不同CPU中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题。



为了解决缓存不一致的问题,在CPU层面做了很多事情,主要提供两种解决方案:

  1. 总线锁

  2. 缓存锁



总线锁:简单来说就是在多CPU环境下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问共享内存中的数据,总线锁把CPU和内存之间的通信给锁住了,这使得锁定期间其他处理器不能操作其他内存地址的数据,所以总线锁开销比较大,这种机制显然不是最合适的。



最好的方式就是控制锁的保护力度,我们只需要保证对于被多个CPU缓存的同一份数据是一致的就行,所以引入了缓存锁,它的核心机制是基于缓存一致性协议来实现的。



缓存一致性协议:为了达到数据访问的一致,需要各个处理器在访问缓存数据时遵守一些协议,在读写时根据协议来操作,常见的协议有MSI、MESI、MOSI等。最常见的就是MESI协议,MESI表示缓存行的四种状态,分别是:

  1. M (Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存中的数据和主内存中的数据不一致

  2. E (Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改

  3. S (Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致

  4. I (Invalid) 表示缓存已经失效



在MESI协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听其它Cache的读写操作。

对于MESI协议,从CPU的角度来说会遵循以下原则:

CPU读请求:缓存处于M、E、S状态都可以被读取,I状态CPU只能从主内存中读取数据

CPU写请求:缓存处于M、E状态才可以被写;对于S状态的写,需要将其它CPU中缓存行设置成无效后才可以写



使用总线锁和缓存锁机制之后,CPU对于内存的操作大概可以抽象成下面这个结构,从而达到缓存一致性效果:



由于CPU高速缓存的出现使得如果多个CPU同时缓存了相同的共享数据时,可能存在可见性问题。也就是CPU0修改了自己本地缓存的值对于CPU1不可见。不可见导致的后果是CPU1后续在对该数据进行写入操作时,使用的是脏数据,使得最终结果不可预测。



可能我们想用代码模拟一下可见性的问题,实际上这种情况很难模拟,因为我们无法让某个线程在某个指定的CPU中运行,这是系统底层的算法,JVM也无法控制;还有最重要的一点,就是我们无法预测CPU缓存什么时候会把值同步回主内存,这个时间可能非常短,短到你无法感知;最后就是线程的执行顺序问题,你无法控制哪个线程的某句代码会在另外一个线程的某句代码后面马上执行,所以我们只能基于它的原理去理解这样一个存在的客观事实。



但是我们可能有个疑问,不是说基于缓存一致性协议或者总线锁可以达到缓存一致性的要求吗?为什么还要加volatile关键字?

MESI协议虽然可以实现缓存一致性,但是也会存在一些问题,就是各个CPU缓存行的状态是通过消息传递来进行的,如果CPU0要对一个在缓存中的共享变量进行写入,首先需要发送一个失效的消息给其它缓存了该数据的CPU,并且要等到他们的确认回执,CPU0在这段时间都会处于阻塞状态。为了避免阻塞带来的资源浪费,在CPU中引入了Store Buffers。CPU0只需要在写入共享数据时,直接把数据写入到store buffers中,同时发送invalidate消息,然后继续去处理其他指令。当收到其他所有CPU发送了invalidate acknowledge消息时,再将store buffers中的数据存储至cache line中,最后再从缓存行同步到主内存。



但是,这种优化存在两个问题:

  1. 数据什么时候提交是不确定的,因为等待其他CPU给回复才会进行数据行同步,这里其实是一个异步操作

  2. 引入了store buffer后,处理器会优先从store buffer中获取值,如果store buffer中有数据,则直接从store buffer中获取,否则再从缓存行获取



我们看一下例子:

value = 3;
isFinish = false;
void exeToCpu0() {
value = 10;
isFinish = true;
}
void exeToCpu1() {
if (isFinish) {
assert value == 10;
}
}



exeToCpu0和exeToCpu1分别在两个独立的CPU中执行,假如CPU0中的缓存行中缓存了isFinish这个共享变量,并且状态为E,而Value可能是S状态。那么在CPU0执行的时候,会先把value=10的指令写入到store buffer中,并且通知其他缓存了该value变量的CPU,在等待其他CPU通知结果的时候,CPU0会执行isFinish=true这个指令。而因为当前CPU0缓存了isFinish并且状态是E,所以可以直接修改isFinish=true,这个时候CPU1发起了read操作去读取isFinish的值可能为true,但value不为10。



这种情况我们可以认为是CPU的乱序执行,也可以认为是一种指令重排序,而这种重排序会带来可见性问题。所以在CPU层面提供了memory barrier(内存屏障)的指令,从硬件层面看这个memory barrier就是CPU flush store buffer中的指令。



什么是内存屏障

内存屏障就是将store buffer中的指令写入到内存,从而使得其他访问统一共享内存的线程可见。X86的memory barrier指令包括Ifence(读屏障), Sfence(写屏障),mfence(全屏障)。

写屏障告诉处理器在写屏障之前的所有已经存储在存储缓存(store buffer)中的数据同步到主内存,简单来说就是使得写屏障之前的指令对写屏障之后的读或者写是可见的

读屏障告诉处理器在读屏障之后的操作都在读屏障之后执行,配合写屏障,使得写屏障之前的所有内存更新对于读屏障之后的读操作可见

全屏障确保屏障前内存读写操作的结果提交到内存之后,再执行屏障后的读写操作



有了内存屏障之后,我们可以这么修改上面的例子去避免可见性问题:

value = 3;
isFinish = false;
void exeToCpu0() {
value = 10;
storeMemoryBarrier();
isFinish = true;
}
void exeToCpu1() {
if (isFinish) {
loadMemoryBarrier();
assert value == 10;
}
}



总的来说,内存屏障作用可以通过防止CPU对内存的乱序来保证共享数据在多线程环境下的可见性。现在回到我们最开始提到的volatile关键字的字节码,这个关键字会生成一个Lock的汇编指令,这个指令就相当于实现了一种内存屏障,这样就可以确保被volatile关键字修饰的共享变量对所有CPU可见。



总结 & 提醒

最后给大家提一嘴,volatile主要作用是保证可见性以及有序性,但是volatile是不能保证原子性的!

也就是说,volatile主要解决的是一个线程修改变量值之后,其他线程立马可以读到最新的值,是解决这个问题的,也就是可见性!但是如果是多个线程同时修改一个变量的值,那还是可能出现多线程并发的安全问题,导致数据值修改错乱,volatile是不负责解决这个问题的,也就是不负责解决原子性问题!原子性问题,得依赖synchronized、ReentrantLock等加锁机制来解决。



发布于: 2020 年 05 月 06 日 阅读数: 1037
用户头像

Java收录阁

关注

士不可以不弘毅,任重而道远 2020.04.30 加入

喜欢收集整理Java相关技术文档的程序员,欢迎关注同名微信公众号 Java收录 阁获取更多文章

评论 (2 条评论)

发布
用户头像
X86_64 架构来说,读读、读写以及写写内存屏障是空操作 ( no-op ),只有写读内存屏障会被替换成具体指令。volatile 写操作后的写读内存屏障会被具体的指令替代 ( HotSpot 使用 `lock add DWORD PTR [rsp], 0x0 ` 而非 ` mfence[3]` )。
2020 年 05 月 09 日 16:16
回复
用户头像
可以理解为多核cpu的特性
2020 年 05 月 07 日 22:34
回复
没有更多了
Java并发编程基础--volatile