承接的问题
单线程跑一遍借阅流程,库存从 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 = 1 | 1 | |
| t2 | 读到 stock = 1 | 1 | |
| t3 | 判断 1 > 0,通过 | 1 | |
| t4 | 判断 1 > 0,通过 | 1 | |
| t5 | 写回 stock = 0 | 0 | |
| t6 | 写回 stock = 0 | 0 |
两个线程都通过了检查,都认为自己借到了书,库存最后是 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 | 线程 B | counter |
|---|---|---|---|
| t1 | 读 counter = 0 | 0 | |
| t2 | 读 counter = 0 | 0 | |
| t3 | 计算 0 + 1 = 1 | 0 | |
| t4 | 计算 0 + 1 = 1 | 0 | |
| t5 | 写回 1 | 1 | |
| t6 | 写回 1 | 1 |
库存扣减也是同一个形状: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 为例:
- 读
book.stock(),判断是否大于 0,这是 check-then-act; - 用读到的
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 线程安全的四类策略
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。