避免活跃性问题-《java并发编程实战》

概述

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

死锁

“哲学家进餐”问题很好描述了死锁情况。5个哲学家去吃中餐,圆桌,5只筷子,每两人中间放一根筷子。哲学家时而思考,时而进餐。每个人都需要一双筷子才能吃到东西,并在吃完后将筷子放回原处继续思考。有些算法能够使得每个人都能相对及时地吃到东西,但有些算法却可能导致一些或者所有哲学家都饿死(每个人都立即抓住自己左边的筷子,然后等待自己右边的筷子空出来,但同时又放不下已经拿到的筷子)。后一种情况将产生死锁:每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前不会放弃已经拥有的资源。
在数据库系统的设计中考虑了监测死锁以及从死锁中恢复。在执行一个事务时可能需要获取多个锁,并一直持有这些所直到事务提交。因此两个事务之间很可能发生死锁,但事实上这种情况并不多见。如果没有外部干涉,那么这些服务将永远等待下去。但数据库服务器不会让这种情况发生。当它检测到一组事务发生了死锁时(通过在表示等待关系的有向图中搜索循环),将选择一个牺牲者并放弃这个事务。作为牺牲者的事务会释放它所持有的资源,从而使其他事务继续进行。应用程序可以重新执行被强行中止的事务,而这个事务现在可以成功完成,因为所有跟他竞争资源的事务都已经完成了。
JVM在解决死锁问题方面并没有数据库服务那么强大。当一组java线程发生死锁时,“游戏”到此结束——这些线程永远不能再使用了。根据线程完成工作的不同,可能造成应用程序完全停止,或者某个特定的子系统停止,或者是性能降低。恢复应用程序的唯一方式就是中止并重启它,并希望不要再发生同样的事情。
1)锁顺序死锁
如果一个线程调用了leftRight(先获取left锁再获取right锁再执行操作),而另一个线程调用了rightLeft(先获取right锁再获取left锁再执行操作),且这两个线程的操作是交错执行,那么它们会发生死锁。
如果所有的线程都以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题了
  
package chapter9;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @author zhen
 * @Date 2018/11/21 14:16
 * 简单的顺序死锁
 */
public class LeftRightDeadlock {
    private final Object left = new Object();
    private final Object right = new Object();

    public void leftRight() {
        synchronized (left){
            try {
                Thread.sleep(3);//睡眠3秒,为了制造死锁
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (right){
                doSomething();
            }
        }
    }

    public void rightLeft() {
        synchronized (right) {
            try {
                Thread.sleep(3);//睡眠3秒,为了制造死锁
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (left){
                doSomething();
            }
        }
    }

    public void doSomething(){
        System.out.println("hello!");
    }

    public static void main(String[] args) {
        final LeftRightDeadlock leftRightDeadlock = new LeftRightDeadlock();
        ExecutorService exec = Executors.newCachedThreadPool();
        exec.execute(new Runnable() {
            public void run() {
                leftRightDeadlock.leftRight();
            }
        });
        exec.execute(new Runnable() {
            public void run() {
                leftRightDeadlock.rightLeft();
            }
        });
    }
}
简单的顺序死锁Demo
2)动态的锁顺序死锁
有时候,并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生
  
//动态的锁顺序死锁
    //两个线程同时调用,一个X向Y转账,一个Y向X转账时候
    public  void transferMoney(Account fromAccount, Account toAccount, BigDecimal amount) {
        synchronized (fromAccount) {
            synchronized (toAccount){
                if (fromAccount.getBalance().compareTo(amount) < 0){
                    throw new RuntimeException("余额不足");
                } else{
                    fromAccount.debit(amount);
                    toAccount.credit(amount);
                }
            }
        }
    }
动态的锁顺序死锁
private static final Object tieLock = new Object();

