LinkedHashMap源码解读
依赖
LinkedHashMap继承HashMap, 拥有HashMap的所有特性, 并且还额外增加了按一定顺序访问的功能
概述
LinkedHashMap 继承自 HashMap,在 HashMap 基础上,通过维护一条双向链表,解决了 HashMap 不能随时保持遍历顺序和插入顺序一致的问题。除此之外,LinkedHashMap 对访问顺序也提供了相关支持。在一些场景下,该特性很有用,比如缓存。在实现上,LinkedHashMap 很多方法直接继承自 HashMap,仅为维护双向链表覆写了部分方法。
节点的构造
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);
}
}
LinkedHashMap内部的Entry相较于Node节点多了两个before、after引用,用来维护LinkedHashMap内部元素的顺序(其内部使用双向链表)。这些还好理解,不过比较好奇的是,为什么HashMap内部的TreeNode节点反而继承的是Entry,而不是HashMap内部的Node
TreeNode 继承 LinkedHashMap 的内部类 Entry 后,就具备了和其他 Entry 一起组成链表的能力。但是这里会有一个疑问。当我们使用 HashMap 时,TreeNode 并不需要具备组成链表能力。如果继承 LinkedHashMap 内部类 Entry ,TreeNode 就多了两个用不到的引用,这样做不是会浪费空间吗?简单说明一下这个问题(水平有限,不保证完全正确),这里这么做确实会浪费空间,但与 TreeNode 通过继承获取的组成链表的能力相比,这点浪费是值得的。在 HashMap 的设计思路注释中,有这样一段话:
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. In
usages with well-distributed user hashCodes, tree bins are
rarely used.TreeNode 对象的大小约是普通 Node 对象的2倍,我们仅在桶(bin)中包含足够多的节点时再使用。当桶中的节点数量变少时(取决于删除和扩容),TreeNode 会被转成 Node。当用户实现的 hashCode 方法具有良好分布性时,树类型的桶将会很少被使用。
通过上面的注释,我们可以了解到。一般情况下,只要 hashCode 的实现不糟糕,Node 组成的链表很少会被转成由 TreeNode 组成的红黑树。也就是说 TreeNode 使用的并不多,浪费那点空间是可接受的。假如 TreeNode 机制继承自 Node 类,那么它要想具备组成链表的能力,就需要 Node 去继承 LinkedHashMap 的内部类 Entry。这个时候就得不偿失了,浪费很多空间去获取不一定用得到的能力。
类的属性
/**
* 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;
构造函数
public LinkedHashMap() {
super();
accessOrder = false;
}
public LinkedHashMap(int initialCapacity) {
super(initialCapacity);
accessOrder = false;
}
public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
accessOrder = false;
}
public LinkedHashMap(Map<? extends K, ? extends V> m) {
super();
accessOrder = false;
putMapEntries(m, false);
}
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
和HashMap的构造函数是类似的,不过可以通过入参accessOrder
来判断内部维护什么顺序的双向链表。
增
链表的建立过程是在插入键值对节点时开始的,初始情况下,让 LinkedHashMap 的 head 和 tail 引用同时指向新节点,链表就算建立起来了。随后不断有新节点插入,通过将新节点接在 tail 引用指向节点的后面,即可实现链表的更新。
因为LinkedHashMap
继承了HashMap
,其并未整体重写父类的put操作,而是直接使用父类的put操作,那么问题来了,LinkedHashMap
是怎么做到在未重写父类方法情况下,有使得内部的节点具有链表的能力呢?研读源码
putVal()
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) {
// 对应table数组
Node<K,V>[] tab;
// 对应位置的 Node 节点
Node<K,V> p;
// n table的长度
// 原tab中对应放入Node的位置
int n, i;
// 如果 table 未初始化,或者容量为 0 ,则进行扩容
// (第一次见这种赋值和判断融合的操作,学到了学到了)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果对应的位置的Node节点为空,则直接创建 Node 节点即可。
// (n-1)&hash 获取所在table的位置
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 哈希冲突解决
// key 在 HashMap 对应的老节点
Node<K,V> e;
K k;
// hash相等 key相等 执行覆盖
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);
// 为Node节点,则说明是链表,且不是覆盖操作。需要遍历查找
else {
// 判断什么时候进行链表转红黑树,什么时候红黑树转链表
for (int binCount = 0; ; ++binCount) {
// 如果链表遍历完了都没有找到相同key的元素,说明该key对应的元素不存在,则在链表 // 最后插入一个新节点
if ((e = p.next) == null) {
// ===1⃣️核心点===
// newNode()
p.next = newNode(hash, key, value, null);
// 链表的长度如果数量达到 TREEIFY_THRESHOLD(8)时,则进行树化。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果待插入的key在链表中找到了,则退出循环
if (e.hash == hash&&((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 覆盖操作, 也就是相同的key的value进行覆盖
if (e != null) { // existing mapping for key
V oldValue = e.value;
// onlyIfAbsent进行判断是否需要覆盖oldValue
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
// 如果超过阀值,则进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
// LinkedHashMap中,重写了HashMap的`newNode()`方法
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
// 重写了newNode方法
// 使得返回的节点为Entry节点,具有before、next指针
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;
}
}
总结
整体来看,就是上述的方式,在整个putVal方法中,重写了newNode()方法,同时newNode方法中,LinkedHashMap还执行了自定义的linkedNodeLast()方法。
小知识点
通过阅读putVal方法,会发现还有三个after开头的方法
// Callbacks to allow LinkedHashMap post-actions
// 回调方法,供LinekdHash执行一些后置处理
// 在hashmap中并未做出具体的实现
// 但是在linkedhashmap中做出了具体的实现
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
删
和插入操作一样,LinkedHashMap的删除操作也是直接调用父类的方法,不过父类的删除逻辑并不会修复LinkedHashMap内部维护的双向链表,这也不是父类删除方法的职责,所以LinkedHashMap是如何实现在删除过程中维护内部的双向链表的呢?上源码
removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable)
public boolean remove(Object key, Object value) {
return removeNode(hash(key), key, value, true, true) != null;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
// 这些和增里面是差不多的
// tab:桶数组 p:待删除节点的前驱节点 n: 桶数组大小 index: 桶数组第i个位置
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null&&(n = tab.length)>0 &&(p=tab[index=(n-1)&hash])!= null) {
// node为需要删除的节点
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 找到对应的key进行remove核心
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
// 如果node == p,说明是链表头是待删除节点
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
// 调用删除回调方法进行后续操作
afterNodeRemoval(node);
return node;
}
}
return null;
}
// LinkedHashMap中afterNodeRemoval具体实现
// 双向链表的正常删除操作
void afterNodeRemoval(Node<K,V> e) { // unlink
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.before = p.after = null;
if (b == null)
head = a;
else
b.after = a;
if (a == null)
tail = b;
else
a.before = b;
}
// linkedlist的删除操作
E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next; // 后继节点
final Node<E> prev = x.prev; // 前驱节点
// 删除的为第一个节点
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
// 删除的为最后一个节点
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
}
说白了就是双向链表的删除,具体删除逻辑和LinkedList是一样的,先确定当前节点的前驱、后继节点,之后先修改指向后断链防止整个链表断开,在此期间需要判断下要删除的节点是否为第一个节点、最后一个节点,防止爆NPE
总结
删除的过程并不复杂,上面这么多代码其实就做了三件事:
- 根据 hash 定位到桶位置
- 遍历链表或调用红黑树相关的删除方法
- 从 LinkedHashMap 维护的双链表中移除要删除的节点
查
因为LinkedHashMap内部有两种机制维护内部的双向链表(按照插入顺序、按照访问顺序)前面说了插入顺序的实现,接下来研究下访问顺序。默认情况下,LinkedHashMap 是按插入顺序维护链表。不过我们可以在初始化 LinkedHashMap,指定 accessOrder 参数为 true,即可让它按访问顺序维护链表。
插入顺序、访问顺序的区别
插入顺序:是指LinkedHashMap在数据插入时的插入顺序
- 比如说1,2,3,4…数据依次从小到大插入
- 若按照插入顺序输出,输出结果就是1,2,3,4…
访问顺序:则是说同样按照插入1,2,3,4…从小到大有序的插入
1. 如果在插入后你随机访问了某个元素,那么那个元素则会排列到集合的最后一位
@Test
public void insetAndVisitedOrder() {
Map<String, String> map = orderMap();
System.out.println(map);
System.out.println("=================");
map.get("key-3");
System.out.println(map);
}
public Map<String, String> orderMap() {
Map<String, String> map = new LinkedHashMap<>(8, 0.75F, true);
for (int i = 0; i < 5; i++) {
map.put("key-" + i, "value-" + i);
}
return map;
}
// {key-0=value-0, key-1=value-1, key-2=value-2, key-3=value-3, key-4=value-4}
// =================
// {key-0=value-0, key-1=value-1, key-2=value-2, key-4=value-4, key-3=value-3}
阐述完毕,接下来看下其内部如何维护这两种类型的访问顺序
get(Object key)
public V get(Object key) {
Node<K,V> e;
if ((e = getNode(hash(key), key)) == null)
return null;
// 如果 accessOrder 为 true,则调用 afterNodeAccess 将被访问节点移动到链表最后
if (accessOrder)
afterNodeAccess(e);
return e.value;
}
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;
}
}
整体的操作共为两步
- 删除当前节点所在的位置
- 将当前节点插入到双向链表的尾部
- 注意可能当前节点可能是head节点、tail节点
同理,因为是维护了访问的双向链表,所以在对外提供的API,包含有访问性质的,都会调用afterNodeAccess
来维护该双向链表
如:
- get
- getOrDefault
应用
在阐述缓存实现之前,还有一个afterNodeInsertion
没有讲解
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;
}
上面的源码的核心逻辑在一般情况下都不会被执行,所以之前并没有进行分析。上面的代码做的事情比较简单,就是通过一些条件,判断是否移除最近最少被访问的节点。看到这里,大家应该知道上面两个方法的用途了。当我们基于 LinkedHashMap 实现缓存时,通过覆写
removeEldestEntry
方法可以实现自定义策略的 LRU 缓存。比如我们可以根据节点数量判断是否移除最近最少被访问的节点,或者根据节点的存活时间判断是否移除该节点等
public class SimpleCache<K, V> extends LinkedHashMap<K, V> {
private static final int MAX_NODE_NUM = 100;
private int limit;
public SimpleCache() {
this(MAX_NODE_NUM);
}
public SimpleCache(int limit) {
super(limit, 0.75f, true);
this.limit = limit;
}
public V save(K key, V val) {
return put(key, val);
}
public V getOne(K key) {
return get(key);
}
public boolean exists(K key) {
return containsKey(key);
}
/**
* 判断节点数是否超限
* @param eldest
* @return 超限返回 true,否则返回 false
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > limit;
}
}
总结
- LinkedHashMap继承自HashMap,具有HashMap的所有特性
- LinkedHashMap内部维护了一个双向链表,来维护节点直接的顺序
- 如果accessOrder为false,则可以按插入元素的顺序遍历元素
- 如果accessOrder为true, 则可以按访问元素的顺序遍历元素
- LinkedHashMap的实现非常精妙,很多方法都是在HashMap中留的钩子(Hook),直接实现这些Hook就可以实现对应的功能了,并不需要再重写put()等方法
- 默认的LinkedHashMap并不会移除旧元素,如果需要移除旧元素,则需要重写removeEldestEntry()方法设定移除策略;
- LinkedHashMap可以用来实现LRU缓存淘汰策略;