2.8 synchronized使用不当带来的死锁问题

synchronized同步锁虽然能够解决线程安全问题,但是如果使用不当,就会导致死锁,即请求被阻塞一直无法返回。

什么是死锁呢?如图2-25所示,简单来说就是两个或者两个以上的线程在执行过程中,由于争夺同一个共享资源造成的相互等待的现象,在没有外部干预的情况下,这些线程将会一直阻塞无法往下执行,这些一直处于相互等待资源的线程就称为死锁线程。

图2-25 死锁的原理

2.8.1 死锁的案例分析

定义一个资源类,提供如下两个方法,这两个方法都加了synchronized对象锁。

• saveResource()方法,用于保存资源。

• statisticsResource()方法,用于统计资源数量。

笔者尽量让导致死锁的问题更加隐蔽,因为平时我们在工作中肯定不会写嵌套synchronized相互加锁的代码,只有不恰当地导致死锁才是最真实的。具体代码如下。

我们通过DeadLockExample类来演示可能导致死锁的场景,代码如下。

两个线程分别访问两个不同的Resource对象,每个resource对象分别调用saveResource()方法保存resource对象的资源,这必然会导致死锁问题。由于两个线程持有自己的对象锁资源,在saveResource()方法中访问对方的statisticsResource()方法并占用对方的锁资源,所以产生互相等待造成死锁的现象。

2.8.2 死锁产生的必要条件

不管是线程级别的死锁,还是数据库级别的死锁,只能通过人工干预去解决,所以我们要在写程序的时候提前预防死锁的问题。导致死锁的条件有四个,这四个条件同时满足就会产生死锁。

• 互斥条件,共享资源X和Y只能被一个线程占用。

• 请求和保持条件,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X。

• 不可抢占条件,其他线程不能强行抢占线程T1占有的资源。

• 循环等待条件,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,即循环等待。

2.8.3 如何解决死锁问题

按照前面说的四个死锁的发生条件,我们只需要破坏其中任意一个,就可以避免死锁的产生。其中,互斥条件我们不可以破坏,因为这是互斥锁的基本约束,其他三个条件都可以破坏。

• 对于请求和保持条件,我们可以一次性申请所有的资源,这样就不存在等待了。

• 对于不可抢占条件,当占用部分资源的线程进一步申请其他资源时,如果申请不到,则可以主动释放其占有的资源,这样不可抢占条件就被破坏掉了。

• 对于循环等待条件,可以通过按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。

2.8.3.1 破坏请求和保持条件

要破坏占用资源所带来的等待,可以一次性申请所有资源,保证同时申请这个操作是在一个临界区中,然后通过一个单独的角色来管理这个临界区。这个角色有两个很重要的功能,就是同时申请资源和同时释放资源,并且这个角色一定是一个单例。

先定义一个ApplyLock类,用来实现统一锁资源的申请,该类中有两个方法,一个是applyLock()方法,用来申请锁,另一个是free()方法,用来统一释放锁。

修改Resource类,定义一个全局唯一的ApplyLock实例,然后在saveResource中调用applyLock()方法和free()方法进行统一锁资源的获取和释放。

由于当前涉及的相关资源都实现了一个统一的锁资源获取和释放,从而打破了请求和保持条件。

2.8.3.2 破坏不可抢占条件

破坏不可抢占条件的核心是当前线程能够主动释放尝试占有的资源,这一点synchronized无法实现,原因是synchronized在申请不到资源时会直接进入阻塞状态,一旦线程被阻塞就无法再释放已经占有的资源。

在java.util.concurrent包中的Lock锁可以轻松地解决这个问题。Lock接口中有一个tryLock()方法可以尝试抢占资源,如果抢占成功则返回true,否则返回false,但是这个过程不会阻塞当前线程,实现代码如下。

2.8.3.3 破坏循环等待条件

破坏循环等待条件的基本思想是,把资源按照某种顺序编号,所有锁资源的申请都按照某种顺序来获取。比如,可以根据hashCode来确定加锁顺序,再根据hashCode的大小确定加锁的对象,实现代码如下。