LRU缓存
LRU简介
LRU 是 Least Recently Used 的缩写,即最不经常用、最长时间未使用,在操作系统中是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。该算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 $t$,当须淘汰一个页面时,选择现有页面中其 $t$ 值最大的,即最久未使用的页面予以淘汰。
这里说的缓存是一种广义的概念,在计算机存储层次结构中,低一层的存储器都可以看做是高一层的缓存。

缓存可以有效地解决存储器性能与容量的矛盾,但如果缓存算法设计不当,非但不能提高访问速度,反而会使系统变得更慢。
从本质上来说,缓存之所以有效是因为程序和数据的局部性(locality)。程序会按固定的顺序执行,数据会存放在连续的内存空间并反复读写。这些特点使得我们可以缓存那些经常用到的数据,从而提高读写速度。
缓存的大小是固定的,它应该只保存最常被访问的那些数据。然而未来不可预知,我们只能从过去的访问序列做预测,于是就有了各种各样的缓存替换策略。
LeetCode题目
题目描述
运用你所掌握的数据结构,设计和实现一个 LRU (最久未使用) 缓存机制。它应该支持以下操作: 获取数据 get 和 写入数据 put 。
获取数据
get(key):如果关键字 (key) 存在于缓存中,则获取关键字的值(总是正数),否则返回-1。写入数据
put(key, value):- 如果关键字已经存在,则变更其数据值;
- 如果关键字不存在,则插入该组「关键字/值」;
- 当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
进阶:你是否可以在 $O(1)$ 时间复杂度内完成这两种操作?
1 | LRUCache cache = new LRUCache( 2 /* 缓存容量 */ ); |
解题思路
实现本题的两种操作,需要用到一个哈希表和一个双向链表。在Java语言中,LinkedHashMap拥有这种数据结构。而且利用这内置的数据结构很容易就可以实现LRU缓存的功能。
1 | public class LRUCache extends LinkedHashMap<Integer, Integer> { |
上面的代码虽然能够通过 LeetCode 的测试,但是如果在面试的时候面试官更想要看到的应该是候选人手动实现 LRU 的功能,而不是直接调用JDK。
通过上面的内容可以了解到LRU 缓存机制主要用到了哈希表与双向链表,在这个过程中:
- 哈希表:通过缓存数据的
key在 $O(1)$ 的时间内获得Node在双向链表中的位置,结构其实是Map<Integer, Node>; - 双向链表:按照被使用的顺序存储键值对(结点),靠近
head的结点是最近刚刚使用的,而靠近tail的结点则是最长时间未使用的。
这样一来,我们需要做的就是:
- 使用哈希表进行定位,找出缓存的数据在双向链表中的位置;
- 根据进行的操作
get/put以及容量capacity维护缓存数据在哈希表和双向链表中存储- 把最近访问过的数据项移动到链表头部;
- 在哈希表中
insert/update/delete数据项。
算法流程:
- 创建双向链表的结点
Node - 创建双向链表用于维护结点的使用信息
get(key)操作- 如果
key不存在,则返回-1; - 如果
key存在,则key对应的结点是刚刚被使用过的结点,通过哈希表定位到该结点在双向链表中的位置,并将其移动到双向链表的头部,最后返回该结点的值。
- 如果
put(key, value)操作key == null,使用key-value创建一个新的结点node,添加该结点到双向链表头部head,并将key-node添加到哈希表中。然后判断结点数量是否超出容量,如果超出,则删除双向链表的尾部结点,并删除哈希表中其对应的映射。key != null,与get操作类似,先通过哈希表定位,再将对应结点的值更新为value,并将该结点移动到双向链表的头部。
哨兵结点:
在双向链表的实现中,使用伪头部
dummy head和伪尾部dummy tail标记界限,这样在添加和删除结点的时候就不需要再检查相邻的结点是否存在。
1 | public class LRUCache { |
- 时间复杂度:
get()、put()都是 $O(1)$。 - 空间复杂度:$O(capacity)$,哈希表和双向链表最多存储
capacity + 1个元素。
上面的代码应该可以让面试官比较满意了,但是如果想更加突出自己的过人之处还该考虑点什么?
哨兵 (
head、tail) 已经考虑;线程安全 (
synchronized可行考虑);吞吐量 (
CAS操作) 可以再提高一点吗?考虑使用(
ConcurrentHashMap) 分段锁;如果容忍一定的准确性损失 (
ReadWriteLock);环形
LRU(Disruptor Ring Buffer);提前分配空间的
LRU;分布式的
LRU应该如何设计?
线程安全并且带有定时任务的LRU缓存
ConcurrentLinkedQueue
ConcurrentLinkedQueue 是一个基于单向链表的无界无锁线程安全的队列,适合在高并发环境下使用,效率比较高。在使用时,可以把它理解为基本的队列,但是保证了多线程下的安全性。和普通队列一样,它也是按照先进先出(FIFO)的规则对接点进行排序。 另外,队列元素中不可以放置 null 元素。
ConcurrentLinkedQueue 整个继承关系如下图所示:

ConcurrentLinkedQueue 中最主要的两个方法是:offer(value)和poll(),分别实现队列的两个重要的操作:入队和出队。(offer(value) 基本等价于 add(value))。
当添加一个元素到队列的时候,它会添加到队列的尾部,当获取一个元素时,它会返回队列头部的元素。
利用 ConcurrentLinkedQueue 先进先出的特性,每当 put/get (缓存被使用)元素的时候,就将元素存放在队列尾部,这样能够保证头部的元素是最近最少使用的。
ReadWriteLock
ReadWriteLock 是一个接口,位于java.util.concurrent.locks包下,里面只有两个方法分别返回读锁和写锁:
1 | public interface ReadWriteLock { |
ReentrantReadWriteLock 是 ReadWriteLock 接口的具体实现类。
读写锁比较适合缓存这种读多写少的场景。读写锁可以保证多个线程同时读取,但是只有一个线程可以写入。但是当读锁被线程持有的时候,读锁是无法被其他线程申请,会处于阻塞状态,直至读锁被释放。该锁也是可重入锁。
另外,同一个线程持有写锁时可以继续申请读锁,但是持有读锁的时候不可以申请写锁。
ScheduledExecutorService
ScheduledExecutorService 是一个接口,ScheduledThreadPoolExecutor 是其主要实现类。

ScheduledThreadPoolExecutor 主要用来在给定的延迟后运行任务,或者定期执行任务。 这个在实际项目用到的比较少,因为有其他方案选择比如 quartz。但是,在一些需求比较简单的场景下还是非常有用的!
ScheduledThreadPoolExecutor 使用的任务队列 DelayQueue 封装了一个 PriorityQueue,PriorityQueue 会对队列中的任务进行排序,执行所需时间短的放在前面先被执行,如果执行所需时间相同则先提交的任务将被先执行。
线程安全的LRU缓存
- 实现方法:
ConcurrentHashMap + ConcurrentLinkedQueue + ReadWriteLock。
这里可能会有疑问的点是:在LeetCode中选择使用双向链表来维护结点之前按使用时间的关系,也可以实现在 $O(1)$ 的时间内完成将结点的移动和删除操作,但是这里用队列来保存结点,如果刚刚访问了队列中间的结点,那这个结点之前或之后的结点该如何处理呢?
这里的做法是:每当 put/get 元素的时候,就将这个元素对应的 key 放到队尾,这样就能保证队列头部的元素就是最近最少使用的,当缓存容量不够的时候,直接移除队列头部的key以及这个key对应的缓存即可。
1 | public class LRUCache<K, V> { |
- 非并发环境测试
1 | public class NonConcurrentTest { |
- 并发环境测试
1 | public class ConcurrentTest { |
线程安全并且带有定时任务的LRU缓存
实际上就是在上面实现的LRU的基础上加上一个定时任务去删除缓存,实现定时任务的方式有很多种:
Timer:不被推荐,多线程会存在问题。ScheduledExecutorService:定时器线程池,可以用来替代TimerDelayQueue:延时队列quartz:一个很火的开源任务调度框架,很多其他框架都是基于quartz开发的,比如当当网的elastic-job就是基于quartz二次开发之后的分布式调度解决方案- $\dots$
因为只是一个小练习,所以选择JDK自带的ScheduledExecutorService,它基于DelayQueue做了很多封装,能够满足大部分需求。
在上面实现的基础上增加一个方法,在put操作的时候,通过这个方法就能直接设置过期时间。
1 | private void removeAfterExpire(K key, long expireTime) { |
1 | public V put(K key, V value, long expireTime) { |
存在的问题:
Lock与ConcurrentXxx一起使用显得多余。从此也可以产生两种方案
- 读写锁加基本的
HashMap、双向链表,或者synchronized同步方法。- 使用并发工具类
ConcurrentXxxx待改进之处:维护队列 $O(n)$,可以使用其他数据结构减少到 $O(1)$。
Redis 中的 LRU
Redis 作为缓存使用时,一些场景下要考虑内存的空间消耗问题。Redis 会删除过期键以释放空间,过期键的删除策略有两种:
- 惰性删除:每次从键空间获取键时,都检查键是否过期,如果过期的话就删除该键,否则返回该键。
- 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。
当需要从缓存中淘汰数据时,我们希望能淘汰那些将来不可能再被使用的数据,保留那些将来还会频繁访问的数据,但最大的问题是缓存并不能预言未来。一个解决方法就是通过 LRU 进行预测:
- 最近被频繁访问的数据将来被访问的可能性也越大;
- 缓存中的数据一般会有这样的访问分布:
- 一部分数据拥有绝大部分的访问量;
- 当访问模式很少改变时,可以记录每个数据的最后一次访问时间,拥有最少空闲时间的数据可以被认为将来最有可能被访问到。
LRU配置参数
Redis配置中和LRU有关的有三个:
maxmemory:配置Redis存储数据时指定限制的内存大小,比如100m。当缓存消耗的内存超过这个数值时, 将触发数据淘汰。该数据配置为 $0$ 时,表示缓存的数据量没有限制, 即 $LRU$ 功能不生效。$64$ 位的系统默认值为 $0$,$32$ 位的系统默认内存限制为 $3GB$。maxmemory_policy:触发数据淘汰后的淘汰策略。maxmemory_samples:随机采样的精度,也就是随即取出 $key$ 的数目。该数值配置越大, 越接近于真实的 $LRU$ 算法,但是数值越大,相应消耗也变高,对性能有一定影响,样本值默认为 $5$ 。
内存淘汰策略
在 Redis 中的内存淘汰策略有:
volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最久未使用的数据淘汰;volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰;volatile-random:从已设置过期时间的数据集(server.db[i].expires)中随机选取数据淘汰;allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最久未使用的key;allkeys-random:从数据集(server.db[i].dict)中随机选取数据淘汰;no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。
volatile-xxx 这三个淘汰策略使用的不是全量数据,有可能无法淘汰出足够的内存空间。在没有过期键或者没有设置超时属性的键的情况下,这三种策略和 noeviction 差不多。
一般的经验规则:
- 使用
allkeys-lru策略:当预期请求符合一个幂次分布(二八法则等),比如一部分的子集元素比其它元素被访问的更多时,可以选择这个策略。 - 使用
allkeys-random策略:循环连续的访问所有的键时,或者预期请求分布平均(所有元素被访问的概率都差不多)。 - 使用
volatile-ttl:要采取这个策略,缓存对象的TTL值最好有差异。
volatile-lru 和 volatile-random 策略,当想要使用单一的 Redis 实例来同时实现缓存淘汰和持久化一些经常使用的键集合时很有用。
- 对未设置过期时间的键进行持久化保存,对设置了过期时间的键参与缓存淘汰。
- 不过一般运行两个实例是解决这个问题的更好方法。
为键设置过期时间也是需要消耗内存的,所以使用 allkeys-lru 这种策略更加节省空间,因为这种策略下可以不为键设置过期时间。
4.0 版本后增加以下两种:
volatile-lfu:从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰。allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的key。
近似LRU算法
$LRU$ 算法需要使用一个双向链表来记录数据的最近被访问顺序,出于节省内存的考虑,$Redis$ 的 $LRU$ 算法并未完整实现。
$Redis$ 并不会选择最久未被访问的键进行回收,而是尝试运行一个近似 $LRU$ 算法,通过对少量键进行取样,然后回收其中的最久未被访问的键。通过调整每次回收时的采样数量 maxmemory-samples,可以实现调整算法的精度。
根据 Redis 作者的说法,每个 Redis Object 可以挤出 $24 bits$ 的空间,但 $24 bits$ 是不够存储两个指针的,而够存储一个低位时间戳,Redis Object 以秒为单位存储了对象新建或者更新时的 Unix Time,也就是 LRU Clock,$24 bits$ 数据要溢出的话需要 $194$ 天,而缓存的数据更新非常频繁,已经足够了。
Redis 的键空间是放在一个哈希表中的,要从所有的键中选出一个最久未被访问的键,需要另外一个数据结构存储这些源信息。最初,Redis只是随机地选 $3$ 个 $key$,然后从中淘汰,后来算法改进到了N个key 的策略,默认是 $5$ 个。
Redis 3.0 之后又改善了算法的性能,提供了一个待淘汰候选 $key$ 的pool,里面默认有 $16$ 个 $key$,按照空闲时间排好序。更新时从 Redis 键空间随机选择 $N$ 个 $key$,分别计算它们的空闲时间 idle,$key$ 只会在 pool 不满或者空闲时间大于 pool 里最小的时,才会进入 pool,然后从 pool 中选择空闲时间最大的 $key$ 淘汰掉。