CodeGym /课程 /JAVA 25 SELF /多线程程序的诊断与调试

多线程程序的诊断与调试

JAVA 25 SELF
第 53 级 , 课程 4
可用

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”。

通过 VisualVMJConsole
打开进程,找到“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 带超时的等待(例如 sleepwait(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"

如果线程长时间处于 BLOCKEDWAITING —— 就需要展开调查。

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 内置的分析器,收集线程、锁、停顿等事件。

示例:监控锁定

VisualVMJMC 中,你可能会看到:

  • 线程“A”在对象 X 上被阻塞。
  • 线程“B”持有对象 X,但在等待对象 Y。
  • 线程“C”持有对象 Y,但在等待对象 X。

这就是典型的循环等待(deadlock)。

如何在实践中使用这些工具?

  • 使用启动参数 -XX:+FlightRecorder 运行应用(或直接使用 JDK 11+)。
  • 打开 JMC,连接到进程,开始录制(start recording)。
  • 分析“热点”、长时间持锁与线程间竞争。

3. 日志记录与追踪

在多线程程序中,靠“肉眼”调试会非常痛苦。请记录进入/退出临界区(synchronized 代码块)、对共享变量的操作、线程的等待与唤醒——这样你就能知道谁在何时获取或释放了资源。

如何进行日志记录?

  • 使用常见框架:java.util.loggingSLF4JLog4j
  • 记录线程名: 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

  1. 运行程序——它会挂起。
  2. 获取 thread dump(使用 jstack 或通过 VisualVM)。
  3. 找到“Thread-1”和“Thread-2”——会看到各自持有一个锁并等待另一个。
  4. 在转储末尾会有“Found one Java-level deadlock”部分。

如何解决

  • 始终按相同顺序获取锁。
  • 使用带有 tryLock() 和超时的 ReentrantLock:若无法获取所有锁——先释放,再重试。

6. 诊断多线程程序的常见错误

错误 №1:不会阅读 thread dump。 初学者常被转储吓到:“这些奇怪的堆栈轨迹与状态是什么?”其实只需掌握主要状态并重点查找 BLOCKED/WAITING,即可简化分析。

错误 №2:忽略线程名。 没有有意义的名字,分析转储就像大海捞针。别偷懒,给线程命名!

错误 №3:synchronized 代码块过大。 如果你同步了大段代码,线程更容易相互阻塞——这会在转储中体现为频繁的 BLOCKED

错误 №4:将 RUNNABLE 与真正运行的线程混淆。 RUNNABLE 并不总是“正在占用 CPU”。JVM 调度器自行决定何时调度。

错误 №5:不使用监控工具。 许多人不知道 VisualVMJMCFlight Recorder,而是靠 println 苦撑。请使用这些工具——它们能极大简化工作。

错误 №6:未记录关键操作。 没有日志,几乎无法弄清谁在何时获取/释放了锁。

错误 №7:试图“凭感觉”抓到数据竞争。 数据竞争并不总是立刻显现——使用 CountDownLatch 编写测试,通过 Thread.yield() 诱发竞争,并分析共享变量的状态。

1
调查/小测验
多线程问题第 53 级,课程 4
不可用
多线程问题
多线程问题
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION