死锁与活跃度
前面谈了很多并发的特性和工具,但是大部分都是和锁有关的。我们使用锁来保证线程安全,但是这也会引起一些问题。
- 锁顺序死锁(lock-ordering deadlock):多个线程试图通过不同的顺序获得多个相同的资源,则发生的循环锁依赖现象。
- 动态的锁顺序死锁(Dynamic Lock Order Deadlocks):多个线程通过传递不同的锁造成的锁顺序死锁问题。
- 资源死锁(Resource Deadlocks):线程间相互等待对方持有的锁,并且谁都不会释放自己持有的锁发生的死锁。也就是说当现场持有和等待的目标成为资源,就有可能发生此死锁。这和锁顺序死锁不一样的地方是,竞争的资源之间并没有严格先后顺序,仅仅是相互依赖而已。
锁顺序死锁
最经典的锁顺序死锁就是LeftRightDeadLock.
final Object left = new Object();
final Object right = new Object();
public void doLeftRight() {
synchronized (left) {
synchronized (right) {
execute1();
}
}
}
public void doRightLeft() {
synchronized (right) {
synchronized (left) {
execute2();
}
}
}
private void execute2() {
}
private void execute1() {
}
}
这个例子很简单,当两个线程分别获取到left和right锁时,互相等待对方释放其对应的锁,很显然双方都陷入了绝境。
动态的锁顺序死锁
与锁顺序死锁不同的是动态的锁顺序死锁只是将静态的锁变成了动态锁。 一个比较生动的例子是这样的。
Account toAccount,//
int amount
) {
synchronized (fromAccount) {
synchronized (toAccount) {
fromAccount.decr(amount);
toAccount.add(amount);
}
}
}
当我们银行转账的时候,我们期望锁住双方的账户,这样保证是原子操作。 看起来很合理,可是如果双方同时在进行转账操作,那么就有可能发生死锁的可能性。
很显然,动态的锁顺序死锁的解决方案应该看起来和锁顺序死锁解决方案差不多。 但是一个比较特殊的解决方式是纠正这种顺序。 例如可以调整成这样:
public void transferMoney(Account fromAccount,//
Account toAccount,//
int amount
) {
int order = fromAccount.name().compareTo(toAccount.name());
Object lockFirst = order>0?toAccount:fromAccount;
Object lockSecond = order>0?fromAccount:toAccount;
if(order==0){
synchronized(lock){
synchronized(lockFirst){
synchronized(lockSecond){
//do work
}
}
}
}else{
synchronized(lockFirst){
synchronized(lockSecond){
//do work
}
}
}
}
这个挺有意思的。比较两个账户的顺序,保证此两个账户之间的传递顺序总是按照某一种锁的顺序进行的, 即使多个线程同时发生,也会遵循一次操作完释放完锁才进行下一次操作的顺序,从而可以避免死锁的发生。
资源死锁
资源死锁比较容易理解,就是需要的资源远远大于已有的资源,这样就有可能线程间的资源竞争从而发生死锁。 一个简单的场景是,应用同时从两个连接池中获取资源,两个线程都在等待对方释放连接池的资源以便能够同时获取 到所需要的资源,从而发生死锁。
资源死锁除了这种资源之间的直接依赖死锁外,还有一种叫线程饥饿死锁(thread-starvation deadlock)。 严格意义上讲,这种死锁更像是活跃度问题。例如提交到线程池中的任务由于总是不能够抢到线程从而一直不被执行, 造成任务的“假死”状况。
除了上述几种问题外,还有协作对象间的死锁以及开发调用的问题。这个描述起来会比较困难,也不容易看出死锁来。
避免和解决死锁
通常发生死锁后程序难以自恢复。但也不是不能避免的。 有一些技巧和原则是可以降低死锁可能性的。
最简单的原则是尽可能的减少锁的范围。锁的范围越小,那么竞争的可能性也越小。 尽快释放锁也有助于避开锁顺序。如果一个线程每次最多只能够获取一个锁,那么就不会产生锁顺序死锁。尽管应用中比较困难,但是减少锁的边界有助于分析程序的设计和简化流程。 减少锁之间的依赖以及遵守获取锁的顺序是避免锁顺序死锁的有效途径。
另外尽可能的使用定时的锁有助于程序从死锁中自恢复。 例如对于上述顺序锁死锁中,使用定时锁很容易解决此问题。
boolean over = false;
while (!over) {
if (left.tryLock(1, TimeUnit.SECONDS)) {
try {
if (right.tryLock(1, TimeUnit.SECONDS)) {
try {
execute1();
} finally {
right.unlock();
over = true;
}
}
} finally {
left.unlock();
}
}
}
}
public void doRightLeft() throws Exception {
boolean over = false;
while (!over) {
if (right.tryLock(1, TimeUnit.SECONDS)) {
try {
if (left.tryLock(1, TimeUnit.SECONDS)) {
try {
execute2();
} finally {
left.unlock();
over = true;
}
}
} finally {
right.unlock();
}
}
}
}
看起来代码会比较复杂,但是这是避免死锁的有效方式。
活跃度
对于多线程来说,死锁是非常严重的系统问题,必须修正。除了死锁,遇到很多的就是活跃度问题了。 活跃度问题主要包括:饥饿,丢失信号,和活锁等。
饥饿
饥饿是指线程需要访问的资源被永久拒绝,以至于不能在继续进行。 比如说:某个权重比较低的线程可能一直不能够抢到CPU周期,从而一直不能够被执行。
也有一些场景是比较容易理解的。对于一个固定大小的连接池中,如果连接一直被用完,那么过多的任务可能由于一直无法抢占到连接从而不能够被执行。这也是饥饿的一种表现。
对于饥饿而言,就需要平衡资源的竞争,例如线程的优先级,任务的权重,执行的周期等等。总之,当空闲的资源较多的情况下,发生饥饿的可能性就越小。
弱响应性
弱响应是指,线程最终能够得到有效的执行,只是等待的响应时间较长。 最常见的莫过于GUI的“假死”了。很多时候GUI的响应只是为了等待后台数据的处理,如果线程协调不好,很有可能就会发生“失去响应”的现象。
另外,和饥饿很类似的情况。如果一个线程长时间独占一个锁,那么其它需要此锁的线程很有可能就会被迫等待。
活锁
活锁(Livelock)是指线程虽然没有被阻塞,但是由于某种条件不满足,一直尝试重试,却终是失败。
考虑一个场景,我们从队列中拿出一个任务来执行,如果任务执行失败,那么将任务重新加入队列,继续执行。假如任务总是执行失败,或者某种依赖的条件总是不满足,那么线程一直在繁忙却没有任何结果。
错误的循环引用和判断也有可能导致活锁。当某些条件总是不能满足的时候,可能陷入死循环的境地。
线程间的协同也有可能导致活锁。例如如果两个线程发生了某些条件的碰撞后重新执行,那么如果再次尝试后依然发生了碰撞,长此下去就有可能发生活锁。
解决活锁的一种方案是对重试机制引入一些随机性。例如如果检测到冲突,那么就暂停随机的一定时间进行重试。这回大大减少碰撞的可能性。
另外为了避免可能的死锁,适当加入一定的重试次数也是有效的解决办法。尽管这在业务上会引起一些复杂的逻辑处理。