前言
第一次看见 LinkedHashMap,还是暑假看《Java 核心技术卷 I》的集合那一章时,里面说了,LinkedHashMap 可以用访问顺序对元素进行迭代,并且还可以基于 LinkedHashMap 来实现一个 LRU 策略。第二次看见它,就是做力扣的这道题了:146. LRU 缓存机制,我当时直接用 LinkedHashMap 来做了。今天,我们就从源码角度看看,LinkedHashMap 到底为啥可以按访问顺序迭代元素,又是怎么可以做一个 LRU 的。
LinkedHashMap 基本结构
public class LinkedHashMap<K,V>
extends HashMap<K,V>
implements Map<K,V>
{
// 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);
}
}
// 双向链表的头尾节点
transient LinkedHashMap.Entry<K,V> head;
transient LinkedHashMap.Entry<K,V> tail;
// 是否按访问顺序迭代
final boolean accessOrder;
}
复制代码
先看下 LinkedHashMap 的基本结构和属性,可见,它是继承了 HashMap 类的,同时也实现了 Map接口,故可以使用 HashMap 的方法和属性。LinkedHashMap 有自己的节点结构:内部类 Entry
,可以看到,此Entry
继承了 HashMap 中的Node
,同时 LinkedHashMap 还有Entry
类型的 head 和 tail 两个指针。看到这,我们猜想,这个 LinkedHashMap 应该维护了一个双向链表结构,但是具体怎么维护的,之后再说。最后,是accessOrder
用来表示 LinkedHashMap 是否按访问顺序组织链表结构。
LinkedHashMap 初始化
public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
accessOrder = false;
}
public LinkedHashMap(int initialCapacity) {
super(initialCapacity);
accessOrder = false;
}
public LinkedHashMap() {
super();
accessOrder = false;
}
public LinkedHashMap(Map<? extends K, ? extends V> m) {
super();
accessOrder = false;
putMapEntries(m, false);
}
// 可以指定 accessOrder
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
复制代码
初始化这块,和那些常用的类一样,都有好几个版本的构造函数,注意到,只有最后一个函数可以指定accessOrder
。然后构造函数都是通过调用父类 HashMap 的构造函数,大家可以去找找 HashMap 的博客看看,不多赘述了。
newNode 方法
要说 LinkedHashMap 的其它方法,必须先说下newNode
,先回顾下 HashMap 里的newNode
:
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);
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);
// 其它一堆代码
// newNode 方法
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}
复制代码
比方说 HashMap 的putVal
方法中newNode
出现了两次,一次是哈希到对应的桶里发现桶是空的,这肯定要新建节点了,一次是遍历到当前桶的末尾了,新建节点到桶的单链表的末尾。这个newNode
也很简单,就是新建个 HashMap 的节点。这都是 HashMap 的操作,这里简单提一下。LinkedHashMap 重写了这个函数,让我们看一下:
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<>(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;
}
}
复制代码
这个 Entry,之前已经说了,继承了 HashMap 的 Node 类。之后会说到,LinkedHashMap 自己并没有重写 put 相关的方法。那么假如 LinkedHashMap 调用了一个putVal
这样的方法,遇到要新建节点的情况时,调用newNode
,一方面,Entry 是 HashMap 的 Node 的子类,可以放到 HashMap 的桶 + 链表结构里,另一方面,这个 Entry 节点又会被组织到 LinkedHashMap 特有的双向链表结构尾部。
也就是说,LinkedHashMap 其实维护了两个结构:一个是 HashMap 的桶+链表或红黑树,另一个是自己的双向链表结构。这个链表结构用来提供那些迭代顺序之类的操作。
get 过程
put 和 get 是 Map 家族最常用的操作,首先看看 get,LinkedHashMap 重写了两个 get 方法:
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;
}
public V getOrDefault(Object key, V defaultValue) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return defaultValue;
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
复制代码
可见,两个 get 完成了之后,都会运行下面的代码:
if (accessOrder)
afterNodeAccess(e);
复制代码
看一下这个代码在干什么:
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last;
// accessOrder 为 true,且节点不在链表尾
if (accessOrder && (last = tail) != e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
// 把节点 p(就是e)从原链表删除
p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a != null)
a.before = b;
else
last = b;
// 把 p 插到链表尾部
if (last == null)
head = p;
else {
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
复制代码
这个代码看着很长,其实很简单,就是把参数传入的节点移到链表尾,分了两个步骤,先移除,再插到链表尾。双向链表的常见操作,有问题可以看这篇博客:LinkedList 源码解析 。
所以说,当accessOrder
设置为 true 的时候,进行 get 操作,并且得到了key
对应的值(也就是找到了),那么会把对应节点移到 LinkedHashMap 维护的双向链表的尾部。
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) {
// 为了 put 而做的一堆操作
afterNodeInsertion(evict);
return null;
}
复制代码
LinkedHashMap 自己并没有重写 put 方法,所以放上了 HashMap 的 put 方法。大家当时看 HashMap 源码的时候,估计都会很疑惑,这个afterNodeInsertion(evict);
是干啥的?这个其实就是给 LinkedHashMap 用的。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);
}
}
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
复制代码
新插入了一个节点之后,会进行if (evict && (first = head) != null && removeEldestEntry(first))
这一长串的判断,判断为真,会将链表的头节点删除。
看一下这个判断条件,首先看 evict。
public V putIfAbsent(K key, V value) {
return putVal(hash(key), key, value, true, true);
}
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 其它调用 putVal 的函数
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {}
复制代码
这个 evict 主要还是看调用 putVal 的方法给传的是什么。除了初始化的大部分情况下都是 true。
(first = head) != null
就是维护的链表结构不是空的。
removeEldestEntry(first)
,这个函数默认是返回 false:
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
复制代码
不过它是 protected 的,我们可以通过继承来重写它。
不管怎样,总之,每次插入了新节点之后,都会进行判断,看看是否需要删除链表的头节点。
问题解答
怎么按访问顺序迭代?
看了上面这些函数的解析,我们可以对一些问题进行回答了,首先是如何按访问顺序迭代。从 get 方法的解析里,可以看出,每次 get 之后,会调用afterNodeAccess
,如果你将accessOrder
设置为 true,是会将此访问节点插到链表尾的,当然,如果没有设置,根据 newNode 方法源码中的linkNodeLast
,会按插入顺序维护此链表。看一个简单的使用例子:
public class someInterface {
public static void main(String[] args){
// accessOrder 设置为 true
LinkedHashMap<String,Integer> testLink = new LinkedHashMap<>(10,0.75f,true);
testLink.put("a",1);
testLink.put("b",2);
testLink.put("c",3);
testLink.get("b");
Iterator<Map.Entry<String,Integer>> iterator = testLink.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String,Integer> next = iterator.next();
System.out.println("key:" + next.getKey() + " value:" + next.getValue());
}
}
}
复制代码
这里我们初始化了一个 LinkedHashMap 对象,accessOrder 设置为 true,put 了之后调用了 get,看一下运行结果:
和我们预期的访问顺序是相符的,最后访问了 b 这个键,因此它位于链表尾。
按访问顺序访问的关键除了 accessOrder 的设置和afterNodeAccess
的操作,其实还有 LinkedHashMap 独特的迭代器:
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new LinkedEntrySet()) : es;
}
final class LinkedEntrySet extends AbstractSet<Map.Entry<K,V>> {
// 其它函数
public final Iterator<Map.Entry<K,V>> iterator() {
return new LinkedEntryIterator();
}
// 其它函数
}
final class LinkedEntryIterator extends LinkedHashIterator
implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
// 迭代的关键所在
abstract class LinkedHashIterator {
LinkedHashMap.Entry<K,V> next;
LinkedHashMap.Entry<K,V> current;
int expectedModCount;
LinkedHashIterator() {
// 初始化 next
next = head;
expectedModCount = modCount;
current = null;
}
public final boolean hasNext() {
return next != null;
}
// 迭代的核心代码
final LinkedHashMap.Entry<K,V> nextNode() {
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;
}
}
复制代码
经过一系列冗长的调用,最终迭代的关键还是 LinkedIterator 的 nextNode 方法。next 被初始化为 head,并且通过next = e.after;
设置下一个 next 的值。这种机制就决定了:LinkedHashMap 的迭代器是按自己维护的这个双向链表来迭代的,和传统的 HashMap 很不同。
如何实现 LRU?
public class someInterface {
public static void main(String[] args){
LinkedHashMap<String,Integer> testLink = new LinkedHashMap<>(10,0.75f,true){
// 重写 removeEldestEntry
@Override
protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
return size() > 3;
}
};
testLink.put("a",1);
testLink.put("b",2);
testLink.put("c",3);
testLink.get("a");
testLink.put("d",4);
Iterator<Map.Entry<String,Integer>> iterator = testLink.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String,Integer> next = iterator.next();
System.out.println("key:" + next.getKey() + " value:" + next.getValue());
}
}
}
复制代码
咱们上面对 put 分析过了,put 完成之后,会在afterNodeInsertion
里判断是否需要删除链表头节点,由于正常情况下,if (evict && (first = head) != null && removeEldestEntry(first))
的前两个判断条件都是 true,而 removeEldestEntry 默认为返回 false,所以,我们得重写这个函数。在例子里,我们通过return size() > 3;
设置了最大容量为 3。如果容量大于 3,再插入新节点时,就会移除链表头节点,而根据前面的分析,链表尾节点就是最久没被使用的节点。我们看看这段代码的执行情况:
中间调用了testLink.get("a");
,所以最终 b 的节点被删了,符合 LRU 的原则。
总结
- LinkedHashMap 继承了 HashMap,拥有 HashMap 的功能
- LinkedHashMap 维护了两个数据结构,一是 HashMap 的结构,二是用来做迭代的双向链表
- LinkedHashMap 独特的迭代器设计和一些函数的重写,导致迭代器按双向链表迭代,并且若没有设置
accessOrder
,则按插入顺序迭代,否则,按访问顺序迭代 - 通过重写
removeEldestEntry
可以实现 LRU 的功能