面试官让我设计个LRU缓存,结果...
小黑有个朋友最近去面试,过程中有问他一些缓存相关的问题。
让他回答一下,设计一个LRU缓存,应该怎么实现
我这个朋友呢,应该是没好好准备这块儿内容,反正是没咋答上来,于是。。。就让他回家等通知了。
今天小黑就带大家来聊一聊LRU算法,并动手写一个LRU缓存。
缓存淘汰策略是啥
在我们平时开发中,经常会使用到缓存,比如一些热点商品,配置数据等,为了提高访问速度都会放到缓存中,但是,往往缓存的容量是有限的,我们不能将所有数据都放在缓存中,需要给缓存设定一个容量,当容量放满之后,要有新的数据放入缓存时,需要按照一定的策略将原来缓存中的数据淘汰掉,那么这个策略就叫做缓存淘汰策略。
缓存淘汰策略有很多种选择,常见的有FIFO(first input first output),LRU(Least Recently Used),LFU(Least Frequently Used)等。
LRU是啥
LRU是Least Recently Used的缩写,意思是最近最少使用,也就是说将最近使用最少的数据淘汰掉。
例如,我们有如下一个缓存结构:
最开始缓存时空的,我们分别往缓存中放入了5,6,9三个元素,接着在放元素3时,缓存空间已经使用完了,这是我们需要淘汰掉一个元素,释放出空间放心的元素,按照LRU算法的逻辑,此时缓存中最近最少使用的元素是5,所以将5淘汰掉,放入元素3。
接下来我们来想想如何实现这样一个LRU缓存结构,在开始写代码之前,我们要先明确我们这个缓存需要满足的一个条件。
- 该缓存的容量要有限制
- 在缓存容量使用完时,再添加新元素时必须使用LRU算法淘汰元素
- 添加元素,查询元素操作的时间复杂度都应该是O(1)
- 对缓存的操作要支持并发
因为有上面的一些要求,我们先来思考下面这个问题。
如何保证所有操作的时间复杂度都是O(1)呢?
要找到这个问题的答案,我们还得再深入思考一下LRU缓存的特点。
首先,按照开头我们图片看,LRU缓存应该是一个队列结构,如果一个元素被重新访问,那么这个元素要重新放到队列的头部;
然后呢,我们的队列有容量限制,每当有新元素要添加进缓存时,都把它添加到队列的头部;有元素要淘汰时,都从队列尾部删除;
这样可以保证添加和淘汰元素的时间复杂度都是O(1),那么我们如果想从缓存中查询元素时,该怎么办呢?
你是不是想到了将队列遍历一遍,找到符合的元素?很显然这样是能找到元素,但是时间复杂度是O(n)的,并且当我们缓存中数据量如果很多时,查询元素的时间是不固定的,可能会很快,可能会特别慢。
如果单纯使用队列的话,是做不到查询操作的时间复杂度为O(1)的。
怎样可以让查询的时间复杂度变成O(1)呢?
队列不可以,但是HashMap可以呀。
但是问题又来了,如果仅使用HashMap,虽然可以让查询时间复杂度变为O(1),但是淘汰元素时,就没办法用O(1)的时间复杂度删除了。
我们可以使用HashMap+链表的组合方式,来完成LRU缓存的结构。
以上结构,我们可以把要缓存数据的key做为HashMap的key,这样保证查询元素时能快速查到数据;
通过双向链表,可以保证在添加新元素和淘汰元素时从头节点和尾节点操作,可能在O(1)时间内完成。
现在是不是思路变得非常清晰了!
好的,我们现在来写一下实现思路:
首先,如果HashMap中包含key,则缓存命中,获取元素;如果不包含,则表示缓存未命中。
如果缓存命中:
- 从链表中移除该元素,将元素添加到链表头部;
- 将头部节点作为value保存到HashMap中;
如果未命中:
- 添加该元素到链表头部;
- 将链表头部节点保存在HashMap中。
- 嗯,到这里我们就可以写代码了。
代码实现
首先我们定义一个Cache接口,在该结构中定义Cache有哪些方法:
/**
* @author 小黑说Java
* @ClassName Cache
* @Description
* @date 2022/1/13
**/
public interface Cache<K, V> {
boolean put(K key, V value);
Optional<V> get(K key);
int size();
boolean isEmpty();
void clear();
}
接下来,我们来实现我们的LRUCache类,该类实现Cache接口:
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 ConcurrentHashMap<>(size);
this.doublyLinkedList = new DoublyLinkedList<>();
}
// ...其他方法
}
首先我们在LRUCache中定义了一个Map和我们自定义的双向链表DoublyLinkedList,在构造方法中进行初始化。
接下来实现具体操作的方法。
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)) {
// 从map中取出
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;
}
// 将链表节点放入map中
this.linkedListNodeMap.put(key, newNode);
return true;
}
首先判断Map中是否存在,如果存在表示缓存命中,将数据更新到链表头部,反之则表示没命中,需要添加新节点到缓存中,判断是否需要执行淘汰策略,最后将新元素放入Map中。
在updateAndMoveToFront()
方法中将链表中的节点更新到最前面,代码如下:
public LinkedListNode<T> updateAndMoveToFront(LinkedListNode<T> node, T newValue){
// 节点不为空,并且该节点必须是在当前链表下
if (node.isEmpty() || (this != (node.getListReference()))) {
return dummyNode;
}
// 将原节点从链表中分离
detach(node);
// 新节点加入链表中
add(newValue);
return head;
}
在执行淘汰策略时执行evictElement()
方法如下:
private boolean evictElement(){
// 移除尾节点
LinkedListNode<CacheElement<K, V>> linkedListNode = doublyLinkedList.removeTail();
if (linkedListNode.isEmpty()) {
return false;
}
linkedListNodeMap.remove(linkedListNode.getElement().getKey());
return true;
}
get操作
public Optional<V> get(K key){
LinkedListNode<CacheElement<K, V>> linkedListNode = this.linkedListNodeMap.get(key);
if(linkedListNode != null && !linkedListNode.isEmpty()) {
// 将命中节点移动到链表的头部,然后放入到Map中
linkedListNodeMap.put(key, this.doublyLinkedList.moveToFront(linkedListNode));
return Optional.of(linkedListNode.getElement().getValue());
}
return Optional.empty();
}
get操作很简单,先判断节点是不是存在或不为空,如果存在则将节点移动到链表的最前面,然后重新放入Map中。和put操作的一点区别是这里使用的是moveToFront()
方法:
public LinkedListNode<T> moveToFront(LinkedListNode<T> node){
return node.isEmpty() ? dummyNode : updateAndMoveToFront(node, node.getElement());
}
到这里我们缓存的基本功能完成了。但是有没有问题呢?
是有问题的,因为我们没有考虑并发场景,要让我们的LRUCache线程安全,需要让所有的操作支持同步。
实现同步可以使用synchronized或者Lock,由于缓存的使用场景中,读取和读取之间不存在并发问题,只有读写之间才需要同步,而synchronized并不支持读写锁,所以我们可以选择使用ReentrantReadWriteLock。
public class LRUCache<K, V> implements Cache<K, V> {
private int size;
private final Map<K, LinkedListNode<CacheElement<K,V>>> linkedListNodeMap;
private final DoublyLinkedList<CacheElement<K,V>> doublyLinkedList;
// 定义一个锁
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public LRUCache(int size){
this.size = size;
this.linkedListNodeMap = new ConcurrentHashMap<>(size);
this.doublyLinkedList = new DoublyLinkedList<>();
}
// ...
}
写锁
在我们的LRUCache中,需要添加写锁的操作有put(),和evictElement()。
public boolean put(K key, V value){
// 加锁
this.lock.writeLock().lock();
try {
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;
} finally {
// 解锁
this.lock.writeLock().unlock();
}
}
evictElement:
private boolean evictElement(){
// 加锁
this.lock.writeLock().lock();
try {
LinkedListNode<CacheElement<K, V>> linkedListNode = doublyLinkedList.removeTail();
if (linkedListNode.isEmpty()) {
return false;
}
linkedListNodeMap.remove(linkedListNode.getElement().getKey());
return true;
} finally {
// 解锁
this.lock.writeLock().unlock();
}
}
读锁
在读取数据时,我们同样要添加读锁。
public Optional<V> get(K key){
// 添加读锁
this.lock.readLock().lock();
try {
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();
} finally {
// 解锁
this.lock.readLock().unlock();
}
}
现在LRUCache则可以支持并发使用。
小结
LRU算法是比较常用的一种淘汰算法,在存在热点数据时,效率很好,但是LRU算法也存在一些缺点,比如,如果偶尔进行一些批量数据操作,将一些并不热门的数据存入缓存,会把热门数据淘汰掉,导致效率下降。这种情况被称为缓存污染。
解决缓存污染问题可以使用LRU的扩展算法LRU-K,还有另一个比较常用的LFU算法等。
以上就是本期的全部内容,如果对你有所帮助,给小黑来个赞吧。