Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

2019-04-30:谈谈线程死锁,如何有效的避免线程死锁? #43

Open
MoJieBlog opened this issue Apr 29, 2019 · 8 comments
Open

Comments

@MoJieBlog
Copy link
Collaborator

No description provided.

@Shanlovana
Copy link
Collaborator

韭菜我又来了,上来废话,抛砖引玉

一、死锁的定义

多线程以及多进程改善了系统资源的利用率并提高了系统 的处理能力。然而,并发执行也带来了新的问题——死锁。所谓死锁是指多个线程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。

二、死锁产生的原因

1) 系统资源的竞争

通常系统中拥有的不可剥夺资源,其数量不足以满足多个进程运行的需要,使得进程在 运行过程中,会因争夺资源而陷入僵局,如磁带机、打印机等。只有对不可剥夺资源的竞争 才可能产生死锁,对可剥夺资源的竞争是不会引起死锁的。

2) 进程推进顺序非法

进程在运行过程中,请求和释放资源的顺序不当,也同样会导致死锁。例如,并发进程 P1、P2分别保持了资源R1、R2,而进程P1申请资源R2,进程P2申请资源R1时,两者都 会因为所需资源被占用而阻塞。

3)信号量使用不当也会造成死锁。

进程间彼此相互等待对方发来的消息,结果也会使得这 些进程间无法继续向前推进。例如,进程A等待进程B发的消息,进程B又在等待进程A 发的消息,可以看出进程A和B不是因为竞争同一资源,而是在等待对方的资源导致死锁。

4) 死锁产生的必要条件

产生死锁必须同时满足以下四个条件,只要其中任一条件不成立,死锁就不会发生。

  • 互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。

  • 不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。

  • 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。

  • 循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。

/** 
* 一个简单的死锁类 
* 当DeadLock类的对象flag==1时(td1),先锁定o1,睡眠500毫秒 
* 而td1在睡眠的时候另一个flag==0的对象(td2)线程启动,先锁定o2,睡眠500毫秒 
* td1睡眠结束后需要锁定o2才能继续执行,而此时o2已被td2锁定; 
* td2睡眠结束后需要锁定o1才能继续执行,而此时o1已被td1锁定; 
* td1、td2相互等待,都需要得到对方锁定的资源才能继续执行,从而死锁。 
*/
public class DeadLock implements Runnable { 
  public int flag = 1; 
  //静态对象是类的所有对象共享的 
  private static Object o1 = new Object(), o2 = new Object(); 
  @Override
  public void run() { 
    System.out.println("flag=" + flag); 
    if (flag == 1) { 
      synchronized (o1) { 
        try { 
          Thread.sleep(500); 
        } catch (Exception e) { 
          e.printStackTrace(); 
        } 
        synchronized (o2) { 
          System.out.println("1"); 
        } 
      } 
    } 
    if (flag == 0) { 
      synchronized (o2) { 
        try { 
          Thread.sleep(500); 
        } catch (Exception e) { 
          e.printStackTrace(); 
        } 
        synchronized (o1) { 
          System.out.println("0"); 
        } 
      } 
    } 
  } 
  
  public static void main(String[] args) { 
      
    DeadLock td1 = new DeadLock(); 
    DeadLock td2 = new DeadLock(); 
    td1.flag = 1; 
    td2.flag = 0; 
    //td1,td2都处于可执行状态,但JVM线程调度先执行哪个线程是不确定的。 
    //td2的run()可能在td1的run()之前运行 
    new Thread(td1).start(); 
    new Thread(td2).start(); 
  
  } 
} 

三、如何避免死锁

在有些情况下死锁是可以避免的。三种用于避免死锁的技术:

  1. 加锁顺序(线程按照一定的顺序加锁)
  2. 加锁时限(线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁)
  3. 死锁检测

加锁顺序

当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。

如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。看下面这个例子:

Thread 1:
  lock A 
  lock B

Thread 2:
   wait for A
   lock C (when A locked)

Thread 3:
   wait for A
   wait for B
   wait for C

如果一个线程(比如线程3)需要一些锁,那么它必须按照确定的顺序获取锁。它只有获得了从顺序上排在前面的锁之后,才能获取后面的锁。

例如,线程2和线程3只有在获取了锁A之后才能尝试获取锁C(译者注:获取锁A是获取锁C的必要条件)。因为线程1已经拥有了锁A,所以线程2和3需要一直等到锁A被释放。然后在它们尝试对B或C加锁之前,必须成功地对A加了锁。

