在前面一文中咱们深刻的分享了synchronized的实现原理,也知道了synchronized是一把重量级的锁。在Java中还有一个关键词,那就是volatile。volatile是轻量级的synchronized,它在多线程中保证了变量的“可见性”。可见性的意思是当一个线程修改了一个变量的值后,另外的线程可以读取到这个变量修改后的值。volatile在Java语言规范中的定义以下:java
Java编程语言容许线程访问共享变量,为了确保共享变量可以被准确和一致性的更新,线程应该确保经过排他锁单独获取这个变量。编程
这句话可能说的比较绕,咱们先来看一段代码:数组
public class VolatileTest implements Runnable { private boolean flag = false; @Override public void run() { while (!flag){ } System.out.println("线程结束运行..."); } public void setFlag(boolean flag) { this.flag = flag; } public static void main(String[] args) throws InterruptedException { VolatileTest v = new VolatileTest(); Thread t1 = new Thread(v); t1.start(); Thread.sleep(2000); v.setFlag(true); } }
这段代码的运行结果:缓存
能够看到尽管在代码中调用了v.setFlag(false)方法,线程也没有结束运行。这是由于在上面的代码中,其实是有2个线程在运行,一个是main线程,一个是在main线程中建立的t1线程。所以咱们能够看到在线程中的变量是互不可见的。 要理解线程中变量的可见性,咱们须要先理解Java的内存模型。安全
<font color="#EE30A7">Java内存模型</font>
在Java中,全部的实例域、静态变量和数组元素都存储在堆内存中,堆内存在线程之间是共享的。局部变量,方法定义参数和异常数量参数是存放在Java虚拟机栈上面的。Java虚拟机栈是线程私有的所以不会在线程之间共享,它们不存在内存可见性的问题,也不受内存模型的影响。多线程
Java内存模型(Java Memory Model 简称 JMM),决定一个一个线程对共享变量的写入什么时候对其它线程可见。JMM定义了线程和主内存之间的抽象关系:并发
线程之间共享变量存储在主内存中,每一个线程都有一个私有的本地内存,本地内存中存储了该线程共享变量的副本。本地内存是JMM的一个抽象几率,并不真实的存在。它涵盖了缓存,写缓存区,寄存器以及其余的硬件和编译优化。编程语言
Java内存模型的抽象概念图以下所示:ide
看完了Java内存模型的概念,咱们再来看看内存模型中主内存是如何和线程本地内存之间交互的。性能
<font color="#EE30A7">主内存和本地内存间的交互</font>
主内存和本地内存的交互即一个变量是如何从主内存中拷贝到本地内存又是如何从本地内存中回写到主内存中的实现,Java内存模型提供了8中操做来完成主内存和本地内存之间的交互。它们分别以下:
- <span style="color:red">lock(锁定)</span>:做用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- <span style="color:red">unlock(解锁)</span>:做用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才能被其它线程锁定。
- <span style="color:red">read(读取)</span>:做用于主内存的变量,它把一个变量从主内存传输到线程的本地内存中,以便随后的load动做使用。
- <span style="color:red">load(载入)</span>:做用于本地内存的变量,它把read操做从主内存中的到的变量值放入本地内存的变量副本中。
- <span style="color:red">use(使用)</span>:做用于本地内存的变量,它把本地内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个须要使用到变量值的字节码指令时将会执行这个操做。
- <span style="color:red">assign(赋值)</span>:做用于本地内存的变量,它把一个从执行引擎接收到的变量赋予给本地内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操做。
- <span style="color:red">store(存储)</span>:做用于本地内存的变量,它把本地内存中的变量的值传递给主内存中,以便后面的write操做使用。
- <span style="color:red">write(写入)</span>:做用于主内存的变量,它把store操做从本地内存中获得的变量的值放入主内存的变量中。
从上面8种操做中,咱们能够看出,当一个变量从主内存复制到线程的本地内存中时,须要顺序的执行read和load操做,当一个变量从本地内存同步到主内存中时,须要顺序的执行store和write操做。Java内存模型只要求上述的2组操做是顺序的执行的,但并不要求连续执行。好比对主内存中的变量a 和 b 进行访问时,有可能出现的顺序是read a read b load b load a。除此以外,Java内存模型还规定了在执行上述8种基本操做时必须知足如下规则:
-
不容许read和load,store和write操做单独出现,这2组操做必须是成对的。
-
不容许一个线程丢弃它最近的assign操做。即变量在线程的本地内存中改变后必须同步到主内存中。
-
不容许一个线程无缘由的把数据从线程的本地内存同步到主内存中。
-
不容许线程的本地内存中使用一个未被初始化的变量。
-
一个变量在同一时刻只容许一个线程对其进行lock操做,可是一个线程能够对一个变量进行屡次的lock操做,当线程对同一变量进行了屡次lock操做后须要进行一样次数的unlock操做才能将变量释放。
-
若是一个变量执行了lock操做,则会清空本地内存中变量的拷贝,当须要使用这个变量时须要从新执行read和load操做。
-
若是一个变量没有执行lock操做,那么就不能对这个变量执行unlock操做,一样也不容许unlock一个被其它线程执行了lock操做的变量。也就是说lock 和unlock操做是成对出现的而且是在同一个线程中。
-
对一个变量执行unlock操做以前,必须将这个变量的值同步到主内存中去。
<font color="#EE30A7">volatile 内存语义之可见性</font>
大概了解了Java的内存模型后,咱们再看上面的代码结果咱们将很好理解为何是这样子的了。首先主内存中flag的值是false,在t1线程执行时,依次执行的操做有read、load和use操做,这个时候t1线程的本地内存中flag的值也是false,线程会一直执行。当main线程调用v.setFlag(true)方法时,main线程中的falg被赋值成了true,由于使用了assign操做,所以main线程中本地内存的flag值将同步到主内存中去,这时主内存中的flag的值为true。可是t1线程没有再次执行read 和 load操做,所以t1线程中flag的值任然是false,因此t1线程不会终止运行。想要正确的中止t1线程,只须要在flag变量前加上volatile修饰符便可,由于volatile保证了变量的可见性。既然volatile在各个线程中是一致的,那么volatile是否可以保证在并发状况下的安全呢?答案是否认的,由于volatile不能保证变量的原子性。示例以下:
public class VolatileTest2 implements Runnable { private volatile int i = 0; @Override public void run() { for (int j=0;j<1000;j++) { i++; } } public int getI() { return i; } public static void main(String[] args) throws InterruptedException { VolatileTest2 v2 = new VolatileTest2(); for (int i=0;i<100;i++){ new Thread(v2).start(); } Thread.sleep(5000); System.out.println(v2.getI()); } }
这段代码启动了100线程,每一个线程都对i变量进行1000次的自增操做,若果这段代码可以正确的运行,那么正确的结果应该是100000,可是实际并不是如此,实际运行的结果是少于100000的,这是由于volatile不能保证i++这个操做的原子性。咱们用javap反编译这段代码,截取run()方法的代码片断以下:
public void run(); descriptor: ()V flags: ACC_PUBLIC Code: stack=3, locals=2, args_size=1 0: iconst_0 1: istore_1 2: iload_1 3: sipush 1000 6: if_icmpge 25 9: aload_0 10: dup 11: getfield #2 // Field i:I 14: iconst_1 15: iadd 16: putfield #2 // Field i:I 19: iinc 1, 1 22: goto 2 25: return
咱们发现i++虽然只有一行代码,可是在Class文件中倒是由4条字节码指令组成的。从上面字节码片断,咱们很容易分析出并发失败的缘由:当getfield指令把变量i的值取到操做栈时,volatile关键字保证了i的值在此时的正确性,可是在执行iconst_1和iadd指令时,i的值可能已经被其它的线程改变,此时再执行putfield指令时,就会把一个过时的值回写到主内存中去了。因为volatile只保证了变量的可见性,在不符合如下规则的场景中,咱们任然须要使用锁来保证并发的正确性。
- 运算结果结果并不依赖变量的当前值,或者可以确保只有单一的线程修改了变量的值
- 变量不须要与其余的状态变量共同参与不变约束
<font color="#EE30A7">volatile 内存语义之禁止重排序</font>
在介绍volatile的禁止重排序以前,咱们先来了解下什么是重排序。重排序是指编译器和处理器为了优化程序性能而对指令进行从新排序的一种手段。那么重排序有哪些规则呢?不可能任何代码均可以重排序,若是是这样的话,那么在单线程中,咱们将不能获得明确的知道运行的结果。重排序规则以下:
- 具备数据依赖性操做不能重排序,数据依赖性是指两个操做访问同一个变量,若是一个操做是写操做,那么这两个操做就存在数据依赖性。
- as-if-serial语义,as-if-serial语义的意思是,无论怎么重排序,单线程的程序执行结果是不会改变的。
既然volatile禁止重排序,那是否是重排序对多线程有影响呢?咱们先来看下面的代码示例
public class VolatileTest3 { int a = 0; boolean flag = false; public void write(){ a = 1; // 1 flag = true; // 2 } public void read(){ if(flag){ // 3 int i = a*a; // 4 System.out.println("i的值为:"+i); } } }
此时有2个线程A和B,线程A先执行write()方法,虽有B执行read()方法,在B线程执行到第4步时,i的结果能正确获得吗?结论是 不必定 ,由于步骤1和2没有数据依赖关系,所以编译器和处理器可能对这2个操做进行重排序。一样步骤3和4也没有数据依赖关系,编译器和处理器也能够对这个2个操做进行重排序,咱们来看看这两中重排序带来的效果:
重上面图片,这2组重排序都会破坏多线程的运行结果。了解了重排序的几率和知道了重排序对多线程的影响,咱们知道了volatile为何须要禁止重排序,那JMM究竟是如何实现volatile禁止重排序的呢?下面咱们就来探讨下JMM是如何实现volatile禁止重排序的。
前面提到过,重排序分为编译器重排序和处理器重排序,为了实现volatile内存语义,JMM分别对这两种重排序进行了如今。下图是JMM对编译器重排序指定的volatile规则:
从上面图中咱们能够分析出:
- 当第一个操做为volatile读时,无能第二个操做是什么,都不容许重排序。这个规则确保了volatile读以后的操做不能重排序到volatile读以前。
- 当第二个操做为volatile写时,不管第一个操做是什么,都不容许重排序。这个规则确保了volatile写以前的操做不能重排序到volatile写以后。
- 当第一个操做是volatile写,第二个操做是volatile读时,不容许重排序。
为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型处理器的重排序,在JMM中,内存屏障的插入策略以下:
- <font color="red">在每一个volatile写操做以前插入一个StoreStore屏障</font>
- <font color="red">在每一个volatile写操做以后插入一个StoreLoad屏障</font>
- <font color="red">在每一个volatile读操做以后插入一个LoadLoad屏障</font>
- <font color="red">在每一个volatile读操做以后插入一个LoadStore屏障</font>
StoreStore屏障能够保证在volatile写以前,前面全部的普通读写操做同步到主内存中
StoreLoad屏障能够保证防止前面的volatile写和后面有可能出现的volatile度/写进行重排序
LoadLoad屏障能够保证防止下面的普通读操做和上面的volatile读进行重排序
LoadStore屏障能够保存防止下面的普通写操做和上面的volatile读进行重排序
上面的内存屏障策略能够保证任何程序都能获得正确的volatile内存语义。咱们如下面代码来分析
public class VolatileTest3 { int a = 0; volatile boolean flag = false; public void write(){ a = 1; // 1 flag = true; // 2 } public void read(){ if(flag){ // 3 int i = a*a; // 4 } } }
经过上面的示例咱们分析了volatile指令的内存屏蔽策略,可是这种内存屏障的插入策略是很是保守的,在实际执行时,只要不改变volatile写/读的内存语义,编译器能够根据具体状况来省略没必要要的屏障。以下示例:
class VolatileBarrierExample { int a; volatile int v1 = 1; volatile int v2 = 2; void readAndWrite() { int i = v1; // 第一个volatile读 int j = v2; // 第二个volatile读 a = i + j; // 普通写 v1 = i + 1; // 第一个volatile写 v2 = j * 2; // 第二个 volatile写 } }
上述代码,编译器在生成字节码时,可能作了以下优化