JAVA 后端架构 · J02 并发编程 · 场景全景
一个下单请求在线程池里
20:00 秒杀,88 号机械键盘只有 100 件,1000 个人同时点“下单”。每个请求被 Tomcat 交给线程池里的一个线程,带着绑在线程上的用户身份去扣库存。两个线程同时读到“还剩 1 件”,各自写回 0,一件货卖给了两个人。这张全景回答:一个请求在线程池里走过哪 11 站,两个线程撞在哪一站,三种修法各把哪两步并成了一步,以及 J02 的 9 个知识点各管哪一站。
所在:Java 地图 J02 · 并发编程上一站:一个订单对象的一生AI 主线用到:站 01、站 08抢购在本机真跑过 实测
场景:1000 人抢 100 件
- 谁
- 用户 1000 到 1999,一共 1000 人,在交易靶场抢 88 号机械键盘。下面跟着其中两个人走:用户 1024 和用户 1025。
- 什么时候
- 20:00:00 开抢。客户端 50 路并发,每人发一次
POST /seckill,请求头X-User带上用户号。 - 库存
- 88 号只有 100 件。每人限买 1 件。
- 期望
- 正好卖出 100 件,库存剩 0;第 101 个人起都返回“已抢光”。
实测 最自然的写法(先 SELECT 库存,够了再 UPDATE 成算好的值)跑两次:卖出 142 件、151 件,库存显示 0。三种修法都正好卖出 100 件。
实测环境:本机 2 核 ARM,JDK 21.0.12,Spring Boot 3.5.16(Tomcat 10.1.55),H2 内存库代替 MySQL。程序启动后自己当压测端,1000 个请求、50 路并发,五种扣库存写法各跑一轮。
撞车的那几行
左边的号是这一行在第几站起作用。两个线程都执行到第 5 站那一行时,后写的那个盖掉了先写的。
3Long user = UserContext.get(); // ThreadLocal 里取出 10244int left = jdbc.queryForObject( "SELECT qty FROM stock WHERE sku_id = 88", Integer.class);4if (left < 1) return SOLD_OUT;5jdbc.update("UPDATE stock SET qty = ? WHERE sku_id = 88", left - 1); // 写回“我算出来的值” 8// 修法③:判断和扣减放进同一条语句8UPDATE stock SET qty = qty - 1 WHERE sku_id = 88 AND qty >= 1 -- 影响 1 行才算抢到
完整代码在 study-labs:java/java/pano-j02-order-in-threadpool →,一条命令跑完五种写法。
全景图:十一站
撞在哪:两个线程,同一件库存
实测:五种写法各跑一轮
| 写法 | 站 | 卖出(两次) | 超卖 | 顺带看到的 |
|---|---|---|---|---|
mem:内存里一个普通 int,读、判断、写回 | 4、5 | 102 / 101 | 2 / 1 | 窗口只有几条指令,撞得少,但不是 0 |
naive:先 SELECT,再 UPDATE 成算好的值 | 4、5 | 142 / 151 | 42 / 51 | 中间隔着一次数据库往返,窗口大得多 |
sync:把上一行包进 synchronized | 6 | 100 / 100 | 0 | 最多 47、48 个 exec 线程同时 BLOCKED 在这把锁上 |
cas:AtomicInteger.compareAndSet 预扣 | 7 | 100 / 100 | 0 | CAS 失败重试 0 次:2 核上同一瞬间最多两个线程在跑 |
db:UPDATE … WHERE qty >= 1 | 8 | 100 / 100 | 0 | 唯一一种部署多台也成立的写法 |
开虚拟线程,再跑 naive | 2、5 | 109 | 9 | 1000 个请求用了 941 个虚拟线程;线程变便宜了,竞态还在 |
两次的超卖件数不一样,这正是竞态的样子:结果取决于线程碰巧怎么交错。50 路并发时 Tomcat 两次都开了 50 个左右的 exec 线程。耗时主要花在 HTTP 往返上,几种写法只差几百毫秒,这一轮不比吞吐,比吞吐留给靶场任务。
一步一步走
1Tomcat Acceptor 和 Poller:接住 1000 个连接
Tomcat 有两个专职线程。Acceptor 只做一件事:在 accept() 上等新连接,来了就交给 Poller。Poller 用一个 epoll 盯住所有连接,哪个连接的请求字节到齐了,就把它交给第 2 站的线程池。
实测 线程转储里:"http-nio-18080-Acceptor" RUNNABLE at sun.nio.ch.Net.accept(Native Method),"http-nio-18080-Poller" RUNNABLE at sun.nio.ch.EPoll.wait(Native Method)
为什么要它连接可能几千个,大多数时间在等数据。让两个线程盯住全部连接,只有真正有活的才占用干活的线程。
往下一层线程、线程状态与上下文切换 · 上一圈:网络 IO 模型、IO 与 NIO 基础
常出的事看线程转储时把 RUNNABLE 当成“在干活”:这两个线程一直是 RUNNABLE,其实正阻塞在本地方法里。Java 的线程状态只看 JVM 这一层,不知道操作系统里它在睡。
2Tomcat 线程池:派给 exec-17
请求被包成一个任务交给 exec 线程池,某个空闲线程(这里叫 exec-17)接下它,从这里一直干到第 11 站。Spring Boot 默认最少 10 个、最多 200 个。Tomcat 的池和 JDK 的 ThreadPoolExecutor 顺序相反:先把线程加到 200,满了才排队。
实测 空闲时 10 个 exec 线程;50 路并发压上来,两次都开到 50 个左右。开 spring.threads.virtual.enabled=true 后,1000 个请求用了 941 个虚拟线程,名字像 VirtualThread[#2256,tomcat-handler-1886]/runnable@ForkJoinPool-1-worker-2。
为什么要它每来一个请求就新建一个平台线程太贵:创建要进内核,每个线程还要预留一块栈(x64 Linux 默认 1 MB,本机 ARM 是 2 MB);池子让线程反复用。虚拟线程是另一条路:线程本身便宜到每个请求可以新建一个。
往下一层线程池:执行流程、参数、拒绝策略、怎么配 · Spring @Async 与线程池 · 下一圈:怎么配、拒绝策略、动态调参 · 虚拟线程:什么时候用、pinning 的坑(上一圈在 网络 IO 模型)
常出的事200 个线程都卡在一条慢 SQL 上,后面的请求全在队列里排着,接口整体超时,CPU 却很闲。JDK 21 的虚拟线程如果在 synchronized 里阻塞,会钉住(pin)底下的载体线程。
3过滤器:把用户 1024 绑到这个线程上
UserContext 过滤器从请求头读出 X-User: 1024,放进一个 ThreadLocal。之后这个线程上的任何代码调 UserContext.get() 都拿到 1024,不用层层传参。Spring 自己也这么干:RequestContextHolder、事务用的连接(TransactionSynchronizationManager)、日志的 MDC,全是 ThreadLocal。
为什么要它“一个请求从头到尾在同一个线程上跑”是第 2 站给的保证,ThreadLocal 就是把“属于这个请求的东西”挂在线程上。
往下一层ThreadLocal 与内存泄漏 · 结构:HashMap 的亲戚们(ThreadLocalMap 和 0x61c88647)
常出的事放进去忘了在第 11 站清掉。线程回池后去接下一个请求,下一个人没带用户号,读到的就是 1024,订单记到别人头上。
4读库存:两个线程都读到 1
exec-17 执行 SELECT qty,读到 1,够买。它还没来得及写回,就被换下了 CPU(时间片到了,或者在等数据库返回)。这时处理用户 1025 的 exec-23 也来读,读到的还是 1。
为什么会撞2 个 CPU 核上跑着 50 个 exec 线程,线程随时会被切走,切走时停在哪一行没人能保证。“先检查、再执行”这种写法,在单线程里永远对,多线程里检查完的结论随时会过期。
往下一层线程、线程状态与上下文切换 · Java 内存模型:可见性、有序性、happens-before · 下一圈:jcstress、CPU 缓存与伪共享
常出的事开发环境一个人点,永远点不出问题;压测或大促才出现,而且每次超卖的件数都不一样,很难复现。
5写回:后写的盖掉先写的
exec-23 写回 qty = 0,返回“抢到了”。exec-17 回来接着执行,也写回它算好的 qty = 0,也返回“抢到了”。库存只少了 1,卖出了 2 件。这叫丢失更新:每个线程都只看见自己那一份“读、改、写”。
实测 naive 两次卖出 142、151 件;就连全在内存里、只有几条指令的 mem 写法也卖出了 102、101 件。
为什么会撞“读、算、写”是三步,不是原子的。内存版还多一层问题:没有锁、没有 volatile,Java 内存模型不保证一个线程写的值另一个线程什么时候能看到(没有 happens-before)。
往下一层Java 内存模型 · 下一圈:用 jcstress 跑出这一站的超卖 · 修法见第 6、7、8 站
常出的事以为给字段加个 volatile 就好了。volatile 只管“看得见”,管不了“读、改、写”三步中间被插队,照样超卖。
6修法①:synchronized,一次只放一个线程进来
把第 4、5 站的读和写包进 synchronized (lock) { … }。exec-17 拿到锁,exec-23 只能在门外等,等 exec-17 写完、放了锁才进来,这时它读到的是 0。JDK 21 上没有偏向锁了(JDK 15 默认关、18 移除):没人抢时用 CAS 改对象头就算加锁(轻量级锁),抢的人多了膨胀成重量级锁,抢不到的线程挂起,状态是 BLOCKED。
实测 卖出正好 100 件;采样到最多 47、48 个 exec 线程同时 BLOCKED 在这一把锁上。
为什么要它最直接:把“读、判断、写”变成别人插不进来的一段。ReentrantLock 能做同样的事,还多了超时、可中断、公平和多个条件队列,它建在 AQS 上,等锁的线程是 WAITING 而不是 BLOCKED。
往下一层synchronized 与锁升级、CAS 与原子类 · AQS 与 ReentrantLock、Condition、Semaphore · 下一圈:读写锁、StampedLock 与用 JFR 找锁竞争
常出的事锁只管得住一个 JVM:订单服务部署两台,每台各有一把锁,照样超卖。锁里做慢操作,200 个 exec 线程全堵在门口。
7修法②:CAS,写回时先核对
库存放在 AtomicInteger 里,扣减写成 compareAndSet(1, 0):“如果现在还是 1,就改成 0”。这是一条 CPU 指令(ARM 上是 LSE 原子指令或 LL/SC),比较和写入之间插不进别人。两个线程都拿着 1 来改,只有一个成功;失败的重新读、再试,读到 0 就返回已抢光。
实测 卖出正好 100 件,CAS 失败重试 0 次:2 核机器上同一瞬间最多两个线程真正在跑,CAS 的窗口又只有一条指令,几乎撞不上。
为什么要它不挂起线程,没有锁的排队和唤醒,竞争不激烈时最快。秒杀里常用它(或 Redis 的原子扣减)做第一道“预扣”,把大部分抢不到的人挡在数据库之外。
往下一层synchronized 与锁升级、CAS 与原子类
常出的事内存里扣成功了,写数据库失败,库存要补回去,不补就少卖。竞争激烈时大量线程原地重试空转 CPU,计数类场景改用 LongAdder。同样只在一个 JVM 里成立。
8修法③:数据库条件更新,判断和扣减放进同一条语句
UPDATE stock SET qty = qty - 1 WHERE sku_id = 88 AND qty >= 1,返回“影响 1 行”才算抢到。InnoDB 执行这条语句时先给这一行加行锁,第二个事务要等第一个提交,再拿最新的 qty 判断条件。J01 全景第 7 站用的就是这个写法。
实测 卖出正好 100 件,第 101 个请求起都是影响 0 行。
为什么要它库存的真相在数据库里,几台应用服务器共用一份。前两种修法管的是一个 JVM 里的线程,它管的是所有连到这个库的事务。
往下一层J06:事务、MVCC 与锁 · J12:抢券的预扣、限流和异步化
常出的事热点行:1000 个请求都排队等同一行的行锁,吞吐被这一行卡死,还可能拖住连接池。量大时先用 CAS 或 Redis 预扣挡掉大部分请求,再落库。
9并发容器:订单放进 ConcurrentHashMap,超时任务进 DelayQueue
抢到的人得到订单 10017。userOrders.computeIfAbsent(1024, …).add(10017):J01 这里是 HashMap,几十个线程同时放会丢数据,这次换成 ConcurrentHashMap,它只锁住正在改的那个桶。30 分钟不付款就取消的任务放进 DelayQueue,后台线程在 take() 上阻塞着等它到期。
实测 每一轮结束,userOrders 里正好 100 个用户。
为什么要它前面几站解决的是“一个数”的并发,这一站是“一个集合”的并发。容器自己做好线程安全,业务代码就不用自己加锁。
往下一层ConcurrentHashMap 与阻塞队列(阻塞队列的一部分:SynchronousQueue 与 LinkedTransferQueue) · HashMap:哈希、扩容、树化
常出的事以为换成并发容器就万事大吉:先 get 再 put 两次调用之间照样会被插队,要用 computeIfAbsent、merge 这类一步完成的方法。
10CompletableFuture:通知和积分交给别的线程
发短信、加积分不该让用户等,CompletableFuture.runAsync(…) 把它们交出去,exec-17 继续往下走。没传线程池时用公共的 ForkJoinPool.commonPool(),它的大小是 CPU 核数减 1;小于 2 时,CompletableFuture 干脆每个任务新建一个线程。
实测 2 核机器上异步任务跑在 Thread-552(每次新建的线程),在那里调 UserContext.get() 得到 null。
为什么要它把不影响结果的慢事挪出请求线程,exec 线程更早回池,第 2 站的 200 个线程能接更多请求。
往下一层CompletableFuture 与异步编排 · Spring @Async 与线程池
常出的事第 3 站的 ThreadLocal 不会跟着过去:用户号、traceId 到了新线程全是空的。默认池被慢 IO 占满,拖垮别的异步任务;异常没人 join 就悄悄丢了。
11写回、清掉 ThreadLocal、线程回池
exec-17 把 OK 写回连接。过滤器的 finally 里调 USER.remove(),然后线程回到池里,停在任务队列上等下一个请求(WAITING)。请求结束了,线程没有结束,它身上挂的东西也不会自己消失。
为什么要它线程池里的线程活得比请求长得多。第 3 站放进去的东西,必须在同一个请求结束前拿掉。
往下一层ThreadLocal 与内存泄漏:为什么要 remove · 线程池:执行流程 · 优雅关闭
常出的事不 remove():一是串号,下一个请求读到 1024;二是泄漏,ThreadLocalMap 的 key 是弱引用、value 是强引用,线程不死,value 就一直在堆里。
知识点地图
知识点名照抄学习地图。下钻按依赖排:被别人用到的先学。学这一站之前就有页面的 5 个,这一次是“下一圈”:带着这张全景回去重学,补上地图要的深度。
| 站 | 知识点 | 状态 | 下钻顺序和理由 |
|---|---|---|---|
| 1、2、4 | 线程、线程状态与上下文切换 懂机制 | 有页 | 1地基:后面每一站都在问“哪个线程、什么状态、什么时候被切走” |
| 4、5 | Java 内存模型:可见性、有序性、happens-before 懂设计 | 有页(下一圈:jcstress、CPU 缓存与伪共享) | 2说清第 5 站为什么会错,才知道锁和 CAS 到底保证了什么 |
| 6、7 | synchronized 与锁升级、CAS 与原子类 懂设计 | 有页 | 3两种 JVM 内的修法;CAS 也是后面 AQS、并发容器的积木 |
| 6 | AQS 与 ReentrantLock、Condition、Semaphore 懂设计 | 有页(下一圈:读写锁、StampedLock、Semaphore 与 JFR) | 4用 CAS 加一个等待队列搭出来的锁,接着第 3 个学 |
| 9 | ConcurrentHashMap 与阻塞队列 懂机制 | 有页(SynchronousQueue 另见专页) | 5要用到 CAS 和锁;阻塞队列又是线程池的零件 |
| 2、11 | 线程池:执行流程、参数、拒绝策略、怎么配 懂设计 | 有页(下一圈:怎么配、拒绝策略、动态调参) | 6这一站的过关题:画出 execute 的三个分支,算出该开多少线程 |
| 3、11 | ThreadLocal 与内存泄漏 懂机制 | 有页(结构的上一圈:HashMap 的亲戚们) | 7要先懂线程池里的线程为什么活得比请求长 |
| 10 | CompletableFuture 与异步编排 懂机制 | 有页 | 8要用线程池,还要处理 ThreadLocal 传不过去的问题 |
| 2 | 虚拟线程:什么时候用、pinning 的坑 懂机制 | 有页(上一圈:网络 IO 模型) | 9放最后:拿它和线程池、synchronized、ThreadLocal 逐个对照 |
这张全景没走到的
- J02 的 9 个知识点都在图上。离这条链路很近、但放在别的站的:
- 死锁:两个线程各拿一把锁等对方。这里只有一把锁,碰不到;用 jstack 找死锁在 J03,数据库死锁在 J06。
- 分布式锁、Redis 预扣:部署多台以后第 6、7 站失效,跨进程的办法在 J07 Redis 和 J12 抢券。
- CountDownLatch、CyclicBarrier、ForkJoin:请求链路里用不上,学 AQS 时顺带看 CountDownLatch。
补给站
- 第 6、7 站:synchronized、CAS 与原子类,对应第 3 个知识点。
- 第 3、10、11 站:ThreadLocal 与内存泄漏、CompletableFuture 与异步编排,对应第 7、8 个知识点。
- 第 8 站:MySQL MVCC 与锁、HikariCP 的 ConcurrentBag(连接池怎么用无锁结构借还连接)。
回到全景
复述练习
- 合上页面,画出 11 站,标出哪几站是同一个 exec 线程干的、哪一站换了线程。
- 每站用一句话讲它做了什么,再用一句话讲两个线程撞在哪、三种修法各把哪几步并成了一步。
- 挑 3 站往下讲两层。比如第 6 站:没人抢时 synchronized 怎么加锁?抢的人多了变成什么?等锁的线程是什么状态,和 ReentrantLock 的等待有什么不同?
也可以直接讲给我听,说“回到全景”,我对照这张图帮你查漏。
动手:交易靶场这一站
地图上 J02 给靶场的任务是“复现超卖,三种办法修好,各压一遍比吞吐”。实测程序已经完成前两件,在它上面补:
- 把 H2 换成真 MySQL 8.4,再跑一遍:
naive超卖得更多还是更少?为什么? - 用 wrk 或 JMeter 在 50、200、500 并发下各压 1 分钟,画出三种修法的吞吐和 P99。
- 过关两题对着代码讲:线程池 execute 的三个分支,按你的接口算出该开多少线程;
volatile能不能保证i++安全、为什么。
预测题
1. 把压测并发从 50 改成 300,Tomcat 会开多少个 exec 线程?多出来的 100 个请求去哪了?
开到上限 200 个。多出来的请求的连接已经被 Acceptor 收下了(连接数上限默认 8192),它们的任务在 Tomcat 线程池的队列里排着,等有线程空出来。accept-count(默认 100)是连接数超过 8192 以后操作系统那一层的排队长度,这时还用不上。用户看到的是响应变慢,不是报错。
从第 1、2 站推出:Tomcat 的池先加线程到 max 再排队,连接和线程是两个池子。
2. 订单服务部署成两台,都用第 6 站的 synchronized,还超卖吗?
超卖。两个 JVM 各有一把锁,互相看不见:A 机的线程和 B 机的线程可以同时读到 1、各自写回 0。第 7 站的 AtomicInteger 也一样,每台机器各有一份库存。只有第 8 站的条件更新跨机器成立,因为库存和行锁都在同一个数据库里;或者把预扣挪到 Redis 这类共享的地方。
从第 4、5、6、8 站推出:锁和原子变量的作用范围是一个进程的内存。
3. 给 mem 写法里的 int 加上 volatile,还超卖吗?
还超卖。volatile 保证每次读都读到最新写入的值,也禁止相关的重排序,但“读、判断、写回”仍是三步。两个线程都可以在对方写回之前读到 1。它修好的是“看不见”,修不好“被插队”。
从第 4、5 站推出,对应 Java 内存模型页的可见性和原子性。
4. 开了虚拟线程,第 6 站锁里换成真 MySQL(要走网络),在 JDK 21 上会怎样?升到 JDK 24 呢?
JDK 21 上,虚拟线程在 synchronized 块里等网络时会钉住(pin)它的载体线程:载体线程默认只有 CPU 核数那么多,本机就 2 个。两个载体都被钉在锁里等数据库,其他几百个虚拟线程都没有线程可用,吞吐掉到接近串行。改用 ReentrantLock 就不会钉住。JDK 24(JEP 491)起 synchronized 也不再钉住。
从第 2、6 站推出。本页实测用的是 H2 内存库,锁里没有真正的网络等待,所以没测出这个现象,结论按 JEP 491 和 JDK 文档。
5. 把过滤器里的 USER.remove() 删掉,然后有一部分请求不带 X-User 头,会看到什么?
不带头的请求如果落在一个以前处理过别人的 exec 线程上,UserContext.get() 拿到的是那个人的用户号,订单就记到他头上。落在新线程上的才是 null。所以这个 bug 时有时无,和第 4 站的超卖一样难复现。
从第 2、3、11 站推出:ThreadLocal 挂在线程上,线程在池里反复使用。
下一圈
两台机器、一个 Redis:库存挪到 Redis 预扣
第 6、7 站在多台机器上失效以后,用 Redis 的原子扣减(Lua 脚本)做预扣,再异步落库。难在 Redis 扣了、数据库没写成功时怎么对账。这是 J07 Redis 和 J12 抢券的全景。
每秒 1 万个请求:200 个线程不够用了
第 2 站的池子满了以后,限流挡在哪一层、慢接口怎么和快接口隔离、库存服务挂了下单怎么降级。这是 J12 高并发与稳定性的全景。