0%

ReadWriteLock:如何快速实现一个完备的缓存

管程和信号量这两个同步原语中任何一个都可以解决所有的并发问题。
Java SDK并发包里的其他工具类作用:分场景优化性能,提升易用性。

读多写少场景。实际工作中,为了优化性能,会使用缓存(缓存的数据一定要是读多写少的),如缓存元数据、缓存基础数据等,这是一种典型的读多写少应用场景。

针对读多写少这种并发场景,Java SDK并发包提供了读写锁-ReadWriteLock,易用,性能好。

读写锁定义

读写锁,并不是Java语言特有的,而是一个广为使用的通用技术,所有的读写锁都遵守三条基本原则:

  1. 允许多个线程同时读共享变量;
  2. 只允许一个线程写共享变量;
  3. 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。

读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作的。

读锁的意义,保证可见性、原子性,避免读到写操作的中间值(long、double),读到的都是对的。

读写锁实现缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Cache<K,V> {//K代表缓存里key的类型,V代表缓存里value的类型
final Map<K,V> m = new HashMap<>();//缓存的数据保存在HashMap里面,不是线程安全的
final ReadWriteLock rwl = new ReentrantReadWriteLock();//可重入的读写锁
//读锁
final Lock r = rwl.readLock();
//写锁
final Lock w = rwl.writeLock();
//读缓存 try{}finally{}编程范式
V get(K key) {
r.lock();
try {
return m.get(key);
} finally {
r.unlock();
}
}
//写缓存 try{}finally{}编程范式
V put(K key, V value) {
w.lock();
try {
return m.put(key, value);
} finally {
w.unlock();
}
}
}

使用缓存首先要解决缓存数据的初始化问题。缓存数据的初始化,可以采用一次性加载的方法,也可以使用按需加载的方式。

如果源头数据的数据量不大,可以采用一次性加载的方式,只需在应用启动的时候把源头数据查询出来,依次调用类似上述代码中的put()方法就可以了。

如果源头的数据量非常大,就需要按需加载-懒加载,指的是只有当应用查询缓存,并且数据不在缓存里的时候,才触发加载源头相关数据进缓存的操作。

实现缓存的按需加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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也是相同的。那么他们会同时执行到代码w.lock()处,但此时只有一个线程能够获得写锁,假设是线程T1,线程T1获取写锁之后查询数据库并更新缓存,最终释放写锁。此时线程T2和T3会再有一个线程能够获取写锁,假设是T2,如果不采用再次验证的方式,此时T2会再次查询数据库(获取写锁后直接从阻塞的地方接着执行)。T2释放写锁之后,T3也会再次查询一次数据库。而实际上线程T1已经把缓存的值设置好了,T2、T3完全没有必要再次查询数据库。所以,再次验证的方式,能够避免高并发场景下重复查询数据库的问题。

读写锁的升级与降级

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//读缓存
r.lock();
try {
v = m.get(key);
if(v == null) {
w.lock();
try {
//再次验证并更新缓存
。。。
} finally {
w.unlock();
}
}
} finally {
r.unlock();
}

先获取读锁,然后再升级为写锁,锁的升级。但是ReadWriteLock并不支持这种升级。代码中,读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒。锁的升级是不允许的。(系统停止响应,CPU利用率低,死锁)

存在多个读线程在读,一旦有一个写操作等待,再有读操作的请求,就会进入等待队列里。
synchronized内部支持升级不支持降级

锁的降级是允许的。(同一线程)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class CacheData {
Object data;
volatile boolean cacheValid;
final ReadWriteLock rwl = new ReentrantReadWriteLock();
//读锁
final Lock r = rwl.readLock();
//写锁
final Lock w = rwl.writeLock();

void processCachedData() {
//获取读锁
r.lock();
if(!cacheValid) {
//释放读锁,因为不允许读锁的升级
r.unlock();
//获取写锁
w.lock();
try {
//再次检查状态
if(!cacheValid) {
data = ...;
cacheValid = true;
}
//释放写锁前,降级为读锁
//自己获取读锁,并非其他线程
r.lock();
} finally {
//释放写锁
w.unlock();
}
}
//此处仍然持有读锁,而且允许其他线程同时读
try {
use(data);
} finally {
r.unlock();
}
}
}

获取写锁的前提是读锁和写锁均未被占用
获取读锁的前提是没有其他线程占用写锁
申请写锁时不中断其他线程申请读锁
公平锁如果有写锁申请,能禁止读锁

总结

读写锁类似于ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了java.util.concurrent.locks.Lock接口,除了支持lock()方法外,也支持tryLock()、lockInterruptibly()等方法(非阻塞、响应中断)。但是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用newCondition()会抛出UnsupportedOperationException异常。

示例用ReadWriteLock实现的缓存,虽然解决了缓存的初始化问题,但是没有解决缓存数据与源头数据的同步问题(保证缓存数据和源头数据的一致性)。

  • 解决数据同步问题的一个最简单的方法就是超时机制。加载进缓存的数据不是长久有效的,而是有时效的,当缓存的数据超过时效,这条数据在缓存中就时效了。访问缓存中失效的数据,会触发缓存重新从源头把数据加载进缓存。
  • 也可以在源头数据发生变化时,快速反馈给缓存。如MySQL作为数据源头,可以通过近实时的解析binlog来识别数据是否发生了变化,如果发生了变化就将最新的数据推送给缓存。其他则采用数据库和缓存的双写方案。