前言
在多线程编程中死锁是一个常见的问题,我们都知道死锁的出现有四个必要条件:资源互斥使用,也就是说每个资源一次只能有一个线程使用;占有并请求,所有的线程都持有它们目前请求到的资源并且申请还未得到的资源;不可剥夺,也就是说所有线程请求到的资源都无法被其他线程抢占;循环等待,也就是线程之间互相等待对方释放己方需要的资源。这里先通过Java代码实现简单的死锁问题,然后通过一个银行转账的示例学习如何预防死锁的出现。
简单示例
这里使用synchronized先后请求两个锁对象,由于两个线程都只请求到了其中一个锁,等待对方释放另外一个锁从而导致死锁产生。
public class DeadLock {
// 第一个所对象
private static final Object lock1 = new Object();
// 第二个锁对象
private static final Object lock2 = new Object();
private static final Runnable task1 = new Runnable() {
@Override
public void run() {
synchronized (lock1) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
}
synchronized (lock2) {
System.out.println("Task1 got two locks");
}
}
}
};
private static final Runnable task2 = new Runnable() {
@Override
public void run() {
synchronized (lock2) {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
}
synchronized (lock1) {
System.out.println("Task2 got two locks");
}
}
}
};
public static void main(String[] args) {
new Thread(task1).start();
new Thread(task2).start();
}
}
直接运行上面的代码会发现整个程序就卡住了,完全没有任何前进,使用jdk的命令行工具jps查看卡住的Java进程号,通过jstack打印程序的内部状态,可以看出内部发生了死锁。
> jps
57139 Launcher
57140 DeadLock
57192 Jps
56863
> jstack 57140
Java stack information for the threads listed above:
===================================================
"Thread-1":
at myjvm.DeadLock$2.run(DeadLock.java:35)
- waiting to lock <0x000000079582e1e0> (a java.lang.Object)
- locked <0x000000079582e1f0> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:745)
"Thread-0":
at myjvm.DeadLock$1.run(DeadLock.java:18)
- waiting to lock <0x000000079582e1f0> (a java.lang.Object)
- locked <0x000000079582e1e0> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:745)
Found 1 deadlock.
在Android开发中如果出现死锁通常都会导致ANR问题,在/data/anr/traces.txt文件中通常就记录着ANR出现时候的日志,查看那些死锁ANR的日志就会出现如上面的报错,这时就需要开发者仔细分析哪些线程请求不同锁导致主线程无法响应。
预防死锁
我们都知道死锁有四个必要条件,第一个是互斥访问,对于大部分资源都无法多线程同时访问,这个条件无法被轻易的破坏,后面有一个循环等待,如果能按照规定的顺序请求资源,这样就会破坏循环等待的必要条件。
// 普通的账号类
class Account {
private String name;
private String id;
private int amount;
private Lock lock;
public Account(String name, String id, int amount) {
this.name = name;
this.id = id;
this.amount = amount;
lock = new ReentrantLock();
}
public String getId() {
return id;
}
public int getAmount() {
return amount;
}
public void setAmount(int amount) {
this.amount = amount;
}
public Lock getLock() {
return lock;
}
@Override
public String toString() {
return "Account{" +
"name='" + name + '\'' +
", id='" + id + '\'' +
", amount=" + amount +
'}';
}
}
public class DeadLockTest {
public static void main(String[] args) {
Account a = new Account("Account-A", "10000", 5000);
Account b = new Account("Account-B", "20000", 5000);
Thread threadA = new Thread(() -> {
transfer(a, b);
});
Thread threadB = new Thread(() -> {
transfer(b, a);
});
threadA.start();
threadB.start();
try {
threadA.join();
threadB.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 通过先锁定A账号再锁定B账号最后在做转账操作
public static void transfer(Account a, Account b) {
synchronized (a) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b) {
a.setAmount(a.getAmount() - 1000);
b.setAmount(b.getAmount() + 1000);
}
}
}
}
上面的例子需要线程同时获取AB两个账号,之后才能做转账操作,不过由于另外一个线程在做BA转账功能,threadA占有了A的锁,threadB占有B的锁,threadA继续获取B的锁无法成功,同理threadB在获取A也无法成功,这就形成了一个死锁。前面讲到破坏循环等待的条件就能够避免死锁问题发生,如果有一个强制的顺序要求必须先申请某个帐号,再申请另外一个帐号这就破坏了循环等待条件。
/** * 按顺序请求加锁 */
public static void transferOrdered(Account a, Account b) {
Account first = a, second = b;
// 根据id排序获取a和b的顺序,如果谁的id比较小就先请求谁的锁
if (a.getId().compareTo(b.getId()) > 0) {
first = b;
second = a;
}
// 先请求id比较小的资源lock,在请求id比较大的资源lock
synchronized (first) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (second) {
a.setAmount(a.getAmount() - 1000);
b.setAmount(b.getAmount() + 1000);
System.out.println(a);
System.out.println(b);
}
}
}
上面的例子使用帐号的id来排序,转账的时候必须先获取帐号id小的帐号锁,之后在获取帐号id大的帐号锁,实际运行就会发现没有再出现死锁问题,转账功能成功实现。除了循环等待条件破坏掉,还有不可被剥夺这个条件,如果在请求第二把锁的时候无法请求到就释放第一把锁之后再重试,这样就破坏了资源不可剥夺的条件,也能预防死锁出现。
/** * 加锁后可以自己释放 */
public static void transferNoHold(Account a, Account b) {
boolean transfered = false;
while (!transfered) {
// 首先获取a的锁
a.getLock().lock();
try {
boolean locked = false;
try {
// 尝试获取b的锁,如果1s内无法获取b的锁,就退出这次迭代,同时将a的锁也放弃
locked = b.getLock().tryLock(1, TimeUnit.SECONDS);
if (locked) {
// 如果a和b的锁都已经获得,执行转款
a.setAmount(a.getAmount() - 1000);
b.setAmount(b.getAmount() + 1000);
System.out.println(a);
System.out.println(b);
// 执行完成后退出循环
transfered = true;
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (locked) {
b.getLock().unlock();
}
}
} finally {
// 放弃a的锁,开始下次循环
a.getLock().unlock();
// 放弃a的锁之后休眠一会,让别的线程有机会得到a的锁
try {
Thread.sleep(new Random().nextInt(500));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
由于synchronized实现的锁机制无法被手动撤销,这里使用了J.U.C里的Lock接口实现加锁功能,首先在获取到第一个帐号的锁之后,尝试使用1秒tryLock第二个帐号的锁,如果无法获取第二个帐号的锁就把第一个帐号的也释放掉并且休眠一会方其他线程有机会能够获取第一个帐号的锁,只要没有成功转账就一直重复尝试获取两个锁对象,实际运行上面的代码会发现确实没有发生死锁现象。