突然发现之前写的自己实现 XXX 的话题不是很被大众关注, 可能是真的写的不行, 也可能是大多都是一些吃了饭没事做瞎写的一些东西, 大家都没兴趣, 之后可能会尽量写一些真正实用的东西, 大家一起学习. 言归正传, 促使我研究这个话题的原因是在工作中遇到需要自己实现多级缓存的情况. 比如在 springboot 中我们虽然可以随意替换缓存技术, 可以使用 redis 也可以使用 ehcache, 但是据我所知, 这些缓存默认都是只能使用一种. 假设现在我需要同时使用 ehcache 和 redis, 其中 ehcache 做本地的第一级缓存. 这里忽略可能用到的 mybatis,hibernate 那些操作数据库的 orm 框架的缓存, 单独只考虑应用层面的缓存. 当然在已经有 springboot,ehcache,redis 的情况下, 通过自定义注解, 切面拦截, 组合 ehcache 和 redis 的两级缓存, 其实并不是很困难. 难点只在于细节部分, 比如怎么让自己定义的注解和切面可以被 springboot 中的缓存注解开关 @EnableCaching 控制, 又比如多点部署时怎么让 ehcache 本地缓存和 redis 集中式缓存实现同步, 当然也有使用缓存的时候怎么加锁的问题. 本话题重点讨论, 在缓存使用时的加锁问题.
其实当我们刚开始学 java 的线程中同步工具时, 我们就接触到 synchronized, 锁, 读写锁等. 其中在 api 文档的 ReentrantReadWriteLock 类里有一个例子, 专门演示了读写锁, 在一个缓存对象中的使用的例子如下:
- class CachedData {
- Object data;
- volatile boolean cacheValid;
- ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
- void processCachedData() {rwl.readLock().lock();
- if (!cacheValid) {
- // Must release read lock before acquiring write lock
- rwl.readLock().unlock();
- rwl.writeLock().lock();
- // Recheck state because another thread might have acquired
- // write lock and changed state before we did.
- if (!cacheValid) {
- data = ...
- cacheValid = true;
- }
- // Downgrade by acquiring read lock before releasing write lock
- rwl.readLock().lock();
- rwl.writeLock().unlock(); // Unlock write, still hold read
- }
- use(data);
- rwl.readLock().unlock();
- }
- }
里面的读写锁, double check, 锁降级等用的炉火纯青自是不用说的, 我当时也是对此记忆犹新, 如获至宝啊, 感觉这段代码很适合装 B, 然后我在我的缓存实现代码里也准备这么玩, 当我把这段代码复制过去, 改成我需要的逻辑后, 我就发现了问题. 瞬间感觉这要是这么玩, 可能装 B 不成那个啥的. 然后我搜了搜网上别人的这种玩法, 想看看别人是不是也有这种尴尬. 然后搜索出来一大把如下面代码 (百度搜索: java 读写锁实现缓存)
- package test;
- import java.util.HashMap;
- import java.util.Map;
- import java.util.Random;
- import java.util.concurrent.locks.ReadWriteLock;
- import java.util.concurrent.locks.ReentrantReadWriteLock;
- /**
- * 设计一个缓存系统
- * 读写锁的应用.
- * JDK1.5 自带的读写锁特性, 读与读不互斥, 读与写互斥, 写与写互斥.
- * 为什么要使用读写锁? 一句话概括那就是提高系统性能, 如何提高呢?
- * 试想, 对于所有对读的操作是不需要线程互斥的, 而如果方法内
- * 使用了 synchronized 关键字同步以达到线程安全, 对于所有的线程不管是读还是写的操作都要同步.
- * 这时如果有大量的读操作时就会又性能瓶颈.
- *
- * 所以, 当一个方法内有多个线程访问, 并且方法内有读和写读操作时,
- * 提升性能最好的线程安全办法时采用读写锁的机制对读写互斥, 写写互斥. 这样对于读读就没有性能问题了
- * @author zhurudong
- *
- */
- public class CacheTest {
- // 缓存的 map
- private Map<String, Object> map = new HashMap<String, Object>();
- // 读写锁对象
- private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
- /**
- * 从缓存中获取数据的方法
- * @param key
- * @return
- */
- public Object getData(String key) {
- readWriteLock.readLock().lock();// 读锁, 只对写的线程互斥
- Object value = null;
- try {
- // 尝试从缓存中获取数据
- value = map.get(key);
- if (value == null) {
- readWriteLock.readLock().unlock();// 发现目标值为 null, 释放掉读锁
- readWriteLock.writeLock().lock();// 发现目标值为 null, 需要取值操作, 上写锁
- try {
- value = map.get(key);// 很严谨这一步. 再次取目标值
- if (value == null) {// 很严谨这一步. 再次判断目标值, 防止写锁释放后, 后面获得写锁的线程再次进行取值操作
- // 模拟 DB 操作
- value = new Random().nextInt(10000) + "test";
- map.put(key, value);
- System.out.println("db completed!");
- }
- readWriteLock.readLock().lock();// 再次对读进行锁住, 以防止写的操作, 造成数据错乱
- } finally {
- /*
- * 先加读锁再释放写锁读作用:
- * 防止在 43 行出多个线程获得写锁进行写的操作, 所以在写锁还没有释放前要上读锁
- */
- readWriteLock.writeLock().unlock();
- }
- }
- } finally {
- readWriteLock.readLock().unlock();
- }
- return value;
- }
- /**
- * test main
- * @param args
- */
- public static void main(String[] args) {
- final CacheTest cache = new CacheTest();
- final String key = "user";
- for (int i = 0; i <1000; i++) {
- new Thread(){
- public void run() {
- System.out.println(cache.getData(key));
- };
- }.start();
- }
- }
- }
估计很多人看了上面代码, 也会和我当时改完 api 文档的例子后一样觉得这代码真他妈严谨. 改完后, 我又仔细想想 (我习惯把逻辑用现实中的场景去验证, 毕竟程序世界各种场景处理方法等都是是现实世界的抽象), 想到了生活中去超市门口那种储存柜子存取包裹, 读操作可以类比为取柜子里的包裹, 写操作可以类比为去超市存包裹, map 可以类比成那个柜子. 使用上面代码在现实中的这种场景下还原出来就是: 当我拿着小票去取包裹, 别人可以同时去取包裹, 但是当我去存包裹的时候, 我让除了我以外的所有人必须在我后面等着, 等我存好了, 别人才能靠近柜子. 这种霸道的做法, 不就是上面那段代码的表现吗? 怎么样看了这段描述, 你们还要像上面那样装 B 吗? 有人会问, 为啥 api 文档里推荐那样写呢, 其实仔细看下文档里的那段代码, 它加锁的那个方法是没有入参的, 缓存的数据直接作为了成员变量, 这个成员变量作为缓存的值对于所有调用 processCachedData 方法的线程来说都是共享的, 有共享数据就要考虑多线程环境下同步的问题. 而上面那段自己的代码是有入参 key 的, 也就是说根据 key 获取的缓存值有可能被所有调用线程共享, 也有可能不共享, 当 key 不同的时候, 这个数据其实是各不影响的. 所以这种情况加锁最好是根据这个入参区别对待. 然后我就有了下面的想法, 不知道是不是也有人想到呢?
- public Object getData1(String key) {
- if(key == null) return null;
- Object data = null;
- synchronized (key.intern()) {
- data = dataMap.get(key);
- if(data == null) {
- data = "query object";
- dataMap.put(key, data);
- }
- }
- return data;
- }
如上代码, 我们可以把 key 当成监视器, 但如果仅仅只是 key 有点不严谨, 因为可能字符串内容一样, 一个是返回字符串池的引用, 一个返回堆里的引用, 根本不是一个对象. 表现出来的结果可能就等同于没有加锁. 所以在这里使用 key.intern(), 都返回池里的引用. 这样控制相当于不同的人拿着不同的小票可以同时存取包裹, 不同的人要是拿着一样的小票进来存取, 就应该一个人存取的时候, 另外一个人等候. 虽然现实场景中每个人的小票应该都是唯一的, 但是程序中是合理的, 我们可以假设小票有重复. 回到程序中: 如果多个线程如果拿着不同的 key 值操作数据不需要等待其它线程的锁, 如果拿着同样的 key 值操作数据就需要等待其它线程释放锁. 这样的结果貌似很合理, 但是在一般缓存应用场景中, 缓存最大的作用是在短时间大量重复的获取相同的 key 的值能够快速的从缓存获取到数据, 假设不加读写请求的区分, 统一加锁, 会导致多个线程不能同时去读取相同的 key 的值, 也需要互相等待, 同样有损性能. 以上两种加锁方案: 第一种进行了读写分开考虑, 多线程中读读不互斥, 读写互斥, 但是没有考虑获取不同的 key 值不需要互斥; 第二种考虑了不同的 key 不需要互斥的情况, 但是没有考虑读读不需要互斥的情况. 可惜的是 java 的 api 并没有提供根据不同的参数获取不同的读写锁的方法, 这一点很尴尬. 相信所有的自称高性能的缓存都会遇到这种尴尬, 于是我找了纯 java 实现的缓存 ehcache 的源码看了下, 发现核心思路就是先默认生成了 2048 个长度的 ReentrantReadWriteLock 数组, 然后使用 hash 算法对 key 进行计算得到一个 int 的值, 然后每个 key 进来去拿数组里 hash 值对应的索引的那个 ReentrantReadWriteLock, 然后再使用这个读写锁来控制, 这样就可以达到不同的 key 读写互不影响, 相同的 key 读读不互斥, 读写互斥. 抽离出来大概代码如下 (适当简化过了).
- // 源码里实际上是把 ReentrantReadWriteLock 封装在一个 ReadWriteLockSync 类里的, 我不喜欢拐弯抹角, 直接简单的提取出来了
- final ReadWriteLock[] rwlocks = new ReentrantReadWriteLock[2048];
- // 这里是自己为了简单, 源码里并不是这么简陋的
- {
- for(int i = 0;i < rwlocks.length;i++) {
- rwlocks[i] = new ReentrantReadWriteLock();
- }
- }
- // 这里是源码里的方法
- public static int selectLock(final Object key, int numberOfLocks) throws CacheException {
- /**
- * 这里用自己和自己小 1 的数字取了一个与运算, 目的就是保证 numberOfLocks 是 2 的 n 次方, 比如 8 和 7 与
- 1000
- & 0111
- = 0000
- 如果结果不等于 0 必然不是 2 的 n 次方
- */
- int number = numberOfLocks & (numberOfLocks - 1);
- if (number != 0) {
- throw new CacheException("Lock number must be a power of two:" + numberOfLocks);
- }
- if (key == null) {
- return 0;
- } else {
- /**
- 这里也很有意思, 一般我们做估计就是 hash(key) % numberOfLocks 来定位数组的索引, 他这样玩其实在 numberOfLocks 是 2 的 n 次方
- 的情况下, 是与 % 操作等效, 如下
- 8 % 4 = 8 & 3
- 1000
- & 0011
- = 0000
- = 0
- 11 % 8 = 11 & 7
- 1011
- & 0111
- = 0011
- = 3
- 每次看到别人用位运算, 只能感叹, 这尼玛才叫装 B 啊!
- */
- int hash = hash(key) & (numberOfLocks - 1);
- return hash;
- }
- }
- // 这里是源码里的 hash 函数, 表示看不懂, 就知道传一个对象进去可以出来一个 int 类型的值
- public static int hash(Object object) {
- int h = object.hashCode();
- h ^= (h>>> 20) ^ (h>>> 12);
- return h ^ (h>>> 7) ^ (h>>> 4);
- }
然后再修改一下最开始说的装 B 的读写锁代码如下
- /**
- 这里使用支持并发的 map 是为了保证容器本身没有并发问题, 容器的存取操作是原子的, 后面 getData2 的同步控制是为了防止业务数据同时读写出现脏读等情况,
- 比如一个 key 在读, 同时也在写, 到底读到的是写前还是写后的数据呢? 这里是属于业务上的并发控制, 至于容器上的并发控制就是明明两个不同的 key 去写因为
- 抢同一个位置导致数据不一致等问题, 就像存包裹, 有两个人非要去存在一个箱子里就会出现争抢了. 并不是说使用了线程并发库就不需要进行并发控制了, 毕竟
- 线程并发库里的读写, 也只能保证单独的读写是原子操作, 假设多个组合起来就不能保证原子性了
- */
- private Map<String,Object> dataMap = new ConcurrentHashMap<>();
- public Object getData2(String key) {
- int locknum = selectLock(key,2048);
- ReadWriteLock rwlock = rwlocks[locknum];
- rwlock.readLock().lock();
- try {
- Object data = dataMap.get(key);
- if (data == null) {
- rwlock.readLock().unlock();
- rwlock.writeLock().lock();
- if(data == null) {
- data = "query object";
- dataMap.put(key, data);
- }
- rwlock.readLock().lock();
- rwlock.writeLock().unlock();
- }
- return data;
- } finally {
- rwlock.readLock().unlock();
- }
- }
其实这样的思路就是尽量降低锁的粒度, 把一个读写锁换成了 2048 个小锁, 像 jdk1.8 以前的 ConcurrentHashMap 采用的锁分段, 分成了 16 端, 其实和这个的思想是一样的, 都是降低锁的粒度, 达到高效的并发控制. 不过, 估计当读写达到一定两级后仅仅这 16 端的切分, 估计也起不到多大的效果了. 所以官方估计意识到了这一点, 在 1.8 后把实现方式改成了 CAS 无锁算法. 我突然想到新版的 ehcache(这篇话题谈到的 ehcache 版本是 2.10.4) 会不会也使用 CAS 呢? 这个新版的源码, 我暂时也没有研究过, 人类对性能的追求是永无止境的, 这个就等有兴趣的朋友再去探究了.
好吧, 就这样结束吧, 如果文章有错误的地方, 欢迎指正. 下一个话题再讨论如何在 springboot 的基础上结合 ehcache 和 redis 无缝实现自定义二级缓存.
来源: https://www.cnblogs.com/rongdi/p/9034886.html