☰
Java高并发Bug排查:线程池、并发容器与分布式锁的六大经典陷阱
2026/10/14 6:12:00 网站建设 项目流程

如果你问我过去一年做Java 高并发 Bug 排查,最难忘的是哪一次,我会说是凌晨两点那场压测事故。业务方反馈订单服务 TP99 从 80ms 一路涨到 5s,服务里已经开始抛RejectedExecutionException,限流阈值被打穿,系统近乎不可用。第一反应是查数据库慢 SQL,结果数据库一切正常;又看 GC 日志,老年代回收也不算频繁;最后才顺着 CPU 高的线索,把线程栈拉下来,发现大量业务线程全部卡在同一个HashMap.get()调用上。那次之后我意识到,高并发下绝大多数 Bug 不是逻辑复杂,而是我们对并发原语的使用方式出了问题。

这个经历很有代表性,所以我想把过去在多个高并发场景里踩过的那些坑整理出来。它们分别是线程池静默拒绝、并发容器复合操作、锁顺序颠倒、分布式锁过期误删、线程复用污染和资源池耗尽。每一条都是现场真实推进过的排查链路,不是理论推演。无论你是刚接触并发的初中级开发者,还是已经在线上扛过几轮流量的高级后端,都应该能从中找到自己项目的影子。

1. 现象初现:一次凌晨压测事故中那场不该发生的CPU飙升

1.1 面对报警我做的第一版排查动作

凌晨收到报警时,监控里最刺眼的不是错误数飙升,而是 CPU 占用率直接顶到 800%。单台实例上的核心线程全部处于 RUNNABLE 状态,业务请求一个接一个堆积。我当时的排查顺序是这样:

  1. top -Hp 进程PID找出 CPU 占用最高的线程 ID。这一步很快,能看到几个线程的 CPU 时间几乎占满了整个核。
  2. 用jstack 进程PID打印线程堆栈,然后把第 1 步拿到的线程 ID 转成十六进制,去堆栈里找对应的nid=0x...。转换命令很土但很实用:printf "0x%x\n" 线程ID。
  3. 跟踪那几条线程的栈,发现它们全部停留在HashMap.get()方法里,而且长时间不退。
  4. 补一板jstat -gcutil 进程PID 1000,观察 GC 情况,确认 Full GC 频率不算高,排除了“GC 导致停顿”这条线。

这一步如果只看结果,会觉得问题很明确:并发写HashMap导致内部链表成环,get()死循环,CPU 耗尽。但如果当时一上来就改代码,把HashMap换掉,大概率会漏掉一个更隐蔽的问题——代码里为什么会有那么多并发写同一个HashMap的路径?背后的线程池、任务模型、资源池配置是否需要一起调?高并发 Bug 的魅力就在这,永远不是单个点出错,而是一连串糟糕的设定共同压出了事故。

1.2 线索对齐之后,不要急着改代码,先压测复现

看到线程栈里全是HashMap.get()之后,我做的第二件事不是改代码,而是用压测脚本复现现场。压测方式很简单:用固定 QPS 持续打订单查询接口,观察触发条件。最后复现的结果很有意思,只有请求量达到某个阈值,线程栈才会稳定地出现HashMap相关调用,低峰期完全正常。这说明问题不是随机抖动,而是并发量到达临界点后才会暴露。

这个“先复现再动手”的教训,我踩过好几次。第一次遇到类似场景时,我直接凭直觉把HashMap换成了ConcurrentHashMap,结果上线后问题并没有完全消失,只是从“CPU 100%”变成了“偶发数据错乱”。原因很简单:当时还隐藏着复合操作和缓存更新的并发时序问题,单纯换容器只能解决一个片段。所以在高并发 Bug 排查里,我会强制自己先回答三个问题:

  • 是否能在压测环境稳定复现?
  • 线程栈里的热点是否稳定指向同一批方法?
  • 换掉问题容器之前,调用链路上还有没有其他共享可变状态?

