1.写在前面
前面的博客,笔者大概介绍了下并发的一些的基础的知识。同时也讲了下并发中很重要的一个知识,就是管程,什么是管程?就是管理共享变量以及对共享变量的操作的过程,让他们支持并发。今天我们就来介绍下java提供的一些并发工具类,以及管程在并发工具类中的使用。
2.Lock
并发中一直有两大核心的问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。Lock用于解决互斥的问题,Condition用于解决同步问题。
2.1再造管程的理由
前面我们介绍的一个死锁的问题:提出了一个破坏不可抢占的条件方案,但是这个方案synchronized没有办法解决。synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。于是我们希望:对于不可抢占这个条件,占用部分资源的线程申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
于是有了下面的三种的方案:
- 能够响应中端。synchronized 的问题是,持有锁A后,如果尝试获取锁B失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说我们给阻塞写的线程发送中断的信号的时候,能够唤醒它,那它就有机会释放曾经持有的锁A。这样就破坏了不可抢占条件了。
- 支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁,这样也能破坏不可抢占的条件。
- 非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也就有机会释放曾经持有的锁。这样也能破坏不可抢占的条件。
对应在lock的中有如下三个方法。
//支持中断
void lockInterruptibly() throws InterruptedException;
//支持超时
boolean tryLock(long time, TimeUtit unit) throws InterruptedException;
//支持非阻塞获取锁的API
boolean tryLock();
2.2如任何保证可见性
原来就是利用了volatile相关的Happens-Before规则。内部持有一个volatile的成员变量state,获取锁的时候,会读写state的值;解锁的时候,也会读写state的值。也就是说,在执行value+1之前,程序先读写了一次volatile变量的state,在执行value+=1之后,又读写了一次volatile变量state。根据相关的 Happens-Before 规则: 顺序性规则:对于线程 T1,value+=1 Happens-Before 释放锁的操作 unlock(); volatile 变量规则:由于 state = 1 会先读取 state,所以线程 T1 的 unlock() 操作Happens-Before 线程 T2 的 lock() 操作; 传递性规则:线程 T1 的 value+=1 Happens-Before 线程 T2 的 lock() 操作。
2.3什么是可重入锁
可重入锁:顾名思义,指的是线程可以重复获取同一把锁。ReentrantLock就是可重入锁。拓展:可重入函数,指的是多个线程可以同时调用该函数。
2.4公平锁与非公平锁
ReentrantLock这个类中有两个构造函数,一个是无参构造函数,一个是传入fair参数的构造函数。fair参数代表的是锁的公平策略,如果传入的是true就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。我们介绍过入口等待队列,锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。
2.5用锁的最佳实践
- 永远只在更新对象的成员变量时加锁。
- 永远只在访问可变的成员变量时加锁。
- 永远不在调用其他对象的方法时加锁。
3.Condition
Condition实现了管程模型里面的条件变量。Java内置的管程里只有一个条件变量,而Lock&Condition实现的管程是支持多个条件变量的。
3.1那如何利用两个条件变量快速实现阻塞队列呢?
一个阻塞队列,需要两个条件变量,一个队列不空(空队列不允许出队),另一个是队列不满(队列已满不允许入队)
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();
}
}
}
Lock 和 Condition 实现的管程,线程等待和通知需要调用await()、signal()、signalAll(),它们的语义和 wait()、notify()、notifyAll() 是相同的。但是不一样的是,Lock&Condition 实现的管程里只能使用前面的 await()、signal()、signalAll(),而后面的 wait()、notify()、notifyAll() 只有在 synchronized 实现的管程里才能使用。如果一不小心在 Lock&Condition 实现的管程里调用了 wait()、notify()、notifyAll(),那程序可就彻底玩儿完了。
3.2同步与异步
通俗点来讲就是调用方是否需要等待结果,如果需要等待结果,就是同步;如果不需要等待结果,就是异步。
如果你想让自己的程序支持异步的话,可以通过下面的两种方式来实现:
- 调用方创建一个子线程,在子线程中执行方法调用,这种调用我们称为异步调用;
- 方法实现的时候,创建一个新的线程执行主要逻辑,主线程直接 return,这种方法我们一般称为异步方法。
4.Semaphore
4.1信号量模型
简单概括:一个计数器,一个等待队列,三个方法。在信号量模型里,计数器和等待队列对外是透明的,所以只能通过信号量模型提供的三个方法来
访问它们,这三个方法分别是:init()
、down()
和 up()
。
init()
:设置计数器的初始值。
down()
:计数器的值减 1;如果此时计数器的值小于 0,则当前线程将被阻塞,否则当前线程可以继续执行。
up()
:计数器的值加 1;如果此时计数器的值小于或者等于 0,则唤醒等待队列中的一个线程,并将其从等待队列中移除。
这里提到的三个方法都是原子性的。在 Java SDK 并发包里,down()
和 up()
对应的则是 acquire()
和 release()
。
4.2如何使用信号量
类比一下红绿灯,有一条规则:车辆在通过路口前必须先检查是否是绿灯,只有绿灯才能通行。其实,信号量的使用也是类似的。这里我们还是用累加器的例子来说明信号量的使用吧。其实很简单,就像我们用互斥锁一样,只需要在进入临界区之前执行一下down()操作,退出临界区之前执行一下up()操作就可以了。而acquire() 就是信号量里的 down() 操作,release() 就是信号量里的 up() 操作。于是就写出了如下的代码:
static int count;
// 初始化信号量
static final Semaphore s = new Semaphore(1);
// 用信号量保证互斥
static void addOne() {
s.acquire();
try {
count+=1;
} finally {
s.release();
}
}
假设两个线程T1和T2同时访问addOne()方法,当它们同时调用acquire()的时候,由于acquire()是一个原子操作,所以只能有一个线程(假设T1)把信号量里的计数器减为0,另外一个线程(T2)则是将计数器减为-1。对于线程T1,信号量里面的计数器的值是0,大于等于0,所以线程T1会继续执行;对于线程T2,信号量里面的计数器的值是-1,小于0,按照信号量模型里对down()操作的描述,线程T2将被阻塞。所以此时只有线程T1会进入临界区执行。当线程 T1 执行 release() 操作,也就是 up() 操作的时候,信号量里计数器的值是 -1,加1 之后的值是 0,小于等于 0,按照信号量模型里对 up() 操作的描述,此时等待队列中的T2 将会被唤醒。于是 T2 在 T1 执行完临界区代码之后才获得了进入临界区执行的机会,从而保证了互斥性。
4.3快速实现一个限流器
Semaphore可以允许多个线程访问一个临界区。
对象池:一次性创建出N个对象,之后所有的线程重复利用这N个对象,当然对象在释放前,也是不允许其他的线程使用的。
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) {
T t = null;
sem.acquire();
try {
t = pool.remove(0);
return func.apply(t);
} finally {
pool.add(t);
sem.release();
}
}
}
// 创建对象池
ObjPool<Long, String> pool = new ObjPool<Long, String>(10, 2);
// 通过对象池获取 t,之后执行
pool.exec(t -> {
System.out.println(t);
return t.toString();
});
我们用一个List
来保存对象实例,用Semaphore
实现限流器。关键的代码是ObjPool
里面的exec()
方法,这个方法里面实现了限流的功能。在这个方法里面,我们首先调用acquire()
方法(与之匹配的是在finally
里面调用release()
方法),假设对象池的大小是10,信号量的计数器初始化为 10,那么前 10 个线程调用 acquire()
方法,都能继续执行,相当于通过了信号灯,而其他线程则会阻塞在acquire()
方法上。对于通过信号灯的线程,我们为每个线程分配了一个对象 t(这个分配工作是通过pool.remove(0)
实现的),分配完之后会执行一个回调函数func
,而函数的参数正是前面分配的对象 t ;执行完回调函数之后,它们就会释放对象(这个释放工作是通过pool.add(t)
实现的),同时调用release()
方法来更新信号量的计数器。如果此时信号量里计数器的值小于等于 0,那么说明有线程在等待,此时会自动唤醒等待的线程 。
5.ReadWriteLock
主要适用于读多写少场景。
5.1什么是读写锁?
三条基本原则:
- 允许多个线程同时读共享变量;
- 只允许一个线程写共享变量;
- 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作。
5.2快速实现一个缓存
具体的代码如下:
class Cache<K,V> {
final Map<K, V> m = new HashMap<>();
final ReadWriteLock rwl = new ReentrantReadWriteLock();
// 读锁
final Lock r = rwl.readLock();
// 写锁
final Lock w = rwl.writeLock();
// 读缓存
V get(K key) {
r.lock();
try { return m.get(key); }
finally { r.unlock(); }
}
// 写缓存
V put(String key, Data v) {
w.lock();
try { return m.put(key, v); }
finally { w.unlock(); }
}
}
我们声明了一个 Cache<K, V> 类,其中类型参数 K 代表缓存里 key 的类型,V 代表缓存里 value 的类型。缓存的数据保存在 Cache 类内部的 HashMap 里面,
HashMap 不是线程安全的,这里我们使用读写锁 ReadWriteLock 来保证其线程安全。ReadWriteLock 是一个接口,它的实现类是 ReentrantReadWriteLock 。
Cache 这个工具类,我们提供了两个方法,一个是读缓存方法 get(),另一个是写缓存方法put()。读缓存需要用到读锁,读锁的使用和前面我们介绍的 Lock 的使用是相同的,都是try{}finally{}这个编程范式。写缓存则需要用到写锁,写锁的使用和读锁是类似的。
缓存有一个问题:使用缓存首先要解决缓存数据的初始化问题。如果源头数据的数据量不大,就可以采用一次性加载的方式。这种方式最简单,只需在应用启动的时候把源头数据查询出来,依次调用类似上面示例代码中的 put()方法就可以了。
如果源头数据量非常大,那么就需要按需加载了,按需加载也叫懒加载,指的是只有当应用查询缓存,并且数据不在缓存里的时候,才触发加载源头相关数据进缓存的操作。
5.3实现缓存的按需加载
这里我们假设缓存的源头是数据库。需要注意的是,如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存需要用到写锁,所以在代码中的⑤处,我们调用了 w.lock() 来获取写锁。 另外,还需要注意的是,在获取写锁之后,我们并没有直接去查询数据库,而是在代码⑥⑦处,重新验证了一次缓存中是否存在,再次验证如果还是不存在,我们才去查询数据库并更新本地缓存。
class Cache<K,V> {
final Map<K, V> m = new HashMap<>();
final ReadWriteLock rwl = new ReentrantReadWriteLock();
final Lock r = rwl.readLock();
final Lock w = rwl.writeLock();
V get(K key) {
V v = null;
// 读缓存
r.lock(); //①
try {
v = m.get(key); //②
} finally{
r.unlock(); //③
}
// 缓存中存在,返回
if(v != null) { //④
return v;
}
// 缓存中不存在,查询数据库
w.lock(); //⑤
try {
// 再次验证
// 其他线程可能已经查询过数据库
v = m.get(key); //⑥
if(v == null){ //⑦
// 查询数据库
v= 省略代码无数
m.put(key, v);
}
} finally{
w.unlock();
}
return v;
}
}
在高并发的场景下,有可能会有多线程竞争写锁。假设缓存是空的,没有缓存任何东西,如果此时有三个线程 T1、T2 和 T3 同时调用 get() 方法,并且参数 key 也是相同的。那么它们会同时执行到代码⑤处,但此时只有一个线程能够获得写锁,假设是线程 T1,线程 T1 获取写锁之后查询数据库并更新缓存,最终释放写锁。此时线程 T2 和 T3 会再有一个线程能够获取写锁,假设是 T2,如果不采用再次验证的方式,此时 T2 会再次查询数据库。T2 释放写锁之后,T3 也会再次查询一次数据库。而实际上线程 T1 已经把缓存的值设置好了,T2、T3 完全没有必要再次查询数据库。所以,再次验证的方式,能够避免高并发场景下重复查询数据的问题。
5.4读写锁的升级与降级
读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒。锁的升级是不允许的,这个你一定要注意。 锁的降级却是允许的。
5.4总结
读写锁类似于 ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。但是有一点需要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出UnsupportedOperationException 异常。
超时机制。所谓超时机制指的是加载进缓存的数据不是长久有效的,而是有时效的,当缓存的数据超过时效,也就是超时之后,这条数据在缓存中就失效了。而访问缓存中失效的数据,会触发缓存重新从源头把数据加载进缓存。
6.写在最后
本篇博客中主要介绍Lock、Condition、Semaphore、ReadWriteLock一些使用的规则,下篇博客笔者会介绍其他的并发工具中的内容。