死锁详细介绍
参考
- 《java并发编程实战》,《java并发编程实战》源码
主要概念
- 顺序加锁+加时赛锁(全局对象,比如某个类的class对象);
- 开放调用:如果调用某个方法时不需要持有锁,则这种调用被称为 开放调用(open call)。非开放调用可以通过细化加锁力度转变为开放调用。非开放调用可能会由于难以分析的混乱的加锁顺序导致顺序死锁;
- 调用某个对象的
wait()
方法可以让当前对象释放已经获取的此对象的监视器锁,记着这三者合用可以让当前线程释放锁并完好恢复:当前对象已经获取此对象锁+obj.wait()释放锁+obj.notifyAll()唤醒等待obj对象监视器的线程;
一.死锁简介
常见的死锁有顺序死锁(lock-ordering deadlock)、资源死锁(resource deadlock)两大类,线程饥饿死锁(thread starvation deadlock)是资源死锁的一种。
死锁形成的四个必要条件为:
- 互斥条件:任务中使用的资源至少有一个是不能共享的;
- 阻塞不释放条件:一个任务在请求资源而阻塞时,不释放已获得的资源;
- 不可剥夺条件:进程获取的资源不能被另一任务强行剥夺,需等待持有资源的线程释放或抛异常结束;
- 循环等待条件:相互等待资源的线程形成首尾相接的环。
一般来说,破坏死锁即破坏以上四个条件。比如数据库监测到一组事务发生死锁时,将选择放弃一个事务,作为牺牲者的事务将放弃他所有持有的资源,从而使其他事务进行-破坏第二个条件。
1.1 顺序死锁 lock-ordering deadlock
线程之间形成相互等待资源的环时,就会形成顺序死锁lock-ordering deadlock。多个线程试图以不同的顺序来获取相同的锁时容易形成顺序死锁。如果所有线程以固定的顺序来获取锁,就不会出现顺序死锁问题。顺序死锁示例如下:
- 简单示例:加锁顺序不一致
public class OrderDeadLockTest {
private final Object left = new Object();
private final Object right = new Object();
public void left2Right() {
synchronized (left) {
sleeping();
synchronized (right) {
System.out.println("l2r:get all monitor");
}
}
}
public void right2Left() {
synchronized (right) {
sleeping();
synchronized (left) {
System.out.println("r2l:get all monitor");
}
}
}
private void sleeping() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
OrderDeadLockTest demo = new OrderDeadLockTest();
Runnable t1 = () -> demo.left2Right();
Runnable t2 = () -> demo.right2Left();
new Thread(t1).start();
new Thread(t2).start();
}
}
测试调用已经获取的资源对象的wait()放弃锁的方式来破解死锁 todo:实际开发中需要配合Lock类一起使用,因为synchronized会阻塞:
public class OrderDeadLocal4WatiTest {
private Object left;
private Object right;
public OrderDeadLocal4WatiTest(Object left, Object right) {
this.left = left;
this.right = right;
}
public void left2Right() {
synchronized (left) {
System.out.println("waiting");
waiting();
System.out.println("start work");
synchronized (right) {
System.out.println("l2r:get all monitor");
}
}
}
public void right2left() {
synchronized (right) {
sleeping();
System.out.println("wait up");
synchronized (left) {
System.out.println("l2r:get all monitor");
left.notify();
right.notify();
}
}
}
private void waiting() {
try {
left.wait(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private void sleeping() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Object lockX = new Object();
Object lockY = new Object();
OrderDeadLocal4WatiTest objX = new OrderDeadLocal4WatiTest(lockX, lockY);
OrderDeadLocal4WatiTest objY = new OrderDeadLocal4WatiTest(lockX, lockY);
new Thread(() -> objX.left2Right()).start();
new Thread(() -> objY.right2left()).start();
}
}
-
动态的锁顺序死锁
比如使用参数引用的对象加锁,由于无法控制参数的顺序,因此可能出现顺序死锁的问题。比如A向B赚钱是加锁顺序A->B,但是两者相互赚钱时顺序相反,可能死锁: -
顺序死锁的解决策略:顺序加锁及加时赛锁
/**
* @Description 对应DynamicOrderDeadLockTest:
* fixme: 当使用多个参数对象加锁时,需要按照一定次序加锁,比如 System.identifityHashCode方法或者加锁对象唯一的可比较的变量值;
* todo:如果System.idenfifityHashCode()相同(极小可能发生)而且没有唯一可比较键值,则在获必要的锁在前先获得"加时赛锁"。
* @Date 2018/10/5 上午11:33
* -
* @Author dugenkui
**/
public class OrderLockInMethodTest {
/**
* 总是现对hashcode比较大的参数进行加锁
*/
static void transferMoney(final String x,final String y){
if(System.identityHashCode(x)>System.identityHashCode(y)){
synchronized (x){
synchronized (y){
System.out.println("transfer "+x+"to "+y);
}
}
}else if(System.identityHashCode(x)<System.identityHashCode(y)){
synchronized (y){
synchronized (x){
System.out.println("transfer "+x+"to "+y);
}
}
}
/**
* 两个参数hashCode相同,则先获取加时赛锁,然后在获取两个账号的锁
* fixme: 注意 加时赛锁 一定是全局唯一,否则没意义,相互等锁的线程仍然可能形成环状
*/
else{
synchronized (OrderLockInMethodTest.class){
synchronized (x){
synchronized (y){
System.out.println("transfer "+x+"to "+y);
}
}
}
}
}
}
1.2 资源死锁(线程饥饿死锁)
比如有两个数据库线程池大小都为1,两个事务都需要两个数据库的连接,但是获取连接顺序不同,此时两个事务相互等待对方释放资源、形成死锁。数据库死锁检测机制一般会放弃一个事务并释放事务获取的资源。
另一个重要的资源死锁 resource dead-lock是 线程资源死锁thread starvation daed-lock,如果有些任务需要等待其他任务的结果,那么这些任务往往是产生线程饥饿死锁的主要来源,有界线程池/资源池和相互依赖的任务不能一起使用。
示例见thread starvation deadlocal
二.死锁的诊断与避免策略
2.1 死锁诊断:线程转储文件 jstat -l id(虚拟机进程id)
堆栈转储/线程转储 包含 线程的栈追踪信息、每个线程加锁信息和线程正在等待的锁及等待的锁被那个线程持有等信息。 例如动态顺序死锁的线程转储文件如下:
//死锁环
Found one Java-level deadlock:
=============================
//Thread-1等待的锁监视器被Thread-0持有,反之也是
"Thread-1":
waiting to lock monitor 0x00007ff83e80c2b8 (object 0x000000076abb8318, a java.lang.String),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x00007ff83e80ebf8 (object 0x000000076abb8350, a java.lang.String),
which is held by "Thread-1"
//相关线程的详细线程栈信息
Java stack information for the threads listed above:
===================================================
"Thread-1":
at deadlock.DynamicOrderDeadLockTest.transferMoney(DynamicOrderDeadLockTest.java:20)
- waiting to lock <0x000000076abb8318> (a java.lang.String)
- locked <0x000000076abb8350> (a java.lang.String)
at deadlock.DynamicOrderDeadLockTest.lambda$main$1(DynamicOrderDeadLockTest.java:30)
at deadlock.DynamicOrderDeadLockTest$$Lambda$2/1989780873.run(Unknown Source)
at java.lang.Thread.run(Thread.java:748)
"Thread-0":
at deadlock.DynamicOrderDeadLockTest.transferMoney(DynamicOrderDeadLockTest.java:20)
- waiting to lock <0x000000076abb8350> (a java.lang.String)
- locked <0x000000076abb8318> (a java.lang.String)
at deadlock.DynamicOrderDeadLockTest.lambda$main$0(DynamicOrderDeadLockTest.java:29)
at deadlock.DynamicOrderDeadLockTest$$Lambda$1/2074407503.run(Unknown Source)
at java.lang.Thread.run(Thread.java:748)
Found 1 deadlock.
2.2 预防
1. 顺序加锁
顺序加锁+加时赛锁(全局对象,比如某个类的class对象)。
加锁顺序可以使用加锁对象的唯一可比较的变量,也可以使用 System.identifyHashCode()
+加时赛锁。 示例如加时赛锁示例
注意有时候加锁对象是程序运行时产生的,不能在一开始就进行排序,所以需要使用如上方案。
2. 开放调用
如果调用某个方法时不需要持有锁,则成这种调用为开放调用。开发调用易于进行死锁分析,容易避免协作的对象之间产生死锁。
3.定时锁和释放锁:tryLock()和wait()
可以尝试使用支持定时获取的锁,失败不会阻塞,并且可以搭配wait()
方法,在获取锁失败时也释放自己的锁,预防死锁产生,未在具体优秀代码中看到过这种用法。
Lock lock=new ReentrantLock();
Condition con=lock.newCondition();
/**假定调用一下代码所在方法时已经获取了对象objLock的锁。
*
* 尝试获取锁,时间3秒中:
* 1.失败则释放自身拥有的所有锁,等待一会儿
* 2.成功则继续运行。
*/
while(!tryLock(3,TimeUnit.SECONDS){
objLock.await();
}
...
4. 防止线程饥饿死锁 thread stavation dead-lock
有界线程池/资源池和相互依赖的任务不能一起使用。
5. 其他
- 空间换时间:使用数据副本而非争用同一个资源;
三.其他活跃性危险
- 线程饥饿,即线程得不到足够的cup时间片执行任务;
- 糟糕的响应性:降低后台线程的优先级;减小热点资源的加锁时间。
- 活锁:
3.1 线程饥饿
引发饥饿最常见的资源就是CUP时钟周期。如果修改了线程的优先级,程序的行为就将与平台相关(jvm会将其自身10个优先级映射到不同平台的不同优先级上),就有可能造成饥饿问题。
大多数情况下都可以使用默认的线程优先级,应该避免使用线程优先级进而增加程序的平台依赖性。
3.2 糟糕的响应性
对于响应糟糕的GUI程序:可以降低后台任务的优先级,从而提高前台程序的响应性。
某个线程长时间占用热点资源也可能造成糟糕的响应性,可以通过减小热点资源的加锁时间提高响应性。
3.3 活锁
多个相互协作的线程都对彼此进行响应从而修改各自的状态,并使得任何一个线程都无法执行,就发生了活锁。典型的活锁场景如让路,或者如下的愚蠢写法:
void func(){
try{
throw new Exception();//替换成其他绝对抛异常的写法
}catch(Exception e){
func();
}
}
要避免活锁,需要在重试机制中引入随机性。例如在并发应用程序中,通过等待随机长度时间和回退可以有效避免活锁的发生。