极客时间 ——《Java并发编程实战》 05 | 一不小心就死锁了,怎么办?

2020-10-28   16 次阅读


我们使用 Account.class 类对象作为 「 互斥锁 」,来解决 银行业务里面的转账 问题,虽然这个方案不存在 并发 问题,但是这里面所有账户的操作都是「串行」的。

例如 账户A 转账给 账户B、 账户C 转账给 账户D,这两个操作在现实世界中是可以并行的,但是在这个方案中被 「串行化」 ,导致性能非常低下。

在 互联网环境 中,如果在可能存在 「大量并发」 的场景中为了保证安全性而使用完全串行化的方式来解决问题,那么 性能 就会成为一个不得不解决的问题。

所有我们需要在 保证安全的同时,尝试提升性能 。【锁的粒度越细,对性能的影响就越低】。

向现实世界要答案

现实世界里, 账户转账操作 是支持「并发」的,而且是真正的「并行」,银行所有窗口都可以做转账操作 。

所以只要我们将现实中的解决方案移植到编程领域中,模仿现实世界的转账操作,串行的问题就解决了。

如果在古代,没有信息化,账户的存在是一个真正的「账本」的实体,每个「账户」都有一个「 账本 」,这些 账本统一存放在文件架上 。 银行柜员给我们做转账时,要去文件架上把「 转出账本 」 和 「转入账本」 都拿到手,然后做转账操作。

那么这个柜员在试图拿账本的时候可能遇到以下三种情况:

  • 文件架上恰好对应的 「转入账本」 和 「转出账本」 都在 ,那就 同时拿走 。
  • 如果文件架上 只存在其中之一 ,那就由柜员先将这个存在的账本拿到手,同时「等待」其他柜员将另一个账本送回来。
  • 如果「 转入账本 」 和 「 转出账本 」 都不存在,那么这个柜员就 等待 两个账本都被送回来。

上面这个过程对应的 编程实现 是:使用 两把锁 。「 转出账本 」一把,「 转入账本 」一把。

在 transfer() 方法内部,我们首先尝试「锁定」 转出账户 this (先把转出账户拿到手 ),然后尝试锁定转入账户 target (再把转入账本拿到手),只有当两者都成功时,才执行转账操作。

这个逻辑可以图形化为下图这个流程:

【↑并行转账的示意图。】

详细的代码实现则如下所示:↓

经过这样优化之后:账户A 转 B,账户C 转D这两个转账操作就可以并行了。

class Account {
	private int balance;
	// 转账
	void transfer(Account target,int amt) {
		// 锁定转出账户
		synchronized(this) {
			// 锁定转入账户
			synchronized(target) {
				if(this.balance > amt) {
					this.balance -= amt;
					target.balance += amt;
				}
			}
		}
	}
}

没有免费的午餐

上面的实现看起来很完美,相对于 使用 Account.class 作为互斥锁 ,那种情况下 锁的粒度太大 ,使整个并发程序成为串行化的程序。

而这里我们锁 定两个账户的范围就小多了 ,这样的锁叫做 细粒度锁。

细粒度锁可以提高「并行度」,是性能优化的重要手段。

【 但是编程中没有银弹,引入技术不可能只带来优势而不存在问题。 】

使用细粒度锁需要付出的代价就是可能导致 「死锁」。

所谓「死锁」 :

jcip 中给的例子是 哲学家进餐 ,每个人手边都只有一根筷子,当每个人都拿起了自己手边的那一根时,每个人都将等待其他人放下筷子,最终所有人都会饿死。

极客时间 中给出的例子就是上面的转账例子:

假设柜员张三拿到了账本A,柜员李四拿到了账本B,则他们都将等待另一本账本被放回文件架,这就将形成死锁。

【转账业务中的"死等"】

死锁比较专业的定义是:一组互相竞争「资源」的线程因互相等待,导致永久「阻塞」 的现象。

上面的转账代码是怎么发生死锁的呢:假设线程Thread-1 执行 账户A 转 B 的操作,账户 A.transfer(账户B);同时 Thread-2 线程 执行账户B 转 账户A 的操作,账户B.transfer(账户A)。

当 T1 和 T2 执行完①处 的代码时, T1 获得了账户A 的锁, T2获得了账户B 的锁,

之后当T1试图获取账户B的锁时,也就是执行到②处的代码。发现其已经被锁定,于是等待锁释放,T2同理 。

于是 T1 和 T2将 无限等待 下去,也就是我们所说的 死锁 产生了。

class Account {
	private int balance;
	// 转账
	void transfer(Account taget,int amt) {
		// 锁定转出账户
		synchronized(this) { ①
			// 锁定转入账户
			synchronized(target) { ②
				if(this.balance > amt) {
					this.balance -= amt;
					target.balance += amt;
				}
			}
		}
	}
}

关于死锁,作者还给了一个 可视化的资源分配图 来说明 锁的占用情况 :其中资源用方形节点表示 ,线程用圆形节点表示 。

资源 中的点指向线程的边表示线程已经获得该资源 ,线程指向资源的边表示线程请求资源 ,但 「尚未得到」 。

转账发生死锁时就是如下的情况,一个"各据山头死等"的尴尬局面。

如何预防死锁

当程序发生死锁时,一般只能重启应用 。因此最好的方法就是提前「 规避 」死锁的发生。

Conffman 总结了死锁发生的「四个」必要条件:

  1. 互斥,共享资源 X 和 Y 只能 被一个线程占用。
  2. 占有且等待,线程T1 已经取得 共享资源X ,在等待 共享资源Y 的时候,不释放 自己持有的 共享资源X 。
  3. 不可抢占,其他线程不能「强行抢占」 线程T1 已经占有的资源。
  4. 循环等待,线程T1 等待 线程T2 占有的资源,线程T2 等待 线程T1 占有的资源,就是循环等待。

所以我们只要破坏以上4点中的一个,就可以成功避免死锁的发生。

其中,「互斥」 这个条件没法办法破坏,因为我们使用锁的目的就是为了互斥而保证线程的安全性。

其他三个条件都有对应的破解方法:

  1. 破解 「占有且等待」,可以采用一次性申请所有资源这种做法,这样就不存在等待了。
  2. 破解「不可抢占」,占用部分资源的线程进一步申请其他资源时,如果无法获取,则主动释放它已经占有的资源,这样「不可抢占」就被破坏了。
  3. 破解「循环等待」,可以使用按序申请资源来预防。所谓「按序」指的是资源存在「线性顺序」,申请的时候先申请资源序号小的,然后再申请序号大的,这样就不存在循环了。

破解死锁的理论已经清晰了,下一步就是将理论转换为具体代码的实现。

  1. 破坏占有且等待条件

之前说了,破坏这个条件的措施是 一次性申请所有资源。

继续使用之前举的转账的例子,其中需要的资源有两个:

  • 「转出账户」
  • 「转入账户」

当这两个账户同时被申请,该如何解决呢?

可以增加一个 「账本管理员」,只允许账本管理员从文件架上拿账本,也就是 柜员无法直接操作文件架,必须通过第三方才能拿到想要的账本。

例如:张三同时申请账本 A 和 B,管理员发现文件架上只存在 账本A,那么这时候管理员不会将 账本A 交给张三,只有当所有申请的资源同时存在时,管理员才会将账本交给张三,这就保证了 —— 「一次性申请所有资源」 。

【经典的解决方案,通过引入第三方来降低问题的复杂性。】

再对应到具体的编程领域,「同时申请」 这个操作是一个 「临界区」,我们需要一个 Java 中的类 来管理这个临界区,我们将这个角色定义为 Allocator 分配者。

它有两个「重要功能」,分别是:

  • 同时申请资源 apply()
  • 同时释放资源 free()

账户 Account 类里面持有一个 Allocator 的单例(必须是单例,因为只能有一个人来分配资源)。

当账户 Account 在执行转账操作的时候,首先向 Allocator 同时申请其需要的 转出账户 和转入账户 这两个资源,成功后再锁定这两个资源;

当转账操作执行完,释放锁之后,通知 Allocator 同时释放 转出账户 和 转入账户 这两个资源,代码如下:

// 分配者
class Allocator {
    private List<Object> als = new ArrayList<>();

    // 一次性申请所有资源
    synchronized boolean apply(Object from, Object to) {
        if (als.contains(from) || als.contains(to)) {
            return false;
        } else {
            als.add(from);
            als.add(to);
        }
        return true;
    }

    // 归还资源
    synchronized void free(Object from, Object to) {
        als.remove(from);
        als.remove(to);
    }
}

class Account {
	// Allocator 应该是单例的
	private Allocator actr;
	private int balacne;
	
	// 转账
  void transfer(Account target,int amt) {
    	// 持续申请资源,当资源申请成功时,死循环终止
    while(!actr.apply(this,target));
    try {
      	// 锁定转出账户
      synchronized(this) {
        	// 锁定转入账户
        synchronized (target) {
          	if (this.balance > amt) {
              	this.balance -= amt;
              	target.balance += amt;
            }
        }
      } 
    } finally {
      		// 释放资源
        	actr.free(this,target);
      }
  }
}
  1. 破坏「不可抢占」条件

破坏「不可抢占条件」的核心是能够主动释放它占有的资源,这一点 synchronized 是做不到的。

因为 synchronized 申请资源的时候,如果没有申请成功,那么线程直接进入阻塞状态,而当线程进入阻塞状态之后什么也干不了,所以无法释放线程已经占有的资源。

Java 在语言层次没有解决这个问题,但是在 JDK 中存在解决了这个问题的类:java.util.concurrent 包下的 Lock 类可以轻松解决这个问题,但是这个在后面才会具体讲。

  1. 破坏「循环等待」条件

破坏这个条件,需要对资源进行排序,然后「按序」 申请资源。

这个实现很简单,我们假设每个账户都有一个不同的属性:id。 这个 id 可以作为排序字段,申请的时候,根据 id 按照从小到打的顺序来进行申请。

比如下面代码中,①到⑥ 处的代码对 转出账户(this) 和 转入账户 target 「排序」,然后按照序号从小到大锁定账户,这样就不存在 「循环等待」了。

// 增加 id 字段,根据 id 从小到大申请资源,破除循环等待条件
public class Account {
    private int id;
    private int balance;

    //转账 这里将 id 小的那个账户作为转入账户
    void transfer(Account2 target, int amt) {
        Account left = this;  // ①
        Account right = target; // ②
        if (this.id > target.id) {
            left = target; // ③
            right = this; // ④
        }
        // 锁定序号小的账户
        synchronized (left) {
            // 锁定序号大的账户
            synchronized (right) {
                if (this.balance > amt) {
                    this.balance -= amt;
                    target.balance += amt;
                }
            }
        }
    }
}

总结

在编程世界中遇到问题时,不应该局限于当下,可以换个思路从现实世界出发,利用现实世界的模型来构思解决方案,这样往往能够让我们的方案更容易理解,也更能够看清楚问题的本质。

但是现实世界的模型有很多「细节」会被我们忽视。 因为现实世界中,人太智能了,以致于有些细节显得抬不重要。

比如转账,为什么在现实世界中会忽视「死锁」 这个问题? 主要是我们会交流,而且是很智能地交流。

而编程世界中,两个线程是不会智能地交流的。 所以在利用现实模型建模时,还要仔细对比现实世界和编程世界里各个角色之间的「差异」。

这一章主要讲了 使用细粒度锁来锁定多个资源时,要注意可能发生的「死锁」问题,这里我们需要将这个意识强化为思维定势,遇到这种特定的场景,马上意识到可能存在「死锁」问题。

当你知道风险之后,才有机会去预防和避免,因此意识技术背后可能引入的问题非常重要。

预防死锁主要是破坏三个条件中的一个,有了思路,实现起来就很简单。但是有时候预防死锁的「成本」也是很高的。

例如上面的破坏占用且等待条件的成本就比破坏循环等待的成本高。 破坏占用且等待条件,我们锁定了所有的账户,而这里还用了死循环 while(!actr.apply(this,target)); 方法 ,不过好在这个 apply 方法基本不耗时。

在转账这个例子中,破坏循环等待条件就是成本最低的一个方案。

所以我们在选择具体方案的时候,还需要评估一下 操作成本 ,从中选择一个成本最低的方案。

课后思考

Q:

使用死循环 while(!actr.apply(this,target)); 和使用 synchronized (Account.class) 相比,谁更有性能优势?为什么?

A:

首先,后者 synchronized (Account.class) 会将程序变为一个串行的程序,导致只有一个线程能操作,其余的所有线程都需要等待当前线程完成。

而while(!actr.apply(this,target)); 只涉及当前操作的两个对象,两种方式的「影响范围」不同。

课后留言个人精选:

  1. 在实际项目中, 对于死循环等待某个条件成立,增加超时 timeout 非常重要,避免一直阻塞下去。
  2. 实际开发中多用 数据库事务 + 乐观锁的方式来保证 A 转 B , C 转 B 一起执行时 B 的 account 对象是同一个对象。

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。