按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁(译者注:并对这些锁做适当的排序),但总有些时候是无法预知的。

加锁时限

另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行(译者注:加锁超时后可以先继续运行干点其它事情,再回头来重复之前加锁的逻辑)。

以下是一个例子,展示了两个线程以不同的顺序尝试获取相同的两个锁,在发生超时后回退并重试的场景:

Thread 1 locks A
Thread 2 locks B

Thread 1 attempts to lock B but is blocked
Thread 2 attempts to lock A but is blocked

Thread 1's lock attempt on B times out
Thread 1 backs up and releases A as well
Thread 1 waits randomly (e.g. 257 millis) before retrying.

Thread 2's lock attempt on A times out
Thread 2 backs up and releases B as well
Thread 2 waits randomly (e.g. 43 millis) before retrying.

在上面的例子中,线程2比线程1早200毫秒进行重试加锁,因此它可以先成功地获取到两个锁。这时,线程1尝试获取锁A并且处于等待状态。当线程2结束时,线程1也可以顺利的获得这两个锁(除非线程2或者其它线程在线程1成功获得两个锁之前又获得其中的一些锁)。

需要注意的是,由于存在锁的超时,所以我们不能认为这种场景就一定是出现了死锁。也可能是因为获得了锁的线程(导致其它线程超时)需要很长的时间去完成它的任务。

此外,如果有非常多的线程同一时间去竞争同一批资源,就算有超时和回退机制,还是可能会导致这些线程重复地尝试但却始终得不到锁。如果只有两个线程,并且重试的超时时间设定为0到500毫秒之间,这种现象可能不会发生,但是如果是10个或20个线程情况就不同了。因为这些线程等待相等的重试时间的概率就高的多(或者非常接近以至于会出现问题)。
(译者注:超时和重试机制是为了避免在同一时间出现的竞争,但是当线程很多时,其中两个或多个线程的超时时间一样或者接近的可能性就会很大,因此就算出现竞争而导致超时后,由于超时时间一样,它们又会同时开始重试,导致新一轮的竞争,带来了新的问题。)

这种机制存在一个问题,在Java中不能对synchronized同步块设置超时时间。你需要创建一个自定义锁,或使用Java5中java.util.concurrent包下的工具。写一个自定义锁类不复杂,但超出了本文的内容。后续的Java并发系列会涵盖自定义锁的内容。

死锁检测

死锁检测是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。

每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。

当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例如,线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B是否已经请求了线程A当前所持有的锁。如果线程B确实有这样的请求,那么就是发生了死锁(线程A拥有锁1,请求锁7;线程B拥有锁7,请求锁1)。

当然,死锁一般要比两个线程互相持有对方的锁这种情况要复杂的多。线程A等待线程B,线程B等待线程C,线程C等待线程D,线程D又在等待线程A。线程A为了检测死锁,它需要递进地检测所有被B请求的锁。从线程B所请求的锁开始,线程A找到了线程C,然后又找到了线程D,发现线程D请求的锁被线程A自己持有着。这是它就知道发生了死锁。

下面是一幅关于四个线程(A,B,C和D)之间锁占有和请求的关系图。像这样的数据结构就可以被用来检测死锁。

img

那么当检测出死锁时,这些线程该做些什么呢?

一个可行的做法是释放所有锁,回退,并且等待一段随机的时间后重试。这个和简单的加锁超时类似,不一样的是只有死锁已经发生了才回退,而不会是因为加锁的请求超时了。虽然有回退和等待,但是如果有大量的线程竞争同一批锁,它们还是会重复地死锁(编者注:原因同超时类似,不能从根本上减轻竞争)。

一个更好的方案是给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为避免这个问题,可以在死锁发生的时候设置随机的优先级。

文章参考:

线程死锁的原理及解决方式

死锁产生的原因及解决方法

@Moosphan Moosphan changed the title 谈谈线程死锁,如何有效的避免线程死锁? 2019-04-30:谈谈线程死锁,如何有效的避免线程死锁? Apr 30, 2019
@whatshappen
Copy link

结果
韭菜明好厉害哦😯

@canyie
Copy link

canyie commented Apr 30, 2019

