Java并发编程(三)—CAS的原理及应用场景

目录

1、CAS的原理

2、CAS的问题

1)ABA问题

2)自旋锁消耗问题

3)多变量共享一致性问题

4)不可中断性

3、CAS的应用

4、总结


1、CAS的原理

CAScompare and swap比较与交换,是一种乐观锁机制,也叫无锁机制。即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)

CAS 中涉及三个要素:

  • 需要读写的内存值 V

  • 进行比较的值 A

  • 拟写入的新值 B

当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做

CAS的原子性

CAS操作是原子的,这意味着它要么完全执行成功,要么完全不执行。即使在多线程环境中,也不会有其他线程能够干扰CAS操作的执行

在之前文章Java并发—volatile关键字的作用及使用场景-CSDN博客中使用了volatile关键字修改变量x的过程,但修改和写回操作不是原子性,还是会有数据不一致性的问题,CAS就可以解决这个问题,那使用CAS操作来修改共享变量x,流程又会是如何呢?


还是使用之前的案例:🌰线程A和线程B从主内存读取和修改x=1的过程

步骤:

        1.初始化:x=1,存储在主内存

        2.线程A读取:A从主内存读取,复制x=1到A的工作内存

        3.线程B读取:B从主内存读取x,复制x=1到B的工作内存

        4.线程A修改:A在工作内存中修改x=2

        5.线程A尝试CAS写回:A将工作内存中的x=2与主内存中的x=1进行比较

                如果主内存中仍然是x=1(预期值),则CAS操作成功,x=1更新至x=2

                如果主内存中x≠1(被其他线程修改),则CAS操作失败,线程A需要重新读取更新的值并再次尝试重新读取最新的值

        6.线程B修改:B在工作内存中修改x=3

        7.线程B尝试CAS写回

                如果主内存中x=1(这是B读取时的值,但此时可能已经被A修改x=2),也就是线程B以为主内存中是x=1(预期值),但实际上主内存是x=2(实际值),预期值与实际值不符,则 CAS操作失败          

                B需要重新读取最新的值x=2,并尝试CAS操作

        8.线程B重新读取:x=2

        9.线程B再次尝试CAS写回:B再次尝试将x=2更新至x=3,如果此时没有其他线程修改x,则CAS操作成功

由上述流程可以看出当使用CAS操作来修改共享变量x时,与volatile修改共享变量x流程会有所不同,CAS也成功得解决了上一篇文章中遗留问题


2、CAS的问题

看起来好像又没问题了,但CAS会出现ABA问题

1)ABA问题

什么是ABA问题呢?

ABA 问题说的是,如果一个变量第一次读取的值是 A,准备好需要对 A 进行写操作的时候,发现值还是 A,那么这种情况下,能认为 A 的值没有被改变过吗?可以是由 A -> B -> A 的这种情况

那如何解决呢?

为了解决ABA问题,常见的做法是在CAS操作中加入版本号标记。例如,可以使用 AtomicStampedReference 类,它不仅保存了值,还保存了一个版本号或标记。每次值发生变化时,版本号都会递增。这样,在进行CAS操作时,compareAndSet()不仅要比较值是否相同,还要比较版本号是否相同,从而避免了ABA问题。

这样,即使值回到了原来的状态,只要版本号不同,CAS操作就会失败,从而确保了操作的正确性

 AtomicInteger 使用 CAS 操作来实现线程安全的整数操作。

来看一下AtomicInteger的源码,AtomicInteger 的核心在于使用了 Unsafe 类的 native() 来执行 CAS 操作

private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;

static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
}

private volatile int value;
  • UnSafe 是一个 CAS 的核心类,由于 Java 无法直接访问底层系统,需要通过本地 native() 进行访问 UnSafe 相当于一个后门,基于该类可直接操作特定的内存数据

  •  valuevolatile 修饰,保证了多线程之间内存可见性

  • valueOffset它表示 AtomicInteger 类中 value 字段相对于对象起始地址的偏移量,valueOffset 计算了 value 字段相对于对象起始地址的位置

AtomicInteger 提供了 compareAndSet 方法来执行 CAS 操作。这个方法接收期望的旧值和新值,如果当前值等于期望的旧值,则原子地更新为新值,并返回 true,否则返回 false

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

Unsafe.compareAndSwapInt 方法是一个 native() 方法,它允许在 Java 中直接使用底层硬件提供的 CAS 指令来实现原子操作,工作流程如下:

  1. 读取当前值:首先读取对象中指定偏移量位置的值
  2. 比较值:将读取到的值与 expectedValue 进行比较
  3. 条件更新:如果当前值与 expectedValue 相等,则使用 CAS 操作将值更新为 newValue;否则,不进行任何操作
  4. 返回结果:返回一个布尔值,表示 CAS 操作是否成功。如果成功,则返回 true;否则返回 false

