Java 实例 – 死锁及解决方法(详细教程)

Java 实例 – 死锁及解决方法

在多线程编程的世界里,死锁就像一场“互相等待”的僵局。两个或多个线程因为争夺资源而陷入无限等待,谁也不肯先放手,最终整个程序陷入停滞。这就像两个人同时伸手去拿同一双筷子,又都等着对方先放,结果谁也吃不上饭。

对于 Java 开发者来说,死锁虽然不常见,但一旦发生,排查起来非常头疼。今天我们就通过几个真实可运行的 Java 实例,带你彻底搞懂死锁的成因、识别方法和解决策略。本文适合初学者和中级开发者,代码均经过实际测试,可直接复制运行。

死锁的四大必要条件

要理解死锁,必须先掌握它发生的四个基本条件。这四个条件就像四把锁,必须同时满足,死锁才会出现。

互斥条件

一个资源一次只能被一个线程占用。比如打印机只能被一个任务打印,不能同时被两个线程使用。在 Java 中,synchronized 关键字就实现了这种互斥。

占有并等待

线程在持有资源的同时,还请求其他资源。例如,线程 A 拿到了锁 1,然后去申请锁 2,但此时锁 2 被线程 B 占用。

不可抢占

已经获得的资源不能被强制释放。线程必须主动释放资源,不能被系统强行夺走。这就像你握着手机,别人不能直接抢走。

循环等待

存在一个线程的循环等待链。比如线程 A 等待线程 B 的资源,线程 B 等待线程 C 的资源,线程 C 又等待线程 A 的资源,形成闭环。

⚠️ 只要破坏其中任意一个条件,死锁就不会发生。

一个典型的死锁实例

让我们通过一个具体例子来观察死锁是如何发生的。

public class DeadlockExample {
    // 定义两个共享资源对象
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    // 线程1:先获取 resource1,再获取 resource2
    public static class Thread1 implements Runnable {
        @Override
        public void run() {
            synchronized (resource1) {
                System.out.println("Thread 1 拿到了 resource1");
                
                try {
                    Thread.sleep(1000); // 模拟处理时间
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                // 尝试获取 resource2,但此时被 Thread2 占用
                synchronized (resource2) {
                    System.out.println("Thread 1 拿到了 resource2");
                }
            }
        }
    }

    // 线程2:先获取 resource2,再获取 resource1
    public static class Thread2 implements Runnable {
        @Override
        public void run() {
            synchronized (resource2) {
                System.out.println("Thread 2 拿到了 resource2");
                
                try {
                    Thread.sleep(1000); // 模拟处理时间
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                // 尝试获取 resource1,但此时被 Thread1 占用
                synchronized (resource1) {
                    System.out.println("Thread 2 拿到了 resource1");
                }
            }
        }
    }

    public static void main(String[] args) {
        // 启动两个线程
        new Thread(new Thread1()).start();
        new Thread(new Thread2()).start();
    }
}

代码解析

  • resource1resource2 是两个独立的锁对象。
  • Thread1 先获取 resource1,再尝试获取 resource2
  • Thread2 先获取 resource2,再尝试获取 resource1
  • 由于线程执行速度可能不同,Thread1 拿到 resource1 后,Thread2 可能已经拿到了 resource2
  • 此时 Thread1 等待 resource2Thread2 等待 resource1,两者互相等待,形成死锁。

运行这段代码后,程序会永久卡住,没有任何输出,CPU 占用率可能飙升,但程序不会结束。

如何检测死锁?

Java 提供了强大的工具来检测死锁。我们可以通过 jstack 命令查看线程堆栈信息。

使用 jstack 检测死锁

  1. 编译并运行上面的死锁程序:

    javac DeadlockExample.java
    java DeadlockExample
    
  2. 在另一个终端中查找进程 ID:

    jps
    

    输出类似:

    12345 DeadlockExample
    
  3. 使用 jstack 查看线程状态:

    jstack 12345
    

在输出中你会看到类似:

Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x0000000002a0a000 (object 0x000000076b000000, a java.lang.Object),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x0000000002a0a010 (object 0x000000076b000010, a java.lang.Object),
  which is held by "Thread-1"

这说明:线程 1 等待线程 0 持有的锁,而线程 0 又在等待线程 1 持有的锁,正是典型的死锁。

避免死锁的四种策略

策略一:统一加锁顺序

最有效的方法是强制所有线程以相同的顺序获取锁。这样就不会形成循环等待。

public class FixedDeadlock {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static class Thread1 implements Runnable {
        @Override
        public void run() {
            // 统一按 resource1 -> resource2 的顺序获取锁
            synchronized (resource1) {
                System.out.println("Thread 1 拿到了 resource1");
                
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (resource2) {
                    System.out.println("Thread 1 拿到了 resource2");
                }
            }
        }
    }

