Java 并发实战:深入解析 CAS (Compare and Swap) 算法及其实现

在现代高并发应用的开发中,如何保证数据在多线程环境下的安全性,同时又能保持系统的高性能,一直是我们面临的核心挑战。如果仅仅依赖传统的 synchronized 锁机制,虽然安全,但在高竞争场景下往往会引起线程频繁的上下文切换,导致性能瓶颈。

那么,有没有一种方法,既能让我们不加锁(避免线程挂起),又能保证共享变量的原子性操作呢?答案是肯定的。这就是我们今天要深入探讨的主角——比较并交换

在这篇文章中,我们将通过实际代码和底层原理,带你理解 CAS 是如何工作的,它为何能比锁机制更快,以及在 Java 开发中我们该如何通过 Atomic 类库来优雅地使用它。无论你是正在构建高性能后端系统,还是对并发编程原理感兴趣,这篇深度解析都将为你提供实用的知识和见解。

什么是 CAS (Compare and Swap)?

比较并交换(Compare and Swap,简称 CAS),其核心思想非常直观:它包含三个操作数——内存值 (V)预期原值 (A)新值 (B)

我们在执行更新操作时,会先将内存中的当前值 V 与我们的预期值 A 进行比较:

  • 如果相等:说明在我们准备操作的这一瞬间,没有其他线程修改过该变量,我们可以安全地将内存值 V 更新为新值 B。
  • 如果不相等:说明在这期间有其他线程抢先修改了变量,那么本次操作失败(或者你可以选择重新尝试,这也就是自旋锁的由来)。

为什么要用 CAS?

为了理解 CAS 的价值,我们需要先回顾一下在多线程环境下最常见的一个陷阱——“检查再行动” 模式,也就是我们常说的 Check-Then-Act。

#### 危险的“检查再行动”模式

想象一下,你正在编写一个简单的互斥锁逻辑。代码看起来可能像这样自然且易懂:

// 危险的代码示例:不要在生产环境中使用!
class UnsafeLock {
    private boolean locked = false;

    public boolean lock() {
        // 1. 检查:如果锁是空闲的
        if (!locked) {
            // 模拟一些耗时操作,增加并发冲突发生的概率
            try { Thread.sleep(10); } catch (Exception e) {}
            
            // 2. 行动:将锁设置为占用状态
            locked = true;
            return true;
        }
        return false;
    }
}

这段代码在单线程环境下完美运行,但在多线程环境下却是一场灾难。

让我们推演一下:假设 线程 A线程 B 同时调用 lock() 方法。

  • 线程 A 读取 INLINECODEc5ad0316 为 INLINECODE33f98ffd,觉得太棒了,可以通过。
  • 就在线程 A 还没来得及执行 INLINECODEbd9f56a2 的那一瞬间(甚至在它 sleep 的时候),线程 B 也读取了 INLINECODE6e4eefbd 变量。
  • 因为线程 A 还没来得及修改它,线程 B 读到的依然是 false
  • 结果:两个线程都认为锁是空闲的,都成功进入了临界区。并发安全荡然无存。

#### 传统解决方案:synchronized

为了解决这个问题,我们通常会引入 synchronized 关键字,将“检查”和“行动”这两个步骤原子化,强行让它们不可分割。

class SafeLock {
    private boolean locked = false;

    // 使用 synchronized 关键字保证原子性
    public synchronized boolean lock() {
        if (!locked) {
            locked = true;
            return true;
        }
        return false;
    }
}

这样确实安全了。但是,这种做法的代价是重量级的锁机制。当线程竞争激烈时,没有抢到锁的线程会被挂起(阻塞),操作系统需要介入进行线程上下文切换。这个过程消耗的资源远多于代码执行本身。对于仅仅修改一个 boolean 变量这样的简单操作,这种开销显得有些“杀鸡用牛刀”。

#### 终极解决方案:CAS 算法

如果我们能在硬件层面保证“检查”和“赋值”这两个动作是原子的,是不是就不需要加锁了?是的,这正是 CAS 的威力所在。

CAS 依靠的是底层硬件指令(在大多数现代处理器上是 cmpxchg 指令),从而在操作系统层面保证了这个操作的原子性,不需要操作系统的调度介入。这意味着它更快,不会引起线程挂起。

在 Java 中实现 CAS:Atomic 类库

从 Java 5 开始,java.util.concurrent.atomic 包为我们提供了一系列类,让我们可以轻松使用 CAS 算法。这些类通常被称为原子变量类

最常用的包括:

  • AtomicBoolean
  • AtomicInteger
  • AtomicLong
  • AtomicReference

它们内部都封装了一个核心方法:compareAndSet(expect, update)。这个方法会直接调用底层的 CAS 操作。

#### 示例 1:使用 AtomicBoolean 重写锁机制

让我们回到之前的锁的例子。如果我们使用 AtomicBoolean,代码会变得极其简洁且高效。

