上周发布了《当Synchronized遇到这玩意儿,有个大坑,要注意!》这篇文章。
文章的最后,我提到了《Java并发编程实战》的第 5.6 节的内容,说大家可以去看看。
我不知道有几个同学去看了,但是我知道绝大部分同学都没去看的,所以这篇文章我也给大家安排一下,怎么去比较好的实现一个缓存功能。
感受一下大师的代码方案演进的过程。
需求
这不都二月中旬了嘛,马上就要出考研成绩了,我就拿这个来举个例子吧。
需求很简单:从缓存中查询,查不到则从数据库获取,并放到缓存中去,供下次使用。
核心代码大概就是这样的:
Integer score = map.get("why"); if(score == null){ score = loadFormDB("why"); map.put("why",score); }
有了核心代码,所以我把代码补全之后应该是这样的:
public class ScoreQueryService { private final Map<String, Integer> SCORE_CACHE = new HashMap<>(); public Integer query(String userName) throws InterruptedException { Integer result = SCORE_CACHE.get(userName); if (result == null) { result = loadFormDB(userName); SCORE_CACHE.put(userName, result); } return result; } private Integer loadFormDB(String userName) throws InterruptedException { System.out.println("开始查询userName=" + userName + "的分数"); //模拟耗时 TimeUnit.SECONDS.sleep(1); return ThreadLocalRandom.current().nextInt(380, 420); } }
然后搞一个 main 方法测试一下:
public class MainTest { public static void main(String[] args) throws InterruptedException { ScoreQueryService scoreQueryService = new ScoreQueryService(); Integer whyScore = scoreQueryService.query("why"); System.out.println("whyScore = " + whyScore); whyScore = scoreQueryService.query("why"); System.out.println("whyScore = " + whyScore); } }
把代码儿跑起来:
好家伙,第一把就跑了个 408 分,我考研要是真能考到这个分数,怕是做梦都得笑醒。
Demo 很简单,但是请你注意,我要开始变形了。
首先把 main 方法修改为这样:
public class MainTest { public static void main(String[] args) { ExecutorService executorService = Executors.newFixedThreadPool(5); ScoreQueryService scoreQueryService = new ScoreQueryService(); for (int i = 0; i < 3; i++) { executorService.execute(()->{ try { Integer why = scoreQueryService.query("why"); System.out.println("why = " + why); } catch (InterruptedException e) { e.printStackTrace(); } }); } } }
利用线程池提交任务,模拟同一时间发起三次查询请求,由于 loadFormDB 方法里面有模拟耗时的操作,那么这三个请求都不会从缓存中获取到数据。
具体是不是这样的呢?
看一下运行结果:
输出三次,得到了三个不同的分数,说明确实执行了三次 loadFormDB 方法。
好,同学们,那么问题就来了。
很明显,在这个场景下,我只想要一个线程执行 loadFormDB 方法就行了,那么应该怎么操作呢?
看到这个问题的瞬间,不知道你的脑袋里面有没有电光火石般的想起缓存问题三连击:缓存雪崩、缓存击穿、缓存穿透。
毕竟应对缓存击穿的解决方案之一就是只需要一个请求线程去做构建缓存,其他的线程就轮询等着。
然后脑海里面自然而然的就浮现出了 Redis 分布式锁的解决方案,甚至还想到了应该用 setNX 命令来保证只有一个线程放行成功。嘴角漏出一丝不易察觉的笑容,甚至想要关闭这篇文章。
不好意思,收起你的笑容,不能用 Redis,不能用第三方组件,只能用 JDK 的东西。
别问为什么,问就是没有引入。
这个时候你怎么办?
初始方案
听说不能用第三方组件之后,你也一点不慌,大喊一声:键来。
加上一个 synchronized 关键字就算完事,甚至你还记得程序员的自我修养,完成了一波自测,发现确实没有问题:
loadFromDB 方法只执行了一次。
但是,朋友,你有没有想过你这个锁的粒度有点太大了啊。
直接把整个方法给锁了。
本来一个好好的并行方法,你给咔一下,搞成串行的了:
而且你这是无差别乱杀啊,比如上面这个示意图,你要是说当第二次查询 why 的成绩的时候,把这个请求给拦下来,可以理解。
但是你同时也把第一次查询 mx 的成绩给拦截了。弄得 mx 同学一脸懵逼,搞不清啥情况。
注意,这个时候自然而然就会想到缩小锁的粒度,把锁的范围从全局修改为局部,拿出比如用 why 对象作为锁的这一类解决方案。
比如伪代码改成这样:
Integer score = map.get("why"); if(score == null){ synchronized("why"){ score = loadFormDB("why"); map.put("why",score); } }
如果到这里你还没反应过来,那么我再换个例子。
假设我这里的查询条件变 Integer 类型的编号呢?
比如我的编号是 200,是不是伪代码就变成了这样:
Integer score = map.get(200); if(score == null){ synchronized(200){ score = loadFormDB(200); map.put(200,score); } }
看到这里你要是还没反应过来的话我只能大喊一声:你个假读者!之前发的文章肯定没看吧?
之前的《当Synchronized遇到这玩意儿,有个大坑,要注意!》这篇文章不全篇都在说这个事儿吗?
你要不知道问题是啥,你就去翻一下。
这篇文章肯定也不会往这个方向去写。不能死盯着 synchronize 不放,不然思路打不开。
我们这里不能用 synchronized 这个玩意。
但是你仔细一看,如果不用 synchronized 的话,这个 map 也不行啊:
private final Map<String, Integer> SCORE_CACHE = new HashMap<>();
这是个 HashMap,不是线程安全的呀。
怎么办?
演进呗。
演进一下
这一步非常简单,和最开始的程序相比,只是把 HashMap 替换为 ConcurrentHashMap。
然后就啥也没干了。
是不是感觉有点懵逼,甚至感觉有一定被耍了的感觉?
有就对了,因为这一步改变就是书里面的一个方案,我第一次看到的时候反正也是感觉有点懵逼:
因为根本就不能满足“相同的请求下,如果缓存中没有,只有一个请求线程执行 loadFormDB 方法”这个需求,比如 why 的短时间内的两次查询操作就执行两次 loadFormDB 方法。
它的毛病在哪儿呢?
如果多个线程都是查 why 这个人成绩的前提下,如果一个线程去执行 loadFormDB 方法了,而另外的线程根本感知不到有线程在执行该方法,那么它们冲进来后一看:我去,缓存里面压根没有啊?那我也去执行 loadFormDB 方法吧。
完犊子了,重复执行了。
那么在 JDK 原生的方法里面有没有一种机制来表示已经有一个请求查询 why 成绩的线程在执行 loadFormDB 方法了,那么其他的查询 why 成绩的线程就等这个结果就行了,没必要自己也去执行一遍。
这个时候就考验你的知识储备了。
你想到了什么?