深入LinkedHashMap源码解析(JDK1.8)
LinkedHashMap源码解析(JDK1.8)
1. 概述
在大多数的情况下,只要不涉及线程安全问题,Map基本都可使用 HashMap ,不过 HashMap 有一个问题,就是迭代HashMap的顺序并不少HashMap插入的顺序,也就是无序的。HashMap的这一缺点往往会带来困扰,因为有些场景,我们期待一个有序的 Map。
LinkedHashMap
继承自 HashMap,在HashMap的基础上,通过维护一条双向链表,解决了 HashMap 不能随时保持遍历顺序和插入顺序一致的问题。除此之外 LinkedHashMap 对访问顺序也提供了相关支持。在一些场景下,该特性很有用,比如缓存。在实现上, LinkedHashMap 很多方法直接继承自 HashMap,仅为维护双向链表重写了部分方法。
2. 原理
LinkedHashMap
继承自HashMap,所以它的底层依旧是基于拉链式散列结构。是由数组和链表或红黑树组成。HashMap 其结构如下:
LinkedHashMap
在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。其结构如下:
上图中,淡蓝色的箭头表示前驱引用,红色箭头表示后继引用。每当有新键值对结点插入,新结点最终会接在 tail
引用指向的结点后面。而 tail 引用则会移动到新的结点上,这样一个双向列表就建立起来了。
上面的结构并不少很难理解,虽然引入了红黑树,导致结构看起来略为复杂了一些。但是大家完全可以忽略红黑树,而只关注链表结构本身。
3. 源码解析
3.1 LinkedHashMap 结构
LinkedHashMap
继承自 HashMap,所以HashMap中的非 private
方法和字段,都可以在LinkedHashMap中直接访问
public class LinkedHashMap<K,V>
extends HashMap<K,V>
implements Map<K,V>
{
// 版本序列号
private static final long serialVersionUID = 3801124242820219131L;
// 链表头结点
transient LinkedHashMap.Entry<K,V> head;
// 链表尾结点
transient LinkedHashMap.Entry<K,V> tail;
/**
* 用来指定LinkedHashMap的迭代顺序,
* true 则表示按照基于访问的顺序来排练,意思就是最近使用的entry,放在链表的最末尾
* false 则表示按照插入顺序来
*/
final boolean accessOrder;
3.2 构造函数
LinkedHashMap 提供了五种方式的构造器,所有构造函数的第一行都会调用父类构造函数,使用super关键字。如下:
构造器一:
public LinkedHashMap(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor);
accessOrder = false;
}
accessOrder
默认为false,accessOrder 表示之后访问顺序按照元素的访问顺序进行,即不按照之前的插入顺序了,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);
}
putMapEntries 是调用到父类HashMap的函数。
构造器五:
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
super(initialCapacity, loadFactor);
this.accessOrder = accessOrder;
}
通过指定accessOrder的值,从而控制访问顺序。
3.3 LinkedHashMap.Entry内部类
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);
}
}
我们这里先分析一些键值对节点的继承体系,先来看看继承体系结构图:上面的继承体系看上去还是挺复杂的,同时也让人有点迷惑。HashMap 的内部类 TreeNode 不继承它的一个内部类 NOde,却继承自 Node 的子类 LinkedHashMap 内部类 Entry。这是为什么?
LinkedHashMap 内部类Entry继承自 HashMap 内部类 Node,并新增类两个引用,分别是 before
和 after
。这两个引用的用途不难理解,也就是用于维护双向链表。
同时,TreeNode 继承 LinkedHashMap 的内部类 Entry 后,就具备了和其它 Entry 一起组成链表的能力。如果继承 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。这个时候就得不偿失类,浪费很多空间去获取不一定用得到的能力。
3.4 链表的建立过程
链表的建立过程是在插入键值对节点时开始的,初始情况下,让 LinkedHashMap 的 head
和 tail
引用同时指向新节点,链表就建立起来了。随后不断有新节点插入,通过将新节点接在 tail 引用指向节点的后面,即可实现链表的更新。
Map 类型的集合类是通过 put(K,V) 方法插入键值对,LinkedHashMap本身并没有重写父类的 put 方法,而是直接使用类父类的实现。但是在 HashMap 中,put 方法插入的是 HashMap 内部类 Node 类型的节点,该类型的节点并不具备与 LinkedHashMap 内部类 Entry 及其子类型节点组成链表的能力。那么,LinkedHashMap 是怎样建立链表的呢?我们先看一下源代码:
// HashMap 中实现
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// HashMap 中实现
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;
// 通过节点 hash 定位节点所在桶位置,并检测桶中是否包含节点引用
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);
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); // 回调方法,后续说明
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict); // 回调方法,后续说明
return null;
}
// HashMap 中实现
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}
// LinkedHashMap 中重写
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);
// 将 Entry 接在双向链表的尾部
linkNodeLast(p);
return p;
}
// LinkedHashMap 中实现
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail;
tail = p;
// last 为null,表面链表还未建立
if (last == null)
head = p;
else {
// 将新节点 p 接在链表尾部
p.before = last;
last.after = p;
}
}
上面就是 LinkedHashMap 插入相关的源码,这里省略了部分非关键的代码。根据上面的代码,可以知道 LinkedHashMap 的插入操作的调用过程。如下:
上图 newNode 方法红色背景标注了,这一步比较关键。LinkedHashMap 重写了该方法。在这个方法中,LinkedHashMap 创建了 Entry
,并通过 linkNodeLast
方法将 Entry 接在双向链表的尾部,实现了双向链表的建立。双向链表建立之后,我们就可以按照插入顺序去遍历 LinkedHashMap,大家可以自己写点测试代码验证一下插入顺序。
以上就是 LinkedHashMap 维护插入顺序的相关分析。我们再回到上面 putAll 的代码,会发现有两个以 after
开头的方法。在JDK1.8 HashMap 的源码中,相关的方法有3个:
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) {}
void afterNodeInsertion(boolean evict) {}
void afterNodeRemoval(Node<K,V> p) {}
根据这三个方法的注释可以看出,这些方法的用途是在增删查等操作后,通过回调的方式,让LinkedHashMap 有机会做一些后置操作。我们在后续进行说明。
3.5 链表节点的删除过程
与插入操作一样,LinkedHashMap 删除操作相关的代码也是直接调用父类的实现。在删除节点时,父类的删除逻辑并不会维护好LinkedHashMap所维护的双向链表,这不是它的职责。那么删除节点后,被删除的节点该如何从双向链表中移除呢?这里就用到类上面提到 HashMap 中三个回调方法运行 LinkedHashMap 对一些操作做出响应。所以,在删除节点后,回调方法 afterNodeRemoval
会被调用。LinkedHashMap 重写该方法,并在该方法中完成类移除被删除节点的操作。相关源码如下:
// HashMap 中实现
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
// HashMap 中实现
final Node<K,V> removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable) {
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<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 {
// 遍历单链表,寻找要删除的节点,并赋值给 node 变量
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
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);
// 将要删除的节点从单链表中移除
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
// 调用删除回调方法进行后续操作
afterNodeRemoval(node);
return node;
}
}
return null;
}
// LinkedHashMap 中重写
void afterNodeRemoval(Node<K,V> e) { // unlink
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
// 将 p 节点的前驱引用、后继引用置为空
p.before = p.after = null;
// b 为空,表面 p 是头节点
if (b == null)
head = a;
else
b.after = a;
// a 为空,表面 p 是尾节点
if (a == null)
tail = b;
else
a.before = b;
}
删除的过程并不复杂,上面这么多代码其实就做了三件事:
-
根据 hash 定位到桶位置 -
遍历链表或调用红黑树相关的删除方法 -
从 LinkedHashMap 维护的双向链表中移除要删除的节点
3.6 访问顺序的维护过程
前面说了插入顺序的实现,这里我们来讲讲访问顺序。默认情况下,LinkedHashMap 是按插入顺序维护链表。不过我们可以在初始化 LinkedHashMap, 指定 accessOrder
参数为 true,即可让它按访问顺序维护链表。访问顺序的原理上并不复杂,当我们调用 get/getOrDefault/replace
等方法时,只需要将这些方法访问节点移动到链表尾部即可。相应的源码如下:
// LinkedHashMap 中重写
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;
}
// LinkedHashMap 中重写
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;
// 如果 b 为空,表面 p 为头节点
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 接在链表的最后
p.before = last;
last.after = p;
}
tail = p;
++modCount;
}
}
3.7 基于 LinkedHashMap 实现缓存
前面介绍了 LinkedHashMap 是如何维护插入和访问顺序的。大家对 LinkedHashMap 的原理应该有了一定的认识。这里我们将通过继承 LinkedHashMap 来实现一个简单的 LRU 策略的缓存。在代码之前,先介绍一些前置知识。
在分析链表建立过程时,我们忽略了部分源码飞行。这里把忽略的部分补上,先看代码:
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 缓存。比如我们可以根据节点数量判断是否移除最近最少被访问的节点,或者根据节点存活时间判断是否移除该节点等。
我们在这里实现一个基于判断节点数量是否超限的策略。在构造缓存对象时,传入最大节点数,当插入节点数超过最大节点数时,移除最近最少被访问的节点。实现代码如下:
class LRUCache extends LinkedHashMap<Integer, Integer>{
private int capacity;
public LRUCache(int capacity) {
super(capacity, 0.75F, true);
this.capacity = capacity;
}
public int get(int key) {
return super.getOrDefault(key, -1);
}
public void put(int key, int value) {
super.put(key, value);
}
/**
* 判断节点数是否超限
* @param eldest
* @return 超限返回 true, 否则返回false
*/
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
return size() > capacity;
}
}
测试代码如下:
LRUCache cache = new LRUCache(2);
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1
cache.put(3, 3); // 该操作会使得关键字 2 作废
cache.get(2); // 返回 -1 (未找到)
cache.put(4, 4); // 该操作会使得关键字 1 作废
cache.get(1); // 返回 -1 (未找到)
cache.get(3); // 返回 3
cache.get(4); // 返回 4
4. 总结
本文从 LinkedHashMap 维护双向链表的角度对 LinkedHashMap 的源码进行了分析,并基于LinkedHashMap 实现了一个简单的 LRU Cache。在日常开发中,LinkedHashMap 的使用频率虽不及 HashMap,但它也是个重要的实现。
在 Java 集合框架中,HashMap、LinkedHashMap 和 TreeMap 三个映射类基于不同的数据机构,并实现了不同的功能。HashMap 底层基于拉链式的散列结构,并在JDK1.8中引入红黑树优化过长链表的问题。基于这样的机构,HashMap可提供高效的增删改查操作。
LinkedHashMap 在其之上,通过维护一条双向链表,实现了散列数据结构的有序遍历。
TreeMap 底层基于红黑树实现,利用红黑树的性质,实现了键值对排序功能。
部分图片来源于网络,版权归原作者,侵删。
👇点击阅读原文,查看往期内容!
快留言👇和我互动吧~