Java并发编程 - CAS 详解与自旋锁

一、什么是 CAS

CAS 即比较和交换(Compare And Swap),是一种CPU并发原语,操作逻辑为:在替换内存的某个位置的值时,首先查看内存中的值与预期值是否一致,如果一致则执行替换操作。该操作是一个原子性操作。

CAS 操作包扩三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。 如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值 。否则,处理器不做任何操作。

二、引例:非原子性操作导致的并发问题

例程如下

private static volatile int count = 0;
public static void main(String[] args) throws InterruptedException {
    final int total = 100;
    Thread th1 = new Thread(() -> {
        for (int i = 0; i < total; i++) {
            increment();
        }
    });
    Thread th2 = new Thread(() -> {
        for (int i = 0; i < total; i++) {
            increment();
        }
    });
    th1.start();
    th2.start();
    th1.join();
    th2.join();
    // 多线程操作count自增操作, 由于非原子性, 导致结果与预期的 200 不一致
    System.out.println("count: " + count);
}

private static void increment() {
    try {
        TimeUnit.MILLISECONDS.sleep(50);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    count++;
}

例程中,两个线程 th1th2 共享同一静态变量 count ,且对该变量进行自增操作;由于非同步代码(无锁)执行非原子性操作,会导致运行的结果与预期不一致。

处理该并发问题除了加锁以外,还可以通过 CAS 的方式来解决

三、Unsafe 类和原子类

1、Unsafe 类

Java 中基于 Unsafe 的类提供了对 CAS 操作的方法,由 JVM 负责将方法编译为 CAS 汇编指令。

Unsafe 类一般不直接使用,通过静态方法 Unsafe.getUnsafe() 获取unsafe对象会报 SecurityException 异常

2、自定义原子类(反射 Unsafe,不推荐)

这里给一种不推荐的方式通过反射获取unsafe对象,自定义自增原子类,其原理与 AtomicInteger 类一致。

private static final UnsafeIncrementOperator operator = new UnsafeIncrementOperator(0);
public static void main(String[] args) throws InterruptedException {
    final int total = 100;
    Thread th1 = new Thread(() -> {
        for (int i = 0; i < total; i++) {
            increment();
        }
    });
    Thread th2 = new Thread(() -> {
        for (int i = 0; i < total; i++) {
            increment();
        }
    });
    th1.start();
    th2.start();
    th1.join();
    th2.join();
    // 自定义实现原子类, 结果与预期的 200 一致
    System.out.println("count: " + operator.get());
}

private static void increment() {
    try {
        TimeUnit.MILLISECONDS.sleep(50);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    operator.increment();
}

/**
 * 自定义原子类
 */
public static class UnsafeIncrementOperator {

    private static final Unsafe unsafe;

    private static final long offset;

    private volatile int value;

    public UnsafeIncrementOperator(int value) {
        this.value = value;
    }

    public final int get() {
        return value;
    }

    static {
        try {
            // 反射强行获取 theUnsafe 对象
            Field theUnsafeField = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe");
            theUnsafeField.setAccessible(true);
            unsafe = (Unsafe) theUnsafeField.get(null);
        } catch (Exception e) {
            throw new RuntimeException("Could not initialize intrinsics", e);
        }
        try {
            // 获取 UnsafeIncrementOperator实例 value 字段的偏移地址 (V), 用于底层 CAS 操作
            offset = unsafe.objectFieldOffset(UnsafeIncrementOperator.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    /**
     * 自增方法
     */
    public void increment() {
        // 内部已实现逻辑: 获取值并尝试交换为自增值, 失败则自旋, 直到自增成功
        unsafe.getAndAddInt(this, offset, 1);
    }
}

3、原子类

Java 的 java.util.concurrent.atomic 包内提供了许多可用的原子类,用于完成 CAS 操作,这些类都是基于 Unsafe 的实现,大致分为以下几种。:

  1. 基本类型原子类
    • AtomicBoolean 布尔类型
    • AtomicInteger 整型
    • AtomicLong 长整型
  2. 引用类型原子类 AtomicReference
  3. 数组类型原子类
    • AtomicIntegerArray 整型数组
    • AtomicLongArray 长整型数组
    • AtomicReferenceArray 引用类型数组
  4. 标记类型原子类
    • AtomicMarkableReference 可标记引用类型原子类:可以标记一个布尔值的引用原子类
    • AtomicStampedReference 含版本号应用类型原子类:引用原子类,可以标记一个 int 类型的版本号
  5. 分散原子类:将 CAS 竞争分散,拆成多个 cell,用 base值 + cell数组中元素的值 = 最终值解决
    • LongAdder
    • DoubleAdder
  6. 累加器原子类:可以进行运算操作,XxxAdder 可以看做 XxxAccumulator 的特例
    • LongAccumulator
    • DoubleAccumulator

4、自旋与自旋锁

自旋与自旋锁是两个密切相关却略有不同的概念

(1)自旋(Spinning)

在 Unsafe 类中有一个 getAndAddInt 方法用于完成自增操作

/**
 * 自增
 * @param var1  持有值的对象
 * @param var2  值内存偏移量, 用于 Unsafe 类获取或修改值
 * @param var4  要增加的值
 */
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    // 成功增加则结束, 否则继续循环(自旋)
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
}

该方法的逻辑为:获取原值的可见值,然后与预期的值做 CAS 操作,如果 CAS 操作成功则直接返回;否则继续循环,重新获取原值并 CAS,直到 CAS 成功才会退出。这个循环的过程就是自旋

自旋是一种等待策略,通常用于低延迟的场景,特别是在多处理器系统中。当一个线程尝试获取一个资源(如锁)或进行某些操作而失败时,自旋会让线程继续占用处理器,反复检查是否能成功进行,而不是放弃 CPU 时间片并进入等待队列(Blocked 或 Waiting)。一旦自旋检查条件成功(资源变得可用或操作成功)后,自旋会中止,线程就可以立即获取资源或者进行后续的操作,从而避免了上下文切换的开销。

自旋与 CAS 经常同时使用,特别是实现自旋锁时;但自旋并不意味着一定会有 CAS 操作,同样可以利用其它条件作为等待的判断条件,例如,等待信号量的信号。

(2)自旋锁(Spin Lock)

自旋锁与自旋密不可分。自旋锁是一种同步原语,它是自旋策略在一个特定同步机制(锁)上的应用。当一个线程尝试获取自旋锁时,如果锁已经被其他线程持有,那么请求锁的线程不会立即放弃处理器,而是会自旋,即持续检查锁的状态,直到锁被释放。例如

public class TestMain {

    private int value;

    private int locked = 0;

    private final AtomicReference<Thread> threadLock = new AtomicReference<>(null);

    /**
     * 获取锁, 获取不到则自旋, 直至获取到
     */
    public void lock(String spinningLog) {
        while (!doLock()) {
            System.out.println(spinningLog);
        }
    }

    private boolean doLock() {
        return threadLock.compareAndSet(null, Thread.currentThread());
    }

    private boolean unlock() {
        return threadLock.compareAndSet(Thread.currentThread(), null);
    }

    public static void main(String[] args) throws InterruptedException {
        final int total = 100;
        final TestMain lock = new TestMain();
        Thread th1 = new Thread(() -> {
            for (int i = 0; i < total; i++) {
                lock.lock("th1 自旋中...");
                try {
                    TimeUnit.MILLISECONDS.sleep(10);
                    lock.value++;
                } catch (InterruptedException e) {
                } finally {
                    lock.unlock();
                }
            }
        });
        Thread th2 = new Thread(() -> {
            for (int i = 0; i < total; i++) {
                lock.lock("th2 自旋中...");
                try {
                    TimeUnit.MILLISECONDS.sleep(10);
                    lock.value++;
                } catch (InterruptedException e) {
                } finally {
                    lock.unlock();
                }
            }
        });
        th1.start();
        th2.start();
        th1.join();
        th2.join();
        System.out.println("value: " + lock.value);
    }

}

这里的 TestMain 对象 lock 就是一种简单自旋锁的实现,tryLock 方法通过 CAS + 自旋 完成锁的获取(value 设置为 1),业务执行完成后再通过 unlock 方法释放锁(value 设置为 0),可以看到结果正常输出为 200;

自旋锁的设计目的是为了减少锁的获取和释放过程中的上下文切换开销,尤其是在锁的持有时间很短的情况下,自旋锁可以提供更好的性能。

四、CAS 存在的问题

1、ABA问题

(1)问题描述

CAS在操作值的时候会检查值是否发生变化,如果没有发生变化则更新。但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了,如下图:
ABA问题
过程描述:

  1. 线程 T1 和线程 T2 同时竞争将 str 的值由 A 改为 B,但是 T1 先执行了 A => B,此时 str = “B”,T2 阻塞;
  2. T1修改完成后,原本 T2 应该继续尝试修改,但中途线程 T3 插入,执行了 B => A 的操作,此时 str = “A”,T2 继续阻塞;
  3. T2 结束阻塞,继续尝试修改 A => B,且修改成功,此时 str = “A”。

从结果上看,确实实现了 str 的值由 A 改为 B 的业务,或者说,从最终状态上看是与预期一致的,在一些业务场景下也是可行的;但如果从过程的角度看,T1 和 T2 同时操作 A => B 原本应为一个成功一个失败,仅因为 T3 线程的参与导致两个操作都成功了,在某些特定场景下则是不合适的——
A 到银行打算从账户取出 500 元,账户里一共 1000 元,由于某些原因(误触两次,或后台为多线程业务)导致后台有两个任务是 1000 => 500,但仅有一个任务执行完成、另一个任务阻塞时,刚好 B 在往同一账户转入 500 元(简单认为转入仅为一个线程),完成了 500 => 1000 的操作,这时由于账户里有 1000 元,另一个取出的任务 1000 => 500 依旧能成功执行,此时可能会有两种情况:

  1. 取钱的两个任务都成功了,但后台仅判断为本次取钱成功,只吐出 500 元,但此时账户里只剩下 500,凭空消失 500,B 懵了;
  2. 取钱的两个任务都成功了,但后台认为是进行了两次取钱 500 的操作,吐出了 1000 元,原本只想取 500 的 A 懵了。

用代码模拟该场景下的ABA问题(这里的主线程即为线程 T3)

private static final AtomicInteger count = new AtomicInteger(1000);
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        System.out.println("t1 1000 => 500");
        if (count.compareAndSet(1000, 500)) {
            System.out.println("t1 execute successs - count: " + count.get());
        } else {
            System.out.println("t1 execute fail - count: " + count.get());
        }
    });
    Thread t2 = new Thread(() -> {
        try {
            TimeUnit.MILLISECONDS.sleep(100);
        } catch (InterruptedException e) {}
        System.out.println("t2 1000 => 500");
        if (count.compareAndSet(1000, 500)) {
            System.out.println("t2 execute successs - count: " + count.get());
        } else {
            System.out.println("t2 execute fail - count: " + count.get());
        }
    });
    System.out.println("count: " + count.get());
    t1.start();
    t2.start();
    t1.join();
    System.out.println("t3 500 => 1000");
    if (count.compareAndSet(500, 1000)) {
        System.out.println("t3 execute successs - count: " + count.get());
    } else {
        System.out.println("t3 execute fail - count: " + count.get());
    }
}

输出如下内容,t1 和 t2 都执行成功了,说明出现了 ABA 问题

count: 1000
t1 1000 => 500
t1 execute successs - count: 500
t3 500 => 1000
t3 execute successs - count: 1000
t2 1000 => 500
t2 execute successs - count: 500

(2)解决方案

ABA 问题可以通过引入版本号的方式解决。Java 中提供了 AtomicStampedReference 类,可以指定一个版本号stamp,只有预期值一致且版本号一致时才能更新,例程如下

private static Integer init1000 = Integer.valueOf(1000);
private static Integer init500 = Integer.valueOf(500);
// 注意 AtomicStampedReference 存储的 reference 是对象, 比较时仅做 == 判断而非 equals
private static final AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(init1000, 1);
public static void main(String[] args) throws InterruptedException {
    // 存钱获取的是统一版本号
    int savingStamp = ref.getStamp();
    Thread t1 = new Thread(() -> {
        System.out.println("t1 1000 => 500");
        if (ref.compareAndSet(init1000, init500, savingStamp, savingStamp + 1)) {
            System.out.println("t1 execute successs - count: " + ref.getReference());
        } else {
            System.out.println("t1 execute fail - count: " + ref.getReference());
        }
    });
    Thread t2 = new Thread(() -> {
        try {
            TimeUnit.MILLISECONDS.sleep(100);
        } catch (InterruptedException e) {}
        System.out.println("t2 1000 => 500");
        if (ref.compareAndSet(init1000, init500, savingStamp, savingStamp + 1)) {
            System.out.println("t2 execute successs - count: " + ref.getReference());
        } else {
            System.out.println("t2 execute fail - count: " + ref.getReference());
        }
    });
    System.out.println("count: " + ref.getReference());
    t1.start();
    t2.start();
    t1.join();
    System.out.println("t3 500 => 1000");
    // 取钱单独获取版本号
    int stamp = ref.getStamp();
    if (ref.compareAndSet(init500, init1000, stamp, stamp + 1)) {
        System.out.println("t3 execute successs - count: " + ref.getReference());
    } else {
        System.out.println("t3 execute fail - count: " + ref.getReference());
    }
}

运行结果如下,t1 和 t2 仅有一个成功,说明

count: 1000
t1 1000 => 500
t1 execute successs - count: 500
t3 500 => 1000
t3 execute successs - count: 1000
t2 1000 => 500
t2 execute fail - count: 1000

AtomicStampedReference 类在做 compareAndSwap 操作时,仅做 == 判断,故需要考虑交换值的对象引用问题。示例中由于 Integer 值超出 0~127 的范围内的对象引用不同故定义了静态变量

2、自旋耗时久

(1)问题描述

在上文的自旋锁例子中,可以看到 lock 方法采用自旋策略,会一直进行 CAS 操作 + 循环,直到获取锁,如果某一线程占用自旋锁的时间过长,会导致其它需要该锁的线程会持续占用 CPU 而不做任何有用的工作(空转),消耗 CPU资源。

(2)解决方案

解决 CAS 自旋耗时久的问题,可以有以下两种方案:

  • 给自旋判断加上除 CAS 以外的其它附加条件,例如 CAS 操作最多尝试 n 次,一旦超过这个次数则直接失败或挂起。这样的锁称为自适应自旋锁

按照该方案,将 TestMain 改为如下:

public class TestMain {

    private int value;

    private int locked = 0;

    private final AtomicReference<Thread> threadLock = new AtomicReference<>(null);

    /**
     * 尝试获取锁, 获取不到则自旋, 如果超过获取的次数则直接失败
     */
    public boolean tryLock(String spinningLog, int times) {
        int i = 0;
        boolean locked = false;
        while (!(locked = doLock()) && (times <= 0 || i++ < times)) {
            System.out.println(spinningLog);
        }
        return locked;
    }

    /**
     * 获取锁, 获取不到则自旋, 直至获取到
     */
    public void lock(String spinningLog) {
        while (!doLock()) {
            System.out.println(spinningLog);
        }
    }

    private boolean doLock() {
        return threadLock.compareAndSet(null, Thread.currentThread());
    }

    private boolean unlock() {
        return threadLock.compareAndSet(Thread.currentThread(), null);
    }

    public static void main(String[] args) throws InterruptedException {
        final int total = 100;
        final int times = 3;
        final TestMain lock = new TestMain();
        Thread th1 = new Thread(() -> {
            for (int i = 0; i < total; i++) {
                if (!lock.tryLock("th1 自旋中...", times)) {
                    System.out.println("th1 获取锁失败 !!!");
                    return;
                } else {
                    System.out.println("th1 获取锁成功");
                }
                try {
                    TimeUnit.MILLISECONDS.sleep(10);
                    lock.value++;
                } catch (InterruptedException e) {
                } finally {
                    lock.unlock();
                }
            }
        });
        Thread th2 = new Thread(() -> {
            for (int i = 0; i < total; i++) {
                if (!lock.tryLock("th2 自旋中...", times)) {
                    System.out.println("th2 获取锁失败 !!!");
                    return;
                } else {
                    System.out.println("th1 获取锁成功");
                }
                try {
                    TimeUnit.MILLISECONDS.sleep(10);
                    lock.value++;
                } catch (InterruptedException e) {
                } finally {
                    lock.unlock();
                }
            }
        });
        th1.start();
        th2.start();
        th1.join();
        th2.join();
        System.out.println("value: " + lock.value);
    }

}

这种修改是将超过次数的 CAS 直接返回获取锁失败的状态,以阻止后续不安全的操作。同理,获取锁超出次数限制后还可以将该线程挂起(调用锁对象的 wait 方法),在持有锁的线程释放锁的时候唤醒该锁对象阻塞队列中的所有线程(notifyAll)继续竞争锁。这里不再给出具体实现。

  • 可以在 CAS 一次失败后,将这个操作暂存起来,后面需要获取结果时,将暂存的操作全部执行,再返回最后的结果。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值