qinghuan
← JUC + JVM / 全部笔记

单元 89 / JUC + JVM

P0 · 必会主线L2 · 进阶

JUC 锁与 CAS:保护的是变量还是完整业务规则

先写清库存、余额等数据必须共同满足的规则,再判断互斥锁或 CAS 能否保护完整操作。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能根据一个库存与订单状态的约束,判断使用 CAS 还是统一临界区。

学习理由、准备要求与知识点

为什么学它

单变量的原子操作,不等于多个变量共同满足业务条件(不变量);选错同步范围仍会产生不符合业务要求的状态。

先读这些内容

知识点清单

  • 互斥锁
  • 可重入
  • finally 解锁
  • CAS
  • 重试循环
  • ABA
  • 单变量原子性
  • 跨字段不变量

运行前先确认JDK 17 API,源码固定 OpenJDK 17.0.12+7。算法为单 JVM 内不可变快照与 AtomicReference CAS,不提供跨进程或数据库事务保证。

01先弄懂它是什么

选择锁之前,先写出绝不能被破坏的业务规则,例如库存不能为负,扣款与消费额度必须一起变化。如果“读旧值、判断够不够、写新值”分开进行,多个线程可能都按同一旧值通过检查。即使单个字段读写是原子的,整段业务仍可能出错,保护范围要围绕完整规则设计。

互斥锁让同一时刻只有持锁线程执行受保护代码;CAS 则先比较当前值是否仍等于预期值,相同才修改,不同就说明有竞争,需要重新判断。成功 CAS 的某个瞬间可以成为整次操作看起来生效的时刻。但这要求整个算法设计正确,不能只因使用 Atomic 类就宣称整段业务都已安全。

原子变量擅长维护一个更新单元。多个相关字段可以封装进不可变对象,再原子替换对象引用,让读者一次读到一致快照;如果对象内部仍被随意修改,原子引用保护不了内部字段。跨数据库与远程服务的业务变化又超出了单JVM共享内存范围,需要事务、条件更新或分布式协议。

文中这几个词,先认一认

不变量
每个允许被观察到的合法状态都应满足的规则,例如库存始终不能为负。
线性化点
并发操作可以被看作在某个瞬间完整生效,这个逻辑时刻用来解释操作的先后。
CAS
一次完成比较与条件修改:当前值等于预期值才写入新值,否则报告未修改。
ABA
值从 A 变成 B 又变回 A;只比较当前值会看不出中间其实发生过变化。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

以扣减库存为例,线程先读取快照,检查余额和库存是否足够,再构造新快照并CAS。若成功,整次状态变更生效;若失败,说明当前引用已变,应重新读取并重新验证约束。不能把基于旧快照计算的结果强行set回去,否则会覆盖其他线程已经提交的修改。

CAS 失败后需要重新检查状态,候选计算可能运行多次,有些实现也会在条件允许时复用上次结果。不要在计算回调里发送邮件、扣远端账户或执行不可撤销的业务动作,否则重试可能重复产生效果。通常回调只算候选状态,成功后再做允许单独发生的动作;若这些动作必须与状态同步成功,还需要持久事件和失败恢复,单靠 JVM 内的 CAS 不够。进程崩溃后,内存状态也会消失。

CAS 的常见实现会借助处理器的原子读改写能力,并满足 JVM 规定的可见性和顺序要求;具体做法取决于平台,不能把它写成普通读取后再普通写入。竞争很激烈时,多个核心反复争同一块缓存数据,失败重试会消耗 CPU;阻塞锁则可能让线程挂起以减少空转。无锁描述的是整体推进性质,不意味着没有成本,也不保证每个线程都能在有限步内成功。

正在加载流程图…

约束检查与候选计算必须在冲突后重新执行;成功CAS是本模型的生效点。
查看流程图源码(Mermaid)
flowchart TD
 R["读取不可变状态快照 old"] --> C["验证库存与额度约束"]
 C -->|不足| F["返回失败且不修改"]
 C -->|足够| N["构造新快照 next"]
 N --> S{"CAS current 从 old 到 next"}
 S -->|成功| D["线性化完成"]
 S -->|冲突| R
 D --> E["按业务协议处理后续效果"]