如果 CAS 操作失败(即当前值与期望值不匹配),则表明有其他线程已经修改了该值,此时调用方可以选择重试操作或采取其他措施

例如:

AtomicInteger value = new AtomicInteger(0);

// 尝试将value从0更新为5,如果当前值是0
boolean result = value.compareAndSet(0, 5);
System.out.println("Update successful: " + result);
System.out.println("New value: " + value.get());

上面是AtomicInteger展示了原子操作,但实际上并不能解决ABA问题

一般大家比较熟悉的是AtomicInteger, AtomicLong, AtomicReference ,那与AtomicStampedReference 失败有什么不同呢?

AtomicStampedReferenceAtomicInteger 都是 Java 并发工具包(java.util.concurrent.atomic 包)中提供的原子类,用于在多线程环境中提供原子操作

在包中,只用AtomicStampedReference ,AtomicStampedReference 是一个更通用的原子引用类,它不仅提供了原子的引用更新,还提供了一个“戳记”(stamp)或版本号,用于解决原子更新中的ABA问题

AtomicStampedReference 保存了一个对象引用和一个与之关联的整型戳记,每次更新引用时,戳记也会被更新

这样,即使引用回到了原始值,通过检查戳记也可以知道引用实际上曾经被修改过


🌰:假设线程A和线程B,都在尝试购买同一活动的门票

可能会出现:

        1、线程A读取门票状态:剩余100张。

        2、线程B读取门票状态:同样看到剩余100张。

        3、线程A尝试购买:线程A尝试购买10张门票,将剩余数量更新为90张。

        4、线程B尝试购买:在不知道线程A已经购买的情况下,线程B也尝试购买10张门票,但由于没有版本号,线程B认为剩余数量仍然是100张

问题:

        ABA问题:尽管线程B读取的门票状态和尝试更新后的状态与线程A的相同(即从100到90),但由于线程B没有检查版本号,它会错误地覆盖线程A的更新,导致实际上只减少了10张门票,而不是20张

后果:

        业务逻辑错误:系统可能会错误地允许更多的用户购买超出实际剩余数量的门票,导致超售或欠售问题。

        用户体验差:用户可能会遇到不可预测的错误或不公平的购票机会

如何使用AtomicStampedReference解决呢?

public class TicketSalesSystem {

    // 创建 AtomicStampedReference 对象,用于原子操作更新门票引用和版本号
    private final AtomicStampedReference<Ticket> ticketRef = new AtomicStampedReference<>(new Ticket("EVT123", 100), 0);

    // 函数用于尝试购买门票
    public boolean buyTicket(String eventId, int ticketsToBuy) {
        Ticket currentTicket;
        int stamp;
        do {
            // 读取当前的门票和版本号
            currentTicket = ticketRef.getReference();
            stamp = ticketRef.getStamp();

            // 检查门票是否属于正确的活动
            if (!currentTicket.getEventId().equals(eventId)) {
                return false;
            }

            // 检查是否有足够的剩余门票
            if (currentTicket.getRemainingTickets() < ticketsToBuy) {
                return false;
            }

            // 减少剩余门票数量
            currentTicket.setRemainingTickets(currentTicket.getRemainingTickets() - ticketsToBuy);

        } while (!ticketRef.compareAndSet(currentTicket, currentTicket, stamp, stamp + 1));

        System.out.println("您成功购买 " + eventId + "活动" ticketsToBuy + "张票");
        return true;
    }
}