    //通过锁顺序避免死锁
    public void transferMoney1(final Account fromAcct, final Account toAcct, final BigDecimal amount) {
        class Helper{
            public void transfer(){
                if (fromAcct.getBalance().compareTo(amount) < 0){
                    throw new RuntimeException("余额不足");
                } 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(fromHash > toHash) {
            synchronized (toAcct){
                synchronized (fromAcct) {
                    new Helper().transfer();
                }
            }
        } else {
            /**
             * 极少的额情况下,两个对象可能拥有相同的散列值,这时必须通过某种任意的方法来决定锁的顺序,有可能会重新引入死锁,为了避免这种情况,可以使用“加时赛”锁,消除了死锁发生的可能性
             */
            synchronized (tieLock){
                synchronized (fromAcct){
                    synchronized (toAcct){
                        new Helper().transfer();
                    }
                }
            }
        }
    }

    class Account{
        private BigDecimal balance = new BigDecimal("5000");

        public BigDecimal getBalance() {
            return balance;
        }

        public void setBalance(BigDecimal balance) {
            this.balance = balance;
        }

        public void debit(BigDecimal amount) {
            balance = balance.add(amount);
        }

        public void credit(BigDecimal amount) {
            balance = balance.divide(amount, 2);
        }

    }
更改锁顺序避免死锁

3)在协作对象之间发生的死锁
某些获取多个锁的操作并不像在LeftRightDeadlock或transferMoney中那么明显,这两个锁并不一定必须在同一个方法中被获取。
如果在持有锁时调用某个外部方法,那么将出现活跃性问题。在这个外部方法中可能会获取其他所(这可能会产生死锁),或者阻塞时间过长,导致其他线程无法及时获取当前被持有的锁
package chapter9;

import common.GuardedBy;

import java.util.HashSet;
import java.util.Set;

/**
 * @author zhen
 * @Date 2018/11/21 17:09
 */
public class TaxiDemo {

    /**
     * @author zhen
     * @Date 2018/11/21 15:14
     * 在相互协作对象之间的锁顺序死锁
     * taxi.setLocation需要先获得Taxi锁然后获得dispatcher锁,dispatcher.要先获得dispatcher锁再获得Taxi锁,所以也可能出现顺序死锁
     */
    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 Point{
        public boolean equals(Dispatcher dispatcher){
            return false;
        }
    }
    class Dispatcher{

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

        public Dispatcher() {
            taxis = new HashSet<Taxi>();
            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;
        }



    }

    class Image {
        public void drawMarker(Point point) {}
    }
}
协作对象之间发生的死锁
4)开放调用
方法调用相当于一种抽象屏障,因而你无须了解在被调用方法中所执行的操作。但也正是由于不知道在被调用方法中执行的操作,因此在持有锁的时候对调用某个外部方法将难以进行分析,从而可能出现死锁。
如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用。这种通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:虽然在没有耿庄的情况下也能确保线程安全的程序,要比分析没有使用封装的程序容易得多。通过尽可能地使用开放调用,将更易于找出那些需要获取多个锁的代码路径,因此也就更容易确保采用一致的顺序来获得锁。
在程序中应尽量使用开放调用。与那些在持有锁时调用外部方法的程序相比,更容易对依赖开放调用的程序进行死锁分析
package chapter9;

import common.GuardedBy;

import java.util.HashSet;
import java.util.Set;

/**
 * @author zhen
 * @Date 2018/11/21 17:10
 */
public class TaxiDemo1 {
    /**
     * @author zhen
     * @Date 2018/11/21 15:14
     * 通过公开调用来避免在相互协作的对象之间产生死锁
     */
    class Taxi {
        @GuardedBy("this") private Point location, destination;
        private final Dispatcher dispatcher;

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

        public Point getLocation() {
            return location;
        }

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

        }
    }

    class Point{
        public boolean equals(Dispatcher dispatcher){
            return false;
        }
    }
    class Dispatcher{

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

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

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

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



    }

    class Image {
        public void drawMarker(Point point) {}
    }
}
公开调用避免协作对象之间发生死锁
5)资源死锁
正如当多个线程相互持有正在等待的锁而又不释放自己持有的锁的时候会发发生死锁,当它们在相同的资源集合上等待时,也会发生死锁。
例如线程池,资源池通常采用信号量实现,资源池为空时候的阻塞。
另一种基于资源的死锁类型就是线程饥饿死锁。例如:一个任务提交另一个任务,并等待被提交任务在单线程的Executor中执行完成。这种情况下,第一个任务将永远等待下去,并使得另一个任务以及在这个Executor中执行的所有其他任务都停止执行。如果某些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源,有界线程池/资源池与互相依赖的任务不能一起使用。

 

死锁的避免与诊断

