在安全性和活跃性之间通常存在某种制衡。我们使用锁来确保线程安全,但过度使用锁,则可能会导致死锁。Java应用程序无法从死锁中恢复过来,因此在设计并发程序的时候应该小心,尽可能排除死锁条件。
一、死锁
哲学家就餐问题可以很好的描述死锁。参加哲学家就餐问题。
死锁的四个必要条件(不分先后):
- 互斥条件。一个资源同一时间只能被一个线程持有。
- 占有并等待。一个线程申请某个资源而被阻塞时,不释放自己已持有的资源。
- 不可剥夺。一个线程不能去强行获取其他线程持有的资源。
- 循环等待。若干个线程形成头尾相接的循环等待关系。
上述四个必要条件,缺少一个就不会造成死锁,即需要四个条件同时成立。
数据库在执行事务的时候,两个事务之间很可能会发生死锁,但我们发现其实真正导致服务不可用的情况并不多。这是因为数据库可以检测到死锁发生的时候,会选择一个事务作为牺牲者,该事务会放弃其占有的资源,从而使得其他事务可以继续运行。
JVM对死锁的处理没有数据库那么强大,所以一般情况下如果出现了死锁,就会导致任务无法继续执行,只能强行中止并重启应用程序。与其他并发问题一样,即使程序有可能发生死锁,也不会立刻显现出来,往往会在高负载的时候出现,这往往是最糟糕的。
锁顺序死锁
锁顺序死锁即死锁的原因是获取锁的顺序不固定导致的。如下图所示:
不正确的顺序获取锁
针对由于获取锁的顺序不一致导致死锁的问题,如果所有线程都已相同的顺序获取锁,那么就不会出现死锁。例如上图的B线程也是先去获取left锁,再获取right锁,B在获取left锁的时候被阻塞,A可以顺利获取right锁,执行完毕后释放锁,B线程被唤醒去获取left锁,right锁,执行任务。最后两个线程都执行完毕,不会发生死锁。
资源死锁
当多个线程在相同资源集合上等待时,也会发生死锁。上一篇文章提到的“饥饿死锁”是其中一种资源死锁。资源死锁和顺序死锁的主要区别是死锁的来源不同,顺序死锁的来源主要是线程获取锁的顺序不固定导致的,资源死锁主要是线程永远无法获得某个资源,从而导致该线程无法继续执行,最终导致死锁。
二、死锁的避免与诊断
死锁在大多数情况下是有危害,而且如果程序发生了死锁,想再恢复到正常可能会非常困难,所以很多手段都是避免死锁的出现,即破坏死锁发生的条件,只需要破坏四个必要条件中的任意一个即可。
下面是几种避免死锁的方法:
- 支持定时锁。例如ReentrantLock提供的tryLock()带超时时间参数的版本,如果在指定时间内没有获取到锁,那么线程就会从阻塞状态中恢复过来,从而避免了永久等待。
- 通过线程转储信息来分析死锁。通过线程转储信息可以分析线程的行为,如果发生了死锁,JVM可以帮我们做很多工作,例如哪些锁导致了问题,涉及哪些线程,它们持有哪些锁等等。从这些信息可以定位到问题根源,从而解决问题。
- 银行家算法。银行家算法是一种避免死锁的的著名算法。可以有效的避免发生失效,这算是一种预防措施,将死锁扼杀在摇篮里。详细参见银行家算法
三、其他活跃性危险
死锁是最常见的活跃性,但在并发程序中还存在其他活跃性问题,包括:饥饿,丢失信号和活锁等。
饥饿
引发饥饿的最常见资源就是CPU周期。如果在Java应用程序中对线程的优先级使用不当,或者在持有锁的时候执行一些无法正常结束的结构(例如无限循环,或者无限期的等待某个资源等),那么也可能导致饥饿的发生。
在Thread API中有定义线程优先级,用int值表示,最小是1,最大是10,默认是5(JDK8是这样的)。不过这个优先级只是参考,JVM会将其和具体的操作系统平台做映射,故有可能在Thread中设置两个不同值的优先级,但映射到操作系统会变成同一优先级。
我们应该尽量不去设置优先级,因为操作系统的线程调度机制会尽可能的做到公平且活跃性良好从而避免活跃性问题。故在大多数并发应用程序中,都可以使用默认的线程优先级(JDK8中是5)。
糟糕的响应性
如果应用程序对时间非常敏感,例如大多数实时系统,那么糟糕的响应性将会大大降低用户体验。例如一个GUI程序,用户点击了某个按钮,例如搜索,如果程序直接在主线程中执行搜索任务,那么GUI界面就会被卡死,用户无法操作,甚至有一些操作系统会提醒用户关闭程序(例如Windows)。所以我们应该将搜索任务设置为后台线程去处理,前台线程只负责和用户交互,这样就避免了糟糕的响应性。
不良的管理也会导致糟糕的响应性,例如操作某一Web服务后端数据库,如果数据库记录非常庞大,那么响应时间将呈指数增长。加入一些管理机制,例如分库分表等可以缓解这个问题。
活锁
活锁和饥饿的表现有些相似,可能会将他俩混淆,但理清概念就不会混淆了。关键就是:饥饿是无法获取锁,即无法获取资源,而活锁是已经获取了锁,但总是无法执行成功,导致线程总是来再次获取锁,再次执行相同的任务,如此往复。例如数据库事务,当一个事务在执行任务的过程中出现错误,事务就会回滚,并将其重新放在队列的开头,下次就会调度它,但由于系统中存在错误,这个事务执行的时候又会出现错误,再次回滚,再次放入队列的开头,如此往复......
用我们日常生活中的一个例子类比会更容易理解活锁。想象这么一个场景,两个人在路上相遇,双方都想移开给对方让路,然后两人再次相遇,一般情况下,这个过程会持续两到三次,最后会有一方选择不让,两人顺利通过。这就是发生了活锁,我们人类是比较智能的,所以持续几次就会结束,但计算机是严格按照程序执行的,故这个过程可能会持续都永远。
和两人相遇这个场景相似的有向数据链路上发送数据包。网络资源也是有限且宝贵的,数据包需要通过数据链路发送到目的地,如果两台主机向同一链路发送数据包,它们就会发生冲突,当检测到冲突的时候,一般会选择重新发送,如果两台主机都选择1s后重新发送,那么它们极有可能再次发生冲突,如此往复......解决问题的方案就是引入随机性,当发生冲突的时候,随机选择一个重发时间,从而大大降低再次冲突的可能性(并不能完全根除)。
小结
活跃性问题是非常严重的问题,一旦出现了活跃性问题,往往除了中止并重启程序之外没有其他机制可以帮助从这种故障中恢复过来,故应该在设计程序的时候避免,预防出现活跃性问题。
网友评论