深入理解CAS及Java中的原子类

在之前我们提过CAS这个玩意,这也是个很重要的玩意,在JDK中, 有很多地方都用到了它

CAS基础了解

CAS:compare and swap,比较与交换

通常指的是这样的一种原子操作:针对一个变量,先看它的内存值与某个期望值是否相同,相同就给它重新赋值

CAS用伪代码可以表示为这样:

if (value == 期望值) {
    value = 新值;
}

这个伪代码比较和赋值这两步走的,CAS可以看做是他们的合成体,CAS是在硬件层面进行原子性保证的CAS可以当做是乐观锁的实现方式,这块可以和数据库的悲观锁和乐观锁作对比

CAS的应用

Java中原子类的增加变更操作就是通过CAS自旋实现的

CAS是一种无锁算法,在不使用锁,也就是线程不被阻塞的情况下,实现多线程之间的变量同步

在Java中,CAS操作是通过Unsafe类提供的,该类提供了三种方式

他们都是 native,本地方法,由JVM提供具体实现,这就意味着JVM对它们的实现可能不同

以 compareAndSwapInt 为例,Unsafe 的 compareAndSwapInt 方法接收 4 个参数,分别是:对象实例、内存偏移量、字段期望值、字段新值,我们来写一段代码测试一下


    public static void main(String[] args) throws Exception {

        Field field = Unsafe.class.getDeclaredField("theUnsafe");
        field.setAccessible(true);
        Unsafe unsafe = (Unsafe) field.get(null);


        User user = new User();
        user.setId(1);

        long offect = unsafe.objectFieldOffset(user.getClass().getDeclaredField("id"));

        System.out.println("unsafe.compareAndSwapInt(user, offect, 1, 2) = " + unsafe.compareAndSwapInt(user, offect, 1, 2));
        System.out.println("unsafe.compareAndSwapInt(user, offect, 3, 6) = " + unsafe.compareAndSwapInt(user, offect, 3, 6));
        System.out.println("unsafe.compareAndSwapInt(user, offect, 2, 21) = " + unsafe.compareAndSwapInt(user, offect, 2, 21));


    }

 我这个User实体类,有一个属性是 int 类型的,属性名为id

这儿要注意一个东西,Unsafe不能直接创建,不信咱试试

Unsafe unsafe = Unsafe.getUnsafe();

都说了 Unsafe,Unsafe,怎么可能让随便掉用,这玩意可是能直接操作内存的,看它的get方法

 可以看出,它先获取了当前类的类加载器,进行了一个判断 VM.isSystemDomainLoader

点进来发现只是判断是不是为null,所以关键点还是在获取类加载器上,什么情况下返回null,什么情况下不返回null

    @CallerSensitive
    public ClassLoader getClassLoader() {
        ClassLoader cl = getClassLoader0();
        if (cl == null)
            return null;
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            ClassLoader.checkClassLoaderPermission(cl, Reflection.getCallerClass());
        }
        return cl;
    }

可以看到,上来先获取了ClassLoader,获取了类加载器,如果为null,那就直接返回,这儿如果返回了null,那上面的校验直接是false,获取Unsafe就会抛出异常

如果获取到的类加载器不是null,它取获取了系统安全管理器,如果系统安全管理器不为空,那就执行 ClassLoader 的 checkClassLoaderPermission ,检查类加载器许可

    static void checkClassLoaderPermission(ClassLoader cl, Class<?> caller) {
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            // caller can be null if the VM is requesting it
            ClassLoader ccl = getClassLoader(caller);
            if (needsClassLoaderPermissionCheck(ccl, cl)) {
                sm.checkPermission(SecurityConstants.GET_CLASSLOADER_PERMISSION);
            }
        }
    }

这个方法有两个入参,第一个可以看到是获取的类加载器,第二个是什么呢?

从上层可以看到,是 Reflection.getCallerClass(),这个方法,可以获取到调用者的类

可以看到,它又获取了调用者的类加载器,在 if 里,进行了一个类加载器许可检查