如果一个程序每次至多只能获得一个锁,那么就不会产生锁顺序死锁。当然这种情况并不现实,但如果能避免使用这种情况,那么就能省去很多工作。如果必须获取多个锁,那么在设计时必须考虑锁的顺序:尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入正式文档并始终遵循这些协议。
在使用细粒度锁的程序中,可以通过使用一种两阶段策略来检查代码中的死锁:首先,找出在什么地方获取多个锁(尽量使这个集合小),然后对所有这些实例进行全局分析,从而确保它们在整个程序中获取锁的顺序都保持一致。尽可能地使用开放调用,这能极大地简化分析过程。如果所有的调用都是开放调用,那么要获取多个锁的实例是非常简单的,可以通过代码审查,或者借助自动化的源代码分析工具。
1)支持定时的锁
还有一项技术可以检测死锁和从死锁中恢复过来,即显式使用Lock类中的定时tryLock功能来代替内置锁机制。当使用内置锁的时候,只要没有获得锁,就会永远等待下去,而显式锁可以指定一个超时时限,在等待超过该时间后tryLock会返回一个失败信息。如果超时时限比获取锁的时间要长很多,那么就可以在发生某个意外情况后重新获得控制权。
2)通过线程转储信息来分析死锁
虽然防止死锁的主要责任在于你自己,但JVM仍然通过线程转储来帮助识别死锁的发生。线程转储包括各个运行中的线程栈追踪信息,这类似于发生异常时的栈追踪信息。线程转储还包含加锁信息,例如每个线程持有了那些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取哪一个锁。
如果使用显式的Lock类而不是内部锁,那么Java 5.0并不支持与Lock相关的转储信息,在线程转储中不会出现显式的Lock。虽然Java 6中包含对显式Lock的线程转储和死锁检测等的支持,但这些锁上获得的信息比在内置锁上获得的信息精确度低。内置锁与获得它们所在的线程栈帧是相关联的,而显式的Lock只与获得它的线程相关联。

 

其他活跃性危险

尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些其他的活跃性危险,包括:饥饿、丢失信号和活锁等。
1)饥饿
当线程由于无法访问它所需要的资源而不能继续执行时,就发生了“饥饿”,引发饥饿的最常见资源就是CPU时钟周期。如果在java应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(例如无线循环、或者无限制地等待某个资源),那么也可能导致饥饿,因为其他需要这个锁的线程将无法得到它。
Thread API中定义的线程优先级只是作为线程调度的参考。在Thread API中定义了10个优先级,JVM根据需要将它们映射到操作系统的调度优先级。这种映射是与特定平台相关的,因此在某个操作系统中两个不同的java优先级可能被映射到同一个优先级,而在另一个操作系统中则可能被映射到另一个不同的优先级。线程优先级并不是一种直观的机制,而通过修改线程优先级锁带来的效果通常也不明显。当提高某个线程的优先级时,可能不会起到作用,或许也可能使得某个线程的调度优先级高于其他线程,从而导致饥饿。
要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数并发应用程序中,都可以使用默认的线程优先级
2)糟糕的响应性
除饥饿以外的另一个问题是糟糕的响应性,如果在GUI应用程序中使用了后台线程,那么这种问题是很常见的。把运行时间较长的任务放到后台线程中运行,从而不会使用户界面失去响应。但CPU密集型的后台任务仍然可能对响应性造成影响,因为它们会与事件线程共同竞争CPU的时钟周期。在这种情况下就可以发挥线程优先级的作用,此时计算密集型的后台任务将对响应性造成影响。如果由其他线程完成的工作都是后台任务,那么应该降低它们的优先级,从而提高前台程序的响应性。
不良的锁管理也可能导致糟糕的响应性。如果某个线程长时间占有一个锁,而其他想要访问这个容器的线程就必须等待很长时间。
3)活锁
活锁时另一种形式的活跃性问题,该问题尽管不会阻塞线程名单也不能继续执行,因为线程将不断重复相同的操作,而且总会失败。活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它重新放到队列的开头。如果消息处理器在处理某种特定类型的消息时存在错误并导致它失败,那么每当这个消息从队列中取出并传递到存在错误的处理器时,都会发生事务回滚。由于这条消息又被放回到队列开头,因此处理器将被反复调用,并返回相同的结果。虽然处理消息的线程没有阻塞,但也无法继续执行下去。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误作为可修复的错误。
当多个相互协作的线程都对彼此进行响应而修改各自的状态,并使得任何一个线程都无法继续执行时,就发生了活锁。这就像两个过于礼貌的人在半路上面对面地相遇:它们彼此都让出对方的路,然而又在另一条路上相遇了。因此它们就这样反复地避让下去。要解决这种活锁问题,需要在重试机制中引入随机性。

 

posted @ 2018-11-22 14:55  guodaxia  阅读(147)  评论(0)    收藏  举报