MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java多线程死锁问题的成因与解决

2021-05-081.1k 阅读

Java多线程死锁问题的成因

在Java多线程编程中,死锁是一个严重的问题,它会导致程序陷入无法继续执行的僵局。死锁的产生通常是由于多个线程相互等待对方释放资源,而又都不愿意主动放弃自己已经持有的资源,从而形成一种无限循环的等待状态。

资源竞争与锁的使用

Java中的多线程通过锁机制来保证对共享资源的安全访问。当一个线程访问共享资源时,它会获取对应的锁,在操作完成后释放锁,以便其他线程可以获取并访问该资源。然而,如果多个线程同时竞争多个资源,并且获取锁的顺序不当,就可能导致死锁。

例如,假设有两个线程 ThreadAThreadB,以及两个共享资源 Resource1Resource2ThreadA 先获取 Resource1 的锁,然后试图获取 Resource2 的锁;而 ThreadB 先获取 Resource2 的锁,然后试图获取 Resource1 的锁。如果这两个操作同时进行,就会出现 ThreadA 等待 ThreadB 释放 Resource2,而 ThreadB 等待 ThreadA 释放 Resource1 的情况,从而导致死锁。

代码示例一:简单的死锁场景

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

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("ThreadA has locked resource1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource2) {
                    System.out.println("ThreadA has locked resource2");
                }
            }
        });

        Thread threadB = new Thread(() -> {
            synchronized (resource2) {
                System.out.println("ThreadB has locked resource2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource1) {
                    System.out.println("ThreadB has locked resource1");
                }
            }
        });

        threadA.start();
        threadB.start();
    }
}

在上述代码中,threadA 先获取 resource1 的锁,然后尝试获取 resource2 的锁;threadB 则相反,先获取 resource2 的锁,再尝试获取 resource1 的锁。当两个线程同时运行时,就很可能会发生死锁。

死锁产生的四个必要条件

  1. 互斥条件:资源在同一时间只能被一个线程占用。在Java中,锁机制就满足了互斥条件,例如 synchronized 关键字修饰的代码块,同一时间只有一个线程可以进入。
  2. 占有并等待条件:一个线程持有至少一个资源,并在等待获取其他线程持有的资源。在上面的例子中,threadA 持有 resource1 并等待 resource2threadB 持有 resource2 并等待 resource1,满足此条件。
  3. 不可剥夺条件:资源只能由持有它的线程主动释放,其他线程不能强行剥夺。在Java中,锁一旦被某个线程获取,其他线程只能等待该线程释放锁,不能强制获取,符合不可剥夺条件。
  4. 循环等待条件:存在一个线程集合 {T1, T2, ..., Tn},其中 T1 等待 T2 持有的资源,T2 等待 T3 持有的资源,以此类推,Tn 等待 T1 持有的资源。在我们的示例中,threadAthreadB 形成了一个简单的循环等待关系。

死锁问题的检测

在实际开发中,及时发现死锁对于保证程序的稳定性至关重要。虽然在开发阶段通过仔细的代码审查和设计可以尽量避免死锁,但运行时的死锁检测仍然是必要的。

使用 jstack 命令

jstack 是JDK自带的一个工具,用于生成Java虚拟机当前时刻的线程快照。通过分析线程快照,可以找出死锁相关的信息。

  1. 获取Java进程ID:在Linux或Mac系统中,可以使用 ps -ef | grep java 命令找到Java进程的ID。在Windows系统中,可以通过任务管理器查看Java进程的PID。
  2. 生成线程快照:使用 jstack <pid> 命令生成线程快照,其中 <pid> 是上一步获取的Java进程ID。
  3. 分析线程快照:在生成的线程快照中,查找包含 Deadlock 关键字的部分,通常会列出死锁相关的线程信息。

例如,对于前面的 DeadlockExample 程序,运行后使用 jstack 命令生成线程快照,会看到类似如下的死锁信息:

Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x00000007160064f8 (object 0x00000007d5b80a78, a java.lang.Object),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x0000000716006608 (object 0x00000007d5b80a88, a java.lang.Object),
  which is held by "Thread-1"

Java stack information for the threads listed above:
===================================================
"Thread-1":
        at DeadlockExample.lambda$main$1(DeadlockExample.java:21)
        - waiting to lock <0x00000007d5b80a78> (a java.lang.Object)
        - locked <0x00000007d5b80a88> (a java.lang.Object)
        at DeadlockExample$$Lambda$2/1449273044.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:748)
"Thread-0":
        at DeadlockExample.lambda$main$0(DeadlockExample.java:12)
        - waiting to lock <0x00000007d5b80a88> (a java.lang.Object)
        - locked <0x00000007d5b80a78> (a java.lang.Object)
        at DeadlockExample$$Lambda$1/1162367740.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:748)

Found 1 deadlock.

从上述信息中,可以清晰地看到死锁涉及的线程 Thread-0Thread-1,以及它们等待和持有的锁对象。

