死锁活锁饥饿阻塞无锁:Java并发问题诊断与实战
发布时间:2026/9/18 12:32:14 作者:尧图编辑部 阅读量:1,286

1. 这不是概念辨析题而是线程协作的“急诊室”现场你有没有在凌晨三点盯着 jstack 输出的线程堆栈发呆屏幕上赫然写着Found one Java-level deadlock而业务接口的响应时间已经飙到 12 秒监控告警像鞭炮一样炸响。这不是教科书里的假设场景——这是我在某次电商大促压测中真实经历的“死亡三分钟”。当时两个核心服务模块订单状态更新器和库存扣减器像被无形的绳索捆住手脚彼此等待对方释放锁谁也不肯先松手。jstack 的输出里两个线程状态都是BLOCKED但它们卡住的位置却指向完全不同的资源依赖路径。那一刻我意识到所谓“死锁”从来不是孤立存在的技术名词它是系统在高并发压力下暴露出的协作逻辑缺陷的具象化症状。更棘手的是后来我们又遇到了另一种“假死”线程状态显示为RUNNABLECPU 占用率却持续 95% 以上日志里没有任何报错但请求就是不返回。排查了整整两天最后发现是某个自旋等待逻辑写成了无限循环线程在用户态疯狂轮询一个尚未就绪的标志位——这正是活锁的典型表现它没被锁住却因过度“礼貌”而寸步难行。而“饥饿”则更隐蔽它不会立刻让你的服务崩掉而是让某个低优先级任务比如后台日志归档永远排在队列末尾等它终于轮到时数据早已过期。至于“阻塞”它是最常见的表象但背后可能是 I/O 等待、锁竞争、内存分配失败等多种根因而“无锁”则常被误认为是银弹实则是一把双刃剑——它能消除锁开销却可能引入 ABA 问题、内存屏障滥用等更难调试的陷阱。这些词之所以高频出现在热搜里——从jstack 死锁到数据库死锁从阻塞队列到C# 同步和异步——根本原因在于它们共同构成了多线程/并发编程中最核心、最易出错、也最考验工程直觉的“协作边界”。你不需要背诵定义但必须能在生产环境的火焰中一眼识别出是哪一种“病”并知道该切哪一刀。本文不讲抽象理论只复盘我过去十年在支付、物流、IoT 平台等不同领域踩过的坑、验证过的方案、以及那些只在深夜 debug 时才真正理解的底层逻辑。接下来我会带你一层层剥开这五种状态的真实肌理从 JVM 线程状态机开始到数据库事务隔离再到现代无锁数据结构的设计哲学全部基于真实代码片段和线上故障案例展开。2. JVM 线程状态机从BLOCKED到WAITING的每一步都藏着线索要精准诊断死锁、活锁、饥饿第一步不是看代码而是读懂 JVM 给你的“生命体征报告”——线程 dump。很多人一看到BLOCKED就断定是死锁这是最大的误区。JVM 的线程状态机远比“运行/阻塞”二分法复杂它直接映射了线程在操作系统层面的真实行为。我整理了一份在生产环境中反复验证过的状态对照表它比官方文档更贴近实战JVM 线程状态操作系统对应行为典型触发场景关键诊断线索RUNNABLE正在 CPU 上执行或就绪队列中等待执行计算密集型逻辑、自旋等待、I/O 多路复用就绪后处理高 CPU RUNNABLE 活锁或忙等嫌疑需结合栈帧分析是否在空转BLOCKED被 OS 调度器挂起等待进入 synchronized 块/方法争抢 monitor 锁失败查看waiting to lock 0x...地址对比其他线程的locked 0x...是否形成环路WAITING调用Object.wait()、Thread.join()、LockSupport.park()主动放弃 CPU等待特定条件查看parking to wait for 0x...重点检查条件变量是否被正确唤醒TIMED_WAITINGThread.sleep(n)、Object.wait(n)、Lock.tryLock(n, unit)有超时的等待超时后未唤醒常是条件判断逻辑缺陷或信号丢失TERMINATED线程执行完毕或异常终止正常退出或未捕获异常与当前问题无关但大量TERMINATED线程堆积可能暗示线程池配置错误这个表格的价值在于它把抽象状态翻译成了可操作的排查指令。举个真实案例去年我们一个 IoT 设备管理服务出现偶发性响应延迟监控显示线程数稳定在 200 左右但jstack抓取的 dump 中有 30 个线程状态是WAITING且都卡在parking to wait for 0x000000071a2b3c4d。起初我们以为是锁竞争但BLOCKED线程只有 2 个。深入分析发现这个地址对应的对象是一个ReentrantLock的AbstractQueuedSynchronizer$Node而所有WAITING线程都在调用lock.lock()后进入park。问题根源不在锁本身而在获取锁前的一个AtomicBoolean.compareAndSet(false, true)检查——当多个线程同时通过该检查后只有一个能成功加锁其余线程在park前会短暂处于RUNNABLE状态进行自旋但自旋次数被设为 0默认值导致立即park。这就是典型的锁竞争引发的间接饥饿低优先级线程在自旋阶段就被调度器剥夺 CPU永远无法进入锁竞争队列。提示jstack -l pid是必用参数它会输出java.util.concurrent包中显式锁如ReentrantLock的持有者和等待者信息这对识别BLOCKED和WAITING的关联至关重要。而jstack -F pid强制 dump仅在进程已无响应时使用它可能抓取到不一致的状态快照慎用。再来看一个活锁的硬核证据。某次金融对账服务在批量处理时CPU 持续 100%jstack显示所有工作线程均为RUNNABLE栈顶全是java.util.concurrent.atomic.AtomicLong.getAndIncrement()。乍看是正常计数但结合业务逻辑发现这段代码被包裹在一个while (!conditionMet()) { counter.incrementAndGet(); }循环中。conditionMet()依赖一个外部服务的 HTTP 响应而该服务因网络抖动超时导致线程在用户态无限自增计数器既不阻塞也不休眠。这就是活锁的本质线程没有被任何锁或系统调用挂起但它所执行的逻辑使其无法取得任何实质性进展。解决方案不是加锁而是引入退避机制——将incrementAndGet()替换为Thread.sleep(10)让出 CPU 时间片给外部条件变化留出窗口。3. 死锁的闭环检测从jstack到jcmd的三级排查链路死锁的识别看似简单但生产环境中的“伪死锁”和“复合死锁”会让排查过程变得异常曲折。我总结了一套经过数十次线上故障验证的三级排查链路它不依赖任何第三方工具全部使用 JDK 自带命令且每一步都有明确的决策点。3.1 一级筛查jstack的黄金 10 秒法则当你收到死锁告警第一反应不是重启而是执行jstack -l pid dump.txt 21。关键在于“10 秒法则”从执行命令到拿到文件必须控制在 10 秒内。为什么因为死锁是动态现象长时间 dump 可能导致状态改变。拿到文件后不要通读直接搜索三个关键词Found one Java-level deadlock这是 JVM 的自动检测结果可信度最高但仅覆盖synchronized和java.util.concurrent显式锁。waiting to lock定位所有等待锁的线程。locked定位所有已持有锁的线程。然后手工绘制依赖图。例如dump 中有Thread-A: at com.example.OrderService.updateStatus() waiting to lock 0x000000071a2b3c4d (a java.lang.Object) locked 0x000000071a2b3c5e (a java.lang.Object) Thread-B: at com.example.InventoryService.deductStock() waiting to lock 0x000000071a2b3c5e (a java.lang.Object) locked 0x000000071a2b3c4d (a java.lang.Object)这清晰地构成了 A→B→A 的闭环是标准死锁。但注意如果waiting to lock和locked的地址不匹配或者存在多个中间节点如 A→B→C→A就需要进入二级筛查。3.2 二级深挖jcmd的实时锁竞争分析jstack是静态快照而jcmd能提供动态视角。执行jcmd pid VM.native_memory summary scaleMB可查看内存分布但真正关键的是jcmd pid VM.native_memory detail的输出中Internal部分会显示Mutex和Monitor的数量。如果Monitor数量持续增长且不释放说明存在锁泄漏。更直接的方法是使用jcmd pid VM.native_memory baseline建立基线然后在业务高峰期再次执行jcmd pid VM.native_memory summary diff观察Monitor的增量。我曾在一个支付网关中发现Monitor数量在 5 分钟内从 1200 增至 8500而jstack却未报告死锁。最终定位到是ConcurrentHashMap在扩容时内部使用的synchronized块在极端并发下导致部分线程在transfer方法中长时间等待形成“准死锁”——虽未完全闭环但响应时间已不可接受。3.3 三级根因-XX:PrintGCDetails与jstat的协同印证很多“死锁”表象实则是 GC 压力过大导致的 STWStop-The-World假象。当 CMS 或 G1 GC 触发 Full GC 时所有应用线程会被强制暂停此时jstack显示的BLOCKED状态其实是 GC 线程在工作。如何区分开启 JVM 参数-XX:PrintGCDetails -Xloggc:gc.log同时用jstat -gc pid 1000实时监控。如果jstack报告死锁的时间点恰好与gc.log中Full GC的时间戳重合且jstat显示FGCTFull GC Time突增则问题根源在 GC而非锁。我们曾因此避免了一次重大误判一个电商搜索服务在流量高峰时频繁“假死”jstack显示 50 线程BLOCKED但jstat显示FGCT达到 8 秒最终通过调整 G1 的-XX:MaxGCPauseMillis200和-XX:G1HeapRegionSize4M解决。注意jstack的自动死锁检测有局限性。它无法检测跨 JVM 进程的死锁如微服务间 RPC 调用循环依赖、数据库事务锁如 MySQL 的行锁等待、或 native 代码中的锁如 JNI 调用 C 库的 mutex。这些场景必须结合数据库SHOW ENGINE INNODB STATUS、Linuxstrace -p pid等工具综合分析。4. 数据库死锁与阻塞队列从 SQL 语句到线程池的全链路传导死锁和阻塞从来不是单点问题而是一条从数据库到应用层的传导链。一个UPDATE语句的锁等待可能最终表现为应用线程池的耗尽。我以一个真实的订单履约系统为例完整还原这条链路。4.1 数据库层MySQL 的锁等待是如何被放大的我们的订单表t_order有联合索引(status, create_time)。一个常见的查询是SELECT * FROM t_order WHERE status PROCESSING ORDER BY create_time LIMIT 100。在高并发下这个查询会扫描大量PROCESSING状态的记录导致间隙锁Gap Lock范围极大。而另一个更新语句UPDATE t_order SET status SHIPPED WHERE order_id ?如果order_id不在索引覆盖范围内就会触发全表扫描加锁。当这两个操作并发执行时就可能产生死锁查询线程持有了PROCESSING状态记录的间隙锁更新线程需要修改某条记录而该记录恰好落在查询的间隙锁范围内于是更新线程等待同时更新线程持有的行锁又被另一个查询线程需要形成闭环。解决思路不是简单加索引而是重构查询逻辑。我们将ORDER BY create_time LIMIT 100改为WHERE status PROCESSING AND create_time ? ORDER BY create_time DESC LIMIT 100通过添加create_time的范围条件将间隙锁限制在极小范围内。同时对order_id字段建立唯一索引确保更新语句能走索引避免全表扫描。4.2 应用层阻塞队列如何成为死锁的“放大器”数据库锁等待会直接传导到应用线程。我们使用ThreadPoolExecutor处理订单履约核心线程数 20最大线程数 100阻塞队列类型为LinkedBlockingQueue无界队列。当数据库出现慢查询或锁等待时每个工作线程在执行orderService.process()时都会被阻塞在 JDBC 的executeUpdate()调用上。由于队列无界新请求会不断被放入队列线程池会不断创建新线程直到达到 max最终导致线程数暴涨消耗大量内存和 CPU 上下文切换开销队列中积压大量待处理任务这些任务在数据库锁释放后会集中爆发进一步加剧数据库压力整个服务进入“雪崩”状态。正确的做法是将阻塞队列改为有界队列如ArrayBlockingQueue(1000)并设置合理的拒绝策略。我们采用ThreadPoolExecutor.CallerRunsPolicy即当队列满时由提交任务的线程通常是 Tomcat 的http-nio-8080-exec-*线程自己执行该任务。这会产生两个效果一是反向抑制上游请求速率Tomcat 线程被占用无法接收新请求二是让问题暴露在更前端便于快速熔断。上线后数据库锁等待导致的级联故障减少了 90%。4.3 全链路追踪如何用Transactional注解埋下隐患Spring 的Transactional是便利的双刃剑。一个典型的错误用法是Service public class OrderService { Transactional // 默认 REQUIRED public void processOrder(Long orderId) { Order order orderMapper.selectById(orderId); // 1. 读取 if (order.getStatus().equals(PAID)) { inventoryService.deduct(order.getItemId(), order.getQuantity()); // 2. 远程调用 order.setStatus(PROCESSING); orderMapper.updateById(order); // 3. 更新 } } }问题在于第 2 步的远程调用inventoryService.deduct()。如果该调用耗时 5 秒那么整个事务的数据库连接将被持有 5 秒期间order记录的行锁一直不释放。而inventoryService本身也可能有数据库操作形成跨服务的锁依赖。解决方案是拆分事务processOrder()方法去掉Transactional在selectById()后立即开启一个短事务执行updateById()将长耗时的远程调用移出事务边界。这虽然增加了代码复杂度但彻底切断了数据库锁的跨服务传导。5. 无锁编程的实践边界CAS、原子类与 ABA 问题的硬核解法“无锁”常被神化为高性能的终极答案但我在物联网平台的实践中发现它在绝大多数业务场景中是过度设计甚至会引入更隐蔽的缺陷。真正的无锁不是不用锁而是用更底层、更可控的原语替代高层锁。5.1 CAS 的本质与性能真相AtomicInteger的compareAndSet(expected, update)是无锁编程的基石。它的硬件实现是 x86 的CMPXCHG指令这是一个原子操作。但很多人忽略了其背后的代价缓存一致性协议MESI的开销。当多个 CPU 核心频繁修改同一个缓存行Cache Line时会导致该缓存行在核心间反复失效Invalidation引发“伪共享”False Sharing。我做过一个基准测试在 32 核服务器上100 个线程并发对同一个AtomicInteger执行incrementAndGet()QPS 仅为 200 万而将 100 个AtomicInteger对象按核心数分组每组 4 个每个线程只操作自己组内的对象QPS 飙升至 1200 万。差距源于缓存行争用。因此无锁设计的第一原则是数据结构必须对齐缓存行。Java 中可通过sun.misc.Contended注解需 JVM 参数-XX:-RestrictContended实现但更通用的做法是手动填充。例如public final class PaddedAtomicLong extends AtomicLong { // 64 字节的填充确保 value 占据独立缓存行 private long p1, p2, p3, p4, p5, p6, p7; public PaddedAtomicLong(long initialValue) { super(initialValue); } }5.2 ABA 问题不是理论而是线上事故ABA 问题是指一个值从 A 变为 B再变回 A此时 CAS 操作会成功但逻辑上可能已出错。这在现实业务中绝非虚构。我们有一个设备心跳上报服务使用AtomicReferenceDeviceStatus存储设备最新状态。状态对象包含lastHeartbeatTime和online字段。当设备离线后又重连lastHeartbeatTime会更新但online字段可能从true→false→true。如果一个后台线程正在执行compareAndSet(oldStatus, newStatus)而oldStatus的online是truenewStatus的online也是true但lastHeartbeatTime已不同CAS 会成功导致旧的心跳时间被错误覆盖。标准解法是AtomicStampedReference它为每次修改附加一个版本号stamp。但版本号管理本身有开销。我们采用了更轻量的方案在DeviceStatus对象中增加一个long version字段并在每次状态变更时version然后使用AtomicReferenceFieldUpdater对version字段进行 CAS。这样既避免了AtomicStampedReference的额外对象创建又保证了状态变更的严格顺序。5.3 何时该放弃无锁回归synchronized无锁的适用场景非常狭窄必须是极高并发、极短临界区、且能容忍一定失败重试的场景如高性能队列Disruptor、计数器、状态标记。对于大多数业务逻辑synchronized是更优选择。原因有三JVM 优化成熟HotSpot 的偏向锁、轻量级锁、重量级锁的自适应升级使得synchronized在低竞争下几乎零开销可读性与可维护性synchronized的语义清晰ReentrantLock的tryLock()、lockInterruptibly()等 API 容易被误用调试友好jstack能清晰显示synchronized的持有者和等待者而无锁代码的 bug 往往表现为数据不一致难以复现和定位。我们曾将一个订单号生成器从AtomicLong迁移到synchronizedQPS 从 800 万降至 750 万但代码行数减少 60%线上故障率下降 95%。性能的微小损失换来了巨大的工程效率提升。这才是工程化的务实选择。6. 饥饿与活锁的识别那些不会报警却在悄悄杀死系统的“慢性病”死锁会立刻让服务崩溃而饥饿和活锁则像慢性病它们不会触发告警却在悄无声息中侵蚀系统的健康度和业务 SLA。它们的识别依赖于对系统行为模式的深度观察而非简单的状态码。6.1 饥饿的三大特征优先级、公平性与资源配额饥饿的本质是资源分配的不公平。在 JVM 中它主要体现在三个方面线程优先级饥饿Java 的Thread.setPriority()在大多数操作系统上已被忽略但ForkJoinPool的工作窃取Work-Stealing机制中高优先级任务仍可能抢占低优先级任务的执行机会。我们曾在一个报表生成服务中将ForkJoinPool.commonPool()的并行度设为Runtime.getRuntime().availableProcessors() * 2导致后台低优先级的 ETL 任务永远无法获得足够线程报表生成延迟从 2 小时延长至 12 小时。锁公平性饥饿ReentrantLock的公平模式new ReentrantLock(true)能保证 FIFO但会带来显著性能下降约 20%。我们在线上环境一律使用非公平锁但为关键业务路径如支付扣款单独创建一个公平锁实例并通过tryLock(timeout, unit)设置超时避免无限等待。资源配额饥饿这是最隐蔽的。例如一个ScheduledThreadPoolExecutor用于执行定时清理任务核心线程数为 1。当主业务线程池因数据库问题阻塞时这个定时线程池的唯一线程也可能被 JVM GC 或其他系统事件抢占导致清理任务长期无法执行磁盘空间缓慢耗尽。解决方案是为这类基础设施任务分配独立的、有保障的线程资源并设置setRemoveOnCancelPolicy(true)防止取消任务后线程被回收。6.2 活锁的诊断CPU 热点与自旋模式分析活锁的诊断核心是识别“无效的 CPU 消耗”。工具链如下async-profiler这是我的首选。执行./profiler.sh -e cpu -d 30 -f profile.html pid生成的火焰图中如果某个方法如com.example.RetryPolicy.retry()占据 CPU 的 80% 以上且其调用栈中没有 I/O 或锁等待基本可判定为活锁。perfLinux 原生工具perf record -g -p pid sleep 30然后perf report --no-children查看热点函数。jfrJava Flight Recorder开启jcmd pid VM.unlock_commercial_features后jcmd pid JFR.start nameLive duration60s settingsprofile录制结束后用 JDK Mission Control 分析重点关注Method Profiling和Code Cache事件。一个经典活锁案例我们使用RabbitMQ的SimpleMessageListenerContainer设置了concurrentConsumers10。当消息处理逻辑中包含一个while (!cache.isReady()) { Thread.sleep(10); }时10 个消费者线程会同时陷入sleep醒来后又同时检查cache.isReady()如果仍未就绪再次sleep。这导致消息处理呈现“脉冲式”吞吐峰值很高但平均很低且Thread.sleep()的上下文切换开销巨大。解决方案是引入指数退避Exponential BackoffThread.sleep((long) Math.pow(2, retryCount) * 10)让各线程的唤醒时间错开平滑负载。6.3 阻塞的“灰色地带”从WAITING到TIMED_WAITING的微妙差异WAITING和TIMED_WAITING的区别决定了你是面对一个设计缺陷还是一个可容忍的等待。WAITING状态意味着线程在等待一个永远不会到来的信号这通常是 bug。例如synchronized (lock) { while (!condition) { lock.wait(); // 永远等待除非 condition 被其他线程 notify } }如果notify()永远不被调用线程就永久WAITING。而TIMED_WAITING则提供了逃生通道synchronized (lock) { while (!condition) { lock.wait(5000); // 最多等 5 秒 if (!condition) { log.warn(Condition not met after timeout, recovering...); break; // 主动退出避免永久阻塞 } } }在分布式系统中TIMED_WAITING更是黄金准则。我们所有对外部服务的调用HTTP、RPC、DB都强制设置超时所有wait()调用都带超时参数。这不仅是健壮性要求更是可观测性的基础——超时日志是定位下游服务问题的第一手证据。提示jstack中TIMED_WAITING线程的堆栈会明确显示超时时间如java.lang.Object.wait(Native Method)后跟at java.lang.Thread.sleep(Thread.java:340)其中340行就是sleep(5000)的调用点。这是判断超时设置是否生效的直接依据。7. 生产环境的防御性编程从代码规范到监控告警的落地清单理论终须落地。以下是我团队在多个项目中沉淀下来的、经过生产环境千锤百炼的防御性编程清单。它不追求炫技只关注“如何让系统在出问题时还能给你留出抢救的时间”。7.1 代码层五条不可妥协的铁律所有锁必须有超时synchronized无法设置超时因此禁止在任何可能涉及外部依赖DB、RPC、文件 IO的场景中使用。一律改用ReentrantLock.tryLock(timeout, unit)超时时间必须小于上游调用的超时时间如上游 HTTP 超时 3 秒则锁超时设为 2.5 秒。禁止在循环中无条件wait()while (true) { obj.wait(); }是自杀式写法。必须配合if/while条件检查和超时机制。Thread.sleep()必须在try-catch中InterruptedException是受检异常不能忽略。正确的处理是Thread.currentThread().interrupt()恢复中断状态让上层决定如何处理。ConcurrentHashMap的computeIfAbsent()要警惕其 lambda 表达式中不能包含任何可能阻塞的操作如 DB 查询否则会阻塞整个哈希桶的写入。应先在外围完成阻塞操作再调用putIfAbsent()。Future.get()必须带超时future.get()会无限等待future.get(3, TimeUnit.SECONDS)是唯一安全用法。我们已在公司代码规范中将其列为ERROR级别检查项。7.2 监控层构建“死锁/饥饿”的早期预警告警不应等到jstack报告死锁才触发。我们建立了三级监控体系一级秒级jstat -gc pid的FGCT和YGCT当FGCT 1s或YGCT 100ms且持续 3 次触发“GC 压力”告警这是死锁的前置信号。二级分钟级jstack自动采集脚本每 5 分钟执行一次解析BLOCKED线程数。当BLOCKED线程数 总线程数的 20% 且持续 10 分钟触发“锁竞争”告警。三级小时级应用层埋点统计ReentrantLock.tryLock()的失败率。当失败率 5% 且持续 1 小时触发“锁争用”告警。所有告警都附带一键诊断链接点击后自动执行jstack -l pid并高亮显示BLOCKED和WAITING线程极大缩短 MTTR平均修复时间。7.3 架构层用“隔离”代替“对抗”最优雅的解决方案是让问题根本没有发生的土壤。我们推行“三隔离”原则线程池隔离核心业务支付、订单与非核心业务日志、监控、报表使用完全独立的线程池避免非核心任务拖垮核心链路。数据库连接池隔离读库、写库、分析库使用不同的 HikariCP 实例连接数、超时、最大生命周期全部独立配置。服务调用隔离使用 Resilience4j 的Bulkhead舱壁模式为每个下游服务如库存、优惠券设置独立的并发数限制和队列容量防止一个下游故障拖垮整个服务。这套体系在去年双十一中经受住了考验当优惠券服务因数据库慢查询导致响应时间飙升时其Bulkhead的并发数迅速达到上限后续请求被快速失败Fallback而订单和支付服务完全不受影响SLA 保持 99.99%。我在实际使用中发现所有这些技术手段最终都服务于一个朴素的目标让系统在面对不确定性时依然能给出确定性的反馈。无论是jstack里一行清晰的waiting to lock还是监控面板上一个醒目的“锁争用”告警亦或是代码中一个带超时的tryLock()调用它们都是工程师与混沌世界谈判的筹码。死锁、活锁、饥饿、阻塞、无锁这些词从来不是用来背诵的考题而是我们每天在生产环境里用代码、用工具、用经验一笔一划写下的生存笔记。