news 2026/9/29 17:55:10

Java高并发生产实战:线程池与分布式锁全链路治理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java高并发生产实战:线程池与分布式锁全链路治理

Java高并发项目里,线程池和分布式锁是两个绕不开的硬骨头。我先讲一次真实的生产事故:凌晨大促刚开始,IM推送服务CPU被打满,线程数一路飙升到三千多,服务直接假死;没过半小时,库存系统又报出负库存——超卖了。排查下来,一个是线程资源无节制创建,一个是跨节点扣减库存缺少互斥控制,两件事说到底是同一个命题:Java高并发场景下,怎么把线程和共享资源管住。

这篇文章不打算讲理论八股,而是把线程池和分布式锁这两条主线串起来,从参数设计、内置线程池的坑、Redis锁的演进到Redisson落地,最后落到数据库约束兜底,完整跑一遍生产环境的并发治理方案。无论你是刚接触Java并发的初中级开发,还是正在处理高并发IM、ERP库存这类业务的技术负责人,这套从线程池到分布式锁的排坑思路都能直接抄作业。

1. 从两个生产事故说起:线程资源失控与库存超卖

1.1 IM推送服务的线程风暴

那次IM推送事故很有代表性。业务方要做一个热点事件的消息广播,给一批在线用户推送营销通知。最初的实现非常粗暴:每个用户的任务直接new Thread().start(),一个热点事件涉及几十万用户,瞬间创建了上千个线程。

线程本身不便宜,每个线程默认栈大小1MB左右,几千个线程光栈内存就是几个G。更可怕的是线程上下文切换,CPU大部分时间花在保存和恢复现场上,业务线程反而抢不到时间片,GC线程也被拖垮。最后的表现就是接口RT飙升、CPU 100%、服务假死,一台台机器相继熔断。

这个事故的本质是线程资源失控:没有复用、没有上限、没有排队机制。后面替换成线程池后,同一量级的推送任务只用几十个线程就稳稳扛住了。所以第一个经验是:Java并发编程里,线程池不是优化手段,而是线程资源的唯一合理管理方式。

1.2 ERP库存超卖:多实例扣减缺少一把锁

第二个事故来自ERP库存场景。系统做了多节点部署,用户下单时执行类似UPDATE stock SET count = count - 1 WHERE sku_id = ?的SQL。单机运行时没问题,但多个应用实例同时处理同一个SKU的请求时,两个事务可以同时读到相同库存,然后各自减一,数据库最终只落一次结果,超卖就出现了。

我当时第一反应是加synchronized,结果完全无效——三个JVM各自持有一把锁,请求被负载均衡分散到不同节点,各锁各的,等于没锁。这才意识到跨节点的互斥必须用分布式锁。

1.3 并发问题的本质:两类资源的博弈

把两个事故放在一起看,生产环境并发问题基本就两类:

第一类是线程资源耗尽,典型症状是线程数爆炸、CPU飙升、请求堆积、服务假死,解法是线程池加合理的队列和拒绝策略。

第二类是共享资源竞争,典型症状是库存超卖、订单重复、数据错乱,解法是分布式锁加数据库约束兜底。

这两类问题往往同时出现。比如高并发IM场景既有推送线程管理问题,又有幂等控制问题;ERP库存场景既有扣减并发问题,又有锁失效引发的数据一致性问题。所以这篇文章把线程池和分布式锁放在一条链路里讲,它们不是孤立的两个知识点,而是并发治理的一体两面。

2. 线程池参数的艺术:从执行顺序到拒绝策略,每一步都是权衡

2.1 线程池的执行顺序:先排队还是先扩线程?

面试和实操中第一个常见的误区分不清线程池的工作顺序。ThreadPoolExecutor处理一个新提交的任务时,顺序是:

  1. 核心线程数未满,创建核心线程执行任务;
  2. 核心线程数已满,任务放入阻塞队列;
  3. 队列已满,创建非核心线程(直到最大线程数);
  4. 最大线程数也满了,执行拒绝策略。

这个顺序意味着:队列不满时,最大线程数等于摆设。很多人把maximumPoolSize设成1000,队列却用的是无界的LinkedBlockingQueue,结果线程永远只涨到核心数,剩下的任务全堆在内存里,最后OOM。

从设计意图说透:核心线程数是为了处理常规流量,队列是为了吸收流量尖峰,最大线程数是为了在尖峰超过队列容量时临时扩员,拒绝策略是最后的熔断闸门。四者配合,缺一不可。

2.2 核心参数怎么定:从公式到经验值

核心线程数没有一个万能答案,但有两个被实践验证的估算方向:

  • CPU密集型任务,核心线程数建议设为CPU核数 + 1,多出来的一个是为了弥补某线程等待IO或GC停顿的空档。
  • IO密集型任务,建议设为CPU核数 * 2,或者用更精细的公式:CPU核数 / (1 - 阻塞系数),阻塞系数一般在0.8到0.9之间。比如8核机器、阻塞系数0.9,算出来是80个线程。

最大线程数我建议压着核心线程数的1.5到2倍,不要给得太宽。给得太宽意味着极端流量下线程数暴涨,线程切换开销反而拖垮系统。

keepAliveTime决定非核心线程空闲多久回收,一般设30到60秒比较合理。太短会导致频繁创建销毁线程,太长浪费资源。

2.3 阻塞队列选型:三类队列的适用场景

阻塞队列的选择直接决定线程池的缓冲行为。我平时主要在三类队列里做取舍:

队列特点适用场景
LinkedBlockingQueue可设置容量,链表实现,生产消费解耦默认选择,务必指定容量,避免无界
ArrayBlockingQueue数组实现,容量固定,边界明确队列长度要求严格、需要精确控制的场景
SynchronousQueue不存任务,直接移交给线程配合零缓冲策略,如CachedThreadPool风格

LinkedBlockingQueue默认容量是Integer.MAX_VALUE,相当于无界,所以使用默认构造就是隐患。实际项目中我几乎总是显式传容量,比如2000,配合最大线程数形成两级缓冲。

2.4 拒绝策略:四种策略背后的取舍

任务被拒绝时,ThreadPoolExecutor提供了四种策略:

  • AbortPolicy:默认策略,直接抛RejectedExecutionException,适合对丢失任务零容忍的强一致场景;
  • CallerRunsPolicy:让提交任务的线程自己执行,相当于把压力回抛给调用方,形成天然背压,适合非核心链路的降级处理;
  • DiscardOldestPolicy:丢弃队列里最老的任务,适合时效性强的场景,比如过期的推送任务;
  • DiscardPolicy:静默丢弃,使用时要确认业务能容忍丢任务。

我个人的习惯是:核心交易链路用AbortPolicy,配合告警;营销推送类链路用CallerRunsPolicy,让上游感知压力;实时性要求高的用DiscardOldestPolicy。

2.5 ThreadFactory和监控:排查故障的两个隐藏武器

线程池里的线程一定要通过自定义ThreadFactory命名,比如user-push-%d。一旦线上出问题,jstack一抓就能看出是哪个业务线程池卡住了,否则全是pool-1-thread-1,排查效率极低。

监控方面,ThreadPoolExecutor自带几个关键指标:getPoolSize()当前线程数、getActiveCount()活跃线程数、getQueue().size()队列积压量、getTaskCount()累计提交任务数、getCompletedTaskCount()完成任务数。把这些指标定时打进监控系统,比等到OOM再措手不及强得多。

3. Executors内置线程池的坑:无界队列和无线程上限是怎么引发事故的

3.1 newFixedThreadPool:无界队列是怎么吃掉内存的

Executors.newFixedThreadPool(10)创建的是一个ThreadPoolExecutor(10, 10, 0L, MILLISECONDS, new LinkedBlockingQueue<Runnable>())。注意这个LinkedBlockingQueue没有传容量,默认无界。

意思是线程数固定10个,但队列可以无限堆积。推送业务遇到突发流量时,几十万任务全塞进队列,每个Runnable对象携带业务上下文,内存直线上升,最终GC扛不住直接OOM。这在线程池事故里是最常见的一种。