使用 ThreadMXBean

Java提供了 ThreadMXBean 接口,可以在程序中动态检测死锁。ThreadMXBean 可以获取线程的各种信息,包括死锁检测。

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

public class DeadlockDetector {
    public static void main(String[] args) {
        ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
        while (true) {
            long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
            if (deadlockedThreads != null) {
                System.out.println("Deadlock detected!");
                for (long threadId : deadlockedThreads) {
                    ThreadInfo threadInfo = threadMXBean.getThreadInfo(threadId);
                    System.out.println("Deadlocked thread: " + threadInfo.getThreadName());
                }
                break;
            }
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

上述代码通过 ThreadMXBeanfindDeadlockedThreads 方法不断检测是否存在死锁。如果检测到死锁,会打印出死锁线程的名称。将这个检测逻辑添加到实际应用中,可以实时监控程序是否发生死锁。

Java多线程死锁问题的解决

既然死锁会对程序造成严重影响,那么如何解决死锁问题就成为了关键。下面将介绍几种常见的解决死锁的方法。

破坏死锁的四个必要条件

  1. 破坏互斥条件:在某些情况下,可以通过使用无锁数据结构来避免互斥锁的使用。例如,Java中的 ConcurrentHashMap 采用了分段锁的机制,允许多个线程同时对不同段进行操作,在一定程度上减少了锁的竞争,避免了传统 HashMap 中对整个数据结构加锁的情况。然而,完全破坏互斥条件在很多场景下是不可行的,因为共享资源往往需要保证数据的一致性,而互斥访问是保证一致性的重要手段。
  2. 破坏占有并等待条件:可以要求线程在启动时一次性获取所有需要的资源,而不是逐步获取。这样可以避免线程在持有部分资源的情况下等待其他资源,从而破坏占有并等待条件。例如,在一个需要访问数据库连接和文件资源的多线程应用中,线程可以在开始时获取数据库连接和文件资源的锁,而不是先获取数据库连接锁,再等待文件资源锁。
public class NoDeadlockExample1 {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            boolean success = false;
            while (!success) {
                synchronized (resource1) {
                    synchronized (resource2) {
                        success = true;
                        System.out.println("ThreadA has locked both resources");
                    }
                }
            }
        });

        Thread threadB = new Thread(() -> {
            boolean success = false;
            while (!success) {
                synchronized (resource1) {
                    synchronized (resource2) {
                        success = true;
                        System.out.println("ThreadB has locked both resources");
                    }
                }
            }
        });

        threadA.start();
        threadB.start();
    }
}

在上述代码中,threadAthreadB 都尝试一次性获取 resource1resource2 的锁。如果获取失败,会通过循环不断尝试,直到成功获取两个锁。这种方式避免了线程在持有一个锁的情况下等待另一个锁,从而避免了死锁。

  1. 破坏不可剥夺条件:在某些特殊场景下,可以允许线程在一定条件下剥夺其他线程的资源。例如,使用 Lock 接口中的 tryLock 方法,线程可以尝试获取锁,如果获取失败,可以选择放弃当前已经持有的锁,并重新尝试获取所有需要的锁。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

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

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            boolean success = false;
            while (!success) {
                if (lock1.tryLock()) {
                    try {
                        if (lock2.tryLock()) {
                            try {
                                success = true;
                                System.out.println("ThreadA has locked both resources");
                            } finally {
                                lock2.unlock();
                            }
                        }
                    } finally {
                        lock1.unlock();
                    }
                }
            }
        });

        Thread threadB = new Thread(() -> {
            boolean success = false;
            while (!success) {
                if (lock2.tryLock()) {
                    try {
                        if (lock1.tryLock()) {
                            try {
                                success = true;
                                System.out.println("ThreadB has locked both resources");
                            } finally {
                                lock1.unlock();
                            }
                        }
                    } finally {
                        lock2.unlock();
                    }
                }
            }
        });

        threadA.start();
        threadB.start();
    }
}

在这个示例中,threadAthreadB 使用 tryLock 方法尝试获取锁。如果获取失败,会释放已经获取的锁,然后重新尝试获取所有锁,从而破坏了不可剥夺条件,避免死锁。

  1. 破坏循环等待条件:通过对资源进行排序,并规定所有线程按照相同的顺序获取资源,可以破坏循环等待条件。例如,假设有多个共享资源 Resource1Resource2Resource3,可以为它们分配一个唯一的标识符,并按照标识符从小到大的顺序获取资源。
public class NoDeadlockExample3 {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("ThreadA has locked resource1");
                synchronized (resource2) {
                    System.out.println("ThreadA has locked resource2");
                }
            }
        });

        Thread threadB = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("ThreadB has locked resource1");
                synchronized (resource2) {
                    System.out.println("ThreadB has locked resource2");
                }
            }
        });

        threadA.start();
        threadB.start();
    }
}

