栏目分类:
子分类:
返回
文库吧用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
文库吧 > IT > 软件开发 > 后端开发 > Java

Volatile和CAS

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

Volatile和CAS

文章目录
    • 错误的双重检查锁
      • 加锁
      • 双重检查锁
      • volatile
    • volatile
      • 什么是重排序
      • Java内存模型
      • volatile禁止重排序
        • volatile写时
        • volatile读时
      • volatile使用场景
    • CAS
      • 原子操作类
      • CAS
      • 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;
    }
}

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

//懒汉式(非线程安全)
class Singleton2{
    //构造函数为private
    private Singleton2() {}
    
    private static Singleton2 instance;
    
    public static Singleton2 getInstance() {
        if (null == instance) {
            instance = new Singleton2();
        }
        return instance;
    }
}

不考虑多线程的情况,上述代码是ok的,但如果存在多线程的情况,上述代码就可能会生成多个实例。例如:

在这个例子中,instance会被重复初始化,生成两个实例。

加锁

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

class Singleton3{
    //构造函数为private
    private Singleton3() {}
    
    private static Singleton3 instance;
    
    public synchronized static Singleton3 getInstance() {
        if (null == instance) {
            instance = new Singleton3();
        }
        return instance;
    }
}

加了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;
    }
}

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

在双重检查的情况下,可以避免每次都进行获取锁和释放锁带来的额外的性能开销,也可以避免重复初始化的问题。例如:

但这种方法存在着一个问题,实例化对象的操作并不是一个原子操作,会被编译器编译为三条指令:

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

通常,编译器和处理器为了提高运行效率会进行指令重排序,都遵循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;
    }
}
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 普通写
} 

假如方法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写
} 

这次假如线程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);
    }

期望的输出结果是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; 
 } 
}

将 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());
    }

运行结果符合预期:

可以看到,在这种情况下, 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;
    }
}

通过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);
}

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

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

在这段代码中,涉及到两个重要的对象:unsafe和valueOffset。

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操作。

转载请注明:文章转载自 www.wk8.com.cn
本文地址:https://www.wk8.com.cn/it/1041117.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 wk8.com.cn

ICP备案号:晋ICP备2021003244-6号