引言
本文属于作者笔记,知识点来自《java并发编程实战》。
活跃性
一个并发应用程序能及时执行的能力称为活跃性。
活跃性故障
活跃性故障是指为确保线程安全性过度使用加锁而使程序无法继续执行下去的原因。这些原因包括:死锁(最常见得活跃性故障)、饥饿、丢失信号和活锁。
死锁
当一个线程永远的持有一个锁,而其他线程也想获得这个锁时,他们就会永远阻塞,就产生了死锁的一种形式(抱死)。
jvm在解决死锁问题上只能通过中止并重启应用程序。
- 顺序死锁
程序LeftRigheDeadlock存在死锁风险
class LeftRightDeadlock{
private final Object left = new Object();
private final Object right=new Object();
public void leftRight(){
synchronized (left){
synchronized (right){
}
}
}
public void rightLeft(){
synchronized (right){
synchronized (left){
}
}
}
}
leftRight和rightLeft这2个方法分别获得left锁和right锁。如果一个线程调用了leftRight,另一个线程调用了rightLeft,并且线程之间的操作是交替执行,那么他们就会发生死锁。如下图所示:
发生死锁的原因是:2个线程试图以不同的顺序来获得相同的锁。
如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现顺序死锁问题。
- 动态顺序死锁
有时候,并不能清楚的知道是否在锁顺序上有足够的控制权来避免死锁。比如下列程序:
public void transferMoney(Account fromAccount,Account toAccount,DollarAmount amount){
synchronized (fromAccount){
synchronized (toAccount){
if(账户余额不足){
throw "余额不足异常";
}else {
fromAccount.debit(amount);
toAccount.credit(amount);
}
}
}
}
锁的顺序取决于transferMoney参数的顺序,而参数取决于外部输入。如果两个线程同时调用transferMoney,其中一个线程x向y转账,另一个线程y向x转账,就会发生死锁。第一个线程获得fromAccount的锁并等待toAccount锁时,而第二个线程toAccount的锁并等待fromAccount的锁。
要解决此问题就必须要定义锁的顺序,如果在账户中存在唯一键值就可以对键值对对象排序按照排序顺序定义锁的顺序。也可以通过hashcode来进行排序。
- 资源死锁
多个线程在相同的资源集合上等待时,也会发生死锁(资源死锁)
比如当一个任务需要连接2个数据库,并且请求资源时不会始终以相同的顺序请求。那么线程A可以持有D1连接并等待D2连接,线程B持有D2的连接而等待D1的连接。(线程池越大这种情况越小)
另一种是线程饥饿死锁。比如:一个任务提交另一个任务。并等待被提交任务在单线程的Executor中的执行完成
死锁避免与诊断
如果必须获得多个锁,在设计时就必须考虑锁的顺序,并将锁的协议写入文档并遵循。
在使用细粒度锁的程序中,可以通过一种两阶段策略来检查代码里的死锁。第一阶段找出在什么地方将要获得多个锁,第二阶段进行全局分析确保在应用程序中的获取锁顺序保持一致。
- 支持定时的锁
有一项技术可以检查死锁和在死锁中恢复,即使用显示的Lock类中的定时tryLock来代替内置锁。显示锁可以设置一个锁获取超时时限,获取锁超过设置时限就会返回一个失败信息。 - 通过线程转储信息分析
虽然防止死锁的主要责任在于你自己,但JVM仍然通过线程转储( Thread Dump)来帮助识别死锁的发生。线程转储包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息。线程转储还包含加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取哪一个锁。在生成线程转储之前,JVM将在等待关系图中通过搜索循环来找出死锁。如果发现了一个死锁,则获取相应的死锁信息,例如在死锁中涉及哪些锁和线程,以及这个锁的获取操作位于程序的哪些位置。
要在UNX平台上触发线程转储操作,可以通过向JM的进程发送 SIGQUIT信号(kil-3),或者在UNX平台中按下Crl-键,在 Windows平台中按下Ctr- Break键。在许多IDE(集成开发环境)中都可以请求线程转储。
如果使用显式的Lock类而不是内部锁,那么Java5.0并不支持与Lock相关的转储信息,在线程转储中不会出现显式的Lock。虽然Java6中包含对显式Lock的线程转储和死锁检测等的支持,但在这些锁上获得的信息比在内置锁上获得的信息精确度低。内置锁与获得它们所在的线程栈帧是相关联的,而显式的Lock只与获得它的线程相关联。
饥饿
当线程由于无法访问他所需要的资源而不能继续执行时,就发生了饥饿,引发饥饿的常见资源时CPU的时钟周期。在java中对线程优先级使用不当和在持有锁时执行一些无法结束的结构(无限循环,无限制等待某个资源等),可能导致饥饿,因为需要这个锁的线程无法得到他。
活锁
当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。这就像两个过于礼貌的人在半路上面对面地相遇:他们彼此都让出对方的路,然而又在另一条路上相遇了。因此他们就这样反复地避让下去。
要解决这种活锁问题,需要在重试机制中引入随机性。例如,在网络上,如果两台机器尝试使用相同的载波来发送数据包,那么这些数据包就会发生冲突。这两台机器都检查到了冲突,并都在稍后再次重发。如果二者都选择了在1秒钟后重试,那么它们又会发生冲突,并且不断地冲突下去,因而即使有大量闲置的带宽,也无法使数据包发送出去。为了避免这种情况发生,需要让它们分别等待一段随机的时间。(以太协议定义了在重复发生冲突时采用指数方式回退机制,从而降低在多台存在冲突的机器之间发生拥塞和反复失败的风险。)在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。
丢失信号
丢失信号是指:线程必须等待一个已经为真的条件,但在开始等待之前没有检查条件谓词。现在线程等待一个已经发生过的事件。
小结
活跃性故障是一个非常严重的问题,因为当出现活跃性故障时,除了中止应用程序之外没有其他任何机制可以帮助从这种故障时恢复过来。最常见的活跃性故障就是锁顺序死锁。在设计时应该避免产生锁顺序死锁:确保线程在获取多个锁时采用一致的顺序。最好的解决方法是在程序中始终使用开放调用。这将大大减少需要同时持有多个锁的地方,也更容易发现这些地方。