多线程与高并发(四)volatile关键字
上一篇学习了synchronized的关键字,synchronized是阻塞式同步,在线程竞争激烈的情况下会升级为重量级锁,而volatile是一个轻量级的同步机制。
前面学习了Java的内存模型,知道各个线程会将共享变量从主内存中拷贝到工作内存,然后执行引擎会基于工作内存中的数据进行操作处理。一个CPU中的线程读取主存数据到CPU缓存,然后对共享对象做了更改,但CPU缓存中的更改后的对象还没有flush到主存,此时线程对共享对象的更改对其它CPU中的线程是不可见的。
而volatile修饰的变量给java虚拟机特殊的约定,线程对volatile变量的修改会立刻被其他线程所感知,即不会出现数据脏读的现象,从而保证数据的“可见性”。
我们可以先简单的理解:被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。
一、
1.1 原子性
原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败。即多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
看下面几行代码:
int a = 10; //语句1 a++; //语句2 int b=a; //语句3 a = a+1; //语句4
上面的4行代码中,只有语句1才是原子操作。
语句1直接将数值10赋值给a,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
语句2实际上包含了三个操作:1. 读取变量a的值;2:对a进行加一的操作;3.将计算后的值再赋值给变量a。
语句3包含两个操作:1:读取a的值;2:再将a的值写入工作内存。
语句4与语句2类似,也是三个操作。
从这里可以看出,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
1.2 有序性
有序性是指程序执行的顺序按照代码的先后顺序执行。
Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
前面线程安全篇中学习过happens-before原则,可以去前篇看看。
1.3 可见性
可见性是指当一个线程修改了共享变量后,其他线程能够立即得知这个修改。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
synchronized能够保证任一时刻只有一个线程执行该代码块,并且在释放锁之前会将对变量的修改刷新到主存当中,那么自然就不存在原子性和可见性问题了,线程的有序性当然也可以保证。
下面我们来看看volatile关键字。
二、volatile的使用
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
-
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
-
禁止进行指令重排序。
2.1 可见性
先看下面的代码:
public class VolatileTest { private static boolean isOver = false; private static int a = 1; public static void main(String[] args) { Thread thread = new Thread(new Runnable() { @Override public void run() { while (!isOver) { a++; } } }); thread.start(); try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } isOver = true; } }
这里的代码会出现死循环,原因在于虽然在主线程中改变了isOver的值,但是这个值的改变对于我们新开线程中并不可见,在线程的本地内存未被修改,所以就会出现死循环。
如果我们用volatile关键字来修饰变量,则不会出现此情形
private static volatile boolean isOver = false;
这说明volatile关键字实现了可见性。
2.2 有序性
再看下面代码:
public class Singleton { private volatile static Singleton instance; private Singleton() { } public Singleton getInstance() { if (instance == null) {//步骤1 synchronized (Singleton.class) {//步骤2 if (instance == null) {//步骤3 instance = new Singleton();//步骤4 } } } return instance; } }
这个是大家很熟悉的单例模式double check,在这里看到使用了volatile字修饰,如果不使用的话,这里可能会出现重排序的情况。
因为instance = new Singleton()这条语句实际上包含了三个操作:
1.分配对象的内存空间;
2.初始化对象;
3.设置instance指向刚分配的内存地址。 步骤2和步骤3可能会被重排序,流程变为1->3->2
如果2和3进行了重排序的话,线程B进行判断if(instance==null)时就会为true,而实际上这个instance并没有初始化成功,将会读取到一个没有初始化完成的对象。
用volatile修饰的话就可以禁止2和3操作重排序,从而避免这种情况。volatile包含禁止指令重排序的语义,其具有有序性。
2.3 原子性
public class VolatileExample { private static volatile int counter = 0; public static void main(String[] args) { for (int i = 0; i < 10; i++) { Thread thread = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 10000; i++) counter++; } }); thread.start(); } try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(counter); } }
启10个线程,每个线程都自加10000次,如果不出现线程安全的问题最终的结果应该就是:10*10000 = 100000;可是运行多次都是小于100000的结果,问题在于 volatile并不能保证原子性,counter++这并不是一个原子操作,包含了三个步骤:1.读取变量counter的值;2.对counter加一;3.将新值赋值给变量counter。如果线程A读取counter到工作内存后,其他线程对这个值已经做了自增操作后,那么线程A的这个值自然而然就是一个过期的值,因此,总结果必然会是小于100000的。
如果让volatile保证原子性,必须符合以下两条规则:
-
运算结果并不依赖于变量的当前值,或者能够确保只有一个线程修改变量的值;
-
变量不需要与其他的状态变量共同参与不变约束
三、实现原理
上面看到了volatile的使用,volatile能够保证可见性和有序性,那它的实现原理是什么呢?
在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令,Lock前缀的指令在多核处理器下会引发了两件事情:
-
将当前处理器缓存行的数据写回到系统内存。
-
这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。volatile的实现原则:
-
Lock前缀的指令会引起处理器缓存写回内存;
-
一个处理器的缓存回写到内存会导致其他处理器的缓存失效;
-
当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
3.1 内存语义
理解了volatile关键字的大体实现原理,那对内volatile的内存语义也相对好理解,看下面的代码:
public class VolatileExample2 { private int a = 0; private boolean flag = false; public void writer() { a = 1; flag = true; } public void reader() { if (flag) { int i = a; } } }
假设线程A先执行writer方法,线程B随后执行reader方法,初始时线程的本地内存中flag和a都是初始状态,下图是线程A执行volatile写后的状态图。
如果添加了volatile变量写后,线程中本地内存中共享变量就会置为失效的状态,因此线程B再需要读取从主内存中去读取该变量的最新值。下图就展示了线程B读取同一个volatile变量的内存变化示意图。
对volatile写和volatile读的内存语义做个总结。
-
线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程 发出了(其对共享变量所做修改的)消息。
-
线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile
-
线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过 主内存向线程B发送消息。
3.2 内存语义的实现
我们知道,JMM是允许编译器和处理器对指令序列进行重排序的,但我们也可以用一些特殊的方式组织指令阻止指令重排序,这个方式就是增加内存屏障。我们先来简答了解下内存屏障,JMM把内存屏障指令分为4类:
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
了解完内存屏障后,我们再来看下volatile的重排序规则:
-
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
-
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
-
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
要实现volatile的重排序规则,需要来增加一些内存屏障,为了保证在任意处理器平台都可以实现,内存屏障插入策略非常保守,主要做法如下:
-
在每个volatile写操作的前面插入一个StoreStore屏障。
-
在每个volatile写操作的后面插入一个StoreLoad屏障。
-
在每个volatile读操作的后面插入一个LoadLoad屏障。
-
在每个volatile读操作的后面插入一个LoadStore屏障。
需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障
StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;
StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序
LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序
LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序
volatile写插入内存屏障后生成的指令序列示意图:
volatile读插入内存屏障后生成的指令序列示意图: