Java多线程之volatile的那些事


本篇笔记主要梳理总结一下volatite的学习、应用心得,共享出来供大家一块学习、进步。内容叙述比较随便,请大家见谅!

一场由并发"引发"的学案
       有个对陈凯歌电影《无极》的调侃,说是一个馒头引发了一场"血案"。看似调侃,但却真实地反映了人类需求和供给之间矛盾严重不平衡时所埋下的灾难,尤其在幼小的心灵中。馒头只有一个,有很多人需要,而占有它的人(不管当下需要不需要)又不让给他人,这必然会引发"血案",尤其和这个馒头相关的这些人又非善类。
       馒头是无辜的,并且还是好东西。如果馒头资源足够丰富,以至于无穷多,"血案"发生的概率会低很多。我们向往共产主义社会,且不用从理论上深究我们老百姓理解的正确与否。在这样的社会中,馒头(也可能是比萨等食物)极大丰富,还会出现馒头引发"血案"的事吗?应该概率很小了,比中双色球的概率应该还小。但这样的社会就像高数中的极限,可以无限接近,但是就是不能达到。不论怎么无限接近,最终的资源还有有限的。即使资源已经接近极大丰富,但是如果还是被部分人所劫持,"血案"还是可能发生。
       社会政治的比喻说到此,我想大家都已懂得。我们的系统就是一个二进制社会,社会就是一个二元系统。软件系统的中CPU、内存、磁盘、网络带宽等等一切系统资源虽说比8086年代已经丰富很多,然而对于当下各种应用的诉求还是倍感不足。另外,就像人类二元系统,即使各种资源已经极大丰富,但是处理不慎,也会引发应用之间的"血案"。这使的我们不的不依助并发提高资源的使用率,满足各种应用的各种诉求,但同时还必须管理好这些并发,以防引发"血案"。
       有些人向往自然经济的农耕社会,因为那些年代,大家自给自己。但先不问能否真的自给自己,试问历史的车轮还允许退回去吗?既然如此,我们就必须面对当前这个世界,老想着过去就是懒惰,不求上进。
       IT技术日新月异,我们必须紧跟时代脉息,在这个大数据、云计算的时代,弄懂那些藏在背后的核心技术-并发、分布式,从某种意义上说,分布式也是为了并发,能更好地并发。
       本来这段的标题是一场由并发"引发"的血案,但因输入法输入错误,变成了学案。后来一想,学案背景更合适。
       学案者,教师根据学生的认知水平,知识经验,为指导学生进行主动的知识建构而编制的学习方案。我是学生,所以我要根据自己的知识水平,为自己准备学案,也请其他老师帮忙修改。
       一场由并发"引发"的学案就此拉开序幕。
        我们的系统为什么需要并发,或者说多进程、多线程这些技术的支持,当然是为了提高资源利用率、系统响应、系统吞吐量。并发任务越是孤立,越是能提高资源利用率、系统响应、系统吞吐量,但是这样的任务所占比率比较少,更多的并发任务之间存在共享、竞争。此外为了提供资源利用率,我们也尽可能提高资源共享。
对象共享

       在单线程的应用,我们不用考虑共享,因为不会发生共享。但在多线程中,我们不得不考虑共享。共享是并发的第一步,也是很重要的一步。如何提供共享,直接决定了并发的效果和效率。
       在多线程应用中,当获得一个共享对象的引用时,我们必须意识到我们打算怎么应用它;在发布一个共享对象时,我们也必须意识允许其他线程怎么使用它。
       常用的安全的共享方式,按照推荐使用方式顺序如下:
       1)线程绑定
       这是共享的退化形式,也是非常有效的方式。正因为共享容易引发线程问题,那么我们可以让某些对象被一个线程独自拥有,这样这些对象就安全了,因为操作这些的独享只有这一个线程而已。
      2)只读共享
      把对象的更改局限在一个线程里,而读取共享出来。这样可以很好地保持共享安全。只读共享包括不变对象。
      3)线程安全共享
      使用系统(JDK)或者已有的线程安全对象,因为这些对象内部很好地实现了并发访问控制,在多个线程操作下不用引入额外的同步便可达到安全共享。
      4)锁保持
      通过锁(synchronized或lock)控制多线程的访问。这是最后一种办法,轻易不要使用。但是我们现在有很多同学,却把这种方式方式当成首选和唯一方式。悲哀!
volatile与JMM

      synchronized是一种重量级同步机制,volatile是一种轻量级、弱弱同步机制。在进一步描述volatitle之前我们先了解一下JMM(Java Memory Model).

      Java线程之间的通信由JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见,定义了线程和主内存之间的抽象关系。共享的变量存储在主内存上,而每一个线程处理器都有自己的Cache(多级),为了提供线程处理效率,共享变量一般都会在自己的cache中保留一份副本,然后按照一定策略和主内存同步。Java内存模型的抽象示意图如下:

Java多线程之volatile的那些事
    一般我们申明变量方式,如下代码片段:
    private boolean flag = true;
 
    public setFlag(boolean flag){
        this.flag = flag;
     }
    public void execute(){
        while(flag){//等待flag变成false退出
            dosomething();//
        }
    }
      如果线程A执行execute while循环里内容,等待其他线程修改flag为false退出循环。此时假设线程B执行了setFlag(false),试问,线程A是否会退出while循环?答案是:不一定。这和我们期望有出入,怎么回事?看了上面的JMM简单描述,我想你可能会明白了。
      那如果把setFlag/execute申明为synchronized方法,结果又是什么?结果线程A会在处理完dosomething之后退出循环。但synchronized是互斥锁,这样在同时只能有一个线程执行execute方法,这势必会降低系统的吞吐量,还有更好的方法吗?如果仅考虑变量线程的可见性,那就是采用volatile,把flag申明为:
      private volatile boolean flag = true;
      volatile确保对申明的变量修改会预知地传递给其他线程。当一个变量被申明为volatile时,编译器和运行时会知道这个变量被共享,所以这个变量不应该在寄存器或工作内存中缓存,而应该直接存储在主内存中,这样一个线程对它修改后,其他线程读取这个变量时会得到最新的值。这个特性也被成为volatile的线程可见性(visibility),也是volatile最主要特性。
      除了volatile变量的线程可见性外,还有
      1)禁止重排序
      就是对它的内存操作不允许和其他内存操作进行重排序。而JVM(不仅仅是JVM)对执行执行的重排序主要是为了利用物理机的特性提高执行效率,只要最后输出的结果是对的。
      2)可见性扩展
      volitile变量可见性的影响已经超出了自己内容的可见性。所有在修改volitile变量之前修改其他共享变量,在volitile变量对其他线程可见时,其他共享变量也是可见的。这是依据现行发生原则(happens-before)第(3)条:对volatile字段的写入操作happens-before于每一个后续的同一个字段的读操作。

      将设有一个共享变量b是volitile,而另一个a不是volitile,这是一个线程执行一个操作C在字段b的写操作之后,那么volatile b写操作之前的所有操作都对此操作C可见。所以修改a总是在修改b之前,也就是说如果其他线程读取到了一个b的值,那么在b变化之前的a也就能够读取到,换句话说就是如果看到了b值的变化,那么就一定看到了a值的变化。而如果上面两条语句交换下顺序就无法保证这个结果一定存在了。在ConcurrentHashMap(jdk1.6.0.45).containsValue中,可以看到每次遍历segments时都会执行int c = segments[i].count;,但是接下来的语句中又不用此变量c,尽管如此JVM仍然不能将此语句优化掉,因为这是一个volatile字段的读取操作,它保证了一些列操作的happens-before顺序,所以是至关重要的。在这里可以看到:ConcurrentHashMapvolatile发挥到了极致!

volatitle与synchronized、atomic变量

       volatile是Java语言的高级特性,能用他来做什么,不能用它来做什么一定要清楚。现在网上有文章说不要轻易使用volatile,还说如果对于多线程掌握不够深入的话,尽可能用synchronized。
       volatile能完成的事情,synchronized也能完成,但反过来不成立。被synchronized的代码块内的共享变量具有原子性,而volatile修饰的变量没有,如下代码所示:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class VolatileTest {
    private volatile int vi = 0;
    public int getVi() {
        try {
            Thread.sleep(1); //模拟耗时操作
        } catch (InterruptedException ex) {
            Logger.getLogger(Volatile.class.getName()).log(Level.SEVERE, null, ex);
        }
        return vi;
    }
    public void incrVi(){
         vi++;
    }
    public void execute() throws InterruptedException {
        ExecutorService es = Executors.newCachedThreadPool();
        for (int j = 0; j < 10; j++) {
            es.execute(new Runnable() {
                public void run() {
                    int j = 0;
                    while (j++ < 5000) {
                       incrVi();
                    }
                }
            });
        }
        es.shutdown();
        es.awaitTermination(1, TimeUnit.HOURS);
    }
    public static void main(String[] args) throws InterruptedException {
        VolatileTest  app = new VolatileTest ();
        app.execute();
        System.out.println("最后结果:" + app.getVi());
    }
}
       请问上面的输出结果是50000?输出不固定,大多数情况小于50000。虽然volatile可以让其修饰的变量值对其他线程马上可见,但多个线程同时读取了最新值,比如10个线程同时读到300,最后都修改为301.volatile是轻量级同步机制,不能排他访问共享变量。
       如果把incrVi方法用synchronized修饰,结果正确了。
       但是这样的话,incrVi同时只能被一个线程访问了,所有的并行操作到了incrVi都变成了串行。如果incrVi是一个耗时操作的话,整个性能会严重受损。
       多线程会产生资源争用,但不是所有时刻都会产生,如果是100%都会产生,synchronized也许是个最佳选择,但实际情况真正在CPU执行发生争用的概率很低。既然这样,我们不要完全采用synchronized机制,让所有线程并发运行起来,只有真正发生争用时在额外处理一下即可,这就是CAS的粗话描述。不恰当量化分析一下,执行synchronized方法花费时为1ms,50000执行是50000ms,50s,而如果换成CAS操作,一次执行只有0.01ms,50000执行争用发生10次,每次处理2ms,这样最后执行时间为500+20=520ms,0.52s。
        JUC(java并发套件)中的并发套件主要就是采用CAS了实现的。上面程序的改进如下:
    public class CASTester{
        private final AtomicInteger atomicI = new AtomicInteger(0);
         public void incrVi(){ 
            try {
                Thread.sleep(1);//模拟耗时操作
            } catch (InterruptedException ex) {
                Logger.getLogger(Volatile.class.getName()).log(Level.SEVERE, null, ex);
            }
            atomicI.incrementAndGet();
        }
        public int getAtomici() {
            return atomicI.get();
        }  
        public void execute() throws InterruptedException {
            ExecutorService es = Executors.newCachedThreadPool();
            for (int j = 0; j < 10; j++) {
                es.execute(new Runnable() {
                    public void run() {
                        int j = 0;
                        while (j++ < 5000) {
                           incrVi();
                        }  
                    }
                });
            }
            es.shutdown();
            es.awaitTermination(1, TimeUnit.HOURS);
        }
        public static void main(String[] args) throws InterruptedException {
            CASTester app = new CASTester();
            app.execute();
            System.out.println("最后结果:" + app.getAtomici());//50000
        }
    }
    不用synchronized修饰,输出结果一样正确,但执行效率可是比synchronized高出很多。简单测试synchronized花费了50s,而CAS操作仅7s。
