目录
前言
LRU(Least Recently Used )即最近最少使用,是一种置换算法,也就是说在需要的时候,把最近最少使用的元素移除。
LRU缓存就是一种基于LRU思想的缓存技术,即在向缓存中加入元素的时候,如果给定的缓存空间已满,那么就把缓存中最近最少使用的元素移除,把新元素放入进去。比如你书桌上放了一摞书,每买一本都放上边,你会偶尔抽出其中一本翻阅,看完后,就把它放到最上边。当你觉得书摞得太高了,就选择把最底层的书抽出来放到一边,因为最底层的是你最近最久未看的。
网上对用LinkedHashMap实现LRU缓存算法的博客有很多,在这篇文章的最后,我也会基于LinkedHashMap实现一个简单的LRU缓存。如果单看实现的话很简单,但如果只是会实现总会感觉不踏实,其实这里边有很多值得学习的地方。这里我会先从LinkedHashMap的源码入手,通过源码分析它是如何在HashMap的基础上实现Linked的。如果只想看实现可以直接从目录跳转过去。
源码分析
HashMap
要说LinkedHashMap就绕不过HashMap,因为从源码中(甚至是命名中)可以看到,LinkedHashMap是继承自HashMap的。对HashMap有过了解的人都知道,它先是用自己的内部结点类来封装键值对数据,然后使用一个数组来保存对应的键值对(key-value)结点。
// HashMap内部结点类,实现了Map.Entry接口
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
// 注意,这个next并不是维护插入顺序的!
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
// ...省略一些方法
}
// 保存结点的数组
transient Node<K,V>[] table;
HashMap顾名思义,是通过hash函数计算来直接把数据映射到数组上的,它没有链接(Linked)。要实现链接,其实想法也很简单,比如说,如果按照插入顺序维护一个单向链表,那么只需要给Node类添加一个指向后继结点的 next
引用(这里不深究字面含义,理解为指针、引用都可),并且在每次插入(put
)的时候,把新结点挂载到链表后边;删除(remove
)的时候,把结点从链表中删除即可。
注意:源码中可以看到其实HashMap.Node其实有一个next字段,但这个字段和维护插入顺序没关系。由于HashMap是用拉链法解决hash冲突的,这个next是用于当结点发生冲突时,维护拉链那个链的,这里可以忽略。
LinkedHashMap
按照上边的猜想,去看LinkedHashMap的源码
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
/**
* The head (eldest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> head;
/**
* The tail (youngest) of the doubly linked list.
*/
transient LinkedHashMap.Entry<K,V> tail;
/**
* The iteration ordering method for this linked hash map: <tt>true</tt>
* for access-order, <tt>false</tt> for insertion-order.
*
* @serial
*/
final boolean accessOrder;
可以看到LinkedHashMap确实有个自己的Entry内部类,并继承自HashMap.Node。这个类也很简单,只是比HashMap.Node多了两个字段:before
、after
,顾名思义,这是维护链表顺序的,并且可以得知LinkedHashMap维护的是双向链表,并且还有两个head
、tail
字段,标记双向链表的头和尾。此外还有个accessOrder
属性,说明LinkedHashMap还可以选择维护元素的访问顺序,这就是和LRU挂上钩了。
但是当我们想去看LinkedHashMap是如何在put
的时候维护链表的,会发现它压根没重写任何 put
方法,所以在使用它的时候,其实调用的还是HashMap的put
,那么看HashMap的put
方法:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // (1)
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // (2)
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict); // (3)
return null;
}
暂时不需要了解 put
的实现细节,只关注标注释的(1)、(2)、(3)。
维护插入顺序
查看(1)处: 这里调用newNode
将输入的键值对封装成了内部的HashMap.Node对象,并放到数组中。但是注意,LinkedHashMap重写了newNode
方法。查看其实现:
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
linkNodeLast(p);
return p;
}
它这里是调用了自身的LinkedHashMap.Entry的构造函数,来构造结点。所以此时结点已经带有了before
、after
字段。并且在构建好结点后,执行了linkNodeLast
方法。
// link at the end of list
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
}
即把结点链接到双向链表的尾部。所以在使用LinkedHashMap进行put
的时候,会调用父类的put
方法,但是在新建结点的时候,会调用它重写的newNode
方法,对结点偷梁换柱。由于LinkedHashMap.Entry extends HashMap.Node,所以这个子类结点是可以直接放到父类的结点数组中的。只重写了这个方法,就实现了在插入时候维护结点插入顺序。妙啊。
维护访问顺序
查看(2)处:这里调用afterNodeAccess
方法,顾名思义,这里是访问完结点后的回调,肯定是用来维护结点访问顺序的。查看其定义:
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
这里有一堆三个空方法,包括注释(3)处的。这里注释说是为了让LinkedHashMap执行回调操作。这也可以理解了,因为HashMap不需要维护链接顺序,所以它自己就放个空方法在这了。当子类实现这些方法的时候,就会去调用子类的。这点和newNode
类似,都是基于 Java 多态的设计。
查看子类 afterNodeAccess
方法:
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
这里看着乱糟糟的一堆,其实就是把刚访问过的入参结点p
,插入到双向链表的尾部,以示最近访问。并且这里还使用了accessOrder
字段,只有在这个字段设置为 true
的时候,即需要维护访问顺序的时候才执行。
那有插入就有删除,类似地,也就可以理解 afterNodeRemoval
方法的实现了,把待删除的结点从链表去删除即可。
现在LinkedHashMap能可选择地维护插入顺序和访问顺序了,那么怎么实现LRU呢?
移除最老的结点
查看(3)处的 afterNodeInsertion
方法在LinkedHashMap中的实现。
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
removeNode(hash(key), key, null, false, true);
}
}
这个方法会在满足条件下,删除双向链表的第一个结点,也就是最老的结点(因为之前分析新访问过的和新插入的结点是在尾部)。这里先暂时忽略evict
,可以认为它在使用中是 true
。所以这里只要存在头结点,并且 removeEldestEntry
方法返回 true
,就会删除最老的结点。
查看 removeEldestEntry
的默认实现:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
可以看到在LinkedHashMap中,该方法默认返回 false
也就是不删除。所以我们要想实现自己的LRU缓存,只需要把我们的删除条件写在该方法中即可,那么每次插入元素的时候,都会在 (3)处执行这个回调,并最终根据 removeEldestEntry
方法来判断是否需要删除最老的元素。
利用LinkedHashMap实现LRU缓存
有了上边的分析,其实代码写起来就很简单了。
只需要继承LinkedHashMap,并重写其 removeEldestEntry
方法,其中设置要触发的删除条件。其它的,源码已经帮我们搞定了。
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
// size标记这个缓存的大小,即可以缓存多少个元素
private int size;
public LRUCache(int initialCapacity, float loadFactor, int size) {
// 这里调用带accessOrder的构造方法,并将其设置为true
// 表示按照访问顺序维护
super(initialCapacity, loadFactor, true);
this.size = size;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 当元素个数大于size的时候就返回true,说明此时条件满足,就会去删除最老的结点
return this.size() > size;
}
public static void main(String[] args) {
// 初始容量和负载因子就使用默认值。
LRUCache<Integer, Integer> cache = new LRUCache<>(16, 0.75f, 5);
for (int i = 0; i < 5; i++) {
cache.put(i, i);
}
System.out.println(cache);
cache.put(5, 5);
System.out.println(cache);
cache.get(1);
System.out.println(cache);
}
}
比如这里把缓存个数设置为5,即当缓存中已有5个元素的时候,再插入会删除最老的元素。输出如下:
{0=0, 1=1, 2=2, 3=3, 4=4}
{1=1, 2=2, 3=3, 4=4, 5=5}
{2=2, 3=3, 4=4, 5=5, 1=1}
当插入5的时候,最老的0被删除了;访问完1后,1被提到了最后边,实现了按访问顺序。
扩展
HashMap.TreeNode为什么继承自LinkedHashMap.Entry
其实上边已经实现了LRU缓存,但是其实源码中还有一些比较有意思的设计。HashMap在1.8以后,引入了红黑树设计,即当hash冲突比较严重,导致拉链比较长的时候,会转换为红黑树。那么LinkedHashMap对于红黑树,是如何维护链表顺序的呢?查看HashMap.TreeNode源码:
/**
* Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
* extends Node) so can be used as extension of either regular or
* linked node.
*/
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
// ... 省略
}
如果源码看不明白,可以看看类图:
(红线表示从属内部类关系,蓝线表示继承关系)
???小朋友,你是否有很多的问号。TreeNode作为HashMap的内部类,却继承自LinkedHashMap.Entry。TreeNode:我父亲是我兄弟Node的儿子???
按理说,TreeNode应该继承自HashMap.Node比较好理解。但是其实这里也是为了实现链接考虑。假想如果那样的话,对于LinkedHashMap有两种思路:
- 它的Entry对Node实现了扩展,使其具有链表特点,那么它就还需要一个TreeEntry继承TreeNode,使其具有红黑树的特点,并且要继承自身的Entry,使其具有链表特点。但是这就和 “Java不能多继承” 冲突了。
- 如果让 LinkedHashMap.Entry 直接继承 HashMap.TreeNode, HashMap.TreeNode直接继承 HashMap.Node,倒也可以实现需求。但是那样的话,如果使用LinkedHashMap,所有结点都将具有TreeNode结点的属性,而转换红黑树的操作是很少发生的,也就是说我们大多数时候都是白白带着一些没用的属性。这里在HashMap中注释也说了,TreeNode结点是常规Node结点的2倍大,所以只在必要的时候才转换。
/**
* ...
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins.
* ...
* /
所以,采用这样一种蛇皮操作实现了:在不需要引入红黑树的,结点就是常规结点,没有红黑树的内容。引入红黑树的时候,TreeNode既是继承自Node(父亲是LinkedHashMap.Entry,爷爷是它),保证了数组元素的统一;又在需要的时候有了链表的特点。妙啊。