《Java 并发编程·线程与内存模型》2.1 竞态的三种形态

用一个会超卖的借阅服务拆解竞态的三种形态:check-then-act、read-modify-write 与发布逸出。给出完整的 UnsafeLoanService 和 32 线程复现程序,并解释为什么这类测试不稳定、跑绿也不能证明线程安全,以及库存没变负时超卖仍然可能发生。

承接的问题

单线程跑一遍借阅流程,库存从 5 减到 4,断言全绿。压测时却出现 5 本库存成功借出 32 次,或者库存变成负数。日志里每个请求都写着「库存检查通过」,看不出谁错了。

这类问题的共同点是:正确性依赖两个线程的执行顺序。顺序对了,结果对;顺序错了,不变量被破坏。这就是竞态(race condition)。它不是某一行的错,而是「读」和「写」之间留了一个可以被别人插入的窗口。

窗口有三种典型形状。先把形状认出来,后面的策略才有落点:

形态一句话典型症状
check-then-act先检查,再根据检查结果行动两个请求都通过检查
read-modify-write读出旧值,算出新值,再写回更新丢失、计数偏小
发布逸出对象没准备好就被别的线程看见半成品对象、空指针、字段为默认值

本节先分别拆开这三种形态,再用一个故意不安全的 UnsafeLoanService 把它们落到同一个借阅场景里。

形态一:check-then-act

check-then-act 是先检查一个条件,再根据检查结果执行动作。问题在于:检查通过之后、动作执行之前,条件可能已经被别的线程改掉。

假设库存只有 1 本,两个线程同时来借:

时刻线程 A线程 B共享的 stock
t1读到 stock = 11
t2读到 stock = 11
t3判断 1 > 0,通过1
t4判断 1 > 0,通过1
t5写回 stock = 00
t6写回 stock = 00

两个线程都通过了检查,都认为自己借到了书,库存最后是 0。业务上却产生了 2 条借阅记录。stock >= 0 这个不变量没有破,但「成功借出数不超过初始库存」破了。

这段逻辑在代码里通常长这样:

Book book = books.findByIsbn(isbn).orElseThrow();
if (book.stock() > 0) {
    books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
    return new Reservation(/* ... */);
}
throw new LoanException(ErrorCode.OUT_OF_STOCK, "out of stock");

findByIsbn 和 save 各自都可能是线程安全的。即使仓库底层是 ConcurrentHashMap,这个判断仍然会错,因为安全性不是「每个方法安全」的叠加,而是「整组操作原子」。containsKey 再 put、exists 再 create、getQuota 再 check,都是同一个形状。

一个容易忽略的变体是「检查的结果被缓存了」:

int stock = book.stock();          // 读一次
validate(member, stock);           // 基于旧值做业务校验
books.save(new Book(..., stock - 1)); // 写回时还是旧值

这里的 stock 是一个快照。快照在被使用之前就可能过期。判断「能不能借」和「扣减库存」必须落在同一个原子单元里,否则中间永远有窗口。

形态二:read-modify-write

read-modify-write 是读出当前值,基于它算出新值,再写回去。最典型的是 counter++。它看起来是一条语句,编译后是三步:

getfield      counter
iconst_1
iadd
putfield      counter

两个线程可能都读到 0,各自加 1,再各自写回 1。两次自增只生效了一次,这叫丢失更新(lost update)。

时刻线程 A线程 Bcounter
t1读 counter = 00
t2读 counter = 00
t3计算 0 + 1 = 10
t4计算 0 + 1 = 10
t5写回 11
t6写回 11

库存扣减也是同一个形状:stock = stock - 1、used = used + 1、map.put(k, map.get(k) + 1)。只要新值是从旧值算出来的,中间没有原子性,就会丢更新。

这里要提前排除一个常见误解:volatile 修不了 count++。volatile 保证可见性和有序性,不保证「读、算、写」三步的原子性。两个线程仍然可以都读到 0。第 4.1 节会展开这条边界。

check-then-act 和 read-modify-write 经常同时出现在一个方法里。以本章的 reserve 为例:

  1. 读 book.stock(),判断是否大于 0,这是 check-then-act;
  2. 用读到的 stock 算出 stock - 1 并写回,这是 read-modify-write。

所以只修其中一半不够。把判断改成原子操作,后面的写回仍然可能覆盖别人的结果;把写回改成原子操作,前面的判断仍然可能基于过期快照。

形态三:发布逸出

发布逸出(unsafe publication / escape)是对象还没有构造完成,或者还没有建立好安全的可见性,就被别的线程拿到了引用。前两种形态争的是「写」,这一种争的是「看见」。

最常见的写法是在构造器里把 this 交给外部:

package com.plume.conc.core;

import java.util.HashMap;
import java.util.Map;

public final class ReservationAudit {
    private static ReservationAudit published;
    private final String name;
    private final Map<String, Reservation> entries;

    public ReservationAudit(String name) {
        this.name = name;
        published = this;                 // this 逃逸,对象尚未构造完成
        this.entries = new HashMap<>();   // 这行可能晚于另一个线程的读取
    }

    public static ReservationAudit published() {
        return published;
    }

    public void record(Reservation reservation) {
        entries.put(reservation.loanId(), reservation);
    }

    public int size() {
        return entries.size();
    }

    public String name() {
        return name;
    }
}

另一个线程如果在这两行之间读到 published,会看到一个非 null 的对象,但它的 entries 仍然是 null,调用 record 或 size 就抛 NullPointerException。即使 entries 已经赋值,没有安全发布的话,另一个线程也可能看不到这个写入。

这里有一个容易误判的点:entries 是 final 字段,按理说 final 字段有特殊的可见性保证。但 final 语义的前提是「对象在构造期间没有逸出」。构造器里先 published = this,这个前提已经被破坏,后面的 final 保证不再成立。第 3.3 节会专门讲 final 与安全发布。

发布逸出还有两种常见变体:

  • 返回内部的可变集合:return this.entries;,调用方可以绕过所有同步直接改。
  • 把可变对象放进静态字段或全局注册表,然后在另一个线程里边读边改。

发布逸出的修复通常不是加一把锁,而是改变发布方式:构造完成后再发布、用工厂方法发布、用静态初始化发布、或者干脆发布一个不可变快照。这也是它和前面两种形态分开讲的原因。

在 libraryd-conc 里落地

把三种形态放进同一个借阅服务。UnsafeLoanService 故意保留竞态,后面的章节会逐步替换成正确实现:

package com.plume.conc.core;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.UUID;

public final class UnsafeLoanService implements LoanService {
    private final BookRepository books;
    private final LoanRepository loans;

    public UnsafeLoanService(BookRepository books, LoanRepository loans) {
        this.books = books;
        this.loans = loans;
    }

    @Override
    public Reservation reserve(String isbn, String memberId) {
        Book book = books.findByIsbn(isbn)
                .orElseThrow(() -> new LoanException(
                        ErrorCode.BOOK_NOT_FOUND, "book not found: " + isbn));

        // 形态一:check-then-act。两个线程可以同时通过这个判断。
        if (book.stock() <= 0) {
            throw new LoanException(ErrorCode.OUT_OF_STOCK, "out of stock: " + isbn);
        }

        Reservation reservation = new Reservation(
                UUID.randomUUID().toString(),
                isbn,
                memberId,
                Instant.now().plus(14, ChronoUnit.DAYS));

        // 形态二:read-modify-write。写回的是基于旧快照算出的 stock - 1。
        books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
        return reservation;
    }

    @Override
    public boolean returnBook(String loanId) {
        Loan loan = loans.findById(loanId).orElse(null);
        if (loan == null) {
            return false;
        }
        Book book = books.findByIsbn(loan.isbn())
                .orElseThrow(() -> new LoanException(
                        ErrorCode.BOOK_NOT_FOUND, "book not found: " + loan.isbn()));
        books.save(new Book(book.isbn(), book.title(), book.stock() + 1));
        return true;
    }
}

loans 只是为了让 returnBook 有完整实现;本节所有竞态都发生在 books 这一侧。returnBook 只用来展示第二种形态,重复归还的幂等性需要借阅记录上的状态字段,本节不展开。

内存仓库用 ConcurrentHashMap 实现,findByIsbn 和 save 各自线程安全。这一点很重要:复现出来的错误只能来自 service 的复合操作,而不是容器本身。如果仓库换成 HashMap,你会同时得到容器层面的并发修改异常,反而看不清业务竞态。InMemoryBookRepository 在前言已经给过;归还流程还需要一个 LoanRepository 的内存实现:

package com.plume.conc.core;

import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

public final class InMemoryLoanRepository implements LoanRepository {
    private final ConcurrentMap<String, Loan> loans = new ConcurrentHashMap<>();

    @Override
    public Optional<Loan> findById(String loanId) {
        return Optional.ofNullable(loans.get(loanId));
    }

    @Override
    public void save(Loan loan) {
        loans.put(loan.loanId(), loan);
    }
}

下面的复现程序放在 conc-lab 的 com.plume.conc.lab 包。初始库存 5,32 个线程同时借,每个线程只借一本:

package com.plume.conc.lab;

import com.plume.conc.core.Book;
import com.plume.conc.core.InMemoryBookRepository;
import com.plume.conc.core.InMemoryLoanRepository;
import com.plume.conc.core.LoanException;
import com.plume.conc.core.LoanService;
import com.plume.conc.core.UnsafeLoanService;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

public final class OversellDemo {
    private static final String ISBN = "978-7-115-00001-1";
    private static final int STOCK = 5;
    private static final int THREADS = 32;

