<高级-1> 并发活跃性问题
程序员文章站
2022-07-12 18:42:15
...
一、避免活跃性危险
活跃性没有明确的定义。安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注于另一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的形式之一就是无意中造成的无限循环,从而使循环之后的代码无法得到执行。线程将带来其他一些活跃性问题。例如,如果线程 A在等待线程B释放其持有的资源,而线程B永远都不释放该资源,那么A就会永久地等待下去。本次将介绍各种形式的活跃性问题,以及如何避免这些问题, 包括死锁,饥饿,以及活锁。
在安全性与活跃性之间通常存在着某种制衡。我们使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致因锁的顺序死锁(Lock-Ording Deadlock),这通常是因为需要获得两个及以上的锁时发生。同样,我们使用线程池和信号量来限制对资源的使用,但这些限制的行为可能会导致资源死锁(Resource Deadlock)。 Java 应用程序无法从死锁中恢复过来,因此在设计时一定要排除那些可能导致死锁的条件。
1.1 死锁
经典的“哲学家进餐”问题很好的描述了死锁情况。有五个哲学家绕着圆桌坐,每个哲学家面前有一盘面,两人之间有一支筷子,这样每个哲学家左右各有一支筷子。哲学家有2个状态,思考或者拿起筷子吃饭。如果哲学家拿到一只筷子,不能吃饭,直到拿到2只才能吃饭,并且一次只能拿起身边的一支筷子。一旦拿起便不会放下筷子直到把饭吃完,此时才把这双筷子放回原处。如果,很不幸地,每个哲学家拿起他或她左边的筷子,那么就没有人可以吃到了这就会造成死锁了。
由上面也可以看出是因为每个哲学家需要获取两个共享资源,而且存在环路依赖关系才导致的死锁。
1.1.1 Lock-ording deadlock
两个线程试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性,因此也不会产生死锁。再看看哲学家问题,正是这种情况。
想要验证锁顺序的一致性,需要对程序中的加锁行为进行全局分析。单独分析每条获取多个锁的路径是不够的,因为单独看每个获取锁的方式看起来都是“合理”的。
但有时候并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生。看如下转账的代码:
所有的线程似乎都是按照相同的顺序获得锁,但事实上锁的顺序取决于传递给加锁方法的参数顺序,而这些参数顺序又取决于外部输入,如果一个线程从X向Y转账,另一个线程从Y向X转账,就会发生死锁。
解决此问题必须定义锁的顺序,并在整个应用程序中都按照这个顺序来获取锁。制定锁的顺序时,要按照一个稳定的排序方式来排,比如通过System.identityHashCode方法,返回Object.hashCode值来作为排序的比较。
上面例子使用了加时锁,虽然增加了代价,但保证了安全性,而且使用加时锁的几率也非常低。如果排序时能获得唯一的键值进行排序那就更容易了,比如银行账号就可以用来作为唯一的键值,不需要加时锁。
1.1.2 在协作对象之间发生的死锁
某些获取多个锁的操作不像上面那么容易判断,获取两个锁的操作并不一定必须在一个方法中被获取。如A类的同步方法里使用了B类的同步方法,B类的同步方法也使用了A类的同步方法,那么一个线程使用A类,一个线程使用B类,也会出现交叉等待,协同死锁。
1.1.3 开放调用
上面的情况,A类和B类并不知道他们会陷入死锁,而且他们本来也不应该知道。方法调用相当于一种抽象屏障,因而你无需了解在被调用方法中所执行的操作。也正是因为这种屏障,所以也难以分析可能出现的死锁。
如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用(Open Call).可以通过开放调用来避免死锁的发生,类似于采用封装机制来提供线程安全的的方法。
1.1.4 资源死锁
正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁。当他们在相同的资源集合上等待时,也会发生死锁。
1.2 死锁的避免与诊断
如果一个程序每次至多只获得一个锁,那么就不会产生lock-ording deadlock,但这并不现实。在使用细粒度的锁的过程中,可以通过使用一种两阶段策略来检查代码中的死锁:
(1)首先找出在什么地方将获取多个锁(使这个集合尽可能小),
(2)然后对所有这些实例进行全局分析,从而确保他们在整个程序中获取锁的顺序都保持一致。尽可能使用开放调用。
1.2.1 支持定时的锁
这一项技术可以检测死锁和从死锁中恢复过来,即显式使用Lock类中的定时tryLock功能来代替内置锁机制。
1.2.2 通过线程转储信息来分析死锁
Jvm可以通过线程转储(Thread Dump)来帮助识别死锁的发生。
1.3 其他活跃性危险
尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些其他的活跃性危险,包括饥饿,丢失信号,活锁等。
饥饿:线程由于无法访问它所需要的资源而不能继续执行。最常见的资源就是CPU时钟周期,如优先级低的线程由于其他高优先级线程一直执行导致无法获得cpu时间。
活锁:该问题不会阻塞线程,但也不能继续执行,因为线程不断重复执行相同的操作,而且总会失败。当多个相互协作的线程对彼此进行退让,修改各自的状态,但反复避让,导致活锁。就像两个相遇的人互相让路交叉等待。
活跃性没有明确的定义。安全性的含义是“永远不发生糟糕的事情”,而活跃性则关注于另一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的形式之一就是无意中造成的无限循环,从而使循环之后的代码无法得到执行。线程将带来其他一些活跃性问题。例如,如果线程 A在等待线程B释放其持有的资源,而线程B永远都不释放该资源,那么A就会永久地等待下去。本次将介绍各种形式的活跃性问题,以及如何避免这些问题, 包括死锁,饥饿,以及活锁。
在安全性与活跃性之间通常存在着某种制衡。我们使用加锁机制来确保线程安全,但如果过度地使用加锁,则可能导致因锁的顺序死锁(Lock-Ording Deadlock),这通常是因为需要获得两个及以上的锁时发生。同样,我们使用线程池和信号量来限制对资源的使用,但这些限制的行为可能会导致资源死锁(Resource Deadlock)。 Java 应用程序无法从死锁中恢复过来,因此在设计时一定要排除那些可能导致死锁的条件。
1.1 死锁
经典的“哲学家进餐”问题很好的描述了死锁情况。有五个哲学家绕着圆桌坐,每个哲学家面前有一盘面,两人之间有一支筷子,这样每个哲学家左右各有一支筷子。哲学家有2个状态,思考或者拿起筷子吃饭。如果哲学家拿到一只筷子,不能吃饭,直到拿到2只才能吃饭,并且一次只能拿起身边的一支筷子。一旦拿起便不会放下筷子直到把饭吃完,此时才把这双筷子放回原处。如果,很不幸地,每个哲学家拿起他或她左边的筷子,那么就没有人可以吃到了这就会造成死锁了。
由上面也可以看出是因为每个哲学家需要获取两个共享资源,而且存在环路依赖关系才导致的死锁。
1.1.1 Lock-ording deadlock
两个线程试图以不同的顺序来获得相同的锁。如果按照相同的顺序来请求锁,那么就不会出现循环的加锁依赖性,因此也不会产生死锁。再看看哲学家问题,正是这种情况。
想要验证锁顺序的一致性,需要对程序中的加锁行为进行全局分析。单独分析每条获取多个锁的路径是不够的,因为单独看每个获取锁的方式看起来都是“合理”的。
但有时候并不能清楚地知道是否在锁顺序上有足够的控制权来避免死锁的发生。看如下转账的代码:
所有的线程似乎都是按照相同的顺序获得锁,但事实上锁的顺序取决于传递给加锁方法的参数顺序,而这些参数顺序又取决于外部输入,如果一个线程从X向Y转账,另一个线程从Y向X转账,就会发生死锁。
解决此问题必须定义锁的顺序,并在整个应用程序中都按照这个顺序来获取锁。制定锁的顺序时,要按照一个稳定的排序方式来排,比如通过System.identityHashCode方法,返回Object.hashCode值来作为排序的比较。
上面例子使用了加时锁,虽然增加了代价,但保证了安全性,而且使用加时锁的几率也非常低。如果排序时能获得唯一的键值进行排序那就更容易了,比如银行账号就可以用来作为唯一的键值,不需要加时锁。
1.1.2 在协作对象之间发生的死锁
某些获取多个锁的操作不像上面那么容易判断,获取两个锁的操作并不一定必须在一个方法中被获取。如A类的同步方法里使用了B类的同步方法,B类的同步方法也使用了A类的同步方法,那么一个线程使用A类,一个线程使用B类,也会出现交叉等待,协同死锁。
1.1.3 开放调用
上面的情况,A类和B类并不知道他们会陷入死锁,而且他们本来也不应该知道。方法调用相当于一种抽象屏障,因而你无需了解在被调用方法中所执行的操作。也正是因为这种屏障,所以也难以分析可能出现的死锁。
如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用(Open Call).可以通过开放调用来避免死锁的发生,类似于采用封装机制来提供线程安全的的方法。
1.1.4 资源死锁
正如当多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁。当他们在相同的资源集合上等待时,也会发生死锁。
1.2 死锁的避免与诊断
如果一个程序每次至多只获得一个锁,那么就不会产生lock-ording deadlock,但这并不现实。在使用细粒度的锁的过程中,可以通过使用一种两阶段策略来检查代码中的死锁:
(1)首先找出在什么地方将获取多个锁(使这个集合尽可能小),
(2)然后对所有这些实例进行全局分析,从而确保他们在整个程序中获取锁的顺序都保持一致。尽可能使用开放调用。
1.2.1 支持定时的锁
这一项技术可以检测死锁和从死锁中恢复过来,即显式使用Lock类中的定时tryLock功能来代替内置锁机制。
1.2.2 通过线程转储信息来分析死锁
Jvm可以通过线程转储(Thread Dump)来帮助识别死锁的发生。
1.3 其他活跃性危险
尽管死锁是最常见的活跃性危险,但在并发程序中还存在一些其他的活跃性危险,包括饥饿,丢失信号,活锁等。
饥饿:线程由于无法访问它所需要的资源而不能继续执行。最常见的资源就是CPU时钟周期,如优先级低的线程由于其他高优先级线程一直执行导致无法获得cpu时间。
活锁:该问题不会阻塞线程,但也不能继续执行,因为线程不断重复执行相同的操作,而且总会失败。当多个相互协作的线程对彼此进行退让,修改各自的状态,但反复避让,导致活锁。就像两个相遇的人互相让路交叉等待。
上一篇: 观察者模式vs事件监听模式
下一篇: swagger与dubbox结合