JAVA多线程死锁问题判断、定位与三种有效规避策略

JAVA多线程死锁问题判断、定位与三种有效规避策略

大家好,今天我们来聊聊Java多线程中一个常见且棘手的问题:死锁。死锁会导致程序停滞不前,资源无法释放,严重影响系统的可用性。我们将深入探讨死锁的判断、定位以及三种有效的规避策略,希望能帮助大家更好地理解和应对这个问题。

一、 什么是死锁?

死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的僵局,若无外力作用,这些线程都将无法继续执行下去。 简单来说,就是线程A拿着资源1等待资源2,线程B拿着资源2等待资源1,彼此互相等待,导致程序卡死。

死锁产生的四个必要条件(缺一不可):

  1. 互斥条件: 资源必须处于独占模式,即一个资源每次只能被一个线程占用。其他线程想使用该资源,必须等待当前线程释放。
  2. 请求与保持条件: 线程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他线程占用。
  3. 不可剥夺条件: 线程已获得的资源在未使用完之前不能被剥夺,只能由占有该资源的线程自己释放。
  4. 循环等待条件: 发生死锁时,必然存在一个线程-资源的循环等待链,例如线程A等待线程B占用的资源,线程B等待线程C占用的资源,线程C等待线程A占用的资源。

二、 如何判断是否存在死锁?

判断是否存在死锁,主要有以下几种方法:

  1. 人工代码审查: 这是最直接也是最基础的方法。仔细检查代码中涉及多线程、锁和资源的部分,重点关注多个线程访问相同资源时的加锁顺序。尤其要注意嵌套锁的使用,这往往是死锁的根源。这种方法适用于代码量较小、逻辑相对简单的场景。

  2. 线程转储 (Thread Dump): 当程序出现停滞现象时,可以生成线程转储文件。线程转储包含了所有线程的当前状态,包括线程的名称、ID、优先级、堆栈信息、持有的锁以及正在等待的锁。通过分析线程转储文件,可以找出相互等待的线程以及它们所持有的锁,从而确定是否存在死锁。

    • 如何生成Thread Dump:

      • JDK自带工具 jstack: jstack <pid> (其中<pid>是Java进程的进程ID)
      • VisualVM: 一款图形化的JVM监控工具,可以方便地生成线程转储。
      • JConsole: 也是JDK自带的图形化监控工具,可以用来生成线程转储。
      • Linux命令 kill -3 <pid>: 这个命令会向JVM发送一个信号,使其生成线程转储信息到标准输出或日志文件中。
    • Thread Dump 分析示例:

      "Thread-1" #12 prio=5 os_prio=0 tid=0x00007fb168e6a000 nid=0x703 waiting for monitor entry [0x00007fb15df08000]
         java.lang.Thread.State: BLOCKED (on object monitor)
              at com.example.DeadlockExample.methodA(DeadlockExample.java:15)
              - waiting to lock <0x000000076b8d9e48> (a java.lang.String)
              at com.example.DeadlockExample.run(DeadlockExample.java:25)
              at java.lang.Thread.run(Thread.java:748)
      
      "Thread-2" #13 prio=5 os_prio=0 tid=0x00007fb168e6b000 nid=0x704 waiting for monitor entry [0x00007fb15e009000]
         java.lang.Thread.State: BLOCKED (on object monitor)
              at com.example.DeadlockExample.methodB(DeadlockExample.java:20)
              - waiting to lock <0x000000076b8d9e70> (a java.lang.String)
              at com.example.DeadlockExample.run(DeadlockExample.java:30)
              at java.lang.Thread.run(Thread.java:748)
      
         Locked ownable synchronizers:
              - None

      分析:从上面的 Thread Dump 可以看到 Thread-1 正在等待锁 <0x000000076b8d9e48>,而Thread-2 正在等待锁 <0x000000076b8d9e70>。 需要结合代码来确定哪个线程拥有哪个锁,从而判断是否存在循环等待。

  3. 死锁检测工具: 某些IDE或代码分析工具(如FindBugs、SonarQube)可以静态分析代码,检测潜在的死锁风险。这些工具通过识别常见的死锁模式,例如循环依赖、不一致的锁顺序等,帮助开发者在开发阶段发现问题。

  4. JVM监控工具: JDK自带的VisualVM、JConsole等工具,可以实时监控JVM的线程状态,并提供死锁检测功能。这些工具可以自动检测死锁,并提供相关的线程信息,方便开发者定位问题。

三、 死锁定位:一个示例

我们来看一个简单的死锁示例:

public class DeadlockExample implements Runnable {

    private String lock1 = "lock1";
    private String lock2 = "lock2";
    private int threadId;

