Java并发编程实战读书笔记——第十章 避免活跃性危险

在安全性与活跃性之间通常存在着某种制衡。我们使用加锁来确保线程安全,但如果过度加锁,则可能导致锁顺序死锁(Lock-Ordering Deadlock)。同样,我们使用线程池和信号量来限制对资源的使用,但这些限制的行为可能会导致资源死锁(ResourceDeadLock)。Java应用程序无法从死锁中恢复,因此要避免。

10.1 死锁

**经典的哲学家进餐问题很好地描述了死锁状况。**5个哲学家去吃中餐,有5根筷子,而不是5双,并且每两个人中间放一根筷子。哲学家们里面思考,时而进餐。算法导致一些或者所有哲学家饿死(锁死)。每个人都立即抓住自己左边的筷子,然后等待自己右边的筷子空出来,但同时又不放下已经拿到的筷子。每个人拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有资源之前都不会放弃已经拥有的资源。

其中多个线程由于存在环路的锁依赖关系而永远地等待下去,称为抱死。Deadly Embrace。

**在数据库系统的设计中考虑了监测死锁以及从死锁中恢复。**在执行一个事务时可能需要获取多个锁,并一直持有这些锁直到事务提交。如果数据库检测到死锁,将选择一个事务牺牲并放弃这个事务,释放这个事务的所有资源,从而使其他事务继续执行。应用程序可以先更新执行被强行中止的事务。

JVM,当一组Java线程发生死锁时,这些线程就永远不能再使用了。恢复应用程序的唯一方式就是中止并重启它,并希望不要再发生同样的事情。

10.1,1 锁顺序死锁

Java并发编程实战读书笔记——第十章 避免活跃性危险_第1张图片

如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序死锁问题。

Java并发编程实战读书笔记——第十章 避免活跃性危险_第2张图片

10.1.2 动态的锁顺序死锁

Java并发编程实战读书笔记——第十章 避免活跃性危险_第3张图片

在transferMoney中如何发生死锁?所有的线程似乎都是按照相同的顺序来获得锁,但事实上锁的顺序取决于传递给transferMoney的参数顺序,而这些参数顺序又取决 于外部输入。如果两个线程同时调用transferMoney,其中一个线程从X向Y转账,另一个线程从Y向X转账,那么就会发生死锁。

可以使用System.identityHashCode方法来制定锁的顺序,消除了发生死锁的可能性。

Java并发编程实战读书笔记——第十章 避免活跃性危险_第4张图片

在极小的情况下,两个对象可能拥有相同的散列值,此时必须通过某种任意的方法来决定锁的顺序,而这可能会重新引入 死锁。可以使用另一个锁,在获得两个Account锁之前,先获取这个锁,从而保证每次只有一个线程可以访问,避免了死锁的可能性。由于hashCode相同的概率非常低,因此这项技术以最小的代价,换来了最大的安全性。

或者使用Account中包含的唯一的、不可变的键值,来制定锁的顺序。

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

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

10.1.4 开放调用

如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用(Open Call)。依赖于开放调用的类通常能表现出更好的行为,它类似于采用封装机制来提供线程安全的方法。通过尽可能地使用开放调用,将更易于找出那些需要获取多个锁的代码路径,因此也就更容易确保采用一致的顺序来获取锁。

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

10.1.5 资源死锁

资源池通常采用信号量来实现当资源池为空时的阻塞行为。

线程A可能持有与数据库D1的连接,并等待与数据库D2的连接,而线程B则持有与D2的连接并等待与D1的连接。资源池越大,出现这种情况的可能性就越小。

线程饥饿死锁(Thread-Starvation Deadlock),一个任务提交另一个任务,并等待被提交的任务在单线程的Executor中执行完成。如果某些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源,有界线程池/资源池与相互依赖的任务不能一起使用。

10.2 死锁的避免与诊断

如果每次只能获得一个锁,那么就不会产生锁顺序死锁。如果要获取多个锁,就必须考虑锁的顺序。

在使用细粒度锁的程序中,可以通过使用一种两阶段策略来检查代码中的死锁:首先,找出在什么地方获取多个锁(使这个集合尽量小),然后对这个集合进行分析,确保它们在整个程序中获取锁的顺序都保持一致。尽可能地使用开放调用。

10.2.1 支持定时的锁

显式使用Lock类中的定时tryLock功能来代替内置锁机制。显式锁可以指定一个超时时限,在等待超过该时间后会返回一个失败信息,并通过一种更平缓的方式来先更新启动计算,而不是关闭整个进程。如果超时,可以释放这个锁,然后后退并在一段时间后再次尝试,从而消除死锁发生的条件。

这项质朴主中有在同时获取两个锁时有效,如果有嵌套的方法中调用中请求多个锁,那么即使你知道已经持有了外层的锁,也无法释放它。

10.2.2 通过线程转储信息来分析死锁

Thread Dump来帮助识别死锁的发生。线程转储包括各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息,它还包含加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取哪一个锁。JVM通过搜索循环来找出死锁。

内置锁与获得它们所在的线程栈帧是相关联的,而显式的Lock只与获得它的线程相关联。

Java并发编程实战读书笔记——第十章 避免活跃性危险_第5张图片

在诊断死锁时,JVM可以帮我们做许多工作——哪些锁导致了这个问题,涉及哪些线程,它们持有哪些其他的锁,以及是否间接地给其他线程带来了不利影响。

10.3 其他活跃性危险

10.3.1 饥饿

当线程由于无法访问它所需的资源而不能继续执行时,就发生了饥饿(Starvation)。引发饥饿的最常见资源就是CPU时钟周期。优先级不当、无法结束的结构。

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

只要改变了线程的优先级,程序的行为就将与平台相关,并且会导致发生饥饿问题的风险。

要避免使用线程优先级,因为这会增加平台依赖性,并可能导致活跃性问题。在大多数并发应用程序中,都可以使用转为的线程优先级。

10.3.2 糟糕的响应性

计算密集型的后台任务可以降低它们的优先级,从而提高前台程序的响应性。

不良的锁管理也可能导致糟糕的响应性。大容器迭代与计算密集任务,使其它访问这个容器的线程就必须等待很好时间。

10.3.3 活锁

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

活锁通常发生在处理事务消息的应用程序中:如果不能成功地处理某个消息,那么消息处理机制将回滚整个事务,并将它先更新放到队列的开头。

要解决这种活锁问题,需要在重试机制中引入随机性。在并发应用程序中,通过等待随机长度的时间和回退可以有效地避免活锁的发生。以太协议定义了在重复发生冲突时采用指数方式回退机制,从而降低在多台存在冲突的机器之间发生拥塞和反复失败的风险。

小结

活跃性故意是一个非常严重的问题,因为当出现活跃性故障时,除了中止应用程序之外没有任务任何机制可以帮助从这种故障时恢复过来。最常见的故障是锁顺序死锁。在设计时应该避免产生锁顺序死锁:确保线程在获取多个锁时采用一致的顺序。最好的解决方法是在程序中始终使用开放调用。这将大大减少需要同时持有多个锁的地方,也更容易发现这些地方。

你可能感兴趣的:(Java并发编程实战)