Java CAS无锁优化

Java对象在内存中的结构和锁状态升级过程博客中已经分析了synchronized的锁升级过程, 在出现较多线程竞争时, synchronized升级到重量级锁会消耗较多的系统资源. 在特定场景下为了优化这个问题, Java还提供了一种无锁保证线程安全的方案: CAS(compare and swap).

CAS的核心思想是比较和替换, 实现主要是在Unsafe类中(Unsafe类拥有直接修改内存等一系列危险操作的能力, 谨慎使用), 这里以Unsafe::getAndAddInt为例, 该方法基于CAS自旋实现了无锁线程安全. 核心实现方法是native compareAndSwapInt(obj, offset, value, newValue), 参数说明如下表所示. compareAndSwapInt方法基于cmpxchg指令实现, 该指令是CPU源语支持的, 可以保证执行的原子性. 实现一次CAS的思路就是: 根据obj和offset获取到要修改属性的值E, 值E参加运算产生新值N, 对比当前共享内存中属性的值V和线程内存中的值E是否一致, 如果一致就说明当前线程的期望值和主内存中的值匹配, 那么就将新值N赋值给V完成修改. 否则认为有其他线程对值V有修改, 当次CAS失败.

参数描述
obj要修改的值所在的对象
offset要修改的值在对象内存中的偏移量
value在线程私有内存中存放的要修改的属性的值, 作为期望值
newValue当前线程基于期望值运算后的新值
// jdk8u_jdk/src/share/classes/sun/misc/Unsafe.java
public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!compareAndSwapInt(o, offset, v, v + delta));
    return v;
}

从上面代码可以看出Unsafe::getAndAddInt方法先通过内存偏移获取到对象中的值v, 将v + delta后尝试使用CAS将新值写入在共享内存中的值. 如果有其他线程已经修改了v, CAS失败并自旋不停地尝试, 直到CAS成功. 流程图如下所示.

在这里插入图片描述

CAS的应用

日常开发时直接使用CAS的情况比较少, 但是在JDK和JVM甚至操作系统中CAS都起到了很多作用.

synchronized在偏向锁和轻量级锁状态时有通过CAS替换数据的状态来判断是否存在竞争; atomic包下的类都是基于CAS实现; AQS(AbstractQueuedSynchronizer)更改状态也是使用CAS; 线程池, CountDownLatch, ReentrantLock等一系列基于AQS实现的也都使用了CAS. 等等.

性能对比

根据上面分析, CSA由于没有加锁普遍会比使用synchronized效率要高. 下面写两段代码, 将两种方式的效率可视化.

第一种方式循环创建2000个线程并执行, 在线程内操作静态属性testSynchronized自增时对类加锁. 通过CountDownLatch等待2000个线程执行完成展示运行结果并计算耗时.

private static int testSynchronized = 0;
final String tag = "testSynchronizedPerformance";
final CountDownLatch countDownLatch = new CountDownLatch(2000);

final long start = System.currentTimeMillis();
log(tag, "start " + start);
for (int i = 0; i < 2000; i++) {
    new Thread(new Runnable() {
        @Override
        public void run() {
            synchronized (TestCAS.class) {
                testSynchronized++;
            }
            countDownLatch.countDown();
        }
    }).start();
}
countDownLatch.await();
log(tag, "value: " + testSynchronized + " duration: " + (System.currentTimeMillis() - start));

第二种方式使用CAS, 这里以AtomicInteger为例. 同样循环创建2000个线程并执行, 线程内使用incrementAndGet方法CAS自增. 计算线程运行耗时和最终的运算结果.

private static AtomicInteger testCAS = new AtomicInteger(0);
final String tag = "testCASPerformance";
final CountDownLatch countDownLatch = new CountDownLatch(2000);

final long start = System.currentTimeMillis();
log(tag, "start " + start);
for (int i = 0; i < 2000; i++) {
    new Thread(new Runnable() {
        @Override
        public void run() {
            testCAS.getAndIncrement();
            countDownLatch.countDown();
        }
    }).start();
}
countDownLatch.await();
log(tag, "value: " + testCAS.get() + " duration: " + (System.currentTimeMillis() - start));

运行结果如下, CAS同synchronized一样都保证了线程安全运算出了正确的结果, 但是CAS的效率明显比synchronized要高, 在当前测试场景下两者效率可以相差一倍. 多次运行后极少数情况会发生CAS比synchronized耗时要高, 例如CAS有1500个线程发生自旋, 而synchronized只有200个线程发生阻塞时. 如果在实际使用时用了更多的线程或更高的并发时可以用LongAdder, LongAdder在CAS的基础之上又使用了分段锁, 在高并发的场景下性能要比AtomicInteger强不少.

testSynchronizedPerformance, start 1578712513327
testSynchronizedPerformance, value: 2000 duration: 599
testCASPerformance, start 1578712513926
testCASPerformance, value: 2000 duration: 349

ABA问题

上面介绍的CAS有一个弊端, 我们已经知道compare那一步比较的是线程私有内存中的值和共享内存中的值是否一致, 如果一致就认为是安全的. 但是在compare时, 共享内存的值有可能数值没发生变化, 而现在的值可能已经不是以前的值了. 这里有点抽象我们还是举个例子, 以线程A的CAS为观察点:

  1. 线程A从共享内存中获取属性tmp的值为1, 让出CPU.
  2. 线程B从共享内存中获取属性tmp的值为1, 自增为2后CAS, CAS成功.
  3. 线程B从共享内存中获取属性tmp的值为2, 自减为1后CAS, CAS成功, 让出CPU.
  4. 线程A自减为0后CAS, CAS成功.

由于CAS只有在最核心的cmpxchg指令才是原子操作, Unsafe::getAndAddInt方法并不能保证原子性. 所以上面的情况是会发生的. tmp的值在线程A CAS的过程中, 线程B改了tmp的值之后又改了回来, 这时tmp的值对于线程A执行cmpxchg指令来说没发生变化, 但是已经变味了. 这就是CAS的ABA问题.

为了规避ABA问题, JAVA提供了一些类例如AtomicStampedReference(也在atomic包下)等. 这些类解决ABA问题的方式是通过添加时间戳或者版本号, 在compare时不仅仅对比两块内存中的值是否一致, 还要校验当前值的时间戳和版本. 都一致的话才能CAS成功.


转载请注明出处:https://blog.csdn.net/l2show/article/details/103909664

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值