4 活跃性

并发应用程序能及时执行的能力称之为活跃性。由于线程并发原因导致的应用程序无法执行,就是活跃性故障

活跃性故障最主要的一种就是死锁。哲学家进餐问题就是经典的死锁问题。

与许多其他的并发危险一样,死锁造成的影响很少会立即显现出来。如果一个类可能发生死锁,那么并不意味着每次都会发生死锁,而只是表示有可能。当死锁出现时,往往是在最糟糕的时候——在高负载情况下。


4.1.1 锁顺序死锁

两个线程试图通过不同的顺序获取多个相同的锁。如果请求的顺序不相同,那么会出现循环的锁依赖现象,产生死锁。如下图所示:

java 并发单线程处理 java并发编程实战 看不懂_加锁


线程A先锁住left,再尝试锁住right,而此时B已经锁住了right,尝试锁住left。显然A和B出现了循环的锁依赖,导致A和B无限阻塞等待下去。

但是如果保证同时请求锁L和锁M的每一个线程,都是按照从 L 到 M 的顺序,那么就不会发生死锁了。

当所有的线程都以固定的顺序来获得锁时,程序中就不会出现锁顺序死锁问题。

4.1.2 动态的锁顺序死锁

看一个转账的例子:

public void transferMoney(Account fromAccount, Account toAccount, BigDecimal amount) {
    synchronized (fromAccount) {
        synchronized (toAccount) {
            if (fromAccount.getBalance().compareTo(amount) < 0) {
                System.out.println("余额不足,转账失败");
            } else {
                fromAccount.debit(amount); // 支出
                toAccount.credit(amount);  // 收入
            }
        }
    }
}

在两个账户间转账时,先对转出账户加锁,再对转入账户加锁,对两个账户都加锁成功时,才执行转账操作,以此保证转账的一致性。

在这个例子中,调用方法的线程看似是按顺序对账户进行了加锁。然而问题是,一个账户既可以是转出账户,也可以是转入账户。假设有两个线程,一个从X向Y转账,一个从Y向X转账,那么仍然会发生死锁:一个线程锁定X等待Y,另一个线程锁定Y等待X。

在这样的场景中,锁顺序是动态的,仍然会发生死锁。

此时可以通过另外的方法来保证锁顺序:先锁定同一个对象。不管是从X向Y转还是从Y向X转,都先锁定其中一个账户,再锁定另外一个账户,这样就能消除死锁。

那么如何确认对象的唯一性呢?正常情况下,每一个账户会有账户id,或者类似的唯一键。如果没有的话,可以使用System.identifyHashCode方法,该方法返回对象的哈希值,根据此值来确认唯一的账户对象。

// 加时赛锁
private static final Object tieLock = new Object();

public void tranferMoney(Account fromAccount, Account toAccount, BigDecimal amount) throws InsufficientResourcesException {
    class Helper {
        public void tranfer() throws InsufficientResourcesException {
            if(fromAccount.compareTo(amount) < 0)
                throw new InsufficientResourcesException();
            else {
                fromAccount.debit(amount);
                toAccount.credit(amount);
            }
        }
    }

    int fromHashCode = System.identityHashCode(fromAccount);
    int toHashCode = System.identityHashCode(toAccount);

    if(fromHashCode < toHashCode) {
        synchronized (fromAccount) {
            synchronized (toAccount) {
                new Helper().tranfer();
            }
        }
    }else if (fromHashCode > toHashCode) {
        synchronized (toAccount) {
            synchronized (fromAccount) {
                new Helper().tranfer();
            }
        }
    }else {
        // 加时赛锁
        synchronized (tieLock) {
            synchronized (fromAccount) {
                synchronized (toAccount) {
                    new Helper().tranfer();
                }
            }
        }
    }
}

本例中,先锁定哈希值较小的对象,再锁定哈希值大的对象。不管两个账户是从A转到B还是从B转到A,加锁始终都是有顺序的。

当然极少数情况下,可能两个对象的哈希值相同,此时可以使用加时赛锁,在获得两个账户锁之前,首先获得“加时赛”锁,从而保证每次只有一个线程以未知的顺序获取两个账户锁。

4.1.3 协作对象之间的死锁

某些获取多个锁的操作不像在上面举例中LeftRightDeadLock或者transferMoney中那么明显,而是隐含在一个同步方法调用另一个同步方法中。比如在出租车调用系统中,Taxi代表一辆出租车,包含位置和目的地两个属性,Dispatcher代表一个调度车队。Demo如下:

class Taxi {
    // 该变量由本对象保护
    @GuardedBy("this")
    private Point location, destination;

