摘要:此时线程和会再有一个线程能够获取写锁,假设是,如果不采用再次验证的方式,此时会再次查询数据库。而实际上线程已经把缓存的值设置好了,完全没有必要再次查询数据库。
大家知道了Java中使用管程同步原语,理论上可以解决所有的并发问题。那 Java SDK 并发包里为什么还有很多其他的工具类呢?原因很简单:分场景优化性能,提升易用性
今天我们就介绍一种非常普遍的并发场景:读多写少场景。实际工作中,为了优化性能,我们经常会使用缓存,例如缓存元数据、缓存基础数据等,这就是一种典型的读多写少应用场景。缓存之所以能提升性能,一个重要的条件就是缓存的数据一定是读多写少的.
针对读多写少这种并发场景,Java SDK 并发包提供了读写锁——ReadWriteLock,非常容易使用,并且性能很好。
什么是读写锁读写锁,并不是 Java 语言特有的,而是一个广为使用的通用技术,所有的读写锁都遵守以下三条基本原则:
允许多个线程同时读共享变量;
只允许一个线程写共享变量;
如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行读操作和写操作的。
快速实现一个缓存在下面的代码中,我们声明了一个 Cache
Cache 这个工具类,我们提供了两个方法,一个是读缓存方法 get(),另一个是写缓存方法 put()。读缓存需要用到读锁,读锁的使用和前面我们介绍的 Lock 的使用是相同的,都是 try{}finally{}这个编程范式。写缓存则需要用到写锁,写锁的使用和读锁是类似的。
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) { 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(); } } }
设计封装缓存类时,我们需要在当应用查询缓存,并且数据不在缓存里的时候,触发加载源头相关数据进缓存的操作,这也是我们需要实现的最基本的功能。下面看下利用 ReadWriteLock 来实现缓存的按需加载。
这里我们假设缓存的源头是数据库。需要注意的是,如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存需要用到写锁,所以在代码中的⑤处,我们调用了w.lock() 来获取写锁。
另外,还需要注意的是,在获取写锁之后,我们并没有直接去查询数据库,而是在代码⑥⑦处,重新验证了一次缓存中是否存在,再次验证如果还是不存在,我们才去查询数据库并更新本地缓存。为什么我们要再次验证呢?
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(); ③ } // 缓存中存在,返回 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; } }
原因是在高并发的场景下,有可能会有多线程竞争写锁。假设缓存是空的,没有缓存任何东西,如果此时有三个线程 T1、T2 和 T3 同时调用 get() 方法,并且参数 key 也是相同的。那么它们会同时执行到代码⑤处,但此时只有一个线程能够获得写锁,假设是线程 T1,线程 T1 获取写锁之后查询数据库并更新缓存,最终释放写锁。此时线程 T2 和 T3 会再有一个线程能够获取写锁,假设是 T2,如果不采用再次验证的方式,此时 T2 会再次查询数据库。T2 释放写锁之后,T3 也会再次查询一次数据库。而实际上线程 T1 已经把缓存的值设置好了,T2、T3 完全没有必要再次查询数据库。所以,再次验证的方式,能够避免高并发场景下重复查询数据的问题。
读写锁的升级上面按需加载的示例代码中,在①处获取读锁,在③处释放读锁,那是否可以在②处的下面增加验证缓存并更新缓存的逻辑呢?详细的代码如下。
// 读缓存 r.lock(); ① try { v = m.get(key); ② if (v == null) { w.lock(); try { // 再次验证并更新缓存 // 省略详细代码 } finally{ w.unlock(); } } } finally{ r.unlock(); ③ }
这样看上去好像是没有问题的,先是获取读锁,然后再升级为写锁,对此还有个专业的名字,叫锁的升级。可惜 ReadWriteLock 并不支持这种升级。在上面的代码示例中,读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒。
小结读写锁类似于 ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。但是有一点需要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/77659.html
摘要:不同的是它还多了内部类和内部类,以及读写对应的成员变量和方法。另外是给和内部类使用的。内部类前面说到的操作是分配到里面执行的。他们都是接口的实现,所以其实最像应该是这个两个内部类。而且大体上也没什么差异,也是用的内部类。 之前讲了《AQS源码阅读》和《ReentrantLock源码阅读》,本次将延续阅读下ReentrantReadWriteLock,建议没看过之前两篇文章的,先大概了解...
摘要:在接下来的分钟,你将会学会如何通过同步关键字,锁和信号量来同步访问共享可变变量。所以在使用乐观锁时,你需要每次在访问任何共享可变变量之后都要检查锁,来确保读锁仍然有效。 原文:Java 8 Concurrency Tutorial: Synchronization and Locks译者:飞龙 协议:CC BY-NC-SA 4.0 欢迎阅读我的Java8并发教程的第二部分。这份指南将...
摘要:可重入意味着锁被绑定到当前线程,线程可以安全地多次获取相同的锁,而不会发生死锁例如同步方法在同一对象上调用另一个同步方法。写入锁释放后,两个任务并行执行,它们不必等待对方是否完成,因为只要没有线程持有写入锁,它们就可以同时持有读取锁。 原文地址: Java 8 Concurrency Tutorial: Synchronization and Locks 为了简单起见,本教程的示例代...
摘要:线程安全的实现前面章节介绍了在现代环境下的实现观察者模式,虽然简单但很完整,但这一实现忽略了一个关键性问题线程安全。截止目前实现了线程安全,在接下来的章节中将介绍提取主题的逻辑并将其类封装为可重复代码单元的方式优缺点。 【编者按】本文作者是 BAE 系统公司的软件工程师 Justin Albano。在本篇文章中,作者通过在 Java8 环境下实现观察者模式的实例,进一步介绍了什么是观察...
阅读 3507·2023-04-26 00:05
阅读 919·2021-11-11 16:55
阅读 3468·2021-09-26 09:46
阅读 3478·2019-08-30 15:56
阅读 880·2019-08-30 15:55
阅读 2899·2019-08-30 15:53
阅读 1907·2019-08-29 17:11
阅读 775·2019-08-29 16:52