    public DeadlockExample(int threadId) {
        this.threadId = threadId;
    }

    public void methodA() {
        synchronized (lock1) {
            System.out.println("Thread-" + threadId + ": Holding lock1...");
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread-" + threadId + ": Waiting for lock2...");
            synchronized (lock2) {
                System.out.println("Thread-" + threadId + ": Holding lock1 and lock2...");
            }
        }
    }

    public void methodB() {
        synchronized (lock2) {
            System.out.println("Thread-" + threadId + ": Holding lock2...");
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread-" + threadId + ": Waiting for lock1...");
            synchronized (lock1) {
                System.out.println("Thread-" + threadId + ": Holding lock2 and lock1...");
            }
        }
    }

    @Override
    public void run() {
        if (threadId == 1) {
            methodA();
        } else {
            methodB();
        }
    }

    public static void main(String[] args) {
        DeadlockExample deadlock1 = new DeadlockExample(1);
        DeadlockExample deadlock2 = new DeadlockExample(2);

        Thread thread1 = new Thread(deadlock1);
        Thread thread2 = new Thread(deadlock2);

        thread1.start();
        thread2.start();
    }
}

在这个例子中,Thread-1 先获取 lock1,然后尝试获取 lock2Thread-2 先获取 lock2,然后尝试获取 lock1。由于两个线程获取锁的顺序相反,导致了循环等待,从而发生死锁。

如何定位这个死锁?

  1. 运行程序: 运行上面的代码,会发现程序卡住,没有任何输出。

  2. 生成Thread Dump: 使用 jstack 命令生成线程转储文件: jstack <pid> (假设进程ID是12345,则命令为 jstack 12345)。

  3. 分析Thread Dump: 在生成的线程转储文件中,查找 BLOCKED 状态的线程。你会发现类似下面的信息:

    "Thread-1" #12 prio=5 os_prio=0 tid=0x00007fb168e6a000 nid=0x703 waiting for monitor entry [0x00007fb15df08000]
       java.lang.Thread.State: BLOCKED (on object monitor)
            at com.example.DeadlockExample.methodA(DeadlockExample.java:15)
            - waiting to lock <0x000000076b8d9e48> (a java.lang.String)
            at com.example.DeadlockExample.run(DeadlockExample.java:25)
            at java.lang.Thread.run(Thread.java:748)
    
    "Thread-2" #13 prio=5 os_prio=0 tid=0x00007fb168e6b000 nid=0x704 waiting for monitor entry [0x00007fb15e009000]
       java.lang.Thread.State: BLOCKED (on object monitor)
            at com.example.DeadlockExample.methodB(DeadlockExample.java:20)
            - waiting to lock <0x000000076b8d9e70> (a java.lang.String)
            at com.example.DeadlockExample.run(DeadlockExample.java:30)
            at java.lang.Thread.run(Thread.java:748)
    
       Locked ownable synchronizers:
            - None

    从Thread Dump中可以清晰地看到,Thread-1 正在等待 lock2,而 Thread-2 正在等待 lock1。结合代码,就可以确定发生了死锁。

四、 死锁规避策略

