一、概述
最近最少使用(LRU)缓存是一种缓存回收算法,它按照使用顺序组织元素。在LRU中,顾名思义,最长时间未使用的元素将从该高速缓存中被逐出。在本教程中,我们将学习LRU,并学习Java中的实现。
例如,如果我们有一个容量为三个项目的缓存:
最初,该cache是空的,我们将元素8放入该高速缓存。元素9和6像以前一样被缓存。但是现在,该高速缓存容量已满,为了放入下一个元素,我们必须丢弃该高速缓存中最近最少使用的元素。
在我们用Java实现LRU缓存之前,最好先了解一下该高速缓存的一些方面:
- 所有的操作都应该按照O(1)的顺序运行
- 所有缓存操作都必须支持并发性
- 若该高速缓存已满,则添加新项必须调用LRU策略
二、LRU的结构
需要设计一个数据结构,可以在恒定时间内完成查询、排序(按时间排序)和删除元素等操作?
为了解决这个问题,我们需要深入思考一下关于LRU缓存及其特性的说法:
- 在操作系统中我们学过,LRU缓存是一种队列-如果重新访问某个元素,它将进入回收顺序的末尾。
- 由于该高速缓存的大小有限,因此该队列将具有特定的容量。每当一个新元素被引入时,它就被添加到队列的头部。当驱逐发生时,它发生在队列的尾部。
- 命中该高速缓存中的数据必须在恒定的时间内完成,这在Queue中是不可能的!所以我们需要使用Java中的hashmap结构。
- 删除最近最少使用的元素必须在常量时间内完成,这意味着对于Queue的实现,我们将使用双链表。
将键保留在Map上,以便快速访问队列中的数据。
LRU算法非常简单!如果键存在于HashMap中,则为缓存命中;不然的话就没命中。
在缓存未命中时,执行两个步骤:
- 在列表前面添加一个新元素。
- 在HashMap中添加一个新条目,并引用列表的头部。
缓存命中后执行两个步骤:
- 删除hit元素并将其添加到列表前面。
- 使用列表前面的新引用更新HashMap。
三、Java的实现
首先定义一个cache接口:
public interface Cache<K, V> {
boolean set(K key, V value);
Optional<V> get(K key);
int size();
boolean isEmpty();
void clear();
}
然后是LRUCache类:
public class LRUCache<K, V> implements Cache<K, V> {
private int size;
private Map<K, LinkedListNode<CacheElement<K,V>>> linkedListNodeMap;
private DoublyLinkedList<CacheElement<K,V>> doublyLinkedList;
public LRUCache(int size) {
this.size = size;
this.linkedListNodeMap = new HashMap<>(maxSize);
this.doublyLinkedList = new DoublyLinkedList<>();
}
}
这里使用HashMap集合来存储对LinkedListNode的所有引用,就是我们上图所见
对应的操作
put操作:
public boolean put(K key, V value) {
CacheElement<K, V> item = new CacheElement<K, V>(key, value);
LinkedListNode<CacheElement<K, V>> newNode;
if (this.linkedListNodeMap.containsKey(key)) {
LinkedListNode<CacheElement<K, V>> node = this.linkedListNodeMap.get(key);
newNode = doublyLinkedList.updateAndMoveToFront(node, item);
} else {
if (this.size() >= this.size) {
this.evictElement();
}
newNode = this.doublyLinkedList.add(item);
}
if(newNode.isEmpty()) {
return false;
}
this.linkedListNodeMap.put(key, newNode);
return true;
}
public LinkedListNode<T> updateAndMoveToFront(LinkedListNode<T> node, T newValue) {
if (node.isEmpty() || (this != (node.getListReference()))) {
return dummyNode;
}
detach(node);
add(newValue);
return head;
}
首先,我们在存储所有键/引用的linkedListNodeMap中找到键。如果键存在,则缓存命中发生,它准备从DoublyLinkedList检索CacheElement并将其移动到前面。之后,我们用一个新的引用更新linkedListNodeMap,并将其移动到列表的前面。
我们检查节点是否为空。此外,节点的引用必须与列表相同。然后,我们从列表中分离节点,并将newValue添加到列表中。
若没有键,也就是缓存未命中,我们必须将新键放入linkedListNodeMap。在此之前,我们检查列表大小。如果列表已满,我们必须从列表中删除最近使用最少的元素。
get操作:
public Optional<V> get(K key) {
LinkedListNode<CacheElement<K, V>> linkedListNode = this.linkedListNodeMap.get(key);
if(linkedListNode != null && !linkedListNode.isEmpty()) {
linkedListNodeMap.put(key, this.doublyLinkedList.moveToFront(linkedListNode));
return Optional.of(linkedListNode.getElement().getValue());
}
return Optional.empty();
}
public LinkedListNode<T> moveToFront(LinkedListNode<T> node) {
return node.isEmpty() ? dummyNode : updateAndMoveToFront(node, node.getElement());
}
首先,我们从linkedListNodeMap获取节点,然后检查它是否为null或空。其余的操作与前面的操作相同,只有moveToFront方法上的一个区别:
以上内容看懂了可以进行一个小实战:
@Test
public void addDataToCacheToTheNumberOfSize_WhenAddOneMoreData_ThenLeastRecentlyDataWillEvict(){
LRUCache<String,String> lruCache = new LRUCache<>(3);
lruCache.put("1","test1");
lruCache.put("2","test2");
lruCache.put("3","test3");
lruCache.put("4","test4");
assertFalse(lruCache.get("1").isPresent());
}
到目前为止都是单线程的环境,为了使这个容器是线程安全的,我们需要同步所有的公共方法。
可以使用可重入的读、写锁,因为它使我们在阅读和写时使用锁时具有更大的灵活性。
之后的并发以后再说,上述内容可以满足面试手撕需要了。