Java 原子类

发布时间 2023-03-25 10:05:35作者: Dazzling!

Java 内部提供了两种方式来解决线程安全问题,一种是加入synchronized 关键字,另一种则是使用 Lock 锁。虽然说这两种方式都能解决掉线程安全的问题,但是在某些场景下会稍微有些麻烦,例如下边这个场景,每次请求接口都会对 reqCount 做一次加一操作:

@RestController
@RequestMapping(value = "/test")
public class TestController {

    private int a = 10;
    private int b = 10;
    private int c = 10;

    @GetMapping(value = "/do-count")
    public void reduce() {
        synchronized (this) {
            if(a==0 || b==0 || c==0){
                System.out.println(a + "," + b + "," + c);
                return;
            }
            a--;
            b--;
            c--;
            System.out.println(a + "," + b + "," + c);
        }
    }
}

如果不希望 reqCount 出现统计失误的话,加入一把锁确实是一个合理的操作。但是除了这种方式之外,是否还有更加简单的方法来解决这种线程安全呢?

其实早在 JDK1.5 之后的 java.util.concurrent.atomic 包中就已经有相应的解决方式了。这个包中对各种常用的数据类型都提供了一个原子性操作的封装,能够保证在多线程环境下的数据一致性。下边就让我们一起深入了解下 JDK 包中的原子类吧。

JDK中的原子类介绍

我们可以将 JDK 中常见的原子类做个简单的分类,下边我们通过一系列的实战案例来认识下这几种原子类的使用方式。

原子更新基本类型

@Test
public void testAtomicInteger(){
    AtomicInteger atomicInteger = new AtomicInteger(1);
    System.out.println(atomicInteger.addAndGet(1));
    System.out.println(atomicInteger.getAndAdd(1));
}

@Test
public void testAtomicBoolean(){
    AtomicBoolean atomicBoolean = new AtomicBoolean(true);
    atomicBoolean.set(false);
    System.out.println(atomicBoolean.get());
}

原子更新数组

@Test
public void testAtomicIntegerArray(){
    int[] value = new int[]{0, 1, 2};
    AtomicIntegerArray ata = new AtomicIntegerArray(value);
    //先指定数组的下标位,在指定需要增加的数值
    ata.addAndGet(0,1);
    System.out.println(ata);
}

原子更新引用对象

@Test
public void testAtomicReference(){
    AtomicReference<String> stringAtomicReference = new AtomicReference<>("idea");
    System.out.println(stringAtomicReference.compareAndSet("idea","idea2"));
    System.out.println(stringAtomicReference.compareAndSet("idea2","idea"));
    System.out.println(stringAtomicReference.compareAndSet("idea","idea2"));
}


/**
* 更新整个对象的类型
*/
@Test
public void updateAccount(){
    AtomicReference<Account> accountAtomicReference = new AtomicReference<>();
    Account account = new Account();
    account.setAge(1);
    account.setId(1);
    account.setName("idea");
    accountAtomicReference.set(account);
    Account account2 = new Account();
    account2.setAge(2);
    account2.setId(2);
    account2.setName("idea2");
    accountAtomicReference.compareAndSet(account,account2);
    System.out.println(accountAtomicReference.get().getAge());
    System.out.println(accountAtomicReference.get().getId());
    System.out.println(accountAtomicReference.get().getName());
}

原子更新引用对象字段

 /**
* 更新指定对象的某个字段
*/
@Test
public void updateAccountField(){
    Account account = new Account();
    account.setAge(1);
    account.setId(1);
    account.setName("idea");
    //age字段一定要为volatile类型
    AtomicIntegerFieldUpdater<Account> accountAtomicReference = AtomicIntegerFieldUpdater.newUpdater(Account.class,"age");
    Account account2 = new Account();
    account2.setAge(2);
    account2.setId(2);
    account2.setName("idea2");
    //这里输出的数值中,age会加2
    System.out.println(accountAtomicReference.incrementAndGet(account2));

}

通过这些简单的案例,我们可以发现,原子类所提供的方法都是比较简单易懂的。但为什么说,简单调用原子类的接口对数据进行修改,可以预防线程安全问题呢?想要了解这个原理,我们就需要深入到底层,去查看原子类在对不同数据进行修改时的实现逻辑。

下边我们以 AtomicInteger.addAndGet 方法为例,来看看它在底层实现上是如何避免线程安全问题的。

点开该方法的源码实现部分,可以看到以下信息:

public final int addAndGet(int delta) {
    //对应实现在下边
    return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}

//cas修改变量数值
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;
}

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

在 JDK 的原子类内部,很多对于数据的修改,其实都利用到了 Unsafe 包的代码。在 sum.misc 包中的 Unsafe 类里面,提供了许多硬件级别的原子操作,可以支持类似于 C 的指针一样的功能,直接指定内存地址进行操作。但是这个包对于很多程序员来说,并不是太提倡使用,因为稍微一不注意,就有可能将其他内存地址的数据给修改了。

其实上边这段源代码展示的核心并不在于使用了 Unsafe,而是原子类对于无锁操作实现的设计思想。这里的 compareAndSwapInt 方法就是 CAS 的具体体现了。

那么什么是CAS呢?为什么使用CAS就能避免线程安全问题呢?

别急,下边听我一一道来。

深入理解 CAS

首先,让我们来看看 CAS 的全称是如何定义的。Compare And Swap,这个公式是 CAS 的全称,意思就是比较并且交换,这种方式也是无锁算法的一种思路,其思路如下所示:

在 compareAndSwap(V,E,N) 函数中,V 表示要被更新的变量值,E 表示预期值,N 表示期望更新的变量值。在调用 compareAndSwap 函数的时候会判断,是否当前变量的数值和 E 是相同的,如果相同则进行修改,否则就说明当前有其他线程修改该值,从而放弃本次修改操作。

通常 CAS 操作会结合一个循环一起使用,当尝试执行 cas 操作却修改失败之后,就会重新将 V 值读出来赋予给 E,然后接着再执行修改操作,直达最终修改成功为止。这个不断尝试修改的过程,我们一般称之为自旋操作。所以常见的 cas 结合循环重试的整体流程如下图所示:

img

现在我们再回过头来看看 JDK 底层对于 CAS 操作的实现部分:

//cas修改变量数值
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;
}

通过源代码可以看到,JDK 的底层是通过循环尝试修改对象 o 的偏移处的值,最后判断当前的内存值是否是 v,是则修改,否则重新尝试。

而 compareAndSwapInt 函数其实本质是一个 native 函数,它在 openJdk 中的实现是调用了 CPU 的 cmpxchg 指令,具体的源代码体现在这个地址可以看到。这条执行会比较寄存器中的 V 和 E 是否相同,并且根据实际情况做修改。

现在看来,似乎 CAS 操作还是挺合理的,通过底层调用了 CPU 的 cmpxchg 指令去直接对内存地址的数据做原子性修改,如果修改失败了还会进行重试,这相比之前的加锁机制要简单了许多。但是这种实现方式是否也存在一定缺陷呢?

答案是肯定的,CAS 也有一定的缺点,下边我列举了一些 CAS 操作存在的不足点。

自旋导致CPU消耗升高

当一个原子类在进行CAS操作过多的时候,会导致CPU一直被占用,一直消耗资源。通常这种情况发生在高并发场景下会比较多。

ABA问题

CAS 需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是 A,后来变成了 B,然后又变成了 A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。

ABA 问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。

JDK 从 1.5 开始提供了 AtomicStampedReference 类来解决 ABA 问题,具体操作封装在 compareAndSet() 中。

compareAndSet() 首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。

不过目前来说这个类比较”鸡肋”,大部分情况下 ABA 问题并不会影响程序并发的正确性,如果需要解决 ABA 问题,使用传统的互斥同步可能比原子类更加高效。

只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS 能够保证原子操作,但是对多个共享变量操作时,CAS 是无法保证操作的原子性的。