安全性与活跃性之间一般存在着某种制衡:数据库
咱们平时使用加锁机制来确保线程安全,但若是过量地使用加锁,则可能致使锁顺序死锁。windows
一样,咱们使用线程池和信号量来限制对资源的使用,但这些被限制的行为可能致使资源死锁。安全
5个哲学家围坐在一个圆桌上,每两个哲学家之间都有一只筷子,哲学家平时进行思考,只有当他们饥饿时,才拿起筷子吃饭。规定每一个哲学家只能先取其左边筷子,而后取其右边筷子,而后才能够吃饭。若是5个哲学家同时拿起本身左边的筷子,就会发生死锁。每一个人都拥有其余人须要的资源,同时又等待其余人已经拥有的资源,而且每一个人在得到全部须要的资源以前都不会放弃已经拥有的资源。bash
当一个线程永远地持有一个锁,而且其余线程都尝试得到这个锁时,那么它们将永远被阻塞。这种状况就是最简单的死锁形式(称为抱死[Deadly Embrace]),其中多个线程因为存在环路的锁依赖关系而永远等待下去。(把每一个线程假想为有向图的一个节点,图中每条边表示的关系是:“线程A等待线程B所占有的资源”。若是图中造成一条环路,那么就存在一个死锁)。网络
发生死锁的缘由是:两个线程试图以不一样的顺序来得到相同的锁。若是按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性,不会产生死锁。若是每一个须要锁L和锁M的线程都以相同的顺序来获取L和M,就不会发生死锁并发
考虑下方的代码,它将资金从一个帐户转入到另外一个帐户。在开始转帐以前,首先要得到这两个Account对象的锁,以却不经过原子方式来更新两个帐户中的余额,同时又不能破坏一些不变性条件,例如“帐户的余额不能为负数”。框架
//容易发生死锁
public void transferMoney(Account fromAccount,
Account toAccount,
DollarAmount amount)
throws InsufficientFundsException {
synchronized (fromAccount) {
synchronized (toAccount) {
if (fromAccount.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAccount.debit(amount);
toAccount.credit(amount);
}
}
}
复制代码
全部的线程彷佛按相同的顺序来得到锁,但事实上锁的顺序取决与传递给transferMoney的参数顺序,而这些参数顺序又取决与外部输入。 若是两个线程同时调用transferMoney,其中一个线程从X向Y转帐,而另外一个线程从Y向X转帐,那么就会发生死锁:dom
A: transferMoney(myAccount, yourAccount, 10);ide
B: transferMoney(yourAccount, myAccount, 20);工具
若是执行时序不当,那么A可能得到myAccount的锁并等待yourAccount的锁,然而B此时拥有yourAccount的锁并正在等到myAccount的锁。
在制定锁的顺序时,可使用System.identityHashCode方法,该方法将返回由Object.hashCode返回的值。下方给出另外一个版本的transferMoney,使用了System.identityHashCode来定义锁的顺序。 虽然加了一些新的代码,但却消除了死锁的可能性。
// 经过锁顺序来避免死锁
private static final Object tieLock=new Object();
public void transferMoney(final Account fromAcct,
final Account toAcct,
final DollarAmount amount)
throws InsufficientFundsException{ //自定义异常类,继承Exception类,当取款的数额大于存款时抛出
class Helper{
public void transfer()throws InsufficientFundsException {
if(fromAcct.getBalance().compareTo(amount)<0)
throw new InsufficientFundsException();
else{
fromAcct.debit(amount); //debit记入借方,fromAcct减小amount
toAcct.credit(amount); //credit记入贷方,toAcct增长amount
}
}
}
//使用了System.identityHashCode来定义锁的顺序
////返回给定对象的哈希码,该代码与默认的方法 hashCode() 返回的代码同样,不管给定对象的类是否重写 (override)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{ //若是获得两个相同的hashcode,使用加时赛锁,从而保证每次只有一个线程以未知的顺序获得这两个锁
synchronized (tieLock) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
}
}
}
复制代码
在极少数状况下,两个对象可能拥有相同的散列值(HashCode),此时必须经过某种任意的方法来决定锁的顺序,而这可能又会从新引入死锁。为了不这种状况,可使用“加时赛(Tie-Breaking)锁”。在得到两个Account锁以前,首先得到这个“加时赛”锁,从而保证每次只有一个线程以未知的顺序获得这两个锁,从而消除了死锁发生的可能性(只要一致地使用这种机制)。
若是常常出现散列冲突(hash collisions),那么这种技术可能会称为并发性的一个瓶颈(相似与在整个程序中只有一个锁的状况,由于常常要等待得到加时赛锁),但因为System.identityHashCode中出现散列冲突的频率很是低,所以这项技术以最小的代价,换来了最大的安全性。
若是在Account中包含一个惟一的,不可变的,而且具有可比性的键值,例如帐号,那么要指定锁的顺序就更加容易:经过键值对对象进行排序,于是不须要使用“加时赛”锁。
某些获取多个锁的操做并不想上面那么明显,这两个锁不必定在同一个方法中被获取。下方中两个互相协做的类,在出租车调度系统中可能会用到它们。Taxi表明一个出租车对象,包含位置和目的地两个属性,Dispatcher表明一个出租车车队。
// 在互相协做对象之间的锁顺序死锁(不要这样作)
// 容易发生死锁
class Taxi{
private Point location,destination; //Taxi表明一个出租车对象,包含位置和目的地两个属性
private final Dispatcher dispatcher; //Dispatcher表明一个出租车车队
public Taxi(Dispatcher dispatcher){
this.dispatcher=dispatcher;
}
public synchronized Point getLocation(){
return location;
}
public synchronized void setLocation(Point location){
this.location=location;
if(location.equals(destination))
dispatcher.notifyAvailable(this);
}
}
class Dispatcher{
private final Set<Taxi> taxis;
private final Set<Taxi> availableTaxis;
public Dispatcher(){
taxis=new HashSet<Taxi>();
availableTaxis=new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi){ //notify 通知 ,available 空闲的
availableTaxis.add(taxi);
}
public synchronized Image getImage(){
Image image = new Image();
for (Taxi t : taxis)
image.drawMarker(t.getLocation());
return image;
}
}
复制代码
尽管没有任何方法显式地获取两个锁,但setLocation和getImage等方法的调用者都会得到两个锁。若是一个线程在收到GPS接收器的更新事件时调用setLocation,那么它将首先更新出租车的位置,而后判断它是否到达了目的地。若是到达了,它会通知Dispatcher:它须要一个新目的地。由于setLocation和notifyAvailable都是同步方法,所以调用setLocation的线程将首先得到Taxi的锁,而后再得到Dispatcher的锁。一样,调用getImage的线程将首先获取Dispatcher锁,而后再获取每个Taxi的锁(每次获取一个)。这与LeftRightDeadlock中的状况相同,两个线程按照不一样的顺序来获取两个锁,所以可能产生死锁。
在LeftRightDeadlock和transferMoney中,要查找死锁时比较简单的:只须要找出那些须要获取两个锁的方法。然而要在Taxi和Dispatcher中查找死锁是比较困难的:若是在持有锁的状况下须要调用某个外部方法,就须要警戒死锁。
若是在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能或获取其余锁(这可能产生死锁),或者阻塞时间过长,致使其余线程没法及时得到当前被持有的锁。
方法调用至关于一种抽象屏障,你无需了解在调用方法中所执行的操做,也正是因为不知道在被调用方法中执行的操做,所以在持有锁的时候对调用某个外部方法将难以进行分析,从而可能出现死锁。
若是在调用某个方法时不须要持有锁,那么这种调用被称为开放调度(Open Call)。
依赖于开放调度的类一般能表现出更好的行为,而且与那些在调度方法时须要持有锁的类相比,也更易于编写。 这种经过开放来避免死锁的方法,相似于采用封装机制来提供线程安全的方法:虽然在没有封装的状况下也能确保构建线程安全的类,但对一个使用了封装的程序进行线程安全分析,要比分析没有使用封装的程序容易得多。 同理,分析一个彻底依赖于开放调用的程序的活跃性,要比分析那些不依赖开放调用的程序的活跃性简单。 经过尽量地使用开放调用,将更容易找出那些须要获取多个锁的代码路径,所以也就更容易确保采用一直的顺序来得到锁。
将上方代码修改成开放调用,从而消除死锁的风险,这须要使同步代码块仅被用于保护那些涉及共享状态的操做。
一般,若是只是为了语法紧凑或简单性(而不是由于整个方法必须经过一个锁来保护)而使用同步方法(而不是同步代码块),将致使上方代码中的问题。
//经过公开调用来避免在互相协做的对象之间产生死锁
class Taxi{
private Point location,destination; //Taxi表明一个出租车对象,包含位置和目的地两个属性
private final Dispatcher dispatcher; //Dispatcher表明一个出租车车队
public Taxi(Dispatcher dispatcher){
this.dispatcher=dispatcher;
}
public synchronized Point getLocation(){
return location;
}
public void setLocation(Point location){
boolean reachedDestination;
synchronized (this) {
this.location=location;
reachedDestination=location.equals(destination);
}
if(reachedDestination)
dispatcher.notifyAvailable(this);
}
}
class Dispatcher{ //调度
private final Set<Taxi> taxis;
private final Set<Taxi> availableTaxis;
public Dispatcher(){
taxis=new HashSet<Taxi>();
availableTaxis=new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi){ //notify 通知 ,available 空闲的
availableTaxis.add(taxi);
}
public Image getImage(){
Set<Taxi> copy;
synchronized (this) {
copy=new HashSet<Taxi>(taxis);
}
Image image=new Image();
for(Taxi t:copy)
image.drawMarker(t.getLocation());
return image;
}
}
复制代码
在程序中应尽可能使用开放调用,与那些在持有锁时调用外部方法的程序相比,更容易对依赖于开放调用的程序进行死锁分析。
有时候在从新编写同步代码块以使用开放调用时会产生意想不到的结果,由于这会使得某个原子操做变成非原子操做。 在许多状况下,某个操做失去原子性是能够接受的。例如,对于两个操做:更新出租车位置以及通知调度程序这辆出租车已准备好出发去一个新的目的地,这两个操做并不须要实现为一个原子操做。
然而,在某些状况下,丢失原子性会引起错误,此时须要经过另外一种技术来实现原子性。 例如,在构造一个并发对象时,使得每次只有单个线程执行使用了开放调用的代码路径。 例如,在关闭某个服务时,你可能但愿全部正在运行的操做执行完成之后,再释放这些服务占用的资源。若是在等待操做完成的同时持有该服务的锁,那么将容易致使死锁,但若是在服务关闭以前就释放服务的锁,则可能致使其余线程开始新的操做。 这个问题的解决方法是,在将服务的状态更新为“关闭”以前一直持有锁,这样其余想要开始新操做的线程,包括想关闭该服务的其余操做,会发现服务已经不可用,所以也就不会试图开始新的操做。而后,你能够等待关闭操做结束,而且知道当开放调用完成后,只有执行关闭操做的线程才能访问服务的状态。所以,这项技术依赖于一些协议(而不是经过加锁)来防止其余线程来进入代码的临界区。
正如当多个线程相互持有彼此正在等待的锁而不释放本身已持有的锁时发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。
假设有两个资源池,例如两个不一样数据库的链接池。资源池一般采用信号量来实现(5.5.3)当资源池为空的阻塞行为。若是一个任务须要链接两个数据库,而且在请求这两个资源时不会始终遵循相同的顺序,那么线程A可能持有与数据库D1的链接,并等待与数据库D2的链接,而线程B则持有与D2的链接并等待与D1的链接(资源池越大,出现这种状况的可能性就越小,若是每一个资源池都有N个链接,那么在发生死锁时不只须要N个循环等待的线程,并且还须要大量不恰当的执行时序)
另外一种基于资源的死锁形式就是线程饥饿死锁(Thread-Starvation Deadlock)。
一个示例:一个任务提交另外一个任务,并等待被提交任务在单线程的Executor中执行完成。这种状况天,第一个任务将永远等待下去,并使得另外一个任务以及在这个Executor中执行的全部其余任务都中止执行。若是某些任务须要等待其余任务的结果,那么这些任务每每时产生线程饥饿死锁的主要来源,有界线程池/资源池与相互依赖的任务不能一块儿使用。
若是必须获取多个锁,那么在设计时必须考虑锁的顺序:尽可能减小潜在的加锁 交互数量,将获取锁时须要遵循的协议写入正式文档并始终遵循这些协议。
在使用细粒度(fine-grained)锁的程序中,能够经过使用一种两阶段策略(Two-Part Strategy)来检查代码中的死锁:首先,找出在什么地方将获取多个锁(使这个集合尽可能小),而后对全部这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。尽量地使用开放调用,这能极大地简化分析过程。 若是全部的调用都是开放调用,那么要发现获取多个锁的实例是很是简单的,能够经过代码审查或者借助自动化的源代码分析工具。
当使用内置锁时,只要没有得到锁,就会永远等待下去,而显式锁则能够执行一个超时时限(Timeout),在等待超过该事件后tryLock会返回一个失败信息。
若是超时时限要比获取锁的时间要长不少,那么就能够在发生某个之外状况后从新得到控制权。
当定时锁失败时,并不须要知道失败的缘由。或许是由于发生了死锁,或许某个线程在持有锁时错误地进入了无限循环,还多是某个操做的执行时间远远超出了预期。 然而,至少能记录所发生的失败,以及关于此次操做的其余有用信息,并经过一种更平缓的方法来从新启动计算,而不是关闭整个进程。
即便在整个系统中没有始终使用定时锁,使用定时锁来获取多个锁也能有效地应对死锁问题。 若是在获取锁时超时,那么能够释放这个锁,而后后退并在一段时间后再次蚕食,从而消除了死锁发生的条件,使程序恢复过来。(这项技术只有在同时获取两个锁时才有效,若是在嵌套的方法调用中请求多个锁,那么即便你知道已经有了外层的锁,也没法释放它)
JVM经过线程转储(Thread Dump)来帮助识别死锁的发生。
线程转储包括各个运行中的线程的栈追踪信息,这相似于发生异常时的栈追踪信息。
线程转储还包含加锁信息,例如每一个线程持有了哪些锁,在那些栈帧中得到这些锁,以及被阻塞的线程正在等待获取哪个锁。 在生成线程转储以前,JVM将在等待关系图中经过搜索循环来找出死锁。若是发现了一个死锁,则获取相应的死锁信息,例如在死锁中涉及哪些锁和线程,以及这个锁的获取操做位于程序的哪些位置.
要在UNIX平台上触发线程转储操做,能够经过向JVM的进程发送SIGQUIT信息(kill-3),或者在UNIX平台中按下Ctrl-\键,在windows平台中按下Ctrl-Break键。在许多IDE(Integrated Development Environment,集成开发环境)中均可以请求线程转储。
当有死锁发生时,能够发现相似以下的信息: Found One Java-level deadlock:
内置锁与得到它们所在的线程栈帧时相关联的,而显式的Lock只得到它的线程相关联。
死锁是最多见的活跃性危险,在并发线程中还存在一些其余的活跃性危险,包括:饥饿,丢失信号和活锁等。
当线程因为没法访问它所须要的资源而不能继续执行时,就发生了“饥饿(Starvation)”。
引起饥饿的最多见资源就是CPU时钟周期。若是在Java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些没法结束的结构(例如无限循环,或无限制等待某个资源),那么也可能致使饥饿,由于其余须要这个锁的线程将没法获得它。
在Thread API定义的线程优先级只是做为线程调度的参考。在Thread API中定义了10个优先级,JVM根据须要将它们映射到操做系统的调度优先级,这种映射时与特定平台(不一样的操做系统)相关的。在某些操做系统中,若是优先级的数量少于10个,那么有多个Java优先级会被映射到同一个优先级。
要避免使用线程优先级,由于这会增长平台依赖性,并可能致使活跃性问题。在大多数并发应用程序中,均可以使用默认的线程优先级。
若是在GUI应用程序中使用了后台线程,那么糟糕的响应性时是常见的。
GUI框架中,若是你的后台任务是cpu密集型的,会与主的事件线程竞争cpu的时钟周期,可能致使cpu主线程的响应性,这时能够下降后台线程的优先级。
不良的锁管理也可能致使糟糕的响应性。若是某个线程长时间占有一个锁(或者正在对一个大容器进行迭代,而且对每一个元素进行计算密集的处理),而其余想要访问这个容器的线程就必须等待很长时间。
活锁(Livelock)是另外一种形式的活跃性问题,尽管不会阻塞线程,但也不能继续执行,由于线程不断重复执行相同的操做,并且总会失败。
活锁一般发生在处理事务消息的应用程序中:若是不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它从新放到队列的开头。若是消息处理其在处理某种特定类型的消息时存在错误并致使它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。因为这条消息又被放回到队列开头,所以处理器将被反复调用,并返回先沟通的结果(有时候也被称为毒药消息,Poison Message)。
虽然处理信息的线程没有阻塞,但也没法继续执行下去。这种形式的活锁一般时由过分的错误恢复代码形成的,由于它错误将不可修复的错误做为可修复的错误。
当多个相互协做的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都没法继续执行时,就发生了活锁。 这就像两个过于礼貌的人在半路上面对面相遇了:他们彼此都让出对方的路,而后又在另外一条路上相遇了,所以他们就这样反复地避让下去。
要解决这种活锁问题,须要在重试机制中引入随机性(randomness)。 例如,在网络上,若是有两台机器尝试使用相同的载波来发送数据包,那么这些数据包就会发生冲突。这两台机器都检查到了冲突,并都在稍后再次发送。 若是两者都选择了在0.1秒后重试,那么会再次冲突,而且不断冲突下去,于是即便有大量闲置的宽带,也没法使数据包发送出去。 为了不这种状况发生,须要让它们分别等待一段随机的时间(以太协议定义了在重复发生冲突时采用指数方式回退机制,从而下降在多台存在冲突的机器之间发生拥塞和反复失败的风险)。
在并发应用程序中,经过等待随机长度的时间和回退能够有效地避免活锁的发生。