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

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP正式会员正式会员 黑卡会员黑卡会员
发布于 2026-10-03 22:20 ·12 浏览 ·4 回复

学完这篇你能得到:把线程池、锁、并发容器这三块从"背 API"变成"知道在生产里怎么选、怎么避坑"的判断力,并拿到一份可直接抄改的代码模板。

第一步:线程池的七个参数,先对应到你的业务

ThreadPoolExecutor 的构造参数一共七个,别看它多,本质就是回答三个问题:常驻几个人、最多几个人、排不下怎么办。

ThreadPoolExecutor pool = new ThreadPoolExecutor(
    8,                              // corePoolSize 常驻核心
    16,                             // maximumPoolSize 峰值上限
    60L, TimeUnit.SECONDS,          // 空闲非核心线程存活时间
    new ArrayBlockingQueue<>(200),  // 有界队列,关键!
    new ThreadFactory() {           // 一定要给线程起名
        private final AtomicInteger i = new AtomicInteger(1);
        public Thread newThread(Runnable r) {
            return new Thread(r, "order-worker-" + i.getAndIncrement());
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy() // 兜底策略
);

执行顺序要记牢:先占满 core → 再塞队列 → 队列满了才扩到 max → 还满就走拒绝策略。很多人以为先扩容再排队,这是最常见的误解。

参数怎么定?CPU 密集任务用 核心数 + 1;IO 密集可以按 核心数 × (1 + 等待时间/计算时间) 估算,再压测微调。

注意:绝对不要用 Executors.newFixedThreadPool() 和 newCachedThreadPool()。前者队列是 LinkedBlockingQueue 且默认容量接近无界,任务堆积会 OOM;后者最大线程数是 Integer.MAX_VALUE,高并发下会疯狂创建线程直接打爆机器。手动 new 才是正解。

第二步:拒绝策略选错,等于把锅甩给用户

JDK 内置四种:AbortPolicy 抛异常(默认)、DiscardPolicy 静默丢弃、DiscardOldestPolicy 丢最老的、CallerRunsPolicy 交给调用线程执行。

后两种"丢弃"策略在生产里基本等于事故——任务悄悄没了,日志里连个影子都没有。常用的是 CallerRunsPolicy,它会阻塞提交线程形成天然背压;或者自定义策略,把任务落库/进 MQ 稍后重试。

第三步:synchronized 还是 ReentrantLock

synchronized 是 JVM 内置的,自动加锁释放锁,代码块抛异常也不会死锁,JDK 6 之后有偏向锁→轻量级锁(CAS 自旋)→重量级锁的升级路径,绝大多数场景够用且更快。

ReentrantLock 的价值在三个 synchronized 做不到的地方:

ReentrantLock lock = new ReentrantLock();
// 1. 可超时,避免无限等待
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
    try { /* 业务 */ } finally { lock.unlock(); }
}
// 2. 可中断
lock.lockInterruptibly();
// 3. 可建多个条件队列(生产者/消费者各一个)
Condition notFull = lock.newCondition();

注意:锁对象千万别用 String 常量或 Integer 这类会被缓存的包装类型,不同业务可能锁到同一个对象上;用 private final Object lock = new Object();,并且尽量别锁 this 或公开可见的对象。

第四步:读多写少,换读写锁或 StampedLock

ReentrantReadWriteLock 的规则是:读读不互斥、读写互斥、写写互斥。适合缓存这类"读远多于写"的场景。

注意:读锁不能升级成写锁(会死锁),但写锁可以降级成读锁。别在持有读锁时去 lock.writeLock().lock()。

如果只是极短的读操作,用 StampedLock 的乐观读更快:

StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead();
int cur = value;                 // 先读
if (!sl.validate(stamp)) {       // 校验期间有没有被写
    stamp = sl.readLock();
    try { cur = value; } finally { sl.unlockRead(stamp); }
}

代价是它不可重入,逻辑不能嵌套调用。

第五步:并发容器,重点是复合操作

  • ConcurrentHashMap:JDK 8 起是"CAS + 锁单个桶头节点",并发度远高于分段锁时代。不允许 null 键值。
  • CopyOnWriteArrayList:写时复制整个数组,适合监听器列表这种"读多写几乎没有"的场景;写操作代价高,且迭代器是快照,读不到最新数据。
  • 阻塞队列:ArrayBlockingQueue 有界,能配合背压,推荐;LinkedBlockingQueue 不传容量就是 Integer.MAX_VALUE,等于无界;SynchronousQueue 不存元素,直接交接线程。

注意:map.get(k) == null 再 put 是典型竞态。要用原子方法:putIfAbsent、computeIfAbsent、merge。另外 computeIfAbsent 的映射函数里不要再操作同一个 map,会死锁。

第六步:把三样拼起来——分段并行统计

Map<String, Long> result = new ConcurrentHashMap<>();
List<String> keys = List.of("a", "b", "c", "d");
CountDownLatch latch = new CountDownLatch(keys.size());

for (String k : keys) {
    pool.execute(() -> {
        try {
            result.merge(k, 1L, Long::sum);   // 原子累加
        } finally {
            latch.countDown();
        }
    });
}
latch.await(3, TimeUnit.SECONDS);            // 等齐或超时

线程池负责调度,ConcurrentHashMap.merge 负责线程安全的聚合,CountDownLatch 负责汇合——这三件套覆盖了日常八成并发场景。

小结

  1. 线程池手动 new ThreadPoolExecutor,队列必须有界,线程必须命名,拒绝策略别静默丢弃。
  2. 优先 synchronized;需要超时、可中断、多条件队列时再用 ReentrantLock。
  3. 读多写少用 ReentrantReadWriteLock 或 StampedLock;读锁不能升级为写锁。
  4. 并发容器的重点不是"用它",而是"用它的原子方法",别自己拼复合操作。
  5. 所有锁和池都要在 finally 里释放/关闭,shutdown() 之后记得等 awaitTermination。
本文转载自 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 因别的原因没清干净,脏值照样串。