独占锁的浪费
缓存、配置、路由表——典型的读多写少。synchronized 和 ReentrantLock 一视同仁:读和读也要互斥,可明明都是只读操作,本可以并行。读写锁补上这块短板,规则三条:写写互斥、读写互斥、读读共享——只有写是排他的。
ReentrantReadWriteLock 骨架
ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
rw.readLock().lock(); // 读操作:共享,读读并行
try { /* 读缓存 */ } finally { rw.readLock().unlock(); }
rw.writeLock().lock(); // 写操作:独占,连读也挡
try { /* 更新缓存 */ } finally { rw.writeLock().unlock(); }内核还是 AQS:state 一拆二,高 16 位记读锁、低 16 位记写锁——第 9 篇埋的伏笔在这兑现。支持重入;还支持锁降级:持有写锁时可以再拿读锁,然后放掉写锁,降级成只读——保证自己刚写的数据能立即可见地读回来。反方向读升级写不被支持:两个读线程同时想升级,各自等对方先放读锁,直接死锁,所以 API 干脆禁止。
它的两个软肋
其一,读锁挡写锁:读请求持续涌入时写线程可能一直排不上号,写被饿死(公平模式能缓解,吞吐下降)。其二,读读共享不是免费的:每次拿读锁、放读锁都是对 state 高 16 位的 CAS 操作,读多到极致时这部分开销反而成了瓶颈。于是有了更激进的方案。
StampedLock:乐观读的降维打击
写少到几乎不写的场景,连「拿读锁」都嫌贵。StampedLock 的乐观读:先不加锁,读一版数据,读完拿版本戳校验——期间没人写过就直接用;写过,转悲观读锁重读。绝大多数校验会通过,读路径近乎无锁:
StampedLock sl = new StampedLock();
double read() {
long stamp = sl.tryOptimisticRead(); // 不加锁,只拿个版本戳
double x = this.x, y = this.y; // 先读到局部变量
if (!sl.validate(stamp)) { // 校验:期间发生过写吗?
stamp = sl.readLock(); // 有——转悲观读锁重读
try { x = this.x; y = this.y; }
finally { sl.unlockRead(stamp); }
}
return x + y;
}使用须知三条:乐观读读到的值必须先复制进局部变量再校验,别用着用着又被改;StampedLock 不可重入,同一线程重复获取会直接死锁;不支持 Condition。约束多,但读多写极少且写得极快时,它是最快的读写方案。
锁的故事到这里讲完。但有一类数据不走「共享」路线——每个线程想要自己独享的一份。下一篇 ThreadLocal:每个线程的抽屉,以及它那桩著名的内存泄漏。
评论 (0)