volatitle与不变模式

        不变对象是线程安全的,不用增加额外的机制可以安全地被多线程并发访问。那这又和volatile有半毛关系?本来没有,但是两者结合起来有很多妙处。
    volatile变量是不和其他状态变量一起纳入不变条件中,但是借助不变模式则可以变向实现,请看下面的例子:
    本例子的逻辑就是,一个提供计算耗时(通过sleep模仿)服务,为了局部提高性能需要保留最近一次的计算原值和计算结果,如果当前需要计算的值和保留的计算值相同则直接返回结果,而不用耗时计算。此外要求本服务线程安全。
    /**
     * 示例服务接口
     */
    interface Service {
        /**
         * @param 计算输入
         * @return
         */
        int service(int i);
    }
    /**
     * 示例服务抽象类
     */
    abstract class ServiceTemplate implements Service {
        /**
         * 示例计算方法,模仿耗时操作
         *
         * @param i
         * @return
         */
        protected Integer compute(Integer i) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException ex) {
                Logger.getLogger(App.class.getName()).log(Level.SEVERE, null, ex);
            }
            return ((Double) Math.sqrt(i)).intValue();
        }
    }
    实现一:粗粒度synchronized
    /**
     * 性能不高的线程安全缓存服务
     */
    class SynchronizedCacheService extends ServiceTemplate {
        private Integer lastNumber = new Integer(-1);
        private Integer lastResult = new Integer(-1);
        public synchronized int service(int req) {
            if (req == lastNumber) {
                return lastResult;
            } else {
                int result = compute(req);
                lastNumber = req;
                lastResult = result;
                return result;
            }
        }
    }
    实现二:细粒度synchronized
    public class FineSynchronizedCacheService extends ServiceTemplate {
        private Integer lastNumber = new Integer(-1);
        private Integer lastResult = new Integer(-1);
        public int service(int req) {
            Integer i = req;
            synchronized (this) {
                if (i.equals(lastNumber)) {
                    return lastResult;
                }
            }
            Integer result = compute(i);
            synchronized (this) {
                lastNumber = i;
                lastResult = result;
            }
            return result;
        }
    }
    虽然实现方式二相比一只是一个小小的改动,然而却是service方法尽可能同时被多个线程访问,提高了吞吐量。但不论如何细粒度,只要是被synchronized修饰的代码块,某一个时刻只能被一个线程访问执行。
    实现三:不变模式和volatile
    class OneValueCache {
        private final Integer lastNumber;
        private final Integer lastResult;
        public OneValueCache(Integer i, Integer result) {
            lastNumber = i;
            if (factors != null) {
                lastResult = result .intValue();
            } else {
                lastResult = null;
            }
        }
        public Integer getResult(Integer i) {
            if (lastNumber == null || !lastNumber.equals(i)) {
                return null;
            } else {
                return lastResult;
            }
        }
    }
    public class VolatileCachedService extends ServiceTemplate {
        private volatile OneValueCache cache = new OneValueCache(null, null);
        public int service(int req) {
            Integer i = req;
            Integer result= cache.getResult (i);
            if (factors == null) {
                factors = compute(i);
                cache = new OneValueCache(i, factors);
            }
            return result;
        }
    }
       如果在VolatileCachedService中分别用两个volatile变量保存原值和结果值,那么因为volatile变量不能其他状态变量参与不变约束,即两个volatile变量不能共同保持原子型,必然有线程会读取到不一致数据。
           通过不变OneValueCache对象内部用了两个final数据,封装了两个volatile变量,这样在初始化时,两个数据必然被同时初始化,并且一经init不允许update。同时因为cache被修饰为volatile,保证新的值可以马上被其他线程获取。
总结-volatile使用约束
思维比较随便,零零散散记录了volatile的一些内容,现在总结一下volatile使用的约束:
1、更新操作不能依赖于当前值,如Volatile例子所反映。如果需要依赖的话,必须每一次更新只有一个线程在进行。(注,这个在Java并发编程实践中文版翻译中存在错误)
2、volatile变量不能和其他状态变量一同参与到不变约束中,但是和不变模式的结合应用
3、对volatile变量的操作是不需要的锁操作
4、不要轻易使用volatile,但是使用好了会有非常的结果,JUC中很多实现就是例证