1. Thread Dump 与线程状态分析
Thread Dump(线程转储)是应用在某一时刻所有线程状态的快照。就像给所有线程拍一张“合影”:谁在做什么、谁卡住了、谁在等谁。Thread Dump 是定位 deadlock、livelock 以及各种神秘卡顿的核心工具。
如何获取 Thread Dump?
通过终端(jstack):
如果已知 Java 进程的 PID,请执行:
jstack <PID>
该命令会在控制台输出所有线程的状态,指出每个线程所处状态以及持有的监视器(锁)。
通过 IDE (IntelliJ IDEA):
在菜单“Run” → “Show Running List” → 选择进程 → “Thread Dump”。
通过 VisualVM 或 JConsole:
打开进程,找到“Threads”选项卡并生成快照。
Thread Dump 示例
转储片段:
"Thread-1" #12 prio=5 os_prio=0 tid=0x000000001e0c7800 nid=0x1a48 waiting for monitor entry [0x000000001f00f000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.DeadlockDemo.lambda$main$0(DeadlockDemo.java:25)
- waiting to lock <0x00000000d6d6baf8> (a java.lang.Object)
- locked <0x00000000d6d6bb08> (a java.lang.Object)
可以看到线程“Thread-1”被阻塞(BLOCKED),持有一个监视器,但在等待另一个。如果出现多个类似线程:有人持有资源 A 等待 B,而另一些持有 B 等待 A——这是典型的死锁。
线程状态
| 状态 | 说明 |
|---|---|
| RUNNABLE | 线程正在运行或已就绪 |
| BLOCKED | 等待获取监视器(锁) |
| WAITING | 等待 notify()/notifyAll()(例如调用了 wait()) |
| TIMED_WAITING | 带超时的等待(例如 sleep、wait(timeout)) |
| TERMINATED | 线程已结束 |
重要提示:状态 RUNNABLE 并不总意味着线程此刻正在执行——它只是处于可运行状态(JVM 的调度器可能不会立刻调度它)。
如何判断出现了死锁?
在转储中出现多个处于 BLOCKED 的线程,每个都在等待由同一组中其他线程持有的监视器。
在转储末尾,jstack 通常会输出:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00000000d6d6baf8 (object 0x00000000d6d6baf8, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x00000000d6d6bb08 (object 0x00000000d6d6bb08, a java.lang.Object),
which is held by "Thread-1"
如果线程长时间处于 BLOCKED 或 WAITING —— 就需要展开调查。
2. 线程监控与分析(Profiling)
VisualVM
VisualVM 是随多数 JDK 提供的免费工具。它允许连接到进程、查看线程状态、生成 Thread Dump、查看 CPU 负载、活跃线程和“挂起”的线程。
Threads 选项卡:可看到已创建的线程数量、其状态与活动历史。
Thread Dump:“Thread Dump” 按钮可生成与 jstack 类似的快照。
Java Mission Control 与 Flight Recorder
Java Mission Control (JMC):实时分析 JVM 的高级工具。可用于研究锁竞争、执行时间、内存分配与延迟等。
Java Flight Recorder (JFR):JVM 内置的分析器,收集线程、锁、停顿等事件。
示例:监控锁定
在 VisualVM 或 JMC 中,你可能会看到:
- 线程“A”在对象 X 上被阻塞。
- 线程“B”持有对象 X,但在等待对象 Y。
- 线程“C”持有对象 Y,但在等待对象 X。
这就是典型的循环等待(deadlock)。
如何在实践中使用这些工具?
- 使用启动参数 -XX:+FlightRecorder 运行应用(或直接使用 JDK 11+)。
- 打开 JMC,连接到进程,开始录制(start recording)。
- 分析“热点”、长时间持锁与线程间竞争。
3. 日志记录与追踪
在多线程程序中,靠“肉眼”调试会非常痛苦。请记录进入/退出临界区(synchronized 代码块)、对共享变量的操作、线程的等待与唤醒——这样你就能知道谁在何时获取或释放了资源。
如何进行日志记录?
- 使用常见框架:java.util.logging、SLF4J、Log4j。
- 记录线程名: Thread.currentThread().getName()。
- 记录时间与线程标识。
- 记录获取/释放锁的事件。
日志记录示例
synchronized(lock) {
System.out.println(Thread.currentThread().getName() + " 获得了 lock");
// 临界区
System.out.println(Thread.currentThread().getName() + " 退出 lock");
}
使用线程名
为线程起有意义的名字!
Thread t = new Thread(runnable, "MyWorker-1");
使用日志器进行追踪的示例
import java.util.logging.Logger;
public class Example {
private static final Logger logger = Logger.getLogger(Example.class.getName());
public void doWork() {
logger.info(Thread.currentThread().getName() + " 开始工作");
synchronized (this) {
logger.info(Thread.currentThread().getName() + " 进入 synchronized");
// ...
}
logger.info(Thread.currentThread().getName() + " 结束工作");
}
}
4. 诊断最佳实践
将锁的作用域最小化
尽量缩短持锁时间。
不佳示例:
synchronized(lock) {
// 耗时的 I/O
// 复杂计算
// 访问数据库
// ... 然后才处理共享数据
}
更佳示例:
// 在 synchronized 外完成:耗时的 I/O、计算
synchronized(lock) {
// 只处理共享数据
}
使用线程名
有意义的线程名可大幅节省分析转储与日志的时间。
为多线程编写测试
使用 JUnit + CountDownLatch 模拟并发场景。
CountDownLatch latch = new CountDownLatch(2);
Runnable task = () -> {
// ...
latch.countDown();
};
new Thread(task, "Worker-1").start();
new Thread(task, "Worker-2").start();
latch.await(); // 等待两个线程都结束
对 try-finally 使用 ReentrantLock
Lock lock = new ReentrantLock();
lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
这样即使发生异常也不会忘记释放锁。为避免相互阻塞,可使用带超时的 tryLock():如果无法获取全部锁——释放并重试。
记录为何需要同步
诸如“这里需要 synchronized,因为……”的注释能帮助你在将来重温设计意图。
5. 实战:分析测试程序中的 deadlock
包含 deadlock 的示例代码
public class DeadlockDemo {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("Thread-1: 获得了 lockA");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
synchronized (lockB) {
System.out.println("Thread-1: 获得了 lockB");
}
}
}, "Thread-1");
Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("Thread-2: 获得了 lockB");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
synchronized (lockA) {
System.out.println("Thread-2: 获得了 lockA");
}
}
}, "Thread-2");
t1.start();
t2.start();
}
}
如何捕捉 deadlock
- 运行程序——它会挂起。
- 获取 thread dump(使用 jstack 或通过 VisualVM)。
- 找到“Thread-1”和“Thread-2”——会看到各自持有一个锁并等待另一个。
- 在转储末尾会有“Found one Java-level deadlock”部分。
如何解决
- 始终按相同顺序获取锁。
- 使用带有 tryLock() 和超时的 ReentrantLock:若无法获取所有锁——先释放,再重试。
6. 诊断多线程程序的常见错误
错误 №1:不会阅读 thread dump。 初学者常被转储吓到:“这些奇怪的堆栈轨迹与状态是什么?”其实只需掌握主要状态并重点查找 BLOCKED/WAITING,即可简化分析。
错误 №2:忽略线程名。 没有有意义的名字,分析转储就像大海捞针。别偷懒,给线程命名!
错误 №3:synchronized 代码块过大。 如果你同步了大段代码,线程更容易相互阻塞——这会在转储中体现为频繁的 BLOCKED。
错误 №4:将 RUNNABLE 与真正运行的线程混淆。 RUNNABLE 并不总是“正在占用 CPU”。JVM 调度器自行决定何时调度。
错误 №5:不使用监控工具。 许多人不知道 VisualVM、JMC、Flight Recorder,而是靠 println 苦撑。请使用这些工具——它们能极大简化工作。
错误 №6:未记录关键操作。 没有日志,几乎无法弄清谁在何时获取/释放了锁。
错误 №7:试图“凭感觉”抓到数据竞争。 数据竞争并不总是立刻显现——使用 CountDownLatch 编写测试,通过 Thread.yield() 诱发竞争,并分析共享变量的状态。
GO TO FULL VERSION