并发王者课-黄金2:行稳致远-如何让你的线程免于死锁

秦二爷

欢迎来到《并发王者课》,本文是该系列文章中的第12篇

在上篇文章中,我们介绍了死锁的概念及其原因,本文将为你介绍的是几种常见的死锁预防策略

简单来说,预防死锁主要有三种策略:

  • 顺序化加锁;
  • 给锁一个超时期限;
  • 检测死锁。

一、顺序化加锁

通常,死锁的产生是由于多个线程无序请求资源造成的。资源是有限的,不可能同时满足所有线程的请求。然而,如果能按照一定的顺序分别满足各个线程的请求,那么死锁也就不再存在,也就是所谓的顺序化加锁(Lock Ordering)

举个通俗点的例子,烦人的路口堵车你一定遇到过。路口之所以堵车,是因为车太多了,大家都争相往自己的方向去,互不相让,不堵才怪。这时候,就需要交警在中间进行协调指挥,疏解拥堵。交警之所以可疏解拥堵,其根本原因在于,交警让原本处于无序竞争的车流变成了井然有序的队列。

车还是那么多的车,路口还是那个路口,可是道路通畅了,这和线程的锁竞争是类似的道理。

在上篇文章的死锁代码中,线程1和线程2分别先占有了A和B,导致了死锁。按照刚才的思路,我们把顺序调整下,线程1和线程2都先占有A,然后再同时争夺B,那么死锁就不会发生。

定义哪吒线程1,先抢占A再争夺B

private static class NeZha implements Runnable {
  public void run() {
    synchronized(lockA) {
      System.out.println("哪吒: 持有A!");

      try {
        Thread.sleep(10);
      } catch (InterruptedException ignored) {}
      System.out.println("哪吒: 等待B...");

      synchronized(lockB) {
        System.out.println("哪吒: 已经同时持有A和B...");
      }
    }
  }
}

定义兰陵王线程2,也是先抢占A再抢占B这与此前就不同了

private static class LanLingWang implements Runnable {
  public void run() {
    synchronized(lockA) {
      System.out.println("兰陵王: 持有A!");

      try {
        Thread.sleep(10);
      } catch (InterruptedException ignored) {}
      System.out.println("兰陵王: 等待B...");

      synchronized(lockB) {
        System.out.println("兰陵王: 已经同时持有A和B...");
      }
    }
  }
}

启动两个线程:

public class DeadLockDemo {
    public static final Object lockA = new Object();
    public static final Object lockB = new Object();

    public static void main(String args[]) {
        Thread thread1 = new Thread(new NeZha());
        Thread thread2 = new Thread(new LanLingWang());
        thread1.start();
        thread2.start();
    }
}

两个线程的输出结果如下:

哪吒: 持有A!
哪吒: 等待B...
哪吒: 已经同时持有A和B...
兰陵王: 持有A!
兰陵王: 等待B...
兰陵王: 已经同时持有A和B...

从运行的结果中可以看到,两个线程都先后获得了自己所需要的资源,而没有导致死锁

调整加锁顺序是一种简单但有效的死锁预防策略。但是,这一策略并不是万能的,它仅适用于你在编码时已经知晓加锁的顺序。

二、给锁一个超时期限

在上篇文章中,我们说过死锁的产生有一些必要的条件,其中一个是无限等待。设定锁超时时间正是为了打破这一条件,让无限等待变成有限等待

仍然以前面的代码为例,哪吒和兰陵王两个线程在争夺资源时,对方都互不相让导致了无限等待的僵局。而此时,如果其中任何一方给等待设定一个期限,那么时间一到,僵局将不攻自破,而线程仍可以再稍等片刻后继续尝试。

需要注意的是,synchronized代码块不可以指定锁超时。所以,如果需要锁超时,你需要使用自定义锁,或者使用JDK提供的并发工具类。相关工具类的用法,会在后续文章中介绍,本文暂不展开描述。

另外,所谓给锁加一个超时的期限,其实有两层含义。一是在请求锁时需要设定超时时间,二是在获取锁之后对锁的持有也要有个超时时间,总不能到手就不放,那是耍流氓

三、死锁检测

作为死锁预防的第三种策略,你可以认为死锁检测(Deadlock Detection)是一项较重的被动技能,当我们无法顺序化加锁,也无法设置锁的超时时间,那么就需要进行死锁检测。

死锁检测的核心原理在于对线程和资源进行数据化打标和跟踪。

在线程获取锁时,会将锁和线程的对应关系通过Graph或者Map等数据结构记录下来。这样一来,线程在获取锁被拒绝时,可以通过遍历已经记录的数据分析是否存在死锁。

当线程发现死锁的情况后,可以采取释放锁,稍等片刻后再次尝试。

附、如何可视化查看线程死锁等状态

在你感觉线程可能被阻塞或死锁时,可以通过jstack命令查看。如果存在死锁,输出的结果中会有明确的死锁提示,如下面所示:

$ jstack -F 8321
Attaching to process ID 8321, please wait...
Debugger attached successfully.
Client compiler detected.
JVM version is 1.6.0-rc-b100
Deadlock Detection:

Found one Java-level deadlock:
=============================

"Thread2":
  waiting to lock Monitor@0x000af398 (Object@0xf819aa10, a java/lang/String),
  which is held by "Thread1"
"Thread1":
  waiting to lock Monitor@0x000af400 (Object@0xf819aa48, a java/lang/String),
  which is held by "Thread2"

Found a total of 1 deadlock.

除了jstack之外,JProfiler也是一款非常强大的线程与堆栈分析工具,并可以和IDEA等IDE完美结合。

借助于JProfiler,我们可以非常直观地看到上述示例代码中的死锁,也可以在Thread Monitor中看到两个线程的状态为blocked.


需要注意的是,JProfiler是一款付费软件,它提供了十天的免费试用时间。如果没有常规的使用需求,而是仅用于学习的话,十天也是够用的。当然,你也可以考虑使用jConsole、jVisualvm等。

小结

以上就是关于死锁预防策略的全部内容。在本文中,我们介绍了三种死锁预发策略。三种策略各有利弊,就实际工作中的应用而言,第二种给锁设定超时期限是更为常用的一种做法,而第一种和第三种具有一定的逻辑难度和技术难度,更侧重于理解而非实际应用。

正文到此结束,恭喜你又上了一颗星✨

夫子的试炼

  • 通过jstack命令查看死锁并解决。

延伸阅读与参考资料

关于作者

关注公众号【庸人技术笑谈】,获取及时文章更新。记录平凡人的技术故事,分享有品质(尽量)的技术文章,偶尔也聊聊生活和理想。不贩卖焦虑,不做标题党。

如果本文对你有帮助,欢迎点赞关注监督,我们一起从青铜到王者

阅读 251

并发王者课
记录平凡人的技术故事,分享有品质(尽量)的技术文章,偶尔也聊聊生活和理想。不贩卖焦虑,不兜售课程...
70 声望
2 粉丝
0 条评论
你知道吗?

70 声望
2 粉丝
宣传栏