qinghuan
← Java SE / 全部笔记

单元 58 / Java SE

P0 · 必会主线L2 · 进阶

并发基础:一次 count++ 为什么不够安全

用两个线程同时加一的例子,分清更新丢失、读不到新值和共享数据范围,再选择合适的保护方式。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能解释更新为何丢失,并根据业务状态必须满足的条件(不变量)选择合适的同步范围和机制。

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

为什么学它

Web 请求共享服务对象,必须识别复合操作与线程隔离的真实边界。

先读这些内容

知识点清单

  • count++ 复合操作
  • 原子性与可见性
  • volatile 的限制
  • AtomicInteger 原子更新
  • 多字段不变量
  • Thread.join
  • ThreadLocal 生命周期
  • 单进程锁与跨实例边界

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

两个线程同时改一个对象时,要分别检查三件事:操作会不会互相穿插,写入能否被另一个线程看见,以及哪些先后关系有保证。这分别叫原子性、可见性和顺序。volatile 能提供特定的可见性与顺序保证,但不能把“读取、加一、写回”自动变成一个不可拆开的操作。

例如两个线程同时执行 count++,都可能读到 5,随后都写回 6,结果少加了一次。用 synchronized 可以让同一时刻只有一个线程进入受同一把锁保护的代码,相关访问都要遵守这套用锁规则;AtomicInteger 则提供加一等专门的原子操作。若要同时保证“库存减少”和“已售数量增加”相互对应,仅保护某一个数字还不够,要让相关字段共同遵守业务规则。

让线程各自处理自己的数据,比让它们到处修改同一批字段更容易检查。需要交给其他线程的数据,可以优先考虑创建后不再变化的对象,并约定由谁负责修改。final 只固定引用,不能冻结对象;一个普通集合可以被多个线程读取,也不表示它允许别人同时修改。

把对象交给另一个线程时,还要确保它能看见已经完成的初始化,这属于安全发布问题。线程启动、任务提交、锁和原子变量各有明确规则,不能只凭“我的机器上一直正常”判断。

文中这几个词,先认一认

原子性
对其他线程而言,相应状态像是一次完整地改变,不会暴露这次操作的中间结果;这不表示其他线程在此期间都停止执行。
可见性
一个线程写入后,另一个线程在什么条件下能读到这个结果。
CAS
比较后再修改:只有当前值仍等于预期值才写入新值,比较与写入作为一个原子操作完成。
线性化点
一个并发操作可以被看作在某个瞬间完成;这个瞬间就是它的线性化点。

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

02看看它怎么工作

CAS 可以理解为“核对旧值后再替换”:先读库存,算出扣减后的数,再要求原子变量只在当前值仍等于刚才读到的值时写入。比较和替换由一次原子操作完成。若比较不相等,说明这次依据已经不适用,要重新读取库存、检查是否够用,再计算新结果。不能拿着旧结果一直重试。本例中,CAS 成功的那一刻可以视为这次扣减真正生效的时刻,术语叫线性化点。

原子变量只保护它所承诺的单个操作。先 get 判断,再单独 set 写入仍可能竞争;两个 AtomicInteger 分别更新也不会自动构成账户转账事务。跨多个字段的不变量可能更适合锁、不可变状态整体替换或更高层事务。把类名带 Atomic 当作整段业务正确保证,是常见误区。

测试应同时检查操作返回值和最终状态。并发执行后库存不为负只是一个性质,还要确认成功次数与实际扣减守恒。Thread.join 等待工作线程结束后再读取结果,避免主线程提前断言。有限自测可以发现常见错误,却不证明所有调度、平台和竞争程度下都已形式化正确,推理仍需依赖原子 API 契约。

正在加载流程图…

CAS 失败后重新验证业务条件,成功交换对应本次扣减的生效点。
查看流程图源码(Mermaid)
flowchart TD
 A["读取当前库存 observed"] --> B{"库存是否足够"}
 B -->|"否"| C["返回失败,不修改"]
 B -->|"是"| D["计算 next=observed-amount"]
 D --> E{"CAS 仍匹配 observed"}
 E -->|"成功"| F["返回成功"]
 E -->|"失败"| A

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

  1. 写出共享状态和必须保持的不变量。
  2. 判断需要单值原子操作还是多字段临界区。
  3. 明确状态发布与读取的可见性。
  4. CAS 失败时重读并重算。
  5. 结合守恒断言与竞争测试验证。