楼上已经说的很好了,关于死锁检测那部分我来补充一下(因为按他的模型来看很可能需要自定义锁)
system_server 进程有个 watchdog 线程,就是专门用来检测系统无响应的情况
不知道你们以前用手机的时候有没有发生过手机莫名其妙自动重启的情况,有可能就是 watchdog 检测到系统线程阻塞(不一定是死锁)直接引发了系统软重启
我们也可以写一个类似的机制,在请求锁之前标记自己的状态, watchdog 反复检测被监控的线程,如果线程在超时时间内没有成功请求到锁就认为发生死锁,接下来你可以做想做的事,比如发送 SIGQUIT 信号记录当前进程的状态

@yangfanggang
Copy link

学习了
暖贴小王子来啦

@ddssingsong
Copy link

这个说的过于专业化了,如果有一个例子就好了

@ddssingsong
Copy link

我们来开启回答问题三段式

  1. 首先,死锁是什么?它会给我们带来啥问题

    多个线程(进程)竞争资源所造成的一种线程永远在等待的现象。当然,书上讲了满足啥啥啥条件,我是一个都么记住

    那它会造成什么结果呢?

    1. 数据错误,如银行取钱问题

    2. 资源被占用,手机会卡

    3. 多重死锁,搞不好还会把手机搞崩溃

  2. 什么情况下会出现死锁?

    这时候一个例子足以说明问题

      
        public void method1() {
            synchronized (String.class) {
                System.out.println("请求一个被 String.class 锁住的的方法");
                synchronized (Integer.class) {
                    System.out.println("请求一个被 Integer.class 锁住的的方法");
                }
            }
        }
    
        public void method2() {
            synchronized (Integer.class) {
                System.out.println("请求一个被 Integer.class 锁住的的方法");
                synchronized (String.class) {
                    System.out.println("请求一个被 String.class 锁住的的方法");
                }
            }
        }

    想想多个线程同时调用这两个方法,会出现什么?背诵下面这段话

    因为如果线程1在执行method1时获取String对象的锁定,并且线程2获取了对Integer的锁定在执行method2时,两者都将等待彼此释放对Integer和String的锁定以继续进行,这将永远不会起作用。

  3. 如何避免死锁

    1. 加锁顺序

      仔细观察下面这段代码,和上面的有何不同,发现了,这个问题就结束了

       public void method1() {
              synchronized (String.class) {
                  System.out.println("请求一个被 String.class 锁住的的方法");
                  synchronized (Integer.class) {
                      System.out.println("请求一个被 Integer.class 锁住的的方法");
                  }
              }
          }
      
          public void method2() {
              synchronized (String.class) {
                  System.out.println("请求一个被 String.class 锁住的的方法");
                  synchronized (Integer.class) {
                      System.out.println("请求一个被 Integer.class 锁住的的方法");
                  }
              }
          }

割------------------------------------------------------------------------------------------------------------------------

一般到这个时候,这个问题就结束了,如果还有下文,继续吹

割------------------------------------------------------------------------------------------------------------------------

  1. 加锁时限

    这玩意就是在锁上加个超时,没啥技术含量,有点技术含量的东西是这玩意要自定义一个加锁机制

    Lock lock = new ReentrantLock();

    说到这里就适可为止了,我好像在某个项目数据库操作的时候用过这个东西,之后就再没见过了

  2. 死锁检测

    哎,总感觉这个东西很难懂,没想到真的搞了半天没看懂

    然后看到这篇文章,就更方了,

    Linux 死锁检测模块 Lockdep 简介:

    http://kernel.meizu.com/linux-dead-lock-detect-lockdep.html

总之,在最后加一句,我觉得我们在写代码或者设计架构的时候最好避免这种情况的出现

@MoJieBlog
Copy link
Collaborator Author

MoJieBlog commented Apr 30, 2019

我自己出的题,我自己也回答下吧。


关于死锁是什么,想看大家看了之前大家的回答应该都有很清楚的理解了。我就给大家举几个经典的例子吧。

死锁产生的条件

一般来说,出现死锁问题需要满足以下条件

  • 互斥条件:一个资源每次只能被一个线程使用
  • 请求与保证条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:线程已获得的资源,在未使用完成之前,不能强行剥夺
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系

在JAVA编程中,有3中典型的死锁类型:

  • 静态的锁顺序死锁
  • 动态的锁顺序死锁
  • 协作对象之间发生的死锁

典型死锁例子

注意以下代码都是错误代码

1.静态的锁顺序死锁

class Test{
    final Object objA = new Object();
    final Object objB = new Object();
    
    public void a(){
        //注意这里   先A后B
        synchronized(objA){
            synchronized(objB){
                //sth....
            }
        }
    }
    
    public void b(){
        //注意这里    先B后A
        synchronized(objB){
            synchronized(objA){
                //sth....
            }
        }
    }
}

2.动态的锁顺序死锁

动态的锁顺序死锁是指两个线程调用同一个方法时,传入的参数颠倒造成的死锁。如下情景,一个线程调用了transferMoney(转账)方法并传入参数accountA,accountB;另一个线程调用了transferMoney方法并传入参数accountB,accountA。此时就可能发生在静态的锁顺序死锁中存在的问题,即:第一个线程获得了accountA锁并等待accountB锁,第二个线程获得了accountB锁并等待accountA锁。

3.协作对象之间发生的死锁

有时,死锁并不会那么明显,比如两个相互协作的类之间的死锁,比如:一个线程调用了A对象的a方法,另一个线程调用了B对象的b方法。此时可能会发生,第一个线程持有A对象锁并等待B对象锁,另一个线程持有B对象锁并等待A对象锁。

@feelschaotic
Copy link

轻松一点学技术,用经典的“哲学家就餐问题“,来解释死锁。

故事开始了……

5 个沉默寡言的哲学家围坐在圆桌前,每人面前一盘意面。叉子放在哲学家之间的桌面上。(5 个哲学家,5 根叉子)

所有的哲学家都只会在思考和进餐两种行为间交替。哲学家只有同时拿到左边和右边的叉子才能吃到面,而同一根叉子在同一时间只能被一个哲学家使用。每个哲学家吃完面后都需要把叉子放回桌面以供其他哲学家吃面。只要条件允许,哲学家可以拿起左边或者右边的叉子,但在没有同时拿到左右叉子时不能进食。

假设面的数量没有限制,哲学家也能随便吃,不需要考虑吃不吃得下。

设计一个进餐规则(并行算法)使得每个哲学家都不会挨饿;也就是说,在没有人知道别人什么时候想吃东西或思考的情况下,每个哲学家都可以在吃饭和思考之间一直交替下去。

我们来分析下这个问题:

1. 什么是死锁

死锁就是 5 个哲学家同时拿起了左边的叉子,想拿右边叉子时发现:诶,怎么被右边这个家伙拿了!!等待右边的哲学家放下他左边的叉子,这时没有一个哲学家可以拿到两个叉子吃面,也就不会放下叉子了,gg,无限等待……

2. 死锁条件

产生死锁必须同时满足以下四个条件,只要其中任一条件不成立,死锁就不会发生。

  1. 互斥条件:同一时间叉子只能给一个人用,毕竟大家 5 张嘴,也不是吃同一碗面,拿不到的哲学家只能等待。

  2. 不剥夺条件:叉子,也就是系统资源,大家一起抢叉子(同时发起竞争),还不能抢别人已经抢到的叉子(资源不可剥夺)。大家面面相觑……

  3. 请求和保持条件:我就算拿不到右边的叉子,我也绝不会主动放下左边的叉子。

  4. 循环等待条件:循环等待链,哲学家1在等2的左叉子,2又在等3的左叉子……你知道我在等你吗?

3. 怎么解决?

只要破坏上面任意一个条件就可以解决问题。

1. 限制并发数

5 个哲学家同时拿叉子会死锁,那 5 - 1 = 4,4 个同时拿就没这问题了,可以保证有 1 个哲学家拿到两个叉子吃面。(喂,你吃快点,大家伙们等着呢~)

2. 破坏请求和保持条件

方法一:大家约定,如果拿不到右边的叉子,那就主动放下左边的叉子,等待下一轮抢夺。
方法二:把拿两只叉子的过程作为临界资源,一次只允许一个哲学家进入。

3. 破坏循环等待条件

拿叉子的顺序不对,右边这位老兄,咱们商量一下,你先拿你右边的叉子行吧,等我吃完!

使用非对称解决方案,即奇数号的哲学家先拿起左边的叉子,接着再请求右边的叉子;而偶数号的哲学家先拿起右边的叉子,接着请求左边的叉子。这样就破坏了同方向环路。

都学会了吗?去力扣刷一刷题加强下吧!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

8 participants