并发编程之避免活跃性危险

本篇文章介绍死锁、死锁的避免和其他的活跃性危险,内容总结摘抄自《Java并发编程实战》,仅作笔记。

在安全性和活跃性之间通常存在着某种制衡。我们使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致锁顺序自锁(Lock-Ordering Deadlock)。同样,我们使用线程池和信号量来限制对资源的使用,但这些被限制的行为可能会导致资源死锁(Resource Deadlock)。Java应用程序无法从死锁中恢复过来,因此在设计时一定要排除那些可能导致死锁出现的条件。

死锁

当一个线程永远地持有一个锁,并且其他线程都尝试获取这个锁时,那么它们将永远被阻塞。在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远地等待下去。这种情况就是最简单的死锁形式,其中多个线程由于存在环路的锁依赖关系而永远地等待下去。

把每个线程假想为有向图中的一个节点,图中每条边表示的关系式:“线程A等待线程B所占有的资源”。如果在图中形成了一条环路,那么就存在一个死锁。

在数据库系统的设计中考虑了监测死锁以及从死锁中恢复。在执行一个事务时可能需要获取多个锁,并一直持有这些锁直到事务提交。因此在两个事务直接很可能发生死锁,如果没有外部干涉,这些事务将永远等待下去。但数据库服务器不会让这种情况发生,当它检测到一组事务发生了死锁时(通过在表示等待关系的有向图中搜索循环),将选择一个牺牲者并放弃这个事务。作为牺牲者的事务会释放它所持有的资源,从而使其他事务继续进行。应用程序可以重新执行被强行中止的事务,而这个事务现在可以成功完成,因为所有与它竞争资源的事务已经完成了。

JVM在解决死锁问题方面并没有数据库服务那样强大,当一组Java线程发生死锁时,这些线程就不能再使用了。根据线程完成工作的不同,可能造成应用程序完全停止,或者某个特定的子系统停止,或者性能降低。恢复应用程序的唯一方式就是中止并重启它。

与许多其他的并发危险一样,死锁造成的影响很少会立即显现出来。如果一个类可能发生死锁,并不意味着每次都会发生死锁,而只是表示有可能。

死锁的产生必须满足以下四个条件,任一条件不满足都不会发生死锁:

  • 互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某资源仅为一个进程所占有。此时若有其他进行请求该资源,则请求进程只能等待。
  • 不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他线程强行夺走,即只能由获得该资源的进程自己来主动释放。
  • 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程被阻塞,但又对自己已有的资源保持不放。
  • 循环等待条件:存在一种进程资源的循环等待链,链中每个进程都已获得的资源同时被链中下一个进程所请求。

锁顺序死锁

例如下面的程序就会发生死锁:

public class LeftRightDeadLock {
    private static final Object left = new Object();
    private static final Object right = new Object();

    public static void main(String[] args) {
        LeftRight leftRight = new LeftRight();
        RightLeft rightLeft = new RightLeft();
        leftRight.start();
        rightLeft.start();
    }
    static class LeftRight extends Thread{
        @Override
        public void run() {
            synchronized (left){
                System.out.println(Thread.currentThread().getName()+":我获取到left锁了...");
                synchronized (right){
                    System.out.println(Thread.currentThread().getName()+":先获取left锁再获取right锁...");
                }
            }
        }
    }

    static class RightLeft extends Thread{
        @Override
        public void run() {
            synchronized (right){
                System.out.println(Thread.currentThread().getName()+":我获取到right锁了...");
                synchronized (left){
                    System.out.println(Thread.currentThread().getName()+":先获取right锁再获取left锁");
                }
            }
        }
    }
}

输出结果

Thread-0:我获取到left锁了...
Thread-1:我获取到right锁了...

LeftRight的run()方法和RightLeft的run()方法分别获取了left锁和right锁。如果两个线程分别执行了这两个方法,并且是交错执行,它们就会发生死锁。发生死锁的原因是:两个线程试图以不同的顺序来获得相同的锁。如果每个需要锁L和锁M的线程都以相同的顺序来请求锁,那么就不会出现循环的加锁依赖性,因此也就不会产生死锁。

要想验证锁顺序的一致性,需要对程序中的加锁行为进行全局分析。如果只是单独地分析每条获取多个锁的代码路径是不够的,对于以上程序,Thread-0和Thread-1都采用了“合理的”方式来获取锁,它们只是不能相互兼容。当需要加锁时,它们需要知道彼此正在执行什么操作。

