概述

LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。
注:这里其实为了不走进误区,这里其实最近是个关键词。

图解

LRU算法 - 图1

  1. 新数据插入到链表头部;
  2. 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
  3. 当链表满的时候,将链表尾部的数据丢弃。

    实现LRU

  4. 用一个数组来存储数据,给每一个数据项标记一个访问时间戳,每次插入新数据项的时候,先把数组中存在的数据项的时间戳自增,并将新数据项的时间戳置为0并插入到数组中。每次访问数组中的数据项的时候,将被访问的数据项的时间戳置为0。当数组空间已满时,将时间戳最大的数据项淘汰。

2.利用一个链表来实现,每次新插入数据的时候将新数据插到链表的头部;每次缓存命中(即数据被访问),则将数据移到链表头部;那么当链表满的时候,就将链表尾部的数据丢弃。

  1. 利用链表和hashmap。当需要插入新的数据项的时候,如果新数据项在链表中存在(一般称为命中),则把该节点移到链表头部,如果不存在,则新建一个节点,放到链表头部,若缓存满了,则把链表最后一个节点删除即可。在访问数据的时候,如果数据项在链表中存在,则把该节点移到链表头部,否则返回-1。这样一来在链表尾部的节点就是最近最久未访问的数据项。

对于第一种方法, 需要不停地维护数据项的访问时间戳,另外,在插入数据、删除数据以及访问数据时,时间复杂度都是O(n)。对于第二种方法,链表在定位数据的时候时间复杂度为O(n)。所以在一般使用第三种方式来是实现LRU算法。

通过JDK数据结构简单实现一个 Java 版的 LRU

  1. public class LruCache<K, V> extends LinkedHashMap<K, V> {
  2. /**
  3. * 缓存大小
  4. */
  5. private final int cacheSize;
  6. /**
  7. * 传递进来最多能缓存多少数据
  8. *
  9. * @param cacheSize 缓存大小
  10. */
  11. public LruCache(int cacheSize) {
  12. // true 表示让 linkedHashMap 按照访问顺序来进行排序,最近访问的放在头部,最老访问的放在尾部。
  13. super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true);
  14. this.cacheSize = cacheSize;
  15. }
  16. @Override
  17. protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
  18. // 当 map中的数据量大于指定的缓存个数的时候,就自动删除最老的数据。
  19. return size() > cacheSize;
  20. }
  21. }

简单实现带过期时间段的LRU

LRU其实还可以再优化,用过redis的都知道可以过期时间,在LRUCache数据结构里面设置TTL过期时间也是可以的,比如:

  1. public class LruCache<K, V> extends LinkedHashMap<K, V> {
  2. /**
  3. * 缓存大小
  4. */
  5. private final int cacheSize;
  6. /**
  7. * 负责存储过期时间
  8. */
  9. private Map<K, Long> expireMap = new ConcurrentHashMap<>();
  10. /**
  11. * 过期扫描周期
  12. */
  13. private final int expireCycle;
  14. /**
  15. * 用于淘汰缓存的线程池
  16. */
  17. private ExecutorService executor;
  18. /**
  19. * 传递进来最多能缓存多少数据
  20. *
  21. * @param cacheSize 缓存大小
  22. */
  23. public LruCache(int cacheSize) {
  24. this(cacheSize,-1);
  25. }
  26. /**
  27. * 传递进来最多能缓存多少数据
  28. *
  29. * @param cacheSize 缓存大小
  30. * @param expireCycle 缓存扫描周期
  31. */
  32. public LruCache(int cacheSize,int expireCycle) {
  33. // true 表示让 linkedHashMap 按照访问顺序来进行排序,最近访问的放在头部,最老访问的放在尾部。
  34. super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true);
  35. this.cacheSize = cacheSize;
  36. this.expireCycle = expireCycle;
  37. this.initTTL();
  38. }
  39. private void initTTL(){
  40. if(this.expireCycle != -1){
  41. executor = Executors.newSingleThreadExecutor();
  42. executor.execute(this::run);
  43. }
  44. }
  45. public V put(K key, V value, Long expire) {
  46. long now = System.currentTimeMillis();
  47. expireMap.put(key, now + expire);
  48. return super.put(key, value);
  49. }
  50. @Override
  51. protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
  52. // 当 map中的数据量大于指定的缓存个数的时候,就自动删除最老的数据。
  53. return size() > cacheSize;
  54. }
  55. @SneakyThrows
  56. private void run() {
  57. while (true) {
  58. for (Map.Entry<K, Long> kvEntry : expireMap.entrySet()) {
  59. long now = System.currentTimeMillis();
  60. if (now >= kvEntry.getValue()) {
  61. this.remove(kvEntry.getKey());
  62. expireMap.remove(kvEntry.getKey());
  63. }
  64. }
  65. Thread.sleep(expireCycle);
  66. }
  67. }
  68. }