3.2 newCachedThreadPool:线程数没有上限的危险

Executors.newCachedThreadPool()创建的是ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, SECONDS, new SynchronousQueue<Runnable>())。

SynchronousQueue不存储任务,来一个任务就要创建一个新线程,闲置线程60秒回收。任务处理够快时这套机制效率很高,可一旦遇到慢任务——比如远程接口响应变慢——新线程会不断创建,直到把CPU和内存打爆。

生产环境里我见过最夸张的一次,推送任务依赖的外部接口超时,CachedThreadPool直接创建了近万个线程,服务彻底瘫痪。

3.3 如何组装一个可控的线程池

内置线程池省事但不省心,生产环境我强烈建议自定义ThreadPoolExecutor。一个可用的模板:

ThreadFactory threadFactory = new ThreadFactoryBuilder() .setNameFormat("user-push-%d") .build(); ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, // 核心线程数 16, // 最大线程数 30L, TimeUnit.SECONDS, // 空闲回收时间 new ArrayBlockingQueue<>(2000), // 有界队列,容量2000 threadFactory, // 命名线程工厂 new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );

如果你用的是Spring Boot,强烈建议把线程池注册成Bean,统一管理生命周期,避免每个组件各建各的线程池造成资源浪费。

3.4 线程池的优雅关闭:别忽略shutdown

线程池关闭是个容易被忽略但事故高发的点。如果忘了关闭,非守护线程会阻止JVM退出;如果直接System.exit,队列里还没执行完的任务又会被丢弃。

正确的关闭顺序是:

  1. 调用shutdown(),不再接收新任务,但队列里已有的任务继续执行;
  2. 调用awaitTermination(timeout, unit),等待所有任务完成;
  3. 超时后若仍有未完成的任务,调用shutdownNow()中断正在执行的任务,并返回未执行任务列表,由业务侧决定是否补偿。

这套优雅关闭逻辑在应用重启时尤其重要,否则正在处理的库存扣减可能只执行到一半。

3.5 线程池饥饿:线程不够,队列再大也没用

线程池还有一个隐蔽问题:饥饿。假设核心线程数设了5,队列容量设了10000,而任务之间存在依赖——比如一个父任务提交多个子任务并等待子任务结果,如果父任务已经占满了核心线程,子任务全部排队,父任务永远等不到子任务完成,整个池子就死锁了。

这种场景要么把依赖任务拆到独立线程池,要么调高新线程数,要么用异步回调代替阻塞等待。线程池的参数永远要和业务执行模型匹配,不能抄一个配置走天下。

4. 分布式锁演化史:从SETNX到Lua脚本,简单锁背后的可靠性代价

4.1 单机锁为什么在集群环境失效

synchronized和ReentrantLock是JVM进程内互斥的,只能锁当前节点。业务一旦多实例部署,同一个扣库存请求可能落到三个不同节点,三个JVM各自判断“锁空闲”,然后同时进入临界区,超卖照旧。

所以要锁的不是某个JVM里的代码块,而是Redis、ZooKeeper这类所有节点都能访问的共享组件。这就是分布式锁存在的意义。

4.2 演进第一步:SETNX加锁

最早的Redis分布式锁用SETNX lock_key 1实现,含义是“只有key不存在时才能设置成功”。加锁成功返回true,解锁直接DEL lock_key。

这个版本有两个致命问题:一是没有过期时间,一旦业务执行中宕机,锁永远不释放,形成死锁;二是解锁时直接删key,可能删掉其他线程刚获取的锁。

4.3 演进第二步:SET NX EX原子化

Redis 2.6.12之后,可以用一条命令完成加锁和设置过期时间:

SetParams params = SetParams.setParams().nx().ex(30); String result = jedis.set(lockKey, requestId, params);

NX保证只有key不存在时才能设置,EX 30给锁加30秒自动过期,两个动作原子完成,宕机死锁的问题解决了。

但误删锁的问题还在。于是释放锁时必须校验持有者身份,一般用UUID或requestId作value,释放前先判断value是否一致:

