第10章-避免活跃性危险

10.0 活跃性

线程的活跃性到底是什么呢?

我们得先知道线程带来的风险:

  • 安全性问题(永远不会发生糟糕的事情)
  • 活跃性问题(某件正确的事情最终会发生)
  • 性能问题(正确的事情要尽快发生)

当某个操作无法继续执行下去时,就会发生活跃性问题,如:死锁、饥饿以及活锁等情况导致操作无法继续执行下去,这时就发生了活跃性问题

10.1 死锁

经典的“哲学家进餐”问题很好地描述了死锁状况,以下情况发生时,哲学家们将会因为凑不到一双筷子吃不到饭而被饿死:每个人都立即抓住自己左边的筷子,然后等待自己右边的筷子空出来,但同时又不放下已经拿到的筷子

“哲学家进餐”问题说明了死锁发生的一种情况:每个人都拥有其他人需要的资源(已拿到的左边的筷子),同时又等待其他人已经拥有的资源(右边的人抓住的筷子),并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源(都不放下自己从左边抓起的筷子)。

上面发生死锁的情况反映在线程上则为:当一个线程永远地持有一个锁,并且其他线程都尝试获得这个锁,那么它们将永远被阻塞。在线程A持有锁L并想获得锁M的同时,线程B持有锁M并尝试获得锁L,那么这两个线程将永远等待下去。这种情况就是最简单的死锁形式(或称为“抱死(Deadly Embrace)”)

10.1.1 锁顺序死锁

锁顺序死锁

如上图所示,发生死锁的原因是两个线程A和B试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁,就不会出现循环的加锁依赖性,因此也就不会发生死锁

如果所有线程都按照固有的顺序来获取锁,那么在程序中就不会出现锁顺序死锁的问题

简单的锁顺序死锁:

/**
 * 简单的锁顺序死锁
 * 容易发生死锁:如果一个线程调用了leftRight,而另一个线程调用了rightLeft,并且这两个线程
 * 的操作是交错执行,那么他们会发生死锁
 *
 */
public class LeftRightDeadlock {
    private final Object left = new Object();
    private final Object right = new Object();

    public void leftRight() {
        synchronized (left) {
            synchronized (right) {
                // do something
            }
        }
    }

    public void rightLeft() {
        synchronized (right) {
            synchronized (left) {
                // do something
            }
        }
    }
}

10.1.2 动态的锁顺序死锁

有时候,我们并不清楚是否在锁顺序上有足够的控制权来避免死锁的发生,比如现实生活中的转账业务,首先要获得两个Account对象的锁,以确保通过原子方式来更新这两个账户的余额,同时又不破坏一些不变性条件,如“账户的余额不能为负数”

/**
 * 动态的锁顺序死锁
 */
public class DynamicDeadlock {

    /**
     * 转账操作(会发生死锁)
     * 两个账户相互给对方转账时,会发生死锁,相当于简单的锁顺序死锁
     *
     * @param fromAccount 转出账户
     * @param toAccount   转入账户
     * @param amount      转账金额
     */
    public void transferMoney(Account fromAccount, Account toAccount, int amount)
            throws InsufficientResourcesException {
        synchronized (fromAccount) {
            synchronized (toAccount) {
                if (fromAccount.getBalance() < 0) {
                    throw new InsufficientResourcesException();
                } else {
                    fromAccount.debit(amount);
                    toAccount.credit(amount);
                }
            }
        }
    }

    class Account {
        private int balance;

        public int getBalance() {
            return balance;
        }

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

        /**
         * 转出
         *
         * @param amount
         */
        public void debit(int amount) {
            this.setBalance(getBalance() - amount);
        }

        /**
         * 转入
         *
         * @param amount
         */
        public void credit(int amount) {
            this.setBalance(getBalance() + amount);
        }
    }
}

上面的程序中似乎所有的线程看起来都是按照相同的顺序来获得锁,但是为什么transferMoney中为什么会发生死锁呢?这是因为事实上锁的顺序取决于传递给transferMoney的参数顺序,如果两个线程同时调用transferMoney,一个线程从张三向李四转账,另一个线程从李四向张三转账,此时就会发生死锁:

A:transferMoney(myAccount,yourAccount,10);
B:transferMoney(yourAccount,MyAccount,20);

如果执行时序不当,A就可能获得myAccount的锁并等待yourAccount的锁,而B此时持有yourAccount的锁,并正在等待myAccount的锁

由于我们无法控制参数的顺序,因此要解决这种死锁,必须要定义锁的顺序,并在程序中都按这个顺序来获取锁,比如像下面这样:

通过锁顺序来避免死锁

/**
 * 动态的锁顺序死锁
 *
 * @author huxiong
 * @date 2016/06/16 17:25
 */
public class DynamicDeadlock {

    /** 当两个对象拥有相同的散列值时使用的加时赛锁 **/
    private static final Object tieLock = new Object();

    /**
     * 通过锁顺序来避免死锁
     *
     * @param fromAccount
     * @param toAccount
     * @param amount
     * @throws InsufficientResourcesException
     */
    public void transferMoneyRight(final Account fromAccount, final Account toAccount, final int amount)
            throws InsufficientResourcesException {
        class Helper {
            public void transfer() throws InsufficientResourcesException {
                if (fromAccount.getBalance() < 0) {
                    throw new InsufficientResourcesException();
                } else {
                    fromAccount.debit(amount);
                    toAccount.credit(amount);
                }
            }
        }
        int fromHash = System.identityHashCode(fromAccount);
        int toHash = System.identityHashCode(toAccount);

        if (fromHash < toHash) {
            synchronized (fromAccount) {
                synchronized (toAccount) {
                    new Helper().transfer();
                }
            }
        } else if (fromHash > toHash) {
            synchronized (toAccount) {
                synchronized (fromAccount) {
                    new Helper().transfer();
                }
            }
        } else {
            // same hashCode? get the tieLock
            synchronized (tieLock) {
                synchronized (fromAccount) {
                    synchronized (toAccount) {
                        new Helper().transfer();
                    }
                }
            }
        }
    }

