并发包里的管程 Lock
我们提到过在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题。
什么是死锁
现实世界里的死等,就是编程领域的死锁了。死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。
如何预防死锁
要避免死锁就需要分析死锁发生的条件,有个叫 Coffman 的牛人早就总结过了,只有以下这四个条件都发生时才会出现死锁:
1、互斥,共享资源 X 和 Y 只能被一个线程占用;
2、占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
3、不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
4、 循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
反过来分析,也就是说只要我们破坏其中一个,就可以成功避免死锁的发生。
其中,互斥这个条件我们没有办法破坏,因为我们用锁为的就是互斥。不过其他三个条件都是有办法破坏掉的,到底如何做呢?
占用且等待
我们可以一次性申请所有的资源,这样就不存在等待了。
不可抢占
占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
循环等待
对于这个条件,可以靠按序申请资源来预防。所谓按序申请,是指资源是有线性顺序的,申请的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
并发包解决死锁方案
前面提出了一个破坏不可抢占条件方案,但是这个方案 synchronized 没有办法解决。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。但我们希望的是:
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
Lock如何解决死锁问题
能够响应中断。synchronized 的问题是,持有锁 A 后,如果尝试获取锁 B 失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说当我们给阻塞的线程发送中断信号的时候,能够唤醒它,那它就有机会释放曾经持有的锁 A。这样就破坏了不可抢占条件了。
支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
体现在 API 上,就是 Lock 接口的三个方法。详情如下:
// 支持中断的 API
void lockInterruptibly()
throws InterruptedException;
// 支持超时的 API
boolean tryLock(long time, TimeUnit unit)
throws InterruptedException;
// 支持非阻塞获取锁的 API
boolean tryLock();
Lock如何保证可见性
Java SDK 里面 Lock 的使用,有一个经典的范例,就是try{}finally{},需要重点关注的是在 finally 里面释放锁。这个范例无需多解释,你看一下下面的代码就明白了。但是有一点需要解释一下,那就是可见性是怎么保证的。你已经知道 Java 里多线程的可见性是通过 Happens-Before 规则保证的,而 synchronized 之所以能够保证可见性,也是因为有一条 synchronized 相关的规则:synchronized 的解锁 Happens-Before 于后续对这个锁的加锁。那 Java SDK 里面 Lock 靠什么保证可见性呢?例如在下面的代码中,线程 T1 对 value 进行了 +=1 操作,那后续的线程 T2 能够看到 value 的正确结果吗?
class X {
private final Lock rtl =
new ReentrantLock();
int value;
public void addOne() {
// 获取锁
rtl.lock();
try {
value+=1;
} finally {
// 保证锁能释放
rtl.unlock();
}
}
}
答案必须是肯定的。Java SDK 里面锁的实现非常复杂,这里我就不展开细说了,但是原理还是需要简单介绍一下:它是利用了 volatile 相关的 Happens-Before 规则。Java SDK 里面的 ReentrantLock,内部持有一个 volatile 的成员变量 state,获取锁的时候,会读写 state 的值;解锁的时候,也会读写 state 的值(简化后的代码如下面所示)。也就是说,在执行 value+=1 之前,程序先读写了一次 volatile 变量 state,在执行 value+=1 之后,又读写了一次 volatile 变量 state。根据相关的 Happens-Before 规则:
1、 顺序性规则:对于线程 T1,value+=1 Happens-Before 释放锁的操作 unlock();
2、volatile 变量规则:由于 state = 1 会先读取 state,所以线程 T1 的 unlock() 操作 Happens-Before 线程 T2 的 lock() 操作;
3、传递性规则:线程 T1 的 value+=1 Happens-Before 线程 T2 的 lock() 操作。
class SampleLock {
volatile int state;
// 加锁
lock() {
// 省略代码无数
state = 1;
}
// 解锁
unlock() {
// 省略代码无数
state = 0;
}
}
Lock的API(interface)
方法名称 | 描述 |
void lock() | 获取锁,调用该方法该线程会获取锁,当锁获得后,从该方法返回 |
void lockInterruptibly() throws InterruptedException | 可中断地获取锁,该方法会响应中断,当获取锁的线程被中断时,中断异常会抛出,同时锁会被释放 |
boolean tryLock() | 尝试非阻塞的获取锁,调用该方法时会立即返回,如果能够获取则返回true,否则返回false |
boolean tryLock(long time, TimeUnit unit) throws InterruptedException | 超时的获取锁,当前线程会在一下3中情况下返回:1、当前线程在超时时间内获取到锁;2、当前线程在超时时间内中断;3、当前线程超时时间结束,返回false |
void unlock() | 释放锁 |
Condition newCondition() | 获取等待通知组件,该组件和当前的锁是绑定关系,当前线程只有获得锁,才能调用该组件的wait()方法,而调用该方法以后,当前线程则释放锁 |
Condition相关API(interface)
方法名称 | 描述 |
void await() throws InterruptedException | 当前线程处于等待状态,直到收到唤醒的信号或者线程中断 |
void awaitUninterruptibly() | 当前线程处于等待状态,直到收到唤醒信号或者中断,或者指定的等待时间已过 |
long awaitNanos(long nanosTimeout) throws InterruptedException | 当前线程处于等待状态,直到收到唤醒信号或者中断,或者指定的等待时间已过 |
boolean await(long time, TimeUnit unit) throws InterruptedException | 当前线程处于等待状态,直到收到唤醒信号或者中断,或者指定的等待时间已过,类似于awaitNanos(long nanosTimeout) |
boolean awaitUntil(Date deadline) throws InterruptedException | 当前线程处于等待状态,直到收到唤醒信号或者中断,或者指定的截至时间已过 |
void signal() | 唤醒一个等待的线程 |
void signalAll() | 唤醒所有的等待线程 |