(转载)Java趣谈—如何构建一个高效且可伸缩的缓存

写得很好的文章,敲代码时收获了很多技巧!

参考:https://zhuanlan.zhihu.com/p/34004674

 

本集概要:

  • 怎样构建一个线程安全而又高效、可伸缩的缓存?
  • 怎样利用设计模式,把缓存做成通用的工具?
  • 除了synchronize和volatile,我们还能使用哪些工具来开发线程安全的代码?

大雄开发的门线传感器在曼联和阿森纳的比赛中一鸣惊人,越来越多的客户向公司订购这款软件……

一、糙版缓存(最简单的实现)

一天,哆啦对大雄说,“大雄,你看我们后台这个统计用户消费信息的报表,每次统计都要去查数据库,既消耗资源,查询起来也慢,你看看能不能给做一个缓存?”

“缓存?这个简单呀,缓存的原理其实就是一个键值对,也就是Map,只要把用户的id作为key,把对应的统计结果作为value,放到Map里头缓存起来,下次再来查询时,先到Map去搜,搜到了就直接返回,搜不到,再去查数据库计算,这样就ok了。”

“可以呀,思路很清晰嘛,一下子就讲出了一份设计文档。”

“那是,那我开始写代码啦!”,大雄泡了杯茶,关掉QQ和Outlook,开始写起了代码。

很快,第一版代码出炉了:

UserCostStatComputer.java

为了线程安全,我给整个compute方法加上了synchronized修饰符,保证每次只有一个线程可以进入compute方法”,大雄向哆啦介绍他的代码。

哆啦看了看,说,“大雄,你这代码写的快是快,但是有点糙啊”

“这…”

“咱先不说性能,就说你这设计,你有没有考虑过,要是还有其他地方也需要缓存,你难道也把这份缓存的逻辑拷贝过去?

“啊,对呀,总不能每次要用到缓存,就在原先代码里加个Map,然后再写上if判断吧…”

“小伙子觉悟不错,好好想想,怎样给原先没有缓存的统计函数,加上缓存的功能?”

“给原先没有某某功能的函数,加上某某功能,这话听着很熟悉啊…”

二、通用缓存(从逻辑变成通用规范)

大雄在大脑中检索着,“对了!装饰模式!哎,之前学过的,怎么到实际运用中就忘了呢!”

利用设计模式,大雄很快把代码进行了重构。首先,定义一个Computable接口:

接着,每个要使用缓存功能的计算器,都要去实现这个接口,比如UserCostStatComputer:

UserCostStatComputer.java(最初的版本)

现在这个UserCostStatComputer当然是没有缓存功能的,没关系,我们再来写一个装饰器类

Memoizer.java

Memoizer类即实现了Computable接口,又持有一个Computable对象,同时在自己的compute方法中,调用了Computable对象的compute方法。这个compute方法的外圈,就是之前的缓存逻辑。

也就是说,UserCostStatComputer类只需要实现业务逻辑即可,真正的缓存操作通过包装类:Memoizer类来执行。

那么问题来了,要怎样使用Memoizer给UserCostStatComputer装饰上缓存功能呢?

装饰装饰,当然要使用Memoizer把UserCostStatComputer装饰(包装)起来。在调用UserCostStatComputer对象时,需要这样操作:

Test.java

总结一下,实现通用只要三步:

  1. 定好“实现缓存”这个规则(实现一个接口)。
  2. 让需要实现缓存的业务逻辑都遵守这个规则(实现接口)
  3. 需要使用业务逻辑时,首先定义接口(Computable computer),new一个装饰类封装业务类(new Memoizer(new UserCostStatComputer())),即可使用对象。

总之,利用Memoizer这个装饰器,我们成功的给原先不具有缓存功能的UserCostStatComputer加上了缓存。

“后面要是有其他的计算器,比如用户地区分布统计计算器、用户年龄分布统计计算器,我们都可以像UserCostStatComputer一样,实现Computable,就可以使用装饰器,装饰上缓存功能了”,大雄得意洋洋地说。

“不错,小伙子悟性很强啊”,哆啦拍拍大雄的肩膀,“好了,现在通过设计模式解决了代码通用性问题,是时候看看性能问题了。”

三、性能问题(降低锁的粒度)

“性能问题?我这个代码性能有问题么”,大雄翻着白眼。

“你看看,你把synchronize加在了什么地方?你加在了函数签名的位置,意味着一次只能有一个线程能够进入compute方法,这就导致了一个用户的id在计算的时候,其他用户无法进行计算,只能等到正在计算的用户计算完了,才能进入compute方法。”

“啊,这对性能影响挺大的…”

“没错,换句话说,你这个加锁的粒度太大了,我们先把synchronize去掉,看看有什么线程安全问题,再来逐一解决!”

Memoizer.java(去掉synchronize后的compute方法)

“很明显,由于你用的是线程不安全的HashMap,cache.put(arg, result)这一行代码就有线程安全问题”,哆啦说。

“那给put方法加个锁?像这样”,大雄说着在纸上写出草稿:

“哈哈,你怎么那么喜欢用synchronized,这样可以是可以,但是粒度还是太大了,Java早就给你提供了ConcurrentHashMap,我们只要把线程安全委托给它去处理就好了。”

“啊,我怎么又忘了…”说完,大雄修改了代码:

Memoizer.java

四、未来任务

“现在再来看其他地方,由于把synchronize去掉了,现在不同的用户id可以同时计算,但是也导致了相同用户id也会同时被计算,也就是说同样的数据会被计算多次,这样缓存就没意义了。”

(UserCostStatComputer类中的compute需要运行一段时间。我们使用了ConcurrentHashMap,只能保证缓存不出错,但是无法阻止(没有命中缓存时)compute进行相同数据的运算,这就造成了性能的浪费。)

(举个例子:用户A使用getGoods方法获取商店A的商品列表,缓存未命中,所以需要使用selectGoods方法从数据库中获取。在selectGoods方法执行期间,用户B同样使用getGoods方法获取商店A的商品列表,仍然无法命中缓存,又要调用selectGoods方法从数据库中获取…在一些大流量的场景中,1s内可能有1w个访问量。在缓存到位前,数据库将承受巨大的压力。)

“啊,假设用户id为1的数据正在计算,这个时候又来了一个线程,也是计算用户id为1的,如果这个线程可以知道,当前有没有线程正在计算id为1的数据。如果有,就等待这个线程返回计算结果,如果没有,就自己计算,那问题不就解决了?可是这个逻辑挺复杂的,好难实现啊。”

“哈哈,很简单,Java早就提供了FutureFutureTask,来实现你说的这种功能,来,把键盘给我,好久没写代码了”,哆啦说完,马上敲起了键盘:

Memoizer.java

这就是终极版的Memoizer。

简单解释一下代码逻辑,线程进了compute方法后:

  1. 首先去存放future的Map里,搜索有没有已经其他线程已经创建好的future。
  2. 如果有(不等于null),那就调用future的get方法。
  3. 如果已经计算完成,get方法会立刻返回计算结果。(完成了的任务会存在map中,get之后直接拿到执行结果)
  4. 否则,get方法会阻塞,直到结果计算出来再将其返回。
  5. 如果没有已经创建的future,那么就自己创建future,进行计算。

有几个小点要单独解释的:

  1. 为什么要使用putIfAbsent
    这里之所以用putIfAbsent而不用put,原因很简单。
    如果有两个都是计算userID=1的线程,同时调用put方法,那么返回的结果都不会为null,后面还是会创建两个任务去计算相同的值。
    而putIfAbsent,当map里面已经有对应的值了,则会返回已有的值,否则,会返回null,这样就可以解决相同的值计算两次的问题。
  2. 为什么要while (true) {}
    这是因为future的get方法会由于线程被中断而抛出CancellationException。
    我们对于CancellationException的处理是cache.remove(arg, f);,也就是把缓存清理掉,然后进入下一个循环,重新计算,直到计算成功,或者抛出ExecutionException。

五、作者的总结

这篇文章中,我们先是用装饰模式,改良了代码的设计,接着通过使用并发容器ConcurrentHashMap以及同步工具Future,取代了原先的synchronize,实现了线程安全性的委托。

《Java并发编程实践》中,将委托称为实现线程安全的一个最有效策略。委托其实就是把原来通过使用synchronize和volatile来实现的线程安全,委托给Java提供的一些基础构建模块(当然你也可以写自己的基础构建模块)去实现,这些基础构建模块包括:

  • 同步容器:比如Vector,同步容器的原理大多就是synchronize,所以用的不多;
  • 并发容器:比如本文用到的ConcurrentHashMap,当然还有CopyonWriteArrayList、LinkedBlockingQueue等,根据你的需求使用不同数据结构的并发容器;
  • 同步工具类:比如本文用到的Future和FutureTask,还有闭锁(Latches)、信号量(Semaphores)、栅栏(Barriers)等

这些基础构建模块,在加上之前所讲的synchronize和volatile,就形成了Java并发的基础知识:

  • 如何写出线程不安全的代码
  • 如何用一句话介绍synchronize的内涵
  • Volatile趣谈——我是怎么把贝克汉姆的进球弄丢的

其实也就是《Java并发编程实践》第一部分的迷你版,总结成一句话就是:
要想写出线程安全的代码,我们需要用到使用synchronize、volatile,或者将线程安全委托给基础构建模块。

当然,在实际应用中,只有这些基础知识是不够的。最简单的例子,你不可能每次请求过来都创建线程,这会吃光内存的,所以,你需要一个线程池,来限制线程的数量,这也就引出了《Java并发编程实践》的第二部分,结构化并发应用程序(Structuring Concurrent Applications),听名字确实不知道想讲什么,所以我也将把它啃下来,然后像第一部分一样,用尽可能通俗易懂的语言和大家分享学习心得。

六、我的总结

《Java并发编程实践》我看了一点,也敲了书里的代码,感觉比较枯燥,让人蛋疼。

个人认为:这篇文章能用通俗易懂的语言总结学到的知识,给出的例子也很有实战意义,自己还需要多多向作者学习。