动态的锁顺序死锁

有时候,并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生。例如下面的程序,它将资金从一个账户转入另一个账户。在开始转账前,首先获得这两个Account对象的锁,以确保通过原子的方式来更新两个账户中的余额,同时又不破坏一些不变性条件。

public class DynamicDeadlock {
    public static void main(String[] args) {
        Account wang = new Account("小王",new BigDecimal("3000"));
        Account zhao = new Account("小赵",new BigDecimal("5000"));
        Transfer transferAToB = new Transfer(wang,zhao,new BigDecimal("1000"));
        Transfer transferBToA = new Transfer(zhao,wang,new BigDecimal("2000"));
        transferAToB.start();
        transferBToA.start();
    }
}

class Transfer extends Thread {
    private Account fromAccount;
    private Account toAccount;
    private BigDecimal amount;

    public Transfer(Account fromAccount, Account toAccount,BigDecimal amount) {
        this.fromAccount = fromAccount;
        this.toAccount = toAccount;
        this.amount = amount;
    }

    @Override
    public void run() {
        synchronized (fromAccount){
            System.out.println("已经锁定了账户:"+fromAccount.getName());
            synchronized (toAccount){
                if (amount.compareTo(BigDecimal.ZERO) < 0){
                    System.out.println("转账金额必须大于0!");
                }else {
                    BigDecimal fromAmount = fromAccount.getAmount();
                    BigDecimal toAmount = toAccount.getAmount();
                    System.out.println("转账前转出账户余额:"+fromAmount+",转入账户余额:"+toAmount);
                    fromAccount.setAmount(fromAmount.subtract(amount));
                    toAccount.setAmount(toAmount.add(amount));
                    System.out.println("转账后转出账户余额:"+fromAccount.getAmount()+",转入账户余额:"+toAccount.getAmount());
                }
            }
        }
    }
}

输出结果

已经锁定了账户:小王
已经锁定了账户:小赵

在以上程序中,上锁的顺序取决于传递给transfer的参数顺序,而这些参数顺序又取决于外部输入,如果两个线程同时调用transfer,其中一个线程从X向Y转账,另一个线程从Y向X转账,那么就会发生死锁。

由于我们无法控制参数的顺序,因此要解决这个问题,必须定义锁的顺序,并在整个应用程序中都按照这个顺序来获取锁。在制定锁的顺序时,可以使用System.identityHashCode方法,此方法返回由Object.hashCode()返回的值,无论锁对象是否重写了hashCode()方法。如下面的代码:

public class IdentityHashCodeTest {
    public static void main(String[] args) {
        String str = new String("A");
        String anoStr = new String("A");

        System.out.println(str.hashCode());
        System.out.println(anoStr.hashCode());
        System.out.println(System.identityHashCode(str));
        System.out.println(System.identityHashCode(anoStr));
    }
}

输出结果

65
65
1414644648
640070680

由于String重写了hashCode()方法,因此str和anoStr的hashCode()方法的返回值是相同的,但他们调用System.identityHashCode()方法永远不会得到相同的值,除非他们是同一个对象。

使用System.identityHashCode()方法解决锁顺序问题,只需要在以上代码的基础上修改Transfer的run()方法如下:

class Transfer extends Thread {
    private Account fromAccount;
    private Account toAccount;
    private BigDecimal amount;

    public Transfer(Account fromAccount, Account toAccount,BigDecimal amount) {
        this.fromAccount = fromAccount;
        this.toAccount = toAccount;
        this.amount = amount;
    }

