Concurrency(十: 死锁和预防)

线程死锁

死锁是指一到多个线程阻塞等待的锁被其他线程持有且不释放.当多个线程在同一时间按不同的顺序来获取相同的锁的情况下,会发生死锁.

例如,线程1持有锁A且尝试去获取锁B,而线程2持有锁B且尝试去获取锁A,那么死锁将会发生.线程1永远获取不到锁B, 而线程2则永远获取不到锁A.且它们都不知道死锁已经发生.它们将永远各自阻塞且持有锁A和B.这种情况我们称之为死锁.

如下描述死锁:

线程 1 持有锁A, 尝试获取锁B.
线程 2 持有锁B, 尝试获取锁A.
复制代码

以下是一个死锁的示例,在TreeNodes中调用不同实例的同步方法:

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调用addChild方式时,线程2在同一时间调用setParent方法,且使用同一个parent和child实例,此时死锁发生了.

如下描述:

// Thread 1:
	// 此时持有parent对象锁
	parent.addChild(child);
	// 尝试去获得child对象锁
	child.setParentOnly(parent);
// Thread 2:
	// 此时持有child对象锁
	child.setParent(parent);
	// 尝试去获得parent锁
	parent.addChildOnly(child);
复制代码

当线程1成功调用parent.addChild(child)时,线程1已经进入同步代码块,并成功获取parent对象锁.其他想要获取parent对象锁的线程只能等待线程1释放.

同样的,当线程2成功调用child.setParent(parent)时,线程2已经进入同步代码块,并成功获取child对象锁,其他想要获取child对象锁的线程只能等待线程2释放.

现在child和parent对象锁同时被不同的两个线程持有.当线程1尝试去调用child.setParentOnly(parent)时,此时child对象锁被线程2持有且未释放,线程1只能阻塞等待child对象锁.同样的,线程2尝试去调用parent.addChildOnly(child)时,此时parent对象锁被线程1持有且未释放,线程2只能阻塞等待parent对象锁.现在两个线程都在阻塞等待对方所持有的对象锁释放.

注意:以上死锁发生需要两个线程同时调用parent.addChild(child)和child.setParent(parent)且需要使用相同的parent和child对象实例.上文中提及的代码可能需要执行很多次才能让死锁发生.

两个线程需要在相同的时间点持有对方所需要的锁.但凡其中一个线程领先另一个线程一点,就能成功获得parent和child对象锁,这样另一个线程一开始就只能阻塞等待对方释放锁.这样死锁就不会发生了.由于线程的执行时机不可预测,因此我们无法按照预期来重现死锁,只能说可能会发生死锁.

更加完整的死锁

死锁可以在多于两个线程的情况下产生.这可能比较难观察.如下示例四个线程的死锁情况:

线程 1 持有锁A, 等待锁B
线程 2 持有锁B, 等待锁C
线程 3 持有锁C, 等待锁D
线程 4 持有锁D, 等待锁A
复制代码

线程1等待线程2释放锁,线程2等待线程3释放锁,线程3等待线程4释放锁,线程4等待线程1释放锁.

数据库死锁

数据库事务是一个更加完整的死锁情况.一个事务中可能会有多个sql更新请求.当一条记录被一个事务更新时会被当前事务锁住,其他想更新同一条记录的事务只能等待持有当前行级锁的事务释放.同一个事务中的多个更新请求可能会锁住数据库中的多条记录.

如果多个事务在同一时间进行且需要更新相同的记录.那么将会有发生死锁的风险.

如下所示:

事务1, 更新请求1, 锁住记录1且进行更新
事务2, 更新请求1, 锁住记录2且进行更新 
事务1, 更新请求2, 尝试获取记录2的行级锁更新记录
事务2, 更新请求2, 尝试获取记录1的行级锁更新记录
复制代码

当记录被不同的更新请求持有,且当前事务不能提前知道执行完当前事务所需要的全部行级锁.那么将很难在数据库事务中检查和预防死锁.

死锁预防

在以下三种措施能够用来预防死锁.

  1. 按顺序获取锁
  2. 持有锁超时
  3. 死锁检测

顺序获取锁

我们知道死锁会发生在多个线程以不同顺序获取相同锁的情况下.

如果我们能确保所有线程都能以相同的顺序来获取锁,那么死锁将不会发生.

如下所示:

线程1:
	持有锁A
	持有锁B
线程2:
	等待获取锁A
	当成功获取锁A时,获取锁C
线程3:
	等待获取锁A
	等待获取锁B
	等待获取锁C
复制代码

如果一个像线程3的线程需要获取多个锁,那么线程需要按照给定的顺序来获取它们.当需要获取序列中的后一个锁之前只能先持有前一个锁.

无论是线程2还是3都需要先获取到锁A才能继续获取锁C.当线程1已经持有锁A时,线程2和3只能等待线程1释放锁A.所以他们只有在成功获取到锁A的情况下,才能继续获取锁B和锁C.

顺序获取锁是一个比较简单且高效的预防死锁的措施.然而,这种方式只能在你知道所有用到的锁的前提下才有用,但实际情况下并不总是这样.

持有锁超时

另一个预防死锁的方式是在线程等待获取锁的过程中加入超时机制.即让线程等待获取锁一段时间后超时.如果线程不能成功获取它执行过程中所需要的全部锁则超时回滚,释放所有它所持有的锁,在一个给定的随机时间后重新尝试执行.给定的随机时间让其他线程有机会去获取它们所等待获取的锁,以便让应用能够退出等待状态继续运行下去.

这里是一个让两个线程尝试以不同的顺序获取相同的锁,且加入超时机制,让线程能够回滚和重新尝试执行的实例.

线程 1 持有锁A
线程 2 持有锁B

线程 1 尝试获取锁B进入等待状态
线程 2 尝试获取锁A进入等待状态

线程 1 等待获取锁B超时
线程 1 回滚和释放锁A
线程 1 等待随机时间(300毫秒)后重新执行

线程 2 等待获取锁A超时
线程 2 回滚和释放锁B
线程 2 等待随机时间(40毫秒)后重新执行
复制代码

以上实例中,线程2领先线程260毫秒去尝试重新执行,看起来是可以成功获取到所有锁来满足执行的.线程A将会重新等待获取锁A.当线程2执行完毕后,线程1也能够获取所有需要的锁来完成执行.(除非线程2或其他线程在线程1执行过程中持有线程1所需要的锁,则执行失败).

有一点需要记住的是,线程获取锁超时并不意味着死锁的发生.可能只是线程持有锁后执行任务的时长过长已经超过了其他线程等待获取锁的超时时长.

此外,如果有足够的线程去竞争相同的资源,即使它们会超时和回滚,仍然有重复一次次持有其他线程所需要锁的风险.也许当只有两个线程互相等待0~500毫秒并进行重试的情况下这种风险不会放生,但如果是10到20个线程的情况下就不一定了.在线程足够多的情况下,两个线程等待重试的时间相同或是接近的几率就很高了.

更大的问题在于持有锁超时的方式在Java同步代码块中不可能实现.我们不能为Javasynchronized添加超时机制.你可能需要自定义锁或是使用java5java.util.concurrency包中的并发数据结构来实现.

死锁检测

死锁检测是一个在顺序获取锁和持有锁超时两个措施都无法使用的情况下才使用的重量级措施.

每一次线程请求获取和成功持有锁的过程都会被记录在可以存储线程和锁关系的数据结构中(如Map).

当一个线程请求获取锁被拒绝后,线程可以遍历数据结构来检查是否发生了死锁.举个例子,线程A请求获取锁7,而锁7被线程B持有,线程A能够进行检查线程B是否需要获取线程A所持有的锁,如果需要则死锁发生(线程A持有锁1, 尝试获取锁7.线程B持有锁7尝试获取锁1).

当然死锁的发生能够在超过两个线程的情况下.线程A等待线程B,线程B等待线程C,线程C等待线程D.为了让线程A对死锁进行检测,我们需要知道线程B所请求的所有锁;因为线程B请求的锁被线程C持有,线程C请求的锁被线程D持有,所以我们需要知道连同线程C和D在内所请求的锁.直到线程A发现它持有线程B所需要的所有锁中的一个或多个为止,则认定死锁发生.

下图展示了4个线程和它们所请求和持有锁之间的关系.这样一个数据结构能够用来检测死锁的发生.

当检测到死锁发生时,线程需要做什么?

可以让线程回滚且释放所有已持有的锁,在等待一段随机时长后重新尝试运行.这种方式类似于持有锁超时措施,区别在于仅在检测到死锁真实发生时,线程才会触发回滚.然而在竞争相同锁的线程过多的情况下,仍然会在多次回滚和等待后重新进入死锁状态.

一个更加恰当的方式是为线程分配优先级,只让一个或少数几个线程进行回滚.在这回滚的片刻时间里,死锁得以解决,让其他线程可以获取它们所需要的锁继续执行.如果给线程分配的优秀级是固定的,同样的线程可能会一直占据高优先级.当然我们可以在检测到死锁时随机分配优先级来解决这个问题.

该系列博文为笔者复习基础所著译文或理解后的产物,复习原文来自Jakob Jenkov所著Java Concurrency and Multithreading Tutorial

猜你喜欢

转载自juejin.im/post/5ca9d5f06fb9a05e5664e3f5