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

Java并发编程中的死锁问题

2022-05-167.4k 阅读

一、死锁的概念

在多线程编程领域,死锁是一种非常棘手且难以调试的情况。简单来说,死锁指的是两个或多个线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,这些线程都将无法推进下去。

想象这样一个场景,有两个线程 A 和 B,线程 A 持有资源 R1 并试图获取资源 R2,而线程 B 持有资源 R2 并试图获取资源 R1。此时,两个线程都在等待对方释放自己所需的资源,从而陷入了无限期的等待,这就是典型的死锁场景。

从本质上讲,死锁的产生是由于并发编程中线程对资源的竞争和不当的同步机制导致的。在 Java 并发编程环境下,死锁问题尤为突出,因为 Java 广泛应用于多线程、高并发的场景,如 Web 服务器、分布式系统等。

二、死锁产生的必要条件

  1. 互斥条件:资源在同一时刻只能被一个线程所占有。例如,一个文件在某一时刻只能被一个线程打开进行写入操作,其他线程若要写入就必须等待该线程释放对文件的占有。
  2. 占有并等待条件:线程已经持有了至少一个资源,但又提出了新的资源请求,而该资源被其他线程占有,此时请求线程会等待,但又不会释放自己已经持有的资源。
  3. 不可剥夺条件:线程所获得的资源在未使用完毕之前,不能被其他线程强行剥夺,只有在使用完后由自己释放。
  4. 循环等待条件:存在一个线程链,链中的每一个线程都在等待下一个线程所持有的资源,最后一个线程又等待第一个线程所持有的资源,从而形成一个循环等待的关系。

这四个条件必须同时满足,死锁才会发生。因此,要预防和解决死锁问题,就需要从破坏这四个条件入手。

三、Java 中死锁的示例代码

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

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread 1: Holding resource 1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Thread 1: Waiting for resource 2");
                synchronized (resource2) {
                    System.out.println("Thread 1: Holding resource 1 and resource 2");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (resource2) {
                System.out.println("Thread 2: Holding resource 2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Thread 2: Waiting for resource 1");
                synchronized (resource1) {
                    System.out.println("Thread 2: Holding resource 1 and resource 2");
                }
            }
        });

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

在上述代码中,我们创建了两个线程 thread1thread2,以及两个共享资源 resource1resource2thread1 首先获取 resource1 的锁,然后尝试获取 resource2 的锁;thread2 则首先获取 resource2 的锁,然后尝试获取 resource1 的锁。由于两个线程互相等待对方释放自己所需的锁,从而导致死锁。

当运行这段代码时,你会看到如下输出:

Thread 1: Holding resource 1
Thread 2: Holding resource 2
Thread 1: Waiting for resource 2
Thread 2: Waiting for resource 1

之后程序就会陷入死锁状态,不再有新的输出。

四、死锁的检测与定位

  1. 使用 jstack 工具:在 Java 开发中,jstack 是一个非常实用的工具,它用于生成 Java 虚拟机当前时刻的线程快照。线程快照是当前 Java 虚拟机内每一条线程正在执行的方法堆栈的集合,通过使用 jstack 工具,我们可以定位到死锁的线程。 假设我们运行上述死锁示例代码后,首先需要找到该 Java 进程的进程 ID。在 Linux 系统下,可以使用 ps -ef | grep java 命令来查找;在 Windows 系统下,可以通过任务管理器找到对应的 Java 进程 ID。 获取到进程 ID 后,在命令行中执行 jstack <pid>,其中 <pid> 就是刚才获取到的进程 ID。执行该命令后,jstack 工具会输出当前 Java 进程中所有线程的堆栈信息。在输出结果中,会有一段专门标识死锁的信息,如下所示:
Found one Java-level deadlock:
=============================
"Thread-1":
  waiting to lock monitor 0x000000076ab0c5d8 (object 0x00000007d5501298, a java.lang.Object),
  which is held by "Thread-0"
"Thread-0":
  waiting to lock monitor 0x000000076ab0c6c8 (object 0x00000007d55012a8, a java.lang.Object),
  which is held by "Thread-1"

Java stack information for the threads listed above:
===================================================
"Thread-1":
        at DeadlockExample$2.run(DeadlockExample.java:23)
        - waiting to lock <0x00000007d5501298> (a java.lang.Object)
        - locked <0x00000007d55012a8> (a java.lang.Object)
        at java.lang.Thread.run(Thread.java:748)
"Thread-0":
        at DeadlockExample$1.run(DeadlockExample.java:13)
        - waiting to lock <0x00000007d55012a8> (a java.lang.Object)
        - locked <0x00000007d5501298> (a java.lang.Object)
        at java.lang.Thread.run(Thread.java:748)

Found 1 deadlock.

