库存超卖了 100 件
秒杀开始三分钟,客服电话被打爆:1000 件库存,卖出了 1100 件。复盘追到扣库存的代码,核心就一行:stock--。单测全过、压测全过、上线翻车——不是玄学,是并发编程的第一课:你以为的一行代码,在 CPU 眼里是一串动作,而线程随时可能在动作中间被插队。
一行代码的三个动作
把 stock-- 拆开看,CPU 实际执行的是三步:从内存读出 stock、在寄存器里减一、把结果写回内存。单线程没有任何问题;两个线程一交错就出事——线程 A 读到 1000 还没来得及写回,线程 B 也读到 1000,各自减一后写回 999:扣了两次,库存只少了 1。时间线画出来一目了然:
时刻 线程A 线程B stock
t1 读 stock=1000 1000
t2 寄存器减一 读 stock=1000 1000 ← B 读到旧值
t3 寄存器减一 1000
t4 写回 999 999 ← A 先落盘
t5 写回 999 999 ← B 后落盘,覆盖 A一组操作要么全部执行完、要么一步都不执行,这叫原子性。i++ 恰好不是原子操作——这就是超卖的元凶,也是并发三宗罪里的第一宗。
并发三宗罪:整个系列的地图骨架
库存超卖是第一宗罪——原子性。第二宗是可见性:线程 A 明明改了变量,线程 B 却可能永远看不到,因为它读的是自己 CPU 核心缓存里的旧副本。第三宗是有序性:代码的执行顺序未必和你写的一样,编译器和 CPU 都会重排指令,单线程下看不出来,多线程下就是偶现的灵异 Bug。记住这个对应关系,整个并发知识体系瞬间有了骨架:synchronized 和各类锁主要治原子性,volatile 主要治可见性,happens-before 规则给有序性兜底。本系列后面讲到的每一个工具,都会标注它治的是哪宗罪。
这个系列的路线图
二十一篇分五段:线程地基(创建方式、六种状态、生命周期);synchronized 与对象锁(字节码里的 monitorenter、锁升级、wait/notify);Java 内存模型(volatile、指令重排、happens-before、CAS 与原子类);AQS 与显式锁(ReentrantLock、读写锁、ThreadLocal);工程实战(线程池、ConcurrentHashMap、协作三件套、CompletableFuture、虚拟线程收官)。收官会放一张并发作战地图,按「治哪宗罪、用在什么场景」把所有工具串起来。
复现准备
JDK 17+,IDE 任选。所有示例都是 main 方法直接跑的裸代码,不依赖任何框架——并发是语言层的能力,框架只会干扰理解。把超卖 Demo 跑起来:
public class OversellDemo {
static int stock = 1000;
public static void main(String[] args) throws Exception {
Runnable sell = () -> {
for (int i = 0; i < 100; i++) {
stock--; // 三步:读、减、写
}
};
for (int t = 0; t < 10; t++) {
new Thread(sell).start();
}
Thread.sleep(1000);
System.out.println("剩余库存:" + stock); // 期望 0,实际每次都不一样
}
}多跑几次,剩余库存会飘出 999、992、983……每次都不同——并发问题的第一个特征就是概率性,不是必现,是看运气。而概率型 Bug 比必现型可怕十倍:你修好的,可能只是这次没触发而已。下一篇先把地基打牢:除了 new Thread 还有哪些正确的创建姿势,线程的六种状态又是怎么流转的。
评论 (0)