LinkedHashMap源码解析
简介
LinkedHashMap 继承于 HashMap,其内部的 Entry 多了两个前驱
、后继
指针,内部额外维护了一个双向链表,能保证元素按插入的顺序访问,也能以访问顺序访问,可以用来实现 LRU 缓存策略。
LinkedHashMap 实现了 Map 接口,即允许放入 key
为 null
的元素,也允许插入 value
为 null
的元素。
LinkedHashMap 可以看成是 LinkedList + HashMap,从名字上可以看出该容器是 LinkedList 和 HashMap 的混合体,也就是说它同时满足 HashMap 和 LinkedList 的某些特性。可将 LinkedHashMap 看作采用 LinkedList 增强的 HashMap。
继承体系
LinkedHashMap 继承与 HashMap,核心的增删改查基本还是 HashMap 中的方法,只是 LinkedHashMap 实现了几个钩子函数,可以在添加删除等最后一步调用 LinkedHashMap 实现的钩子函数进行额外的操作,下面会详细讲解。
存储结构
我们知道 HashMap 使用 数组 + 单链表 + 红黑树 的存储结构,那 LinkedHashMap 是怎么存储的呢?
通过上面的继承体系,我们知道它继承了 HashMap,所以它的内部也有这三种结构,但是它还额外添加了一种 “双向链表” 的结构存储所有元素的顺序。
添加删除元素的时候需要同时维护在 HashMap 中的存储,也要维护在 LinkedList 中的存储,所以性能上来说会比 HashMap 稍慢。
简单使用
public class Main {
public static void main(String[] args) {
HashMap<String, String> hashMap = new HashMap<>();
hashMap.put("name", "张三");
hashMap.put("age", "13");
hashMap.put("gender", "男");
System.out.println(hashMap);
LinkedHashMap<String, String> linkedHashMap = new LinkedHashMap<>();
linkedHashMap.put("name", "张三");
linkedHashMap.put("age", "13");
linkedHashMap.put("gender", "男");
System.out.println(linkedHashMap);
}
}
/*
* 控制台打印
* {gender=男, name=张三, age=13} // HashMap 无序
* {name=张三, age=13, gender=男} // LinkedHashMap(默认)按照元素添加的顺序遍历
*/
源码解析
内部类Entry
/*
* LinkedHashMap中的Entry节点,继承了HashMap中的Node。
*/
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中的Node节点
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
存储节点 Entry,继承自 HashMap 的 Node 类,next 用于单链表存储于桶中,before 和 after 用于双向链表存储所有元素。
属性
/*
* 双向链表头节点,旧数据存在头节点。
*/
transient LinkedHashMap.Entry<K,V> head;
/*
* 双向链表尾节点,新数据存在尾节点。
*/
transient LinkedHashMap.Entry<K,V> tail;
/*
* 是否需要按访问顺序排序
* true:双向链表按照元素的访问顺序排序(LRU)。
* false:按照元素添加顺序排序。
*/
final boolean accessOrder;
构造方法
/*
* 传入初始容量和加载因子。
*/
public LinkedHashMap(int initialCapacity, float loadFactor) {
// 调用HashMap的构造器
super(initialCapacity, loadFactor);
// accessOrder置为false,表示双向链表按照元素的添加顺序构建。
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);
}
/*
* 上面的4个构造器基本都差不多,都直接将accessOrder置为了false。
* 而此构造器可以自定义accessOrder。
*/
public LinkedHashMap(int initialCapacity,
float loadFactor,
boolean accessOrder) {
// 为初始容量和加载因子赋值
super(initialCapacity, loadFactor);
// 为accessOrder赋值
this.accessOrder = accessOrder;
}
前四个构造方法 accessOrder 都等于 false,说明双向链表是按插入顺序存储元素。
最后一个构造方法 accessOrder 从构造方法参数传入,如果传入 true,则就实现了按访问顺序存储元素,这也是实现 LRU 缓存策略的关键。
扩展:LRU 缓存机制
在解析核心方法之前,我们先来了解一下什么是 LRU(Least Recently Used)即最近最少使用算法,是操作系统中一种常用的页面置换算法,有兴趣的同学们可以去做一下这道题:LeetCode146.LRU缓存,这道题考察的就是数据结构封装的能力,本题使用 HashMap + 手动构建双向链表
实现了一个简单的 LRU 算法,题解参考。做到了题目中要求的 get() 和 put() 方法的平均时间复杂度为
O
(
1
)
O(1)
O(1)。
其实这道题我们可以直接使用 LinkedHashMap 来实现,(ps:毕竟是算法题,还是得自己实现,不要直接使用LinkedHashMap来实现,不然面试时候直接gg)
。
- 使用 LinkedHashMap 就能实现的原理就是 HashMap 中留给 LinkedHashMap 实现的钩子函数。
- 当进行 put 操作后,如果当前的元素个数大于了规定容量 (LRU场景下有最大容量) ,那么就调用对应的钩子函数将最不经常使用的节点从链表和 map 中删除(这里是头结点)。
- 当访问了某个节点后 (一般是修改和查询) 调用对应的钩子函数将此节点插入到链表的结尾,表示当前节点是当前最热门的节点。
当对 LRU 有了一定的认识后,下面的几个钩子方法我们就能很简单的搞定了!
HashMap#put()
LinkedHashMap 没有重写 put() 方法,即调用的还是 HashMap#put() 方法,只是重写了一些方法。
所以我们我们大致看一下 HashMap#put() 方法,详细讲解参考HashMap源码解析。
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) {
// 省略了四种情况的添加操作。
// 这里是插入操作,调用了newNode()方法。
// 这里是替换操作
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;
}
我们再来看一下 LinkedHashMap 重写的 newNode 方法:
- 就是先构建一个 LinkedHashMap 内部的 Entry 节点,然后将节点插入到双向链表的末尾。
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
// 创建的不是HashMap中的Node,而是LinkedHashMap中的Entry节点
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
// 将p插入到双向链表的尾部
linkNodeLast(p);
return p;
}
||
\/
// 将节点p插入到双向链表的尾部。
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
// tail是双向链表尾结点,用last存一下
LinkedHashMap.Entry<K,V> last = tail;
// tail指向插入节点
tail = p;
// last = null 说明双向链表中没有节点
if (last == null)
// 将head(头节点)指向p
head = p;
// last != null 说明双向链表不为null
else {
// 将p的前驱指向原尾结点
p.before = last;
// 原尾结点的后继指向p,完成插入。
last.after = p;
}
}
afterNodeInsertion(boolean evict)
- 在节点插入之后做些什么,在 HashMap 中的 putVal() 方法中被调用,HashMap 中这个方法的实现为空,就是留给 LinkedHashMap 实现的。
- 即在插入一个节点后是否需要将头结点从链表和 Map 中移除(在实现LRU Cache时,如果当前元素个数大于规定的容量,需要将最不常使用的节点删除,此时最不常使用的节点就是头节点)。
/*
* 表示在插入节点后该做什么。
*/
void afterNodeInsertion(boolean evict) {
LinkedHashMap.Entry<K,V> first;
/*
* evict固定为true。
* removeEldestEntry默认为false,即不删除头节点,留给我们重写。
*/
if (evict && (first = head) != null && removeEldestEntry(first)) {
// 头结点
K key = first.key;
/*
* 调用HashMap的removeNode()方法,将节点从哈希表中删除
* 此方法中也有一个钩子方法afterNodeRemoval,即将此节点从链表中删除。
*/
removeNode(hash(key), key, null, false, true);
}
}
/*
* 此方法是LinkedHashMap的方法,默认是返回false,留给我们重写,后面实现LRU的核心就是此方法。
*/
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return false;
}
evict,驱逐的意思。
- 如果 evict 为 true,且头节点不为空,且确定移除最老的元素,那么就调用 HashMap.removeNode() 把头节点移除(这里的头节点是双向链表的头节点,而不是某个桶中的第一个元素);
- HashMap.removeNode() 从 HashMap 中把这个节点移除之后,会调用 afterNodeRemoval() 方法;
- afterNodeRemoval() 方法在 LinkedHashMap 中也有实现,用来在移除元素后修改双向链表,见下文;
- 默认 removeEldestEntry() 方法返回 false,也就是不删除元素。
afterNodeAccess(Node<K,V> e)
- 在节点访问之后被调用,主要在 put() 已经存在的元素或 get() 时被调用,如果 accessOrder 为 true,调用这个方法把访问到的节点移动到双向链表的末尾。
void afterNodeAccess(Node<K,V> e) { // move node to last
LinkedHashMap.Entry<K,V> last; // 链表尾结点
/*
* 条件1:accessOrder为true,表示按照访问顺序排序
* 条件2:e这个节点不是尾结点(如果是尾结点的话无需操作)
*/
if (accessOrder && (last = tail) != e) {
/*
* p:当前需要移动的节点
* b:p的前驱
* a:p的后继
*/
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
// 将p的后继置为null
p.after = null;
// ---------将p节点从链表中删除
// p的前驱为null,说明p是头结点
if (b == null)
// 将头节点变为p的后继
head = a;
// p不是头节点
else
// 将p的前驱节点的后继指向p的后继,
b.after = a;
// a不为null,说明p有后继节点
if (a != null)
//将p的后继节点的前驱指向p的前驱(完成删除p节点)
a.before = b;
else
last = b;
// ---------将p节点插入到末尾
if (last == null)
head = p;
else {
// p的前驱指向尾结点
p.before = last;
// 尾结点的后继指向p
last.after = p;
}
// 最终的尾结点就是p
tail = p;
/*
* 注意这里modCount会变化(faild-fast机制),
* 因为当你在遍历LinkedHashMap时,同时有线程访问数据,会造成链表结构发生变化,直接抛异常。
*/
++modCount;
}
}
(1)如果 accessOrder 为 true,并且访问的节点不是尾节点;
(2)从双向链表中移除访问的节点;
(3)把访问的节点加到双向链表的末尾;(末尾为最新访问的元素)
afterNodeRemoval(Node<K,V> e)
- 在节点被删除之后调用的方法,在 HashMap 中的 removeNode() 方法中被调用。
/*
* 将节点从双向链表中删除。
*/
void afterNodeRemoval(Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.before = p.after = null;
// 把节点p从双向链表中删除。
if (b == null)
head = a;
else
b.after = a;
if (a == null)
tail = b;
else
a.before = b;
}
get(Object key)
- 查找元素
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;
}
如果查找到了元素,且 accessOrder 为 true,则调用 afterNodeAccess() 方法把访问的节点移到双向链表的末尾。
LinkedHashMap 实现 LRU
LinkedHashMap 如何实现 LRU 缓存淘汰策略呢?
关于 LRU,Least Recently Used,最近最少使用算法,也就是优先淘汰最近最少使用的元素。在上面我们已经介绍过了。
经过上方的代码分析,我们发现只需要达成两个条件即可:
- accessOrder 赋值为 true
- 重写 removeEldestEntry() 方法
代码实现
import java.util.LinkedHashMap;
import java.util.Map;
public class LRU<K, V> extends LinkedHashMap<K, V> {
private int maxSize;
public LRU(int size, float loadFactory) {
/*
* 调用LinkedHashMap中唯一的一个可以为accessOrder赋值的构造器
* 为accessOrder赋值为true,表示链表的顺序为LRU。
*/
super(size, loadFactory, true);
// 设定缓存的最大容量
this.maxSize = size;
}
/*
* 重写removeEldestEntry规定当缓存中元素满时进行删除最不常使用的元素。
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
// 当元素个数大于了缓存的容量,就移除元素
return size() > this.maxSize;
}
}
测试
public static void main(String[] args) {
LRU<String, String> cache = new LRU<String, String>(10, 0.75f);
cache.put("name", "李四"); // name
cache.put("age", "13"); // name -> age
cache.put("gender", "男"); // name -> age -> gender
cache.put("age", "15"); // 这里age使用了 name -> gender -> age
cache.put("class", "19407"); // name -> gender -> age -> class
cache.put("name", "张三"); // 这里name使用了 gender -> age -> class -> name
for (Map.Entry<String, String> e : cache.entrySet()){
System.out.println(e.getKey() + " " + e.getValue());
}
}
输出
gender 男
age 15
class 19407
name 张三
如果我们将 LRU 的 size 属性设置为 3,则输出:
age 15
class 19407
name 张三
头结点 gender 被淘汰掉了。
我们使用 LinkedHashMap 实现了 LRU 缓存淘汰策略!
总结
(1)LinkedHashMap 继承自 HashMap,具有 HashMap 的所有特性;
(2)LinkedHashMap 内部维护了一个双向链表存储所有的元素;
(3)如果 accessOrder 为 false,则可以按插入元素的顺序遍历元素;
(4)如果 accessOrder 为 true,则可以按访问元素的顺序遍历元素(LRU);
(5)LinkedHashMap 的实现非常精妙,很多方法都是在 HashMap 中留的钩子(Hook),直接实现这些 Hook 就可以实现对应的功能了,并不需要再重写 put() 等方法;
(6)默认的 LinkedHashMap 并不会移除旧元素,如果需要移除旧元素,则需要重写 removeEldestEntry() 方法设定移除策略;
(7)LinkedHashMap 可以用来实现 LRU 缓存淘汰策略(accessOrder 赋值为 true,重写 removeEldestEntry() 方法);
参考文章
- 彤哥读源码_死磕 java集合之LinkedHashMap源码分析
- shstart7_LinkedHashMap源码解析