import java.util.concurrent.atomic.AtomicBoolean;

public class CASLockDemo {
    
    // 初始值为 false
    private static AtomicBoolean locked = new AtomicBoolean(false);

    public static void main(String[] args) {
        // 模拟多个线程尝试获取锁
        Runnable task = () -> {
            String threadName = Thread.currentThread().getName();
            // 尝试获取锁
            boolean isLocked = tryLock();
            
            if (isLocked) {
                System.out.println(threadName + " 成功获取了锁!正在执行关键任务...");
                try {
                    // 模拟业务操作
                    Thread.sleep(1000); 
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    unlock();
                    System.out.println(threadName + " 释放了锁。");
                }
            } else {
                System.out.println(threadName + " 获取锁失败,资源正忙。");
            }
        };

        // 启动 3 个线程进行测试
        new Thread(task, "线程 A").start();
        new Thread(task, "线程 B").start();
        new Thread(task, "线程 C").start();
    }

    /**
     * 尝试加锁的方法
     * 逻辑:如果当前值是 false(未锁),则将其设为 true(已锁)
     */
    public static boolean tryLock() {
        // compareAndSet 的意思是:
        // "我看了一眼当前的值,如果是 false,我就把它改成 true 并返回 true。
        //  如果不是 false(说明被别人改了),我就什么都不做,并返回 false。"
        return locked.compareAndSet(false, true);
    }

    /**
     * 释放锁
     * 注意:为了简单起见,这里直接设为 false。
     * 在实际生产代码中,通常需要检查当前线程是否真的是持有锁的线程。
     */
    public static void unlock() {
        locked.set(false);
    }
}

代码解析:

在这个例子中,INLINECODE2cd1d6a3 方法不再需要 INLINECODE0a76d20d。INLINECODEf3ecf033 这一行代码利用了 CPU 的 CAS 指令。在同一时刻,只有一个线程能观察到 INLINECODE80dbd125 并将其成功更新为 INLINECODEa9e88172,其他线程会看到 INLINECODEe35d1577,从而 CAS 操作失败,返回 false

#### 示例 2:并发计数器 AtomicInteger

除了布尔锁,最常见的应用场景就是计数器。比如,我们需要在多线程环境下统计网站访问量。

如果使用普通的 INLINECODE8c6fbb5b,这在字节码层面其实包含了“取值、加一、写回”三个步骤,并非原子操作。使用 INLINECODE9113c199 可以轻松解决这个问题。

import java.util.concurrent.atomic.AtomicInteger;

public class CounterDemo {

    // 创建一个原子整数,初始值为 0
    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                // 使用 incrementAndGet 方法原子性地将值加 1
                count.incrementAndGet();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        
        t1.start();
        t2.start();
        
        t1.join();
        t2.join();

        // 期望输出 2000,因为每个线程加了 1000 次
        System.out.println("最终计数值: " + count.get()); 
    }
}

深入理解 compareAndSet

你可能会问,incrementAndGet() 内部是怎么实现的?其实它就是在一个循环中不断调用 CAS。我们可以手动模拟一下这个过程,这能让你更深刻地理解 CAS 的“乐观”特性。

import java.util.concurrent.atomic.AtomicInteger;

public class ManualCASDemo {
    
    public static void main(String[] args) {
        AtomicInteger val = new AtomicInteger(0);

        // 模拟 updateAndGet 的逻辑:将原值加 10
        int updateResult = updateAndGet(val, 10);
        System.out.println("更新后的值: " + updateResult);
    }

    /**
     * 模拟原子更新操作:取当前值,加上 delta,然后更新回变量。
     * 我们手动实现这个逻辑来展示 CAS 的工作流程。
     */
    public static int updateAndGet(AtomicInteger atomic, int delta) {
        int prev;
        int next;
        do {
            // 1. 先获取当前的值
            prev = atomic.get();
            // 2. 计算新的值
            next = prev + delta;
            // 3. 尝试更新。
            // compareAndSet(prev, next) 的意思是:
            // "如果我刚才看到的 prev 还是现在的内存值,就把它改成 next。"
            // 如果返回 false,说明在计算 next 的时候,别的线程修改了值,
            // 这时 do-while 循环会重新读取最新值,重新计算,重新尝试,直到成功为止。
        } while (!atomic.compareAndSet(prev, next));
        
        return next;
    }
}

这个 do-while 循环就是所谓的自旋。我们并没有阻塞线程,而是一直在尝试直到成功。这通常比阻塞等待更高效,但在竞争极其激烈的情况下(大量线程同时修改同一个变量),可能会导致 CPU 空转(忙等待),浪费资源。

常见的原子类概览

