剖析LRU算法及LinkedHashMap源码实现机制

一、简述

LRU(Least Recently Used),注意L代表的不是Latest,翻译成中文通常叫:近期最少使用算法、最近最少使用算法。LRU与LFU(Least Frequently Used)、FIFO(First In First Out)是3种常用的缓存算法(页面置换算法)。缓存算法的应用场景有很多,例如操作系统在物理内存不足时触发的磁盘交换、CPU中L1、L2、L3缓存淘汰替换、超市中畅销货品在货架的摆放位置优化等。缓存算法的实现和变种也有很多,这里只针对LRU算法抛砖引玉。

二、算法原理

缓存算法的根本目的,是淘汰掉“最不常用”的元素。LRU算法淘汰的是截止当前缓存区中最久没有被访问过的元素,这意味着“最后一次访问时间”是LRU算法决定是否淘汰元素的唯一标准。

因此,假设我们记录下每个元素最后一次被访问的时间戳,并按照此时间戳早晚进行排序,那么,时间最早的元素应该被淘汰掉(如图1)。所以,将LRU准确地翻译成中文,应该是:距今最久未用淘汰算法。

图1:最后一次访问时间最早的元素首先被淘汰

这里补充一点,LRU算法并不是绝对公平的。举个例子,假如某个元素仅仅在淘汰执行前被访问了1次,在之前的数百万次的请求中从未被访问,按照LRU的算法逻辑,它仍然会留存下来,这其实无法准确描述“是否常用”这一特性。“是否常用”其实在不同的业务场景有不同的定义,大家可以细细体会。

三、LRU的实现机制

依照LRU算法原理,其实现机制并不复杂,仅仅需要根据元素的最后一次访问时间排序即可。但当运用在1个生产环境的缓存系统中时,会面临以下几个工程问题:

  1. 在缓存访问频次极高的情况下,时间戳即使精确到纳秒,依然存在大量的相同时间戳,排序无效。若采用递增ID替代时间戳,则存在溢出的风险。
  2. 每次进行缓存淘汰时,都需要将缓存区所有元素进行排序。当元素数极多时,缓存系统的性能将急剧下降,CPU耗费极高。
  3. 在元素本身占用内存不大的情况下,附加的“时间戳/自增ID”甚至在内存占用上喧宾夺主。

最佳的实现思路是利用链表的有序特性,将缓存元素的“按最后一次访问时间戳排序”巧妙地转换为其在链表中的相对顺序。因为LRU算法关心的并不是元素的绝对访问时间,而是元素被访问的先后顺序。即:元素被命中时,移到链表头。执行淘汰时,从链表尾开始淘汰。因此,LRU算法实现的核心数据结构是:循环链表。

但对于一个<K,V>缓存系统而言,仅有链表结构是不够的。链表虽然解决了缓存淘汰问题,但缓存访问却需要每次都从链表头开始遍历。JDK中的java.util.LinkedHashMap给出了最佳的实现(链表也是Linked这一前缀的由来),同时解决了元素的快速访问与缓存淘汰问题。

四、JDK中LinkedHashMap对LRU的实现源码分析

1、概述

从Oracle的JDK源码的注释中可以看到,从JDK1.4开始,Josh Bloch就提供了LinkedHashMap供开发者使用。LinkedHashMap继承自HashMap,拥有<K,V>结构的同时还提供了缓存淘汰的扩展。

 

2、LinkedHashMap的使用示例(LRU效果)

 

上面的代码打印结果为:abce,很正常,按照加入的顺序打印

打印结果为:ceab ,可以看出LinkedHashMap构造参数中的“true”启用了其内部的LRU 特性。

3、利用LinkedHashMap的LRU特性,构造固定容量缓存系统

 

说明:上述代码中FixedSizeCahceMap构造参数中的fixSize指定了缓存的最大元素个数。removeEldestEntry方法重写了LinkedHashMap中关于判断是否删除“最久未用元素”的逻辑,“return size() > fixSize”表明,当缓存中的元素数达到fixSize时,将eldest元素淘汰掉。

4、LinkedHashMap实现源码分析

上文提到LinkedHashMap继承自HashMap,其主要目的是充分利用HashMap现有的<K,V>数据结构,在此基础上通过Override一些关键方法,将<K,V>结构与循环链表结构完美结合运用。被Override的几个关键点:

(1)定义链表的节点

(2)LinkedHashMap初始化时,建立循环链表

 

(3)向缓存中添加元素时的重写

(4)缓存命中时的方法重写

五、总结

LRU算法是缓存算法的入门必修课,而使用缓存算法也是很多互联网基础设施研发的必经之路,尤其在数据规模不断增大而物理资源相对有限的分布式领域,深入了解其机制原理并触类旁通,将是夯实高阶研发基础的第一步。