针对死锁的四个必要条件,我们可以采取相应的策略来规避死锁的发生。以下介绍三种有效的规避策略:

  1. 避免循环等待:统一加锁顺序

    这是最常用的也是最有效的死锁规避策略。通过为所有需要访问多个共享资源的线程制定统一的加锁顺序,可以打破循环等待的条件。

    • 实现方式:

      • 使用全局排序: 为所有锁定义一个全局的排序规则(例如,基于锁对象的哈希值),线程必须按照这个顺序获取锁。
      • 使用锁层次结构: 将锁组织成一个层次结构,线程必须按照层次结构从上到下的顺序获取锁。
    • 示例代码(修改上面的死锁示例):

      public class DeadlockAvoidanceExample implements Runnable {
      
          private String lock1 = "lock1";
          private String lock2 = "lock2";
          private int threadId;
      
          public DeadlockAvoidanceExample(int threadId) {
              this.threadId = threadId;
          }
      
          public void methodA() {
              // 统一加锁顺序:先 lock1 后 lock2
              synchronized (lock1) {
                  System.out.println("Thread-" + threadId + ": Holding lock1...");
                  try {
                      Thread.sleep(10);
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
                  System.out.println("Thread-" + threadId + ": Waiting for lock2...");
                  synchronized (lock2) {
                      System.out.println("Thread-" + threadId + ": Holding lock1 and lock2...");
                  }
              }
          }
      
          public void methodB() {
              // 统一加锁顺序:先 lock1 后 lock2
              synchronized (lock1) { // 修改这里,先获取lock1
                  System.out.println("Thread-" + threadId + ": Holding lock1...");
                  try {
                      Thread.sleep(10);
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
                  System.out.println("Thread-" + threadId + ": Waiting for lock2...");
                  synchronized (lock2) {
                      System.out.println("Thread-" + threadId + ": Holding lock1 and lock2...");
                  }
              }
          }
      
          @Override
          public void run() {
              if (threadId == 1) {
                  methodA();
              } else {
                  methodB();
              }
          }
      
          public static void main(String[] args) {
              DeadlockAvoidanceExample deadlock1 = new DeadlockAvoidanceExample(1);
              DeadlockAvoidanceExample deadlock2 = new DeadlockAvoidanceExample(2);
      
              Thread thread1 = new Thread(deadlock1);
              Thread thread2 = new Thread(deadlock2);
      
              thread1.start();
              thread2.start();
          }
      }

      在修改后的代码中,methodB 也按照先获取 lock1 后获取 lock2 的顺序加锁,从而避免了循环等待,消除了死锁的风险。

    • 优点: 简单易懂,容易实现。

    • 缺点: 需要对所有锁进行全局管理,当锁的数量很多时,维护成本较高。可能会降低程序的并发性,因为线程必须按照固定的顺序获取锁,即使某些情况下可以并行获取锁。

  2. 避免请求与保持:一次性获取所有资源

    线程在执行任务前,一次性获取所有需要的资源,避免在持有部分资源的情况下请求新的资源。

    • 实现方式:

      • 使用 tryLock() 方法: ReentrantLock 提供了 tryLock() 方法,可以尝试获取锁,如果获取失败,立即返回,避免阻塞。线程可以循环尝试获取所有需要的锁,直到全部获取成功。
      • 使用 Lock 接口的 lock() 方法,但结合超时机制: 如果一段时间内无法获取所有需要的锁,则释放已经获取的锁,并稍后重试。
    • 示例代码:

      import java.util.concurrent.locks.Lock;
      import java.util.concurrent.locks.ReentrantLock;
      import java.util.concurrent.TimeUnit;
      
      public class AvoidRequestAndHoldExample implements Runnable {
      
          private Lock lock1 = new ReentrantLock();
          private Lock lock2 = new ReentrantLock();
          private int threadId;
      
          public AvoidRequestAndHoldExample(int threadId) {
              this.threadId = threadId;
          }
      
          public void methodA() {
              try {
                  if (lock1.tryLock(1, TimeUnit.SECONDS) && lock2.tryLock(1, TimeUnit.SECONDS)) {
                      try {
                          System.out.println("Thread-" + threadId + ": Holding lock1 and lock2...");
                          // 执行业务逻辑
                          Thread.sleep(100);
                      } finally {
                          lock2.unlock();
                          lock1.unlock();
                      }
                  } else {
                      System.out.println("Thread-" + threadId + ": Failed to acquire both locks, retrying...");
                      // 稍后重试
                      Thread.sleep(100);
                      methodA(); // 递归调用,或者使用循环
                  }
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
          }
      
          public void methodB() {
              try {
                  if (lock1.tryLock(1, TimeUnit.SECONDS) && lock2.tryLock(1, TimeUnit.SECONDS)) {
                      try {
                          System.out.println("Thread-" + threadId + ": Holding lock1 and lock2...");
                          // 执行业务逻辑
                          Thread.sleep(100);
                      } finally {
                          lock2.unlock();
                          lock1.unlock();
                      }
                  } else {
                      System.out.println("Thread-" + threadId + ": Failed to acquire both locks, retrying...");
                      // 稍后重试
                      Thread.sleep(100);
                      methodB(); // 递归调用,或者使用循环
                  }
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
          }
      
          @Override
          public void run() {
              if (threadId == 1) {
                  methodA();
              } else {
                  methodB();
              }
          }
      
          public static void main(String[] args) {
              AvoidRequestAndHoldExample deadlock1 = new AvoidRequestAndHoldExample(1);
              AvoidRequestAndHoldExample deadlock2 = new AvoidRequestAndHoldExample(2);
      
              Thread thread1 = new Thread(deadlock1);
              Thread thread2 = new Thread(deadlock2);
      
              thread1.start();
              thread2.start();
          }
      }

      在这个例子中,线程尝试同时获取 lock1lock2,如果任何一个锁获取失败,则释放所有已获取的锁,并稍后重试。这样可以避免线程在持有部分资源的情况下请求新的资源,从而避免死锁。

    • 优点: 可以提高程序的并发性,因为线程可以并行尝试获取锁。

    • 缺点: 实现较为复杂,需要处理锁获取失败的情况。可能会导致线程饥饿,因为线程可能一直无法获取所有需要的锁。

  3. 避免不可剥夺:使用可中断锁

    允许线程在等待锁的过程中被中断,从而释放已经持有的资源,避免死锁。

    • 实现方式:

      • 使用 ReentrantLocklockInterruptibly() 方法: 该方法允许线程在等待锁的过程中响应中断信号。当线程被中断时,会抛出 InterruptedException 异常,线程可以捕获该异常并释放已经持有的资源。
    • 示例代码:

      import java.util.concurrent.locks.Lock;
      import java.util.concurrent.locks.ReentrantLock;
      
      public class AvoidNonPreemptionExample implements Runnable {
      
          private Lock lock1 = new ReentrantLock();
          private Lock lock2 = new ReentrantLock();
          private int threadId;
      
          public AvoidNonPreemptionExample(int threadId) {
              this.threadId = threadId;
          }
      
          public void methodA() {
              try {
                  lock1.lockInterruptibly();
                  try {
                      System.out.println("Thread-" + threadId + ": Holding lock1...");
                      Thread.sleep(10);
                      System.out.println("Thread-" + threadId + ": Waiting for lock2...");
                      lock2.lockInterruptibly();
                      try {
                          System.out.println("Thread-" + threadId + ": Holding lock1 and lock2...");
                          // 执行业务逻辑
                      } finally {
                          lock2.unlock();
                      }
                  } finally {
                      lock1.unlock();
                  }
              } catch (InterruptedException e) {
                  System.out.println("Thread-" + threadId + ": Interrupted while waiting for lock, releasing resources...");
                  // 处理中断,释放已持有的资源
              }
          }
      
          public void methodB() {
              try {
                  lock2.lockInterruptibly();
                  try {
                      System.out.println("Thread-" + threadId + ": Holding lock2...");
                      Thread.sleep(10);
                      System.out.println("Thread-" + threadId + ": Waiting for lock1...");
                      lock1.lockInterruptibly();
                      try {
                          System.out.println("Thread-" + threadId + ": Holding lock2 and lock1...");
                          // 执行业务逻辑
                      } finally {
                          lock1.unlock();
                      }
                  } finally {
                      lock2.unlock();
                  }
              } catch (InterruptedException e) {
                  System.out.println("Thread-" + threadId + ": Interrupted while waiting for lock, releasing resources...");
                  // 处理中断,释放已持有的资源
              }
          }
      
          @Override
          public void run() {
              if (threadId == 1) {
                  methodA();
              } else {
                  methodB();
              }
          }
      
          public static void main(String[] args) throws InterruptedException {
              AvoidNonPreemptionExample deadlock1 = new AvoidNonPreemptionExample(1);
              AvoidNonPreemptionExample deadlock2 = new AvoidNonPreemptionExample(2);
      
              Thread thread1 = new Thread(deadlock1);
              Thread thread2 = new Thread(deadlock2);
      
              thread1.start();
              thread2.start();
      
              // 主线程在一段时间后中断其中一个线程,模拟外部干预
              Thread.sleep(100);
              thread1.interrupt();
          }
      }

      在这个例子中,线程使用 lockInterruptibly() 方法获取锁。如果线程在等待锁的过程中被中断,会抛出 InterruptedException 异常。线程可以在 catch 块中释放已经持有的资源,从而避免死锁。

    • 优点: 可以避免线程长时间阻塞,提高程序的响应性。

    • 缺点: 需要处理中断信号,代码较为复杂。

五、 如何选择规避策略

选择哪种死锁规避策略,取决于具体的应用场景和需求。

策略 适用场景 优点 缺点
统一加锁顺序 锁的数量较少,且容易确定全局排序规则的场景。 简单易懂,容易实现。 需要对所有锁进行全局管理,维护成本较高。可能会降低程序的并发性。
一次性获取所有资源 线程需要同时访问多个资源,且可以容忍锁获取失败的情况。 可以提高程序的并发性。 实现较为复杂,需要处理锁获取失败的情况。可能会导致线程饥饿。
使用可中断锁 需要保证程序的响应性,且可以容忍线程被中断的情况。 可以避免线程长时间阻塞,提高程序的响应性。 需要处理中断信号,代码较为复杂。

六、 总结

死锁是多线程编程中一个需要认真对待的问题。通过理解死锁的产生条件,掌握死锁的判断和定位方法,以及灵活运用各种死锁规避策略,我们可以有效地避免死锁的发生,提高程序的稳定性和可靠性。

关键在于:

  • 理解死锁的四个必要条件。
  • 熟练使用线程转储工具进行死锁定位。
  • 根据实际情况选择合适的死锁规避策略。

希望今天的分享对大家有所帮助。谢谢!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注