如果三个问题没答清楚,后面的修复大概率是拆东墙补西墙。排查工具本身不难,难的是把线索串成一条完整的因果链。

1.3 CPU 高但 GC 正常时的常见排查路径

很多高并发线上事故都是从 CPU 飙升开始的,但 CPU 高和 GC 高不是一回事。我习惯把排查路径分为四类:

现象重点排查方向常用手段
用户态 CPU 高并发容器死循环、超长计算、锁自旋top -Hp、jstack
内核态 CPU 高上下文切换频繁、锁竞争严重vmstat、pidstat -w
GC 线程占用高分配速率过快、大对象过多、老年代频繁回收jstat -gcutil、GC日志
IO 等待高外部接口慢、数据库慢 SQL、磁盘繁忙sar、arthas trace

那次事故的线程栈已经足够说明问题,CPU 高纯粹是死循环。真实开发里如果碰到线程大量阻塞在锁上,CPU 反而不高,那是另一套排查路径。后面第 4 节我会单独讲死锁场景,这里先不展开。

2. 线程池静默认怂:参数失算与父子任务互等如何拖垮接口

2.1 线程池参数不是越大越好,任务入队顺序更关键

线程池在高并发系统里几乎无处不在,但太常见的东西反而容易被忽视。很多人调线程池只调corePoolSize和maximumPoolSize,忽略了workQueue的作用。实际上线程池处理任务是有明确顺序的:

  1. 当前线程数小于corePoolSize,新建线程执行任务。
  2. 当前线程数大于等于corePoolSize,任务优先放进阻塞队列。
  3. 队列满了,才会继续创建新线程,直到maximumPoolSize。
  4. 到这里还是满的,就会触发拒绝策略。

这个顺序意味着:如果你用了new LinkedBlockingQueue()这种无界队列,maximumPoolSize基本形同虚设。因为任务永远能塞进队列,线程数永远不会从corePoolSize涨上去。几十万请求堆积在队列里慢慢消化,接口 RT 从几百毫秒一路拉到几十秒,限流一打,故障就爆发了。

反过来,用SynchronousQueue这种没有缓冲的队列,又会出现另一个问题:生产者提交速度和消费者处理速度只要稍有波动,就会频繁创建线程、触发拒绝。所以选队列要结合业务场景,不能觉得“无界最安全”。一个比较可靠的起步配置是:ArrayBlockingQueue有界队列,容量按照预期积压量估算,配合自定义拒绝策略做监控告警。

2.2 线程数计算的活例子:从 QPS 和 RT 倒推

线程数到底开多少,不能靠感觉。我常用的一个粗算思路是:线程数 ≈ 需要支撑的QPS × 单个任务平均耗时。举个例子,假设某个接口目标支撑 100 QPS,一次任务从投递到执行完平均耗时 500ms,那每个线程每秒大约能处理 2 个任务,线性推算至少需要 50 个线程。再考虑抖动和突发流量,通常会再乘以 1.3 到 1.5 的系数,起步给到 60 到 75 个。

但注意,这个公式只是初值,最终一定以压测为准。实际场景里任务可能不是均匀分布,外部依赖也可能出现尾部延迟,所以还要给线程池配置一个和业务相配套的等待策略。比如任务执行超过 2s 就应该有超时告警,而不是让线程无限等下去。

另外还有一个非常隐蔽的参数坑:自定义线程池时没有给线程取名字。事件回顾时,我见过不少团队创建线程池直接new ThreadPoolExecutor(...),没有传ThreadFactory,结果线程名全是pool-1-thread-1。线上出问题时,栈里只能看出是 pool-1,根本判断不了是哪个业务模块。排查成本无限拔高。给线程池命名这件事,看起来只是几行配置,实际上决定了你深夜排查故障的速度。

2.3 父子任务共用一个线程池,可能把自己等死

