Java 并发编程深度解析:线程池 / 锁 / 并发容器

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP正式会员正式会员 黑卡会员黑卡会员
发布于 2026-10-03 22:20 ·7 浏览 ·4 回复
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-694.html
转载请注明出处,版权归原作者所有。
他们都看过 1 人浏览过
CLARA轻量论坛系统

全部回复 4

dp32323
dp32323 正式会员正式会员 1楼 2026-10-03 22:27

参数怎么定这篇已经讲透了,但线程池在生产里翻车,八成不是参数算错,而是缺了监控、隔离、优雅关闭这三件事。

监控和隔离一起说:线程名前缀不只是好看,是给监控用的——把 getActiveCount、getQueue().size()、拒绝次数打到 Micrometer 或定时日志里,没有监控的池子就是黑盒。同时不同业务别共用一个池,一个慢下游能把整池线程占满,连带把无关业务一起拖死,按依赖方拆池、各自定参数才是稳的。优雅关闭则是 shutdown() + awaitTermination(30, SECONDS),超时再 shutdownNow(),别忘了处理被中断残留的任务。

拒绝策略补一点:CallerRunsPolicy 看着很美,但在 Tomcat 里提交线程就是 HTTP 工作线程,让它跑慢任务等于直接拉长接口 RT,高并发下会连锁堆积;而且池已 shutdown 时它其实是丢弃(源码里判了 isShutdown)。所以关键链路还是落库或进 MQ 重试更靠谱。

锁那块,ReentrantLock 除了可中断、超时、公平,还有多 Condition 精确唤醒,这是 synchronized 单条件队列比不了的。并发容器记得 CHM 的 size() 是估算值,复合操作一定走 computeIfAbsent/merge,别 get 完再 put;CopyOnWriteArrayList 写时全量复制,只适合读多写极少的配置类数据。

延伸一句:JDK 21 虚拟线程做 IO 密集确实香,但 synchronized 会 pin 住载体线程,得换 ReentrantLock,这个要到 JDK 24(JEP 491)才修,升级前先评估。

zero
zero 见习用户见习用户 #358 2楼 2026-10-03 22:37
dp32323:参数怎么定这篇已经讲透了,但线程池在生产里翻车,八成不是参数算错,而是缺了监控、隔离、优雅关闭这三件事。 监控和隔离一起说:线程名前缀不只是好看,是给监控用的…

你补的监控、隔离、优雅关闭这三件事才是生产翻车的主因,参数只是入场券——我顺着再往下接几个实操点。

隔离要提个边界:按依赖方拆池是对的,但别拆太碎。每个池至少 core 个线程常驻,机器上挂几十个池子,线程数先把自己压垮。我的经验是"关键链路独立 + 慢下游独立",其余共用,池总数控制在个位数。监控指标也别只看 activeCount/queueSize/rejectCount,真正能验证参数对不对的是队列等待时间——任务入队打时间戳、开始执行时相减,它才是接口 RT 里被排队吃掉的那部分;配合 getQueue().remainingCapacity() 做前置降级(余量低于阈值直接走快速失败/MQ),比等拒绝策略触发要早一轮。

锁这块多 Condition 确实香,实操上再补一句:tryLock(timeout, unit) + finally unlock() 是标配,别用 lock() 裸奔,否则一个死循环就把等待队列焊死了。另外楼主原文提的"偏向锁"已经过时——JEP 374 在 JDK 15 就默认禁用偏向锁,JDK 18 把相关代码彻底移除,现在只剩轻量级锁→重量级锁这条路,所以"JDK 6 之后的升级路径"要按版本打折看。

虚拟线程 pin 的问题,JDK 21 上可以先加 -Djdk.tracePinnedThreads=full 跑一轮压测把 pin 点定位出来,通常就是 synchronized 里包了 IO,换成 ReentrantLock 就好,不一定非等 JDK 24。

最后一个小坑:shutdown() 之前记得先停上游提交入口(关掉 MQ 消费、下线服务实例),否则你在 awaitTermination,那边还在往队列里灌任务,永远等不完。

wbcm
wbcm 见习用户见习用户 #359 3楼 2026-10-03 22:44
zero:你补的监控、隔离、优雅关闭这三件事才是生产翻车的主因,参数只是入场券——我顺着再往下接几个实操点。 隔离要提个边界:按依赖方拆池是对的,但别拆太碎。每个池至少…

这几条补得实在,尤其"队列等待时间"这个指标,比线程数、队列长度那种瞬时值有用得多——我顺着加几个能直接抄的细节。

埋点不用自己包 Runnable,ThreadPoolExecutor 本身留了 beforeExecute/afterExecute 两个钩子,重写就行。但有个坑:任务开始时间得用 ThreadLocal 存,afterExecute 里算完差值必须 remove,否则线程被复用后脏值串到下一个任务,P99 直接虚高。排队时长和执行时长建议分开打点,参数对不对一眼能看出来。

队列余量做前置降级我认同,只提醒它只对有界队列精确(ArrayBlockingQueue/LinkedBlockingQueue 都行),SynchronousQueue 恒为 0、PriorityBlockingQueue 无容量概念,别写死一种判断;而且读余量和提交之间有竞态,它只能当软阈值触发快速失败,不能当硬保证。

线程预算再补一层:池大小该跟下游资源对齐。DB 连接池就 20 个,前面给 8 个池各配 50 线程,最后只是把排队从线程池挪到连接池,RT 一点没省。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #360 4楼 2026-10-03 22:52
wbcm:这几条补得实在,尤其"队列等待时间"这个指标,比线程数、队列长度那种瞬时值有用得多——我顺着加几个能直接抄的细节。 埋点不用自己包 Runnable,`Thr…

这几条我全认,尤其 afterExecute 里必须 remove、以及"池大小跟下游资源对齐"这两点,我再顺着钩子这个方向补两个容易踩的坑。

第一个是 afterExecute 的 Throwable 语义:只有 execute() 直接提交的任务抛异常,throwable 才会传进来;submit() 提交的会被 FutureTask 吞掉,第二个参数永远是 null。两边都在用的话,异常统计得走 future.get() 或自己包一层,光在 afterExecute 里判 t != null 会漏掉一半。另外 beforeExecute 自己抛异常时,任务根本不执行、afterExecute 也不会被调用——所以这个钩子里别做可能失败的事,埋点只做本地累加最稳,别同步写远程。

队列余量当软阈值我也同意,建议再配一个 CallerRuns 的执行计数一起看:余量掉到阈值 + CallerRuns 开始涨,基本就是在把压力回灌给调用线程了。顺带说,余量读的是瞬时值,多个线程共享一个池时抖动会很大,采样间隔别太密,5-10 秒一次够用。

下游对齐那点再往下推一层:线程数其实是"最坏情况并发",真要卡住下游得上 Semaphore 做舱壁。而且注意 CallerRunsPolicy 下调用线程执行的任务是绕过池的并发控制的,舱壁和线程池得放在同一层收口,不然信号量放行了、线程池又把任务甩回调用线程,照样打穿下游。

小细节兜底:ThreadLocal 建议在 beforeExecute 的 set 之前先 remove 一次,防上一轮 afterExecute 因别的原因没清干净,脏值照样串。