模拟抢票

   TicketSalesSystem system = new TicketSalesSystem();

        // 线程A尝试购买门票
        Thread threadA = new Thread(() -> system.buyTicket("EVT123", 10));
        
        // 线程B尝试购买门票
        Thread threadB = new Thread(() -> system.buyTicket("EVT123", 10));

        // 启动线程
        threadA.start();
        threadB.start();

        try {
            // 等待线程结束
            threadA.join();
            threadB.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 输出最终的门票状态
        System.out.println("最终买票状态: " + system.ticketRef.getReference());

在这个例子中,TicketSalesSystem 类使用 AtomicStampedReference 来原子地更新门票的状态。buyTicket 方法尝试购买一定数量的门票,如果当前门票的剩余数量足够,它会减少剩余门票的数量。compareAndSet 方法用于确保更新操作是原子的,并且解决了ABA问题。

如果在尝试更新门票时遇到ABA问题,compareAndSet 方法会返回 false,这时 buyTicket 方法会重新读取当前的门票和版本号,然后再次尝试更新,直到成功为止

但值得注意的是:这里只是修改ticketsToBuy一个变量,由上可知CAS只能保证单一变量的原子性,那多个变量呢?就需要多个CAS操作和其他技术


案例:假设线程A和线程B,都在尝试购买不同活动的门票

 TicketInfo 类来封装购票数量和活动 ID,使用 AtomicReference 类,以原子地更新一个对象引用


public class TicketWithCAS {
    private AtomicInteger remainingTickets = new AtomicInteger(100); // 剩余票数
    private AtomicReference<TicketInfo> ticketInfo = new AtomicReference<>(new TicketInfo(0, 0)); // 购票信息

    public void buyTicket(int ticketsToBuy, int eventId) {
        while (true) {
            int currentRemaining = remainingTickets.get();
            if (currentRemaining >= ticketsToBuy) {
                TicketInfo currentInfo = ticketInfo.get();
                if (ticketInfo.compareAndSet(currentInfo, new TicketInfo(ticketsToBuy, eventId))) {
                    if (remainingTickets.compareAndSet(currentRemaining, currentRemaining - ticketsToBuy)) {
                        System.out.println(Thread.currentThread().getName() + " 成功购买 " + ticketsToBuy + " 张票");
                        System.out.println("剩余票数: " + remainingTickets.get());
                        break;
                    } else {
                        // 如果剩余票数发生变化,需要重试
                        continue;
                    }
                }
            } else {
                System.out.println(Thread.currentThread().getName() + " 购票失败,剩余票数不足");
                break;
            }
        }
    }
}

模拟购票:

// 创建 TicketWithCAS 对象
TicketWithCAS ticketWithCAS = new TicketWithCAS();

// 创建线程模拟购票
Thread thread1 = new Thread(() -> { ticketWithCAS.buyTicket(10, 1);}, "Thread 1");

Thread thread2 = new Thread(() -> { ticketWithCAS.buyTicket(20, 2);}, "Thread 2");

Thread thread3 = new Thread(() -> { ticketWithCAS.buyTicket(30, 3);}, "Thread 3");

// 启动线程
thread1.start();
thread2.start();
thread3.start();

使用 CAS 来保证多个变量的一致性较为复杂,因为 CAS 操作只能保证单个变量的原子性。为了实现多变量的一致性,我们可以使用 AtomicReference 类,它可以用来原子地更新一个对象引用


因此,为了避免这些问题,我们需要引入版本号或类似机制(例如:synchronized关键字和ReentrantLock

实际上,AtomicStampedReference 实际上是一种版本号机制

说到版本号机制,在实际工作中,通常选择使用数据库自带的 version 字段作为版本号

2)自旋锁消耗问题

 使用 CAS 操作时,当 CAS 操作失败时,线程通常会进入自旋状态,不断尝试重新执行 CAS 操作(通常是一个 do-while 循环),直到成功为止。这种自旋重试机制在某些情况下可能会导致 CPU 资源的过度消耗,尤其是在高并发场景下

如果某个线程一直取到的值和预期值都不一样,这样就会无限循环

解决:在自旋次数达到一定阈值后,切换到阻塞模式,使用传统的锁机制

3)多变量共享一致性问题

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

当需要对多个变量进行原子操作时,CAS 只能保证单个变量的原子性,而无法保证多个变量之间的原子性。这可能会导致数据的一致性问题

解决

        ①对于两个或更多变量,可以使用复合 CAS 操作来实现。这通常涉及到将多个变量组合成一个复合对象,并使用 AtomicReference 来更新这个复合对象

        ②使用锁或其他并发控制机制来保证多个变量的一致性,如 ReentrantLock 或 synchronized 关键字

4)不可中断性

CAS 操作通常是不可中断的,这意味着在进行 CAS 操作时,如果发生中断,可能会导致线程挂起或行为异常。在某些情况下,这可能会导致死锁或资源泄漏等问题

解决:在可能的情况下,使用可中断的锁或其他机制,以便在必要时能够中断线程,如ReentrantLock

3、CAS的应用

①无锁数据结构:实现线程安全的无锁队列、栈、哈希表等数据结构,如ConcurrentLinkedQueue, ConcurrentSkipListMap,它们利用 CAS 来保证在高并发下的性能和一致性

并发集合:如 ConcurrentHashMap 使用 CAS 结合锁分段技术来实现高效的并发读写操作

无锁计数器:使用CAS实现一个高效的无锁计数器,避免了使用同步锁带来的性能开销

4、总结

CAS通过比较并交换的方式来更新共享变量,避免了使用传统锁机制带来的性能开销,但在高并发场景下可能会遇到自旋消耗CPU资源和ABA问题等


下一篇:Java并发编程(四)—synchronized关键字的应用-CSDN博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值