管程和信号量这两个同步原语,理论上可以解决所有的并发问题,Java SDK 还有很多其他的工具类的原因:分场景优化性能,提升易用性。
一种并发场景:读多写少,例如缓存。Java SDK 并发包提供了读写锁——ReadWriteLock。
1.那什么是读写锁呢?
读写锁遵循三个基本原则:
- 允许多个线程同时读共享变量,性能优于互斥锁的原因;
- 只允许一个线程写共享变量;
- 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
2. 快速实现一个缓存
ReadWriteLock是个接口,其中一个实现是ReentrantReadWriteLock,从名称看是可重入锁。
实现代码例子如下:
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();
// 读缓存,try{}finally{}编程范式
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();
}
}
}
使用缓存首先要解决缓存数据的初始化问题,两种方式:
- 一次性加载的方式,适合数据量不大,应用启动的时候加载;
- 按需加载的方式,也叫懒加载,也就是先查询缓存,没有再到数据库查询同时放入缓存。
3. 实现缓存的按需加载
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();
// 读缓存,try{}finally{}编程范式
V get(K key) {
V v = null;
// 读缓存
r.lock(); // (1)
try {
v = m.get(key); // (2)
} finally {
r.unlock(); // (3)
}
// 缓存中存在,返回
if (v != null) { // (4)
return v;
}
// 缓存中不存在,查询数据库
w.lock(); // (5)
try {
// 再次验证
// 其他线程可能已经查询过数据库
v = m.get(key); // (6)
if (v == null) { // (7)
// 查询数据库
v= 省略代码无数
m.put(key, v);
}
} finally {
w.unlock();
}
return v;
}
}
在5处写缓存,需要写锁,在代码6和7处,为什么要重新判断是否存在?
原因是在高并发的场景下,有可能会有多线程竞争写锁。
- 假设缓存是空的,没有缓存任何东西,如果此时有三个线程 T1、T2 和 T3 同时调用 get() 方法,并且参数 key 也是相同的。
- 那么它们会同时执行到代码⑤处,但此时只有一个线程能够获得写锁,假设是线程 T1,线程 T1 获取写锁之后查询数据库并更新缓存,最终释放写锁。
- 此时线程 T2 和 T3 会再有一个线程能够获取写锁,假设是 T2,如果不采用再次验证的方式,此时 T2 会再次查询数据库。
- T2 释放写锁之后,T3 也会再次查询一次数据库。
- 而实际上线程 T1 已经把缓存的值设置好了,T2、T3 完全没有必要再次查询数据库。
- 所以,再次验证的方式,能够避免高并发场景下重复查询数据的问题。
4. 读写锁的升级与降级
上面按需加载的示例代码中,在①处获取读锁,在③处释放读锁,那是否可以在②处的下面增加验证缓存并更新缓存的逻辑呢?详细的代码如下。
// 读缓存
r.lock(); //(1) ①
try {
v = m.get(key); // (2) ②
if (v == null) {
w.lock();
try {
// 再次验证并更新缓存
// 省略详细代码
} finally{
w.unlock();
}
}
} finally{
r.unlock(); //(3) ③
}
先是获取读锁,然后再升级为写锁,这叫锁的升级。ReadWriteLock 并不支持这种升级。
问题: 读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒。
锁的降级却是允许的。示例如下:
class CachedData {
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(); // (1)
} finally {
// 释放写锁
w.unlock();
}
}
// 此处仍然持有读锁
try {
use(data);
} finally {
r.unlock();
}
}
}
5. 总结
读写锁类似于 ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。但是有一点需要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。
异常缓存存在一个同步问题,即数据库和缓存可能不一致,解决办法:
- 超时机制,即设定一个时间,缓存内容失效,查询缓存触发重新查询数据库
- 源头数据变化时,及时反馈。
6.课后思考
有同学反映线上系统停止响应了,CPU 利用率很低,你怀疑有同学一不小心写出了读锁升级写锁的方案,那你该如何验证自己的怀疑呢?