    public static void main(String[] args) throws Exception {
        int rounds = args.length > 0 ? Integer.parseInt(args[0]) : 10;
        boolean reproduced = false;
        for (int round = 1; round <= rounds; round++) {
            Outcome outcome = runOnce();
            System.out.printf("round %02d: 初始库存=%d 成功=%d 最终库存=%d%n",
                    round, STOCK, outcome.success(), outcome.finalStock());
            if (outcome.success() > STOCK || outcome.finalStock() < 0) {
                System.out.println("违反不变量:成功数超过库存,或库存变为负数。");
                reproduced = true;
                break;
            }
        }
        if (!reproduced) {
            System.out.println("本轮未复现;未复现不等于线程安全。");
        }
    }

    private static Outcome runOnce() throws InterruptedException {
        InMemoryBookRepository books = new InMemoryBookRepository();
        books.save(new Book(ISBN, "Java 并发编程", STOCK));
        LoanService service = new UnsafeLoanService(books, new InMemoryLoanRepository());

        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done = new CountDownLatch(THREADS);
        AtomicInteger success = new AtomicInteger();

        for (int i = 0; i < THREADS; i++) {
            String memberId = "m-" + i;
            Thread.ofPlatform().name("borrower-" + i).start(() -> {
                try {
                    start.await();
                    service.reserve(ISBN, memberId);
                    success.incrementAndGet();
                } catch (LoanException e) {
                    // OUT_OF_STOCK 是预期内的失败,忽略。
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    done.countDown();
                }
            });
        }

        start.countDown();
        done.await();
        return new Outcome(success.get(),
                books.findByIsbn(ISBN).orElseThrow().stock());
    }

    private record Outcome(int success, int finalStock) {}
}

编译并运行:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
mvn -q -pl conc-lab -am compile
java -cp conc-lab/target/classes:conc-core/target/classes com.plume.conc.lab.OversellDemo 10

本机(Apple M1 Pro,10 核,32 GB,Temurin 25.0.5+8)输出:

round 01: 初始库存=5 成功=32 最终库存=4
违反不变量:成功数超过库存,或库存变为负数。

32 次借阅全部成功,库存只减了 1。为什么不是 -27?因为 32 个线程几乎同时读到 stock = 5,各自算出 4 并写回,最后一次写覆盖了前面 31 次。库存没有变负,但超卖已经发生。

这里要澄清一个容易写错的预期:在这个实现里,最终库存不会变成负数。每次 save 写入的都是「某个通过检查的正数减一」,所以最小写到 0。库存变负需要另一类写法:扣减时不是基于检查时的那份快照,而是重新读取当前值再减一;或者扣减动作本身不做非负检查。本节的服务每次写回都基于检查过的正数快照,丢的是更新,不是把 0 减成 -1。程序里保留 finalStock < 0 的判断,是为了覆盖读者自己改成「重新读再减」的变体;在本节的实现里它不会触发。

所以判据不能只看库存是否为负。成功数超过初始库存,已经是超卖;库存停在 4 而借出 32 本,比库存变成 -1 更能说明「扣减被覆盖了」。

本机连续 3 次运行都是第 1 轮复现。但这不说明它「一定复现」。竞态测试的结果依赖核数、调度、JIT 编译、临界区长度,甚至一次日志打印。把 UUID 和 Instant 的生成挪到写回之后,窗口会缩到几条指令,复现率可能明显下降;换一台机器、换一个 JIT 时机,也可能几十轮都看不到。把 -XX:ActiveProcessorCount 降到 1 在本机仍然复现,但这只是这一次的结果,不能当成规律。第 2.3 节会用 JCStress 把「碰运气」换成「按交错统计」。

常见错误与边界

  • 跑一次没复现就认为没问题。 复现是概率事件,没复现只说明这次调度没踩中。反过来,跑绿也不能证明安全。
  • 用了 ConcurrentHashMap 就以为复合操作安全。 单次 get/put 安全,get 再 put 不安全。这个坑在 2.2 还会出现。
  • 以为 volatile 能修 count++。 它只保证可见性,不保证三步的原子性。
  • 只在构造器里找发布逸出。 返回内部集合、注册回调、放进静态缓存,都是发布。
  • 用 Thread.sleep 来「稳定复现」。 sleep 改变的是概率,不是语义;生产代码里加 sleep 更不是修复。
  • 把库存为负当成唯一判据。 成功数超过初始库存、借阅记录数与扣减次数不一致,都是不变量被破坏。

小结

  • 竞态是「正确性依赖交错顺序」。三种形态分别是 check-then-act、read-modify-write、发布逸出。
  • check-then-act 的问题在检查与动作之间的窗口;read-modify-write 的问题在旧值被覆盖;发布逸出的问题在对象未完成就被看见。
  • 一个方法里可以同时有两种形态。reserve 既有库存检查,也有 stock - 1 的写回,只修一半不够。
  • 这类复现程序不稳定是常态。它的价值是让你看见一次错误,而不是证明错误不存在。
  • 下一节把三种形态对应到四类策略:不可变、互斥、线程封闭、并发容器。

阅读导航:上一节 1.3 生命周期、优先级与守护线程 | 下一节 2.2 线程安全的四类策略

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收