在并发编程中,我们经常通过锁来保证线程安全,但使用锁也可能会带来一系列其他的问题,如死锁等问题。我们知道Java虚拟机无法从死锁中恢复,因此了解死锁的发生场景能够让我们在编程过程中尽可能避免死锁的发生。
死锁
定义
死锁指一组线程中的每个线程都在等待由其他线程占有的因而无法获得的资源,导致线程无法继续推进执行,这里的资源可能是锁,也可能是其他计算机资源,如数据库连接等。
从上面的定义中,我们可以看出死锁的发生有几个必要的条件:
- 资源独占
- 不可剥夺
- 保持申请
- 循环等待
锁顺序死锁
当多个线程试图以不同的顺序获取多个相同的锁时,就可能发生死锁。
考虑A向B转账的业务:
这种是一种很常见的危险情况,比如我们声明如下的一个方法:
1 | public void transfer(Object accountA, Object accountB) { |
看起来我们似乎控制了锁的获取顺序,但是由于转账的双方是不确定的,因此依然可能会出现锁顺序引起的死锁,这种情况,可以根据一定策略,动态改变锁的获取顺序,从而保证所有线程获取锁的顺序是一致的,如通过比较对象哈希值,规定加锁顺序,如下:
1 | public void transfer(Object fromAccount, Object toAccount) { |
协作对象间的死锁
如果一个操作会涉及到多个协作对象,且均需要获取锁,这时就可能导致协作对象间的死锁,一种比较简单的情况即是协作对象间发生锁顺序死锁。
这里指的协作对象可能是不同的功能模块,也有可能是外部方法,当我们持有锁时调用协作对象,就有可能发生死锁。
资源死锁
即线程等待的是资源而不是锁,这种情况与获取锁的情况类似,比如申请数据库连接造成死锁。
避免和诊断死锁
使用显式锁
java.util.concurrent.locks 包中定义了显式锁的接口,提供了比内部锁更灵活的机制,显式锁的接口定义如下:
1 | public interface Lock { |
通过显式锁,我们能够实现带有定时的锁,在请求一个锁时,如果在一定时间内没有获得到锁则返回获取失败,这样可以避免死锁的发生,具体可参考 ReentrantLock 。
通过线程转储分析死锁
我们可以通过良好的程序设计来预防死锁的发生,同时也可以通过 线程转储 来分析运行中的程序是否发生了死锁,以简单的锁顺序死锁为例:
1 |
|
利用编译器的线程转储我们可以看到:
Thread-1@625处于阻塞状态,持有<0x276>对象锁,并等待Thread-0@622释放<0x277>对象锁0x277>0x276>
Thread-0@622处于阻塞状态,持有<0x277>对象锁,并等待Thread-0@622释放<0x276>对象锁0x276>0x277>
其他活跃度问题
饥饿
当线程申请的资源被其他线程永久占用时发生饥饿,比较常见的情况如下:
- 低优先级线程被饿死,抵制使用线程优先级可以尽可能少的引起饥饿问题
- 使用SingleThreadExecutor,工作线程执行的任务进入无限期等待,其他的任务永远无法提交到工作线程中而导致被饿死
弱响应性
客户端程序通常会在后台线程中处理耗时操作,但后台线程和主线程竞争CPU资源也会影响到程序的响应性。通常情况下,后台线程的优先级要低于主线程。
活锁
活锁的情况经常出现在存在重试机制的系统中,当一个任务出现错误时,系统无限进行重试,导致工作线程无法向前推进,可以通过一些策略避免活锁,例如RocketMQ的重试机制。
参考资料
《Java并发编程实战》