避免活跃性危险
安全性和活跃性是相对的,我们用加锁机制确保线程安全性的同时也可能因为死锁等原因产生活跃性问题。
死锁
在数据库系统的设计中考虑了监测死锁以及从死锁中恢复。在执行事务时,如果在某个事务上发生了死锁的问题,那么 它会先中止这个事务,然后执行其它的,当其它的事务都执行完毕的时候 ,回来再重新执行这个刚才被抛弃的事务。(解决环路的问题)
不过JVM没有这套系统,当一组java线程发生死锁时,这些线程将永不能使用了。根据线程的工作不同,应用程序可能完全停止。唯一的解决办法就是重启。
锁顺序死锁
有两个以上的资源,一定要按照顺序获得相关的锁,防止出现互等的情况发生。
动态的锁顺序死锁
有时候,并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生。例如:银行的两个账户之间的转账,由于这两个账户是由外部输入,当两个账户相互转账时,这个很容易发生死锁。
解决办法:通过锁顺序来避免死锁,解铃还需系铃人嘛,既然是锁顺序引起的,那么规定一个固定的锁顺序就能解决这个问题了。
在制定锁顺序时,可以使用System.identityHashCode方法,该方法将返回由Object.hashCode返回的值。下面的代码就是加锁顺序的代码。
int fromHash = System.identityHashCode(fromacct);
int toHash = System.identityHashCode(toacct);
if (fromHash < toHash) {
synchronized (fromacct) {
synchronized (toacct) {
new Helper().transfer();
}
}
} else if (fromHash > toHash) {
synchronized (toacct) {
synchronized (fromacct) {
new Helper().transfer();
}
}
} else {
synchronized (tieLock) {
synchronized (fromacct) {
synchronized (toacct) {
new Helper().transfer();
}
}
}
}
System.identityHashCode方法就是获得object里面hashcode的结果。极少数情况下,两个对象可能有相同的散列值。采用加时赛锁。在获得两个Account锁之前,先获得这个加时赛锁。从而保证每次只有一个线程以未知的顺序获得这两个锁。
如果在Account中包含一个唯一的,不可变的并且具备可比性的键值,例如账号,那么要制定锁的顺序就更加容易了:通过键值对对象进行排序,因此不需要使用加时赛锁了。
在协作对象之间发生的死锁
在协作对象之间可能存在多个锁获取的情况,但是这些获取多个锁的操作并不像在LeftRightDeadLock或transferMoney中那么明显,这两个锁并不一定必须在同一个方法中被获取。如果在持有锁时调用某个外部方法,那么这就需要警惕死锁问题,因为在这个外部方法中可能会获取其他锁,或者阻塞时间过长,导致其他线程无法及时获取当前被持有的锁。例如出租车调度系统中,Taxi代表一个出租车对象,包含位置和目的地两个属性。
开放调用
开放调用,是指在调用某个方法时不需要持有锁。开放调用可以避免死锁,这种代码更容易编写。这种通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:虽然在没有封装的情况下也能确保构建线程安全的程序,但对一个使用了封装的程序进行线程安全分析,要比分析没有使用封装的程序容易的多。同理,分析一个完全依赖于开放调用的程序的活跃性,要比分析那些不依赖于开放调用的程序的活跃性简单。通过尽可能地使用开放调用,将更易于找出那些需要获取多个锁的代码路径,因此也就更容易确保采用一致的顺序来获得锁。
与那些在持有锁外部方法的程序相比,更易于对依赖开放调用的程序进行死锁分析。
资源死锁
当多个线程在相同的资源集合上等待时,也会发生死锁(等待资源池中的多个资源资源时,可能会发生死锁)。
所以限时锁可能也是一种解决问题的思路。
在使用线程池执行任务时,如果任务依赖于其他任务,那么就可能产生死锁问题。在单线程的Executor中,若果一个任务将另一个任务提交到同一个Executor,并且等待这个被提交的任务的结果,那么这必定会导致死锁。第一个任务在工作队列中,并等待第二个任务的结果;而第二个任务则处于等待队列中,等待第一个任务执行完成后被执行。这就是典型的线程饥饿死锁。即使是在多线程的Executor中,如果提交到Executor中的任务之间相互依赖的话,也可能会由于工作线程数量不足导致的死锁问题。
如果某些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源,有界线程池/资源池与相互依赖的任务不能一起使用。
死锁的避免与诊断
尽管是一个并发高手可能也会因为大意便程序发生死锁,而死锁的原因也很多,像上面那种因为协作对象之间而发生的死锁是非常难发现的。所以我们在写程序的过程中应该尽量避免锁的交互。同时应尽可能的使用开放调用。另外避免使用多个锁,或者使得这个集合尽量小,然后对这些实例进行全局分析,从而确保他们在整个程序中获取锁的顺序都保持一致。尽可能地使用开放调用,这能极大地简化分析过程。如果所有的调用都是开放调用,那么要发现获取多个锁的实例是非常简单的,可以通过代码审查,或者借助自动化的源码分析工具。
支持定时的锁
显式使用Lock类中的定时tryLock功能来替代内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁则可以指定一个超时时限,在等待超过该事件后tryLock会返回一个失败信息。如果超时时限比获得锁的时间要长得多,那么久可以在发生某个意外情况后重新获得控制权。
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
在定时锁失败时,你并不需要知道失败的原因。或许是因为发生了死锁,或许某个线程在持有锁时错误地进入了无限循环,还可能是某个操作的执行时间超过了你的预期。然而,至少你能记录所发生的失败,以及关于这次操作的其他有用信息,并通过一种更平缓的方式来重新启动计算,而不是关闭整个进程。
定时锁技术只有在同时获取两个锁的时候才有效,如果在嵌套的方法调用中请求多个锁,那么即使你知道已经持有了外层的锁,也无法释放它。
通过线程转储信息来分析死锁
JVM通过线程转储来帮助识别死锁的发生。线程转储中包含加锁信息,例如每个线程持有了哪些锁,在哪些线桢中获得这些锁,以及被阻塞的线程正在等待获取哪一个锁。在生成线程转储之前,JVM将在等待转储之前,JVM将在等待关系图中通过搜索循环来找出死锁。如果发现了一个死锁,则获取相应的死锁信息,以及这个锁的获取操作位于程序的哪些位置。
要在UNIX可以向jvm的进程发送SIGQUIT信号,或者在UNIX平台按下ctrl-\。windows平台按下ctrl+break(这个break键笔记本是没有的。只有87键及以上的键盘有。一般用来调试服务器。)在许多IDE中都可以请求线程转储。
如果使用显式的lock类而不是内部锁,那么Java5并不支持与Lock相关的转储信息,在线程转储中不会出现显式的Lock。虽然Java6包含了对显式Lock的线程转储和死锁检测等支持,但在这些锁上获得的信息比内置锁上获得的信息精确度低。内置锁与获得它们所在的线程栈桢是相关联的,而显式的Lock只与获得它的线程相关联。
其他活跃性危险
饥饿
就是要执行任务的线程因为优先级等原因被迫等待,而造成的饥饿性等待。
你经常能发现某个程序会在一些奇怪的地方调用Thread.sleep和Thread.yield,这是因为该程序试图克服优先级调整问题或者响应性问题,并让低优先级的线程执行更多时间。
要避免使用线程优先级,因为这会增加平台依赖性。并可能导致活跃性问题。
可以查看相应的文档:Thread 常搞混的几个概念sleep、wait、yield、interrupt
糟糕的响应性
和饥饿是一类的问题,如果GUI应用程序中使用了后台线程,那么这种问题是很常见的。
不良的锁管理也可能导致糟糕的响应性。如果某个线程长时间占有一个锁,而其他想要访问这个容器的线程就必须等待很长时间。
活锁
活锁是另一种形式的活跃性问题,该问题尽管不用阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作。而且总会失败。活锁通常发生在处理事务消息的应用程序中,如果不能成功的处理某个消息,那么消息处理机制将回滚整个事务。并将它重新放到队列的开头。所以程序不会死掉。但也不能继续执行了。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的错误。
书上给了一个很形象的栗子,两个人走路遇见了,然后相互避让,结果又在另一条路上遇见,不断重复。
解决问题的办法是在重试机制中引入随机性。如,两台机器尝试使用相同的载波(就是频率,如果相同的话会发生混叠,使传输信号失真)来发送数据包,那么这些数据包就会发生冲突。并又双双重试。引入随机的概念后,让它们在等待随机的时间段后再重试。以太协议定义了重复发生冲突时采用指数方式回退机制,从而降低在多台存在冲突的机器之间发生拥塞和反复失败的风险。
在并发应用中,我们可以通过让程序等待随机长度的时间来避免活锁的发生。