按顺序看,每一步做了什么

  1. 列出涉及哪些字段以及它们共同满足的业务约束。
  2. 选择同一把锁或同一个不可变状态引用保护整个约束。
  3. 从一致快照检查条件并构造候选新状态。
  4. CAS失败后重读重算,成功后返回明确结果。
  5. 使用并发测试验证结果,再从线性化点证明不变量。

03对照实现代码

AtomicInteger 更新函数为什么要求没有副作用 (新标签页)↗

OpenJDK 17.0.12+7 · AtomicInteger#getAndUpdate / compareAndSet

原创CAS循环示意,使用公开AtomicInteger API。固定源码的getAndUpdate使用weakCompareAndSetVolatile并在旧值未变时复用候选值;此模型以强CAS简化控制流。

核心机制代码 · java
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.IntUnaryOperator;

final class AtomicUpdateModel {
    static int transform(AtomicInteger state, IntUnaryOperator pure) {
        for (;;) {
            int before = state.get(); // 获取本轮用于计算的旧值。
            int after = pure.applyAsInt(before); // 必须无副作用,冲突后可能再次执行。
            if (state.compareAndSet(before, after)) return before; // 成功更新后返回原值。
        }
    }
}

AtomicInteger内部value是volatile字段,compareAndSet委托JDK内部原子操作。公开API描述其原子性与内存效果,具体机器指令由虚拟机和硬件决定。应用不应自行依赖Unsafe实现细节,也不能把所有访问模式都当成相同强度:plain、acquire、release与volatile各有约束。

固定版本 getAndUpdate 先算 next 再尝试弱 CAS。失败后重读 prev;若值没变,可复用候选结果,否则重新调用函数。这说明回调可能执行多次,回调次数也不等于成功次数。模型省略了复用优化,但保留失败后重新检查的规则。

强 CAS 在当前值不符时失败;弱 CAS 还允许值符合却报告失败,通常放进重试循环。不同方法名附带的内存访问模式也要核对,旧 weakCompareAndSet 和新变体不能随意互换。不能只看都叫 CAS 就认定具有相同可见性,应按对应 JDK API 说明使用。

ReentrantLock则适合需要多个操作组成临界区、可中断等待或条件队列的场景。加锁必须与finally解锁配对,公平模式也不意味着操作系统会公平调度每个线程。选型依据是约束范围、竞争程度、等待策略与可读性,而不是把锁或CAS当成天然更高级的方案。

代码先调用谁,再调用谁

  1. 业务读取原子变量当前快照
  2. 纯函数根据快照计算候选值
  3. CAS比较当前值与预期值并尝试提交
  4. 失败后重新读取并按需重算
  5. 成功返回旧值或新值,具体取决于API契约

04动手写一个例子

两个资源一次预留的不可变快照CAS

库存stock与预算budget必须同时扣减。每次reserve(units,cost)要求参数为正,只有两个资源都足够才成功;任意并发交错都不能留下只扣一个资源的中间状态。

输入 1
初始stock=3,budget=10;预留2,7
输出 1
true;状态1,3
输入 2
接着预留1,4
输出 2
false;状态仍1,3

将两个long字段放进不可变record,由AtomicReference保存当前版本。一次get获得同一版本的两个资源,避免分别读取时把不同更新阶段拼成不存在的状态。判断通过后创建新的record,其字段为安全的非负减法结果,然后尝试整体替换引用。

CAS比较引用身份,所有成功写入都创建新对象,且不把旧对象重新安装回去。这个限制使本算法不需要复用节点时的ABA处理;如果以后加入对象池或允许回滚旧引用,证明就必须重新审查。原子引用中的对象不能包含会被外部修改的可变字段,否则快照不再可靠。

成功CAS前状态仍是old,且old满足两个资源足够,所以新状态非负;失败调用不改变状态。把所有成功CAS按实际发生顺序排列,就得到等价串行执行。并发自测只能发现反例,不能枚举所有调度,因此代码测试应与这个不变量证明共同使用。

