多线程之死锁介绍及预防

综述

在遇到线程安全问题的时候,我们会使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock)。或者有的场景我们使用线程池和信号量来限制资源的使用,但这些被限制的行为可能会导致资源死锁(Resource DeadLock)。
我们知道Java应用程序不像数据库服务器,能够检测一组事务中死锁的发生,进而选择一个事务去执行;在Java程序中如果遇到死锁将会是一个非常严重的问题,它轻则导致程序响应时间变长,系统吞吐量变小;重则导致应用中的某一个功能直接失去响应能力无法提供服务,这些后果都是不堪设想的。因此我们应该及时发现和规避这些问题。
 

死锁产生的条件

死锁的产生有四个必要的条件

  • 互斥使用,即当资源被一个线程占用时,别的线程不能使用
  • 不可抢占,资源请求者不能强制从资源占有者手中抢夺资源,资源只能由占有者主动释放
  • 请求和保持,当资源请求者在请求其他资源的同时保持对原有资源的占有
  • 循环等待,多个线程存在环路的锁依赖关系而永远等待下去,例如T1占有T2的资源,T2占有T3的资源,T3占有T1的资源,这种情况可能会形成一个等待环路

对于死锁产生的四个条件只要能破坏其中一条即可让死锁消失,但是条件一是基础,不能被破坏。
 

各种死锁

锁顺序死锁

死锁 当多个线程同时需要同一个锁,但是以不同的方式获取它们。

例如,如果线程1持有锁A,然后请求锁B,线程2已经持有锁B,然后请求锁A,这样一个死锁就发生了。线程1永远也得不到锁B,线程2永远也得不到锁A。它们永远也不知道这种情况。

public class TreeNode {
 
  TreeNode parent   = null;  
  List     children = new ArrayList();
​
  public synchronized void addChild(TreeNode child){
    if(!this.children.contains(child)) {
      this.children.add(child);
      child.setParentOnly(this);
    }
  }
  
  public synchronized void addChildOnly(TreeNode child){
    if(!this.children.contains(child){
      this.children.add(child);
    }
  }
  
  public synchronized void setParent(TreeNode parent){
    this.parent = parent;
    parent.addChildOnly(this);
  }
​
  public synchronized void setParentOnly(TreeNode parent){
    this.parent = parent;
  }
}

如果一个线程(1)调用parent.addChild(child)的同时其他线程(2)在同一个parent和child实例上调用child.setParent(parent)方法,就会发生死锁。 下面是说明这个问题的一些伪代码:

Thread 1: parent.addChild(child); //locks parent
      --> child.setParentOnly(parent);
​
Thread 2: child.setParent(parent); //locks child
      --> parent.addChildOnly()

首先,线程1调用parent.addChild(child),因为addChild()是同步的,所以线程1会锁住parent对象,防止其他线程获得。

然后,线程2调用child.setParent(parent),因为setParent()是同步的,所有线程2会锁住child对象,防止其他线程获得。

现在,parent和child对象被这两个不同的线程锁住。接下来,线程1尝试调用child.setParentOnly()方法,但是child对象被线程2锁住了,因此这个调用就会阻塞在那。线程2也尝试调用parent.addChildOnly()方法,但是parent对象被线程1锁住了。线程2也会阻塞在这个方法的调用上。现在两个线程都在等待获取被其他线程持有的锁。

线程确实需要同时获得锁。例如,如果线程1早线程2一点点,获得了锁A和B,然后,线程2就会在尝试获取锁B时,阻塞在那。这样就不会有死锁发生。由于,线程调度是不确定的,所以,我们无法准确预测什么时候会发生死锁。

@Slf4j
public class DeadLock implements Runnable {
    public int flag = 1;
    //静态对象是类的所有对象共享的
    private static Object o1 = new Object(), o2 = new Object();

