并发编程实战 - 避免活跃性危险【死锁场景】

顺序死锁与资源死锁 - 我们使用加锁机制来确保线程安全,但是如果过度的使用加锁,则可能导致锁顺序死锁。同样,我们使用线程池和信号量来限制对资源的使用,但这些限制的行为可能导致资源死锁。

在数据库系统的设计中考虑了监测死锁以及从死锁中恢复。当执行一个事务时可能需要获取多个锁,并一直持有这些锁直到事务提交。因此两个事务之间很可能发生死锁。当发生死锁的时候,数据库服务器将选择一个牺牲者并放弃这个事务。作为牺牲者的事务将放弃它所持有的资源,从而让其他事务继续执行。

JVM在解决死锁问题方面并没有数据库服务器那样强大。当一组Java线程发生死锁时,“游戏”将到此结束 - 这些线程永远不能再使用。恢复应用程序的唯一方式就是中止并重启它,并希望不要再发生同样的事情。

1、锁顺序死锁

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){
                    doSomethingElse();
                }
            }
        }
    }
两个线程分别调用上面的两个方法就会发生死锁。如果所有的线程都以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。

2、动态的锁顺序死锁

    public void transferMoney(Account fromAccount,Account toAccount,DollarAmount amount){
        synchronized (fromAccount){
            synchronized (toAccount){
                if (fromAccount.getBalance().compareTo(amount) < 0){
                    throw new InsufficientFundsException();
                }else{
                    fromAccount.debit(amount);
                    toAccount.credit(amount);
                }
            }
        }
    }

上述看似无害的代码,有的时候也会发生死锁。为什么?所有的线程都按照相同的顺序来获得锁,但事实上锁的顺序取决于传递给该方法的参数,而这些参数顺序又取决于外部输入。如果两个线程同时调用这个方法,其中一个传入从X向Y转账,而另一个线程从Y向X转账,那么就会发生死锁。要解决这个问题,必须定义锁的顺序,并在整个程序中都按照这个顺序来获取锁。制定锁顺序的时候,可以使用System.identityHashCode,该方法将返回有object.hashCode返回的值。

    public void transferMoney(Account fromAccount,Account toAccount,DollarAmount amount){

                int fromHash = System.identityHashCode(fromAccount);
                int toHash = System.identityHashCode(toAccount);
                if (fromHash<toHash){
                    synchronized (fromAccount){
                        synchronized (toAccount) {
                    ......
                        }
                    }
                }else if(fromHash>toHash){
                    synchronized (toAccount){
                        synchronized (fromAccount) {
                            ......
                        }
                    }
                }else{
                    synchronized (tiedLock){
                        synchronized (fromAccount){
                            synchronized (toAccount) {
                                ......
                            }
                        }
                    }
                }
    }
在极少数情况下,两个对象可能拥有相同的散列值,为了避免这种情况,可以使用“加时赛”锁。在获取两个锁之前,先获得这个“加时锁”,从而保证每次只有一个线程以未知的顺序获得这两个锁,从而消除死锁的发生。

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

class Taxi {
    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{
        private final Set<Taxi> taxis;
        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())l
           return image;
            }

        }
    }

【如果在持有锁时调用某个外部方法,而这个外部方法可能需要其他锁,这就可能造成死锁】

尽管没有任何方法会显式地获取两个锁。但setLocation和getImage等方法的调用者都会获得两个锁。调用setLocation会首先获得Taxi的锁,然后获取Dispatcher的锁。同样,调用getImage会首先获得Dispatcher的锁,然后再获取Taxi的锁。如果一个线程调用了setLocation,而另一个线程调用了getImage,两个线程按照不同的顺序获取锁,因此就可能发生死锁。

解决方式:开放调用。如果调用某个方法时不需要持有锁,那么这种调用被称为开放调用。依赖于开放调用的类通常能表现出更好的行为,并且与那些在调用方法时需要持有锁的类相比,也更易于编写。

上述代码通过“开放调用 + 同步代码块【保护那些涉及共享状态的操作】”即可解决。 - 解除了持有锁时调用外部方法

class Taxi {
    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){
        boolean reachedDestination;
        synchronized (this){
            this.location = location;
            reachedDestination = location.equals(destination);
        }
        if (reachedDestination)//此时不再持有锁
        dispatcher.notifyAvailable(this);
    }

    class Dispatcher{
        private final Set<Taxi> taxis;
        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(){
            Set<Taxi> copy;
            synchronized (this){
                copy = new HashSet<>(taxis);
            }
            Image image = new Image();
            for (Taxi t : taxis){
                image.drawMarker(t.getLocation());//此时也不再持有锁
           return image;
            }

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

4、资源死锁

当多个线程在相同的资源集合上等待时,也会发生死锁。有界线程池/资源池不能与相互依赖的任务一起使用。

 5、支持定时的锁

还有一项技术可以检测死锁和从死锁中恢复过来:Lock,显式地使用Lock类中的定时tryLock功能来代替内置锁。当使用内置锁时,只要没有获取锁,就会永远等待下去,而显式锁则可以指定一个超时时限,在等待超过该时间后tryLock会返回一个失败信息。

还有一个问题就是避免使用线程的优先级,因为这会增加平台依赖性,并导致出现活跃性问题。在Thread Api中定义了10个优先级,JVM根据需要将它们映射到操作系统的调度优先级。这种映射与特定的平台有关,因此在某个操作系统中两个不同的Java优先级可能被映射到同一个优先级,而在另一个操作系统中则可能被映射到另一个不同的优先级。在某些操作系统中,如果优先级的数量少于10,那么多个Java优先级会被映射到同一个优先级。

通常,我们尽量不要改变线程的优先级,只要改变了线程的优先级,程序的行为就与平台相关,并且会导致发生饥饿问题。在大多数应用程序中,所有的线程都具有相同的优先级Thread.NORMAL。

猜你喜欢

转载自blog.csdn.net/json_it/article/details/79167093