1. 为什么并发bug总在半夜爆出来
连续几周凌晨被电话叫醒之后,我终于忍无可忍,开始认真统计那段时间线上到底出了多少并发相关的事故。数完我自己都愣住:一个月12个,分布在订单、库存、支付回调、优惠券、用户积分好几个系统里。有的表现为偶发超卖,有的是线程池直接拒绝服务,有的是数据被覆盖成旧值,还有一次是两台机器各自处理同一笔订单,最后状态又回去了。
这不是我一个人的问题。大多数Java后端团队都会经历这个阶段:八股文里背过synchronized和ConcurrentHashMap,知道有锁和原子类这回事,但真到了生产环境,并发bug照样一个接一个地爆。因为它们几乎都是偶发的,压测环境复现不出来,测试环境数据量太小也触发不了,只有线上某个瞬间流量一冲,问题才现出原形。
这篇文章适合谁看?如果你刚接手高并发服务,或者你所在的项目并发量不算夸张,但bug开始频繁出现,那我这套方法可以直接拿去用。我最后通过6招把月均12个并发bug降到了0,不是靠堆人力排查,而是靠调整写代码的方式、上线前的前置检查,以及一套相对完整的监控定位手段。下面我会把这6招的原理解释清楚,每一步都给你能直接落地的方案和参数。
先说清楚一个认知:并发bug不是靠细心就能杜绝的。它需要你从设计源头就规避竞态,从容器和工具层面选对方案,从监控层面提前暴露风险,再从流程层面把问题拦截在发布之前。这四件事缺了哪一件,都会在某个流量高峰还回来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一招:线程池参数别再拍脑袋了
2.1 线程池三板斧:命名、拒绝策略、监控钩子
线程池是Java并发里最常用也最容易配错的东西。很多同学直接Executors.newFixedThreadPool(10)就上线了,这种做法在流量一上来的时候就会出事。JDK自带的几个快捷线程池实现,问题非常大:newFixedThreadPool和newSingleThreadExecutor用的是无界LinkedBlockingQueue,任务只进不出,队列越长内存占用越高,直到OOM;newCachedThreadPool的线程数不设上限,流量波动一大,瞬间能创建几百上千个线程,把CPU和内存打满。
所以我第一条规定就是:业务代码里不允许用Executors的快捷方法,一律创建自定义线程池。必要参数至少包含核心线程数、最大线程数、队列容量、拒绝策略,以及一个能被日志快速识别的线程工厂和一个可见的监控钩子。线程工厂不是可有可无的细节,否则线上打出来的线程名全是pool-1-thread-1,你根本不知道是哪个业务在乱创建线程。我一般会自定义ThreadFactory,命名格式是业务名-线程类型-id,同时给线程设置UncaughtExceptionHandler,避免异常被吞掉以后线程池完全没反应。
代码大概长这样:
java复制ThreadFactory threadFactory = new ThreadFactory() {
private final AtomicInteger counter = new AtomicInteger(0);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "order-async-worker-" + counter.incrementAndGet());
t.setUncaughtExceptionHandler((thread, throwable) ->
log.error("线程 {} 执行异常", thread.getName(), throwable));
return t;
}
};
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, 16, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2000),
threadFactory,
new CallerRunsPolicy()
);
拒绝策略我建议先在项目里统一规定:核心业务用CallerRunsPolicy,让请求方线程自己去跑,至少不会丢任务;如果业务可以容忍丢弃,就用自定义的DiscardPolicy并打告警。千万不要用默认的AbortPolicy,它会在高并发下直接抛RejectedExecutionException,而很多调用方根本没有catch这个异常,结果就是接口返回500。
2.2 线程数到底该配多少,实测比公式更靠谱
关于线程数,网上的经验公式很多:CPU密集型用CPU核数+1,IO密集型用CPU核数 * 2,或者更复杂的CPU核数 * (1 + 等待时间/计算时间)。这些公式作为初始值可以参考,但真正的参数一定要压测定。因为线程数不是越大越好,线程切换本身有开销,线程太多反而把CPU时间都耗在上下文切换上,任务吞吐不升反降。
我自己的做法是拿核心场景做压测,用逐渐加大线程数和流量QPS的方式,观察P99响应时间和吞吐量的变化曲线。线程池的队列长度也需要跟着调:队列太长,响应时间会变得不可控,因为任务在队列里排队的时间越长,前端等待越久;队列太短,线程数很容易打满然后触发拒绝策略。我一般把队列长度控制在核心线程数能支撑的积压量的两倍以内,再结合业务对延迟的容忍度来定。
我们线上有个异步同步ES数据的线程池,最初参数是:核心4、最大8、队列5000。看起来没毛病,结果某天大促流量进来,一批批任务往队列里塞,积压越来越多,等真正开始执行的时候,数据已经过期了。后来把参数改成核心8、最大16、队列500,配合背压机制,反而稳住了。这里的关键点是:队列不是越大越安全,它只是把问题往后推迟。
2.3 踩坑实录:无界队列引发的OOM惨案
有一次我们一个服务频繁OOM,一开始以为是堆太小,把堆从2G调到4G还是不行,最后发现罪魁祸首就是newFixedThreadPool的无界队列。某个上游突然把一批大批量任务发过来,每次任务处理又比较慢,任务在队列里越堆越多,每个任务对象里又带着很大的数据,内存直接被打爆。
那次事故之后,我把所有线程池都换成了有界队列,并且在队列达到80%容量的时候打印WARN日志,达到100%的时候触发告警。线程池的监控指标也在beforeExecute和afterExecute里记录下来,包括任务耗时、活跃线程数、队列积压量、拒绝次数,定期上报到监控系统。这样再出问题的时候,我可以直接看数据倒推是上游流量太大、还是线程池参数不合理、或者是任务本身执行太慢。
提示:线程池不要只在初始化的时候配好就完事。参数一定要做成可动态调整的,或者至少能在配置中心里改。很多时候线上流量涨了,你只需要临时把最大线程数和队列调大一点就能救急,不需要重启服务。
3. 第二招:用对容器,大部分并发bug消失一半
3.1 容器选择矩阵:别再什么场景都上ConcurrentHashMap
Java并发包提供了很多线程安全的容器,但很多人用错了对象。ConcurrentHashMap适合高并发读写、key分布均匀的场景,但它不是万能的;CopyOnWriteArrayList适合读多写极少的场景,比如配置列表、白名单,因为它的写操作会复制整个数组,频繁写会很浪费;Hashtable和Collections.synchronizedMap给整个Map加锁,性能远不如ConcurrentHashMap,但有些老项目还在用。
我见过一个典型的错误:业务里有个Map<String, List<String>>,用来缓存某个用户的标签列表,更新很频繁,但代码里用了CopyOnWriteArrayList。结果每次更新标签都复制一次整个列表,CPU飙升,接口响应时间直线上升。换成ConcurrentHashMap加普通ArrayList,每次只更新对应key的value引用,问题直接解决。容器选型一定要先分析读写比例、数据规模、并发模式,再决定用哪个。
下面这个选择逻辑是我现在项目里review代码的时候必查的一张表:
| 场景 | 推荐容器 | 不推荐 |
|---|---|---|
| key-value缓存,高并发读写 | ConcurrentHashMap | Hashtable、Collections.synchronizedMap |
| 配置列表、低频写高频读 | CopyOnWriteArrayList | Collections.synchronizedList |
| 生产者消费者解耦 | ArrayBlockingQueue / LinkedBlockingQueue | 自己加锁 + List |
| 需要延迟/定时任务 | ScheduledThreadPoolExecutor | Timer |
| 只追加、不修改的历史记录 | ConcurrentLinkedQueue | synchronizedList |
3.2 复合操作才是真正的坑
容器选对了只是第一步,更常见的并发bug来自复合操作。ConcurrentHashMap的单个put、get是线程安全的,但get之后再put这个组合不是原子的。比如经典的库存扣减逻辑:
java复制Integer stock = stockMap.get(skuId);
if (stock != null && stock > 0) {
stockMap.put(skuId, stock - 1);
}
这段代码在并发场景下一定会超卖,因为两个线程可能同时get到同一个库存值,然后各自减一,最后写回去的值只比原来少1。ConcurrentHashMap保证的是put操作本身原子,不保证这段读-改-写的过程原子。解决方法是用它的原子方法:compute、merge、putIfAbsent。比如上面的逻辑可以改成一行:
java复制stockMap.compute(skuId, (key, val) -> (val == null || val <= 0) ? val : val - 1);
compute内部对同一个key的处理是原子的,多个线程同时操作同一个key时,会串行执行这段Lambda,不会再出现读取到旧值的情况。其他类似的复合操作包括:putIfAbsent后再初始化对象、先检查再删除、先读取再更新。凡是“先查再写”的模式,都要考虑是不是需要原子化处理。
3.3 案例:用户积分系统为什么老是重复入账
我们有个积分系统,用户签到加积分,逻辑是先查当前积分,再加5分,再写回去。最开始用的就是普通HashMap加synchronized锁整个方法,问题不大。后来拆成多节点部署,HashMap变成各个节点本地一份,锁也变成节点内的锁,跨节点就失效了。于是出现了重复入账和积分错乱。当时的修复方案分了两层:第一层,本地缓存换成ConcurrentHashMap的compute原子操作,保证单节点内不重不漏;第二层,积分流水表加唯一索引,以用户ID+日期作为唯一键,数据库兜底,重复的签到请求直接被索引挡住。这样既保证了性能,又有一个最终兜底的防线。
这个案例告诉我们一个经验:并发bug的修复不能只靠一层防护。本地原子操作是第一层,数据库唯一约束是第二层,幂等机制是第三层。每一层都有可能被绕过,但三层叠加,基本就能把大部分竞态问题堵死。
4. 第三招:锁别乱加,尤其是事务里面
4.1 加锁前先想清楚:锁的是什么,顺序一致吗
一提到并发,很多人第一反应就是加锁。但锁是最容易制造新问题的工具。synchronized和ReentrantLock都能保证互斥,但用不好就会引入死锁、性能下降、锁粒度过大等问题。我见过一个项目,所有Service方法都用synchronized修饰,并发直接退化成串行,吞吐量腰斩还美其名曰“为了数据安全”。
锁的首要原则是:锁的对象要明确,锁的粒度要尽量小。锁一个业务主键比锁整个Service方法好,锁一个订单ID的key比锁整个Map好。另一个容易被忽略的问题是加锁顺序。多个锁同时存在时,所有线程必须按同样的顺序获取锁,否则就会出现经典的死锁:线程A持锁1等待锁2,线程B持锁2等待锁1,两边互相等对方释放,最终系统卡死。
排查死锁的时候,jstack会直接打出Found one Java-level deadlock,指出哪两个线程、持有什么锁、等待什么锁。但更可靠的是从一开始就约定加锁顺序,比如多个资源参与时,统一按资源ID排序后再加锁,从机制上消灭循环等待。
4.2 事务里加锁为什么容易出大坑
Spring事务和Java锁一起用,是并发bug的重灾区。很多人会在一个@Transactional方法里用synchronized锁住一段代码,看起来既保证了事务原子性,又保证了并发安全。但这样做的后果是:锁的范围比事务的范围小的话,事务还没提交,锁已经释放了,另一个线程进来读到的是还没提交的旧数据;锁的范围比事务大的话,事务提交的时候还占着锁,高并发下所有线程都在等这把锁,连接池很快耗尽。
更严重的坑是事务方法里做了远程调用。比如事务内调用支付接口、消息队列、外部HTTP服务,这些操作本身的耗时不可控,如果它们还处在同步代码块里,那么锁被持有的时间可能长达几秒钟。数据库连接池就那么几个连接,线程全堵在这把锁上,其他正常请求也拿不到连接,最后表现为“服务不可用”。我接手过的一个老项目,就是因为订单创建方法里同步调用了外部风控接口,锁的持有时间经常超过3秒,一到业务高峰整个服务的线程池全部阻塞。
正确的做法是:锁和事务分离。事务只包住需要原子性的数据库操作,锁放在事务外层,并且在锁内不要做任何远程调用。如果必须远程调用,先把事务提交,再释放锁,再发起调用。这样锁的持有时间被压缩到最小,事务的边界也清晰。
4.3 乐观锁和悲观锁,怎么选才不出并发bug
锁的选择不是越强越好。数据库层的悲观锁(SELECT ... FOR UPDATE)能保证同一行的读写互斥,但会阻塞其他读请求;乐观锁(版本号或CAS)则在更新时检查版本号,冲突就重试。两者各有适用场景。
我有一个判断标准:如果并发冲突概率高、写操作频繁,比如库存扣减、账户余额变动,用悲观锁或数据库行锁更稳,因为重试成本太高;如果冲突概率低、读多写少,比如文章点赞、用户资料更新,用乐观锁加版本号更合适,性能更好。
有一点必须提醒:乐观锁不是没有bug。很多开发者在更新时忘记检查版本号,或者在重试时没有做最大次数限制,导致高并发下大量更新失败。我一般会在重试逻辑里设置最多3次重试,每次重试前重新查询最新数据,超过次数直接返回冲突错误,让上游自己决定怎么处理。版本号字段用@Version由JPA或MyBatis-Plus自动管理,不会出现手写漏掉的情况。
5. 第四招:状态机与幂等设计,消除竞态根源
5.1 状态不收敛,并发bug就永远堵不完
很多并发bug的本质不是技术问题,而是业务状态没有收敛。比如一个订单,既有支付回调在改状态,又有用户取消在改状态,还有定时任务在判断超时关闭。如果这三处都各自写了if (status == xxx) { status = yyy; },又缺少统一的入口,那并发场景下一定会出现状态错乱:晚上11点的订单,支付回调说“已支付”,取消任务说“已关闭”,最后数据库里存了哪个状态完全看线程执行的先后顺序。
我的解决方案是把所有状态流转收口到一个状态机里。状态机由三部分组成:当前状态、目标状态、允许的迁移规则。任何地方要修改订单状态,都必须经过状态机校验,不允许直接UPDATE ... SET status = ?。具体实现可以是枚举加代码校验:
java复制public enum OrderState {
CREATED, PAID, SHIPPED, COMPLETED, CLOSED;
public boolean canTransitTo(OrderState target) {
switch (this) {
case CREATED:
return target == PAID || target == CLOSED;
case PAID:
return target == SHIPPED || target == CLOSED;
case SHIPPED:
return target == COMPLETED;
default:
return false;
}
}
}
状态机的好处非常明显:并发情况下,即使两个线程同时拿到同一个订单,它们要修改的状态经过校验后只有一个能成功。它能保证不合法迁移被直接拒绝,而不是两个线程各写各的,最后覆盖成一个错误状态。这个模式在订单、审批流、工单、退款流程里都能直接用。
5.2 幂等键:让同一操作只能生效一次
状态机解决的是内部状态流转的冲突,幂等键解决的是外部请求重复投递的问题。支付回调、MQ消息、定时任务扫描,这些场景天然会重复触发。最简单的幂等方案是数据库唯一索引:比如支付回调处理表,以订单号 + 支付渠道流水号做唯一索引,重复插入直接报错,捕获后按成功处理,不重复更新订单状态。
对于MQ消费场景,我习惯在消息体里带一个业务侧的幂等键,消费端先查本地幂等表,如果已经处理过就直接返回确认;没处理过就执行业务逻辑,同时插入幂等记录。这里有个细节要注意:幂等记录插入和业务操作必须在同一个事务里,否则会出现并发时两个线程同时通过幂等检查,然后各自执行业务逻辑,最终数据重复。
还有一种是使用Redis分布式锁做幂等,比如用SET key value NX EX,拿到锁才处理,释放锁时校验value是否一致。它在纯Redis场景下简单高效,但如果业务操作之后Redis刚好宕机,锁可能丢失,所以Redis分布式锁更适合做前置拦截,不能替代数据库的唯一索引兜底。我现在所有涉及金额、库存、积分的操作,底层都强制加数据库唯一约束,Redis锁只能算第一道防护。
5.3 案例:支付回调风暴是怎么被按住的
有一次我们接了一个支付渠道,它的回调机制是每5秒重试一次,连续重试十几次。我们的处理接口原本没有幂等设计,第一次回调改状态成功,第二次回调又进来,查一下状态已经是PAID,但代码里没有判断就继续走一遍发货逻辑,结果库存扣了两次,优惠券也发了两份。后来用了“状态机 + 幂等键”的组合:支付回调先查订单状态,只有CREATED状态才允许流转到PAID;同时在支付流水表加唯一索引,重复回调直接走吞掉分支。改造之后,别说回调七次,回调七十次也不会造成数据问题。
注意:幂等不是只对支付回调有用。任何被外部触发的接口、任何从消息队列消费的处理逻辑,都应该默认对方可能会重复调用。你只需要多设计一层唯一约束或幂等表,就能避免一整类并发问题。
6. 第五招:监控不是为了截图,是为了定位
6.1 jstack线程转储,五分钟定位到卡死点
线程池配了、容器选对了、锁也规范了,但如果线上还是出了问题,我靠的是监控定位能力。并发问题最常用的定位工具是jstack,直接抓当前JVM的线程快照,能看到每个线程的状态:RUNNABLE、BLOCKED、WAITING、TIMED_WAITING,以及它们在等哪把锁。
我一般这样用:先连续抓三次线程转储,每次间隔几秒,然后对比这三次快照中哪些线程的状态一直没变。如果一个线程一直是BLOCKED,且它在等同一个锁,那基本可以判断这个锁被某个事务或远程调用长时间持有。再往下看,谁持有的锁,就能找到元凶。有一次线上某个接口P99从50ms涨到5秒,jstack一看,发现几十个线程都阻塞在同一个ReentrantLock上,持有锁的线程正卡在URLConnection.connect()上等外部接口超时,原因一下就清楚了。
对于WAITING状态的线程,要重点看它们是否在park方法上,或者在LockSupport.park上等待。很多线程池里的空闲线程就是WAITING状态,这是正常的;但如果WAITING线程的数量超过预期,可能是任务迟迟没有提交给线程池,或者锁的等待队列堆积太多,需要结合线程池监控指标一起看。
6.2 线程池监控指标和数据库锁等待
线程池的监控不能只靠jstack,要从运行时数据里看趋势。我习惯在ThreadPoolExecutor里重写beforeExecute和afterExecute,记录每次任务执行耗时;再定时打印线程池的activeCount、queue.size()、completedTaskCount,以及被拒绝的任务数。把这几个指标录到监控系统里,进行趋势对比,就能看出什么时候开始积压、什么时候开始拒绝,反推是上游流量突增还是下游执行变慢。
数据库层的并发问题同样需要监控。MySQL的information_schema.innodb_trx可以看到当前未提交的事务,sys.innodb_lock_waits可以查锁等待关系。我最常用的是看是否存在长事务:一个事务运行超过5秒还没提交,后面对同一行的更新请求就会全部阻塞,体现在应用层就是连接池被占满。此时先用SQL查长事务,杀掉不正常的会话,再让开发检查事务里是不是做了远程调用或其他重活。
6.3 从监控到修复:一次CPU飙升的完整排查路径
分享一个比较典型的排查过程:某天收到CPU 100%告警,先看监控,发现不是GC导致,因为GC时间没异常。接着top查进程CPU高,再用jstack抓线程快照,看到大量线程卡在某个ConcurrentHashMap.compute的调用栈上。再仔细看,原来我们用了compute做库存扣减,但Lambda里又调用了远程服务,导致同一个key的compute操作长时间占用了Map的bin锁。其他需要操作同一批key的线程全部排队等待,CPU消耗在线程调度和锁竞争上。
修复方案是:compute里的Lambda逻辑必须是纯内存操作,不能有任何远程调用;把远程调用移出compute块,先算出新的value,再一次性提交。这次经历让我定了一条review红线:任何原子方法里不允许做IO操作。这个规则现在还在用,直接拦截了至少三次类似的潜在问题。
7. 第六招:前置拦截,把bug堵在发布前
7.1 并发冒烟测试,别等线上爆了才后悔
很多并发bug在测试环境发现不了,是因为测试环境的并发量太低、数据量太小。我在项目里推行了一个“并发冒烟”门槛:任何涉及共享状态、事务、锁、缓存的核心接口,在提测阶段必须跑一轮小规模的并发测试。工具可以用JMeter、wrk,或者直接用Shell脚本配合简单Java程序,把QPS压到预估峰值的1.5倍,持续时间不少于15分钟,观察有没有报错、数据有没有错乱、线程池有没有拒绝、数据库有没有死锁。
对于写操作场景,我还会专门构造并发冲突的测试数据。比如库存只剩10件,并发来20个请求同时买,断言最终扣减结果正确,且不多扣、不少扣。如果连这种人工构造的冲突都扛不住,那说明逻辑本身就是有问题的,没必要等到线上被真实用户打爆。
7.2 Java并发代码审查清单,每一条都是血泪教训
code review阶段,我会拿一份并发专项的检查清单来核对。这份清单是我踩了无数坑之后整理出来的,现在分享给大家:
- 是否用了
Executors快捷方法?如果有,打回。 SimpleDateFormat是否作为静态变量使用?如果是,打回,换成DateTimeFormatter或ThreadLocal。- 是否在
compute、merge等原子方法内做了远程调用或IO?打回。 - 静态集合、静态Map是否在并发环境下被读写?确认是否有线程安全保护。
@Transactional方法里是否包含了远程调用或synchronized代码块?确认锁和事务边界是否分离。- 是否使用了
AtomicInteger/AtomicLong但语义上其实应该用LongAdder(写并发极高的场景)? - 是否有“先判断后更新”的复合操作,却没用原子方法或锁?
- 线程池是否有命名工厂、有界队列、自定义拒绝策略?
- 从MQ消费的逻辑是否有幂等处理?
这份清单不是学术标准,但每条背后都对应过我线上真实的bug。审查的时候一条条过,比代码逐行看有效得多。
7.3 灰度发布和快速回滚,最后一道防线
即使前面都做了,发布依然可能出问题。我现在的发布流程强制灰度:先发布一台机器,观察监控指标5分钟,确认线程池、响应时间、错误率都正常,再逐步扩大到全量。如果新版本出现并发异常,监控会立刻发现,在这一台机器上做回滚,影响面被控制在最小范围。这个流程看起来简单,但能救命的点在于,它让你始终有一个快速回到稳定版本的窗口。
回滚方面,我建议发布前确认两件事:第一,数据库表结构变更是否能向前兼容,新代码有问题时旧代码能不能继续运行;第二,配置中心和线程池参数是否存在前后不一致。如果这两点没确认,回滚不是“一键搞定”的,搞不好还会更乱。
8. 这些方法用下来,我现在的并发工作习惯
坦白说,这6招不是哪一个单独发挥作用,而是组合起来才让并发bug从月均12个降到0。线程池参数定了,容器选型规范了,锁和事务边界清楚了,状态机和幂等设计从源头消灭了大半竞态,监控能快速定位剩余问题,前置压测和review又兜住了新代码质量。每一条看起来都不难,难的是坚持执行。
我现在的习惯是:所有新写的并发代码,先过一遍审查清单再提测;所有涉及金额、库存、积分的接口,默认加幂等设计;线程池参数一定来自压测数据,不写死拍脑袋数字。最后再分享一个小技巧:每次修完一个并发bug,我会把它的根因、触发条件、修复方式、如何避免写进一个团队内部的缺陷库。半年下来,你会发现很多问题其实是同一类原因反复出现,提前拦截掉这一类,可比一个一个debug效率高多了。
