一、死锁发生的四个必要条件
经典的“哲学家进餐”问题很好的描述了死锁的情况。5个哲学家吃中餐,坐在一张圆桌上,有5根筷子,每个人吃饭必须用两根筷子。哲学家时而思考时而进餐。分配策略有可能导致哲学家永远无法进餐。
类似的,当线程A尝试持有锁L1,并尝试获取锁L2;同时,线程B持有锁L2,并尝试获取锁L1,并且都不释放已经拥有的锁。这就是最简单的死锁。其中存在环状的锁依赖关系。称为“抱死”。
数据库系统有监视、检测死锁的环节。当两个事务需要的锁相互依赖时,DB将选择一个牺牲者放弃这个事务,牺牲者会释放持有的资源,从而使其他事务顺利的执行。
JVM在解决死锁问题时并没有数据库系统那么强大,当一组线程发生死锁时,那么这写线程就凉凉——永远不会被使用。
死锁的发生必须具备以下四个必要条件:
- 互斥,共享资源 X 和 Y 只能被一个线程占用;
- 占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
- 不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
- 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
下面看看3种死锁的情况:锁顺序死锁,动态锁顺序死锁,协作对象之间发生死锁。
1.1 锁顺序死锁
首先我们来看一下最简单的死锁(锁顺序死锁)是怎么样发生的:
public class LeftRightDeadlock { private final Object left = new Object(); private final Object right = new Object(); public void leftRight() { // 得到left锁 synchronized (left) { // 得到right锁 synchronized (right) { doSomething(); } } } public void rightLeft() { // 得到right锁 synchronized (right) { // 得到left锁 synchronized (left) { doSomethingElse(); } } } }
我们的线程是交错执行的,那么就很有可能出现以下的情况:
- 线程A调用
leftRight()
方法,得到left锁 - 同时线程B调用
rightLeft()
方法,得到right锁 - 线程A和线程B都继续执行,此时线程A需要right锁才能继续往下执行。此时线程B需要left锁才能继续往下执行。
- 但是:线程A的left锁并没有释放,线程B的right锁也没有释放。
- 所以他们都只能等待,而这种等待是无期限的-->永久等待-->死锁
1.2 动态锁顺序死锁
我们看一下下面的例子,你认为会发生死锁吗?
// 转账 public static void transferMoney(Account fromAccount, Account toAccount, DollarAmount amount) throws InsufficientFundsException { // 锁定汇账账户 synchronized (fromAccount) { // 锁定来账账户 synchronized (toAccount) { // 判余额是否大于0 if (fromAccount.getBalance().compareTo(amount) < 0) { throw new InsufficientFundsException(); } else { // 汇账账户减钱 fromAccount.debit(amount); // 来账账户增钱 toAccount.credit(amount); } } } }
上面的代码看起来是没有问题的:锁定两个账户来判断余额是否充足才进行转账!
但是,同样有可能会发生死锁:
- 如果两个线程同时调用
transferMoney()
- 线程A从X账户向Y账户转账
- 线程B从账户Y向账户X转账
- 那么就会发生死锁。
A:transferMoney(myAccount,yourAccount,10); B:transferMoney(yourAccount,myAccount,20);
1.3 协作对象之间发生死锁
我们来看一下下面的例子:
public class CooperatingDeadlock { // Warning: deadlock-prone! class Taxi { @GuardedBy("this") private Point location, destination; private final Dispatcher dispatcher; public Taxi(Dispatcher dispatcher) { this.dispatcher = dispatcher; } public synchronized Point getLocation() { return location; } // setLocation 需要Taxi内置锁 public synchronized void setLocation(Point location) { this.location = location; if (location.equals(destination)) // 调用notifyAvailable()需要Dispatcher内置锁 dispatcher.notifyAvailable(this); } public synchronized Point getDestination() { return destination; } public synchronized void setDestination(Point destination) { this.destination = destination; } } class Dispatcher { @GuardedBy("this") private final Set<Taxi> taxis; @GuardedBy("this") private final Set<Taxi> availableTaxis; public Dispatcher() { taxis = new HashSet<Taxi>(); availableTaxis = new HashSet<Taxi>(); } public synchronized void notifyAvailable(Taxi taxi) { availableTaxis.add(taxi); } // 调用getImage()需要Dispatcher内置锁 public synchronized Image getImage() { Image image = new Image(); for (Taxi t : taxis) // 调用getLocation()需要Taxi内置锁 image.drawMarker(t.getLocation()); return image; } } class Image { public void drawMarker(Point p) { } } }
上面的getImage()
和setLocation(Point location)
都需要获取两个锁的。因为它们本身是同步方法,然后在方法内部调用其他方法时又需要获取锁。并且在操作途中是没有释放锁的。
这就是隐式获取两个锁(对象之间协作)。
这种方式也很容易就造成死锁,需要警惕在已经持有锁的方法内调用外部同步方法。
二、避免死锁的方法
只有上面四个必要条件都发生时才会出现死锁,那么反过来,也就是说只要我们破坏其中一个,就可以成功预防死锁的发生。
四个条件中我们不能破坏互斥,因为我们使用锁目的就是保证资源被互斥访问,于是我们就对其他三个条件进行破坏:
- 占用且等待:一次性申请所有的资源,这样就不存在等待了。
- 不可抢占,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
- 循环等待,靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化申请后就不存在循环了。
避免死锁可以概括成三种方法:
- 固定加锁的顺序(针对锁顺序死锁)
- 开放调用(针对对象之间协作造成的死锁)
- 使用定时锁-->
tryLock()
- 如果等待获取锁时间超时,则抛出异常而不是一直等待!
2.1 固定锁顺序避免死锁
上面transferMoney()
发生死锁的原因是因为加锁顺序不一致而出现的~
- 正如书上所说的:如果所有线程以固定的顺序来获得锁,那么程序中就不会出现锁顺序死锁问题!
那么上面的例子我们就可以改造成这样子:
public class InduceLockOrder { // 额外的锁、避免两个对象hash值相等的情况(即使很少) private static final Object tieLock = new Object(); public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException { class Helper { public void transfer() throws InsufficientFundsException { if (fromAcct.getBalance().compareTo(amount) < 0) throw new InsufficientFundsException(); else { fromAcct.debit(amount); toAcct.credit(amount); } } } // 得到锁的hash值 int fromHash = System.identityHashCode(fromAcct); int toHash = System.identityHashCode(toAcct); // 根据hash值来上锁 if (fromHash < toHash) { synchronized (fromAcct) { synchronized (toAcct) { new Helper().transfer(); } } } else if (fromHash > toHash) {// 根据hash值来上锁 synchronized (toAcct) { synchronized (fromAcct) { new Helper().transfer(); } } } else {// 额外的锁、避免两个对象hash值相等的情况(即使很少) synchronized (tieLock) { synchronized (fromAcct) { synchronized (toAcct) { new Helper().transfer(); } } } } } }
得到对应的hash值来固定加锁的顺序,这样我们就不会发生死锁的问题了!
2.2 开放调用避免死锁
在协作对象之间发生死锁的例子中,主要是因为在调用某个方法时就需要持有锁,并且在方法内部也调用了其他带锁的方法!
- 如果在调用某个方法时不需要持有锁,那么这种调用被称为开放调用!
我们可以这样来改造:
- 同步代码块最好仅被用于保护那些涉及共享状态的操作!
class CooperatingNoDeadlock { @ThreadSafe class Taxi { @GuardedBy("this") private Point location, destination; private final Dispatcher dispatcher; public Taxi(Dispatcher dispatcher) { this.dispatcher = dispatcher; } public synchronized Point getLocation() { return location; } public synchronized void setLocation(Point location) { boolean reachedDestination; // 加Taxi内置锁 synchronized (this) { this.location = location; reachedDestination = location.equals(destination); } // 执行同步代码块后完毕,释放锁 if (reachedDestination) // 加Dispatcher内置锁 dispatcher.notifyAvailable(this); } public synchronized Point getDestination() { return destination; } public synchronized void setDestination(Point destination) { this.destination = destination; } } @ThreadSafe class Dispatcher { @GuardedBy("this") private final Set<Taxi> taxis; @GuardedBy("this") private final Set<Taxi> availableTaxis; public Dispatcher() { taxis = new HashSet<Taxi>(); availableTaxis = new HashSet<Taxi>(); } public synchronized void notifyAvailable(Taxi taxi) { availableTaxis.add(taxi); } public Image getImage() { Set<Taxi> copy; // Dispatcher内置锁 synchronized (this) { copy = new HashSet<Taxi>(taxis); } // 执行同步代码块后完毕,释放锁 Image image = new Image(); for (Taxi t : copy) // 加Taix内置锁 image.drawMarker(t.getLocation()); return image; } } class Image { public void drawMarker(Point p) { } } }
使用开放调用是非常好的一种方式,应该尽量使用它~
2.3 使用定时锁
使用显式Lock锁,在获取锁时使用tryLock()
方法。当等待超过时限的时候,tryLock()
不会一直等待,而是返回错误信息。
使用tryLock()
能够有效避免死锁问题~~
2.4 死锁检测
虽然造成死锁的原因是因为我们设计得不够好,但是可能写代码的时候不知道哪里发生了死锁。
JDK提供了两种方式来给我们检测:
- JconsoleJDK自带的图形化界面工具,使用JDK给我们的的工具JConsole
- Jstack是JDK自带的命令行工具,主要用于线程Dump分析。
具体可参考:
三、总结
发生死锁的原因主要由于:
- 线程之间交错执行
- 解决:以固定的顺序加锁
- 执行某方法时就需要持有锁,且不释放
- 解决:缩减同步代码块范围,最好仅操作共享变量时才加锁
- 永久等待
- 解决:使用
tryLock()
定时锁,超过时限则返回错误信息
- 解决:使用
2. Java多线程之线程同步
深入理解Java多线程(2.1)- 线程安全详解
深入理解Java多线程(2.2)- Java并发编程的3个特性(原子性,可见性,有序性)
深入理解Java多线程(2.3)- volatile详解和使用场景
深入理解Java多线程(2.4)- volatile底层实现原理
深入理解Java多线程(2.5)- synchronized用法和实现原理详解
深入理解Java多线程(2.6)- synchronized原理之锁优化
深入理解Java多线程(2.7)- 死锁的原因和如何避免死锁
深入理解Java多线程(2.8)- 彻底理解ThreadLocal的用法和原理
回到目录:深入理解Java多线程 - 教程系列详解
链接:https://www.jianshu.com/p/68c0fef7b63e