美文网首页
java多线程与高并发(三)volatile与CAS

java多线程与高并发(三)volatile与CAS

作者: 小偷阿辉 | 来源:发表于2021-04-25 00:04 被阅读0次

1.volatile关键字原理

用 volatile 关键字修饰的共享变量,编译成字节码后增加 Lock 前缀指令,该指令要做两件事:

  1. 将当前工作内存缓存行的数据立即写回到主内存。
  2. 写回主内存的操作会使其他工作内存里缓存了该共享变量地址的数据无效(缓存一致性协议保证的操作)。

Lock前缀指令还有内存屏障作用:

  1. 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的(即在执行到共享变量时,因为内存屏障的存在,会把它前面的操作都写回主内存,然后再执行共享变量的操作,对共享变量的操作一执行完也会写回主内存)。

1.1.具有原子性吗?

 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,但是多次运行结果都是一个小于 10000 的值。因为 inc++ 不是一个原子性操作,它包括读取变量,进行加1操作,写入工作内存。

加入线程1读取到 inc=100 的值,然后进行加1操作被阻塞,这时线程上下文切换,线程2读取也读取到 inc=100 的值,然后进行加1操作,写入工作内存,最后写入主内存。由于线程1被阻塞到第二步操作,即使工作内存缓存行失效,它也不会重新读取 inc 的值。所以这时出现线程安全问题。

说明 volatile 关键字不具有原子性。

1.2.具有可见性吗?

Lock 前缀指令保证当工作内存中缓存行数据写会主内存时,会使其他工作内存的缓存行无效,会重新读取主内存中数据。

说明 volatile 关键字具有可见性。

1.3.具有有序性吗?

Lock 前缀指令有内存屏障的作用。

一共有4种内存屏障,分别是 LoadLoad、LoadStore、StoreStore、StoreLoad。

  • LoadLoad:确保 Load1 数据的读取先于 Load2 的数据及所有后续数据的读取。
  • StoreStore:确保 Store1 数据的写回先于 Store2 数据及所有后续数据的写回。
  • LoadStore:确保 Load1 数据的读取先于 Store2 数据及所有后续数据的写回。
  • StoreLoad:确保 Store1 数据的写回先于 Load2 数据及所有后续数据的读取。

JMM 插入内存屏障保守策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障,后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障和一个 LoadStore 屏障。
0a1aa22f431a9232811c5aa6edbb237b.png

由于 JMM插入内存屏障保守策略增加了很多内存屏障, 增加很多开销,性能会下降,所以很多处理器对内存屏障的插入进行了优化。如 X86/X64 处理器,只会在写操作后面增加一个 storeLoad 内存屏障。

f52ebd3d8aeb8d042ead84bc64c1b67d.png

虽然只有 StoreLoad 一个内存屏障了,但是还是保证了有序性。

说明 volatile 关键字具有有序性。

1.4.DCL单例

何为DCL,DCL即Double Check Lock,双重检查锁定。下面从几个单例模式来讲解

懒汉式

public void Singleton{
    private static Singleton singleton;
 
    private Singleton(){}
 
    public static Singleton getInstance(){
        if(singleton==null){
               singleton=new Singleton();
        }
 
            return singleton;
    }
 
        
 
}

这种方法在单线程下是可取的,但是在并发也就是在多线程的情况下是不可取的,因为其无法保证线程安全,优化如下:

public void Singleton{
    private static Singleton singleton;
 
    private Singleton(){}
 
    public synchronized static Singleton getInstance(){
        if(singleton==null){
               singleton=new Singleton();
        }
 
        return singleton;
    }
 
}

优化非常简单,在getInstance方法上加上了synchronized同步,尽管jdk6以后对synchronized做了优化,但还是会效率较低的,性能下降。那该如何解决这个问题?于是有人就想到了双重检查DCL

public void Singleton{
    private static Singleton singleton;
 
    private Singleton(){}
 
    public static Singleton getInstance(){
        if(singleton==null){
            synchronized(Singleton.class){
                if(singleton==null)  singleton=new Singleton();
            }
              
        }
        return singleton;
    }
 
}

这个代码看起来perfect:

如果检查第一一个singleton不为null,则不需要执行加锁动作,极大的提高了性能
如果第一个singleton为null,即使有多个线程同时判断,但是由于synchronized的存在,只有一个线程能创建对象
当第一个获取锁的线程创建完成singleton对象后,其他的在第二次判断singleton一定不会为null,则直接返回已经创建好的singleton对象
DCL看起来非常完美,但其实这个是不正确的。逻辑没问题,分析也没问题?但为何是不正确的?不妨我们先回顾一下创建对象的过程

为对象分配内存空间
初始化对象
将内存空间的地址赋值给对应的引用
但由于jvm编译器的优化产生的重排序缘故,步骤2、3可能会发生重排序:

为对象分配内存空间
将内存空间的地址赋值给对应的引用
初始化对象
如果2、3发生了重排序就会导致第二个判断会出错,singleton != null,但是它其实仅仅只是一个地址而已,此时对象还没有被初始化,所以return的singleton对象是一个没有被初始化的对象

知道问题的原因,那么我们就可以解决?

不允许重排序

重排序不让其他线程看到

解决方法
利用volatile的特性即可阻止重排序和可见性

public class Singleton {
   //通过volatile关键字来确保安全
   private volatile static Singleton singleton;
 
   private Singleton(){}
 
   public static Singleton getInstance(){
       if(singleton == null){
           synchronized (Singleton.class){
               if(singleton == null){
                   singleton = new Singleton();
               }
           }
       }
       return singleton;
   }
}
类初始化的解决方案

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

1.5.总结

volatile主要有两个作用:
1.线程可见性
2.防止cpu指令重排

2.CAS

由于 volatile 关键字不具有原子性,所以一般在使用 volatile 关键字的地方,常常出现 CAS。

CAS是 Compare And Swap,它和 volatile 关键字都是实现 JUC 的基础,其中 java.util.concurrent.atomic 核心都是 CAS 。

使用 CAS 有两个核心参数,第一个是旧值,第二个是期望值。根据当前类(this)和 内存偏移(valueOffset)计算出内存中的值,当内存中的值和旧值相等时,更新为新值并返回 true ,否则返回 false。

比如 AtomicInteger 类中的 CompareAndSet() 方法:

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

根据 this 和 valueOffset 计算出的值与 expect 是否相等,相等把内存中的值更新为 update 并返回 true ,否则返回 false 。

2.1带来的其他问题?

CAS有三个问题:

  1. ABA问题
  2. 循环时间长开销大
  3. 只能保证一个变量的原子操作

2.1.1ABA问题

ABA问题描述的是当 CAS 更新一个值原来是 A,变成了 B ,又变成了 A,那么再使用 CAS 进行检查时会发现值没有发生变化,但是实际上却变化了。

利用乐观锁加版本号解决。
如果是基础类型:无所谓,不影响结果值
如果是引用类型:就像你的女朋友和你分手之后又复合,中间经历了别的男人

2.1.2循环时间长开销大

自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。

利用自旋次数或者超时时间解决。

2.1.3只能保证一个变量的原子操作

对多个变量使用 CAS 保证不了原子性。

利用锁或者 JDK1.5 提供的 AtomicReference 类解决。

相关文章

网友评论

      本文标题:java多线程与高并发(三)volatile与CAS

      本文链接:https://www.haomeiwen.com/subject/gmflrltx.html