读写锁
1. ReadWriteLock,读写锁。包括读锁和写锁。规则是这样的:
①允许多个线程同时读共享变量,即 读锁与读锁之间是不互斥的;
②同一时间只允许一个线程写共享变量,即,读锁与写锁、写锁与写锁之间是互斥的;也就是说,如果有线程在写共享变量,此时禁止其它线程读共享变量。
2. ReadWriteLock同样也支持公平锁和非公平锁模式,但需要注意的是,只有写锁支持条件变量,读锁不支持条件变量。// 事实上也不需要。
3. 实现一个“按需加载”的缓存。——使用缓存首先要解决数据初始化问题,如果数据量不大,那么可以在应用启动时一次性将数据从数据源(如MySQL数据库)查询出来,然后依次调用put方法添加至缓存。如果数据量很大,那么就只能使用按需加载,也叫懒加载。
class Cache {
final Map 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(); // ReadWriteLock不支持锁升级,因此需要先释放读锁,然后才能再进一步获取写锁
}
if(v != null) {
return v;
}
// 如果数据在缓存中不存在,查询数据库
w.lock(); // 上写锁 ②
try {
//再次验证,因为在这个过程中,其它线程可能已经查询过数据库了
v = m.get(key);
if(v == null){
//查询数据库
m.put(key, v);
}
} finally{
w.unlock();
}
return v;
}
}
注意,在获取写锁后,需要再次验证。因为在高并发场景下,可能会有多个线程竞争写锁。假设现在缓存是空的,此时,T1、T2、T3同时调用get()方法,且方法参数key都相同。它们会同时执行到代码②处,但只有一个线程能获取写锁。假设是T1,那么在T1执行完之后,缓存中是已经有我们需要的数据了的。而如果没有再次验证,那么T2和T3会重复查询数据库。因此,通过这样的再次验证的方式,能够避免高并发场景下重复查询数据的问题。
4. 缓存还需要解决 缓存数据与源头数据的同步问题。
一种方案是 超时机制。我们为加载进缓存的数据都设置一个时效,当缓存中的数据超过时效之后,那么就表示该数据失效了,需要重新从源头将数据加载进缓存中。
另一种方案是,在源头数据发生变化时,快速反馈给缓存。当然,此方案就依赖具体的场景了。例如 MySQL 作为数据源头,可以通过近实时地解析 binlog 来识别数据是否发生了变化,如果发生了变化就将最新的数据推送给缓存。
此外,还可以采取数据库和缓存的双写方案。
具体采用哪种方案,还是要看应用场景。
5. 锁升级与锁降级
ReadWriteLock只支持锁降级,不支持锁升级。
所谓锁升级,指的是线程在持有读锁、且不释放读锁的情况下,进一步申请写锁。而所谓锁降级,指的是线程在持有写锁、且不释放写锁的情况下,进一步申请读锁。——注意,所谓升级、降级不是合并,进行锁升级后,读锁和写锁仍然要分别释放。
锁升级的应用,比如上边的缓存实现,当缓存中没有我们想要的数据时,我们需要进一步使用写锁,来查询数据库并写缓存。
锁降级的应用:当持有写锁获取到数据之后,我们后续需要对该数据继续使用(非写操作),那么释放写锁、然后持有读锁对该数据进行后续的使用,这显得十分合理。反之,如果仅仅是简单的把数据返回,就不要需要锁降级了。