面试官让我设计个LRU缓存,结果...

·  阅读 4477
面试官让我设计个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算法等。

以上就是本期的全部内容,如果对你有所帮助,给小黑来个赞吧。

我是小黑,一名在互联网“苟且”的程序员

流水不争先,贵在滔滔不绝

分类:
后端
标签:
分类:
后端
标签: