vlambda博客
学习文章列表

深入LinkedHashMap源码解析(JDK1.8)

LinkedHashMap源码解析(JDK1.8)

深入LinkedHashMap源码解析(JDK1.8)深入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源码解析(JDK1.8)

LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。其结构如下:

深入LinkedHashMap源码解析(JDK1.8)

上图中,淡蓝色的箭头表示前驱引用,红色箭头表示后继引用。每当有新键值对结点插入,新结点最终会接在 tail 引用指向的结点后面。而 tail 引用则会移动到新的结点上,这样一个双向列表就建立起来了。

上面的结构并不少很难理解,虽然引入了红黑树,导致结构看起来略为复杂了一些。但是大家完全可以忽略红黑树,而只关注链表结构本身。

3. 源码解析

3.1 LinkedHashMap 结构

LinkedHashMap 继承自 HashMap,所以HashMap中的非 private 方法和字段,都可以在LinkedHashMap中直接访问深入LinkedHashMap源码解析(JDK1.8)

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);
}
}

我们这里先分析一些键值对节点的继承体系,先来看看继承体系结构图:深入LinkedHashMap源码解析(JDK1.8)上面的继承体系看上去还是挺复杂的,同时也让人有点迷惑。HashMap 的内部类 TreeNode 不继承它的一个内部类 NOde,却继承自 Node 的子类 LinkedHashMap 内部类 Entry。这是为什么?

LinkedHashMap 内部类Entry继承自 HashMap 内部类 Node,并新增类两个引用,分别是 beforeafter。这两个引用的用途不难理解,也就是用于维护双向链表。

同时,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 的 headtail 引用同时指向新节点,链表就建立起来了。随后不断有新节点插入,通过将新节点接在 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 的插入操作的调用过程。如下:

深入LinkedHashMap源码解析(JDK1.8)

上图 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;

}

删除的过程并不复杂,上面这么多代码其实就做了三件事:

  1. 根据 hash 定位到桶位置
  2. 遍历链表或调用红黑树相关的删除方法
  3. 从 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 底层基于红黑树实现,利用红黑树的性质,实现了键值对排序功能。

部分图片来源于网络,版权归原作者,侵删。
👇点击阅读原文,查看往期内容! 快留言👇和我互动吧~