String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end"; jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

这段Lua脚本保证了“校验+删除”的原子性,防止A线程的锁过期后被B线程获取,A线程跑完却把B的锁删掉的情况。

到这里,一个最基本可用的Redis分布式锁就成型了:SET原子加锁、Lua脚本安全释放。但离生产级还差得远。

4.4 过期时间杀死漫长的业务:看门狗续期机制

锁设30秒过期,业务刚好执行35秒,锁在30秒时自动释放,下一个请求立刻拿到锁进入临界区,两个线程同时操作共享资源,等于锁白加了。

解决思路是续期:启动一个守护线程,在锁快过期时自动续期。Redisson的实现看门狗默认锁超时30秒,每10秒检查一次,如果业务还在执行就续期30秒。这个机制极大缓解了业务超时锁过期的问题。

但看门狗也不是绝对安全。极端情况下GC停顿超过10秒,看门狗线程没能在窗口内续期,锁依然会释放。所以锁内业务时间必须尽量短,能用异步处理就不要在持锁状态下做远程调用。

4.5 主从切换丢锁与RedLock争议

Redis主从架构下还有一个隐患:master节点获取锁成功后,锁数据还没来得及同步到slave,master宕机,slave晋升为master,此时锁数据丢失,另一个节点能再次获取同一把锁。

Redis官方为此推出了RedLock算法:部署5个独立Redis节点,加锁时必须在多数节点(至少3个)上加锁成功才算成功,从而把单点故障的概率降下来。

但RedLock的争议一直在:它依然不是强一致方案,网络分区时可能出现同一把锁被两个客户端同时持有的情况。业界两位大佬Antirez和Martin Kleppmann为此专门论战过,结论是RedLock在极端情况下并不比单节点锁安全多少,反而复杂度高、性能差。

生产环境里,我见过的大多数团队用的是“单Redis实例+看门狗+数据库兜底”,走RedLock的极少。原因很直接:分布式锁是为了挡住绝大多数并发问题,极端一致要靠数据库约束来补,没必要为低概率事件付出巨大复杂度。

4.6 三种分布式锁实现方式的对比

从工程选型角度,主流分布式锁有三条路:

实现一致性性能运维成本适用场景
Redis锁高可用模式,非强一致高低缓存链路、性能要求高的场景
ZooKeeper锁顺序临时节点,强一致中中对一致性要求高的场景
数据库锁唯一索引或行锁低低小规模系统、简单场景

ZooKeeper锁通过临时顺序节点实现,节点创建成功即持锁,会话断开或节点删除即释放,不存在过期时间难设置的问题,一致性比Redis锁可靠。但ZK集群本身也有运维复杂度,且每次加锁涉及网络往返,吞吐量不如Redis。

我的选型经验是:默认Redis锁,锁住核心业务后一定要用数据库约束兜底;如果业务对一致性要求极高且能接受稍低的吞吐,再考虑ZooKeeper实现。

5. 生产级分布式锁落地:Redisson、锁粒度设计与故障演练

5.1 Redisson RLock的使用细节

Redisson把分布式锁封装成了标准接口,使用体验接近JUC的ReentrantLock:

RLock lock = redissonClient.getLock("stock:lock:" + skuId); try { if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // 执行库存扣减等临界区逻辑 } else { // 获取锁失败,快速返回或降级 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }

这里有几个容易踩的细节:

第一,tryLock(3, 10, TimeUnit.SECONDS)第一个参数是等待锁的最长时间,第二个参数是锁的租约时间。指定租约时间后,看门狗不会自动续期,到点强制释放,所以这个值必须大于业务最大执行时间。

第二,finally里不要无条件unlock()。如果服务端锁已经因为租约到期而释放,当前线程的unlock会抛IllegalMonitorStateException。先判断isHeldByCurrentThread()再解锁是稳妥写法。

第三,Redisson的unlock内部会校验持有者线程ID,其他线程调unlock解不开别人的锁,这比原生SETNX加UUID的方式更安全。

5.2 锁粒度设计:别再锁一整张表

分布式锁最常见的性能杀手是锁粒度太大。很多人直接把"stock_lock"当key,所有SKU共用一把锁。结果是不同商品的订单互相阻塞,QPS直接塌方。

正确做法是按业务维度拆分锁:库存场景锁到"stock:lock:" + skuId,订单场景锁到"order:lock:" + orderNo,用户资产场景锁到"account:lock:" + userId。锁粒度越小,并发能力越高,但也要注意锁太多会占Redis内存,合理的维度是“业务操作真正互斥的最小范围”。

5.3 故障演练:Redis宕机、锁过期、重复请求

分布式锁从代码上线到稳定运行,必须经过三轮故障演练。

第一轮,模拟Redis宕机。Redisson默认情况下Redis挂了,tryLock会抛异常。此时锁服务不可用,业务怎么办?我的方案是两层:锁获取失败降级为数据库乐观锁校验,同时打开限流开关,直接把大部分流量挡在门外。

第二轮,模拟锁过期但业务未结束。给看门狗续期留足余量,并且把临界区的远程调用全部换成快路径,必要时把耗时的子任务挪到锁外执行。

第三轮,模拟客户端重复提交。同一订单号的并发请求,分布式锁应确保只有一个请求真正扣库存,其余请求要么等待要么直接返回处理中。测试通过的标准是:Redis里锁的加解锁次数与有效订单数一致,库存表数量不出现负数。

5.4 高并发IM推送场景如何和分布式锁配合

回到文章开头的IM推送事故。现在线程池负责管理推送线程,分布式锁负责什么呢?典型场景是同一用户的推送任务不能并发执行,否则用户会收到重复消息。

这时以"push:lock:" + userId为key加锁,同一用户的多条推送任务在锁外排队,线程池的任务只做提交,真正执行推送的线程拿到锁后再发消息。线程池管住了全局线程数量,分布式锁管住了单用户的互斥,两者配合没有互相挤压。

6. 数据一致性的终极兜底:数据库约束、幂等设计与组合拳

6.1 分布式锁不是银弹:最后的防线是数据库

我对分布式锁的态度一直是:能挡住99%的并发问题,但别把100%的希望寄托在它身上。Redis锁抖动、看门狗GC停顿、主从切换丢锁,任何一个窗口都可能让多个请求同时进入临界区。

所以生产环境真正的定海神针是数据库自身的约束能力。库存防超卖,乐观锁是首选:

UPDATE stock SET count = count - #{num}, version = version + 1 WHERE sku_id = #{skuId} AND version = #{version} AND count >= #{num}

这个SQL的意思是:更新前检查版本号和库存量,更新时版本号加一,影响行数为0说明版本冲突或库存不足,业务层据此返回失败。即使分布式锁失效,数据库层面也不会产生负数库存。

6.2 悲观锁和唯一索引:两把不同的防守武器

悲观锁适合读写冲突比较频繁的场景。SELECT ... FOR UPDATE查询时直接锁住对应行,事务提交后释放,并发控制交给数据库。但悲观锁容易引发锁等待和死锁,吞吐量比乐观锁低不少,我一般只在转账、账户扣款这类强一致场景使用。

唯一索引则是防重复的利器。订单表在order_no字段建立唯一索引,重复下单时第二次插入会直接冲突,数据库层面就拒绝了重复数据:

INSERT INTO order_record (order_no, status) VALUES (#{orderNo}, 1); -- order_no是唯一索引,重复插入会抛DuplicateKeyException

这个方案比查一次再插一次的检查式逻辑靠谱得多,因为“查和插”两步之间永远存在并发窗口,唯一索引把这个窗口直接堵死。

6.3 组合拳示例:ERP库存扣减的三层防护

以一个完整的ERP库存扣减流程收尾,把线程池、分布式锁、数据库约束串起来:

第一层,线程池。下单请求由线程池统一调度,控制并发请求总量,防止数据库连接池和中间件被瞬间打满。

第二层,分布式锁。以"stock:lock:" + skuId加锁,同一SKU的扣减请求在Redis层面互斥,正常流量下绝大多数冲突在这一层被消化。

第三层,数据库约束。锁成功获取后执行乐观锁扣减SQL,version校验和count >= num检查双条件保证即使锁失效,数据依然一致。

这套三层的意义在于:每一层都是下一层的过滤器。线程池挡掉流量洪峰,分布式锁挡掉并发竞争,数据库约束保证最终一致。哪一层出问题,下面还有底牌。

我在实际项目中体会最深的一点是:并发治理不是越复杂的锁越安全,而是用最合适的工具挡住最常见的问题,再用数据库的底线兜住最极端的意外。线程池配不好,并发再高也扛不住;分布式锁配得再花哨,没有数据库约束兜底,迟早有一天会翻车。这套组合拳跑通之后,高并发IM推送、ERP库存扣减这些场景基本都能稳住,剩下的事情就是在监控上多花心思,把线程池指标和锁的获取成功率都看清,问题才可能在发生之前就被掐灭。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 17:55:09

模型服务压测不可信的8大变量与一套可落地的性能测试方法

最近排查了一个很蹊跷的压测问题&#xff1a;同一台机器&#xff0c;同一个训练好的模型权重&#xff0c;服务代码一行没动&#xff0c;只是把一个配置开关从关改成开&#xff0c;压测出来的 QPS 反而暴跌了 30%。一开始我以为是开关写反了&#xff0c;或者模型加载出问题&…

作者头像 李华
网站建设 2026/9/29 17:54:28

OpenClaw实战:6款热门部署方案从Teams到NAS全解析

1. 榜单背后的主角&#xff1a;OpenClaw 是什么、为什么能火OpenClaw&#xff0c;近两个月社区里被讨论得最多的开源 AI 代理框架之一&#xff0c;很多“懂行”的玩家已经把它当成本地 AI 助手的默认选项。大家叫它“龙虾”&#xff0c;一方面是因为“Claw”这个单词本身就有爪…

作者头像 李华
网站建设 2026/9/29 17:53:40

TI MSPM0驱动PS2摇杆:ADC序列采样与GPIO消抖移植实战

1. 从一颗摇杆说起&#xff1a;为什么要在MSPM0上折腾PS2模块 PS2双轴按键摇杆模块大概是电子爱好者手里最常见、也最容易被低估的输入设备之一。它便宜、好买、结构简单&#xff0c;两个电位器加一个轻触按键&#xff0c;五根引脚就能把二维方向加按压状态全部输出。很多人第一…

作者头像 李华
网站建设 2026/9/29 17:53:21

Zabbix交换机监控模板:端口流量与硬件状态统一监控实践

简介&#xff1a;这份资源是面向网络运维与Zabbix使用者的交换机监控模板包&#xff0c;针对交换机端口流量、接口状态、错误统计等关键指标难以快速接入监控的问题&#xff0c;提供可直接导入的配置模板&#xff0c;适合具备一定SNMP与Zabbix基础的运维人员使用。压缩包内共2个…

作者头像 李华
网站建设 2026/9/29 17:51:53

SpringBoot快速搭建网页实战:模板引擎、热更新与版本选择全攻略

1. 先想清楚&#xff1a;你要的"网页"到底是哪种很多刚接触Java后端的朋友都会问同一个问题&#xff1a;怎么快速搭建一个SpringBoot项目网页&#xff1f;我刚开始学的时候也绕了不少弯路&#xff0c;要么卡在环境上&#xff0c;要么项目能启动但访问不到页面&#x…

作者头像 李华
网站建设 2026/9/29 17:51:51

uniapp跨端人员轨迹绘制实战:从坐标清洗到地图渲染全流程

上个月接了个外勤人员的轨迹回放需求&#xff0c;要在 uniapp 项目里做一张人员轨迹绘制图&#xff0c;横跨微信小程序、H5、安卓 App 三端。说白了就是把一群人一天跑过的地方按时间顺序画在地图上&#xff0c;带播放、缩放&#xff0c;还要兼顾老手机的性能。这类需求在物流调…

作者头像 李华