    public static class Thread2 implements Runnable {
        @Override
        public void run() {
            // 也按相同的顺序获取锁
            synchronized (resource1) {
                System.out.println("Thread 2 拿到了 resource1");
                
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                synchronized (resource2) {
                    System.out.println("Thread 2 拿到了 resource2");
                }
            }
        }
    }

    public static void main(String[] args) {
        new Thread(new Thread1()).start();
        new Thread(new Thread2()).start();
    }
}

✅ 两个线程都先获取 resource1,再获取 resource2,不会形成循环等待。

策略二:使用超时机制

Java 提供了 tryLock() 方法(配合 ReentrantLock),可以设置超时时间,避免无限等待。

import java.util.concurrent.locks.ReentrantLock;

public class TimeoutLock {
    private static final ReentrantLock lock1 = new ReentrantLock();
    private static final ReentrantLock lock2 = new ReentrantLock();

    public static class Worker implements Runnable {
        private final String name;
        private final ReentrantLock first;
        private final ReentrantLock second;

        public Worker(String name, ReentrantLock first, ReentrantLock second) {
            this.name = name;
            this.first = first;
            this.second = second;
        }

        @Override
        public void run() {
            // 尝试获取第一个锁,最多等待 2 秒
            if (first.tryLock(2, java.util.concurrent.TimeUnit.SECONDS)) {
                try {
                    System.out.println(name + " 拿到了第一把锁");

                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }

                    // 尝试获取第二把锁
                    if (second.tryLock(2, java.util.concurrent.TimeUnit.SECONDS)) {
                        try {
                            System.out.println(name + " 拿到了第二把锁");
                        } finally {
                            second.unlock();
                        }
                    } else {
                        System.out.println(name + " 获取第二把锁超时,放弃");
                    }
                } finally {
                    first.unlock();
                }
            } else {
                System.out.println(name + " 获取第一把锁超时,放弃");
            }
        }
    }

    public static void main(String[] args) {
        // 线程1:先拿 lock1,再拿 lock2
        new Thread(new Worker("Thread-1", lock1, lock2)).start();
        // 线程2:先拿 lock2,再拿 lock1
        new Thread(new Worker("Thread-2", lock2, lock1)).start();
    }
}

策略三:避免嵌套锁

尽量减少锁的嵌套使用。如果必须使用,确保逻辑清晰,避免在持有锁时执行耗时操作。

策略四:使用工具类检测

在开发阶段,可以引入 ThreadMXBean 监控线程状态。

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;

public class ThreadMonitor {
    public static void checkDeadlock() {
        ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
        long[] deadlockedThreads = threadBean.findDeadlockedThreads();

        if (deadlockedThreads != null && deadlockedThreads.length > 0) {
            System.out.println("检测到死锁!共有 " + deadlockedThreads.length + " 个线程陷入死锁");
            for (long id : deadlockedThreads) {
                System.out.println("死锁线程 ID: " + id);
            }
        } else {
            System.out.println("当前无死锁");
        }
    }

    public static void main(String[] args) {
        // 模拟运行一段时间后检测
        try {
            Thread.sleep(5000);
            checkDeadlock();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

实际开发中的建议

  • 不要在 synchronized 块中调用外部方法,尤其是可能再次获取锁的方法。
  • 锁的粒度要尽量小,避免长时间持有锁。
  • 使用 ReentrantLock 代替 synchronized,可获得更灵活的控制(如超时、公平性等)。
  • 在日志中记录锁的获取与释放,便于排查问题。

总结

死锁是多线程编程中的经典难题,但只要掌握其成因和应对策略,就能有效避免。我们通过一个真实案例展示了死锁的形成过程,学习了如何使用 jstack 检测死锁,并提供了四种实用的解决方案:统一加锁顺序、使用超时机制、避免嵌套锁和引入监控工具。

记住:预防胜于治疗。在设计多线程程序时,从一开始就考虑锁的使用顺序和资源管理,远比事后排查要高效得多。

Java 实例 – 死锁及解决方法,不仅是技术问题,更是一种编程思维的训练。当你能优雅地处理并发问题时,你的代码才真正走向成熟。