除了基本的 INLINECODE92de967e 和 INLINECODE44b35317,Java 还提供了更多强大的原子类来适应不同的数据结构:

  • INLINECODE02bd56fd: 用于对 INLINECODE54507cf1 类型进行原子操作,提供了 INLINECODE61f6483a、INLINECODE29c151bc 等方法。
  • INLINECODE6255381b: 适用于 INLINECODEd37d9999 类型,常用于生成序列号或高精度的时间戳。
  • AtomicReference: 可以原子性地更新对象引用。这对于实现无锁的数据结构(如无锁链表节点)非常重要。
  • 数组类型: INLINECODE95783df4, INLINECODE9888cdc3, AtomicReferenceArray。它们可以保证对数组中某个元素的修改是原子的。

#### 示例 3:AtomicReference 使用场景

假设你有一个“当前最佳配置”对象,多个线程可能会尝试更新它,只有更新后的版本比旧的版本更新时才允许替换。

import java.util.concurrent.atomic.AtomicReference;

class Config {
    int version;
    String data;
    
    public Config(int version, String data) {
        this.version = version;
        this.data = data;
    }
}

public class ReferenceCasDemo {
    
    public static void main(String[] args) {
        // 初始配置,版本 1
        Config configV1 = new Config(1, "Initial Settings");
        AtomicReference atomicConfig = new AtomicReference(configV1);

        Thread updater = new Thread(() -> {
            // 我们想更新到版本 2
            Config newConfig = new Config(2, "Optimized Settings");
            
            // 使用 CAS 更新对象引用
            // 只有当 atomicConfig 当前的引用指向 configV1 时,才会替换为 newConfig
            boolean success = atomicConfig.compareAndSet(configV1, newConfig);
            
            if(success) {
                System.out.println("更新成功: 版本 " + atomicConfig.get().version);
            } else {
                System.out.println("更新失败: 配置已被其他线程修改");
            }
        });

        updater.start();
    }
}

CAS 的局限性与 ABA 问题

虽然 CAS 很强大,但它并不是万能药。在实际开发中,我们必须警惕一个经典的问题:ABA 问题

什么是 ABA 问题?

假设一个变量初始值是 A。

  • 线程 1 读取到了 A,准备把它改成 C,但动作慢了点。
  • 线程 2 进场,迅速把 A 改成了 B,然后又迅速把 B 改回了 A。
  • 线程 1 现在开始执行 CAS,它检查内存中的值,发现依然是 A
  • 线程 1 认为“没人动过它”,于是成功把值改成了 C。

后果: 虽然值看起来没变,但状态可能已经发生了变化。比如在链表操作中,节点被删除又重新加入,虽然引用地址一样,但链表结构可能已经变了。
解决方案:

为了解决这个问题,Java 提供了 INLINECODE8c69c989INLINECODE1664a185。它们的思路是给变量加上一个版本号标记

原本的比较 INLINECODE5a20eb26 变成了比较 INLINECODE7ddc8143。即使值回到了 A,但版本号已经变了(比如从 1 变成了 3),CAS 就会失败。

性能优化与最佳实践

在结束之前,让我们聊聊如何在实际项目中更好地使用 CAS。

  • 避免在高竞争下使用简单的 CAS 循环:如果多个线程激烈竞争同一个变量,大量的 CPU 时间会浪费在自旋重试上。这时,可以考虑使用 LongAdder(Java 8 引入),它在高并发下通过分散热点来提高性能,而不是在一个变量上死磕。
  • CAS 只是手段,不是目的:它能保证单个变量的原子性。如果你涉及到多个变量(例如:“同时更新账户余额和操作日志”),你仍然需要使用 INLINECODE79e1b0d0 或 INLINECODE9d1d2054 来保证复合操作的原子性。
  • 内存可见性:INLINECODE657c479a 类不仅保证了原子性,还通过 INLINECODE18291560 语义保证了可见性。这意味着当一个线程修改了原子变量,其他线程能立即看到最新的值,这比普通的变量操作更可靠。

总结

在这篇文章中,我们一起深入探讨了 比较并交换 (CAS) 算法。我们从最基础的并发安全问题出发,理解了“检查再行动”模式的风险,并通过对比 synchronized 锁机制,发现了 CAS 作为无锁(Lock-Free)算法的核心价值。

我们学会了:

  • 如何使用 INLINECODE8e408abe、INLINECODEe1be81db 和 AtomicReference 来编写线程安全且高效的代码。
  • CAS 在底层是如何通过 compareAndSet 配合硬件指令实现原子性的。
  • incrementAndGet 等方法背后隐藏的自旋逻辑。
  • 了解了 CAS 可能带来的 ABA 问题及其解决方案(带版本号的引用)。

掌握 CAS 是通往高级 Java 并发编程的必经之路。它让我们在追求高性能的同时,依然能够保证系统的稳定性。下次当你遇到简单的并发计数、状态标记或序列号生成需求时,请优先考虑 java.util.concurrent.atomic 包中的这些利器。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/34790.html
点赞
0.00 平均评分 (0% 分数) - 0