LRU 复习:最近最少使用,优先删除最久未访问的
1. LinkedHashMap
1.1 基本应用
LinkedHashMap
与 HashMap
的区别:LinkedHashMap
可以让我们根据插入顺序遍历 Map
。
LinkedHashMap 底层还是数组加链表的形式,只是可以允许我们按照
插入/访问
顺序输出。
public class MapTest {
public static void main(String[] args) {
Map<String, Integer> linkedHashMap = new LinkedHashMap<>();
Map<String, Integer> hashMap = new HashMap<>();
linkedHashMap.put("A", 10);
linkedHashMap.put("Z", 20);
linkedHashMap.put("融易贷", 60);
linkedHashMap.put("支付宝", 90);
hashMap.put("A", 10);
hashMap.put("Z", 20);
hashMap.put("融易贷", 60);
hashMap.put("支付宝", 90);
// 遍历顺序和插入顺序一致
System.out.println("linkedHashMap" + linkedHashMap.keySet());
// 无序
System.out.println("hashMap" + hashMap.keySet());
}
}
输出:
linkedHashMap[A, Z, 融易贷, 支付宝]
hashMap[A, 支付宝, 融易贷, Z]
2. 源码解读
2.1 双链表部分
核心就是重写了
newNode
方法,并继承HashMap.Node
,newNode
的时候维护双链表
// 1. LinkedHashMap 继承与 HashMap
public class LinkedHashMap<K,V>
extends HashMap<K,V>
implements Map<K,V> {}
// 2. put 方法仍然使用的是 HashMap 中的 put,最终调用的是 HashMap.putVal
// 3. LinkedHashMap 内部类 Entry 继承 HashMap.Node 并增加属性 before, after
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);
}
}
// 4. 并重写 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;
}
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;
}
}
2.1.1 示例
public class OtherTest {
public static void main(String[] args) {
Map<Integer, String> linkedHashMap = new LinkedHashMap<>();
linkedHashMap.put(1, "A");
linkedHashMap.put(2, "B");
linkedHashMap.put(3, "C");
System.out.println(linkedHashMap.keySet());
}
}
注意 beforore 和 after 执向顺序,后续要用。可以看到,整体的添加顺序是 head 不变,tail 后移(head 存储的是最旧的元素)
2.2 LinkedHashMap accessOrder
支持最终顺序为:true 访问顺序,false 插入顺序。核心代码为:如果访问元素存在则将其在链表中位置移动到 tail 处(最新)
以下源码的阅读注意 before after 指向
这段代码与上述代码唯一的区别就是将 accessOrder
设置为 true
public class OtherTest {
public static void main(String[] args) {
Map<Integer, String> linkedHashMap = new LinkedHashMap<>(10, 0.75f, true);
linkedHashMap.put(1, "A");
linkedHashMap.put(2, "B");
linkedHashMap.put(3, "C");
System.out.println(linkedHashMap.keySet()); // [1, 2, 3]
linkedHashMap.get(2);
System.out.println(linkedHashMap.keySet()); // [1, 3, 2]
}
}
true 访问顺序,false 插入顺序
/**
* 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;
// get 方法,元素获取 getNode 调用的 HashMap 的方法
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
// 位置调换方法
if (accessOrder)
// 这个是核心方法
afterNodeAccess(e);
return e.value;
}
元素移动核心代码
// 将访问元素移动到 tail(最新位置处)
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
// 如果访问的是 tail 元素不用移动
if (accessOrder && (last = tail) != e) {
/** 等价于
* LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e; // p 访问元素本身
* LinkedHashMap.Entry<K,V> b = p.before; // b 是访问元素的前一个
* LinkedHashMap.Entry<K,V> a = p.after; // a 是访问元素的后一个
*/
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null; // p 要移动到 tail,after 不指向别的元素
if (b == null) // b == null 访问的是 head 元素
head = a; // head 后移 head = p.after
else
// 否则就是 p 前边有元素,让 p 前后的元素关联起来,自己出去
b.after = a; // 等价于:p.before.after = p.after(结合上边的图查看)
// a != null 不是 tail 这个应该一定成立(个人理解,因为访问 tail 不进入)
if (a != null)
// p 要出去,让 p 前后的关联起来
a.before = b; // 等价于:p.after.before = p.before
else
// 感觉进不来
last = b;
// 感觉这个 last 也永远不为 null
if (last == null)
head = p;
else {
// p 移动到 tail 位置,原来的 tail(last)变第二个,维护 指向关系
p.before = last;
last.after = p;
}
// tail 指向 p,p 移动到最新位置
tail = p;
++modCount; // 这个只是为了防止迭代的时候进行修改
}
}
到这里,是不是感觉与 LRU 很接近了,每次访问将访问的元素提到最前面(LinkedHashMap tail 表示前面),只差元素满的时候自动剔除最旧的数据了(head),我们接着往下看。
2.3 自动剔除
LinkedHashMap 重写了 afterNodeInsertion,只要
removeEldestEntry()
返回 true 就可以对 head 元素进行删除,因此我们如果要实现 LRU 只需要修改removeEldestEntry
即可。
// HashMap.putVal 的最后有一个函数:afterNodeInsertion(true)
// LinkedHashMap 重写了 afterNodeInsertion
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;
// 只要 head != null(有元素添加到 map)
// removeEldestEntry(first) 是否允许删除最旧的,返回 true 表示进行删除
if (evict && (first = head) != null && removeEldestEntry(first)) {
K key = first.key;
// 就会对最旧的 head 元素进行删除
removeNode(hash(key), key, null, false, true);
}
}
// 默认 false,即默认不剔除而是像 HashMap 一样一直扩容,只是多维护了一个双链表
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
我们来看下剔除方法:removeNode
,结合第一张图片删除中间元素 2 看比较好理解
removeNode
为 HashMap
的删除 HashMap
节点的方法,这个省略。
removeNode
最后调:afterNodeRemoval
进行链表元素剔除
void afterNodeRemoval(Node<K,V> e) { // unlink
// 上面讲过,这其实是三句
// p 删除元素
// b = p.before
// a = p.after
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
// p 要从链表中移除,前后指针不用指向其他元素
p.before = p.after = null;
// b == null 表示 e 为 head,即要删除的是 head
if (b == null)
// 将 head 指向 p 的后面元素 after,head = a = p.after
head = a;
else
// 删除元素,让元素前后指向跳过 p
b.after = a; // p.before.after = p.after
// a == null 表示删除的是 tail
if (a == null)
tail = b; // tail 指针前移即可
else
// 跳过 p
a.before = b; // p.after.before = p.before
}
2.4 顺序遍历
最后我们再看一下为何可以顺序访问元素
可以看到 keySet
返回的是 java.util.LinkedHashMap.LinkedKeySet
,我们遍历都是获取 keySet
,然后迭代,因此这一步应该就是发生在 keySet
这一步了。
另外 keySet
直接输出:System.out.println(lruDemo.keySet());
隐式调用的是 toString()
方法,最后还是调用的迭代器遍历,代码就省略了感兴趣的小伙伴自己可以看下,位置:java.util.AbstractCollection#toString
。
public Set<K> keySet() {
Set<K> ks = keySet;
if (ks == null) {
ks = new LinkedKeySet();
keySet = ks;
}
return ks;
}
而 LinkedKeySet
的迭代器方法返回的是:java.util.LinkedHashMap.LinkedKeyIterator
public final Iterator<K> iterator() {
return new LinkedKeyIterator();
}
LinkedKeyIterator
又继承于 java.util.LinkedHashMap.LinkedHashIterator
,根据继承会先初始化父类特征,而 LinkedHashIterator
创建时,使用 next
记录了 head
abstract class LinkedHashIterator {
// 构造
LinkedHashIterator() {
next = head;
expectedModCount = modCount;
current = null;
}
}
final class LinkedKeyIterator extends LinkedHashIterator
implements Iterator<K> {
public final K next() { return nextNode().getKey(); }
}
可以看到核心就是 nextNode
如何实现的了:可以看到遍历的过程是从 head 开始向后遍历直到 tail,同样是依靠链表来维护顺序的。即 keySet() 索引越靠近 0 的位置是越旧的元素(从 head 往 tail 方向遍历的)
public final boolean hasNext() {
return next != null;
}
final LinkedHashMap.Entry<K,V> nextNode() {
// e 就是 head
LinkedHashMap.Entry<K,V> e = next;
// 这个是并发监测的,不用管
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
// 没有元素遍历抛异常
if (e == null)
throw new NoSuchElementException();
// 当前元素
current = e;
// next 后移
next = e.after;
// 返回当前元素
return e;
}
3. LRU 实现
通过上面 LinkedHashMap
我们可以看到只需要 removeEldestEntry
在元素满的时候返回 true,即可实现 LRU 算法。
3.1 LinkedHashMap 实现 LRU
public class LruDemo<K, V> extends LinkedHashMap<K, V> {
private int cacheSize;
public LruDemo(int cacheSize) {
super(cacheSize, 0.75f, true);
this.cacheSize = cacheSize;
}
// 当 map 长度大于 cacheSize 的时候,将最旧的元素剔除,即可实现LRU
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return this.size() > cacheSize;
}
public static void main(String[] args) {
LruDemo<Integer, String> lruDemo = new LruDemo<>(3);
lruDemo.put(1, "a");
lruDemo.put(2, "b");
lruDemo.put(3, "c");
System.out.println(lruDemo.keySet()); // [1, 2, 3]
// 访问 1
System.out.println(lruDemo.get(1));
System.out.println(lruDemo.keySet()); // [2, 3, 1]
// 添加 4,最旧的 2 被删除
lruDemo.put(4, "d");
System.out.println(lruDemo.keySet()); // [3, 1, 4]
}
}
3.2 自定义实现
分析:每次访问需要调整顺序,使用链表比较合适,增删比较快。根据 key 快速定位到元素位置,可以利用 HashMap 维护 Key 和 Node 节点。先通过 HashMap 获取对应的节点,之后再进行操作。
在本例中,head 是最新的(每次都加到 head.prev
处),tail 是旧的,删除先删 tail.next
, 遍历 keySet 索引越靠近 0 越新(与 LinkedHashMap 反方向实现的)
public class LruCacheDemo2<K, V> {
private int cacheSize; // 缓存大小
private Map<K, Node<K, V>> nodeMap; // node节点 Map 方便通过 key 快速访问
// 维护链表,通过将 tail head 维护为一个虚拟节点,降低代码复杂度,不用担心 tail head 空指针的问题
private NodeLinkedList<K, V> nodeLinkedList;
public LruCacheDemo2(int cacheSize) {
this.cacheSize = cacheSize;
this.nodeMap = new HashMap<>(cacheSize);
this.nodeLinkedList = new NodeLinkedList<>();
}
// 节点对象
static class Node<K, V> {
K key;
V value;
public Node() {
}
public Node(K key, V value) {
this.key = key;
this.value = value;
}
// 前后指针
Node<K, V> prev;
Node<K, V> next;
}
static class NodeLinkedList<K, V> {
Node<K, V> tail;
Node<K, V> head;
// 头尾指针都是虚拟指针,这样就可以避免删除的时候移动 头尾指针 了
public NodeLinkedList() {
this.tail = new Node<>();
this.head = new Node<>();
head.prev = tail;
tail.next = head;
}
protected void remove(Node<K, V> node) {
if (node == null) {
throw new IllegalArgumentException("node is null");
}
// 因为 tail head 都是虚拟节点,删除直接删除即可,不用考虑 空指针 的情况
node.prev.next = node.next;
node.next.prev = node.prev;
// 删除节点:前后指针清空指向
node.prev = null;
node.next = null;
}
// 添加头结点
protected void addHead(Node<K, V> node) {
if (node == null) {
throw new IllegalArgumentException("node is null");
}
// 节点添加,看上图方便理解
// node 节点前后指向
node.prev = this.head.prev;
node.next = this.head;
// 维护原来节点的指向,将 node 添加
this.head.prev.next = node;
this.head.prev = node;
}
}
public V get(K key) {
Node<K, V> node = nodeMap.get(key);
if (node == null) {
return null;
}
// 存在,就需要将当前节点放到第一个(head.prev)处
// 先删除,后添加到头(比直接修改方便理解)
nodeLinkedList.remove(node);
nodeLinkedList.addHead(node);
return node.value;
}
public void put(K key, V value) {
// 存在只需要更新和换位
if (nodeMap.containsKey(key)) {
Node<K, V> node = nodeMap.get(key);
node.value = value;
// 更新数据之后维护访问顺序
nodeLinkedList.remove(node);
nodeLinkedList.addHead(node);
} else {
// 新节点,判断下是否放满了
if (nodeMap.size() == cacheSize) {
Node<K, V> last = nodeLinkedList.tail.next;
// Map 根 链表同步删除(删除 last)
nodeLinkedList.remove(last);
nodeMap.remove(last.key);
}
}
Node<K, V> node = new Node<>(key, value);
nodeMap.put(key, node);
nodeLinkedList.addHead(node);
}
public boolean remove(K key) {
Node<K, V> node = nodeMap.get(key);
if (node == null) {
throw new IllegalArgumentException("key not exists");
}
nodeLinkedList.remove(node);
nodeMap.remove(key);
return true;
}
// 模仿 LinkedHashMap 迭代器的遍历方法
public String ketSet() {
if (this.nodeMap.size() == 0) {
return "[]";
}
StringBuilder builder = new StringBuilder();
builder.append("[");
Node<K, V> tmp = this.nodeLinkedList.head.prev;
while (true) {
builder.append(tmp.key);
tmp = tmp.prev;
if (tmp == this.nodeLinkedList.tail) {
break;
}
builder.append(", ");
}
return builder.append("]").toString();
}
// 验证:索引越靠近 0 越新(与 LinkedHashMap 反方向实现的)
public static void main(String[] args) {
LruCacheDemo2<Integer, String> lruCache = new LruCacheDemo2<>(3);
lruCache.put(1, "A");
lruCache.put(2, "B");
lruCache.put(3, "C");
System.out.println(lruCache.ketSet()); // [3, 2, 1]
System.out.println(lruCache.get(2));
System.out.println(lruCache.ketSet()); // [2, 3, 1]
System.out.println(lruCache.get(1));
System.out.println(lruCache.ketSet()); // [1, 2, 3]
lruCache.put(4, "D");
System.out.println(lruCache.ketSet()); // [4, 1, 2]
lruCache.remove(4);
lruCache.remove(2);
}
}
感兴趣的小伙伴可以 Debug 一下,应该会有更深刻的理解。
4. 写到最后
LRU 第二个方法参考的:尚硅谷Java大厂面试题第3季,周阳老师主讲的 P65-P70
感兴趣的小伙伴欢迎前去学习。