避免 Java 线程活跃性危险

前言

        (分享一首有趣,声音又好听的歌,歌词好生动!嗯,两个月没更新了,好像都把我忘了,赶紧更新更新更新一波,最近又考试又去洛阳浪了一波又准备年后事情又搞微服务系统,好像找借口/手动逗比。尽量多更好吧!)

在安全性与活跃性之间通常存在某种制衡,使用加锁机制来确保线程安全,但过度使用加锁,可能导致锁顺序死锁(Lock-Ordering Deadlock)。使用线程池和信号量来限制对资源的使用,但这些资源限制的行为可能会导致资源死锁(Resource Deadlock)。Java 无法从死锁中恢复,因此尽可能避免和排除会导致死锁出现的条件。

死锁

       简答的死锁形式(或称为

"抱死 [Deadly Embrace]"),其中多个线程由于存在环路的锁依赖关系而永远等待下去。(每个线程假象为有向图中的一个节点,图中每条边表示的关系是:"线程 A 等待线程 B 所占有的资源"。如果在图中形成一个环路,就存在一个死锁。)

在数据库系统的设计中考虑了监测死锁以及从死锁中恢复。在执行一个事务时可能需要获取多个锁,并一直持有这些锁直到事务提交。因此在两个事务之间很可能发生死锁,但事实上这种情况不多见。如果没有外部干涉,这些事务将永远等待下去(在某个事务中持有的锁可能在其他事务中也需要)。当数据库检测到一组事务发生了死锁时(通过在表示等待关系的有向图中搜索循环),将选择一个牺牲者并放弃这个事务。作为牺牲者的事务会释放它持有的资源,从而使其他事务继续进行。

JVM 在解决死锁问题时,当一组 Java 线程发生死锁时,这些线程永远不能再使用。根据线程完成工作的不同,可能造成应用程序完全停止,或某个特定的子系统停止,或者是性能降低。恢复应用程序的唯一方式时中止并重启它,并希望不要再发生同样的事。

与许多其他的并发危险一样,死锁造成的影响很少会立即显现出来。一个类可能发生死锁,并不意味着每次都会发生死锁,而只能表示有可能。当死锁出现时,往往是在最糟糕时 —— 在高负载情况下。

 锁顺序死锁:

leftRight 和 rightLeft 两个方法分别获得 left 锁和 right 锁。如果一个线程调用了 leftRight,而另一个线程调用了 rightLeft,并且两个线程的操作时交错执行会发生死锁,原因是两个线程试图以不同顺序来获得相同的锁。

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

避免 Java 线程活跃性危险

动态的锁顺序死锁:

避免 Java 线程活跃性危险

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

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

A: transferMoney(myAccount,yourAccount,10);

B: transferMoney(yourAccount,myAccount,20);

由于无法控制参数的顺序,因此必须定义锁的顺序并在整个应用程序中都按照这个顺序来获取锁才能避免死锁问题。

避免 Java 线程活跃性危险

避免 Java 线程活跃性危险

在极少数情况下,两个对象可能拥有相同的散列值,此时必须通过某种任意的方法来决定锁的顺序,而这可能会重新引入死锁。为了避免这种情况,使用 "加时赛(Tie-Breaking)" 锁。在获得两个 Account 锁之前,首先获取这个锁,从而保证每次只有一个线程以未知的顺序获得这个锁,从而消除死锁的可能性(只要一致地使用这种机制)。如果经常会出现散列值冲突的情况,可能会成为并发性的一个瓶颈,但由于出现散列冲突的频率非常低,因此以最小的代价换取最大的安全性。

如果在 Account 包含一个唯一的、不可变的,并且具由可比性的键值,例如账号,要制定锁的顺序就更容易:通过键值对对象进行排序,因而无需使用 "加时赛" 锁。

锁被持有的时间通常很短,然而在真实系统中,死锁往往都是很严重的问题。作为商业产品的应用程序每天可能要执行数十亿次获取锁 - 释放锁的操作。只要在这数十亿次操作中有一次发生了错误,就可能导致程序发生死锁,并且及时应用程序通过了压力测试也不可能找出所有潜在的死锁(短时间持有锁时为了降低锁的竞争程度,但却增加了在测试中找出潜在死锁风险的难度)。

避免 Java 线程活跃性危险

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

避免 Java 线程活跃性危险

避免 Java 线程活跃性危险

某些获取多个锁的操作并不明显,在出租车调度系统中可能会用到它们。Taxi 代表一个出租车对象,包含位置和目的地两个属性,Dispatcher 代表一个出租车车队。

尽管没有任何方法会显式获取两个锁,但

setLocation 和 getImage 等方法的调用者都会获取两个锁。如果一个线程在收到 GPS 接收器的更新事件时调用 setLocation,它将首先更新出租车的位置,然后再判断是否到达了目的地。如果已到达,会通知 Dispatcher:它需要一个新的目的地。因为 setLocation 和 notifyAvailable 都是同步方法,因此调用 setLocation 的线程将首先获取 Taxi 的锁,再获取 Dispatcher 的锁。同样,调用 getImage 的线程将首先获取 Dispatcher 的锁,然后获取每个 Taxi 的锁(每次获取一个)。两个线程按照不同的顺序来获取两个锁,就可能导致死锁。

在 Taxi 和 Dispatcher 中查找死锁比较困难,如果在持有锁的情况下调用某个外部方法就需要警惕死锁。

开放调用:

由于不知道在被调用方法中执行的操作,因此在持有的时候对调用某个外部方法将难以进行分析,从而可能出现死锁。

如果在调用某个方法时不需要持有锁,这种调用被称为开放调用。依赖于开放调用的类通常能表现出更好的行为,并且与那些在调用方法时需要持有锁的类相比也更容易编写。通过开放调用来避免死锁的方法,类似于采用封装机制来提供线程安全的方法:虽然在没有封装的情况下也能确保构建线程安全的程序,但对于一个使用了封装的程序进行线程安全分析,要比分析没有使用封装的程序容易的多。分析一个完全依赖于开放调用的程序的活跃性,要比分析那些不依赖开放调用的程序的活跃性简单。通过尽可能使用开放调用,将更容易找出那些需要获取多个锁的代码路劲,因此也就更容易确保采用一致性的顺序来获得锁(对开放调用以及锁顺序的依赖,反映在构造同步对象过程中存在的复杂性)。

避免 Java 线程活跃性危险

避免 Java 线程活跃性危险

在重新编写同步代码块以使用开放调用时会产生意想不到的结果,因为会使得某个原子操作变为非原子操作。在许多情况下,使某个操作失去原子性是可以接受的。例如,对于两个操作:更新出租车位置以及通知调度程序这辆车已准备好出发去一个新的目的地,这两个操作并不需要实现为一个原子操作。

在某些情况下,丢失原子性会引发错误,此时需要通过其他方式来实现原子性。例如,在关闭某个服务时,可能希望所有正在运行的操作执行完成以后,再释放这些服务占用的资源。如果在等待操作完成的同时持有该服务的锁,将容易导致死锁,但如果在访问关闭之前就释放服务的锁,则可能导致其他线程开始新的操作。解决方法是,在将服务的状态更新为 "关闭" 之前一直持有锁,其他想要开始新操作的线程,包括想关闭该服务的其他线程,会发现服务已经不可用,因此也就不会试图开始新的操作。然后,可以等待关闭操作的结束,并知道当开放调用完成后,只有执行关闭操作的线程才能访问服务的状态。这个方法依赖于构造一些协议(而不是通过加锁)来防止其他线程进入代码的临界区。

资源死锁:

在相同的资源集合上等待又不释放自己持有的锁时,也会发生死锁。

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

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

死锁的避免与诊断

       如果必须获取多个锁,那么在设计时必须考虑到锁的顺序:尽量减少潜在的加锁交互数量,将获取锁时需要遵循的协议写入正式文档并始终遵循这些协议。

在使用细粒度的程序中,可以通过使用一种两阶段策略来检查代码中的死锁:首先,找出在什么地方获取多个锁(使这个集合尽量小),然后对所有这些实例进行全局分析,确保它们在整个程序中获取锁顺序都保持一致。尽可能地使用开放调用,极大简化分析过程。如果所有的调用都是开放调用,那么要发现获取多个锁的实例时非常简单,可以通过代码审查,或者借助自动化的源代码分析工具。

支持定时的锁:

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

当定时锁失败时,并不需要知道失败的原因。或许是因为发生了死锁,或许某个线程在持有锁时错误地进入了无限循环,可能是某个操作的执行时间远远超过你的预期。至少你能记录所发生的失败,以及关于这次操作的其他有用信息,并通过一种更平缓的方式来重新启动计算,而不是关闭整个进程。

使用定时锁来获取多个锁也能有效地应对死锁问题。如果在获取锁超时,就可以释放这个锁,然后后退并在一段时间后再次尝试,从而消除死锁发生的条件,使程序恢复过来(只有在同时获取两个锁时才有效,如果在嵌套的方法调用中请求多个锁,那么即使你知道持有外层的锁,也无法释放它)。

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

