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 →,一条命令跑完五种写法。

全景图:十一站

JVM 进程 · OrderApp(JDK 21 · Spring Boot 3.5 · Tomcat 10.1) 三选一:修法 压测客户端 1000 个用户 50 路并发 ① 接连接 Acceptor 收连接 Poller 盯可读 ② 派给线程 exec 线程池 先加线程再排队 ③ 绑上用户 过滤器把 1024 放进 ThreadLocal ④ 读库存 SELECT qty → 1 两个线程都读到 1 ⑤ 写回:撞了 各写 qty = 0 一件卖给两个人 ⑥ 加锁 synchronized 一次进一个 ⑦ CAS AtomicInteger 失败就重试 ⑧ 条件更新 WHERE qty >= 1 数据库行锁 ⑨ 并发容器 ConcurrentHashMap DelayQueue ⑩ 异步通知 CompletableFuture 换了个线程 ⑪ 写回、回池 remove() 用户 线程等下一个 1000 个POST /seckill 可读的连接 exec-17 用户 1024 left = 1 把“读”和“写”并成一步 抢到 1 件 订单 10017 不等通知 ← 200 "OK" exec-17 回池,等下一个请求 不修:1000 人买走 142 件 (实测,库存只有 100)
线程、线程池、虚拟线程 ThreadLocal 与 CompletableFuture 内存模型(JMM) 锁与 CAS 并发容器与阻塞队列 数据库行锁(J06) 出事的地方
上排是一个请求从进门到扣库存,第 5 站是撞车的地方。右边一列是三种修法,选一种接在第 4 站后面,替掉第 5 站。下排是抢到以后的事,最后一条虚线是线程回到池里:请求结束了,线程没结束,这是第 3、11 站要配对的原因。点任意一站,跳到下面的分步讲解。

撞在哪:两个线程,同一件库存

exec-17用户 1024 库存表stock.qty exec-23用户 1025 时间 → SELECT qty读到 1,够买 被切走 / 等数据库left 还是 1 UPDATE qty = 0返回“抢到了” SELECT qty也读到 1 UPDATE qty = 0返回“抢到了” 1 1 0 0(又写一次 0) 卖出 2 件,库存只少 1 件 exec-23 的扣减被盖掉了
线程号和用户号是示意,撞法是真的:每一轮里都有几十对线程这样撞上。问题出在“读、判断、写回”是三步,中间随时可能换别的线程上 CPU。三种修法的共同点,就是让第二个线程没法插进这三步中间:锁让它在外面排队,CAS 让它的写回失败,条件更新把三步塞进数据库的一条语句。

实测:五种写法各跑一轮

写法站卖出(两次)超卖顺带看到的
mem:内存里一个普通 int,读、判断、写回4、5102 / 1012 / 1窗口只有几条指令,撞得少,但不是 0
naive:先 SELECT,再 UPDATE 成算好的值4、5142 / 15142 / 51中间隔着一次数据库往返,窗口大得多
sync:把上一行包进 synchronized6100 / 1000最多 47、48 个 exec 线程同时 BLOCKED 在这把锁上
cas:AtomicInteger.compareAndSet 预扣7100 / 1000CAS 失败重试 0 次:2 核上同一瞬间最多两个线程在跑
db:UPDATE … WHERE qty >= 18100 / 1000唯一一种部署多台也成立的写法
开虚拟线程,再跑 naive2、510991000 个请求用了 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、5Java 内存模型:可见性、有序性、happens-before 懂设计有页(下一圈:jcstress、CPU 缓存与伪共享)2说清第 5 站为什么会错,才知道锁和 CAS 到底保证了什么
6、7synchronized 与锁升级、CAS 与原子类 懂设计有页3两种 JVM 内的修法;CAS 也是后面 AQS、并发容器的积木
6AQS 与 ReentrantLock、Condition、Semaphore 懂设计有页(下一圈:读写锁、StampedLock、Semaphore 与 JFR)4用 CAS 加一个等待队列搭出来的锁,接着第 3 个学
9ConcurrentHashMap 与阻塞队列 懂机制有页(SynchronousQueue 另见专页)5要用到 CAS 和锁;阻塞队列又是线程池的零件
2、11线程池:执行流程、参数、拒绝策略、怎么配 懂设计有页(下一圈:怎么配、拒绝策略、动态调参)6这一站的过关题:画出 execute 的三个分支,算出该开多少线程
3、11ThreadLocal 与内存泄漏 懂机制有页(结构的上一圈:HashMap 的亲戚们)7要先懂线程池里的线程为什么活得比请求长
10CompletableFuture 与异步编排 懂机制有页8要用线程池,还要处理 ThreadLocal 传不过去的问题
2虚拟线程:什么时候用、pinning 的坑 懂机制有页(上一圈:网络 IO 模型)9放最后:拿它和线程池、synchronized、ThreadLocal 逐个对照

这张全景没走到的

  • J02 的 9 个知识点都在图上。离这条链路很近、但放在别的站的:
  • 死锁:两个线程各拿一把锁等对方。这里只有一把锁,碰不到;用 jstack 找死锁在 J03,数据库死锁在 J06。
  • 分布式锁、Redis 预扣:部署多台以后第 6、7 站失效,跨进程的办法在 J07 Redis 和 J12 抢券。
  • CountDownLatch、CyclicBarrier、ForkJoin:请求链路里用不上,学 AQS 时顺带看 CountDownLatch。

补给站

回到全景

复述练习

  1. 合上页面,画出 11 站,标出哪几站是同一个 exec 线程干的、哪一站换了线程。
  2. 每站用一句话讲它做了什么,再用一句话讲两个线程撞在哪、三种修法各把哪几步并成了一步。
  3. 挑 3 站往下讲两层。比如第 6 站:没人抢时 synchronized 怎么加锁?抢的人多了变成什么?等锁的线程是什么状态,和 ReentrantLock 的等待有什么不同?

也可以直接讲给我听,说“回到全景”,我对照这张图帮你查漏。

动手:交易靶场这一站

地图上 J02 给靶场的任务是“复现超卖,三种办法修好,各压一遍比吞吐”。实测程序已经完成前两件,在它上面补:

  1. 把 H2 换成真 MySQL 8.4,再跑一遍:naive 超卖得更多还是更少?为什么?
  2. 用 wrk 或 JMeter 在 50、200、500 并发下各压 1 分钟,画出三种修法的吞吐和 P99。
  3. 过关两题对着代码讲:线程池 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 挂在线程上,线程在池里反复使用。

下一圈