弊端

当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。

扩展

1.LRU-K

LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。
相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。

数据第一次被访问时,加入到历史访问列表,如果书籍在访问历史列表中没有达到K次访问,则按照一定的规则(FIFO,LRU)淘汰;当访问历史队列中的数据访问次数达到K次后,将数据索引从历史队列中删除,将数据移到缓存队列中,并缓存数据,缓存队列重新按照时间排序;缓存数据队列中被再次访问后,重新排序,需要淘汰数据时,淘汰缓存队列中排在末尾的数据,即“淘汰倒数K次访问离现在最久的数据”。
LRU-K具有LRU的优点,同时还能避免LRU的缺点,实际应用中LRU-K是综合最优的选择。由于LRU-K还需要记录那些被访问过、但还没有放入缓存的对象,因此内存消耗会比LRU要多。
LRU-K的详细内容见LRU-K详解

2.Two queues

Two queues(以下使用2Q代替)算法类似于LRU-K,不同点在于2Q将LRU-K算法中的访问历史队列(注意这不是缓存数据的)改为一个FIFO缓存队列,即:2Q算法有两个缓存队列,一个是FIFO队列,一个是LRU队列。 当数据第一次访问时,2Q算法将数据缓存在FIFO队列里面,当数据第二次被访问时,则将数据从FIFO队列移到LRU队列里面,两个队列各自按照自己的方法淘汰数据。

新访问的数据插入到FIFO队列中,如果数据在FIFO队列中一直没有被再次访问,则最终按照FIFO规则淘汰;如果数据在FIFO队列中再次被访问到,则将数据移到LRU队列头部,如果数据在LRU队列中再次被访问,则将数据移动LRU队列头部,LRU队列淘汰末尾的数据。
2Q的详细内容见Two queues详解

3.Multi Queue(MQ)

  1. MQ算法根据访问频率将数据划分为多个队列,不同的队列具有不同的访问优先级,其核心思想是:优先缓存访问次数多的数据。 详细的算法结构图如下,Q0Q1....Qk代表不同的优先级队列,Q-history代表从缓存中淘汰数据,但记录了数据的索引和引用次数的队列:

新插入的数据放入Q0,每个队列按照LRU进行管理,当数据的访问次数达到一定次数,需要提升优先级时,将数据从当前队列中删除,加入到高一级队列的头部;为了防止高优先级数据永远不会被淘汰,当数据在指定的时间里没有被访问时,需要降低优先级,将数据从当前队列删除,加入到低一级的队列头部;需要淘汰数据时,从最低一级队列开始按照LRU淘汰,每个队列淘汰数据时,将数据从缓存中删除,将数据索引加入Q-history头部。如果数据在Q-history中被重新访问,则重新计算其优先级,移到目标队列头部。 Q-history按照LRU淘汰数据的索引。

MQ需要维护多个队列,且需要维护每个数据的访问时间,复杂度比LRU高。
Multi Queue的详细内容见Multi Queue详解

LRU算法对比

对比点 对比
命中率 LRU-K > MQ(2) > 2Q > LRU
复杂度 LRU-K > MQ(2) > 2Q > LRU
代价 LRU-K > MQ(2) > 2Q > LRU