• Java并发编程(三)volatile域


    相关文章
    Java并发编程(一)线程定义、状态和属性
    Java并发编程(二)同步
    Android多线程(一)线程池
    Android多线程(二)AsyncTask源代码分析

    前言

    有时仅仅为了读写一个或者两个实例域就使用同步的话,显得开销过大,volatile关键字为实例域的同步訪问提供了免锁的机制。假设声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被还有一个线程并发更新的。

    再讲到volatile关键字之前我们须要了解一下内存模型的相关概念以及并发编程中的三个特性:原子性,可见性和有序性。

    1. java内存模型与原子性。可见性和有序性

    Java内存模型规定全部的变量都是存在主存其中,每一个线程都有自己的工作内存。线程对变量的全部操作都必须在工作内存中进行。而不能直接对主存进行操作。而且每一个线程不能訪问其它线程的工作内存。
    在java中。运行以下这个语句:

    int i=3;

    运行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入主存其中。而不是直接将数值3写入主存其中。
    那么Java语言 本身对 原子性、可见性以及有序性提供了哪些保证呢?

    原子性

    对基本数据类型的变量的读取和赋值操作是原子性操作。即这些操作是不可被中断的,要么运行。要么不运行。
    来看一下以下的代码:

    x = 10;        //语句1
    y = x;         //语句2
    x++;           //语句3
    x = x + 1;     //语句4

    仅仅有语句1是原子性操作,其它三个语句都不是原子性操作。
    语句2实际上包括2个操作,它先要去读取x的值,再将x的值写入工作内存,尽管读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,可是合起来就不是原子性操作了。
    相同的,x++和 x = x+1包括3个操作:读取x的值。进行加1操作。写入新的值。


    也就是说,仅仅有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。


    java.util.concurrent.atomic包中有非常多类使用了非常高效的机器级指令(而不是使用锁)来保证其它操作的原子性。比如AtomicInteger类提供了方法incrementAndGet和decrementAndGet,它们分别以原子方式将一个整数自增和自减。能够安全地使用AtomicInteger类作为共享计数器而无需同步。
    另外这个包还包括AtomicBoolean。AtomicLong和AtomicReference这些原子类仅供开发并发工具的系统程序猿使用。应用程序猿不应该使用这些类。

    可见性

    可见性,是指线程之间的可见性,一个线程改动的状态对还有一个线程是可见的。也就是一个线程改动的结果。还有一个线程马上就能看到。
    当一个共享变量被volatile修饰时,它会保证改动的值会马上被更新到主存。所以对其它线程是可见的,当有其它线程须要读取时,它会去内存中读取新值。
    而普通的共享变量不能保证可见性,因为普通共享变量被改动之后。什么时候被写入主存是不确定的,当其它线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

    有序性

    在Java内存模型中,同意编译器和处理器对指令进行重排序。可是重排序过程不会影响到单线程程序的运行,却会影响到多线程并发运行的正确性。


    能够通过volatile关键字来保证一定的“有序性”。

    另外能够通过synchronized和Lock来保证有序性,非常显然,synchronized和Lock保证每一个时刻是有一个线程运行同步代码。相当于是让线程顺序运行同步代码。自然就保证了有序性。

    2. volatile关键字

    一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

    • 保证了不同线程对这个变量进行操作时的可见性,即一个线程改动了某个变量的值,这新值对其它线程来说是马上可见的。
    • 禁止进行指令重排序。

    先看一段代码,假如线程1先运行,线程2后运行:
      

    //线程1
    boolean stop = false;
    while(!stop){
        doSomething();
    }
    
    //线程2
    stop = true;

    非常多人在中断线程时可能都会採用这样的标记办法。可是其实,这段代码会全然运行正确么?即一定会将线程中断么?不一定。或许在大多数时候,这个代码能够把线程中断。可是也有可能会导致无法中断线程(尽管这个可能性非常小,可是仅仅要一旦发生这样的情况就会造成死循环了)。
    为何有可能导致无法中断线程?每一个线程在运行过程中都有自己的工作内存。那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存其中。

    那么当线程2更改了stop变量的值之后,可是还没来得及写入主存其中。线程2转去做其它事情了,那么线程1因为不知道线程2对stop变量的更改,因此还会一直循环下去。
    可是用volatile修饰之后就变得不一样了:

    • 使用volatile关键字会强制将改动的值马上写入主存。
    • 使用volatile关键字的话。当线程2进行改动时,会导致线程1的工作内存中缓存变量stop的缓存行无效;
    • 因为线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。

    volatile保证原子性吗?

    我们知道volatile关键字保证了操作的可见性,可是volatile能保证对变量的操作是原子性吗?

    public class Test {
        public volatile int inc = 0;  
        public void increase() {
            inc++;
        }
    
        public static void main(String[] args) {
            final Test test = new Test();
            for(int i=0;i<10;i++){
                new Thread(){
                    public void run() {
                        for(int j=0;j<1000;j++)
                            test.increase();
                    };
                }.start();
            }
             //保证前面的线程都运行完
            while(Thread.activeCount()>1)  
                Thread.yield();
            System.out.println(test.inc);
        }
    }

    这段代码每次运行结果都不一致,都是一个小于10000的数字,在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会切割开运行。
    假如某个时刻变量inc的值为10,线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被堵塞了;然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值。因为线程1仅仅是对变量inc进行读取操作,而没有对变量进行改动操作。所以不会导致线程2的工作内存中缓存变量inc的缓存行无效。所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存。最后写入主存。然后线程1接着进行加1操作,因为已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10。所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存。最后写入主存。

    那么两个线程分别进行了一次自增操作后。inc仅仅添加了1。
    自增操作不是原子性操作,而且volatile也无法保证对变量的不论什么操作都是原子性的。

    volatile能保证有序性吗?

    在前面提到volatile关键字能禁止指令重排序。所以volatile能在一定程度上保证有序性。


    volatile关键字禁止指令重排序有两层意思:

    • 当程序运行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行。
    • 在进行指令优化时。不能将在对volatile变量訪问的语句放在其后面运行。也不能把volatile变量后面的语句放到其前面运行。

    3. 正确使用volatile关键字

    synchronized关键字是防止多个线程同一时候运行一段代码。那么就会非常影响程序运行效率,而volatile关键字在某些情况下性能要优于synchronized,可是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。

    通常来说,使用volatile必须具备以下2个条件:

    • 对变量的写操作不依赖于当前值
    • 该变量没有包括在具有其它变量的不变式中

    第一个条件就是不能是自增自减等操作,上文已经提到volatile不保证原子性。


    第二个条件我们来举个样例它包括了一个不变式 :下界总是小于或等于上界

    public class NumberRange {
        private volatile int lower, upper;
        public int getLower() { return lower; }
        public int getUpper() { return upper; }
        public void setLower(int value) { 
            if (value > upper) 
                throw new IllegalArgumentException(...);
            lower = value;
        }
        public void setUpper(int value) { 
            if (value < lower) 
                throw new IllegalArgumentException(...);
            upper = value;
        }
    }

    这样的方式限制了范围的状态变量,因此将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全,从而仍然须要使用同步。

    否则,假设凑巧两个线程在同一时间使用不一致的值运行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。

    比如,假设初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 而且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3),这显然是不正确的。


    其实就是要保证操作的原子性就能够使用volatile,使用volatile主要有两个场景:

    状态标志

    volatile boolean shutdownRequested;
    ...
    public void shutdown()
     { 
     shutdownRequested = true;
      }
    public void doWork() { 
        while (!shutdownRequested) { 
            // do stuff
        }
    }

    非常可能会从循环外部调用 shutdown() 方法 —— 即在还有一个线程中 —— 因此,须要运行某种同步来确保正确实现 shutdownRequested 变量的可见性。然而,使用 synchronized 块编写循环要比使用volatile 状态标志编写麻烦非常多。因为 volatile 简化了编码,而且状态标志并不依赖于程序内不论什么其它状态,因此此处非常适合使用 volatile。

    双重检查模式 (DCL)

    public class Singleton {  
        private volatile static Singleton instance = null;  
        public static Singleton getInstance() {  
            if (instance == null) {  
                synchronized(this) {  
                    if (instance == null) {  
                        instance = new Singleton();  
                    }  
                }  
            }  
            return instance;  
        }  
    }  

    在这里使用volatile会或多或少的影响性能,但考虑到程序的正确性。牺牲这点性能还是值得的。
    DCL长处是资源利用率高。第一次运行getInstance时单例对象才被实例化。效率高。缺点是第一次载入时反应稍慢一些。在高并发环境下也有一定的缺陷。尽管发生的概率非常小。


    DCL尽管在一定程度攻克了资源的消耗和多余的同步,线程安全等问题,可是他还是在某些情况会出现失效的问题,也就是DCL失效,在《java并发编程实践》一书建议用以下的代码(静态内部类单例模式)来替代DCL:

    public class Singleton { 
        private Singleton(){
        }
          public static Singleton getInstance(){  
            return SingletonHolder.sInstance;  
        }  
        private static class SingletonHolder {  
            private static final Singleton sInstance = new Singleton();  
        }  
    } 

    关于双重检查能够查看http://blog.csdn.net/dl88250/article/details/5439024

    4. 总结

    与锁相比,Volatile 变量是一种非常easy但同一时候又非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。假设严格遵循 volatile 的使用条件即变量真正独立于其它变量和自己曾经的值 ,在某些情况下能够使用 volatile 取代 synchronized 来简化代码。然而,使用 volatile 的代码往往比使用锁的代码更加easy出错。本文介绍了能够使用 volatile 取代 synchronized 的最常见的两种用例,其它的情况我们不妨去使用synchronized 。

  • 相关阅读:
    【STL】queue容器
    【STL】stack容器
    【STL】deque容器
    【STL】迭代器
    【STL】vector容器
    【STL】string
    tensorflow学习012——tf.keras函数式API
    tensorflow学习010——优化函数、学习速率、反向传播算法、网络优化、超参数
    Tensorflow学习009——softmax多分类
    tensorflow学习008——逻辑回归实现
  • 原文地址:https://www.cnblogs.com/brucemengbm/p/7221342.html
Copyright © 2020-2023  润新知