注意,前面是调用者,后边是当前,前面没什么,关键看最后这个,它对调用者进行了判断

其实这块就是在判断 var0是不是BootstrapClassLoader,因为双亲委派机制的保护,不是的话,直接抛异常出去

扩展,类加载器:

  • BootstrapClassLoader(启动类加载器):主要负责加载核心的类库(java.lang.*等),JVM_HOME/lib目录下的,构造ExtClassLoader和APPClassLoader。
  • ExtClassLoader (拓展类加载器):主要负责加载jre/lib/ext目录下的一些扩展的jar
  • AppletClassLoader(系统类加载器):主要负责加载应用程序的主函数类
  • 自定义类加载器:主要负责加载应用程序的主函数类

当一个类加载器收到类加载任务时,会先交给自己的父加载器去完成,因此最终加载任务都会传递到最顶层的BootstrapClassLoader,只有当父加载器无法完成加载任务时,才会尝试自己来加载

好了, 让我们再回到CAS上,画个图来做示例

 注意,真正的CAS,能且只能保证原子性,Java中的CAS,JVM做了优化, 可以保证线程安全,在Java中实现,其实也很简单,在CAS前面添加LOCK#前缀指令即可

上面我们在用CAS的时候,有一个偏移量,这个我们注意一下,子类初始化时,会先初始化父类,如果父类还有父类,则继续初始化,最终会到Object(这也是为什么子类可以强转到父类,因为子类内存空间含有父类的那一块初始化好的内存)

在一个对象初始化后,它有一个对象头,64 位的 Java 虚拟机中,对象头的标记字段占 64 位,而类型指针又占了 64 位,也就是说,一个Java对象对内存的占用就是16个字节,为了减少开销,64 位 Java 虚拟机引入了压缩指针,将堆中原本 64 位的 Java 对象指针压缩成 32 位的,也就是说,从原来的16个字节占用, 压缩到了12个字节占用,但是,虚拟机要求对象起始地址必须是8字节的整数倍,所以必须填充4个字节,到16字节,填充数据并非必须存在, 仅仅是为了字节对齐

我们在Java使用CAS,那么在虚拟机,一定有它的实现,感兴趣的可以看JDK的源码实现

不过,不同的操作系统,不同的CPU,都会有不同的实现,无论是虚拟机的CAS实现,还是Java中的CompareAndSwap,都是对相应平台的CAS指令的一层简单封装,CAS作为一种硬件原语,有着天然的原子性,这也是CAS的价值所在

CAS的缺陷

为了进行更改,可能会进行自旋+CAS,长时间的自旋CAS操作不成功,会给CPU带来很大的开销

且,CAS只能保证一个共享变量原子操作

还有就是ABA问题,所谓ABA问题,也很简单,我们举个例子,thread1令value=1,thread2令value=2,再令value=1,thread1再读,还是1,看似没有变化,实际上已经发生了变化

ABA问题的解决

要解决ABA问题,其实也很简单,像数据库的乐观锁,搞一个版本号或者标记

Java提供了一个原子引用类,AtomicStampedReference<V>,它有两个属性,reference-实际存储的变量,int类型属性 stamp-版本,修改一次就加一

还有一个简化版的类,AtomicMarkableReference<V>,它有两个属性,reference-实际存储的变量,boolean类型属性 mark-是否修改过

CAS原子操作类

我们知道,在并发情况下,很容易出现数据安全问题,比如多个线程执行 ++ 操作,就有可能是错误的结果,为了保证结果,通常可以使用synchronized来控制,但是synchronized是悲观锁,并不是很高效

Java为了保证原子性,提供了Atomic....相关的类,atomic包下都是采用乐观锁策略去原子更新数据的,Java中,使用了CAS进行具体实现

atomic包下,有五大类

1、基本类型:AtomicInteger,AtomicLong,AtomicBoolean

2、引用类型:AtomicStampedReference,AtomicMarkableReference

3、数组类型:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray

4、对象属性原子修改器:AtomicIntegerFieldUpdate,AtomicLongFieldUpdate,AtomicReferenceFieldUpdate

5、原子类型累加器:LongAccumulator,DoubleAccumulator,DoubleAdder,LongAdder

这些类具体的使用我们不做赘述,各位看官可自行研究相关API

这里有一个地方要注意,AtomicIntegerFieldUpdate的使用是存在限制的

1、字段必须用volatile修饰,在线程之间共享变量保证立刻可见

2、字段的描述类型(public、protected、default、private)与调用者与操作对象的关系一致,也就是说,调用者可以直接操作对象字段,那么,就可以反射进行原子操作,但是对于父类字段,子类不能直接操作,尽管,子类可以访问父类的字段

3、只能是实例变量,不能是类变量,也就是说不能加static

4、只能是可修改的变量,不能是final(事实上,final和volatile是冲突的,不能同时存在)

5、对于AtomicIntegerFieldUpdate或者AtomicLongFieldUpdate,只能修改int类型和long类型,不能改Integer和Long,如果要改的话,得用AtomicRenferenceUpdate(这一点也很好理解,Integer和Long,是对象,不是基本类型)

思考一个问题,无论是AtomicInteger还是AtomicLong,都有CAS的共同缺陷,并发高的时候,经常会失败自旋,贼影响性能,那该怎么办呢?

所以,Java也有改进,提供了DoubleAdder和LongAdder

原理,其实就是拆分处理,比如,十个线程进行累加,放在之前,肯定是同一时间点,有九个线程在自旋,现在我们把它拆开,十个线程自己加自己的,最后把这十个线程加在一起就好了

现在假设有四个线程访问一个变量,对变量进行++操作,线程1CAS成功,直接在变量上进行增加,但是其他三个线程都CAS失败了,这个时候,这三个CAS失败的三个线程,会自己处理自己后续的++操作请求,等全部都处理完了,这三个线程各自的处理结果,再和第一个处理结果相加,就是最后的结果

---- 我们以DoubleAdder为例

    public void add(double x) {
        Cell[] as; long b, v; int m; Cell a;
        if ((as = cells) != null ||
            !casBase(b = base,
                     Double.doubleToRawLongBits
                     (Double.longBitsToDouble(b) + x))) {
            boolean uncontended = true;
            if (as == null || (m = as.length - 1) < 0 ||
                (a = as[getProbe() & m]) == null ||
                !(uncontended = a.cas(v = a.value,
                                      Double.doubleToRawLongBits
                                      (Double.longBitsToDouble(v) + x))))
                doubleAccumulate(x, null, uncontended);
        }
    }

可以看到,进行了一个判断,这个as上来一定是空的,我们可以去看看这个cells是什么

这个是DoubbleAdder父类,我们再去追,发现它是一个内部类,这个内部类还考虑到了伪共享的问题,什么是伪共享,请看我这篇文章最后一个节

缓存一致性协议和CPU缓存架构(MESI协议)、伪共享_是菜菜的小严惜哎的博客-CSDN博客https://blog.csdn.net/weixin_46097842/article/details/125049047

    @sun.misc.Contended static final class Cell {
        volatile long value;
        Cell(long x) { value = x; }
        final boolean cas(long cmp, long val) {
            return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);
        }

        // Unsafe mechanics
        private static final sun.misc.Unsafe UNSAFE;
        private static final long valueOffset;
        static {
            try {
                UNSAFE = sun.misc.Unsafe.getUnsafe();
                Class<?> ak = Cell.class;
                valueOffset = UNSAFE.objectFieldOffset
                    (ak.getDeclaredField("value"));
            } catch (Exception e) {
                throw new Error(e);
            }
        }
    }

所以前面是false,那就是看后边的了

很明显,后边用CAS在操作base,base是什么呢,那就是要操作的原始值,假设第一个线程进来, CAS修改一定是成功的,两边都是false,那就正常走,后边的线程如果失败了,那就会走这个if里的逻辑,就是这样滴~