防止死锁的主要责任在于你自己,但 JVM 仍然通过线程转储(Thread Dump)来帮助识别死锁的发生。线程转储包括各个运行中的线程的栈追踪信息,类似于发生异常时的栈追踪信息。线程转储还包含加锁信息,例如每个线程持有了哪些锁,在哪些栈帧中获得这些锁,以及被阻塞的线程正在等待获取哪个锁。(定期触发线程转储,可以观察程序的加锁行为)在生成线程转储之前,JVM 将在等待关系图中通过搜索循环来找出死锁。如果发现了一个死锁,则获取相应的死锁信息,例如在死锁中涉及哪些锁和线程,以及整个锁的获取操作位于程序的哪些位置。

在 UNIX 平台上触发线程转储操作,可以通过向 JVM 的进程发生 SIGOUT 信号(kill -3),或者在 UNIX 平台下 Ctrl-,在 Windows 平台下 Ctrl-Break。在许多 IDE 中都可以请求线程转储。

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

避免 Java 线程活跃性危险

在导致死锁中包括3个组件:一个 J2EE 应用程序,一个 J2EE 容器,以及一个 JDBC 驱动程序,分别由不同的生产商提供。这3个组件都是商业产品,并经过大量的测试,但每个组件中都存在一个错误,并且这个错误只有当它们进行交互时才会显现出来,并导致服务器出现一个严重的故障。

当诊断死锁时,JVM 可以帮我们做许多工作 —— 哪些锁导致了这个问题,涉及哪些线程,它们持有哪些其他所,以及是否间接地给其他线程带来不利影响。其中一个线程持有 MumbleDBConnection 上的锁,并等待获取 MumbleDBCallableStatement 上的锁,而另一个线程则持有 MumbleDBCallableStatement 上的锁,并等待 MumbleDBConnection 上的锁。

JDBC 驱动程序明显存在一个锁顺序问题:不同的调用链通过 JDBC 驱动程序以不同的顺序获取多个锁。如果不是由于另一个错误,这个问题永远不会显现出来:多个线程试图同时使用同一个 JDBC 连接。在 JDBC 规范中并没有要求 Connection 必须是线程安全的,以及 Connection 通常被封闭在单个线程中使用。这个生产商试图提供一个线程安全的 JDBC 驱动,因此在驱动程序代码内部对多个 JDBC 对象施加了同步机制。然而却没有考虑锁的顺序而容易发生死锁,正是由于这个存在死锁风险的驱动程序与错误共享 Connection 的应用程序发生交互才使得这个问题暴露出来。

其他活跃性危险

       除了死锁,还有饥饿、丢失信号和活锁等。

饥饿:

当程序由于无法访问它所需要的资源而不能继续执行时,会发生 "饥饿

(Starvation)"。引发饥饿的最常见资源就是 CPU 时钟周期。如果在 Java 应用程序中对线程的优先级使用不当,或者在持有锁时执行一些无法结束的结构(无限循环,或者无限制等待某个资源),也会导致饥饿。

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

尽量不要改变线程的优先级,只要改变了优先级,程序的行为将与平台相关,并且导致发生饥饿问题的风险。经常能发现某个程序会在一些奇怪的地方调用 Thread.sleep 或 Thread.yield,这是因为该程序试图克服优先级调整问题或响应问题,并试图让低优先级的线程执行更多的时间。

糟糕的响应性:

在 GUI 应用程序中把运行时间较长的任务放到后台线程中运行,从而不会使用户界面失去响应。但 CPU 密集型的后台任务仍然可能对响应性造成影响,因为它们会与事件线程共同竞争 CPU 的时钟周期。

不良的锁管理也可能导致糟糕的响应性。如果某个线程长时间占有一个锁(或者正在对一个大容器进行迭代,并对每个元素进行计算密集的处理),而其他想要访问这个容器的线程就必须等待很长时间。

活锁:

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

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

要解决这种活锁问题,需要在重试机制中引入随机性。例如,在网络上,如果两台机器尝试使用相同的载波来发送数据包,这些数据包将会发送冲突。这两台机器都检查到了冲突,并都在稍后再次发送。如果二者都选择在1秒后重试,就又发生冲突,并且不断地冲突下去,因而即使有大量闲置的带宽,也无法使数据包发送出去。为了避免这种情况发生,需要让它们分别对待一段随机的时间。(以太协议定义了在重复发生冲突时采用指数方式回退机制,从而降低在多台存在冲突的机器之间发生拥塞和反复失败的风险)在并发应用程序中,通过迭代随机长度的时间和回退可以有效地避免活锁的发生。

(完)

仅做读书笔记,参考文献:Java Concurrency in Practice

 

识别二维码,关注我,每周一更。

避免 Java 线程活跃性危险

 

原文始发于微信公众号(一个八月想偷懒的开发坑):避免 Java 线程活跃性危险