多线程编程核心技术(十三)ReadWriteLock
一种非常普遍的并发场景:读多写少场景。实际工作中,为了优化性能,我们经常会使用缓存,例如缓存元数据、缓存基础数据等,这就是一种典型的读多写少应用场景。缓存之所以能提升性能,一个重要的条件就是缓存的数据一定是读多写少的,例如元数据和基础数据基本上不会发生变化(写少),但是使用它们的地方却很多(读多)。
不同的是:StampedLock 里的写锁和悲观读锁加锁成功之后,都会返回一个 stamp;然后解锁的时候,需要传入这个 stamp。
这个stamp的作用从源码中看是用来进行校验的,但如果是作为校验的标志位,本身线程的方法栈的地址就可以当票据了。这个我也想不明白,如果是锁升级的原因,那进行对象更新就行了。
public void unlockWrite(long stamp) { WNode h; if (state != stamp || (stamp & WBIT) == 0L) throw new IllegalMonitorStateException(); state = (stamp += WBIT) == 0L ? ORIGIN : stamp; if ((h = whead) != null && h.status != 0) release(h); }
读写锁,并不是 Java 语言特有的,而是一个广为使用的通用技术,所有的读写锁都遵守以下三条基本原则:
1.允许多个线程同时读共享变量;
2.只允许一个线程写共享变量;
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(); 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; } }
获取写锁的前提是读锁和写锁均未被占用 获取读锁的前提是没有其他线程占用写锁。所以想更新缓存,需要再次调用写方法,如果直接在read方法内部对锁升级为write锁就会导致死锁。
但是write锁是可以进行降级为read锁的,因为写锁的特点是同时需要读锁和写锁。
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(); ① } finally { // 释放写锁 w.unlock(); } } // 此处仍然持有读锁 try {use(data);} finally {r.unlock();} } }
读写锁类似于 ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。但是有一点需要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。
另外这里的读写锁,性能还有可以提升的地方,因为可能很多业务都会使用这个缓存懒加载,实际生产环境,写缓存操作可能会比较多,那么不同的缓存key,实际上是没有并发冲突的,所以这里的读写锁可以按key前缀拆分,即使是同一个key,也可以类似ConcurrentHash 一样分段来减少并发冲突