    class Account {
        /* ... */
    }
}

总结:要避免顺序死锁问题,关键就在于明确并规定锁的顺序,使得在应用程序中始终按照我们制定的顺序来获取锁

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

有时候获取锁的操作并不像上面代码中那么明显,两个锁不一定必须在同一个方法中获取,有可能发生在两个相互协作的对象之间,这时候查找死锁会比较困难:如果在持有锁的情况下调用某个外部方法,这时候就要警惕死锁

如果在持有锁时调用某个外部方法,那么将出现活跃性问题,在这个外部方法中有可能会获取其他锁(这时有可能出现像之前的顺序死锁现象),或者阻塞时间过长,导致其他线程无法及时获得当前被持有的锁

10.1.4 开放调用

相对于持有锁时调用外部方法的情况,如果在调用某个方法时不需要持有锁,这种调用就叫做“开放调用”(Open Call)

在程序中应该尽量使用开放调用,以便于对依赖于开放调用的程序进行死锁分析

10.1.5 资源死锁

相对于多个线程相互持有彼此正在等待的锁而又不释放自己持有的锁时会发生死锁,当这种等待发生在相同的资源集合上时,也会发生死锁,称之为资源死锁

资源死锁通常有两种情况:

  • 单纯的资源等待
  • 线程饥饿死锁

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

有界线程池/资源池相互依赖的任务不能一起使用

10.2 死锁的避免与诊断

通过导致死锁的各种情况分析,要避免线程的死锁,通常可以使用一种“两阶段策略”来检查代码中的死锁:

  1. 找出在什么地方获得多个锁并且分析它们,以确保它们在整个程序中获取锁的顺序保持一致
  2. 尽可能使用开放调用从而简化分析过程

相应的操作分别为:

  • 使用支持定时的锁
  • 通过线程转储信息来分析死锁

10.2.1 支持定时的锁

简单来说支持定时的锁就是显式使用Lock类中的定时tryLock功能来代替内置锁机制从而可以检测死锁和从死锁中回复过来

定时锁失败时,你不需要知道到底是怎么失败的,失败的原因可能多种多样,但是至少你能记录所发生的失败以及失败时涉及操作的其他有用信息,以便通过一种更平缓的方式来重新启动计算,而不是把整个进程都关闭了

使用定时锁来获取多个锁也能有效地应对死锁问题:获取锁超时时,可以释放这个锁,然后后退并稍后重试,从而消除死锁发生的条件(很多时候死锁都是因为两个线程同时等待对方的锁造成的,错开执行时序可以消除死锁),时程序得以恢复(这种技术只在同时获取两个锁时有效

10.2.2 其他活跃性危险

死锁是最常见的活跃性危险,意味着在并发程序中还存在其他一些活跃性危险:

  • 饥饿
  • 丢失信号
  • 活锁

10.3.1 饥饿

假定我们是农民工,中午下工之后去食堂吃饭,但是本来应该把饭菜都准备好的食堂此时还没有开火,我们只能等待食堂做好饭,因为中午不吃饭下午就没力气,没力气就干不了活

线程就像这个农民工一样,当线程由于无法访问它所需要的资源而不能继续执行时,就发生了“饥饿”,就像农民工无法吃上饭就不能继续干活一样,因为他们“饥饿”。

引发农民工饥饿的原因就是食堂没有做好的饭菜,而引发线程饥饿最常见的资源就是CPU时钟周期,如果一个线程的优先级不当或者在持有锁时发生无线循环、无限等待某个资源,这就会导致此线程长期占用CPU时钟周期,其他需要这个锁的线程无法得到这个锁,因此就发生了饥饿

Thread Api中定义的线程优先级只是作为线程调度的参考,JVM会根据需要将优先级映射到操作系统的调度优先级,意味着这种映射是与平台相关的,意味着在一个操作系统中两个不同的java优先级会被映射到同一个优先级,而在另一个系统中有可能会被映射到另一个不同的优先级

正式由于优先级与平台的相关性,所以通常情况下,我们尽量不要修改线程的优先级,否则会导致发生饥饿问题的风险

小结

避免使用优先级,因为这会增加平台依赖性从而导致活跃性问题,多数情况下,使用默认的线程优先级就可以了

10.3.2 活锁

假如有这样一个场景:两个绅士在去往对方城市旅游的路上狭路相逢,他们彼此都让出对方的路,然而冤家路窄,另一条路上他们又相遇了,如果运气不好他们之后都又遇见对方的话,结果就是他们就这样反反复复地避让下去。因而也就没法旅游了

活锁就是类似于绅士让路一样,是另一种形式的活跃性问题,这种问题发生时,尽管不会阻塞线程(绅士相遇都相互让路了,可以各自继续赶路),但也不能继续执行(一直在让路,到不了旅行目的地),因为线程将不断重复相同的操作,而且总是失败

活锁通常发生在处理事务消息的应用程序中:不能成功处理某个消息时,回滚整个事务,并把这个消息重新放回待处理的队列头部,假如之后还是不能处理成功,那么这个过程将循环执行,使这种情况发生的消息通常称为“毒药消息”(Poison Message

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

要解决活锁问题,需要在重试机制中引入随机性(如以太协议在重复发生冲突时采用指数方式回退机制:冲突发生时等待随机的时间然后重试,如果等待的时间相同的话还是会冲突),在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。

posted @ 2016-06-22 17:20  中岛嘉兰  阅读(164)  评论(0编辑  收藏  举报