前面用两个线程演示过死锁。这次把执行顺序固定下来,再用线程堆栈看一下它们到底在等什么。

构造互相等待

import java.util.concurrent.CountDownLatch;

public class DeadlockCheckDemo {
    private static final Object FIRST = new Object();
    private static final Object SECOND = new Object();
    private static final CountDownLatch HELD = new CountDownLatch(2);

    private static void acquire(Object own, Object other) {
        synchronized (own) {
            HELD.countDown();
            try {
                HELD.await();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
            synchronized (other) {
                System.out.println("取得两把锁");
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread a = new Thread(() -> acquire(FIRST, SECOND), "lock-a");
        Thread b = new Thread(() -> acquire(SECOND, FIRST), "lock-b");
        a.start();
        b.start();
        a.join();
        b.join();
    }
}

CountDownLatch 在这里让两个线程先各自取得第一把锁,再尝试第二把。这个程序是故意写出的死锁示例,运行后不会自行结束,观察完成后需要结束进程。

lock-a 持有 FIRST 等 SECOND,lock-b 持有 SECOND 等 FIRST。它们都不会先退出外层同步块,因此谁也无法继续。

死锁通常涉及四个条件:资源互斥、持有资源继续等待、资源不能被强行夺走、等待关系形成环路。这里四项都具备。

查看线程堆栈

在另一个终端找到 Java 进程,再导出堆栈:

jps -l
jstack <pid>

重点看 lock-a、lock-b 两个线程的栈,找到各自的 locked 和 waiting to lock。如果一个线程等待的对象,正好出现在另一个线程已经持有的锁里,再反过来对上,就能看到完整的等待环。

jstack 还会报告检测到的 Java 级死锁。也可以用 jconsole 连接这个本地进程,在“线程”页检测死锁。对象地址每次运行可能不同,不能拿某次输出中的地址当固定标识。

打破等待环

最直接的修改,是让两个线程始终按 FIRST、SECOND 的顺序获取锁。注意还要删除示例中的 HELD 等待:如果第一个线程持有 FIRST 后等第二个线程到达,而第二个线程又拿不到 FIRST,仍然会卡住。

多个业务对象需要一起加锁时,可以按一个稳定且唯一的编号排序。不能直接拿可能相同的哈希值当成绝对顺序,除非相等时也有单独的处理规则。

另一种方式是用 ReentrantLock.tryLock() 设置等待上限。第二把锁获取失败,就释放已经拿到的第一把锁,再决定是否重试。双方完全同步地反复退让也可能形成活锁,所以超时只提供退出机会,并不是自动解决了所有协调问题。

排查时还要注意:长时间等待不一定是死锁。网络调用迟迟不返回、线程池没有空闲线程,也可能表现为“程序不动了”。先看线程栈和等待关系,再判断原因。

最后修改:2018 年 01 月 21 日
如果觉得我的文章对你有用,请随意赞赏