避免活跃性危险

死锁

“哲学家进餐”问题描述了死锁:每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源。

线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远地等待下去,这种情况就是最简单的死锁形式(或称为“抱死”Deadly Embrace)。

数据库设计中考虑了监测死锁以及从死锁中恢复。发生死锁时,根据事务权重判断,回滚权重低的事务,从而使其他事务继续进行。

JVM在解决死锁问题时没有如此强大,当一组Java线程死锁时,这些线程永远不能用了。

锁顺序死锁

LeftRightDeadLock发生死锁的原因在于,2个线程试图以不同的顺序来获得相同的锁。

如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现顺序死锁问题。

动态的锁顺序死锁

1
2
3
4
5
6
7
8
public void transferMoney(Account from, Account to, Amount amount) {
synchronized(from) {
synchronized(to) {
from.debit(amount);
to.credit(amount);
}
}
}

如果并发:

1
2
Thread A : transferMoney(a, b, 10);
Thread B : transferMoney(b, a, 20);

那么就可能发生死锁。

解决思路:

对多个需要加锁对象进行对比排序,采用固定的顺序进行加锁。如书中建议使用System.identityHashCode()来获取一致性hash,当hash碰撞时,采用“加时赛”的方式,额外增加一个锁以保证每次只有一个线程以未知顺序获得锁,从而消除死锁可能性。如果加锁对象包含一个唯一的、不可变的且具备可比性的键值属性,如id,那么制定锁规则会更加容易。

在协作对象之间发生的死锁

如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中,可能会获取其他锁,这可能会产生死锁,或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。

开放调用

在调用某个方法时不需要持有锁,那么这种调用被称为开放调用(Open Call)。

通过尽可能地使用开放调用,将易于找出那些需要获取多个锁的代码路径,因此也就容易确保采用一致的顺序来获得锁。

资源死锁

当多个线程相互持有彼此正在等待的资源,又不释放自己已持有的资源时,会发生资源死锁。

如线程A持有数据库D1的连接,并等待与数据库D2的连接,而线程B持有数据库D2并等待D1的连接,就会发生资源死锁。

另一个基于资源的死锁形式就是线程饥饿死锁。如:一个任务提交另一个任务,并等待被提交的任务在单线程Executor中执行完成,这种情况下,第一个任务将永远等待,其他任务将无法执行。如果某些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源,有限线程、资源池与相互依赖的任务不能一起使用。

死锁的避免与诊断

如果必须获得多个锁,那么在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数量,将获取锁时需要遵守的协议写入正式文档并始终遵循。

支持定时的锁

显式使用Lock类中的定时tryLock功能来代替内置锁机制:指定超时时限,在等待超时后返回一个失败信息。

通过线程转储信息来分析死锁

线程转储包含各个运行中的线程的栈追踪信息,类似于发生异常时的栈追踪信息。线程转储还包含加锁信息,例如每个线程持有了哪些锁,在哪些栈桢中获得这些锁,以及被阻塞的线程正在等待哪个锁。

其他活跃性危险

饥饿

当线程由于无法访问它所需要的资源而不能继续执行时,就发生了“饥饿”,引发饥饿最常见的资源就是CPU时钟周期。如果Java程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(如无限循环、无限等待某个资源),那么也可能导致饥饿。

Thread API中定义的线程优先级只是作为线程调度的参考,其中10个优先级会被JVM根据需要映射到操作系统的调度优先级,这种映射是与特定平台相关联的。

要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数程序中,都可以使用默认线程优先级。

糟糕的响应性

CPU密集型的后台任务可能对响应性造成影响,因为它们会与响应线程共同竞争CPU时钟周期,这时应该降低它们的线程优先级,提高前台的响应性。

不良的锁管理也可能导致糟糕的响应性,如对一个大容器进行迭代并对每个元素进行计算密集的处理。

活锁

当多个相互协作的线程都对彼此进行响应,从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。

要解决这种活锁问题,需要在重试机制中引入随机性。