使用缓存时对于加锁的思考
突然发现之前写的自己实现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无缝实现自定义二级缓存。