jlearning.cn

《Java并发编程实战》读书笔记——避免活跃性问题

为了让并发程序安全,会选择加锁。但是过度、不恰当的加锁会导致“锁顺序死锁”。同样,使用线程池和信号量来限制对资源的使用,这些被限制的行为可能导致“资源死锁”。这一章会主要介绍死锁的分类,主要根据死锁产生的原因进行分类。和如何避免和诊断死锁。最后介绍死锁之外的其他活跃性风险,比如说饥饿等。

死锁

一个资源每次只能被一个人使用(互斥条件),每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源(请求与保持条件),并且在每个人在获得所有需要的资源之前都不放弃已经拥有的资源(不剥夺条件)。(循环等待)就产生了死锁。

锁顺序死锁

两个线程试图用不同的顺序来获得相同的锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class LeftRightDeadLock{
private final Object left = new Object();
private final Object right = new Object();
public void leftRight(){
synchronized(left){
synchronized(right){
doSomething();
}
}
}
public void rightLeft(){
synchronized(right){
synchronized(left){
doSomethind();
}
}
}
}

要想验证锁顺序的一致性,需要对程序中的加锁行为进行全局分析。

动态的锁顺序死锁

1
2
3
4
5
6
7
8
9
10
11
12
13
public void transferMoney(Account fromAccount, Account toAccount, DollarAmount amount)
throws InsufficientFundsException{
synchronized(fromAccount){
synchronized(toAccount){
if(fromAccount.getBalance().comparaTo(amount)<0)
throw new InsufficientFundsException();
else{
fromAccount.debit(amount);
toAccount.credit(amount);
}
}
}
}

虽然锁顺序都是先取得fromAccount的锁,在取得toAccount的锁,但是事实上上锁的顺序取决于传递给函数的参数的顺序。

这种死锁可以通过制定锁的顺序来解决。使用System.identityHashCode方法,每次先获得hashCode小的那个对象的锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private static final Object tieLock = new Object();
public void transferMonet(final Account fromAcct,final Account toAcct, final DollarAmount amount) throws insufficientFundsException{
class Helper{
public void transfer() throws InsufficientFundsException{
if(fromAcct.getBalance().comparaTo(amount)<0)
throw new insufficientFundsException();
else{
fromAcct.debit(amount);
toAcct.credit(amount);
}
}
}
int fromHash = System.identityHashCode(fromAcct);
int toHash = System.identityHashCode(toAcct);
if(fromHash<toHash){
synchronized(fromAcct){
synchronized(toAcct){
new Helper().transfer();
}
}
}else if(from Hash>toHash){
synchronized(toAcct){
synchronized(fromAcct){
new Helper().transfer();
}
}
}else{
//极少数的情况下,两个对象拥有相同的散列值,此时通过另一个锁保证每次只有一个线程以位置的顺序获得这两个锁。如果经常出先散列冲突的情况,这个地方因为相当于给整个程序加一个锁,会成为性能瓶颈。
synchronized(tieLock){
synchronized(toAcct){
synchronized(fromAcct){
new Helper().transfer();
}
}
}
}
}

如果每个账户都包含唯一不可变的可比的键值,会更简单,不用比较散列值。

在协作对象之间发生的死锁

在持有锁的情况下调用某个外部方法,这个外部方法中可能会获取其他锁。可能会像锁顺序死锁一样。

开放调用

如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。

使用同步代码块仅被用于保护那些设计共享状态的操作。

资源死锁

如果某些任务需要等待其他任务的结果,妈么这些任务往往是产生线程饥饿死锁的主要来源。

死锁的避免与诊断

如果程序每次最多只获得一个锁,那么就不会发生死锁。但是不现实。所以应该在设计时考虑锁的顺序,尽量减少潜在的加锁交互数量。

两阶段策略(Two-Part Strategy):

  • 找出在什么地方将获取多个锁。
  • 多所有这些实例进行全局分析,确保他们在整个程序中获取锁的顺序都是一致的。

支持定时的锁

显式使用Lock类中的定时tryLock功能来代替内置锁机制。显式锁可以指定一个超时时限,等待超过该事件后会返回一个失败信息。

通过线程转储(Thread Dump)信息来分析死锁

其他活跃性问题

饥饿

当前线程由于无法访问它需要的资源而不能继续执行,就发生了饥饿。最典型的是由于错误的使用线程优先级,导致获取不到CPU时钟周期。

糟糕的响应性

CPU密集型后台任务与事件线程竞争CPU的时钟周期,从而影响响应性。

可以通过降低后台任务的优先级解决这个问题。

活锁

错误的将不可修复的错误作为可修复的错误,导致如果不能成功处理某个消息,那么就回滚并重新放到队列开头,反复调用返回相同的结果。

当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行。就发生了活锁。

需要在重试机制中引入随机性。