从上述输出中,我们可以清晰地看到死锁的两个线程 Thread-0Thread-1,以及它们等待和持有的锁信息,从而方便我们定位死锁问题。 2. 使用 VisualVM:VisualVM 是一款功能强大的 Java 性能分析工具,它可以对 Java 应用程序进行全面的监控和分析,包括死锁检测。 首先,启动 VisualVM 工具,然后在左侧的应用程序列表中找到正在运行的死锁示例程序。右键点击该程序,选择 “线程” 标签。在 “线程” 页面中,如果存在死锁,VisualVM 会自动检测并在底部的 “死锁” 区域显示死锁信息,同时会突出显示发生死锁的线程。通过 VisualVM,我们可以直观地看到死锁线程的状态、堆栈信息等,为调试死锁问题提供了很大的便利。

五、死锁的预防与解决

  1. 破坏互斥条件:在大多数情况下,资源的互斥特性是无法改变的,因为很多资源本身就具有排他性,如打印机等设备资源。但在某些场景下,可以通过资源的设计和管理来尽量减少互斥资源的使用。例如,在一些数据访问场景中,可以使用无锁数据结构(如 ConcurrentHashMap)来替代传统的互斥锁保护的数据结构,从而避免因资源互斥导致的死锁。
  2. 破坏占有并等待条件:一种有效的方法是在启动线程前,让线程一次性获取它所需要的所有资源。例如,我们对前面的死锁示例代码进行修改:
public class NoDeadlockExample {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        // 定义一个方法来获取两个资源
        Runnable acquireResources = () -> {
            synchronized (resource1) {
                synchronized (resource2) {
                    System.out.println("Thread: Holding resource 1 and resource 2");
                }
            }
        };

        Thread thread1 = new Thread(acquireResources);
        Thread thread2 = new Thread(acquireResources);

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

在上述代码中,通过将获取两个资源的操作放在同一个 Runnable 中,确保线程在获取到第一个资源后,紧接着获取第二个资源,从而避免了占有并等待的情况,也就不会发生死锁。 3. 破坏不可剥夺条件:在 Java 中,可以通过设置线程的中断机制来实现资源的可剥夺。当一个线程长时间持有资源且不释放,而其他线程又急需该资源时,可以通过中断该线程,让它释放已持有的资源。例如:

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

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            while (true) {
                synchronized (resource1) {
                    System.out.println("Thread 1: Holding resource 1");
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        System.out.println("Thread 1: Interrupted, releasing resource 1");
                        return;
                    }
                    synchronized (resource2) {
                        System.out.println("Thread 1: Holding resource 1 and resource 2");
                    }
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            thread1.interrupt(); // 中断 thread1
            synchronized (resource2) {
                System.out.println("Thread 2: Holding resource 2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource1) {
                    System.out.println("Thread 2: Holding resource 1 and resource 2");
                }
            }
        });

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

在上述代码中,thread2 启动后立即中断 thread1thread1 在捕获到中断异常后,释放 resource1,从而避免了死锁。但这种方法需要谨慎使用,因为中断线程可能会导致一些未完成的操作被强制终止,需要根据具体业务场景进行合理设计。 4. 破坏循环等待条件:可以通过对资源进行排序,然后规定所有线程按照相同的顺序获取资源。例如,我们还是以之前的两个资源 resource1resource2 为例,定义一个资源排序规则:

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

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread 1: Holding resource 1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource2) {
                    System.out.println("Thread 1: Holding resource 1 and resource 2");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread 2: Holding resource 1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource2) {
                    System.out.println("Thread 2: Holding resource 1 and resource 2");
                }
            }
        });

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

在上述代码中,两个线程都按照先获取 resource1,再获取 resource2 的顺序获取资源,从而避免了循环等待,也就不会发生死锁。

六、死锁与活锁、饥饿的区别

  1. 活锁:活锁也是一种线程间的异常情况,但与死锁不同的是,处于活锁状态的线程并非完全阻塞,它们一直在尝试执行操作,但由于某些条件始终无法满足,导致线程不断地重试而无法取得进展。 例如,在一个两人相向而行的场景中,两人都试图给对方让路,但每次都同时向同一侧移动,结果就会导致两人一直在移动,但始终无法通过。在编程中,假设有两个线程 ABA 检测到 B 正在执行某个操作,于是 A 等待 B 完成后再执行;而 B 检测到 A 正在等待,于是 B 也等待 A 先执行,这样两个线程就会不断地互相等待并尝试重新执行,陷入活锁状态。
  2. 饥饿:饥饿是指一个线程由于优先级较低,或者被其他高优先级线程持续占用资源,导致该线程长期无法获得执行机会。例如,在一个多线程系统中,有一个低优先级的线程需要访问某个资源,但高优先级的线程不断地抢占该资源,使得低优先级线程一直处于等待状态,无法执行其任务。与死锁和活锁不同,饥饿的线程并非处于等待其他线程释放资源的状态,而是因为自身优先级问题无法获得资源。

七、并发框架中的死锁问题

  1. ExecutorService 中的死锁:在使用 ExecutorService 执行任务时,如果任务之间存在循环依赖,就可能会导致死锁。例如,假设我们有两个任务 TaskATaskBTaskA 依赖 TaskB 的执行结果,而 TaskB 又依赖 TaskA 的执行结果,并且将这两个任务提交到同一个 ExecutorService 中执行,就可能会发生死锁。
