一、死锁概述
关于死锁,我们可以从哲学家用餐问题说起(该例子来自《Java并发编程实战》)。
话说5个哲学家去用餐,坐在一张圆桌旁,他们总共有5根筷子(而不是5双),并且每两个人中间放一支筷子,哲学家时而思考,时而进餐,每个人都需要一双筷子才能吃到东西,并且吃完后将筷子放回原处继续思考。
有些筷子管理算法能使每个人都能相对及时的吃到东西,但有些算法却可能导致一些或者所有哲学家都饿死的情况,比如每个人都立即抓住自己左边 筷子,然后等待自己右边的筷子空出来,但同时又不放下已经拿到的筷子,这种情况下就会出现所谓的死锁。
也就是说每个人都拥有其他人需要的资源,同时又等待其他人已经拥有的资源,并且每个人在获得所有需要的资源之前都不会放弃已经拥有的资源。
当线程 A
持有锁 L
并想获得锁 M
的同时,线程 B
持有锁 M
并尝试获得锁 L
,那么这两个线程将永远的等待下去,这种就是最简单的死锁情况。与该例子相似的其实还有银行家算法,有兴趣的可以看一下。
可以看到,如果把每个线程都想象为有向图中的一个节点,途中每条边表示的关系是:线程
A
等待线程B
锁占有的资源,如果在途中形成了一个环,那么这种情况就是死锁了。
线程发生死锁时,线程之间相互等待,但又不释放自身的资源,导致陷入一种死循环的等待过程中,但一般产生死锁的情况不会立即展示出来,也就是说,如果一个类可能发生死锁,那么并不意味着每次都会发生死锁,而只是表示有可能发生。
二、避免死锁
针对死锁发生的情况,我们可以从以下几个方面进行着手,避免死锁的发生。
1. 加锁的顺序
在多线程操作进行加锁的时候,加锁的顺序是一个很重要的点,如果是按照不同的顺序进行加锁,那么死锁就很容易发生。我们拿《并发编程实战》中的一个例子来说明:
线程1执行leftRight方法:
lock left,尝试lock right,然后永久等待
线程2执行rightLeft方法:
lock right,尝试lock left,然后永久等待
在这个简单的例子中,两个线程试图以不同的顺序来获得相同的锁,如果这两个线程的操作是交错执行,那么就会发生死锁。而如果按照相同的顺序来请求锁(比如都先锁left,再锁right),那么就不会出现循环的加锁依赖,因此也就不会产生死锁了。
如果所有线程以固定的顺序来获得锁,那么在程序中就不会出现锁顺序的死锁问题。
书中还提供了一个简单的代码示范:
public class DeadLockTest {
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. 使用定时锁
也就是说使用锁的时候,使用支持超时时间的锁来代替内置锁,比如Lock类中的tryLock
功能。在之前,在使用内置锁的时候,只要没有获取到锁,那么就会永远等待下去,而显式锁可以指定一个超时时间(Timeout),在过了超时时间之后,返回相应的错误信息。然后我们可以进行后续操作,比如过一段时间再次尝试,或者轮询tryLock等,这样就避免了死锁发生的可能性。
当有非常多的线程同一时间去竞争资源时,即便有超时机制,但有可能会导致其他的问题,比如这些线程重复地尝试但却始终得不到锁,因为当线程很多时,其中两个或多个线程的超时时间一样或者接近的可能性就会很大,因此就算出现竞争而导致超时后,由于超时时间一样,它们又会同时开始重试,导致新一轮的竞争。
所以说使用定时锁的时候需要考虑一下并发的线程数量会不会很多。
3. 死锁检测
虽然我们可以在程序中通过定义顺序锁或者定时锁这种方式来避免死锁的发生,但总有一些我们控制不了的加锁顺序,或者锁超时处理不了的场景,这种情况下,JVM提是提供了线程转储(Thread Dump)来帮助识别死锁的发生。
- Thread Dump包括了各个运行中的线程的栈追踪信息,这类似于发生异常时的栈追踪信息,还包含了加锁信息,比如说每个线程持有了哪些锁。
- 查看Thread Dump的方式有很多种,比如通过ps然后结合kill命令来查看,也可以通过JDK自带的工具,比如jps配合jstack,使用JConsole工具,使用JVisualVM工具等。有关获取Thread Dump这里就不多说了,等后续有时间,专门测试一下。
三、线程饥饿
有的时候,我们还能看到一个概念:饥饿。线程饥饿和线程死锁稍微有些不同,如果一个线程一直访问不到它所需要的资源就会发生饥饿(Starvation),发生饥饿最常见的有下面几种情况:
- 线程的优先级使用不当,如果线程高的优先级一直占用优先级低的线程的资源,那么就会导致低优先级的线程一直获取不到资源,这时候就会发生饥饿;比如低优先级线程A,高优先级线程B,C,D,线程B先获取CPU时间占用资源,然后释放后,线程C接着占用资源,周而往复,低优先级线程A就一直获取不到资源导致饥饿;
- 一个线程一直占用某个资源不释放,那么其他线程就会一直等待得不到执行,这时候也会发生饥饿;
Java中的读写锁ReentranctReadWriteLock就有可能发生饥饿,比如说某一个资源读操作优先级较高,那么等待写操作的线程就会一直阻塞,也就是发生了饥饿。但与死锁不太同的是,饥饿的线程可以在一段时间之后接着执行,比如说占用资源的线程释放了资源等。
了解了什么时候会发生饥饿的情况,我们就可以来简单说下怎么尽量避免饥饿的发生:
- 我们要尽量避免修改线程的优先级,线程优先级只是作为线程调度的参考,Thread中定义了10个优先级,JVM根据需要将他们映射到操作系统的调度优先级,这种映射是与特定操作系统平台相关的,因此在某个操作系统中两个不同的Java优先级可能被映射到同一个优先级,而在另一个操作系统中有可能相反。所以我们尽量不要修改线程的优先级,因为只要改变了线程的优先级,程序的性为就将与平台有关,并且会导致发生饥饿的风险;
- 线程执行完之后,记得释放资源,特别是Lock,正常来说,我们都是将unlock操作放到finally块中执行,这样就不至于出现在异常的时候资源无法正常释放的情况;
- 这一点就比较明了了,不要在锁中出现死循环的情况,当前,无论是否有锁都不应该有无限循环的情况出现。
四、活锁
还有一种情况被称为活锁(Livelock),活锁不会阻塞线程,但也不能继续执行,因为线程将不断重复执行相同的操作,而且总会失败。活锁其实和死锁类似:
- 活锁通常发生在处理事务消息的应用程序中,比如说消息处理机制中,异常情况下回滚整个事务,并将消息重新放到队列的开头,然后监听器监听到该消息,然后再执行,再失败,再回滚。虽然处理消息的线程并没有阻塞,但也无法执行下去,这种就是所谓的活锁,这种形式的活锁通常是由于过度的错误恢复代码导致的,因为它错误的将不可修复的错误作为可以修复的错误。
- 另一种形式的活锁是多个线程对彼此进行互相谦让,都主动将资源让给别的线程使用,这样该资源在多个线程之间来回跳转而又得不到执行,就发生了活锁,这就像两个有礼貌的人在半路上面对面的遇到,然后他们彼此都给对方让路,一直反复地避让下去。在JDK中表示的话有点类似于线程A中调用线程B的join方法,线程B中调用线程A的join方法;
而要避免活锁问题,可以从以下几个方法入手:
- 设置重试次数的最大值,这样的话如果一直发送失败,我们就可以停止发送消息,然后记录下来;
- 在重试机制中引入随机性,就说上面那两个相互让路的人,我们可以修改两个让路人的时序来解除活锁,比如说你让1秒,我让2秒;
- 从事务中的回滚操作入手,比如说不回滚,如果失败和上面类似,记录异常信息,然后使用定时任务或异步方式对表中记录进行处理;
- 最后一点也比较简单,也就是程序中尽量不要出现两个线程间相互谦让的情况,不过这种情况也不多见。
五、总结
线程死锁,活锁,饥饿都被称为线程的活跃性问题。线程的活跃性和线程的安全性有些不同:
- 线程安全性是说线程运行出的结果和预期的结果不一致,发生了一些糟糕的事情,要保证线程的安全性,就是要保证线程同步,我们可以使用加锁机制来实现;
- 而线程的活跃性是指在线程执行过程中,某个操作无法继续执行,这时候就发生了活跃性问题。或者说并发应用程序能及时执行的能力称为活跃性(A concurrent application’s ability to execute in a timely manner is known as its liveness.)
而针对活跃性的这几种常见情况,我们可以简单总结下:
- 死锁是说多个线程同时请求对方占用的资源,但都不释放自身的资源的情况;
- 活锁是线程不会发生阻塞,但也执行不了的情况;
- 饥饿是线程一直等待其他线程占有的资源,但一直访问不到的情况。
本文主要参考自:
《Java并发编程实战》及并发编程网的部分文章
Chinaunix.net-C/C++-有关活锁的疑问
还有Oracle的官方文档:docs.oracle.com/concurrency/deadlock.html
网友评论