如何预防和避免线程死锁

线程死锁是指两个或多个线程在执行过程中,因争夺共享资源而造成的一种互相等待的现象,若无外力干预,它们都将无法向前推进。预防和避免线程死锁是并发编程中非常重要的一环。

主要有以下几种策略和方法:

  1. 破坏死锁产生的四个必要条件: 死锁的发生需要同时满足以下四个条件,只要破坏其中一个或多个,就可以避免死锁。

    • 互斥条件 (Mutual Exclusion):一个资源每次只能被一个线程使用。

      • 预防方法:这个条件通常是无法破坏的,因为很多资源本身就是临界资源,必须互斥访问(例如打印机、共享变量的写操作)。但可以考虑某些场景是否能用无锁数据结构或CAS操作替代独占锁,或者将资源设计为可共享的(如果业务允许)。
    • 请求与保持条件 (Hold and Wait):一个线程因请求资源而阻塞时,对已获得的资源保持不放。

      • 预防方法:
        • 一次性申请所有资源:线程在开始执行前,一次性申请它所需要的所有资源。如果不能全部获得,则不占用任何资源,或者释放已有的资源然后等待。这种方式实现起来比较困难,因为很难预知一个线程未来需要的所有资源,且可能导致资源利用率下降。
        • 先释放再申请:当线程需要新的资源但无法立即获取时,它必须先释放已经持有的资源,然后再尝试获取。
    • 不可剥夺条件 (No Preemption):线程已获得的资源,在未使用完之前,不能被其他线程强行剥夺,只能在使用完时由自己释放。

      • 预防方法:
        • 允许剥夺:如果一个线程请求新的资源得不到满足,它可以释放它当前持有的资源。或者,操作系统可以强行剥夺某个线程持有的资源(这在用户态编程中较难实现,且可能导致状态不一致)。
        • 主动释放:当线程发现无法获取所需资源时,主动释放已占有的资源。
    • 循环等待条件 (Circular Wait):存在一种线程资源的循环等待链,链中每个线程已获得的资源同时被链中下一个线程所请求。

      • 预防方法:
        • 按序申请资源:对所有共享资源进行排序,规定所有线程必须按照相同的顺序来申请资源。例如,如果要同时获取锁A和锁B,所有线程都必须先获取锁A,再获取锁B。这样就不会出现一个线程持有A等B,另一个线程持有B等A的情况。这是实践中最常用且有效的预防死锁的方法。
        • 将资源分级:与按序申请类似,给资源分级,线程只能按级别升序申请资源,不能跨级或降序申请。
  2. 使用更高级的并发工具和机制:

    • 使用java.util.concurrent包中的工具:

      • Lock接口及其实现(如ReentrantLock):提供了比synchronized更灵活的锁操作。
        • tryLock():尝试获取锁,如果获取不到可以立即返回或在超时后返回,避免无限期等待。线程可以根据tryLock()的返回结果决定是继续等待还是先释放自己持有的其他锁。
        • 可中断的锁获取:lockInterruptibly()允许线程在等待锁的过程中响应中断。
      • ReadWriteLock:读写锁,允许多个读线程同时访问,但在写线程访问时,其他读写线程均被阻塞。在读多写少的场景下能提高并发性,减少锁竞争。
      • Semaphore:信号量,控制同时访问特定资源的线程数量。
      • CountDownLatch / CyclicBarrier:用于线程间的协作和同步,避免因不当的等待导致死锁。
    • 避免嵌套锁:尽量减少在持有锁的情况下再去获取另一个锁。如果必须使用嵌套锁,务必保证所有线程获取锁的顺序一致。

    • 使用超时机制:在尝试获取锁或资源时,设置一个超时时间。如果在超时时间内未能获取到,则放弃获取,并释放已经持有的锁,然后可以进行重试或者执行其他逻辑。这可以避免无限等待导致的死锁,并将其转化为一个可处理的失败。

  3. 死锁检测与解除: 这是一种事后补救措施,系统允许死锁发生,但能够检测到它们并采取措施解除死锁。

    • 检测:通常通过分析资源分配图(或等待图)是否存在环路来检测死锁。
    • 解除:
      • 剥夺资源:从一个或多个死锁线程中剥夺资源,分配给其他线程。
      • 终止进程/线程:终止一个或多个处于死锁状态的线程,释放其占有的资源。选择哪个线程被终止通常基于一些策略,如优先级、已执行时间、已占用资源等。 这种方法在通用操作系统中可能存在,但在应用程序级别的并发编程中,我们更倾向于通过良好的设计来预防死锁,而不是依赖运行时检测和解除,因为解除死锁的代价可能很高,并可能导致数据不一致。
  4. 良好的编程习惯和设计:

    • 尽量减少锁的持有时间:只在必要时加锁,一旦操作完成,立即释放锁。锁的粒度也要尽可能小,但不能过小以至于频繁加解锁影响性能。
    • 将共享资源和其锁封装在类内部:通过良好的封装,避免外部代码直接操作锁,减少出错的可能性。
    • 避免在持有锁的时候调用外部的、不可控的方法:因为这些外部方法可能会尝试获取其他锁,或者执行耗时操作,从而增加死锁的风险和锁的持有时间。
    • 进行充分的测试和代码审查:特别关注并发代码块,使用工具(如JProfiler, VisualVM等可以检测线程状态和锁信息)辅助分析。

总结一下,最常用和最有效的预防死锁的策略是:

  • 按序申请资源(破坏循环等待条件)。
  • 使用带超时的锁获取(tryLock())。
  • 尽量减少锁的持有范围和时间。

拓展延申:

  1. 银行家算法 (Banker's Algorithm): 这是一种经典的死锁避免算法(注意是避免而不是预防,它在资源分配前判断是否会导致系统进入不安全状态)。操作系统可以根据当前资源分配情况、进程的最大需求以及可用资源来判断是否批准一个进程的资源请求。如果分配后系统仍处于安全状态(即存在一个安全的执行序列,使得所有进程都能完成),则分配;否则,让进程等待。银行家算法实现较为复杂,开销也较大,在通用编程中较少直接使用,更多的是一种理论指导。

  2. 死锁的定位与分析: 当怀疑或确认发生死锁时,如何定位是一个重要技能。

    • Java中可以使用jstack <pid>命令来获取JVM的线程快照,分析线程堆栈信息,查找处于BLOCKED状态的线程以及它们在等待哪个锁,由哪个线程持有。
    • 一些IDE(如IntelliJ IDEA)和APM工具(如SkyWalking, Pinpoint)也提供了死锁检测和可视化功能。
    • 日志分析:在获取锁和释放锁的关键点打印日志,有助于追溯死锁发生时的线程行为。
  3. 不同锁类型的选择对死锁的影响:

    • 可重入锁(如ReentrantLock, synchronized):允许同一个线程多次获取同一个锁,不会自己把自己锁死。但这并不能防止不同线程间的死锁。
    • 公平锁与非公平锁:公平锁倾向于将锁分配给等待时间最长的线程,可能降低吞吐量但避免饥饿。非公平锁允许插队,可能提高吞吐量但可能导致某些线程长时间获取不到锁。它们对死锁的直接影响不大,但对系统整体的活跃性有影响。