import java.util.concurrent.*;

public class ExecutorServiceDeadlock {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        Future<Integer> futureA = executorService.submit(() -> {
            try {
                Future<Integer> futureB = executorService.submit(() -> {
                    // TaskB 的逻辑
                    return 2;
                });
                return futureB.get() + 1;
            } catch (InterruptedException | ExecutionException e) {
                e.printStackTrace();
                return -1;
            }
        });

        try {
            System.out.println(futureA.get());
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        } finally {
            executorService.shutdown();
        }
    }
}

在上述代码中,TaskA 提交了 TaskB 并等待其结果,而 TaskB 如果也以类似方式依赖 TaskA,就会形成循环依赖,导致死锁。 2. BlockingQueue 中的死锁BlockingQueue 是 Java 并发包中常用的阻塞队列,用于线程间的安全通信。如果在使用 BlockingQueue 时,生产者和消费者之间的逻辑设计不当,也可能会导致死锁。 例如,假设有一个生产者线程不断向 BlockingQueue 中添加元素,当队列满时,生产者线程会阻塞等待队列有空闲空间。同时,有一个消费者线程从队列中取出元素,但如果消费者线程在处理取出的元素时出现异常,导致无法继续从队列中取元素,而生产者线程又一直等待队列有空闲空间,就会形成死锁。

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class BlockingQueueDeadlock {
    private static final BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(10);

    public static void main(String[] args) {
        Thread producer = new Thread(() -> {
            int i = 0;
            while (true) {
                try {
                    queue.put(i++);
                    System.out.println("Produced: " + (i - 1));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread consumer = new Thread(() -> {
            while (true) {
                try {
                    Integer num = queue.take();
                    // 模拟处理元素时出现异常
                    if (num % 2 == 0) {
                        throw new RuntimeException("Simulated exception");
                    }
                    System.out.println("Consumed: " + num);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        producer.start();
        consumer.start();
    }
}

在上述代码中,当消费者线程处理到偶数元素时抛出异常,导致无法继续从队列中取元素,而生产者线程会一直等待队列有空闲空间,从而形成死锁。

八、死锁问题的优化与最佳实践

  1. 尽量减少锁的使用范围:在编写多线程代码时,应尽量将锁的作用范围限制在最小的必要区域内。这样可以减少线程持有锁的时间,降低死锁发生的概率。例如,在对一个大对象的部分数据进行操作时,不要对整个对象加锁,而是只对需要操作的部分数据加锁。
  2. 使用定时锁:Java 中的 ReentrantLock 提供了带超时的锁获取方法,如 tryLock(long timeout, TimeUnit unit)。通过设置合适的超时时间,如果在规定时间内无法获取到锁,线程可以选择放弃获取锁并执行其他操作,从而避免死锁。
import java.util.concurrent.locks.ReentrantLock;

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

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            if (lock1.tryLock()) {
                try {
                    System.out.println("Thread 1: Holding lock 1");
                    if (lock2.tryLock(1, java.util.concurrent.TimeUnit.SECONDS)) {
                        try {
                            System.out.println("Thread 1: Holding lock 1 and lock 2");
                        } finally {
                            lock2.unlock();
                        }
                    } else {
                        System.out.println("Thread 1: Could not acquire lock 2 within 1 second");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock1.unlock();
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            if (lock2.tryLock()) {
                try {
                    System.out.println("Thread 2: Holding lock 2");
                    if (lock1.tryLock(1, java.util.concurrent.TimeUnit.SECONDS)) {
                        try {
                            System.out.println("Thread 2: Holding lock 1 and lock 2");
                        } finally {
                            lock1.unlock();
                        }
                    } else {
                        System.out.println("Thread 2: Could not acquire lock 1 within 1 second");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock2.unlock();
                }
            }
        });

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

在上述代码中,thread1thread2 在获取第二个锁时都设置了 1 秒的超时时间,如果在 1 秒内无法获取到锁,线程会打印提示信息并放弃获取锁,从而避免死锁。 3. 使用线程安全的集合类:Java 并发包提供了许多线程安全的集合类,如 ConcurrentHashMapCopyOnWriteArrayList 等。这些集合类内部已经对并发访问进行了优化,使用它们可以减少手动加锁带来的死锁风险。例如,在多线程环境下需要使用哈希表时,优先选择 ConcurrentHashMap 而不是传统的 Hashtable 或手动加锁保护的 HashMap。 4. 进行代码审查:在团队开发中,定期进行代码审查是发现潜在死锁问题的有效方法。通过审查代码,可以发现线程对资源的竞争关系、锁的使用方式等是否合理,及时发现并修正可能导致死锁的代码逻辑。

在 Java 并发编程中,死锁是一个复杂且难以调试的问题,但通过深入理解死锁的概念、产生条件,掌握死锁的检测、预防和解决方法,以及遵循最佳实践原则,我们可以有效地避免死锁问题,编写出健壮、高效的多线程程序。在实际开发中,还需要不断积累经验,根据具体的业务场景和需求,灵活运用各种技术手段来确保并发程序的稳定性和可靠性。