03对照实现代码

原子加一的 Java 委托与 CAS 教学模型 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/util/concurrent/atomic/AtomicInteger.java · java.util.concurrent.atomic.AtomicInteger#incrementAndGet()

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
import java.util.concurrent.atomic.AtomicInteger;
public final class SourceModel {
    public static int increment(AtomicInteger value) {
        for (;;) {
            int observed = value.get();
            int next = observed + 1; // 与 int 原子加一一样,数值范围仍可能环绕。
            if (value.compareAndSet(observed,next)) return next;
            // 失败说明状态被改变,必须重读,不能复用旧候选。
        }
    }
}

真实 AtomicInteger.incrementAndGet 委托 JDK 内部原子加法能力,再把返回的旧值转换成新值。Java 文件并未用这段 CAS 循环逐行实现全部机器操作。模型使用公开 compareAndSet 展示等价的单值原子更新思路,不声称复制底层指令或虚拟机内建实现。

原子方法仍遵循 int 数值范围,增加到最大值之后可能环绕,并不会自动抛算术溢出。原子性解决并发不可分割,不解决业务数值是否合法。库存算法通过非负状态与足够条件控制减法,另一个计数业务若不允许溢出,需要额外范围规则。

CAS 检查的是观察到的值,不记录完整历史。如果状态先变化又回到相同值,单纯值比较未必能识别中间经历,这就是某些结构需要版本标记的原因。本例只有减法且不暴露任意写入口,状态单调减少,问题范围比一般无锁链表小得多。

有竞争时,CAS 循环可能失败很多次。原子更新不保证每个线程公平得到机会,也不能保证一次调用最多等多久。模型展示的是正确重试方式,实际速度仍受竞争、硬件和调度影响;需要长时间等待或协调多个条件时,锁与条件队列可能更容易写清楚。

代码先调用谁,再调用谁

  1. incrementAndGet 请求原子更新
  2. JDK 内部原子加法返回旧值
  3. Java 层形成新值返回
  4. CAS 模型通过比较交换重试表达同一单值更新语义

04动手写一个例子

并发扣减有限预算且不出现负数

Budget 初始非负,tryTake(amount) 只接受正数;余额足够时原子扣减并返回 true,否则返回 false。预算只允许扣减,没有补充操作,不暴露底层原子变量。

输入 1
初始5,取3再取3
输出 1
true、false,剩2
输入 2
初始0,取1
输出 2
false
输入 3
初始1000,多线程总计尝试1600次取1
输出 3
恰好1000次成功,剩0

把余额放进私有 AtomicInteger,构造器拒绝负初值,方法拒绝非正扣减。每轮读取 current 后,若小于 amount 就返回失败;否则 candidate=current-amount 非负且在 int 范围内,可以安全尝试 CAS。底层变量不对外暴露,调用者不能绕过规则随意写负数。

CAS 成功表示从观察值到候选值的状态变化是原子的,本次操作返回成功。失败说明另一个线程抢先修改,必须回到读取与余额检查。只允许扣减使余额单调不增,因此读取到不足时可以在线性化于该读取的意义下返回失败。

并发测试让多个线程竞争同一个预算,成功次数用独立原子计数记录。等待全部线程结束后断言剩余为零且成功次数等于初始预算,两个性质共同检查没有超扣或丢失扣减。测试不比较线程完成顺序,也不宣称公平性,因为接口没有这种保证。

为什么这样写是对的余额始终非负,初始余额等于当前余额加所有成功扣减金额之和。

时间 单次尝试常数工作,重试次数取决于竞争,单次调用无固定重试上界。额外空间 O(1) 对象状态;测试线程与计数器资源另计。

每轮执行一次读取、比较、减法和 CAS,成功或余额不足时结束;争用可能使同一调用多次重试,不能简单写每次操作最坏 O(1)。

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

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

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