    @Override
    public void run() {
        class Helper{
            public void transfer(){
                if (amount.compareTo(BigDecimal.ZERO) < 0){
                    System.out.println("转账金额不能为0!");
                }else {
                    BigDecimal fromAmount = fromAccount.getAmount();
                    BigDecimal toAmount = toAccount.getAmount();
                    System.out.println("转账前"+fromAccount.getName()+"账户余额:"+fromAmount+","+toAccount.getName()+"账户余额:"+toAmount);
                    fromAccount.setAmount(fromAmount.subtract(amount));
                    toAccount.setAmount(toAmount.add(amount));
                    System.out.println("转账后"+fromAccount.getName()+"账户余额:"+fromAccount.getAmount()+","
                            +toAccount.getName()+"账户余额:"+toAccount.getAmount());
                }
            }
        }

        //获取两个锁对象的hashcode
        int fromHash = System.identityHashCode(fromAccount);
        int toHash = System.identityHashCode(toAccount);

        //先加锁hash值较小的那个,再加锁hash值较大的那个,这样就控制了锁顺序
        if (fromHash<toHash){
            synchronized (fromAccount){
                System.out.println("已经锁定了账户:"+fromAccount.getName());
                synchronized (toAccount){
                    System.out.println("已经锁定了账户:"+toAccount.getName());
                    new Helper().transfer();
                }
            }
        } else {
            synchronized (toAccount){
                System.out.println("已经锁定了账户:"+toAccount.getName());
                synchronized (fromAccount){
                    System.out.println("已经锁定了账户:"+fromAccount.getName());
                    new Helper().transfer();
                }
            }
        }
    }
}

在极少数情况下,两个对象可能拥有相同的散列值,此时必须通过某种任意的方法来决定锁的顺序,而这可能又会重新引起死锁。为了避免这种情况,可以使用“加时赛”锁。在获得两个Account锁之前,首先获得这个“加时赛”锁,从而保证每次只有一个线程以未知的顺序获得这两个锁,从而消除了死锁发生的可能性。

如果在Account中包含一个唯一的、不可变的,并且具备可比性的键值,例如账号,那么要执行锁的顺序就更加容易一些:通过键值对对象进行排序,因此并不需要“加时赛”锁。

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

某些获取多个锁的操作并不像在前面介绍的这么明显,有时候多个锁并不一定在同一个方法中被获取。例如下面两个相互协作的类,Taxi代表一个出租车对象,包含位置和目的地两个属性,Dispather代表一个出租车车队。在出租车调度系统中可能会用到它们。

class Taxi{
    private final Dispatcher dispatcher;
    private String location;
    private String destination;

    public Taxi(Dispatcher dispatcher) {
        this.dispatcher= dispatcher;
    }
    public synchronized String getLocation(){
        return location;
    }
    public synchronized void setLocation(String 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<>();
        availableTaxis = new HashSet<>();
    }
    public synchronized void notifyAvailable(Taxi taxi){
        availableTaxis.add(taxi);
    }
    public synchronized String getImage(){
        StringBuilder image = new StringBuilder();
        for (Taxi t:taxis){
            image.append(t.getLocation());
        }
        return image.toString();
    }
}

尽管没有任何方法显式地获取两个锁,但setLocation()和getImage()方法的调用者都会获得两个锁。一个线程调用setLocation时由于此方法是同步方法,因此会首先获得Taxi的锁,然后获取Dispather的锁。同样,调用getImage的线程首先获取Dispatcher的锁,再获取Taxi的锁。这种情况与前面介绍的锁顺序死锁情况相同,两个线程按照不同的顺序来获取两个锁,因此就有可能产生死锁。

在锁顺序死锁中要查找死锁是比较简单的,只需要找出那些需要获取两个锁的方法。但在Taxi和Dispatcher中查找死锁则比较困难:如果在持有锁的情况下调用某个外部方法,那么就需要警惕死锁,因此在这个外部方法中可能会获取其他锁,或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁。

开放调用

然而对于Taxi和Dispatcher来说,它们并不知道它们将要陷入死锁,它们也不应该知道。方法调用相当于一种抽象屏障,因此我们无须了解在被调用方法中所执行的操作。但也正是由于不知道在被调用方法中执行的操作,因此在持有锁的时候对调用某个外部方法将难以进行分析,进而可能出现死锁。

如果在调用某个方法时不需要持有锁,那么那种调用被称为开放调用(Open Call)。可以很容易地将上面的程序中的Taxi和Dispatcher修改为使用开放调用,从而消除死锁发生的风险。这需要使同步代码块仅被用于保护那些设计共享状态的操作。修改为开放调用后的setLocation()方法和getImage()方法如下:

public void setLocation(String location){
    boolean reachedDestination;
    synchronized (this){
        this.location = location;
        reachedDestination = location.equals(destination);
    }
    if (reachedDestination){
        dispather.notifyAvailable(this);
    }
}

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

在程序中应尽量使用开放调用,与那些在持有锁时调用外部方法的程序相比,更易于对依赖于开放调用的程序进行死锁分析。