为什么这样写是对的当前快照中的库存与预算均非负,每个成功预留同时扣减两个字段,失败预留不修改任何字段。

时间 无竞争一次尝试O(1);重试k次为O(k),单线程最坏重试次数无固定上界额外空间 活跃状态O(1),每次尝试分配一个常量大小候选对象

每轮只读两个字段、检查、减法和一次CAS;高竞争下被丢弃候选会增加分配与GC成本,不能宣称任意负载恒定完成时间。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 46 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.*;

public class Main {
    record State(long stock, long budget) {
        State { if (stock < 0 || budget < 0) throw new IllegalArgumentException(); }
    }
    static final class Pool {
        final AtomicReference<State> current;
        Pool(long stock, long budget) { current = new AtomicReference<>(new State(stock,budget)); }
        boolean reserve(long units, long cost) {
            if (units <= 0 || cost <= 0) throw new IllegalArgumentException();
            for (;;) {
                State old = current.get(); // 一次读取两个资源的一致版本。
                if (old.stock() < units || old.budget() < cost) return false;
                State next = new State(old.stock()-units, old.budget()-cost); // 先验证再安全减法。
                if (current.compareAndSet(old,next)) return true; // 两个字段在此一起生效。
            }
        }
    }
    static void check(boolean ok) { if (!ok) throw new AssertionError(); }
    public static void main(String[] args) throws Exception {
        Pool p = new Pool(3,10);
        check(p.reserve(2,7) && p.current.get().equals(new State(1,3)));
        check(!p.reserve(1,4) && p.current.get().equals(new State(1,3)));
        check(!new Pool(0,0).reserve(1,1));
        check(new Pool(Long.MAX_VALUE,Long.MAX_VALUE).reserve(Long.MAX_VALUE,Long.MAX_VALUE));
        try { p.reserve(0,1); throw new AssertionError(); }
        catch (IllegalArgumentException expected) { }
        Pool shared = new Pool(1000,2000);
        ExecutorService executor = Executors.newFixedThreadPool(4);
        try {
            List<Future<Integer>> jobs = new ArrayList<>();
            for (int i=0;i<4;i++) jobs.add(executor.submit(() -> {
                int count=0;
                for (int j=0;j<500;j++) if (shared.reserve(1,2)) count++;
                return count;
            }));
            int total=0;
            for (Future<Integer> job:jobs) total+=job.get(5,TimeUnit.SECONDS);
            check(total==1000 && shared.current.get().equals(new State(0,0)));
        } finally { executor.shutdownNow(); }
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 任一资源不足必须整体失败。
  • 零和负数请求拒绝,防止反向增加资源。
  • Long.MAX_VALUE资源的减法在验证后仍安全。
  • 多个线程总成功数不能超过容量限制。
进阶选读 · 从ABA到高竞争进度保证

场景无锁栈复用节点,线程观察头节点A后暂停,其他线程弹出并重新压回A,旧CAS可能通过但链关系已改变。

做法结合节点生命周期管理、版本戳或不可复用策略分析ABA;对高竞争统计使用分片,对跨字段业务约束保留一致更新单元。基准同时测吞吐、尾延迟与失败重试数。

复杂度带版本戳的单次比较仍是常量级操作,但版本回绕、内存回收与重试次数需要独立证明;分片汇总一般需要O(分片数)。

好处与代价复杂无锁结构可能减少阻塞,也增加证明与回收成本;简单锁在低竞争复杂事务中往往更容易维护。

05项目里怎么用

先明确单JVM保护范围:多个服务实例各自拥有原子库存,并不会形成全局库存。真正共享的资源需要由数据库条件更新、事务锁或统一资源服务裁决。把内存CAS示例直接搬到多副本销售系统,会让每个副本都认为自己拥有完整额度。

性能评估要控制竞争形态。四个线程更新四个独立计数器与四个线程争用同一个计数器不是同一负载;平均吞吐也不能说明最慢线程是否饥饿。使用规范的基准工具处理预热和优化影响,另外用并发正确性工具探索内存交错,别把普通循环计时当成所有场景的结论。

读多写少、数据较小时,整体替换不可变对象比较清楚;数据很大时,复制成本增加,复杂多步更新也可能更适合锁。无论选哪种,都把必须共同成立的业务规则写进接口与测试,避免后来新增一个没受保护的字段,把原来的并发保证破坏。

容易踩的坑

  • 两个Atomic字段分别更新,误以为形成一笔事务。
  • CAS回调中执行外部副作用,冲突重试造成重复效果。
  • 只用volatile做先检查后修改,仍存在竞态。
  • 将无锁误解为无等待或一定更快。
  • 读写混用不同锁或绕过统一原子状态。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
不可变快照CAS读者获得一致版本,提交点清晰冲突重试与对象分配成本小状态、短计算、适度竞争
互斥锁容易保护复杂多字段操作可能阻塞并需管理锁顺序较复杂临界区与条件等待
分片累加减少热点更新竞争汇总不是任意时刻的严格单值快照统计计数而非资源预留

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01两个原子变量为什么不能自动维护跨字段约束?

展开参考答案与推导

原子性只覆盖各自操作,中间状态和交错仍可被观察;需要同一锁或整体状态替换。

02CAS函数为什么应是纯函数?

展开参考答案与推导

竞争导致计算重复执行,副作用可能多次发生,而只有一次CAS成功。

03无锁与等待自由有什么区别?

展开参考答案与推导

无锁强调系统整体持续有操作取得进展,等待自由要求每个操作在有限自身步骤内完成;CAS循环通常不能给每个线程固定上界。

换个情况,还会处理吗

01单机压测库存正确,扩容后超卖,可能哪里错?

展开参考答案与推导

各实例内存状态彼此独立,原子性没有跨进程;应检查共享数据库条件更新与业务幂等约束。

02改用CAS后CPU升高吞吐下降,如何分析?

展开参考答案与推导

测失败重试、热点缓存行竞争、候选对象分配和GC;比较同等负载下锁或分片方案,不能只看是否阻塞。

03允许把旧快照重新设置回去会改变什么?

展开参考答案与推导

引用可能经历ABA,历史变化不再能由引用身份识别,原算法的版本推进证明失效,需要重新设计版本或更新协议。

写代码试一试

01实现两个资源整体预留。

输入 / 条件
库存3预算10,申请2和7,再申请1和4
输出 / 预期
true,false,剩余1和3

提示 把字段放入不可变record。

展开参考答案与推导

按正文CAS循环,失败后重读重算并维持非负约束。

02实现有上限且不溢出的原子递增。

输入 / 条件
当前9,上限10,两次增加1
输出 / 预期
true,false,最终10

提示 先检查delta不超过limit-current。

展开参考答案与推导

正数参数验证后用差值避免加法溢出,CAS失败重试。

03证明并测试有锁转账的总额守恒。

输入 / 条件
A=100,B=50,转账30
输出 / 预期
A=70,B=80,总额150

提示 同一临界区保护两个账户。

展开参考答案与推导

先验证余额和接收方加法溢出,再同时更新;并发测试总额与非负,跨锁时按固定账户顺序获取。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能根据一个库存与订单状态的约束,判断使用 CAS 还是统一临界区。

本篇重点加练

CAS 的重试语义和跨字段不变量决定更新函数是否可安全重算以及状态是否可被一致观察。

  • 重试循环
  • 跨字段不变量

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    同一线程嵌套调用两层加锁方法,内层校验可能抛异常。逐步记录锁获取次数和退出路径,审查每层的解锁责任。

    提交内容
    控制流图与计数轨迹
    怎样算完成
    • 包含内外层异常
    • 标出进入临界区失败的路径

    练到的知识点互斥锁 · 可重入 · finally 解锁

  2. 作业 02原理问答

    CAS 更新函数除修改计数外还记录审计事件,竞争失败时会重新调用。分析这个接口对业务副作用提出的约束。

    提交内容
    调用轨迹与接口契约
    怎样算完成
    • 覆盖连续竞争失败
    • 区分计算尝试和成功状态变更

    练到的知识点CAS · 重试循环

  3. 作业 03代码推演

    更新函数读取一次外部汇率再参加 CAS,失败后重试时汇率已经改变。定义一次业务操作允许使用的输入快照,跟踪各次计算的输入身份。

    提交内容
    尝试轨迹与输入契约
    怎样算完成
    • 区分外部读取和共享状态读取
    • 记录成功尝试对应的事实

    练到的知识点重试循环

  4. 作业 04原理问答

    写线程在锁内同时更新余额和冻结额,读线程分别调用两个加锁 getter。评审一次组合读取是否受完整不变量保护。

    提交内容
    读写交错与观察契约
    怎样算完成
    • 包含合法中间时间点
    • 区分每次访问安全和整体快照

    练到的知识点跨字段不变量

综合应用

  1. 作业 05面试场景

    内存槽位依次指向对象 A、B、A,另一个线程仅比较引用是否相等。设计能区分“值恢复”和“期间未修改”的观测需求。

    提交内容
    交错时间线与状态字段评审
    怎样算完成
    • 写清身份和版本含义
    • 说明单变量原子能力的范围

    练到的知识点ABA · 单变量原子性

  2. 作业 06设计评审

    优惠券池维护可用数、预留数、已成交数,取消与成交可并发。先给出合法状态和守恒关系,再比较两种同步方案的审查重点。

    提交内容
    不变量表与方案对照
    怎样算完成
    • 包含失败路径和数值边界
    • 说明操作线性化位置

    练到的知识点跨字段不变量

  3. 作业 07编程实践

    实现本地三态库存模型,用可控屏障并发执行预留、成交和取消;输出每次状态变化并检查自己定义的业务不变量。

    提交内容
    Java 程序与交错测试记录
    怎样算完成
    • 不依赖 sleep 碰运气
    • 异常情况下状态仍可审计

    练到的知识点互斥锁 · finally 解锁 · CAS · 重试循环 · 单变量原子性 · 跨字段不变量

  4. 作业 08设计评审

    操作会生成唯一流水号并更新账户状态,团队把流水号生成放在 CAS 更新函数内。审查失败尝试留下的资源、日志和业务身份。

    提交内容
    副作用清单与更新契约
    怎样算完成
    • 区分可浪费编号与已承诺事实
    • 记录重复计算边界

    练到的知识点重试循环 · 跨字段不变量

  5. 作业 09编程实践

    编写本地历史验证器,对带调用与返回时刻的双字段操作寻找符合自定顺序规格的解释;规模限定为少量线程与短历史。

    提交内容
    Java 程序与历史样例
    怎样算完成
    • 能报告找到、未找到及搜索耗尽
    • 不把有限搜索当作完整证明

    练到的知识点重试循环 · 跨字段不变量

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    热点库存竞争明显,锁方案和 CAS 方案都能通过正确性测试。制定公平的对照实验并说明吞吐、CPU 与尾延迟各反映什么。

    提交内容
    实验脚本与分析框架
    怎样算完成
    • 固定线程数和工作量
    • 不以一次运行推断普遍优劣

    练到的知识点CAS · 重试循环 · ABA · 跨字段不变量

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    构造最终余额完全正确但某次查询违反账户约束的并发历史,用来审查只检查最终值的测试是否充分。

    提交内容
    反例及改进的观测要求
    怎样算完成
    • 过程观测与最终观测分开
    • 不靠非法输入破坏前提

    练到的知识点重试循环 · 跨字段不变量

  2. 作业 12思维拓展 · 跨域迁移

    把同一模型迁移到有费用的外部模型调用:CAS 竞争失败不能退回已经消耗的调用费。重新评估重算、输入快照和资源预算。

    提交内容
    跨域成本与一致性评审
    怎样算完成
    • 区分本地状态原子性和外部费用
    • 包含重试次数上界

    练到的知识点重试循环 · 跨字段不变量

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