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处理一个新提交的任务时顺序是核心线程数未满创建核心线程执行任务核心线程数已满任务放入阻塞队列队列已满创建非核心线程直到最大线程数最大线程数也满了执行拒绝策略。这个顺序意味着队列不满时最大线程数等于摆设。很多人把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 LinkedBlockingQueueRunnable())。注意这个LinkedBlockingQueue没有传容量默认无界。意思是线程数固定10个但队列可以无限堆积。推送业务遇到突发流量时几十万任务全塞进队列每个Runnable对象携带业务上下文内存直线上升最终GC扛不住直接OOM。这在线程池事故里是最常见的一种。3.2 newCachedThreadPool线程数没有上限的危险Executors.newCachedThreadPool()创建的是ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, SECONDS, new SynchronousQueueRunnable())。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队列里还没执行完的任务又会被丢弃。正确的关闭顺序是调用shutdown()不再接收新任务但队列里已有的任务继续执行调用awaitTermination(timeout, unit)等待所有任务完成超时后若仍有未完成的任务调用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节点获取锁成功后锁数据还没来得及同步到slavemaster宕机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的ReentrantLockRLock 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层面互斥正常流量下绝大多数冲突在这一层被消化。第三层数据库约束。锁成功获取后执行乐观锁扣减SQLversion校验和count num检查双条件保证即使锁失效数据依然一致。这套三层的意义在于每一层都是下一层的过滤器。线程池挡掉流量洪峰分布式锁挡掉并发竞争数据库约束保证最终一致。哪一层出问题下面还有底牌。我在实际项目中体会最深的一点是并发治理不是越复杂的锁越安全而是用最合适的工具挡住最常见的问题再用数据库的底线兜住最极端的意外。线程池配不好并发再高也扛不住分布式锁配得再花哨没有数据库约束兜底迟早有一天会翻车。这套组合拳跑通之后高并发IM推送、ERP库存扣减这些场景基本都能稳住剩下的事情就是在监控上多花心思把线程池指标和锁的获取成功率都看清问题才可能在发生之前就被掐灭。