LRU

LRU,最近最少使用,把数据加入一个链表中,按访问时间排序,发生淘汰的时候,把访问时间最旧的淘汰掉。

比如有数据 1,2,1,3,2
此时缓存中已有(1,2)
当3加入的时候,得把后面的2淘汰,变成(3,1)
显然
LRU对于循环出现的数据,缓存命中不高
比如,这样的数据,1,1,1,2,2,2,3,4,1,1,1,2,2,2.....
当走到3,4的时候,1,2会被淘汰掉,但是后面还有很多1,2

LRU的一个实现方法:

用一个双向链表记录访问时间,因为链表插入删除高效,时间新的在前面,旧的在后面。 用一个哈希表记录缓存(key, value),哈希查找近似O(1),发生哈希冲突时最坏O(n), 同时哈希表中得记录 (key, Node(key, value))

package algorithm.cache;

import java.util.HashMap;
import java.util.Map;

public class LRUCache<E> {


    private Map<Object, DoublyLinkedNode<E>> data;
    //容量
    private int capacity;

    private DoublyLinkedNode<E> head;
    private DoublyLinkedNode<E> tail;

    public LRUCache() {
        this(10);
    }

    public LRUCache(int capacity) {
        data = new HashMap<>(capacity, 1);
        this.capacity = capacity;
    }

    public int Size() {
        return this.data.size();
    }

    public void put(Object key, E element) {

        DoublyLinkedNode<E> newNode = new DoublyLinkedNode();
        newNode.key = key;
        newNode.element = element;

        if (this.data.containsKey(key)) {
            DoublyLinkedNode<E> oldNode = this.data.get(key);
            this.remove(key, oldNode);
        }

        if (data.size() == this.capacity) {
            removeFail();
        }

        this.add(key, newNode);
        if (data.size() == 1) {
            this.head = newNode;
            this.tail = newNode;
            this.head.next = this.tail;
            this.tail.pre = this.head;
        }


    }

    private void removeFail() {
        DoublyLinkedNode<E> item = this.tail;
        this.tail = item.pre;
        data.remove(item.key);
    }

    private void add(Object key, DoublyLinkedNode<E> item) {
        item.next = this.head;
        if (this.head != null) {
            this.head.pre = item;
        }
        this.head = item;
        data.put(key, item);
    }

    private void remove(Object key, DoublyLinkedNode<E> item) {

        if (item.equals(this.tail)){
            this.tail = item.pre;
        }

        if (item.pre != null) {
            item.pre.next = item.next;
        }
        if (item.next != null) {
            item.next.pre = item.pre;
        }


        data.remove(key);
    }

    public E get(Object key) {
        if (!data.containsKey(key)) {
            return null;
        }
        DoublyLinkedNode<E> item = data.get(key);
        this.remove(key, item);
        this.add(key, item);
        return item.element;
    }

    private class DoublyLinkedNode<E> {
        Object key;
        E element;
        DoublyLinkedNode pre;
        DoublyLinkedNode next;
    }

    public static void main(String[] args) {
        LRUCache<Integer> cache = new LRUCache<>(2);
        cache.put(1, 1);
        cache.put(2, 2);
        System.out.println("get 1 = " + cache.get(1));
        System.out.println("add 3");
        cache.put(3, 3);    // 该操作会使得关键字 2 作废
        System.out.println("get 2 = " + cache.get(2));
        System.out.println("add 4");
        cache.put(4, 4);    // 该操作会使得关键字 1 作废
        System.out.println("get 3 = " + cache.get(3));
        System.out.println("get 4 = " + cache.get(4));
        System.out.println("get 1 = " + cache.get(1));

    }

}

get 1 = 1
add 3
get 2 = null
add 4
get 3 = 3
get 4 = 4
get 1 = null

java官方实现 LinkedHashMap

LinkedHashMap底层就是用的HashMap加双链表实现的,而且本身已经实现了按照访问顺序的存储。 此外,LinkedHashMap中本身就实现了一个方法removeEldestEntry用于判断是否需要移除最不常读取的数, 方法默认是直接返回false,不会移除元素,所以需要重写该方法。即当缓存满后就移除最不常用的数。

public class LRU<K,V> {

  private static final float hashLoadFactory = 0.75f;
  private LinkedHashMap<K,V> map;
  private int cacheSize;

  public LRU(int cacheSize) {
    this.cacheSize = cacheSize;
    int capacity = (int)Math.ceil(cacheSize / hashLoadFactory) + 1;
    map = new LinkedHashMap<K,V>(capacity, hashLoadFactory, true){
      private static final long serialVersionUID = 1;

      @Override
      protected boolean removeEldestEntry(Map.Entry eldest) {
        return size() > LRU.this.cacheSize;
      }
    };
  }

  public synchronized V get(K key) {
    return map.get(key);
  }

  public synchronized void put(K key, V value) {
    map.put(key, value);
  }

  public synchronized void clear() {
    map.clear();
  }

  public synchronized int usedSize() {
    return map.size();
  }

  public void print() {
    for (Map.Entry<K, V> entry : map.entrySet()) {
      System.out.print(entry.getValue() + "--");
    }
    System.out.println();
  }
}

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

扩展

LRU-K

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

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

two queue

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

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

Multi Queue(MQ)

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

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

MQ需要维护多个队列,且需要维护每个数据的访问时间,复杂度比LRU高。

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

实现带有过期时间的LRU

 // 构造方法:只要有缓存了,过期清除线程就开始工作
    public LRU() {
        swapExpiredPool.scheduleWithFixedDelay(new ExpiredNode(), 3,3,TimeUnit.SECONDS);
    }
public class ExpiredNode implements Runnable {
        @Override
        public void run() {
            // 第一步:获取当前的时间
            long now = System.currentTimeMillis();
            while (true) {
                // 第二步:从过期队列弹出队首元素,如果不存在,或者不过期就返回
                Node node = expireQueue.peek();
                if (node == null || node.expireTime > now)return;
                // 第三步:过期了那就从缓存中删除,并且还要从队列弹出
                cache.remove(node.key);
                expireQueue.poll();
            }// 此过程为while(true),一直进行判断和删除操作
        }
    }