资源死锁

正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。

假设有两个资源池,例如两个不同数据库的连接池。资源池通常采用信号量来实现当资源池为空时的阻塞行为。如果一个任务需要连接两个数据库,并且在请求这两个资源时不会始终遵循相同的顺序,那么线程A可能持有数据库D1的连接,并等待与数据库D2的连接,而线程B持有D2的连接并等待D1的连接。资源池越大,这种情况出现的可能性就越小。如果每个资源池都有N个连接,那么在发生死锁时不仅需要N个循环等待的线程,还需要大量不恰当的执行时序。

另一种基于资源的死锁形式是线程饥饿死锁。例如,一个任务提交另一个任务,并等待被提交任务在单线程的Executor中执行完成。这种情况下,第一个任务将永远等地下去,并使得另一个任务以及在这个Executor中执行的所有其他任务都停止执行(单线程池只能执行一个任务,在执行的这个任务又提交了另一个任务并且等待这个任务完成,肯定死锁了)。如果某些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源,有界线程池/资源池与相互依赖的任务不能一起使用。

死锁的避免与诊断

如果一个程序每次至多只能获得一个锁,那么就不会产生锁顺序死锁,但这种情况不现实。如果必须获取多个锁,那么在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入正式文档并始终遵循这种协议。

在使用细粒度锁的程序中,可以通过一种两阶段策略来检查代码中的死锁:首先,找出在什么地方将获取多个锁,然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。尽可能地使用开放调用,这能极大的简化分析过程。

支持定时的锁

有一项技术可以检测死锁和从死锁中恢复过来,即显式使用Lock类中的定时tryLock来代替内置锁机制。当使用内置锁时,只要没有获得锁,就会永远等待下去,而显式锁可以指定一个超时时限,在等待超过该时间后tryLock将会返回一个失败信息。如果超时时限比获取锁的时间要长很多,那么就可以在发生某个意外情况后重新获得控制器。

即使在整个系统中没有始终使用定时锁,使用定时锁来获取多个锁也能有效地应对死锁问题。如果在获取锁时超时,那么可以释放这个锁,然后后退并在一段时间后再次产生ghi,从而消除死锁发生的条件,使程序恢复过来。

其他活跃性危险

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

饥饿

当线程由于无法访问它所需要的资源而不能继续执行时,就发生了“饥饿”。引发饥饿的最常见资源就是CPU时钟周期。如果在Java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构,那么也可能导致饥饿,因为其他需要这个锁的线程将无法获取它。

在Thread API中定义的线程优先级只是作为线程调度的参考。在Thread API中定义了10个优先级,JVM根据需要将它们映射到操作系统的调度优先级。这种映射与特定平台相关,因此在某个操作系统中两个不同的Java优先级可能被映射到同一个优先级,而在另一个操作系统中可能被映射到另一个不同的优先级。

操作系统的线程调度器会尽力提供公平的、活跃性良好的调度,甚至超出Java语言规范的需求范围。在大多数Java应用程序中,所有线程都具有相同的优先级Thread.NORM_PRIORITY。线程优先级并不是一种直观的机制,通过修改线程优先级所带来的效果通常也不明显。当提高某个线程的优先级时,可能不会起到任何作用,或者也可能使得某个线程的调度优先级高于其他线程,从而导致饥饿。

通常,我们尽量不修改线程优先级。只要改变了线程的优先级,程序的行为就与平台相关,并且会导致发生饥饿问题的风险。

活锁

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

活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又被放回到队列开头,因此处理器将被反复调用,并返回相同的结果。虽然处理消息的线程并没有完全阻塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的错误。

当多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。这就像两个过于礼貌的人在半路上面对面地相遇:他们彼此都让出对方的路,然而又在另一条路上相遇,一直反复地避让下去。

要解决这种活锁问题,需要在重试机制中引入随机性。例如,在网络上,如果两台机器尝试使用相同的载波来发送数据包,那么这些数据包就会发生冲突。这两台机器都检查到了冲突,并都在稍后再次重发。如果二者都选择了在1秒钟后重试,它们又会发生冲突,并且不断地冲突下去。为避免这种情况,可以让它们分别等待一段随机的时间。在并发应用中,通过等待随机长度的时间和回退都可以有效地避免活锁的发生。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值