我们继续来看,首先可以看到,里面的if,还是进行了Cell的判断,是不是进行了初始化

在这一步结束了之后,可以看到它执行了  a = as[getProbe() & m]

    static final int getProbe() {
        return UNSAFE.getInt(Thread.currentThread(), PROBE);
    }

看,传了一个当前线程,这个就是在计算Cell下标,如果,这个下表为止不为空,看,有一个很重要的地方

是a的值,也就是当前下标的值,进行一个加操作,而不是在base上进行加

如果说,它也没成功的话,那就得执行最下面的 doubleAccumulate 方法了

点进去的话,会发现这个方法是父类 Striped64 提供的

上来之后,最明显的,就是一个自旋(叫死循环也没问题),一般进行CAS的都会有这种处理,因为要保证成功

我们来仔细看看这个方法的处理逻辑,前面就不用管了,我们直接看自旋了里的这一大堆判断,大家可以对着代码来看

首先,上来判断 cells 不为空,紧接着判断这个cell位置是不是为空,为空说明这个位置的cells对象还没创建出来

然后判断cellBusy是不是等于0,等于0的话,创建出来一个cell

 

注意这个 x ,这个 x 就是我们需要加的值,所以说,假设cell刚开始没有,要进行加一,那就是一,后续的加,就在这个一的基础上加

在这个时候cell已经创建出来了,但是,小心但是!

但是,创建的cell还没有放到cells里,所以需要给它放进去,我们看它的处理

 if (cellsBusy == 0 && casCellsBusy())

我们看这个if,它判断了 cellsBusy是不是等于0,并且,用CAS,尝试改成1,改成功了,其实说明当前线程获取到锁了

剩下的里面的逻辑,其实就是在创建,赋值,然后最后把cessBusy改成0

 注意,到这儿,我们的前提都是,为空的情况下,那,如果不为空呢?

让我们直接跳过这个if,看下面,如果cell不为空怎么办

第一个 else if,我们直接忽略掉,重点是下面这个 else if,可以看到,直接去用CAS加了,成功的话直接结束

既然说到CAS加成功,那就会有失败,失败怎么办呢?我们看这一段

 看这个逻辑条件,也是尝试获取锁,获取到锁之后,那就开始用移位操作扩容,并且进行赋值,扩容为两倍(至于为啥是两倍,因为左移了1....),然后最后释放掉cellsBusy的状态

OK,到这儿为止,cells创建完成+内部为空不为空的情况已经完事了,让我们开始看cells没有创建完成的情况

同样的操作,我们收起大if

可以清楚看到,上来也是加锁,然后创建一个长度为2的数组,然后通过计算,把当前值传入到所对应的位置,然后释放锁,结束

好,如果,假设,就是拿不到锁怎么办?也就是说,cells没有创建,也还拿不到锁去创建,我们可以看到有最后一段逻辑,最后一段else

它尝试从base直接去改,改成功了,那就结束,失败了,走最外层自旋

也就是说,只能有一个线程对cells进行初始化

给大家手画一副流程逻辑图,写字不好,请将就看

到这里,是不是完全明白了这个方法的处理逻辑,以及DoubleAdder的操作逻辑?

看完了 add 方法,我们再看一个 sum 方法

    public double sum() {
        Cell[] as = cells; Cell a;
        double sum = Double.longBitsToDouble(base);
        if (as != null) {
            for (int i = 0; i < as.length; ++i) {
                if ((a = as[i]) != null)
                    sum += Double.longBitsToDouble(a.value);
            }
        }
        return sum;
    }

可以看到,调用 sum 方法的时候,它会返回当前时刻的累加值,注意,当前时刻,也就是说,它不一定准,比如别的线程还在自己加的时候,调用了 sum,也可能,调用 sum 的时候,刚好在扩容

所以说,这个方法在高并发情况下,不是线程安全的

以上就是这次的总结整理,大家可以按照自己的实际情况来使用

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值