多线程-volatile是如何实现可见性的

转载自: https://blog.csdn.net/hxcaifly/article/details/88093099

可见性定义

通俗来说: 可见性是指当一个线程修改了共享变量的值,其他线程能够立即感知这个修改。

在JAVA规范中是这样定义的:java编程语言允许多个线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。通俗的讲就是如果有一个共享变量N,当有两个线程T1、T2同时获取了N的值,T1修改N的值,而T2读取N的值。那么可见性规范要求T2读取到的必须是T1修改后的值,而不能在T2读取旧值后T1修改为新值。volatile关键字修饰的共享变量可以提供这种可见性规范,也叫做读写可见。那么底层实现是通过什么机制保证volatile变量读写可见的?

Java内存模型通常在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值,依赖主内存作为传递媒介的方式实现可见性的。无论是普通变量还是volatile变量都是如此,普通变量和volatile变量的唯一区别就是:volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

Volatile的实现机制

在说这个问题之前,我们先看看CPU是如何执行java代码的。

首先编译之后Java代码会被编译成字节码.class文件,在运行时会被加载到JVM中,JVM会将.class转换为具体的CPU执行指令,CPU加载这些指令逐条执行。

以多核CPU为例(两核),CPU的速度比内存要快得多,为了弥补这个性能差异,CPU内核都会有自己的高速缓存区,当内核运行的线程执行一段代码时,首先将这段代码的指令集进行缓存行填充到高速缓存, 若是变量被CPU修改后,会将修改的值刷新到高速缓存,然后在刷新到内存中。此时若另一个内核使用这个变量,读取到这个变量依然是旧的。

volatile关键字就是用来解决这个问题的:当一个线程写入该值后,另一个线程读取的必定是新值。

volatile保证了修饰的共享变量在转换为汇编语言时,会加上一个以lock为前缀的指令,当CPU发现这个指令时,立即会做两件事情:

  1. 将当前内核高速缓存行的数据立刻回写到内存;
  2. 使在其他内核里缓存了该内存地址的数据无效。

第一步很好理解,第二步如何做到呢?

MESI协议:在早期的CPU中,是通过在总线加LOCK#锁的方式实现的,但这种方式开销太大,所以Intel开发了缓存一致性协议,也就是MESI协议,该解决缓存一致性的思路是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,那么他会发出信号通知其他CPU将该变量的缓存行设置为无效状态。当其他CPU使用这个变量时,首先会去嗅探是否有对该变量更改的信号,当发现这个变量的缓存行已经无效时,会从新从内存中读取这个变量。

volatile为什么没有原子性

volatile保证了读写一致性。但是当线程2已经使用旧值完成了运算指令,且将要回写到内存时,是不能保证原子性的。

volatile防止指令重排

普通变量仅仅会保证在该方法执行过程中所有依赖赋值结果的地方都能得到正确的结果,而不能保证变量赋值操作的顺序域代码中的执行顺序一致。

被volatile修饰的变量,会加一个lock前缀的汇编指令。若变量被修改后,会立刻将变量由工作内存回写到主存中。那么意味了之前的操作已经执行完毕。这就是内存屏障。

使用volatile的好处

从底层实现原理我们可以发现,volatile是一种非锁机制,这种机制可以避免锁机制引起的线程上下文切换和调度问题。因此,volatile的执行成本比synchronized更低。

volatile的不足

使用volatile关键字,可以保证可见性,但是却不能保证原子操作,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class TestVolatile {
public volatile int inc = 0;
public void increase() {
inc++;
}

public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}

while(Thread.activeCount()>1)
Thread.yield();
System.out.println(test.inc);
}
}

这里我们用10个线程,每个线程+1000,预期应该是10000,实际上编译执行这段代码,输出值都会小于10000。为什么会这样?因为,自增操作并不是原子操作,它含括读,加1,写入工作内存三步操作。这三步是分开操作的,当inc的值为5,thead1执行自增操作,当thread1读到5之后,还没有来得及写入就被阻塞了,那么thead2读取的依然是原值。所以volatile的非锁机制只能保证修饰的变量的可见性,而当对变量进行非原子操作时,volatile就无法保证了。这种时候就需要使用synchronzied或lock。

额外

JMM中lock操作要求值从主内存读取,unlock操作要求值必须写回到主内存,synchronized的底层就是这两指令的实现,所以可以保证可见性,至于原子性则是因为JVM规范中要求monitor这块儿必须是单线程的。volatile的话则完全是jmm规范要求,底层基于硬件Lock指令完成的,该指令要求值必须刷回内存同时让其他cpu cache无效,这样就实现了可见性,同时又保证对应值的赋值操作的先后顺序不可被cpu指令重排序干扰,所以看起来像是禁止了指令重排序,实际指令重排序还是存在的,只不过操作到volatile修饰的值的时候硬件保证其前面的操作都一定完成了而已。

0%