基于基础结构实现LRU缓存淘汰策略
1)什么是缓存?
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等等。
2)为什么使用缓存?即缓存的特点
缓存的大小是有限的,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?就需要用到缓存淘汰策略。
3)什么是缓存淘汰策略?
指的是当缓存被用满时清理数据的优先顺序。
4)有哪些缓存淘汰策略?
常见的3种包括先进先出策略FIFO(First In,First Out)、最少使用策略LFU(Least Frenquently Used)、最近最少使用策略LRU(Least Recently Used)。
5)链表实现LRU缓存淘汰策略
当访问的数据没有存储在缓存的链表中时,直接将数据插入链表表头,时间复杂度为O(1);当访问的数据存在于存储的链表中时,将该数据对应的节点,插入到链表表头,时间复杂度为O(n)。如果缓存被占满,则从链表尾部的数据开始清理,时间复杂度为O(1)。
6)数组实现LRU缓存淘汰策略
方式一:首位置保存最新访问数据,末尾位置优先清理
当访问的数据未存在于缓存的数组中时,直接将数据插入数组第一个元素位置,此时数组所有元素需要向后移动1个位置,时间复杂度为O(n);当访问的数据存在于缓存的数组中时,查找到数据并将其插入数组的第一个位置,此时亦需移动数组元素,时间复杂度为O(n)。缓存用满时,则清理掉末尾的数据,时间复杂度为O(1)。
方式二:首位置优先清理,末尾位置保存最新访问数据
当访问的数据未存在于缓存的数组中时,直接将数据添加进数组作为当前最有一个元素时间复杂度为O(1);当访问的数据存在于缓存的数组中时,查找到数据并将其插入当前数组最后一个元素的位置,此时亦需移动数组元素,时间复杂度为O(n)。缓存用满时,则清理掉数组首位置的元素,且剩余数组元素需整体前移一位,时间复杂度为O(n)。(优化:清理的时候可以考虑一次性清理一定数量,从而降低清理次数,提高性能。)
双向链表
双向链表每个结点除了存储数据外,还有两个指针分别指向前一个节点地址(前驱指针prev)和下一个节点地址(后继指针next)。
首节点的前驱指针prev和尾节点的后继指针均指向空地址。
由于存储两个指针,所以和单链表相比,存储相同的数据,需要消耗更多的存储空间。
插入、删除操作比单链表效率更高O(1)级别。以删除操作为例,给定数据值删除对应节点,单链表和双向链表都需要从头到尾进行遍历从而找到对应节点进行删除,时间复杂度为O(n)。给定节点地址删除节点,此操作必须找到前驱节点,单链表需要从头到尾进行遍历直到p->next = q,时间复杂度为O(n),而双向链表可以直接找到前驱节点,时间复杂度为O(1)。
而对于一个有序链表,双向链表的按值查询效率要比单链表高一些。因为我们可以记录上次查找的位置p,每一次查询时,根据要查找的值与p的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。
LinkedHashMap
LinkedHashMap而言,它继承HashMap,数组+链表/红黑树的结构。
LinkedHashMap复写了HashMap.Node,增加了before, after,实现了双向链表结构。
static class LinkedHashMapEntry<K,V> extends HashMap.Node<K,V> {
LinkedHashMapEntry<K,V> before, after;
LinkedHashMapEntry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
1、LinkedHashMap初始化
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
//排序模式,Lru中为true
this.accessOrder = accessOrder;
}
accessOrder表示是否记录访问顺序,true时按访问顺序遍历,false时按插入顺序遍历。
双向链表,头结点head,通过head开始遍历,通过after属性可以不断找到下一个,直到tail尾结点,从而实现顺序性。
2、put方法
LinkedHashMap并未重写父类HashMap的put方法,而是重写了父类HashMap的put方法调用的子方法newNode、replacementNode、newTreeNode、replacementTreeNode等方法,提供了自己特有的双向链接列表的实现。
两者创建Node比较
//HashMap中newNode
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}
//LinkedHashMap中newNode
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMapEntry<K,V> p = new LinkedHashMapEntry<K,V>(hash, key, value, e);
linkNodeLast(p);
return p;
}
linkNodeLast方法
private void linkNodeLast(LinkedHashMapEntry<K,V> p) {
LinkedHashMapEntry<K,V> last = tail;
tail = p;
if (last == null)
//保存最后访问的为tail。
head = p;
else {
p.before = last;
last.after = p;
}
}
其他的方法与之类似,put重点是复写HashMap的一些小方法取实现双向链表的目的。
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
LinkedHashMapEntry<K,V> q = (LinkedHashMapEntry<K,V>)p;
LinkedHashMapEntry<K,V> t = new LinkedHashMapEntry<K,V>(q.hash, q.key, q.value, next);
transferLinks(q, t);
return t;
}
private void transferLinks(LinkedHashMapEntry<K,V> src,
LinkedHashMapEntry<K,V> dst) {
LinkedHashMapEntry<K,V> b = dst.before = src.before;
LinkedHashMapEntry<K,V> a = dst.after = src.after;
if (b == null)
head = dst;
else
b.after = dst;
if (a == null)
//也赋值给tail
tail = dst;
else
a.before = dst;
}
put方法不用说,肯定是将新插入的元素放在双向链表的最后位置。
3、get方法
LinkedHashMap重写了父类HashMap的get方法,实际在调用父类getEntry()方法取得查找的元素后,再由当前的排序模式,记录访问顺序,看是否将这个节点添加到双向链表的表头,并从原来的位置删除。
public V get(Object key) {
Node<K,V> e;
//调用父类HashMap的getNode()方法,取得要查找的元素
if ((e = getNode(hash(key), key)) == null)
return null;
//判断排序模式
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
排序模式
void afterNodeAccess(Node<K,V> e) {
//上一次的尾结点
LinkedHashMapEntry<K,V> last;
//当accessOrder为true并且传入的节点并不是上一次的尾结点时
if (accessOrder && (last = tail) != e) {
//p:当前节点
//b:当前节点的前一个节点
//a:当前节点的后一个节点;
LinkedHashMapEntry<K,V> p = (LinkedHashMapEntry<K,V>)e, b = p.before, a = p.after;
//将p.after设置为null,断开了与后一个节点的关系,但还未确定其位置
p.after = null;
//因为将当前节点p拿掉了,那么节点b和a之间断开了连接,我们先将a、b两节点建立连接(站在b的角度)
//当前节点的前一个节点b为null,表示当前节点p就是头节点,节点拿掉后,p的后一个节点a就成为了头节点
//否则,将p的前一个节点b的后指针指向p的后一个节点a,b中将b、a联系起来
if (b == null)
head = a;
else
b.after = a;
//站在节点a的角度建立与节点b关联
//如果节点a为null,表示当前节点p为尾结点,节点p拿掉后,p的前一个节点b为尾结点,赋值给局部节点last
//否则当前节点的后一个节点a的前指针指向p的前一个节点b
//这里已经判断了(last = tail) != e,说明传入的节点并不是尾结点,说明e.after即a必然不为null。这里我认为是代码好读,last就是当前最后节点,不论a、b;有些博客说的放置反射机制破坏封装
if (a != null)
a.before = b;
else
last = b;
//前面设置了p后指针已经设置为null
//last为null,链表没数据,将链表头节点直接给p
//last不为null,b-a,链,p的前指针指向last,p在last后;last的尾结点设置为p,last即b都赋值
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
//节点p设置为尾结点
tail = p;
++modCount;
}
}
这段代码有些复杂,都在代码中标注了,包含了一些我个人的理解。
get方法中如果accessOrder是true的话,它会和put一样同样是将最近访问的元素放在末尾。
4、remove方法
LinkedHashMap中没有重写remove方法,它调用HashMap的remove方法。
传递的参数key计算出hash,据此可找到对应的Node节点,接下来如果该Node节点是直接在数组中的Node,则将table数组该位置的元素设置为node.next;如果是链表中的,则遍历链表,直到找到对应的node节点,然后建立该节点的上一个节点的next设置为该节点的next;树类似。
LinkedHashMap重写了其中的afterNodeRemoval方法。
void afterNodeRemoval(Node<K,V> e) { // unlink
//p为删除节点
//b 删除节点的前一个节点
//a 删除节点的后一个节点
LinkedHashMapEntry<K,V> p = (LinkedHashMapEntry<K,V>)e, b = p.before, a = p.after;
//p去掉了
p.before = p.after = null;
//前一个节点为null的话,删除节点就是头节点呀!头节点删除了,后一个节点a就是头节点
if (b == null)
head = a;
else
//将b的后一个节点指向删除节点的后一个节点
b.after = a;
//删除节点的后一个节点,即a不存在的话,最后的节点就是b
if (a == null)
tail = b;
else
//否则,把a的前一个节点设置为b
a.before = b;
//也是从a、b两个角度,分别把他们的前后节点赋值
}
LruCache
Lru的实现很多,但基本是以LinkedHashMap实现的,其中accessOrder为true。
这边看下Glide中的LruCache的实现
初始化
private final Map<T, Y> cache = new LinkedHashMap<>(100, 0.75f, true);
public LruCache(long size) {
this.initialMaxSize = size;
this.maxSize = size;
}
put就是将数据存储到LinkedHashMap中,其中计算出当前map的大小。
@Nullable
public synchronized Y put(@NonNull T key, @Nullable Y item) {
//这里基本实现单独将item设置为1
final int itemSize = getSize(item);
//父类不处理
if (itemSize >= maxSize) {
onItemEvicted(key, item);
return null;
}
//判断
if (item != null) {
//+1
currentSize += itemSize;
}
//这里由cache.put()的返回值判断添加还是修改
//如果已经存在一个相同的key,该key的新value覆盖旧value,同时返回的是前一个key对应的value,此时size减少一次
//如果是新的一个key,则返回的是null,之前的增加一次不用做变化
@Nullable final Y old = cache.put(key, item);
if (old != null) {
currentSize -= getSize(old);
//不处理
if (!old.equals(item)) {
onItemEvicted(key, old);
}
}
evict();
return old;
}
protected int getSize(@Nullable Y item) {
return 1;
}
evict中只有trimToSize方法
private void evict() {
trimToSize(maxSize);
}
protected synchronized void trimToSize(long size) {
Map.Entry<T, Y> last;
Iterator<Map.Entry<T, Y>> cacheIterator;
//判断当前缓存是否超过最大值
while (currentSize > size) {
//超过
//去除头节点
cacheIterator = cache.entrySet().iterator();
//头节点的下一个节点
last = cacheIterator.next();
final Y toRemove = last.getValue();
//此处还是1
currentSize -= getSize(toRemove);
final T key = last.getKey();
//移除头节点的下一个节点
cacheIterator.remove();
onItemEvicted(key, toRemove);
}
}
protected void onItemEvicted(@NonNull T key, @Nullable Y item) {
// optional override
}
在添加过缓存对象后,来判断缓存是否已满,如果满了就要删除近期最少使用的算法。
其中map.entrySet()看下
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new LinkedEntrySet()) : es;
}
LinkedEntrySet的iterator()返回LinkedEntryIterator,它的初始化在父类LinkedHashIterator中
public final Iterator<Map.Entry<K,V>> iterator() {
return new LinkedEntryIterator();
}
//LinkedEntryIterator 继承LinkedHashIterator
final class LinkedEntryIterator extends LinkedHashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
//初始化,将头节点赋给next
LinkedHashIterator() {
next = head;
expectedModCount = modCount;
current = null;
}
LinkedEntryIterator的nextNode()
final LinkedHashMapEntry<K,V> nextNode() {
LinkedHashMapEntry<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
current = e;
next = e.after;
return e;
}
遍历时候和前文说的一致,先返回头节点。
public final boolean remove(Object o) {
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Object value = e.getValue();
return removeNode(hash(key), key, value, true, true) != null;
}
return false;
}
remove调用父类HashMap的remove方法,不多介绍了。
结合LinkedHashMap的accessOrder属性,put后将数据方在链表尾端,多次put后,从头到尾就是加入的时候顺序。当超过缓存大小是,trimToSize中判断条件程数,取出最远时间的头节点并移除。
get方法去除一些判断,很简单。map.get()自然将会数据更新队列,保持整个队列是按照访问顺序排序。
@Nullable
public synchronized Y get(@NonNull T key) {
return cache.get(key);
}
这就是从链表、LinkedHashMap、LruCache依次去理解一个lru缓存原理。