15、LinkedHashMap
上一节我们讲了一个重点容器 HashMap,其内部实现比较复杂,工作中和面试中都经常被涉及,本节,我们再讲解一个容易跟 HashMap 混淆的容器 LinkedHashMap
LinkedHashMap 是 HashMap 的增强版,既能实现快速的增删改查操作,又能实现容器内元素的有序遍历
借助这个特性,利用 LinkedHashMap 可以轻松实现 LRU 缓存,具体如何来做呢?带着这个问题,我们来学习本节的内容吧
LRU 缓存是一种常见的缓存淘汰策略,LRU 的全称是 Least Recently Used,即最近最少使用 会优先淘汰最近最少使用的缓存数据,从而保留最近使用频率较高的缓存数据 通常由一个哈希表和一个双向链表组成,哈希表用于快速查找缓存数据,双向链表用于维护缓存数据的使用顺序 LinkedHashMap:在访问缓存数据时 1、如果数据在哈希表中 √:则将其从双向链表中删除,并将其插入到双向链表尾部 2、如果数据在哈希表中 ×:将其插入到链表尾部 3、当缓存数据达到一定数量时,就需要淘汰最近最少使用的缓存数据,具体来说,从双向链表头部删除最近最少使用的缓存数据 使用 LRU 缓存可以有效地提高缓存的命中率,从而提高程序的性能,但维护双向链表,增加了额外的空间开销和时间复杂度 在实际应用中,需要根据具体的场景来选择合适的缓存淘汰策略
1、整体结构:哈希表 + 双向有序链表
1.1、Entry 节点
上一节,我们讲到,HashMap 底层使用哈希表来实现,并且基于链表来解决哈希冲突,键和值包裹为如下 Node 节点,存储在 table 数组的链表中
public class HashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable { 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; } // ... 省略 getter、setter 等方法 ... } transient Node<K, V>[] table; // Node 数组 // ... 省略其他属性和方法 ... }
LinkedHashMap 继承自 HashMap,并且增加了排序功能,键和值不再包裹为 Node 节点,而是包裹为 Entry 节点,如下代码所示
public class LinkedHashMap<K, V> extends HashMap<K, V> implements Map<K, V> { 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; // 在双向链表中的排序方式 // 默认为 false,按照节点 "插入的先后顺序排序" // 设置为 true ,按照节点 "访问的先后顺序排序" final boolean accessOrder; }
LinkedHashMap 中的 Entry 继承自 HashMap 中的 Node,Entry 中的 next 指针用来将节点串联在 table 数组的链表中
Entry 中新增的 before、after 指针,用来将节点串联在一个有序的双向链表中,head、tail 便是有序双向链表的头指针和尾指针
accessOrder 控制双向链表的排序方式,accessOrder 默认为 false,此时,双向链表按照节点 "插入的先后顺序排序"
当然,我们也可以通过 LinkedHashMap 的有参构造函数,将 accessOrder 设置值为 true,此时,双向链表按照节点 "访问的先后顺序排序"
1.2、示例
Map<Integer, String> map = new LinkedHashMap<>(); // 插入的先后顺序排序 map.put(2, "a"); // 2 map.put(5, "b"); // 2 -> 5 map.put(18, "c"); // 2 -> 5 -> 18 map.put(5, "d"); // 2 -> 5 -> 18 map.get(2); // 2 -> 5 -> 18 Set<Map.Entry<Integer, String>> entrySet = map.entrySet(); for (Map.Entry<Integer, String> entry : entrySet) { System.out.println(entry.toString()); } // 2=a // 5=d // 18=c
Map<Integer, String> map = new LinkedHashMap<>(16, 0.75f, true); // 访问的先后顺序排序 map.put(2, "a"); // 2 map.put(5, "b"); // 2 -> 5 map.put(18, "c"); // 2 -> 5 -> 18 map.put(5, "d"); // 2 -> 18 -> 5 map.get(2); // 18 -> 5 -> 2 Set<Map.Entry<Integer, String>> entrySet = map.entrySet(); for (Map.Entry<Integer, String> entry : entrySet) { System.out.println(entry.toString()); } // 18=c // 5=d // 2=a
1.3、图示
LinkedHashMap 与 HashMap、Entry 与 Node 之间的关系及其包含字段的差别,我们用一张图总结了一下,如下所示
如果我们将键值对 <2, "a">、<5, "b">、<18, "c">、<7, "d">、<21, "e"> 分别存储到 HashMap 和 LinkedHashMap 容器中,那么对应的底层存储结构如下图所示
为了简化,下图并非具体的内存结构图,而是经过抽象之后的示意图
在 LinkedHashMap 容器中,每个节点都有两个 "角色",一个是作为 table 数组的链表中的节点,一个是作为双向有序链表中的节点
2、通过 entrySet() 输出有序的元素集合
2.1、函数定义
尽管哈希表这种数据结构主要是为了快速增删改查,但是为了方便开发,不管是 HashMap 还是 LinkedHashMap,都提供了遍历其内部键值对的方法
在 Map 接口中,定义了三个方法来返回内部数据,如下所示
// Map 接口中的方法 Set<Map.Entry<K, V>> entrySet(); Set<K> keySet(); Collection<V> values();
2.2、使用方法
这三个方法的用法和实现方法大同小异,我们拿 entrySet() 举例讲解,如下代码所示
我们通过 entrySet(),获取到 HashMap 中的所有键值对,然后再进行遍历,以下代码包含三种遍历方式
其中,for-each 循环只是一个语法糖,其底层通过迭代器来实现(下一节会详细讲解)
forEach() 函数是 Java 8 中的引入的函数式编程语法,其作用跟 for-each 循环类似(这部分在函数式编程中讲解)
Map<Integer, String> map = new HashMap<>(); map.put(2, "a"); map.put(5, "b"); map.put(18, "c"); map.put(7, "d"); map.put(21, "e"); Set<Map.Entry<Integer, String>> entrySet = map.entrySet(); // for-each 循环 for (Map.Entry<Integer, String> entry : entrySet) { System.out.println(entry.toString()); } // 迭代器遍历 Iterator<Map.Entry<Integer, String>> itr = entrySet.iterator(); while (itr.hasNext()) { System.out.println(itr.next().toString()); } // forEach() 函数, 函数式编程, Lambda 表达式 entrySet.forEach(e -> System.out.println(e.toString())); // 2=a // 18=c // 5=b // 21=e // 7=d
2.3、底层原理
entrySet() 函数如下所示,entrySet() 的返回值并非 HashSet,而是一个特殊的 Set,叫做 EntrySet
public Set<Map.Entry<K, V>> entrySet() { Set<Map.Entry<K, V>> es; return (es = entrySet) == null ? (entrySet = new EntrySet()) : es; }
EntrySet 的设计非常巧妙,它是 HashMap 的内部类,不承载任何数据,只是提供了一些方法,用来访问外部类(HashMap)中的数据,如下所示
// EntrySet 是 HashMap 的内部类 final class EntrySet extends AbstractSet<Map.Entry<K, V>> { public final int size() { return size; } public final void clear() { HashMap.this.clear(); } // for-each 遍历和迭代器遍历 public final Iterator<Map.Entry<K, V>> iterator() { return new EntryIterator(); } // forEach() 遍历 public final void forEach(Consumer<? super Map.Entry<K, V>> action) { Node<K, V>[] tab; if (action == null) throw new NullPointerException(); if (size > 0 && (tab = table) != null) { int mc = modCount; for (int i = 0; i < tab.length; ++i) { for (Node<K, V> e = tab[i]; e != null; e = e.next) action.accept(e); } if (modCount != mc) throw new ConcurrentModificationException(); } } } // 迭代器 final class EntryIterator extends HashIterator implements Iterator<Map.Entry<K, V>> { public final Map.Entry<K, V> next() { return nextNode(); } } abstract class HashIterator { // ... 省略其他属性和方法 ... final Node<K, V> nextNode() { Node<K, V>[] t; Node<K, V> e = next; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); if ((next = (current = e).next) == null && (t = table) != null) { do { } while (index < t.length && (next = t[index++]) == null); } return e; } }
从上述代码实现中,我们发现,迭代器和 forEach() 函数在代码实现上稍有不同,但遍历顺序是相同的,均是按照下标从小到大的顺序,依次遍历 table 数组中的各个链表
本小节的示例代码中,键为 2、18 的键值对存储在 table[2] 对应的链表中,键为 5、21 的键值对存储在 table[5] 对应的链表中,键为 7 的键值对存储在 table[7] 对应的链表中
所以,for-each 循环、迭代器和 forEach() 函数输出的结果均为如下所示
2=a 18=c 5=b 21=e 7=d
抛开设计,从实现的角度来说,我们完全可以把 EntrySet 中几个函数,搬移到外部类 HashMap 中
这样 HashMap 就不需要借助 EntrySet,直接就支持 for-each 循环遍历、迭代器遍历、forEach() 遍历了
而之所以没有这么做是因为:作为数据结构,哈希表并不支持遍历操作,HashMap 作为哈希表的封装类,其提供的方法理应符合哈希表的特性
HashMap 通过引入 EntrySet,从用法上给人一种将哈希表中数据放入另一个数据结构中
通过操作另一个数据结构来实现遍历的感觉,避免了让人感觉 HashMap 违反了哈希表不支持遍历的特性
2.4、HashMap 中的 forEach()
实际上,在 JDK 8 中,为了支持函数式编程,HashMap 中也定义了 forEach() 函数,如下代码所示
HashMap 中的 forEach() 函数的代码实现,跟 EntrySet 中的 forEach() 函数的代码实现,几乎相同
不过,这只是为了支持函数式编程做的妥协,所以,HashMap 并没有因此也提供 iterator() 函数
@Override public void forEach(BiConsumer<? super K, ? super V> action) { Node<K, V>[] tab; if (action == null) throw new NullPointerException(); if (size > 0 && (tab = table) != null) { int mc = modCount; for (int i = 0; i < tab.length; ++i) { for (Node<K, V> e = tab[i]; e != null; e = e.next) action.accept(e.key, e.value); } if (modCount != mc) throw new ConcurrentModificationException(); } }
也就是说,我们不仅可以在 entrySet() 的返回值上调用 forEach() 函数,还可以在 HashMap 本身上调用 forEach() 函数,示例代码如下所示
Map<Integer, String> map = new HashMap<>(); map.put(2, "a"); map.put(5, "b"); map.put(18, "c"); map.put(7, "d"); map.put(21, "e"); // 在 EntrySet 上调用 forEach() Set<Entry<Integer, String>> entrySet = map.entrySet(); for (Entry<Integer, String> entry : entrySet) { System.out.println(entry.toString()); } entrySet.forEach(e -> System.out.println(e.toString())); // 在 Map 上调用 forEach() map.forEach((key, value) -> System.out.println(key + "=" + value));
2.5、values() 和 keySet()
搞清楚了 HashMap 的 entrySet() 之后,我们再来看看 values() 和 keySet()
values() 返回的是实现了 Collection 接口的 Values 类
keySet() 返回的是实现了 Set 接口的 KeySet 类
Values 和 KeySet 的设计和实现思路,跟 EntrySet 非常类似,我们就不一一讲解了,读者可以自行阅读源码
2.6、LinkedHashMap 的 entrySet()
以上 HashMap 遍历键值对的方法,LinkedHashMap 都支持,只不过比 HashMap 多了一个特性:支持有序遍历
在本小节开头的示例代码中,如果我们将代码中的 HashMap 改为 LinkedHashMap,如下所示,代码依旧可以工作
// HashMap 改为 LinkedHasMap Map<Integer, String> map = new LinkedHashMap<>(); // 插入的先后顺序排序 map.put(2, "a"); map.put(5, "b"); map.put(18, "c"); map.put(7, "d"); map.put(21, "e"); Set<Entry<Integer, String>> entrySet = map.entrySet(); // for-each 循环 for (Entry<Integer, String> entry : entrySet) { System.out.println(entry.toString()); } // 迭代器遍历 Iterator<Map.Entry<Integer, String>> itr = entrySet.iterator(); while (itr.hasNext()) { System.out.println(itr.next().toString()); } // forEach() 函数, 函数式编程, Lambda 表达式 entrySet.forEach(e -> System.out.println(e.toString()));
将 HashMap 改为 LinkedHashMap 之后,代码的输出结果变为如下所示,输出结果的顺序跟键值对插入的先后顺序一致
2=a 5=b 18=c 7=d 21=e
那么,LinkedHashMap 是怎么做到有序遍历的呢?
LinkedHashMap 定义了 LinkedEntrySet、LinkedKeySet、LinkedValues 三个类,这三个类的代码实现类似,我们拿 LinkedEntrySet 举例讲解
LinkedEntrySet 的源码如下所示,在 LinkedEntrySet 中,迭代器和 forEach() 函数遍历的对象不再是 table 数组,而是双向链表
双向链表默认按照节点插入的先后顺序排序,所以,for-each 循环和 forEach() 函数遍历输出的结果也就有序了
final class LinkedEntrySet extends AbstractSet<Map.Entry<K, V>> { public final int size() { return size; } public final void clear() { LinkedHashMap.this.clear(); } public final Iterator<Map.Entry<K, V>> iterator() { return new LinkedEntryIterator(); } public final void forEach(Consumer<? super Map.Entry<K, V>> action) { if (action == null) throw new NullPointerException(); int mc = modCount; LinkedHashMap.Entry<K, V> e; for (e = head; e != null; e = e.after) action.accept(e); if (modCount != mc) throw new ConcurrentModificationException(); } } final class LinkedEntryIterator extends LinkedHashIterator implements Iterator<Map.Entry<K, V>> { public final Map.Entry<K, V> next() { return nextNode(); } } abstract class LinkedHashIterator { // ... 省略其他属性和方法 ... 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 = e.after; return e; } }
3、插入、删除、修改、查找的实现思路
对于 LinkedHashMap,插入、删除、修改、查找这 4 个基本操作,不仅需要操作哈希表,还要操作双向有序链表
因为 LinkedHashMap 继承自 HashMap,其中,操作哈希表的逻辑已经在 HashMap 中实现了,比如扩容、treeify 等,直接复用即可
LinkedHashMap 只需要实现操作双向有序链表的逻辑,正因如此,LinkedHashMap 中的代码并不多
3.1、插入键值对
新增的键值对会被包裹成 Entry 节点,通过 next 指针串在 table 数组的对应链表中,同时,通过 before、after 指针串在双向有序链表的尾部
双向链表的有序性是在元素插入、删除、更新、查找的过程中动态维护的
不管哪种排序方式(按照插入顺序或访问顺序),将新键值对插入到双向链表的尾部,双向链表仍然有序
将 Entry 节点串在 table 数组对应的链表中的逻辑,已经在 HashMap 中实现,直接复用即可,将 Entry 节点串在双向有序链表尾部的代码如下所示
// link at the end of list 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; } }
我们知道,在插入键值对时,哈希表有可能会扩容,这并不会影响双向有序链表,所以,对于扩容,LinkedHashMap 复用 HashMap 中的逻辑即可,没有任何新增逻辑
3.2、删除键值对
删除键值对时,对应的 Entry 节点会从 table 数组对应的链表和双向有序链表中删除(更加精准的表述应该是脱离,unlink),删除操作不会破坏双向链表的有序性
将 Entry 节点从 table 数组对应链表中的删除的逻辑,已经在 HashMap 中实现,直接复用即可,将 Entry 节点从双向有序链表中删除的代码如下所示
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; }
3.3、修改键对应值
对于哈希表,修改键对应的值,其结构不需要调整,对于双向有序链表,这个要分情况来看
- 如果双向有序链表是按照 "插入顺序来排序" 的,那么不需要对其结构进行调整,双向链表仍然有序
- 如果双向有序链表是按照 "访问顺序来排序" 的,修改键对应的值,也是一种访问
所以,为了保证双向链表的有序性,需要调整双向链表的结构,将这个被修改的节点,移动到双向链表的尾部,对应的代码如下所示
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; } }
3.4、查找键值对
对于按照 "插入顺序排序" 的双向链表,查找键值对不会影响双向链表的有序性
对于按照 "访问顺序排序" 的双向链表,查找键值对时,会将键值对对应的节点,移动到双向链表的尾部
其对应的代码实现,也是上述的 afterNodeAccess() 函数
4、利用 LinkedHashMap 实现 LRU 缓存
LRU 缓存我想你应该不陌生吧,缓存会预设一个大小,当缓存满了之后,优先淘汰访问时间最早的数据
LRU 缓存有以下几个基本操作:查找、插入、更新、删除
其中,插入、删除、更新操作都要涉及查找操作,比如:插入操作需要先查找是否已经插入、删除操作需要先查找到要删除的数据
为了快速查找,我们需要将数据组织成支持快速查找的数据结构,比如哈希表
当插入数据时,如果缓存已满,需要淘汰访问时间最早的数据,如果通过遍历来查找访问时间最早的数据,那么势必会影响插入操作的性能
所以,我们需要维护一种数据结构,能够快速的查找到访问时间最早的数据,比如双向有序链表
而 LinkedHashMap 底层便是哈希表和双向有序链表的结合,所以,利用 LinkedHashMap 可以轻松实现 LRU 缓存
不过,LRU 缓存一般都会限制缓存大小,当缓存超过这个大小限制之后,才会触发淘汰操作
那么,限制缓存大小这部分功能,如何通过 LinkedHashMap 实现呢?
LinkedHashMap 在设计时,已经帮我们考虑到了这一点
在调用 put() 函数添加好元素之后,会调用 afterNodeInsertion() 函数完成一些额外的收尾工作,代码如下所示
如果 removeEldestEntry() 函数返回 true,则会删除双向有序链表中的第一个节点,如果双向有序链表是按照访问时间排序的,那么第一个节点就是访问时间最早的节点
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) { // ... 省略添加元素的操作 ... if (++size > threshold) resize(); afterNodeInsertion(evict); return null; } void afterNodeInsertion(boolean evict) { LinkedHashMap.Entry<K, V> first; // first 为双向链表的头部元素 if (evict && (first = head) != null && removeEldestEntry(first)) { K key = first.key; removeNode(hash(key), key, null, false, true); } }
removeEldestEntry() 函数默认直接返回 false,也就是说,默认情况下,LinkedHashMap 中的 afterNodeInsertion() 函数并不会做任何操作
所以,如果我们希望在缓存满了之后,能够删除访问时间最早的节点,需要重写 removeEldesetEntry() 函数
// 参数为双向链表的头部元素 protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return false; // 默认返回 false }
4.1、继承 LinkedHashMap
综上所述,我们定义了一个 LRUCache 类,让其继承 LinkedHashMap 类,并重写了 removeEldestEntry() 函数,具体代码如下所示
public class LRUCache<K, V> extends LinkedHashMap<K, V> { private int cacheMaxSize; // 缓存大小限制 public LRUCache(int size) { super((int) (size / 0.75f + 1), 0.75f, true); this.cacheMaxSize = size; } // 参数为双向链表的头部元素 @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { // this.size() 返回 LinkedHashMap 中元素的个数 return this.size() > cacheMaxSize; } }
上述代码并不难理解,我们重点解释一下其中的一个细节
通过 super() 调用 LinkedHashMap 的构造函数时,第一个参数 initialCapicity 传入的值为 size / 0.75f + 1,这个值是怎么来的?
- 因为 LinkedHashMap 的动态扩容需要扫描所有的键值对,所以,尽量减少动态扩容能够有效提高 LinkedHashMap 的效率
如果 LinkedHashMap 的 table 数组的大小为 n,那么当元素个数大于 n * loadFactor 时,就会触发动态扩容 - 反过来,如果我们能预估存储在 LinkedHashMap 中的数据量(size),那么可以在创建 LinkedHashMap 对象时,通过调用有参构造函数
指定 initialCapacity 的值为 size / loadFactor + 1(这里的加一是考虑到无法整除,存在四舍五入的情况),这样就不会再触发动态扩容了
4.2、匿名内部类
除了以上实现方式之外,我们还可以使用匿名内部类来实现 LRUCache,具体代码如下所示
public class LRUCache<K, V> { private int cacheMaxSize; // 缓存大小限制 private LinkedHashMap<K, V> map; public LRUCache(int size) { this.map = new LinkedHashMap<K, V>() { @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { // this.size() 返回 LinkedHashMap 中元素的个数 return this.size() > cacheMaxSize; } }; this.cacheMaxSize = size; } public V get(K key) { return map.get(key); } public void put(K key, V value) { map.put(key, value); } public void remove(K key) { map.remove(key); } }
5、课后思考题
1、请借助 HashMap 和 LinkedList 两个容器,重新实现 LinkedHashMap
public class LruCache<K, V> { private int capacity; private int size = 0; private HashMap<K, V> hashMap = new HashMap<>(); private LinkedList<K> linkedList = new LinkedList<>(); public LruCache(int capacity) { this.capacity = capacity; } public void put(K key, V value) { if (hashMap.containsKey(key)) { hashMap.put(key, value); linkedList.remove(key); linkedList.addLast(key); return; } if (size >= capacity) { K removedKey = linkedList.removeFirst(); hashMap.remove(removedKey); size--; } hashMap.put(key, value); linkedList.addLast(key); size++; } public V get(K key) { V value = hashMap.get(key); if (value != null) { linkedList.remove(key); linkedList.addLast(key); } return value; } public void remove(K key) { hashMap.remove(key); linkedList.remove(key); size--; } }
2、对于 LRU 缓存,除了哈希表加双向有序链表这种实现方式之外,还有其他方法哪些实现方式呢?
只要是既支持快速查找,又支持有序遍历的数据结构都可以用来实现 LRU,比如跳表
本文来自博客园,作者:lidongdongdong~,转载请注明原文链接:https://www.cnblogs.com/lidong422339/p/17401979.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步