Main.java · 完整可运行程序 · Java
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
    static final class Budget {
        private final AtomicInteger remaining;
        Budget(int initial) {
            if (initial < 0) throw new IllegalArgumentException("initial");
            remaining = new AtomicInteger(initial);
        }
        boolean tryTake(int amount) {
            if (amount <= 0) throw new IllegalArgumentException("amount");
            for (;;) {
                int observed = remaining.get();
                if (observed < amount) return false;
                // 只有余额足够才形成非负候选,失败后回到顶部重读。
                if (remaining.compareAndSet(observed,observed-amount)) return true;
            }
        }
        int remaining() { return remaining.get(); }
    }
    static void require(boolean condition) {
        if (!condition) throw new AssertionError();
    }
    public static void main(String[] args) throws InterruptedException {
        Budget small = new Budget(5);
        require(small.tryTake(3) && !small.tryTake(3) && small.remaining() == 2);
        require(!new Budget(0).tryTake(1));
        boolean zeroRejected = false;
        try { small.tryTake(0); } catch (IllegalArgumentException e) { zeroRejected = true; }
        require(zeroRejected);
        boolean negativeRejected = false;
        try { new Budget(-1); } catch (IllegalArgumentException e) { negativeRejected = true; }
        require(negativeRejected);
        Budget shared = new Budget(1000);
        AtomicInteger successes = new AtomicInteger();
        Thread[] workers = new Thread[8];
        for (int i = 0; i < workers.length; i++) {
            workers[i] = new Thread(() -> {
                for (int j = 0; j < 200; j++)
                    if (shared.tryTake(1)) successes.incrementAndGet();
            });
            workers[i].start();
        }
        // 所有工作线程结束后再验证守恒,不用 sleep 猜测完成。
        for (Thread worker : workers) worker.join();
        require(shared.remaining() == 0);
        require(successes.get() == 1000); // 成功次数与实际扣减一起检查。
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 初值零合法但不能成功扣减。
  • 扣减量零或负数拒绝。
  • 余额不足不改变状态。
  • 多个线程的成功扣减总额不会超过初值。
进阶选读 · 可选深入:多资源原子预留

场景一次操作同时扣多个商品库存或多个账户余额。

做法把需要共同变化的状态放入同一受控事务或锁边界,或以不可变复合状态整体 CAS;失败补偿和持久化策略单独设计。

复杂度成本取决于资源数、争用和事务系统,不能由单个 AtomicInteger 推断。

好处与代价整体原子性更强但竞争范围和失败恢复成本增加,多个独立原子变量不自动组成事务。

05项目里怎么用

优先把共享状态限制在少数清晰组件中,让其它代码使用不可变结果。对请求私有数据不要放进 static 或共享单例字段;对共享缓存也应说明哪些操作需要原子组合。并发问题往往跨写入、读取和生命周期整条链,局部加 volatile 可能只处理了其中一部分。

计数工具要按用途选。AtomicInteger 适合对一个值做准确更新和条件判断;高并发统计还有其他累加结构,但它们读取统计结果的规则,不一定适合库存“够才扣”的判断。不能只因为吞吐更高就直接替换,必须同时检查返回结果和更新保证是否满足业务。

验证并发代码时,要同时检查业务规则、测试和运行记录。一次自测通过,只说明这次实际发生的执行顺序符合断言,不代表所有调度都已覆盖;压测还要记录环境和负载。观察锁等待、重试次数和最慢一批请求的延迟能帮助找到竞争,但优化前先确认任何失败都不会破坏库存、金额等基本规则。

容易踩的坑

  • 给计数器加 volatile 就认为 count++ 原子。
  • CAS 失败后继续使用旧观察值和候选。
  • 多个 AtomicInteger 更新被误当成整体事务。
  • 用 sleep 等待测试线程并提前读取结果。

几种做法,怎么选

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

方案优势代价与限制适用条件
线程内或不可变状态共享推理负担小跨任务协作需传递新值能分离状态的业务
锁保护临界区多字段不变量容易表达竞争与阻塞需管理复杂复合操作
CAS 原子状态单值条件更新直接重试、公平性和组合复杂度较高短小单状态更新

06检验是否读懂了

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

先用自己的话解释

01volatile 为什么不能自动保证加一正确?

展开参考答案与推导

加一包含读取、计算和写回多个步骤,volatile 不把它们合成不可分割操作。两个线程仍可能基于同一旧值更新,需要原子方法或覆盖整组动作的锁。

02CAS 成功在本算法中意味着什么?

展开参考答案与推导

它证明余额仍等于本轮观察值,并已原子替换为扣减后的候选。这个交换是成功操作的生效点;失败时必须重读并重新判断余额,不能沿用旧条件。

03原子性为什么不等于不会溢出?

展开参考答案与推导

原子只描述并发状态变化是否不可分割,不扩大数值范围。AtomicInteger 加法仍采用 int 规则,业务不允许环绕时需要范围检查或更合适表示。

换个情况,还会处理吗

01两个账户都用原子变量,转账为何仍可能不一致?

展开参考答案与推导

扣款与入账是两个独立操作,中间失败或并发观察可能看到部分状态。需要共同事务、锁或明确补偿协议,不能由各字段原子推出整个转账原子。

02自测偶尔看到预算尚未用完,先检查什么?

展开参考答案与推导

确认主线程是否等待所有工作线程真正结束、工作线程是否异常退出,以及总尝试次数是否足够。先修复测试同步与输入前提,再判断预算算法,不应靠增加 sleep 时间掩盖。

03CAS 重试很高是否一定要继续加线程?

展开参考答案与推导

高重试可能说明争用已经成为瓶颈,更多线程可能增加无效工作。观察重试和尾延迟,考虑减少共享、分批或锁方案;选择应基于语义与负载而非无锁标签。

写代码试一试

01验证一次取尽预算

输入 / 条件
初始7,取7再取1
输出 / 预期
true、false,剩0

提示 等于余额也是合法成功。

展开参考答案与推导

加入精确取尽测试,检查条件使用 observed < amount 而不是小于等于。后续取一失败且余额不变,覆盖从正数到零的边界。

02增加成功扣减金额统计

输入 / 条件
不同线程分别取不同正数量
输出 / 预期
统计总额加剩余额等于初值

提示 成功后累计金额而非次数。

展开参考答案与推导

在测试中用 AtomicLong 累计每次成功的 amount,全部线程 join 后验证守恒。金额不同时时间上成功次数不再等于扣减总额,测试指标必须对应业务不变量。

03为预算增加补充操作

输入 / 条件
初始0,补5后取3
输出 / 预期
剩2

提示 补充也需要原子范围检查。

展开参考答案与推导

补充方法使用 CAS 循环和精确加法验证上界,不能直接 set(get()+amount)。同时重新审查单调减少前提和失败返回解释,增加与扣减交错的测试,而不只复制原方法。

读完之后,独立完成

课后作业:自己做一遍

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

这次练什么

能解释更新为何丢失,并根据业务状态必须满足的条件(不变量)选择合适的同步范围和机制。

本篇重点加练

重点强化可见性、多字段不变量和线程复用中的生命周期,避免只验证最终计数。

  • 原子性与可见性
  • 多字段不变量
  • ThreadLocal 生命周期

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

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

基础必做

  1. 作业 01代码推演

    初值 count=4,两个线程各执行一次 count++。展开关键动作并列出多种合法交错,分别标出读取、计算、写入;说明仅运行一次程序能支持哪些结论。

    提交内容
    交错时间线与证据边界说明。
    怎样算完成
    • 交错符合各线程程序顺序
    • 区别原子性与可见性

    练到的知识点count++ 复合操作 · 原子性与可见性

  2. 作业 02排错实验

    分别用普通 int、volatile int 与 AtomicInteger 实现两个线程累计任务;控制启动和等待完成,重复有限次数,记录观察结果并解释哪些性质不能只靠压力测试证明。

    提交内容
    Java 实验、自写总量检查、运行条件及记录。
    怎样算完成
    • 实验规模有界
    • 每次都等待工作结束
    • 区分观测与机制论证

    练到的知识点volatile 的限制 · AtomicInteger 原子更新

  3. 作业 03原理问答

    同一个发布标志与数据字段分别被不同线程使用。画出可能的读写关系,并提出需要什么同步合同才能让读取方合理使用数据;不能以执行速度快慢代替依据。

    提交内容
    访问图与同步条件说明。
    怎样算完成
    • 可见性问题有具体对象与字段
    • 所需条件能转成测试或机制论证

    练到的知识点原子性与可见性

综合应用

  1. 作业 04设计评审

    库存状态包含 available、reserved、sold,初始为 (20,0,0),三个操作可并发改变状态。先定义业务状态必须遵守的条件(不变量),再设计何时允许读取一致快照及何时判定工作全部完成。

    提交内容
    状态转换规则、同步范围与验收案例。
    怎样算完成
    • 涵盖多字段整体状态
    • 完成判定与业务一致性分开

    练到的知识点多字段不变量 · Thread.join

  2. 作业 05面试场景

    固定线程池处理请求 A 后再处理 B,身份存在线程本地变量;服务部署两个进程,各自使用 synchronized 控制全站名额。分别列出两种设计需要验证的作用范围。

    提交内容
    两张作用域图、故障复现场景与修复验收条件。
    怎样算完成
    • 覆盖线程复用和异常路径
    • 区分线程、进程与服务范围

    练到的知识点ThreadLocal 生命周期 · 单进程锁与跨实例边界

  3. 作业 06编程实践

    实现一次最多领取三张的单进程票箱,初始票数 17;领取失败不改变状态,每次成功返回领取数。用多个线程执行有界请求并核对最终账本,不以打印顺序判定正确性。

    提交内容
    Java 实现、自写零票/超额/并发测试和运行证据。
    怎样算完成
    • 总量守恒有断言
    • 工作结束后再汇总
    • 说明成功操作的可观察边界

    练到的知识点AtomicInteger 原子更新 · 多字段不变量 · Thread.join

  4. 作业 07排错实验

    编写两个线程交换库存“待售/已售”字段的实验,记录单次快照而不仅是最终总和;定义合法快照集合,比较两种同步范围的观测结果。

    提交内容
    Java 实验、自写合法状态检查、有限重复运行证据。
    怎样算完成
    • 多字段状态有独立断言
    • 观察结果与机制论证分开

    练到的知识点原子性与可见性 · 多字段不变量

  5. 作业 08编程实践

    实现可复用工作线程的请求上下文包装器,任务可正常返回或抛错,后续任务读取自己的用户标识;自写检查任务前后上下文状态。

    提交内容
    Java 实现、自写异常/无身份/连续任务测试及运行证据。
    怎样算完成
    • 每次执行生命周期可追踪
    • 任务失败不绕过清理验收

    练到的知识点ThreadLocal 生命周期

  6. 作业 09设计评审

    请求上下文包含租户标识,库存服务还维护共享的两字段统计。确定哪些数据属于线程、请求和服务,并制定异常及线程切换后的读取合同。

    提交内容
    作用域图、所有权表和验收序列。
    怎样算完成
    • 上下文与业务共享状态分别处理
    • 跨线程传递有明确责任

    练到的知识点多字段不变量 · ThreadLocal 生命周期

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

  1. 作业 10设计评审

    掌握锁与性能测量后:比较原子重试与互斥锁在同一票箱工作负载中的适用性,制定公平的测量计划;要求同时关注失败重试次数、延迟与业务正确性。

    提交内容
    实验设计及选择依据,不要求给出最快实现。
    怎样算完成
    • 相同工作量与预热条件
    • 性能判断不牺牲一致性

    练到的知识点AtomicInteger 原子更新 · 多字段不变量 · 单进程锁与跨实例边界

思维拓展

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

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

    检验“每个字段都用原子类就能保证任何组合约束”的主张。构造一个至少两个字段的业务状态和合法交错,并说明你的实验能验证或不能验证什么。

    提交内容
    业务约束、交错时间线及可选最小实验。
    怎样算完成
    • 反例或证明覆盖整体状态
    • 没有把一次压力测试当作完整证明

    练到的知识点原子性与可见性 · 多字段不变量

  2. 作业 12思维拓展 · 条件变化

    工作从固定线程池改为可暂停、恢复到其他线程的任务流程,原有请求上下文与统计读取方案哪些前提需要重审?尚未学异步框架时只分析给定线程切换序列。

    提交内容
    前提变化表与新的上下文合同。
    怎样算完成
    • 任务身份与线程身份分别说明
    • 业务不变量在切换前后可验证

    练到的知识点多字段不变量 · ThreadLocal 生命周期

完成后,再检验一次

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

继续阅读

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

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