    @Override
    public void run() {
        log.info("flag:{}", flag);
        if (flag == 1) {
            synchronized (o1) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o2) {
                    log.info("1");
                }
            }
        }
        if (flag == 0) {
            synchronized (o2) {
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                synchronized (o1) {
                    log.info("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();
    }
}
 

更复杂的死锁

Thread 1  locks A, waits for B
Thread 2  locks B, waits for C
Thread 3  locks C, waits for D
Thread 4  locks D, waits for A

线程1等待线程2,线程2等待线程3,线程3等待线程4,线程4等待线程1.

常见的数据库死锁

一个更复杂的死锁发生场景,就是数据库事务。一个数据库可能包含许多SQL更新请求。在一个事务中,要更新一条记录,但这条记录被来自其它事务的更新请求锁住了,知道第一个事务完成。在数据库中,同一个事务内的每条更新请求可能都会锁住一些记录。

如果多个事务同时运行,并且更新相同的记录。这就会有发生死锁的风险。

例如:

Transaction 1, request 1, locks record 1 for update
Transaction 2, request 1, locks record 2 for update
Transaction 1, request 2, tries to lock record 2 for update.
Transaction 2, request 2, tries to lock record 1 for update.

一个事务事先并不知道所有的它将要锁住的记录,所有在数据库中检测和预防死锁变得更加困难。

重入锁死

重入锁死是一种类似于死锁和嵌套管程失败的情景。

如果一个线程重入获得了一个非重入的锁,读写锁或者一些其他的同步器就会发生重入锁死。重入意味着一个线程已经持有了一个锁可以再次持有它。Java的同步块是可以冲入的。因此,下面这段代码执行将不会出现问题。

public class Reentrant{
    public synchronized outer(){
        inner();
    }
​
    public synchronized inner(){
        //do something
    }
}

outerinner方法都被声明为synchronized,这等同于一个synchronized(this)块。如果一个线程在outer()方法里面调用inner()方法将不会出现问题,因为这两个方法都被同步在同一个管程对象"this"上。如果一个线程已经持有了一个管程对象上的锁,它就可以访问同一个管程对象上所有的同步块。这被称作可重入

下面Lock的实现是不可重入的:

public class Lock{  
    private boolean isLocked = false;
​
    public synchronized void lock()throws InterruptedException{
        while(isLocked){
            wait();
        }
        isLocked = true;
    }
​
    public synchronized void unlock(){
        isLocked = false;
        notify();
    }
}

如果一个线程两次调用lock()方法而在两次调用之间没有调用unlock(),第二次调用lock()将会阻塞。一个重入锁死就发生了。

要避免重入锁死你有两种选择:

  • 编写代码避免获取已经持有的锁

  • 使用可重入锁

使用哪种方法更适合于你的程序取决于具体的情景。可重入锁的性能常常不如非重入锁,而且更难实现,可重入锁通常没有不可重入锁那么好的表现,而且实现起来复杂,但这些情况在你的项目中也许算不上什么问题。无论你的项目用锁来实现方便还是不用锁方便,可重入特性都需要根据具体问题具体分析。

如何预防死锁 呢?

锁排序 当多个线程获取同一个锁但是以不同的顺序,就会发生死锁。

如果你确保所有的锁一直以相同的顺序被其他线程获取,死锁就不会发生。看下面这例子:

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持有锁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将会在线程之前大约200毫秒重试去获得锁,所以,大体上将会获得所有的锁。已经在等待的线程A一直在尝试获取锁A。当线程2完成时,线程1也将会获得所有的锁。

我们需要记住一个问题,上面提到的仅仅是因为一个锁超时了,而不是说线程发生;了死锁,这也仅仅是说这个线程获取这个锁花费了多少时间去完成任务。

另外,如果线程足够多,尽管设置了超时和重试,也是会有发生死锁的风险。2个线程各自在重试前等待0~500毫秒也许不会发生死锁,但如果10或者20个线程情况就不同了。这种情况发生死锁的概率要比两个线程的情况要高得多。

锁超时机制存在的一个问题是在Java中在进入一个同步代码块时设置时长是不可能的。你不得不创建一个自定义的锁相关的类或者使用在Java5中java.util.concurrency包中的并发结构之一。

死锁检测

死锁检测是一个重量级的死锁预防机制,主要用于在锁排序和锁超时都不可用的场景中。

当一个线程请求一个锁当时请求被禁止时,这个线程可以遍历锁图(lock graph)检查是否发生了死锁。例如,如果一个线程A请求锁7,但是锁7被线程B持有,然后,线程A可以检测线程B是否有请求任何线程A持有的锁。如果有,就会发生一个死锁。

当然,一个死锁场景可能比两个对象分别持有对方的锁要复杂的锁。线程A可能等待线程B,线程B等待线程C,线程C等待线程D,线程D等待线程A。为了检测死锁,线程A必须一次测试所有的被线程B请求的锁。从线程B的锁请求线程A到达线程C,然后又到达线程D,从上面的检测中,线程A找到线程A自身持有的一个锁。这样,线程A就会知道发生了死锁。

下面是一个被四个线程持有和请求锁的图。类似于这样的一个数据结构可以用来检测死锁。

那么,如果检测到一个死锁,这些线程可以做些什么?

一个可能的做法就是释放所有的锁,回退,随机等待一段时间然后重试。这种做法与锁超时机制非常相似除了只有发生死锁时线程才会回退(backup)。而不仅仅是因为锁请求超时。然而,如果大量的线程去请求同一个锁,可能重复的发生死锁,尽管存在回退和等待机制。

一个更好的做法就是为这些线程设置优先级,这样一来,就会只有一个或者一些线程在遇到死锁时发生回退。剩下的线程继续请求锁假如没有死锁再发生。如果赋予线程的优先级是固定的,同样的线程总是拥有更高的优先级。为了避免这种情况,我们可以在发生死锁时,随机的为线程设置优先级。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值