《Java 并发编程·线程与内存模型》14.3 虚拟线程下的池、ThreadLocal 与限流
虚拟线程把每条阻塞任务降到约 1 KB,但连接池、下游 QPS 和 ThreadLocal 副本数没变。本节实测 1 000 个任务不设限时 maxInFlight=1 000、36–57 ms 打穿下游;加 Semaphore(16) 后恒定 16、耗时 2 256–2 286 ms。另测 10 万条虚拟线程各存 1 KB ThreadLocal 多占约 104 MB。
tag
虚拟线程把每条阻塞任务降到约 1 KB,但连接池、下游 QPS 和 ThreadLocal 副本数没变。本节实测 1 000 个任务不设限时 maxInFlight=1 000、36–57 ms 打穿下游;加 Semaphore(16) 后恒定 16、耗时 2 256–2 286 ms。另测 10 万条虚拟线程各存 1 KB ThreadLocal 多占约 104 MB。
JDK 24 的 JEP 491 让 synchronized 不再 pin 虚拟线程,‘虚拟线程里别用 synchronized’已经过期。本节用 JFR 逐项验证:20 条同步块阻塞 0 个 pin 事件,类初始化阻塞 19 个;FFM usleep 虽不触发事件,却让 10 条载体全被占住,探针延迟 437–450 ms。
平台线程一条要占 0.5–1 MB 栈,一万并发就逼近内存红线。本节从 1:1 与 M:N 的差别讲起,拆开载体线程、mount/unmount 和栈片段,并在本机用 -Xmx512m 创建 10 万个虚拟线程:堆占用约 89–103 MB、启动 91–132 ms,载体线程只有 10 条。
异步把执行线程换掉了,ThreadLocal 里的请求 ID 也就断了。本节实测 commonPool 与裸线程池里读到的上下文都是 null,给出在提交时捕获、执行时安装的 Executor 装饰器,并用 1 线程池的 418ms 对 20ms 说明为什么三个下游要各自隔离线程池。
exceptionally、handle、whenComplete 的差别在于「谁能改变结果」,而不是「谁会被调用」。本节实测 orTimeout 在 200ms 预算下于 203–208ms 触发,底层任务照常跑完且未被中断;cancel(true) 同样不中断。三路通知的降级链整体 217ms,而不是串行至少 860ms。
Future 只能阻塞取值,CompletableFuture 把「结果还没到」变成一个可以继续挂步骤的对象。本节实测默认执行器是 parallelism=9 的 commonPool,thenApply 在完成者线程上执行,三路下游扇出从串行 290ms 降到 120ms,并给出 allOf 不 fail-fast 的真实证据。
阈值同时决定任务数和叶子成本:切太小,任务对象与调度开销吃掉收益;切太大,叶子里的插入排序退化成平方复杂度。本机 100 万元素归并排序实测,阈值 1 是 140ms、16 是 21ms、65536 是 1629ms,顺序版 105ms。文末用实测输出说明异常如何跨线程包装,以及 cancel 为什么停不下正在跑的叶子。
parallel() 只多了一个方法调用,却把用哪个池、拆多细、有没有副作用三件事都藏了起来。本机实测:同样 20 个 10ms 任务,commonPool 空闲时 31ms,被阻塞任务占满后 276ms;1000 个元素的并行流比顺序流慢 2.49 倍;36 个阻塞任务在 commonPool 上 1231ms,换成 16 线程的自定义池 916ms。
线程池是任务等线程,ForkJoinPool 是任务找线程:每个 worker 一个双向队列,自己从头部取最新的任务,空闲线程从尾部偷最老的任务。本机 10 核实测 commonPool 并行度 9,1 亿整数求和在四轮里从顺序 67ms 降到 13ms,池自己记录到 189 次窃取;并行度改成 3 后是 17ms。
固定频率和固定延迟的分母不同:前者对齐计划时刻,后者从执行结束开始算。本机实测同一个 200ms 周期、80ms 耗时的任务,fixedRate 六次的落后量稳定在 1–9ms,fixedDelay 则从 82ms 一路涨到 444ms;单次执行超过周期时,fixedRate 不丢弃欠账而是背靠背补跑。文末给出幂等键与补偿的落地写法。
延迟任务有两种组织方式:堆,或者时间轮。本文拆开 DelayQueue 的优先级队列与 leader 线程,实测乱序入队仍按剩余延迟出队、指数退避重试在 +0/+60/+173ms 完成;再手写一个 8 槽 50ms 的时间轮,对比同组任务的量化误差:时间轮平均 30ms,DelayQueue 平均 2.2ms。
Timer 只有一条线程、一个异常就能让它彻底停摆,还把相对延迟换算成墙上时钟的绝对时刻。三个实验对照:慢任务把后续任务从 50ms 推到 317ms;抛异常后 Timer 线程死亡、700ms 内只跑了 1 次,而 STPE 里健康任务照跑 8 次;字节码显示两者分别取 currentTimeMillis 与 nanoTime。
线程池上线之后要回答三个运维问题:指标怎么读、参数能不能改、进程怎么退。本节逐个核对 getActiveCount、队列长度、getCompletedTaskCount 的口径与误差,实测 setCorePoolSize 上调立即补线程、下调却要等 keepAlive 才收敛,并对比 shutdown 与 shutdownNow 的返回值和中断行为。
队列满、线程到顶之后发生什么,取决于拒绝策略。本节实测四种 JDK 拒绝策略在同一场景下的不同结果,解释 CallerRunsPolicy 如何在调用线程上形成反压、它在什么条件下会失效,并用无界队列实验证明 max 参数如何被架空,最后给出有界队列容量的推导方法与可记录指标的自定义拒绝策略。
把 ThreadPoolExecutor 的七个参数逐个拆开,说明 core、max、queue、keepAlive、工厂与拒绝策略各管哪一段,用三段判断还原 execute 的执行顺序,并以逐步提交实验打印 poolSize 与 queue 的真实变化,验证核心线程默认不超时、只有开启 allowCoreThreadTimeOut 才会归零。
读多写少的缓存该用什么锁:ReentrantReadWriteLock 让读并行、写互斥,StampedLock 再用版本戳把读变成乐观尝试。三版缓存代码对照,附读写并行、乐观读失效重试、不可重入、writeLock 不响应中断的本机实测输出。
ReentrantLock 比 synchronized 多出的四件事——可中断、超时、公平、多条件队列,逐项给出本机实测;再用 JFR 验证 JDK 25 默认配置下 synchronized 已不 pin 虚拟线程(LockingMode=1 可复现 20 次 pin),据此重新回答什么时候仍该坚持 synchronized。
从自旋锁的 CPU 代价讲到 LockSupport.park/unpark 的许可语义:许可最多一个、先 unpark 后 park 也生效、中断不清标志、虚假唤醒为何必须循环检查。附本机线程 dump 与 park 边界情形的真实输出,并给出借阅服务的唤醒式 worker。
从 Striped64 的 base + Cell[] 结构讲清 LongAdder 为什么能绕开单变量 CAS 的天花板:给出 JDK 25 的 add/sum 源码、cell 分片的实测形状、@Contended 与伪共享的真实编译约束,并在负载 155 的机器上如实记录 JMH 误差超过 50% 的数据不可引用。
把 java.util.concurrent.atomic 拆成基本、引用、数组、字段更新器四族:给出 JDK 25 的 getAndUpdate 源码与返回值语义、字段更新器省掉一个对象的代价与四条硬约束、实测的四种构造失败消息,并厘清 weakCompareAndSet 在 JDK 25 已被弃用为 plain 语义这件事。