
先说一件我上半年处理过的线上事故。业务高峰期网关监控里一批数据库相关接口的 P99 从平时 200ms 直接飙到 3 秒开外日志里反复出现同一行异常com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 30007, active 6, maxactive 6。字面意思很直白——一个线程找连接池借数据库连接等了整整 30 秒没借到池里 6 个连接全被占用。但真正折磨人的是后面这个问题为什么 6 个连接会被占满占满之后还不归还顺着这条线查下去我最后发现根源出在业务代码里对wait/notify的错误使用连带牵扯出一整条线程饥饿的食物链。这篇就把wait、notify、sleep这三个方法和线程饥饿问题放在一起讲透它们各自的底层行为是什么、饥饿是怎么一步步形成的、线上怎么排查、怎么预防。适合写过多线程代码的 Java 工程师尤其是被连接池超时、接口长尾耗时折磨过的同学。1. wait、sleep、notify 的底层逻辑锁、监视器和线程状态先澄清一个最基础的认知三个方法里wait和notify是Object的方法sleep是Thread的静态方法。这个 API 归属差异本身就暗示了它们的本质分工——sleep只管当前线程歇一会儿wait/notify管的是线程之间通过对象的监视器monitor协作。1.1 sleep 不会释放锁最常见的认知偏差我面试时经常问一个问题synchronized块里调Thread.sleep(1000)另一个线程能不能进这个锁不少候选人犹豫半天甚至有人回答能因为 sleep 会释放 CPU。这个误区非常普遍。sleep的语义是让当前线程进入TIMED_WAITING状态暂停执行指定毫秒数。它在 JVM 层面的动作就是让出 CPU 时间片但线程持有的监视器锁一个都不会释放。看这段代码public void methodA() { synchronized (lock) { System.out.println(enter); try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }这 5 秒内任何想拿lock的线程都只能阻塞在锁外。如果拿到锁的线程同时还开着事务、占着数据库连接那影响面就大了。所以我的经验法则很简单在持有锁的代码块里不要出现 sleep也不要做任何耗时的 IO 操作。另外注意sleep 这个名字很容易让人联想到操作系统里的进程等待语义。比如 C 语言的waitpid是父进程等子进程退出嵌入式 RTOS 里的sleep是任务级延时这些跟 Java 的wait、sleep完全是不同层面的东西。初学者经常把进程等待和对象等待混在一起真到排查问题时会走很多弯路。记住本文讨论的wait是Object实例方法它管的是对象监视器上的等待集合。1.2 wait 的真实动作释放监视器并进入等待集wait的行为比sleep复杂得多因为它涉及线程间协作。调用obj.wait()的线程必须已经持有obj的监视器否则直接抛IllegalMonitorStateException。持有监视器之后wait()会做这么几件事把当前线程放入obj的等待集wait set释放obj的监视器锁线程状态变为WAITING无参版本或TIMED_WAITING带超时版本直到被notify/notifyAll唤醒或者超时时间到了线程重新去竞争监视器竞争成功之后从wait()返回继续往下执行。所以wait和sleep最本质的区别就是一句话sleep 只管自己休息不碰锁wait 会主动交出锁等别人给信号。这个设计是为了解决条件不满足时把锁让出来给别人用的场景典型如生产者消费者模型队列满了生产者没必要死占着锁不如先 wait等消费者腾出空间再通知它。为什么wait必须放在synchronized块里这不仅是语法要求更核心的是要保证检查条件 - 调用 wait这个动作和对端的通知之间没有竞争窗口。设想一个场景线程 A 检查条件发现不满足正准备调wait如果它在检查之后、调用wait之前线程 B 把条件改成满足并调了notify——那么这次通知就丢失了A 会永远等下去。这就是并发里著名的lost wakeup问题。synchronized把检查和等待包成一个原子操作正是为了堵住这个窗口。1.3 notify 与 notifyAll唤醒谁、由谁决定notify会从等待集里挑一个线程唤醒notifyAll唤醒所有等待线程。这里有个关键点很多人不知道JVM 规范并没有规定notify必须挑哪个线程HotSpot 的实现也不是先来后到的公平队列。它内部是从等待集合里选一个具体选谁跟线程被挂起时的状态和竞争时机有关业务代码无法控制。这意味着什么如果等待线程很多而你每次都只notify一个极有可能某些线程被反复跳过——它们一直在等待集里却始终没被选中。这就是wait/notify使用不当引发线程饥饿的第一种常见路径后面第二节我会详细展开。另外从wait()返回不代表条件一定满足。这是教科书反复强调的坑唤醒可能是虚假唤醒spurious wakeup也可能是多个线程同时被唤醒后条件又被别的线程改掉了。所以正确的等待写法一定是循环synchronized (lock) { while (!condition) { lock.wait(); } // 能走到这里说明条件一定满足 }绝对不能写成if (!condition) wait();。我在代码 review 里见过不少这种写法平时流量小可能不炸一旦并发上来、多线程竞争激烈就会偶发出现条件不满足却继续往下走的诡异 bug。这类 bug 最难排查因为现场不可复现日志里什么都看不出来。2. 线程饥饿的形成路径从公平性缺失到资源池耗尽线程饥饿thread starvation描述的是线程明明可运行却因为一直拿不到所需资源而无法推进。这里的资源可以是 CPU 时间片、锁、数据库连接、线程池里的执行线程。很多人习惯把一切卡住的现象都叫死锁但饥饿和死锁有本质区别排查思路也完全不同。2.1 饥饿不等于死锁死锁是互相等谁都不让形成环路之后整体无法推进饥饿是有人一直轮不到系统里其他线程可能跑得好好的只有个别线程或一批线程被晾着。我在线上做问题分类时会按下面这个表快速判断特征死锁线程饥饿普通锁竞争线程状态通常 BLOCKED互相持有对方需要的锁WAITING/BLOCKED等待目标迟迟不出现BLOCKED短暂等待但频率高CPU 使用率整体偏低其他线程正常饿着的线程低整体偏高典型现象请求永久挂起无超时日志池化组件报超时如 wait millis 30007耗时增加但不会完全无响应是否自动恢复不干预不会恢复资源和竞争缓解后可能恢复降低竞争后恢复死锁要靠破坏环路来解决而饥饿要先回答资源到底被谁占着、为什么占着不放。连接池场景里的active 6就是最典型的饥饿外显业务线程在等连接这不是死锁是资源被长期持有导致的饥饿。2.2 不公平竞争为什么等待者可能一直等不到HotSpot 对synchronized的锁获取并不是严格公平的。当一个锁被释放时新到达的线程和已经在等待的线程会一起竞争而且新生线程在抢占上往往能插队成功——偏向锁和轻量级锁优化会偏向最近活跃的线程。高并发下如果持锁时间又长极少数等待线程可能反复插队失败表现为饥饿。ReentrantLock也一样默认是非公平的。虽然new ReentrantLock(true)能开公平模式按等待时间排队但公平锁的吞吐量通常比非公平锁低因为多了排队和唤醒的调度成本。我的建议是大多数业务场景用非公平锁只有明确存在低优先级线程必须避免被饿死的诉求才考虑公平模式。开公平锁之后一定要用压测数据验证吞吐下降是否可接受别为了一个理论上的公平性牺牲整体性能。除了锁本身的不公平notify的随机挑选也可能造成饥饿。假设一个等待队列里有 10 个线程等同一个条件如果生产者每次只notify一个而 JVM 恰好总是选中同一个线程其余 9 个会持续 WAITING。虽然概率不高但一旦出现就是线上幽灵问题——接口偶发超时重启就好过几天又出现。2.3 连接池场景wait millis 30007 是怎么出现的回到开头那个异常GetConnectionTimeoutException: wait millis 30007, active 6, maxactive 6。这是 Druid 连接池给出的信息拆开翻译一下maxactive6连接池最多持有 6 个连接active6这 6 个连接全部被业务线程借走且尚未归还wait millis 30007当前线程借连接时发现池里没有空闲连接于是按maxWait30000毫秒进入等待30 秒后依然借不到抛出超时异常。如果 Druid 配置里把maxWait设成了 -1线程就会无限等下去连这个异常都不会有问题更难暴露。所以从可观测性角度maxWait必须配成有限值。回到为什么 6 个连接会被占满且不释放这个问题最常见的三种原因慢 SQL 把连接长时间借走执行时间远超正常水平业务代码拿到连接后在锁里做远程调用或 sleep连接归还被无限推后某个事务异常后既没提交也没回滚连接泄漏在业务线程里。这三种情况的排查路径完全不同。我处理的那次事故恰好三条里占了两条下一节写完整还原。3. 一次真实线上饥饿问题的排查链路那次事故排查前后用了一个下午。流程说不上复杂但每一步都有取舍。我尽量按当时的顺序写方便你以后遇到类似问题直接照着做。3.1 现象与第一判断长尾耗时和连接池告警故障的典型表现是平时 P99 约 200ms 的查询接口高峰期变成 2~5 秒监控里大量wait millis日志刷屏。第一步不要急着改代码先回答三个问题只有这一个接口慢还是所有走数据库的接口都慢——判断是不是连接池全局性问题连接池活跃数是否长期触顶——通过 Druid 监控页观察 active、maxactive、等待线程数数据库本身的负载是否异常——排除 DB 侧瓶颈。当时的情形成立所有走该数据源的接口都受影响active 长期等于 6。但数据库 CPU 不高慢 SQL 日志里也没有明显的全表扫描。这说明问题大概率不在 SQL 本身而在连接池内部——连接被借走之后迟迟不归还。3.2 jstack 两次采样还原线程都在哪线上排查线程卡在哪最快的工具是jstack。但关键是要采样两次间隔 3 到 5 秒对比变化jstack -l pid dump1.txt sleep 3 jstack -l pid dump2.txt为什么强调两次因为单次快照只能看到此刻的状态可能只是瞬时抖动两次对比才能区分真卡住和单纯慢。当时两次采样中都稳定存在一组相同的线程比如http-nio-8080-exec-13 #29 waiting on java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(...) at com.alibaba.druid.pool.DruidDataSource.getConnectionDirect(...) at com.alibaba.druid.pool.DruidDataSource.getConnection(...) at com.example.OrderService.queryOrder(OrderService.java:88)这类线程状态是WAITING等待对象是 Druid 内部的NotEmptyCondition——连接池专门用来等空闲连接的条件队列。数量很多十几个 HTTP 工作线程都堵在这一层。到这里基本可以坐实线程不是在等业务锁而是在等连接资源。3.3 找到占用者持锁线程为什么卡住等连接的线程查清楚了下一个问题是那 6 个连接在谁手里那 6 个线程在干什么。同样从线程转储里捞持有连接的线程这一找就发现了问题。有 3 个线程明明拿着连接却全部阻塞在业务代码里的一把锁上business-worker-1 #12 prio5 os_prio0 cpu12.34ms java.lang.Thread.State: BLOCKED (on object monitor) at com.example.InventoryService.deductStock(InventoryService.java:70) - waiting to lock 0x000000076b1a4a58 (a java.lang.Object) - locked 0x000000076b1a4cd0 (a com.alibaba.druid.pool.DruidPooledConnection)注意这个堆栈的两个锁信息线程先获得了数据库连接锁住了DruidPooledConnection然后在业务锁0x000000076b1a4a58上 BLOCKED。换句话说连接被借走了但持锁线程没能推进业务只能攥着连接等另一把锁。3.4 根因确认一条慢SQL引发的连锁饥饿继续追那把业务锁的另一端谁持有0x000000076b1a4a58转储显示持锁的是一个正在执行订单批量更新的线程。它之所以持有锁这么久是因为在锁内部执行了一条批量 SQL——由于数据倾斜和索引失效这条 SQL 实际跑了 8 秒。到这一步完整的食物链就拼出来了某个请求在synchronized块里执行慢 SQL8 秒不放手一直占着业务锁另外 3 个线程拿着数据库连接卡在这把业务锁上等锁释放连接无法归还连接池连接池 6 个连接被这 4 个线程消耗殆尽3 个等锁、1 个慢 SQL其余业务线程借不到连接在getConnection()上排队直到 30 秒超时连接被占满后连正常 SQL 也没连接可用接口大面积超时。这个问题的本质不是死锁而是一条慢 SQL 引发的大面积资源饥饿——慢 SQL - 长持锁 - 连接被卡 - 等待连接的线程饥饿。排查到这里修复方案就非常明确了。4. 修复、调优与保命经验4.1 锁内别做长事务和远程调用最直接的修复是把慢 SQL 挪出锁或者缩小锁范围。原来的代码是库存扣减 订单更新 批量查询整体包在一个synchronized块里批量更新和查询都属于重操作。改成只锁关键的扣减判断部分把 SQL 执行放到锁外同时给批量 SQL 补上合适的索引把单次执行时间从 8 秒降到 200ms。这条经验对所有多线程代码都适用临界区越大锁持有时间越长饥饿风险越高。写代码时我给自己定了一个判断标准——如果在synchronized块或Lock保护区域内出现了 SQL、HTTP 调用、文件 IO、Thread.sleep那基本就是设计有问题。这个标准我每次 code review 都会用拦下来过不少隐患。4.2 wait 必须配超时和 while 条件回到wait的正确姿势。很多线上偶发问题都出在无超时等待 if 判断的组合上。我推荐的稳定模板是这样的synchronized (lock) { long deadline System.currentTimeMillis() 3000; while (!condition System.currentTimeMillis() deadline) { lock.wait(deadline - System.currentTimeMillis()); } if (!condition) { // 超时仍未满足走降级逻辑不要继续执行 throw new TimeoutException(condition not met within 3s); } // 条件满足执行真正的业务逻辑 }带超时等待的意义不只是防止永久 WAITING更重要的是它把卡住变成可控的超时让上层能感知并降级。另一个容易被忽略的坑notify 要放进 finally保证异常路径也会唤醒等待线程。我见过一个案子业务代码在 try 里抛异常notify写在 try 后面没执行结果等待线程集体饿死。正确写法是synchronized (lock) { try { // 生产或消费逻辑 } finally { lock.notifyAll(); } }提示唤醒操作放在 finally 里是并发编程里性价比最高的一个习惯。它不是风格问题是正确性问题。4.3 notify 与 notifyAll 的取舍优先用notifyAll这是几乎所有并发实践的一致建议。单一notify的选择不可控可能引发两个问题被选中的线程条件恰好不满足多条件等待时很常见它只能继续wait但这次通知被白白消耗其他条件已满足的线程没被选中继续饿着。notifyAll虽然会唤醒所有等待线程让它们重新竞争监视器但整体正确性远高于notify。只有在严格满足等待条件单一、所有等待线程都在等同一个条件、被唤醒的线程一定能消费这次通知时才值得用notify做性能优化。一般业务代码里请默认notifyAll。4.4 连接池参数的正确调法出问题之后有人第一反应是把maxactive从 6 调到 50。这个思路要警惕——连接池调大只是缓解症状不是修复根因。而且连接数不是越大越好每个连接都是数据库端的会话资源连接过多会占用数据库内存和会话数反而可能拖慢数据库。正确做法是先算清楚合理值。一个实用的估算方式拿业务高峰期的平均 SQL 执行耗时和 QPS按理论并发 每秒请求数 × 平均耗时秒来估。比如 QPS 200、平均耗时 20ms理论并发约 4maxActive6其实是够的。当时的问题不是池子小而是连接被长时间占用导致每个连接的有效吞吐远低于正常水平。参数层面我建议关注三件事maxWait必须配成有限值如 30000不能是 -1否则饥饿线程会无限等下去问题连日志都暴露不了开启连接池的泄漏检测Druid 对应removeAbandonedtrue、removeAbandonedTimeoutMillis300000至少在测试环境能发现连接泄漏监控里盯active和等待线程数而不是只盯 QPS。连接池的健康度看的是有没有线程在等资源不是每秒处理多少请求。4.5 最后的防线线程转储与监控预案排查线程问题我的经验是把jstack转储当成事故现场照片。出了诡异问题先别猜先拍照。推荐一套简单可复用的流程发现接口长尾或连接池超时立刻连续jstack -l三次间隔 3 秒保存现场用grep统计线程状态分布RUNNABLE/BLOCKED/WAITING/TIMED_WAITING看哪种状态占比异常对 WAITING/BLOCKED 的线程重点看卡在哪个对象或条件上往上翻调用栈找业务入口把两次快照中位置不变的线程拎出来这些往往是真的卡住而不是瞬时抖动结合连接池监控和慢 SQL 日志确认饥饿的源点到底是锁、连接还是线程池。这套流程我在类似问题里反复用过十次里八次能快速定位。关键在于不要只做一次快照单张快照很容易被误判成偶然现象。最后再分享一个小技巧排查线程饥饿时我习惯顺手看一眼jstack输出里线程的 CPU 时间cpuxx ms以及有没有 deadlock 检测提示。如果同一个线程两次采样的 CPU 时间几乎不涨说明它不是在疯狂自旋而是真的在等待——这能帮你快速排除忙等造成的假饥饿。连接池、线程池、锁的等待问题说到底都逃不过资源持有时间太长这一条。把持有时间压下来比调大任何池子都管用。