多线程并发 之 volatile

前言

相信大家谈及到 Java 多线程的时候,对volatile一定不会陌生。这篇我就来分享一下我对volatile的了解。

两个优化点

再解释volatile之前,我觉得需要先从操作系统的两个点手解释了。分别是指令重排序和高速缓存问题。

指令重排序

指令重排序简单来讲,就是以下的程序可能并不会像你想象的一样顺序执行下来,2可能会比1先执行掉。但是不会改变存在数据依赖关系的两个操作的执行顺序,如3就不会在1,2之前执行。

1
2
3
int a = 1; //1
int b = 1; //2
int c = a + b; //3

重排序有分为三种,这里简单介绍两种重排序。编译器优化的重排序、指令级并行的重排序

  1. 编译器优化的重排序

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。可以说编译重排序通过分析,然后刻意修改了执行顺序使得执行效率得以提高。

  1. 指令级并行的重排序

首先要明白指令级并行的重排序并不是刻意去打乱执行顺序的,它总是顺序去取指令(虽然可能在编译的时候已经被打乱了),但是指令执行时的各种条件,指令与指令之间的相互影响,可能导致顺序放入流水线的指令,最终乱序执行完成。这就是所谓的“顺序流入,乱序流出”。举个例子:假设操作系统有两个加法器,一个除法器,执行以下命令:

1
2
3
a = a + 1;   //1
b = b / 0.1; //2
c = c + 1; //3

那么很有可能就会因为两个加法器可以同时执行1,3两条命令,又因为种种原因导致除法器执行速度慢,所以就导致3会先与2被执行。

一句话概述就是在单线程中指令会被打乱执行,但是结果和每一条命令顺序执行的结果是一样的。特意指定了单线程也就是说在多线程下,指令重排序会导致最终的结果预料不到。这个在后文解释。

高速缓存

操作系统优化的另外一个点就是高速缓存。从下图中可以知道CPU执行命令的时候,与主存之间存在速度上的差异,所以引入了高速缓存来弥补CPU等待低效率的主存。

你想输入的替代文字

同样举个例子:

1
2
3
for(i=0; i<100; i++){
a = a + 1;
}

如果没有高速缓存那么每一次 a+1 CPU要用到a变量时,会先将a从主存获取到寄存器上,执行完毕再写回到主存。效率很明显比较低效。

如果这时候引入了缓存,那么先把主存中的数据读入到缓存中,寄存器和缓存直接的读写入会高效很多,执行完毕把数据从缓存刷到主存上

两个点在多线程下出现的问题

上述两个优化点在单线程是没什么问题的,但是在多线程都会出现难以预料的问题,先看使用高速缓存在多线程带来的影响。

可见性问题

虽然利用缓存是提高了执行效率,但是也带来了一个新的问题,就是可见性问题。在多线程情况下,如果一个变量被两个线程共用就有可能出现问题。

1
2
3
4
5
6
//线程1执行的代码
int i = 0;
i = 10;

//线程2执行的代码
j = i;

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.

这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值

重排序的问题

虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:

1
2
3
4
5
6
7
8
9
//线程1:
context = loadContext(); //语句1
inited = true; //语句2

//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

如何解决上述问题

对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

对于重排序问题,在Java里面,可以通过volatile关键字来保证一定的“有序性”,简单来说就是通过内存屏障使得指令在重排序时不会将后面的代码排到内存屏障之前,不会将前面的代码排到内存屏障之后。对于内存屏障的认识还不够深,所以这里不细将内存屏障。

volatile解决不了原子性问题

volatile只能解决可见性,不能保证原子性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Test {

private static int a = 0;
private static Test lock = new Test();
public static void main(String args[]) throws Exception{

Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
a++;
}
}
});

Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
a++;
}
}
});

thread.start();
thread1.start();

thread.join();
thread1.join();
System.out.println(a);
}

}

如果这个时候两个线程同时对a(初始值0)进行加100次,两个线程执行完毕会发现最后a的值是小于20000的,这是因为a++不是一个原子操作,就算你使用了volatile也不能保证结果是20000!

你想输入的替代文字

看上图,我们知道虽然volatile保证线程取值都是最新的值,但是在你不能保证一个线程在写回a的时候,另外一个线程刚好也是写回的,就会导致一个线程的一次加被覆盖无效了。