    private final 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{
    @GuardedBy("this")
    private final Set<Taxi> taxis;

    @GuardedBy("this")
    private final Set<Taxi> availableTaxis;

    public Dispatcher() {
        this.taxis = new HashSet<Taxi>();
        this.availableTaxis = new HashSet<Taxi>();
    }

    public synchronized void notifyAvailable(Taxi taxi) {
        availableTaxis.add(taxi);
    }

    public synchronized  Image getImage() {
        Image image = new Image();
        for (Taxi t : taxis) {
            image.drawMarker(t.getLocation());
        }
        return image;
    }
}

以上demo中,尽管没有方法会显式地获取两个锁。但setLocation方法是同步方法,在调用该方法时会先获取调用对象taxi的锁,接着setLocation内部通过dispatcher调用了notifyAvailable同步方法,也就是说会继续获取dispatcher的锁。同理getImage方法会先获取dispatcher的锁,接着在调用getLocation时获取taxi的锁。

于是不同的线程按照不同的顺序来获取锁,还是会产生锁顺序死锁。而且这种情况下的死锁是不明显的,稍不注意就会被忽略。

如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他锁(可能会产生死锁),或者阻塞时间过长(导致同步方法一直不释放锁),导致其他线程无法及时获得当前被持有的锁。

4.1.4 开放调用

如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。通过尽可能使用开放调用,将更易于找出那些需要获取多个锁的代码路径,也就更容易确保采用一致的顺序来获得锁。

将上文中的出租车调度系统进行开放调用修改,以消除死锁和线程阻塞风险:

class Taxi {
    // 该变量由本对象保护
    @GuardedBy("this")
    private Point location, destination;

    private final Dispatcher dispatcher;

    public Taxi(Dispatcher dispatcher) {
        this.dispatcher = dispatcher;
    }

    public synchronized Point getLocation() {
        return location;
    }

    public void setLocation(Point location) {
        boolean reachedDistination;
        synchronized (this) {
            this.location = location;
            reachedDistination = location.equals(destination);
        }
        if (reachedDistination) {
            dispatcher.notifyAvailable(this);
        }
    }

}

class Dispatcher {
    @GuardedBy("this")
    private final Set<Taxi> taxis;

    @GuardedBy("this")
    private final Set<Taxi> availableTaxis;

    public Dispathcer() {
        this.taxis = new HashSet<Taxi>();
        this.availableTaxis = new HashSet<Taxi>();
    }

    public synchronized void notifyAvailable(Taxi taxi) {
        availableTaxis.add(taxi);
    }

    public Image getImage() {
        Set<Taxi> copy;
        synchronized (this) {
            copy = new HashSet<>(taxis);
        }
        Image image = new Image();
        for (Taxi t : copy) {
            image.drawMarker(t.getLocation());
        }
        return image;
    }
}

有时候,在重新编写同步代码块以使用开放调用时会产生意想不到的结果,因为这会使得某个原子操作变为非原子操作。在许多情况下,使某个操作失去原子性是可以接受的,如例子中的setLocation的操作,更新出租车当前位置和通知调度程序并不需要实现为一个原子操作。而另外一些情况下,丢失原子性会引发错误,此时可以通过其他的方式来实现原子性。

4.1.5 死锁的避免与诊断

避免锁顺序死锁

如果每次只获取一个锁,那么就不会产生锁顺序死锁。

当然,有时必须获取多个锁,那么在设计时必须考虑锁的顺序,尽量减少潜在的锁交互数量,将获取锁时需要遵循的协议写入正式文档并始终遵循这些协议。尽可能使用开放调用

设置超时时限

使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁则可以指定一个超时时间,超过时限后tryLock会返回失败。此种方式可以通过设置加锁时限来避免死锁。

死锁分析

发生死锁时,可以通过JVM线程转储(Thread Dump)来进行分析。线程转储包括各个运行中的线程的栈追踪信息,类似于发生异常时的栈追踪信息。

线程转储还包括加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中获得了这些锁,以及被阻塞的线程正在等待获取哪一个锁。

4.1.6 其他活跃性危险

尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些其他的活跃性危险。如:饥饿、丢失信号和活锁等。

当线程长时间无法访问它所需要的资源而不能继续执行时,就是所谓的饥饿。引发饥饿的最常见资源就是CPU时钟周期,如使用不当的线程优先级会导致低优先级的线程始终无法获取CPU时钟周期。

尽量不要改变线程的优先级,使用默认的优先级即可。只要改变了线程的优先级,程序的行为就将与平台相关,并且可能导致饥饿问题的风险。

活锁(LiveLock)是另一种形式的活跃性问题,活锁不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败

活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头,然后继续处理失败-回滚-放到队列开头的重复流程。这种形式的活锁通常是由过度的错误回复代码造成的,因此它错误地将不可修复的错误当成了可修复的错误。

当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时就发生了活锁。在重试机制中引入随机性可以解决这种活锁问题。