ReadWriteLock(读写锁)和 StampedLock
1. ReadWriteLock(读写锁):实现高性能缓存
总结:
要点 | 内容 |
适用场景 | 读多写少、高并发读取场景(如缓存) |
锁类型 |
|
读锁 vs 写锁 | 多线程可同时读,写独占 |
按需加载中的“二次检查” | 避免重复查询数据库 |
锁升级 | ❌ 不支持 |
锁降级 | ✅ 支持(写锁降为读锁) |
数据一致性 | 可采用超时失效、Binlog 推送或双写策略 |
1.1. 读写锁的概念
并发优化的场景:读多写少
- 实际开发中,缓存常用于提升性能(比如缓存元数据、基础数据)
- 这类数据 读取频繁、写入稀少,典型读多写少
常规锁(互斥锁)的限制
synchronized
或ReentrantLock
会限制所有线程串行访问,即便是多个读取操作- 性能瓶颈:多个读线程也互相阻塞
ReadWriteLock的基本规则:
- 允许多个线程同时读共享变量;
- 只允许一个线程写共享变量;
- 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
Java 实现类:
- 接口:ReadWriteLock
- 实现:ReentrantReadWriteLock(支持可重入)
1.2. 封装线程安全的缓存类
示例:Cache<K, V>
类(线程安全)
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(); }}
}
缓存数据的加载策略
1. 一次性加载(适合数据量小)
- 程序启动时从源头加载所有数据,调用
put()
写入缓存 - 简单易行,示意图如下:
2. 按需加载(懒加载,适合数据量大)
原理:
- 查询缓存时,如果缓存中没有数据,则从源头加载并更新缓存
实现逻辑(含二次检查):
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;
}
为什么要“再次验证”?
- 防止多个线程同时 miss 缓存,导致重复数据库查询(读写锁是排他的)
1.3. 读写锁的升级与降级
1. 不支持锁的升级
不允许持有读锁时再获取写锁(会死锁)
错误代码示例:
r.lock();
try {if (m.get(key) == null) {w.lock(); // ❌ 升级为写锁,阻塞try { m.put(key, 查询数据库()); }finally { w.unlock(); }}
} finally {r.unlock(); // 死锁
}
2. 支持锁的降级
持有写锁时,可以先获取读锁,再释放写锁
w.lock(); // 写锁
try {if (!cacheValid) {data = 查询数据();cacheValid = true;r.lock(); // 降级为读锁}
} finally {w.unlock(); // 释放写锁
}try {use(data); // 仍持有读锁
} finally {r.unlock(); // 释放读锁
}
补充:缓存一致性问题及解决方案
常见解决方式:
方式 | 描述 |
超时失效机制 | 每条缓存数据设定有效期,到期重新加载 |
Binlog 同步 | 数据库变更触发缓存更新(如 MySQL Binlog) |
数据双写 | 同时写入缓存和数据库(需解决一致性问题) |
2. StampedLock(比读写锁更快)
StampedLock
提供 写锁、悲观读锁、乐观读 三种模式;- 乐观读是 无锁读取 + 校验机制,适合读多写少;
stamp
类似数据库中的version
,用于一致性验证;- 不支持重入、不支持条件变量、不支持中断;
- 使用不当可能造成 CPU 飙升问题。
2.1. StampedLock的概念
背景与作用
- 传统读写锁(ReadWriteLock):适用于“读多写少”的场景,支持多个线程并发读,但写操作会阻塞所有读操作。
- StampedLock(JDK 1.8 新增):
-
- 提供更高性能的读写控制机制;
- 特别适合读多写少场景;
- 支持 三种锁模式,引入了性能更优的“乐观读”
StampedLock的三种锁模式:
锁类型 | 特点 | 互斥性 | 适用场景 |
写锁 | 和写锁类似 | 与所有其他锁互斥 | 修改共享数据 |
悲观读锁 | 与 ReadLock 类似,可多个线程同时持有 | 与写锁互斥 | 读取共享数据(有一定写的可能性) |
乐观读 | 无锁!性能最好 | 可与写锁并发(需校验) | 读取频繁,修改极少场景 |
- 加锁后都会返回一个 stamp,释放锁时需要传入。
代码示例:
final StampedLock sl = new StampedLock();// 悲观读锁
long stamp = sl.readLock();
try {// 读取操作
} finally {sl.unlockRead(stamp);
}// 写锁
long stamp = sl.writeLock();
try {// 写操作
} finally {sl.unlockWrite(stamp);
}
2.2. 乐观读原理与用法
乐观读流程:
- 调用
tryOptimisticRead()
获取 stamp; - 读取共享变量到局部变量(期间数据可能被其他线程写操作修改!);
- 通过
validate(stamp)
判断是否有写操作发生;
-
- 若返回
true
,说明无写操作,读取有效; - 若返回
false
,则需“升级为悲观读锁”。
- 若返回
示例代码:
long stamp = sl.tryOptimisticRead();
int curX = x, curY = y;
if (!sl.validate(stamp)) {stamp = sl.readLock(); // 升级为悲观读try {curX = x;curY = y;} finally {sl.unlockRead(stamp);}
}
return Math.sqrt(curX * curX + curY * curY);
为什么比 ReadWriteLock 更快?
- 乐观读无锁,不阻塞写操作;
- 只有在检测到写入发生时,才升级为悲观读,大大减少了锁竞争和阻塞。
使用注意事项
注意点 | 说明 |
❌ 不支持重入 |
|
❌ 不支持条件变量 | 不能用 |
❌ 不支持中断 | 调用 |
对比数据库乐观锁
- 数据库中通过
version
字段实现乐观锁控制; - 读取时返回
version
,更新时用where version=旧值
控制; - 与 StampedLock 的
stamp
机制非常相似,便于理解乐观读校验的本质。
2.3. 使用模板
1. 读操作模板:
long stamp = sl.tryOptimisticRead();
// 读取局部变量
...
if (!sl.validate(stamp)) {stamp = sl.readLock();try {...} finally {sl.unlockRead(stamp);}
}// 使用局部变量...
2. 写操作模板:
long stamp = sl.writeLock();
try {// 修改共享变量...
} finally {sl.unlockWrite(stamp);
}