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),一直进行判断和删除操作 } }