Java 采用的是管程技术,synchronized 关键字及 wait()、notify()、notifyAll() 这三个方法都是管程的组成部分。
管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是
同步,即线程之间如何通信、协作。
管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。如果想访问共享变量,只能通过调用管程提供的互斥方法,即只允许一个线程进入管程。
在管程模型里,共享变量和对共享变量的操作是被封装起来的,当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队列中等待。管程里还引入了条件变量的概念,而且每个条件变量都对应有一个等待队列,如下图,条件变量 A 和条件变量 B 分别都有自己的等待队列。
假设有个线程 T1 执行出队操作,不过需要注意的是执行出队操作,有个前提条件,就是队列不能是空的,而队列不空这个前提条件就是管程里的条件变量。 如果线程 T1 进入管程后恰好发现队列是空的,那怎么办呢?等待啊,去哪里等呢?就去条件变量对应的等待队列里面等。此时线程 T1 就去“队列不空”这个条件变量的等待队列中等待。线程 T1 进入条件变量的等待队列后,是允许其他线程进入管程的。
再假设之后另外一个线程 T2 执行入队操作,入队操作执行成功之后,“队列不空”这个条件对于线程 T1 来说已经满足了,此时线程 T2 要通知 T1,告诉它需要的条件已经满足了。当线程 T1 得到通知后,会从等待队列里面出来,但是出来之后不是马上执行,而是重新进入到入口等待队列里面。
条件变量及其等待队列我们讲清楚了,下面再说说 wait()、notify()、notifyAll() 这三个操作。前面提到线程 T1 发现“队列不空”这个条件不满足,需要进到对应的等待队列里等待。这个过程就是通过调用 wait() 来实现的。如果我们用对象 A 代表“队列不空”这个条件,那么线程 T1 需要调用 A.wait()。同理当“队列不空”这个条件满足时,线程 T2 需要调用 A.notify() 来通知 A 等待队列中的一个线程,此时这个队列里面只有线程 T1。至于 notifyAll() 这个方法,它可以通知等待队列中的所有线程。
下面的代码实现的是一个阻塞队列,阻塞队列有两个操作分别是入队和出队,这两个方法都是先获取互斥锁,类比管程模型中的入口。
对于入队操作,如果队列已满,就需要等待直到队列不满,所以这里用了 notFull.await();
对于出队操作,如果队列为空,就需要等待直到队列不空,所以就用了 notEmpty.await();
public class BlockedQueue<T>{
final Lock lock =
new ReentrantLock();
// 条件变量:队列不满
final Condition notFull =
lock.newCondition();
// 条件变量:队列不空
final Condition notEmpty =
lock.newCondition();
// 入队
void enq(T x) {
lock.lock();
try {
while (队列已满){
// 等待队列不满
notFull.await();
}
// 省略入队操作...
//入队后,通知可出队
notEmpty.signal();
}finally {
lock.unlock();
}
}
// 出队
void deq(){
lock.lock();
try {
while (队列已空){
// 等待队列不空
notEmpty.await();
}
// 省略出队操作...
//出队后,通知可入队
notFull.signal();
}finally {
lock.unlock();
}
}
}
锁应该是私有的、不可变的、不可重用的。
// 普通对象锁
private final Object
lock = new Object();
// 静态对象锁
private static final Object
lock = new Object();
while(条件不满足) {
wait();
}
MESA 管程里面,T2 通知完 T1 后,T2 还是会接着执行,T1 并不立即执行,仅仅是从条件变量的等待队列进到入口等待队列里面。这样做的好处是 notify() 不用放到代码的最后,T2 也没有多余的阻塞唤醒操作。但是也有个副作用,就是当 T1 再次执行的时候,可能曾经满足的条件,现在已经不满足了,所以需要以循环方式检验条件变量。
除非经过深思熟虑,否则尽量使用 notifyAll() 。
使用 notify() 需要满足以下三个条件:
比如上面阻塞队列的例子中,对于“队列不满”这个条件变量,其阻塞队列里的线程都是在等待“队列不满”这个条件,所有等待线程被唤醒后执行的操作也是相同的,同时也满足第 3 条,只需要唤醒一个线程。所以上面阻塞队列的代码,使用 signal() 是可以的。
Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题。
Lock 和 synchronized 区别:
// 支持中断的API
void lockInterruptibly()
throws InterruptedException;
// 支持超时的API
boolean tryLock(long time, TimeUnit unit)
throws InterruptedException;
// 支持非阻塞获取锁的API
boolean tryLock();
class X {
private final Lock rtl =
new ReentrantLock();
int value;
public void addOne() {
// 获取锁
rtl.lock();
try {
value+=1;
} finally {
// 保证锁能释放
rtl.unlock();
}
}
}
Lock 使用范式:
try {
lock();
} finally {
unlock();
}
如何保证可见性
利用了 volatile 相关的 Happens-Before 规则。Java SDK 里面的 ReentrantLock,内部持有一个 volatile 的成员变量 state,获取锁的时候,会读写 state 的值;解锁的时候,也会读写 state 的值。
value+=1
Happens-Before 释放锁的操作 unlock()
unlock()
操作 Happens-Before 线程 T2 的 lock()
操作value+=1
Happens-Before 线程 T2 的 lock()
操作。可重入锁,指的是线程可以重复获取同一把锁。
例如下面代码中,当线程 T1 执行到 ① 处时,已经获取到了锁 rtl ,当在 ① 处调用 get() 方法时,会在 ② 再次对锁 rtl 执行加锁操作。此时,如果锁 rtl 是可重入的,那么线程 T1 可以再次加锁成功;如果锁 rtl 是不可重入的,那么线程 T1 此时会被阻塞。
class X {
private final Lock rtl =
new ReentrantLock();
int value;
public int get() {
// 获取锁
rtl.lock(); ②
try {
return value;
} finally {
// 保证锁能释放
rtl.unlock();
}
}
public void addOne() {
// 获取锁
rtl.lock();
try {
value = 1 + get(); ①
} finally {
// 保证锁能释放
rtl.unlock();
}
}
}
可重入函数,指的是多个线程可以同时调用该函数,每个线程都能得到正确结果;同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。也就是线程安全的。
ReentrantLock 这个类有两个构造函数,一个是无参构造函数,一个是传入 fair 参数的构造函数。fair 参数代表的是锁的公平策略,如果传入 true 就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。
//无参构造函数:默认非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
//根据公平策略参数创建锁
public ReentrantLock(boolean fair){
sync = fair ? new FairSync()
: new NonfairSync();
}
调用其他对象的方法,实在是太不安全了,也许其他方法里面有线程 sleep() 的调用,也可能会有奇慢无比的 I/O 操作,这些都会严重影响性能。更可怕的是,其他类的方法可能也会加锁,然后双重加锁就可能导致死锁。
Condition 实现了管程模型里面的条件变量。
Lock&Condition 实现的管程是支持多个条件变量的,synchronized 只能有一个条件变量。
线程等待和通知需要调用 await()、signal()、signalAll()。
通俗点来讲就是调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步。
如果你想让你的程序支持异步,可以通过下面两种方式来实现:
Dubbo 异步转同步:TCP 协议本身就是异步的,在 TCP 协议层面,发送完 RPC 请求后,线程是不会等待 RPC 的响应结果的。Dubbo 异步转同步的功能是通过 DefaultFuture 这个类实现的。
当 RPC 返回结果之前,阻塞调用线程,让调用线程等待;当 RPC 返回结果后,唤醒调用线程,让调用线程重新执行。
大致的原理代码如下:
// 创建锁与条件变量
private final Lock lock
= new ReentrantLock();
private final Condition done
= lock.newCondition();
// 调用方通过该方法等待结果
Object get(int timeout){
long start = System.nanoTime();
lock.lock();
try {
while (!isDone()) {
done.await(timeout);
long cur=System.nanoTime();
if (isDone() ||
cur-start > timeout){
break;
}
}
} finally {
lock.unlock();
}
if (!isDone()) {
throw new TimeoutException();
}
return returnFromResponse();
}
// RPC结果是否已经返回
boolean isDone() {
return response != null;
}
// RPC结果返回时调用该方法
private void doReceived(Response res) {
lock.lock();
try {
response = res;
if (done != null) {
done.signal();
}
} finally {
lock.unlock();
}
}
可以概括为:一个计数器,一个等待队列,三个方法。计数器和等待队列对外是透明的,所以只能通过信号量模型提供的三个方法来访问它们,这三个方法分别是:init()、down() 和 up()。
Java 信号量模型是由 java.util.concurrent.Semaphore
实现的,这三个方法都是原子操作。
在累加器的例子里面,count+=1 操作是个临界区,只允许一个线程执行,也就是说要保证互斥。
static int count;
//初始化信号量
static final Semaphore s
= new Semaphore(1);
//用信号量保证互斥
static void addOne() {
s.acquire();
try {
count+=1;
} finally {
s.release();
}
}
Semaphore 有一个功能是 Lock 不容易实现的,那就是:允许多个线程访问一个临界区。常见的有:池化资源,同一时刻允许多个线程同时使用连接池。
只要把计数器的值设置成对象池里对象的个数 N,就能完美解决对象池的限流问题了。
public class PoolWithSemaphore {
class ObjPool<T, R> {
final List<T> pool;
final Semaphore sem;
// 构造函数
ObjPool(int size, T t) {
pool = new Vector<T>() {
};
for (int i = 0; i < size; i++) {
pool.add(t);
}
sem = new Semaphore(size);
}
// 利用对象池的对象,调用func
R exec(Function<T, R> func) throws InterruptedException {
T t = null;
sem.acquire();
try {
t = pool.remove(0);
return func.apply(t);
} finally {
pool.add(t);
sem.release();
}
}
}
public static void main(String[] args) throws InterruptedException {
// 创建对象池
PoolWithSemaphore x = new PoolWithSemaphore();
ObjPool<Integer, String> pool = x.new ObjPool<Integer, String>(10, 2);
// 通过对象池获取t,之后执行
pool.exec(t -> {
System.out.println(t);
return t.toString();
});
}
}
这个例子中,对象保存在了 Vector 中,这里不可以换成 ArrayList,因为ArrayList 非线程安全,因为可能存在多个线程同时执行 remove 和 add 方法,会导致不可预知的错误。
1、wait() 方法,在 MESA 模型里面,增加了超时参数,你觉得这个参数有必要吗?
答:有,避免没人唤醒一直阻塞。超时后会退出条件变量的等待队列,重新进入管程的等待队列。
2、你已经知道 tryLock() 支持非阻塞方式获取锁,下面这段关于转账的程序就使用到了 tryLock(),你来看看,它是否存在死锁问题呢?
class Account {
private int balance;
private final Lock lock
= new ReentrantLock();
// 转账
void transfer(Account tar, int amt){
while (true) {
if(this.lock.tryLock()) {
try {
if (tar.lock.tryLock()) {
try {
this.balance -= amt;
tar.balance += amt;
} finally {
tar.lock.unlock();
}
}//if
} finally {
this.lock.unlock();
}
}//if
}//while
}//transfer
}
答:不会死锁,但是可能活锁,两个线程同时对自己加锁,然后获取对方的锁失败,又释放自己的锁,反复循环。可以在外层 finally 处加一个随机时间的sleep。
3、DefaultFuture 里面唤醒等待的线程,用的是 signal(),而不是 signalAll(),你来分析一下,这样做是否合理呢?
答:合理。每个rpc请求都会占用一个线程并产生一个新的DefaultFuture实例,它们的lock&condition是不同的,并没有竞争关系
参考资料:王宝令----Java并发编程实战