• Volatile和CAS



    在代码规范中,有一条规范是“ static 和 synchronized不应双重检查锁

    错误的双重检查锁

    先回顾一下单例模式,单例模式是指:一个类有且仅有一个实例,并且自行实例化向整个系统提供。单例模式通常分为饿汉式和懒汉式。

    饿汉式:
    饿汉式在类创建的同时就已经创建好一个静态的对象供系统使用,以后不再改变,所以天生是线程安全的。

    //饿汉式
    public class Singleton {
        //构造函数为private
        private Singleton() {}
        //有一个 private static final的变量,在类初始化时实例化
        private static final Singleton instance = new Singleton();
        //通过public static 的方法获得变量引用
        public static Singleton getInstance() {
            return instance;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    懒汉式:
    懒汉式在第一次调用时实例化。

    //懒汉式(非线程安全)
    class Singleton2{
        //构造函数为private
        private Singleton2() {}
        
        private static Singleton2 instance;
        
        public static Singleton2 getInstance() {
            if (null == instance) {
                instance = new Singleton2();
            }
            return instance;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    不考虑多线程的情况,上述代码是ok的,但如果存在多线程的情况,上述代码就可能会生成多个实例。例如:
    在这里插入图片描述
    在这个例子中,instance会被重复初始化,生成两个实例。

    加锁

    在这种情况下,一般可以选择加锁的方式来解决,如下:

    class Singleton3{
        //构造函数为private
        private Singleton3() {}
        
        private static Singleton3 instance;
        
        public synchronized static Singleton3 getInstance() {
            if (null == instance) {
                instance = new Singleton3();
            }
            return instance;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    加了synchronized之后,重复初始化的问题被解决了,但也带来了性能开销的问题。在JDK1.5及之前的版本中,是不推荐使用synchronized来实现线程同步的。因为synchronized是一种重量级锁,底层是通过监视器对象来实现的,依赖于操作系统的互斥锁,操作系统需要在用户态和内核态之间进行切换,影响效率。

    双重检查锁

    为了优化加锁带来的性能开销,可以使用双重检查锁,如下:

    class Singleton4{
        //构造函数为private
        private Singleton4() {}
        
        private static Singleton4 instance;
        
        public static Singleton4 getInstance (){
            if (null == instance) {
                synchronized (Singleton4.class) {
                    if (null == instance) {
                        // 实例化对象
                        instance = new Singleton4();
                    }
                }
            }
            return instance;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    这种方法是在获取锁之前,检查对象是否为空,为空再去获取锁,获取成功之后,再次检查对象是否为空,不为空的话进行初始化操作。

    在双重检查的情况下,可以避免每次都进行获取锁和释放锁带来的额外的性能开销,也可以避免重复初始化的问题。例如:
    在这里插入图片描述
    但这种方法存在着一个问题,实例化对象的操作并不是一个原子操作,会被编译器编译为三条指令:

    • 为对象分配内存空间
    • 初始化对象
    • 将对象指向分配的内存空间

    通常,编译器和处理器为了提高运行效率会进行指令重排序,都遵循as-if-serial语义。as-if-serial语义是指,不论编译器和处理器对指令怎么进行重排序,程序的执行结果都不能被改变。这里的执行结果不变是指对单线程程序而言。如果是多线程的话,可能出现意想不到的结果。

    对于实例化对象的操作,可能会被重排序为:

    • 为对象分配内存空间
    • 将对象指向分配的内存空间(此时对象不为空)
    • 初始化对象

    此时可能会出现一下情况:
    在这里插入图片描述
    此时为什么static 和 synchronized不应双重检查锁的困惑已经解开。

    volatile

    这个问题的关键在于指令重排序,禁止指令重排序即可解决,因此可以使用关键字volatile。

    class Singleton5{
        //构造函数为private
        private Singleton5() {}
        //使用volatile
        private volatile static Singleton5 instance;
        
        public static Singleton5 getInstance (){
            if (null == instance) {
                synchronized (Singleton5.class) {
                    if (null == instance) {
                        // 实例化对象
                        instance = new Singleton5();
                    }
                }
            }
            return instance;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    volatile

    什么是重排序

    在执行程序时,为了提高性能,编译器和处理器通常会对指令进行重排序。这些重排序一般遵循as-if-serial语义和happens-before规则。

    as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。

    数据依赖性:
    如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。(写后读、写后写、读后写)

    为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。

    happens-before规则仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。(并不意味着前一个操作必须要在后一个操作之前执行)

    与程序员密切相关的happens-before规则如下:
    1.程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
    2.监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
    3.volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
    4.传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

    Java内存模型

    要理解volatile,首先要了解JMM,如下图:
    在这里插入图片描述
    主内存:被所有的线程所共享,对于一个共享变量(比如静态变量,或是堆内存中的实例)来说,主内存当中存储了它的“本尊”。直接操作主内存速度太慢,因此使用了性能较高的工作内存。

    工作内存:每一个线程拥有自己的工作内存(逻辑概念,非物理概念),对于一个共享变量来说,工作内存当中存储了它的“副本”。线程对共享变量的所有操作都必须在工作内存进行,不能直接读写主内存中的变量。不同线程之间也无法访问彼此的工作内存,变量值的传递只能通过主内存来进行。

    对于变量 static int a = 0 ,如果线程A对其进行 a = 3的操作,流程如下:
    在这里插入图片描述
    假设线程B要读取a的值且在线程A之后执行,那么它读到的是0还是3呢?

    都有可能。如果线程B在线程A的第三步之前读取,读到的是0,如果在线程A的第三步之后读取,读到的是3。

    如果变量a使用volatile修饰, volatile static int a = 0 ,线程B读到的一定是3。因为volatile会要求线程B在线程A的写完成之后才能读取。

    volatile禁止重排序

    针对volatile变量,Java内存模型(JMM)制定了相关的重排序规则,简单总结为三条:

    • 当第二个操作是volatile写时,无论第一个操作是什么,都不能重排序,保证volatile写之前的操作不会被编译器重排序到volatile写之后。
    • 当第一个操作是volatile读时,无论第二个操作是什么,都不能重排序,保证volatile读之后的操作不会被编译器重排序到volatile读之前。
    • 当第一个操作是volatile写,第二个是volatile读时,不能重排序。

    上面的例子命中了第三条规则。

    volatile写时

    当发生volatile写的时候,JMM会做两件事:

    • 将该线程对应的本地内存中的共享变量的值刷新到主内存中。(volatile变量所在的缓存行的所有共享变量,不止是volatile变量)
    • 将其他线程本地内存中对应的缓存置为无效,下次访问相同内存地址时,将强制执行缓存行填充。

    对于第一条规则,当第二个操作是volatile写时,如果进行了重排序操作,会导致其他线程本地内存中的缓存行无效,无法保证volatile写之前的共享变量数据的一致。举个例子:

    volatile int a = 0;
    int b = 1;
    
    public void A (){
        b = 2;    // 1 普通写
        a = 1;      // 2 volatile写
    }
    
    public void B() {
        int c = 0;
        if (a == 1)    // 3  volatile读
            c = b;      // 4 普通写
    } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    假如方法A先执行,若方法B能读到a=1,则c应该为2;若方法B读不到a=1,则c应该为0。

    在这段代码中,存在以下happens-before关系:
    在这里插入图片描述
    根据程序顺序原则,代码1的执行结果对代码2可见,代码3的执行结果对代码4可见;根据volatile语义,代码2的执行结果对代码3可见;根据happens-before的传递性,代码1的执行结果应该对代码4可见。因此,我们期望得到的c值为2。

    而代码1和代码2之间不存在数据依赖,假如volatile允许重排序的话,代码2先执行,由于a是volatile变量,所以会将a = 1, b = 1刷新进入主内存;此时线程A的cpu时间片用完了,轮到了线程B执行方法B,由于a是volatile变量所以代码3处执行的时候会将b = 1, a = 1从主内存中读出,代码4再执行的话c会变为1,而不是期望的2。

    volatile读时

    当发生volatile读时,JMM会做两件事:

    • 将该线程本地内存中的缓存行置为无效。
    • 从主内存中读取共享变量。

    对于第二条规则,当第一个操作是volatile读时,会使缓存行中的普通共享变量也从主内存中重新获取,如果进行了重排序操作,无法保证这些数据一致。继续以前面的代码为例:

    volatile int a = 0;
    int b = 1;
    
    public void B() {
        int c = 0;    //非共享变量
        if (a == 1)    // 1  volatile读
            c = b;      // 2 普通写
    } 
    
    public void A (){
        b = 2;    // 3 普通写
        a = 1;      // 4 volatile写
    } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    这次假如线程B(方法B)先执行,线程A(方法A)后执行,正常情况下,语句1会返回false,最终的c值应当为0。

    但语句1和语句2之间没有数据依赖关系,假如volatile允许重排序的话,代码2先执行,会将c(非共享变量)赋值为1并写到缓冲区。此时线程B的cpu时间片用完了,轮到线程A执行。线程A执行后,会将a=1,b=2的结果刷新到主存中,并将线程B本地缓存中的a和b所在缓存行置为无效。再次轮转到线程B执行时,执行语句1,会从主存中重新读取共享变量a,此时读到a为1,语句1返回结果为true,语句2之前的执行结果1会生效,这个1既不是我们期望的0,也不是当前b的最新值。

    volatile使用场景

    使用volatile就能保证线程安全了吗?如下例:

    public volatile static int count = 0;
    
        @Test
        public void Test() throws InterruptedException {
            for (int i = 0; i < 10; i++) {
                Thread thread = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int j = 0; j < 1000; j++){
                            // 非原子操作
                            count++;
                        }
                    }
                });
                thread.start();
            }
            // 主线程+回收线程有两个,如果大于两个,说明上面线程还有执行完
            while (Thread.activeCount() > 2) {
                Thread.sleep(10);
            }
            System.out.println(count);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    期望的输出结果是10000,但实际上每次运行的结果可能都不一样。(运行了10次,结果都不是10000)
    在这里插入图片描述
    可见,volatile只能保证可见性,不能保证原子性。因此,使用volatile的场景为:
    1、对变量的写操作不依赖于当前值
    例如上述示例。
    2、该变量没有包含在具有其他变量的不变式中(这句话我也不是很理解,看例子把)
    例如:一个非线程安全的数值范围类,它包含了一个不变式 —— 下界总是小于或等于上界,代码如下:

    public class NumberRange { 
      private volatile int lower;
      private volatile int 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; 
     } 
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;而仍然需要使用同步——使setLower() 和 setUpper() 操作原子化。

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

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

    CAS

    原子操作类

    前面提到,volatile不能保证线程安全,如果想要得到预期的10000,可以使用synchronized关键字,只需要在count++ 之前使用 synchronized (Test.class) 即可。

    加上同步锁之后, count++ 就变成了原子性操作,代码实现了线程安全。但在某些情况下,synchronized不是最佳选择,它会让没有得到锁资源的线程进入BLOCKED状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。

    尽管Synchronized做了优化,增加了从偏向锁到轻量级锁再到重量级锁的过度,但是在最终转变为重量级锁之后,性能仍然较低。

    java.util.concurrent.atomic 包中提供了一系列原子操作类,其中 AtomicInteger 对 int 进行了封装,提供原子性的访问和更新操作,如下例:

    public void CASTest1() throws InterruptedException {
            AtomicInteger testCAS = new AtomicInteger(0);
            for (int i = 0; i < 10; i++) {
                Thread thread = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int j = 0; j < 1000; j++){
                            testCAS.getAndIncrement();
                        }
                    }
                });
                thread.start();
            }
            while (Thread.activeCount() > 2) {
                Thread.sleep(10);
            }
            System.out.println(testCAS.get());
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    运行结果符合预期:
    在这里插入图片描述
    可以看到,在这种情况下, AtomicInteger 达到了和synchronized一样的效果,且很多时候性能优于synchronized。

    而 AtomicInteger 底层是通过CAS来保证原子性的。

    CAS

    CAS全称Compare And Set(或Compare And Swap),CAS包含三个操作数:内存位置(V)、原值(A)、新值(B)。简单来说CAS操作就是一个虚拟机实现的原子操作,这个原子操作的功能就是将旧值(A)替换为新值(B),如果旧值(A)未被改变,则替换成功,如果旧值(A)已经被改变则什么都不做。进入一个自旋操作,即不断的重试。

    如下图:
    在这里插入图片描述
    以前面的自增操作为例,Java1.8之前,利用CAS实现原子性的方式如下:

    private volatile int value;
    
    public final int incrementAndGet() {
        for (;;) {
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return next;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    通过compareAndSet将变量自增,如果自增成功则完成操作,如果自增不成功,则自旋进行下一次自增,由于value变量是volatile修饰的,通过volatile的可见性,每次get()都能获取到最新值,这样就保证了自增操作每次自旋一定次数之后一定会成功。

    compareAndSet利用JNI(JAVA本地调用,允许java调用其他语言)来完成CPU指令的操作:

    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
    
    • 1
    • 2
    • 3

    Java1.8中直接将getAndAddInt方法直接封装成了原子性的操作,更加方便使用:

    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
    
    • 1
    • 2
    • 3

    在这段代码中,涉及到两个重要的对象:unsafevalueOffset

    unsafe:Java语言不像C,C++那样可以直接访问底层操作系统,但是JVM为我们提供了一个后门,这个后门就是unsafe。unsafe为我们提供了硬件级别的原子操作

    valueOffset:是通过unsafe.objectFieldOffset方法得到,所代表的是AtomicInteger对象value成员变量在内存中的偏移量。我们可以简单地把valueOffset理解为value变量的内存地址。

    unsafe的compareAndSwapInt方法参数包括CAS的三个基本元素:valueOffset参数代表了V,expect参数代表了A,update参数代表了B。

    CAS的缺陷

    1、ABA问题

    问题:

    因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。

    解决方法:

    ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。

    从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

    2、循环开销过大

    问题:

    前面说过,如果旧值(A)已经被改变,就会进入自旋操作。
    自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。例如,Unsafe下的getAndAddInt方法会一直循环,直到成功才会返回。

    解决方案:

    如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

    3、只能保证一个共享变量的原子操作
    问题:

    当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性。

    解决方案;

    可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

  • 相关阅读:
    【python3】3.函数、类、模块
    echarts进阶配置
    MySQL数据库介绍和部分基本操作
    fast planner中拓扑路径搜索及代码解析--topo_prm.cpp
    USB Composite 组合设备之多路CDC实现
    java的泛型机制详解篇一(基础知识点)
    【KafkaStream】流式计算概述&KafkaStream入门
    ENVI报错:SaveRasterFile failed:IDLnaMetadata Error
    CUDA编程- __syncthreads()函数
    html5播放器禁止拖拽功能实例(教学内容禁止拖动观看)
  • 原文地址:https://blog.csdn.net/wyplj2015/article/details/124306856