首页 > 学院 > 开发设计 > 正文

Java并发编程系列(一)----深入剖析volatile关键字

2019-11-14 12:30:24
字体:
来源:转载
供稿:网友

从内存模型讲起

这里写图片描述

由于CPU的高速运算,主内存的频率远远达不到要求,因此需要将用到的数据进行缓存,比如一个很简单的操作:

i=i+1;

这样CPU首先会从主内存中将i的值读到CPU的高速缓存(工作内存)中,然后进行加1操作,完事之后将新的值写入到主内存中。有人会想,MDZZ,这有什么好说的,看似简单的一小步,确是并发编程爬坑的一大步。

假如i的值为0,两个线程进行操作,最终的结果一定是2吗?想象一下这样的一种情况:

Thread1在CPU1上读取i的值,此时Thread2在CPU2上也读取了i的值。 Thread1加1操作后写回主内存,而此时Thread2中缓存的还是0,加1后又写回主内存,此时i的值是1而不是2。

这就是多线程下的缓存一致性问题,那么在多线程环境下是如何解决的呢,有两种方法:

总线加锁。 通过缓存一致性协议。

总线加锁是一种独占的方式占用内存,导致加锁的内存同一时间只能被一个cpu访问,比如,Thread1对i=i+1使用了总线锁,从开始执行到结束的过程,其他线程都无法访问该内存,也就不存在一致性问题。

缓存一致性协议的思想是:当CPU往主内存写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

并发编程的三个性质

1. 原子性

所谓的原子性,指的是要么执行成功,要么不执行。这看起来好像又是很简单,举个例子:

i++;

这个是原子操作吗?很多人想当然地认为是,事实上却是三个操作:1.读取i的值。2.将i的值加1。3.写入新的值。如果在多线程的环境下,并发的结果和期望值会不一致,比如有这么一个Counter类,就是一个简单的自增操作:

package com.rancho945.concurrent;public class Counter { public int count = 0; public void increase() { count++; }}

然后开个20线程,每个线程对同一个对象进行一万次自增操作:

package com.rancho945.concurrent;public class Test { PRivate static final int THREAD_COUNT = 20; public static void main(String[] args) { final Counter counter = new Counter(); for (int i = 0; i < THREAD_COUNT; i++) { new Thread(new Runnable() { @Override public void run() { for (int j = 0; j < 50000; j++) { counter.increase(); } } }).start(); } // 等待所有线程执行完毕 while (Thread.activeCount() > 1) { Thread.yield(); } System.out.println(counter.count); }}

每次运行的结果都会有很大的不一样。就是因为每个线程都缓存了副本,造成数据的不一致,原理跟前面分析的i=i+i是一样的。我们看一下Counter类的字节码,在increase方法中,一个自增操作从getfield到putfield包含了四条字节码,并不是原子操作(这里只是假设一每条字节码执行都是原子操作也是不严谨的,每一条字节码指令都有可能分几步执行)

public class com.rancho945.concurrent.Counter { public int count; public com.rancho945.concurrent.Counter(); Code: 0: aload_0 1: invokespecial #10 // Method java/lang/Object."<init>":()V 4: aload_0 5: iconst_0 6: putfield #12 // Field count:I 9: return public void increase(); Code: 0: aload_0 1: dup 2: getfield #12 // Field count:I 5: iconst_1 6: iadd 7: putfield #12 // Field count:I 10: return}

2. 可见性

看一段代码

package com.rancho945.concurrent;public class WorkingTask implements Runnable{ private boolean stop = false; @Override public void run() { while(!stop){ //some task } } public void stopTask(){ stop = true; }}

这个相信是很多人见过的一段代码,假如线程1正在执行任务,线程2调用了stopTask,线程1能被终止吗?绝大多数情况下是可以的,但在极少数情况下停止不了,就会出现迷之bug,原因是当stop变为true时,还没有来得及把值写回主内存中,就去做其他事或者阻塞了,导致线程1进入死循环。这就是所谓的可见性问题。事实上前面原子性的例子里面也有可见性的问题,就是当一个线程完成值的修改,重新写入主内存中后,其他线程缓存了旧值,无法感知到新值的改变。

3.有序性

再看一段代码

package com.rancho945.concurrent;public class Gift { //表示是否完成礼物的制作 private boolean isInit = false; private String gift = ""; public void makeGift(){ System.out.println("正在制作礼物"); gift = "heart"; System.out.println("礼物制作完成"); isInit = true; } public void sendGift(){ while(!isInit){ System.out.println("礼物还没准备好,再等等"); } System.out.println("女神,我有东西想送给你"+gift); }}

这讲的是一个凄美的爱情故事,一个礼物店的老板(线程1)制作礼物(makeGift),当礼物制作好就在礼物盒上打个制作完成的标记(isInit=true);某个屌丝码农(线程2)送礼物给女神(sendGift),当礼物未完成制作时等待,完成后(isInit=true)就送出去。这看起来也没有什么问题,但事实上有一定几率送出的礼物是空的,此时就会被女神呵呵了。

让我们来看看这个程序为什么会出现这样的问题,看makeGift的代码:

照我们的理解,程序是从上到下往下执行的,但是编译器或者虚拟机在执行的时候有可能会对其进行重排序,也就是先执行isInit=true后再去初始化礼物,如果线程1刚执行完isInit= true但还没有完成礼物初始化时候线程2刚好读取到isInit的值,就把礼物送出去了,此时女神将会收到一个空礼物盒。结果就呵呵了。

那么有人就会问,如果是这样到处重排序,世界都乱了,重排序有一个原则,就是在单线程环境下能够保证上下文语义的正确性,看下面的一段代码:

i=10;k=i+8;m=10;

k的值依赖于i的值,因此在执行k=i+8之前必须保证i=10执行完毕。然而m=10可以在i=10之前执行,或者是在k=i+8之前执行,这都不会对其结果产生影响,因此JVM虚拟机可能会对执行顺序进行重排序从而获取更好的性能。那么我们在重新看前面礼物的例子就可以解释为什么会出现礼物没有制作好就会送出去了。(说得好像送礼物给女神就会逆袭一样)

volatile语义

瞎逼逼了半天,终于开始讲volatile了,那么volatile有什么用,它有两层语义:

保证更改的内容会马上写回到主内存中,并且强制该变量在其他线程中的缓存无效,使改变后的变量对其他线程立即可见。 保证在对该变量操作之前的指令执行完毕,也就是禁止重排序。

可见性

如果我们将前面可见性例子中的stop变量使用volatile修饰,那么就可以保证改变的值对其他线程立即可见,从而避免停止任务失败的情况。

有序性

如果用volatile修饰Gift类中的isInit变量,就不会出现礼物为空的情况,因为volatile可以保证在执行volatile变量操作之前所有的操作都已经执行完毕,也就是isInit=true的执行不会被重排序到前面(注:volatile重排序语意在JDK1.5之后才完全修复,意味着在1.5之前仍然不能保证不进行重排序)。

原子性

那么volatile能够保证原子性吗? 很多人觉得既然volatile变量的改变对所有的写操作对其他的线程立即可见,也就是该变量在各个线程中是一致的,那么也就是线程安全的。这个论据是没有错,但是结论是错的。我们把前面原子性的示例代码稍微做一下改动:

package com.rancho945.concurrent;public class Counter { //注意这里改成了volatile public volatile int count = 0; public void increase() { count++; }}

看一下它的字节码:

public void increase(); Code: 0: aload_0 1: dup 2: getfield #12 // Field count:I 5: iconst_1 6: iadd 7: putfield #12 // Field count:I 10: return

volatile能够保证的是getstatic指令加载变量时变量值是最新的,但是在执行iconst_1和iadd指令的时候,变量的值有可能被其他线程改变了,而此时变量的值仍然不是正确的,因此volatile不能保证原子性。

volatile关键字的应用

volatile关键字的应用一般来说遵循两个原则:

变量值不依赖当前状态。 变量不出现在其他表达式中。

第一个原则提现就是前面的i++,因为i++操作依赖当前i的值,所以不能用volatile保证其线程安全。 第二个原则的体现为:

volatile int i;//该表达式依赖了i,不能保证其线程安全k = i+10;

volatile常见的两个应用场景:

标记位

public class WorkingTask implements Runnable{ private volatile boolean stop = false; @Override public void run() { while(!stop){ //some task } } public void stopTask(){ stop = true; }}

这个前面已经说过了,不在赘述。

单例模式的双重检查

class Singleton{ private static Singleton instance = null; private Singleton() { } public static Singleton getInstance() { if(instance==null) { //1 synchronized (Singleton.class) { //2 if(instance==null) //3 instance = new Singleton(); //4 } } return instance; }}

这里的单例有什么问题呢?我们来看一下,假设有这么一个过程:

线程A和线程B都执行到1处。 线程A执行到2,获取到类锁,此时线程B阻塞。 线程A执行到3,发现实例为空,执行到4处。

关键点到了,正常情况下,4处的执行顺序为:

为对象申请内存。 实例化对象。 将对象的内存引用赋给instance。

由于可能进行了重排序,4处的执行顺序为:

为对象申请内存。 将对象的内存引用赋给instance。 实例化对象。

如果进行了重排序,那么在将对象内存赋给instance后(此时对象尚未初始化完成),线程A退出了同步块,线程B进入了3,发现instance不为null,直接将没有示例化完成的对象返回,导致获取的实例结果并不正确。 因此,在单例的引用使用volatile变量修饰,可以保证执行4的时候不被重排序,从而保证单例构造的正确性。

class Singleton{ //注意这里使用了volatile修饰 private volatile static Singleton instance = null; private Singleton() { } public static Singleton getInstance() { if(instance==null) { //1 synchronized (Singleton.class) { //2 if(instance==null) //3 instance = new Singleton(); //4 } } return instance; }}

题外话

懒汉模式的另外一种优雅的实现方式

public class Singleton { private Singleton() {} public static class Holder { static Singleton instance = new Singleton(); } public static Singleton getInstance() { return Holder.instance; } }

该方法通过内部类的静态变量来实现,由内部类的加载和初始化来保证线程安全。

参考资料

《深入理解Java虚拟机》 周志明 著 《Java并发编程的艺术》 方腾飞 著 《Java并发编程实战》 Brian Goetz等著 童云兰等译 单例模式与双重检测 http://www.VEvb.com/topic/652440 Java内存模型 http://ifeve.com/java-memory-model-6/
发表评论 共有条评论
用户名: 密码:
验证码: 匿名发表