ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Guava Cache 源码级原理剖析:过期清除、LRU 淘汰、弱引用回收与移除回调

Guava Cache 源码级原理剖析:过期清除、LRU 淘汰、弱引用回收与移除回调 Guava Cache 源码级原理剖析过期清除、LRU 淘汰、弱引用回收与移除回调【免费下载链接】JCSprout‍ Java Core Sprout : basic, concurrent, algorithm项目地址: https://gitcode.com/gh_mirrors/jc/JCSprout本篇文章基于 JCSprout 仓库中的 guava-cache 文档并结合仓库内src/main/java/com/crossoverjie/guava/下的实战示例源码深入剖析 Google Guava 中Cache组件的设计原理。你将掌握如何用LoadingCache解决时间窗口 N 内异常 X 次触发告警的实时统计需求、Guava Cache 基于LocalCache的懒式过期删除流程、accessQueue/writeQueue双队列与 LRU 淘汰、基于 Java 四种引用强/软/弱/虚实现的 GC 回收以及removalListener移除回调的完整实现链路。从需求出发为什么要用 Guava Cache缓存在日常开发中举足轻重如果应用对某类数据有着较高的读取频次并且改动较小那就非常适合利用缓存来提高性能。缓存之所以能提高性能是因为它的读取效率很高——就像 CPU 的L1、L2、L3缓存一样级别越高读取速度越快。但天下没有免费的午餐读取快的同时内存更小、资源更宝贵所以我们应当只缓存真正需要的数据本质就是典型的空间换时间。在 Java 世界里缓存大致可分为三类各有优劣缓存类型实现方式优点缺点JVM 缓存堆缓存直接创建全局变量如Map、List等容器存放数据使用简单只能显式写入、清除数据不能按规则淘汰数据如 LRU、LFU、FIFO没有清除回调缺少其他定制功能Ehcache、Guava Cache专门用于 JVM 缓存的成熟开源组件自动清除数据、多种清除算法、清除回调、弱引用回收等功能丰富必然带来额外维护成本增加系统消耗分布式缓存Redis、Memcached 等中间件多节点共享内存支持分布式场景引入网络开销与中间件运维成本上文提到的两种缓存都是堆内缓存只能在单个节点中使用分布式场景下就需要 Redis、Memcached 这类可共享内存的中间件不在本文讨论范围。本文聚焦 Guava Cache——Google 出品的 Java 核心增强库应用非常广泛。Guava Cache 实战Kafka 日志异常时间窗告警docs/frame/guava-cache.md中记录了一个真实需求也是 Guava Cache 的绝佳应用场景从 Kafka 实时读取应用系统的日志信息该日志信息包含应用的健康状况。如果在时间窗口 N 内发生了 X 次异常信息就需要作出反馈报警、记录日志等。这个需求可以巧妙地利用 Guava Cache 的过期自动清除特性缓存写入后 N 分钟内不再写入即被清空配合CacheLoader在 key 不存在时返回默认值0每次消费日志时对计数器自增并判断是否超过阈值 X即可完成时间窗口内的异常统计。核心代码如下Value(${alert.in.time:2}) private int time; Bean public LoadingCache buildCache() { return CacheBuilder.newBuilder() .expireAfterWrite(time, TimeUnit.MINUTES) .build(new CacheLoaderLong, AtomicLong() { Override public AtomicLong load(Long key) throws Exception { return new AtomicLong(0); } }); } /** * 判断是否需要报警 */ public void checkAlert() { try { if (counter.get(KEY).incrementAndGet() limit) { LOGGER.info(***********报警***********); // 将缓存清空 counter.get(KEY).getAndSet(0L); } } catch (ExecutionException e) { LOGGER.error(Exception, e); } }代码要点CacheBuilder.newBuilder().expireAfterWrite(time, TimeUnit.MINUTES)构建一个写入后time分钟过期的LoadingCacheCacheLoader.load()定义了缓存 miss 时的加载逻辑返回new AtomicLong(0)即通过 Key 获取不到缓存时默认返回 0counter.get(KEY).incrementAndGet()每次消费日志时对计数器自增一旦达到阈值limit就触发告警随后getAndSet(0L)将计数器归零开启下一个统计周期使用AtomicLong作为缓存值保证并发场景下计数操作incrementAndGet、getAndSet的原子性。这样每次消费日志时调用一次checkAlert()Guava Cache 在内部替我们完成了时间窗口的管理——这正是它相比手写Map容器的价值所在。仓库中对应的完整可运行示例在 CacheLoaderTest.java它构建了一个expireAfterWrite(2, TimeUnit.SECONDS)且带removalListener的LoadingCache并从LinkedBlockingQueue中轮询任务、模拟对同一 KEY 的持续访问。该示例同时覆盖了过期清除与移除回调两个主题后文会反复引用。大胆假设过期数据是如何被清除的在阅读源码之前先做一个大胆假设——Guava Cache 可能会这样实现内部通过一个队列维护缓存顺序每次访问过的数据移动到队列头部并且额外开启一个线程来判断数据是否过期过期就删掉。这很像自己动手实现一个 LRU Cache 的思路可参考仓库文档 docs/algorithm/LRU-cache.md其中LRUAbstractMap正是数组 队列 守护线程定期检查超期的实现而LRUMap、LRULinkedMap则演进为HashMap 双向链表和LinkedHashMap 重写removeEldestEntry。胡适说过大胆假设小心论证。下面就来验证 Guava 到底是怎么实现的。原理分析LocalCache 中的懒式过期删除看原理最好的方式就是跟着代码一步步走。仓库中的示例 CacheLoaderTest.java 在获取缓存之前休眠了 3 秒超过 2 秒的过期时间从而触发过期场景。沿着调用链追下去最终会定位到com.google.common.cache.LocalCache类的 2187 行附近这里是过期删除的关键位置。在进入核心逻辑之前第 2182 行会先判断count是否大于 0——这个count保存的是当前缓存的数量并用volatile修饰以保证多线程下的可见性关于 volatile 的更多细节可参考仓库文档 MD/volatile.md。继续往下2761 行处根据方法名称就能看出是在判断当前的 Entry 是否过期这个 entry 就是通过 key 查询到的。判断逻辑很明显根据构建时指定的过期方式expireAfterWrite、expireAfterAccess来对比当前时间与记录的写入/访问时间从而确定当前 key 是否过期。如果过期就继续往下走尝试进行过期删除。删除时的逻辑非常清晰获取当前缓存的总数量自减一前面已经获取了锁所以线程安全删除该 Entry并将更新后的总数赋值给count。到这里结论已经很明确了Guava 并没有按照之前猜想的另起一个线程来维护过期数据而是在查询get的路径上顺带完成过期数据的清理即懒删除。为什么选择懒删除而非后台线程文档给出了合理的推断新起线程需要资源消耗维护过期数据还要获取额外的锁增加了消耗在查询时顺带处理几乎零额外成本。当然这种设计的代价是如果某个缓存迟迟没有被访问那么即使它已经过期也不会被立即回收内存上会暂存一段时间的僵尸数据。不过对于一个高吞吐的应用来说这完全不是问题——因为持续不断的读写请求会天然地推动过期清理。总结第一趴并发数据结构、双队列与构建者模式回顾跟代码的过程会发现通过一个 key 定位数据时出现了先定位 Segment、再定位具体位置的两段式 Hash 过程。如果了解过 ConcurrentHashMap 的原理就会想到这其实非常类似——Guava Cache 为了满足并发场景的使用核心数据结构就是按照 ConcurrentHashMap 设计的这里同样是一次 key 到具体位置的定位过程相当于做了两次 Hash 定位。同时前文内部用队列维护顺序的假设有一部分是对的Guava Cache 内部会维护两个队列——accessQueue访问顺序队列和writeQueue写入顺序队列用于记录缓存的顺序这样才可以按照顺序淘汰数据类似于利用 LinkedHashMap 来做 LRU 缓存。在 ReferenceEntry 的源码注释 中也能看到使用访问序的 entry 由双向链表维护新 entry 在写入时加到链表尾部过期 entry 从链表头部被淘汰——这正是 LRU 语义的体现。此外从构建方式来看CacheBuilder使用了**构建者模式Builder Pattern**来创建对象。因为作为一个面向开发者的工具需要支持大量可自定义的属性过期时间、最大容量、引用类型、移除监听器等用构建者模式再合适不过链式调用清晰、参数可控、且对象构建与配置分离。进一步分析Java 的四种引用docs/frame/guava-cache.md后半部分分析了 Guava Cache 的另外两个高级特性基于 GC 的引用回收和移除时的回调通知。在进入源码之前先补习一下 Java 自带的两个特性——引用与事件回调Guava 中都有具体的应用。JVM 根据可达性分析算法见仓库文档 MD/GarbageCollection.md找出需要回收的对象判断对象的存活状态都和引用有关。在 JDK 1.2 之前对象的状态只有被引用和没被引用两种这种划分对垃圾回收并不友好因为总有一些对象的状态介于两者之间。因此 JDK 1.2 之后新增了四种引用状态用于更细粒度地划分引用关系引用类型说明回收时机强引用Strong Reference最常见如A a new A();不能被垃圾回收软引用Soft Reference有用但非必要的对象在即将发生内存溢出OOM之前回收弱引用Weak Reference比软引用更弱存放非必须对象垃圾回收时无论内存是否足够都会被回收虚引用Phantom Reference最弱的引用甚至无法通过引用获取对象唯一作用对象被回收时获得通知仓库中 ReferenceTest.java 从参数传递的角度验证了 Java 引用语义基本类型int传值不影响外部变量引用类型传引用Car、List可以修改对象内部状态而重新赋值引用car2 new Car(...)不会影响外部引用——理解这些区别有助于理解缓存值被 GC 回收时的行为。事件回调Caller 与 Notifier 的异步问答事件回调是一种常见的设计模式Netty 等框架大量使用了这种设计。在 Java 中利用接口即可实现回调仓库 callback 包 下有一个完整的 demo模拟如下功能Caller 向 Notifier 提问提问方式是异步的Caller 提问后继续做其他事情Notifier 收到问题执行计算然后回调 Caller 告知结果。首先定义一个回调接口 CallBackListener.javapublic interface CallBackListener { /** * 回调通知函数 * param msg */ void callBackNotify(String msg) ; }Caller.java 中调用 Notifier 执行提问调用时将接口传递过去并新建线程实现异步public class Caller { private final static Logger LOGGER LoggerFactory.getLogger(Caller.class); private CallBackListener callBackListener; private Notifier notifier; private String question; /** * 使用 */ public void call() { LOGGER.info(开始提问); // 新建线程达到异步效果 new Thread(new Runnable() { Override public void run() { try { notifier.execute(Caller.this, question); } catch (InterruptedException e) { e.printStackTrace(); } } }).start(); LOGGER.info(提问完毕我去干其他事了); } // 隐藏 getter/setter }Notifier.java 收到提问后执行耗时计算最后通过回调接口告知 Caller 结果public class Notifier { private final static Logger LOGGER LoggerFactory.getLogger(Notifier.class); public void execute(Caller caller, String msg) throws InterruptedException { LOGGER.info(收到消息【{}】, msg); LOGGER.info(等待响应中。。。。。); TimeUnit.SECONDS.sleep(2); caller.getCallBackListener().callBackNotify(我在北京); } }在 Main.java 中模拟执行public static void main(String[] args) { Notifier notifier new Notifier(); Caller caller new Caller(); caller.setNotifier(notifier); caller.setQuestion(你在哪儿); caller.setCallBackListener(new CallBackListener() { Override public void callBackNotify(String msg) { LOGGER.info(回复【{}】, msg); } }); caller.call(); }执行结果如下2018-07-15 19:52:11.105 [main] INFO c.crossoverjie.guava.callback.Caller - 开始提问 2018-07-15 19:52:11.118 [main] INFO c.crossoverjie.guava.callback.Caller - 提问完毕我去干其他事了 2018-07-15 19:52:11.117 [Thread-0] INFO c.c.guava.callback.Notifier - 收到消息【你在哪儿】 2018-07-15 19:52:11.121 [Thread-0] INFO c.c.guava.callback.Notifier - 等待响应中。。。。。 2018-07-15 19:52:13.124 [Thread-0] INFO com.crossoverjie.guava.callback.Main - 回复【我在北京】注意日志顺序Caller打印开始提问后立刻返回继续做事2 秒后Notifier计算完成并通过callBackNotify回调通知结果——这就是一个完整的异步事件回调模型Guava Cache 的移除通知正是建立在这一机制之上。Guava 的引用回收weakKeys / weakValues / softValues回到 Guava Cache。既然理解了 Java 的四种引用就可以看 Guava 如何用它实现引用回收在初始化缓存时可以利用以下 API 自定义键和值的引用关系CacheBuilder.weakKeys()使用弱引用保存 keyCacheBuilder.weakValues()使用弱引用保存 valueCacheBuilder.softValues()使用软引用保存 value。当使用这样的构造方式时弱引用的 key 和 value 在失去强引用后就会被垃圾回收从而在不显式调用清除方法的情况下自动释放内存。在源码层面Cache 中的ReferenceEntry是类似 HashMap 的 Entry 存放数据的其定义包含值引用、键引用、访问时间等常用操作interface ReferenceEntryK, V { /** * Returns the value reference from this entry. */ ValueReferenceK, V getValueReference(); /** * Sets the value reference for this entry. */ void setValueReference(ValueReferenceK, V valueReference); /** * Returns the next entry in the chain. */ Nullable ReferenceEntryK, V getNext(); /** * Returns the entrys hash. */ int getHash(); /** * Returns the key for this entry. */ Nullable K getKey(); /* * Used by entries that use access order. Access entries are maintained in a doubly-linked list. * New entries are added at the tail of the list at write time; stale entries are expired from * the head of the list. */ /** * Returns the time that this entry was last accessed, in ns. */ long getAccessTime(); /** * Sets the entry access time in ns. */ void setAccessTime(long time); }从getValueReference()的实现来看ValueReference有强引用和弱引用的不同实现如StrongValueReference、WeakValueReference、SoftValueReference等key 也是相同的道理通过weakKeys()时 key 会以弱引用的形式保存在 entry 中。注释也印证了前文的结论使用访问序的 entry 由双向链表维护新 entry 在写入时加到链表尾部过期 entry 从链表头部被淘汰。除了依赖 GC 的自动回收Guava Cache 也支持显式回收对应Cache接口中的三个方法/** * Discards any cached value for key {code key}. * 单个回收 */ void invalidate(Object key); /** * Discards any cached values for keys {code keys}. * * since 11.0 */ void invalidateAll(Iterable? keys); /** * Discards all entries in the cache. */ void invalidateAll();invalidate(key)回收单个 key 对应的缓存invalidateAll(Iterable? keys)批量回收指定 keysGuava 11.0 起提供invalidateAll()清空整个缓存。移除回调removalListener 的完整实现链路在初始化缓存时注册removalListener即可在缓存被移除时收到通知。仓库中的 CacheLoaderTest.java 改造了之前的例子loadingCache CacheBuilder.newBuilder() .expireAfterWrite(2, TimeUnit.SECONDS) .removalListener(new RemovalListenerObject, Object() { Override public void onRemoval(RemovalNotificationObject, Object notification) { LOGGER.info(删除原因{}删除 key{},删除 value{}, notification.getCause(), notification.getKey(), notification.getValue()); } }) .build(new CacheLoaderInteger, AtomicLong() { Override public AtomicLong load(Integer key) throws Exception { return new AtomicLong(0); } });当缓存过期被删除时会回调自定义的onRemoval方法并告知删除原因。执行结果2018-07-15 20:41:07.433 [main] INFO c.crossoverjie.guava.CacheLoaderTest - 当前缓存值0,缓存大小1 2018-07-15 20:41:07.442 [main] INFO c.crossoverjie.guava.CacheLoaderTest - 缓存的所有内容{10000} 2018-07-15 20:41:07.443 [main] INFO c.crossoverjie.guava.CacheLoaderTest - job running times10 2018-07-15 20:41:10.461 [main] INFO c.crossoverjie.guava.CacheLoaderTest - 删除原因EXPIRED删除 key1000,删除 value1 2018-07-15 20:41:10.462 [main] INFO c.crossoverjie.guava.CacheLoaderTest - 当前缓存值0,缓存大小1 2018-07-15 20:41:10.462 [main] INFO c.crossoverjie.guava.CacheLoaderTest - 缓存的所有内容{10000}可以看到RemovalNotification中携带了删除原因EXPIRED、被删除的 key 和 value。这里注意一个细节日志中删除的 value 是1因为 value 是AtomicLong可变对象缓存放的是同一个引用所以在过期前累计的自增值会体现在通知中——这也提醒我们用 Guava Cache 存放可变对象时要清楚引用共享的语义。那么 Guava 是如何实现这个回调的呢沿着源码继续追踪LocalCache.getLiveValue()判断缓存过期后会走到removeValueFromChain()removeValueFromChain()中的enqueueNotification()方法会将回收的缓存包含 key、value以及回收原因包装成事件对象加入到一个本地队列中此时并不会立刻回调初始化时注册的RemovalListener——既然写入了队列那就肯定有消费方回到获取缓存的地方在finally中执行了postReadCleanup()方法这里就是对刚才的队列进行消费继续跟进会发现消费队列时会将之前包装好的移除消息取出并调用我们自定义的onRemoval事件从而完成一次完整的事件回调。这个设计非常精妙移除通知并不在删除动作发生时同步执行而是先入队、再在读取路径的收尾阶段postReadCleanup统一派发。这样做一方面避免了在加锁的删除路径上执行用户回调防止死锁或性能抖动另一方面与查询时顺带清理的懒删除策略保持了一致的节奏——回调的触发同样不依赖额外线程。环境与运行说明仓库pom.xml中声明了com.google.guava:guava:22.0依赖项目基于 Spring Boot 1.5.6.RELEASE 父工程、JDK 11 编译见 pom.xml 中的java.version11/java.version与 guava 依赖声明。直接运行 CacheLoaderTest.java 的main方法即可复现过期清除与移除回调的完整日志输出运行 Main.java 则可复现异步事件回调 demo。全文总结回顾整个分析过程Guava Cache 的设计精髓可以归纳为以下几点懒式过期清除不启动后台线程维护过期数据而是在每次 get 时顺带检查并删除过期 Entry配合volatile int count保证缓存数量的可见性在加锁的保护下完成取数量、自减、删除三步操作。这种设计牺牲了过期数据即刻回收的确定性换来了极低的维护开销对高吞吐应用非常友好。并发数据结构底层核心结构仿照 ConcurrentHashMap通过先定位 Segment、再定位具体位置的两次 Hash 完成 key 定位满足高并发读写。双队列 LRU内部维护accessQueue访问序和writeQueue写入序两个双向链表队列按顺序淘汰数据实现类似 LinkedHashMap 的 LRU 语义仓库 docs/algorithm/LRU-cache.md 中的LRULinkedMap正是借助 LinkedHashMap 的removeEldestEntry实现同一目标。GC 友好的引用回收通过weakKeys()、weakValues()、softValues()将 key/value 包装为不同引用强度的ValueReference让垃圾回收器替我们回收不再被外部强引用的缓存项并辅以invalidate系列方法进行显式回收。事件驱动的移除通知移除动作通过enqueueNotification()入队由读取路径finally中的postReadCleanup()消费队列并派发到RemovalListener整个回调链路复用 Java 接口回调这一基础设计模式见 callback 包 的完整 demo。Guava Cache 是理解顶级开源库如何平衡性能、功能与复杂度的绝佳样本。无论是把 CacheLoaderTest.java 的时间窗计数模式应用到告警、限流可结合仓库文档 docs/distributed/Distributed-Limit.md 与 docs/algorithm/Limiting.md等场景还是深入研究其源码实现都值得我们在日常开发中反复琢磨。【免费下载链接】JCSprout‍ Java Core Sprout : basic, concurrent, algorithm项目地址: https://gitcode.com/gh_mirrors/jc/JCSprout创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表