TT Lab
开始
学习 学习路径 课程

堆还有空间,服务却停了

堆还有空间,服务却停了

在 TT Lab 中继续学习

一句话总结

如果堆和 GC 都好好的,服务却停了,那就是线程在某处等待。线程转储(jcmd <pid> Thread.print)会显示在那一刻所有线程停在哪一行、在等什么,并把持有锁的线程与排在它后面的线程配成对。

为什么需要它

这是本课程标题所指的事件。监控画面上堆 40%、GC 停顿 5ms、CPU 3%。可是健康检查超时,用户看到的是“无限加载”。服务器还活着——端口开着,进程也在。重启之后就恢复了。然后几天后又停了。原因是 200 个请求处理线程全都排在同一把锁前面,而持有这把锁的线程在无限期地等待一个没有响应的外部调用。堆里没有任何痕迹。线程转储里全都写着。

工作原理

jcmd 文档把 Thread.print 描述为“连同堆栈跟踪一起打印所有线程(影响:中等,与线程数成正比)”,并写明 -l 选项会加上 java.util.concurrent 的锁信息。jstack <pid> 也能得到同样的转储。转储不会让 JVM 停下来——只是会稍等片刻,直到线程到达安全点。所以在生产环境运行时也可以、也应该获取转储。

转储的一个片段如下(JDK 21,实测)。

"worker-2" #23 [213] prio=5 os_prio=0 cpu=11.78ms elapsed=1.45s tid=0x... nid=213 waiting for monitor entry
   java.lang.Thread.State: BLOCKED (on object monitor)
        at Stuck$Db.work(Stuck.java:41)
        - waiting to lock <0x000000008bb16390> (a Stuck$Db)
        at Stuck.lambda$main$3(Stuck.java:83)
"worker-1" #22 [212] ... waiting on condition
   java.lang.Thread.State: TIMED_WAITING (sleeping)
        at java.lang.Thread.sleep(java.base@21/Native Method)
        at Stuck$Db.hold(Stuck.java:56)
        - locked <0x000000008bb16390> (a Stuck$Db)

读取的顺序是固定的。(1) 按状态统计——RUNNABLE 表示正在工作,BLOCKED 表示正在等待 synchronized 监视器,WAITING/TIMED_WAITING 表示正通过 wait()、park()、sleep() 等待。如果大部分请求线程是 BLOCKED,就是锁竞争;如果大部分是 WAITING 且栈中有读取套接字的调用,就是在等待外部调用。(2) 收集 waiting to lock <주소>(占位符为地址)中的地址。同一个地址出现几十次,它就是瓶颈。(3) 用 - locked <주소>(占位符为地址)搜索这个地址,就会找到持有锁的线程。该线程栈的最顶端就是原因——上面的示例中是 Thread.sleep,现实中通常是 SocketInputStream.read 之类的等待外部响应。

如果使用的不是 synchronized,而是 java.util.concurrent 的 ReentrantLock、Semaphore,状态就不是 BLOCKED,而是 WAITING (parking),行会变成 - parking to wait for <주소> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)(占位符为地址)。只是随锁的种类不同,措辞有所区别,读法是一样的。ReentrantLock 文档中的 tryLock(timeout, unit) 如果在规定时间内没能拿到锁就返回 false,所以与 synchronized 不同,可以为等待设置上限。把无限期等待的请求改成“请稍后重试(503)”,就是本模块的恢复手段。

死锁(deadlock)会受到特殊对待。两个线程互相等待对方的锁时,JVM 会在转储末尾整理并打印 Found one Java-level deadlock: 及相关的线程和锁,最后以 Found 1 deadlock. 收尾。死锁不会自行解开,所以一旦看到这句话,除了重启别无他法,根本的解决办法是统一加锁的顺序。

转储要获取多份。一份只是一瞬间。每隔几秒获取三份,如果同一个线程一直停在同一个位置,那就是真的卡住了;如果每次位置都不同,只是在忙。jcmd 输出的第一行是 pid,第二行是时刻,所以三份的时刻行必须各不相同。

在现场相遇的样子

最常见的错误是先重启。重启会让证据消失。在服务停住的那一刻获取三份转储只需要 10 秒——之后再重启也不晚。第二种是用其他用户运行 jcmd。正如文档所强调的,必须与启动 JVM 的用户相同才能连上。第三种是不去找持有锁的线程,只统计 BLOCKED 的数量。排队的线程是受害者,不是原因。原因在持有 locked 的那一个线程的栈顶。

下一项实验要做什么

启动 /opt/lab/fixtures/jvm/Stuck.java——一个只有 4 个处理线程的小型 HTTP 服务——先获取一份正常的转储,然后通过 /poison 让它永远持有数据库锁,再获取其余请求以 BLOCKED 堆积起来的转储。从转储中读出持有锁的线程和排队的线程数量并记录下来,用 Deadlock.java 查看死锁转储,用 -Ddb.lock.timeout.ms= 开启 tryLock 并重新启动,确认无限等待变成了 503,最后留下以 2 秒为间隔的三份转储。