有一种死锁不发生在 JVM 锁层面,而是发生在业务任务编排层面。场景是这样的:线程池核心线程数为 2,任务 A 被线程 1 执行,任务 A 内部又向同一个线程池提交了子任务 B,然后调用Future.get()等待 B 的结果。如果此时核心线程已满,且队列也被其他任务占满,B 永远排不到执行机会,A 就会无限期阻塞。这种“父任务等子任务,子任务等线程”的问题,需要用jstack才能看清,线程栈会呈现大量FutureTask.get()等待节点,但线程本身没有互相持锁。

修复思路通常有几种:

  • 父任务和子任务拆分到不同线程池,避免互相抢占执行线程;
  • 核心线程数设置到足够覆盖父子任务的峰谷,但这治标不治本;
  • 给Future.get()设置超时,避免永久卡死;
  • 改用入口任务完成后统一提交,而不是在任务内部层层嵌套。

我在实际项目里更倾向最后一种,因为把任务拆成“主流程 + 后续流程”,比互相嵌套更容易排查和监控。嵌套任务会隐藏资源关系,出问题的时候你根本不知道哪一环占用了哪个池。

2.4 拒绝策略里的二次事故

线程池满了之后,默认的AbortPolicy会直接抛异常。很多团队为了“更友好”,改成了自定义拒绝策略:捕获异常后 sleep 500ms,然后重新提交任务。听起来很合理,结果是灾难性的。因为线程池已经满了,所有提交线程都在拒绝策略里 sleep,然后再重试,形成一波放大过的重试流量。最终线程池没恢复,上游却被这波重试打崩了。

我给团队定的规范是:拒绝策略必须快速失败,失败的任务写入告警队列或者降级渠道,绝不能原地重试。如果业务允许丢弃部分非核心任务,可以用DiscardPolicy但一定要有监控计数,否则被丢掉的任务连一点痕迹都没有。拒绝策略不是业务逻辑的兜底,它是系统容量边界的最后一道防线。

3. 并发容器的“安全错觉”:死循环、超发与覆盖写入的现场还原

3.1 从CPU飙升到HashMap死循环,别再相信“JDK8没事”

那场凌晨事故里,真正锁死 CPU 的就是并发写HashMap。很多人听说 JDK 8 把HashMap的扩容从头插法改成了尾插法,就默认“JDK 8 的 HashMap 不会死循环了”。这个说法不准确。JDK 8 确实解决了环形链表问题,但在高并发下多线程同时触发扩容,仍然可能出现节点覆盖、数据丢失和size计数错乱。虽然不像死循环那么致命,但同样会造成严重的事故。

判断 HashMap 是否真的成环,jstack 里可以看到线程反复执行HashMap.get()方法,且栈深长时间不变化。更准确的方式是拿一把 dump,用 MAT 或 JProfile 打开堆快照,搜索问题对象里的 next 节点引用关系,如果发现 Next 链上出现了回指,基本可以实锤。不过说实话,线上排查时很少有机会慢慢分析堆快照,最直接的还是线程栈 + 请求量复现。

这类问题的根治方案没有悬念:共享可变集合一律用ConcurrentHashMap。但换容器只是起点,不是终点。数据一致性还得看业务操作是不是原子的。

3.2 ConcurrentHashMap 的复合操作并不安全

很多人把ConcurrentHashMap当成万能药,实际上它只能保证单个put、get、remove操作线程安全,不能保证“先检查再操作”这种复合动作是原子的。最经典的例子是库存扣减:

java if (stockMap.containsKey(skuId)) { int stock = stockMap.get(skuId); stockMap.put(skuId, stock - 1); }

两个线程同时走到这段代码,都读到了库存 10,都回写 9,实际只扣了一次。在秒杀场景里,这就是超卖。

正确做法是把计数本身变成线程安全对象,或者使用原子方法。通常我会这样写:

java Map<String, AtomicLong> inventory = new ConcurrentHashMap<>(); public boolean deduct(String skuId, int count) { AtomicLong stock = inventory.computeIfAbsent(skuId, k -> new AtomicLong(0)); long remain = stock.addAndGet(-count); if (remain < 0) { stock.addAndGet(count); // 回补,或者直接失败 return false; } return true; }

这里computeIfAbsent虽然能保证 key 不存在时只初始化一次,但它内部依然有锁开销,映射函数里绝对不要做远程调用、数据库访问这类重操作,否则锁内 IO 会把并发能力直接拉低。另外,AtomicLong.addAndGet返回的剩余库存可以用来判断是否超扣;如果要追求极高并发下的计数性能,可以用LongAdder,但它不提供完整的剩余值判断,需要额外设计。

还有一个同样高频的坑:map.putIfAbsent(key, value)的返回值经常被忽略。这个方法返回的是之前已存在的值,很多代码使用它的目的是避免重复初始化资源,但如果重复初始化本身成本很高,就必须根据返回值做二次清理,否则会出现两个线程一个创建 A 一个创建 B,最后各自拿到不同的实例,共享状态还是会坏掉。

3.3 缓存更新顺序里藏着的“并发时间差”

容器问题说透了,还有一个和并发容器紧密相关的场景:热点数据缓存的更新顺序。常见的错误是先更新数据库,再删缓存,期间瞬间会有请求读到旧缓存。更隐蔽的是,缓存删除操作如果失败了,后续请求会一直读到旧值。

我当时在秒杀场景里遇到的是另一个版本:线程 A 拿到缓存库存 10,线程 B 抢先把库存改成 5,线程 A 又回写 10,覆盖了 B 的更新。这类问题的本质不是容器的线程安全,而是“读改写”不是一个原子操作。解决方式不只有加锁,更常见的是用版本号做乐观控制。比如缓存里存stock=10,version=3,每次扣减都检查当前版本,只允许递增的版本号写入,否则重试。这种设计比全局分布式锁轻量得多,也更适合读多写少的热点数据。

但要提醒一句,乐观控制在“写冲突比例高”的场景下会表现为大量重试,反而拖慢系统。所以如果秒杀这类场景允许,更推荐直接让库存操作落在数据库行锁上,用UPDATE ... SET stock = stock - ? WHERE stock >= ?这类原子 SQL,确保不会超卖,再通过 Redis 做热点预热和限流。并发容器只是工具,真正的数据一致性还要靠底层的约束机制。

4. 死锁的潜伏期:锁顺序颠倒与分布式锁过期误删的完整链路

4.1 一次 jstack 死锁定位实录

有一次故障没有 CPU 飙升,也没有线程池报警,只是某个分账接口在大促期间连续超时。查线程栈时看到了一个非常典型的死锁片段,线程 T1 持有账户 A 的锁,等待账户 B 的锁,线程 T2 则持有账户 B 的锁,等待账户 A 的锁。两个线程互相咬死,谁也不让。

这里最有效的工具是jcmd 进程PID Thread.print或者直接jstack,输出里如果出现Found one Java-level deadlock,后面会列出完整的“held lock”和“waiting lock”链路。现场排查时我一般会写一个小脚本把所有线程里waiting to lock的地址汇总排序,看看是否有两个线程形成闭环。

这种死锁的修复方式非常机械但很有效:统一加锁顺序。比如账户转账场景,先对账户 ID 排序,不管调用方向是 A 到 B 还是 B 到 A,都从小到大拿锁,锁顺序永远一致,死锁的四个必要条件之一就没了。写起来大概是:

java Object lockFirst = fromId < toId ? lockOf(fromId) : lockOf(toId); Object lockSecond = fromId < toId ? lockOf(toId) : lockOf(fromId); synchronized (lockFirst) { synchronized (lockSecond) { doTransfer(fromId, toId, amount); } }

很多人觉得锁顺序这种代码很简单,但实际业务里最容易破功的是“锁的粒度不统一”。比如一个地方用账户对象做锁,另一个地方用账户 ID 字符串做锁,同一个业务走了两套锁对象,排序规则就失效了。规范的做法是提供统一的锁管理器,所有需要加锁的代码都从同一个地方取锁对象。

4.2 别用“锁内执行一切”来解决问题

锁顺序修完之后,死锁“消失”了,但性能又出了新问题。因为我把整个转账流程都塞进了 synchronized 块,包括余额检查、更新数据库、写流水、调外部账户服务。结果锁持有时间拉长,后边的请求全部排队。这个时候又得回头做精细化。

锁的粒度必须小到只保护共享变量本身。数据库更新可以用乐观锁版本号或原子 SQL,远程调用必须挪到锁外执行。如果一个转账过程确实需要在一个事务里完成校验和入账,那就用数据库事务的隔离机制,而不是用 Java 对象锁去兜底。Java 锁解决的是进程内并发,数据库锁解决的是跨线程跨实例的一致性,两者职责不同,不能混用。

另外一个技巧是给ReentrantLock加超时。相比synchronized的无限等待,tryLock(3, TimeUnit.SECONDS)能拿到就执行,拿不到就返回失败并落监控。这样即使出现了预期之外的死锁,系统也不会永久挂死,而是以超时错误的形式暴露出来,比无限阻塞更容易定位。

4.3 分布式锁:过期时间、唯一标识和误删陷阱

进程内死锁修完,微服务架构下还有一个更常见的坑:分布式锁过期。通常是业务用一个SET key value NX EX seconds做加锁,但业务执行时间超过了锁过期时间,锁自动释放了。另一个线程拿到锁开始处理,第一个线程此时也处理完了,执行del key释放锁,于是把第二个线程的锁给误删了。两个线程同时进入临界区,故障随之而来。

解决误删的标准做法是锁 value 里放一个当前线程或请求的唯一 ID,删除前先比较 value 是否一致,再执行删除。这里“比较 + 删除”必须是原子操作,直接 Java 代码里get再del依然有竞态,需要用一段 Lua 脚本:

lua if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

至于锁时间太短的问题,不建议把过期时间盲目调大。如果业务确实经常超过租约时间,应该使用带看门狗续期机制的分布式锁客户端:锁持有期间自动续期,业务结束释放锁,应用进程异常退出时锁自动过期。自己做看门狗容易出各种边界问题,优先使用成熟实现,虽然它们内部也各有取舍,但至少比裸写 SET NX 要安全得多。

分布式锁这个 topic 涉及很深,我只强调两个体会:第一,锁必须有唯一持有者标识,防止误删;第二,锁的过期策略要覆盖业务最长执行时间。这两点做好了,大部分分布式锁故障都能避免。

5. 线程复用与资源池:那些不像Bug却比Bug更隐蔽的高并发坑

5.1 ThreadLocal 残留导致“上一个请求的数据串到下一个请求”

高并发系统为了性能几乎都会使用线程池,而线程池的核心特性就是线程复用。这个特性会无限放大一个常见错误:ThreadLocal用完之后没有清理。

我遇到过一次非常诡异的任务串号事件。一个用户查询他自己的订单,结果响应里出现了另一个用户的收货地址。代码里没有明显的错误,数据库查询条件也带了用户 ID,后来查线程栈和日志才发现,网关把用户身份放进了ThreadLocal,后续业务代码从ThreadLocal取用户 ID。结果线程池复用了上一次请求的线程,而这个线程的ThreadLocal里还留着上一个用户的信息,刚好赶上异常链路没有走到清理逻辑,脏数据就泄漏给了下一个请求。

修复方式很机械,但必须形成铁律:

java private static final ThreadLocal<String> USER_CONTEXT = new ThreadLocal<>(); public void handleRequest(String userId) { try { USER_CONTEXT.set(userId); process(); } finally { USER_CONTEXT.remove(); } }

注意是finally,不是try结束就清。凡是用到ThreadLocal的地方,必须把清理动作放在 finally 里,否则任何异常都会导致线程上下文被污染。

ThreadLocal还有一个更隐蔽的问题,就是大对象无法释放。ThreadLocalMap里的 Entry 以ThreadLocal为 key,value 是强引用。如果线程池线程长期存活,ThreadLocal的 value 会一直挂在当前线程上,即使业务已经不再使用它。如果 value 是几 MB 级的缓存对象,几十个线程就能把老年代吃满,表现为高并发下 JVM 内存缓慢上涨,最终触发 Full GC。这类问题用jstack很难直接看出来,通常需要结合堆 dump 分析线程对象的引用树。但与其等排查,不如在上游就堵住,尤其是配合线程池使用时,ThreadLocal必须每次用完清理。

顺便提一句,SimpleDateFormat在高并发线程池场景下也是经典的串号或NumberFormatException来源,因为它内部持有共享的Calendar状态。老项目里最常见的修复是把SimpleDateFormat放进ThreadLocal,但新代码建议直接用DateTimeFormatter,它是不可变且线程安全的对象,不需要线程隔离。

5.2 资源池被高并发直接击穿:连接回收与超时设置的平衡

最后一个坑,看起来像 Java 并发 Bug,实际上是资源池容量和 RT 之间的失衡。典型场景是应用接单线程 100 个,数据库连接池只有 30 个。高并发到来时,大量线程在连接池的getConnection上排队等待,jstack 能看到一堆线程卡在HikariCP或 Druid 的获取连接方法上。此时如果你只盯着业务代码,会觉得所有线程都阻塞在同一个地方,像是死锁,实际上只是连接资源不够分。

这种情况的修复不是简单把maximumPoolSize改成 500。连接池过大,数据库侧的 CPU 和锁竞争会先扛不住。正确思路是拆成两个层面:

  • 应用层控制并发任务量,让真正同时执行的任务数接近连接池上限;
  • 连接池配置合理的connectionTimeout和空闲超时,避免线程无限期等待;
  • 慢 SQL 和慢外部接口必须有独立监控,因为连接池被耗尽往往是被某一个慢依赖拖累的。

资源池大小我同样建议从“需求 QPS × 单事务耗时”粗算起步,再结合压测调整。比如数据库需要支撑每秒 500 个事务,单个事务平均执行 20ms,那每个连接每秒能处理 50 个事务,至少需要 10 个连接。实际运行中因为有慢 SQL 和长事务,要留 2 到 3 倍余量,最后用压测验证。

这里还想强调一个工程习惯:排查问题时不仅要看线程池参数,还要把上下游资源池参数放在同一张表里看。线程池、数据库连接池、远程调用线程池、HTTP 线程池,这几个池子的容量是互相约束的。只看一个池子永远找不到瓶颈。

5.3 故障复盘之后,我给自己定的几条并发纪律

做完整轮高并发 Bug 排查,我心里最鲜明的一句话是:高并发问题很少是单个写法错了,而是多个不自信的工程决策叠加在一起,在流量峰值互相放大。

现在我几乎在项目里会用最简单的规则来约束团队:

  • 共享可变集合只允许ConcurrentHashMap,且复合操作必须用原子方法或额外锁;
  • 线程池必须命名、必须有界队列、必须拒绝时快速失败并告警;
  • 锁粒度能小则小,能不用锁就不用锁,用乐观锁和原子类型替代;
  • ThreadLocal只在finally中使用,禁止在线程池中裸放;
  • 资源池容量和上下游并发模型必须一起压测,不能只调一个参数。

这些规则听起来都很基础,但高并发线上故障的最后原因,往往就是某个基础约定被打破后没人发现。如果你也希望少熬几次夜,建议按这个清单过一遍线上代码。发现几个违反规则的点并不难,难得是能在故障发生之前正视它们的风险。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询