在上述代码中,threadAthreadB 都按照 resource1 -> resource2 的顺序获取资源,避免了循环等待,从而防止死锁的发生。

合理使用锁的粒度

锁的粒度是指锁所保护的资源范围。如果锁的粒度过大,会导致很多线程因为等待锁而被阻塞,降低系统的并发性能;如果锁的粒度过小,又会增加锁的开销。在设计多线程程序时,需要合理调整锁的粒度,以避免死锁并提高性能。

例如,在一个银行转账的场景中,如果对整个银行账户对象加锁,那么在进行转账操作时,所有涉及该账户的操作都需要等待锁,并发性能较低。可以将锁的粒度细化到每个账户的余额字段,这样不同账户之间的操作可以并发进行,减少锁的竞争,降低死锁的可能性。

public class BankAccount {
    private double balance;
    private final Object balanceLock = new Object();

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    public void deposit(double amount) {
        synchronized (balanceLock) {
            balance += amount;
        }
    }

    public void withdraw(double amount) {
        synchronized (balanceLock) {
            if (balance >= amount) {
                balance -= amount;
            }
        }
    }

    public double getBalance() {
        synchronized (balanceLock) {
            return balance;
        }
    }
}

在上述代码中,BankAccount 类通过对 balanceLock 锁的使用,将锁的粒度控制在对 balance 字段的操作上,而不是对整个 BankAccount 对象加锁,提高了并发性能,同时减少了死锁的风险。

使用超时机制

在获取锁时设置一个超时时间,如果在超时时间内未能获取到锁,则放弃当前操作并释放已经获取的锁。这样可以避免线程无限期等待锁,从而防止死锁。

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

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

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            try {
                if (lock1.tryLock(5, TimeUnit.SECONDS)) {
                    try {
                        if (lock2.tryLock(5, TimeUnit.SECONDS)) {
                            try {
                                System.out.println("ThreadA has locked both resources");
                            } finally {
                                lock2.unlock();
                            }
                        } else {
                            System.out.println("ThreadA failed to lock lock2 within timeout");
                        }
                    } finally {
                        lock1.unlock();
                    }
                } else {
                    System.out.println("ThreadA failed to lock lock1 within timeout");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        Thread threadB = new Thread(() -> {
            try {
                if (lock2.tryLock(5, TimeUnit.SECONDS)) {
                    try {
                        if (lock1.tryLock(5, TimeUnit.SECONDS)) {
                            try {
                                System.out.println("ThreadB has locked both resources");
                            } finally {
                                lock1.unlock();
                            }
                        } else {
                            System.out.println("ThreadB failed to lock lock1 within timeout");
                        }
                    } finally {
                        lock2.unlock();
                    }
                } else {
                    System.out.println("ThreadB failed to lock lock2 within timeout");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        threadA.start();
        threadB.start();
    }
}

在上述代码中,threadAthreadB 使用 tryLock 方法并设置了5秒的超时时间。如果在5秒内未能获取到锁,线程会打印提示信息并放弃操作,避免了无限期等待导致的死锁。

使用 ThreadLocal

ThreadLocal 可以为每个线程创建一个独立的变量副本,每个线程对该变量的操作都不会影响其他线程。这样可以避免多个线程对共享资源的竞争,从而减少死锁的可能性。

例如,在一个多线程的日志记录场景中,如果多个线程共享一个日志文件对象,可能会因为对日志文件的读写操作而导致死锁。使用 ThreadLocal 可以为每个线程创建一个独立的日志缓冲区,每个线程先将日志写入自己的缓冲区,然后在适当的时候将缓冲区的内容写入日志文件。

public class ThreadLocalExample {
    private static final ThreadLocal<StringBuilder> threadLocalLog = ThreadLocal.withInitial(() -> new StringBuilder());

    public static void main(String[] args) {
        Thread threadA = new Thread(() -> {
            threadLocalLog.get().append("ThreadA log message 1\n");
            threadLocalLog.get().append("ThreadA log message 2\n");
            System.out.println("ThreadA log:\n" + threadLocalLog.get().toString());
        });

        Thread threadB = new Thread(() -> {
            threadLocalLog.get().append("ThreadB log message 1\n");
            threadLocalLog.get().append("ThreadB log message 2\n");
            System.out.println("ThreadB log:\n" + threadLocalLog.get().toString());
        });

        threadA.start();
        threadB.start();
    }
}

在上述代码中,threadLocalLog 为每个线程提供了一个独立的 StringBuilder 对象,避免了多个线程对共享日志资源的竞争,从而减少了死锁的风险。

通过对死锁成因的深入理解,以及合理运用上述解决方法,可以有效地避免Java多线程编程中的死锁问题,提高程序的稳定性和并发性能。在实际开发中,需要根据具体的业务场景和需求,选择合适的方法来预防和解决死锁。同时,要养成良好的编程习惯,在设计多线程程序时充分考虑死锁的可能性,从根源上减少死锁的发生。