LinkedHashMap
LinkedHashMap
如何使用LinkedHashMap实现LRU缓存?
上一节我们讲了一个重点容器HashMap,其内部实现比较复杂,工作中和面试中都经常被涉及。本节,我们再讲解一个容易跟HashMap混淆的容器LinkedHashMap。LinkedHashMap是HashMap的增强版,既能实现快速的增删改查操作,又能实现容器内元素的有序遍历。借助这个特性,利用LinkedHashMap可以轻松实现LRU缓存,具体如何来做呢?带着这个问题,我们来学习本节的内容吧。
一、整体结构:哈希表+双向有序链表
上一节,我们讲到,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;
//...省略其他属性和方法...
}
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;
final boolean accessOrder; //在双向链表中的排序方式
}
LinkedHashMap中的Entry继承自HashMap中的Node,Entry中的next指针用来将节点串联在table数组的链表中。Entry中新增的before、after指针,用来将节点串联在一个有序的双向链表中。head、tail便是有序双向链表的头指针和尾指针。accessOrder控制双向链表的排序方式,accessOrder默认为false,此时,双向链表按照节点插入的先后顺序排序。当然,我们也可以通过LinkedHashMap的有参构造函数,将accessOrder设置值为true,此时,双向链表按照节点访问的先后顺序排序。示例代码如下所示。
Map<Integer, String> lmap = new LinkedHashMap<>(16, 0.75f, true);
lmap.put(2, "a"); // 2
lmap.put(5, "b"); // 2->5
lmap.put(18, "c"); // 2->5->18
lmap.put(5, "d"); // 2->18->5
lmap.get(2); // 18->5->2
Set<Entry<Integer, String>> entrySet = lmap.entrySet();
for (Entry<Integer, String> entry : entrySet) {
System.out.println(entry.toString());
}
//输出的结果为:
//18=c
//5=d
//2=a
LinkedHashMap与HashMap、Entry与Node之间的关系及其包含字段的差别,我们用一张图总结了一下,如下所示。

如果我们将键值对<2, "a">、<5, "b">、<18, "c">、<7, "d">、<21, "e">分别存储到HashMap和LinkedHashMap容器中,那么对应的底层存储结构如下图所示。为了简化,下图并非具体的内存结构图,而是经过抽象之后的示意图。在LinkedHashMap容器中,每个节点都有两个“角色”,一个是作为table数组的链表中的节点,一个是作为双向有序链表中的节点。
二、通过entrySet()输出有序的元素集合
尽管哈希表这种数据结构主要是为了快速增删改查,但是为了方便开发,不管是HashMap还是LinkedHashMap,都提供了遍历其内部键值对的方法。在Map接口中,定义了三个方法来返回内部数据,如下所示。
//Map接口中的方法
Set<Map.Entry<K, V>> entrySet();
Set<K> keySet();
Collection<V> values();
这三个方法的用法和实现方法大同小异,我们拿entrySet()举例讲解。如下代码所示。我们通过entrySet(),获取到HashMap中的所有键值对,然后再进行遍历。以下代码包含三种遍历方式。其中,for-each循环只是一个语法糖,其底层通过迭代器来实现(下一节会详细讲解)。forEach()函数是Java8中的引入的函数式编程语法,其作用跟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");
// for-each循环
Set<Entry<Integer, String>> entrySet = map.entrySet();
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()));
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)中的数据,如下所示。
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违反了哈希表不支持遍历的特性。
实际上,在JDK8中,为了支持函数式编程,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.()));
// 在Map上调用forEach()
map.forEach((key, value) -> System.out.println(key + "=" + value));
搞清楚了HashMap的entrySet()之后,我们再来看看values()和keySet(),values()返回的是实现了Collection接口的Values类,keySet()返回的是实现了Set接口的KeySet类。Values和KeySet的设计和实现思路,跟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");
// for-each循环
Set<Entry<Integer, String>> entrySet = map.entrySet();
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().());
}
// forEach()函数,函数式编程,Lambda表达式
entrySet.forEach(e->System.out.println(e.()));
将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;
}
}
三、插入、删除、修改、查找的实现思路
对于LinkedHashMap,插入、删除、修改、查找这4个基本操作,不仅需要操作哈希表,还要操作双向有序链表。因为LinkedHashMap继承自HashMap,其中,操作哈希表的逻辑已经在HashMap中实现了,比如扩容、treeify等,直接复用即可。LinkedHashMap只需要实现操作双向有序链表的逻辑。正因如此,LinkedHashMap中的代码并不多。
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中的逻辑即可,没有任何新增逻辑。
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)修改键对应值
对于哈希表,修改键对应的值,其结构不需要调整。对于双向有序链表,这个要分情况来看。如果双向有序链表是按照插入顺序来排序的,那么不需要对其结构进行调整。双向链表仍然有序。如果双向有序链表是按照访问顺序来排序的,修改键对应的值,也是一种访问,所以,为了保证双向链表的有序性,需要调整双向链表的结构,将这个被修改的节点,移动到双向链表的尾部。对应的代码如下所示。
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;
}
}
4)查找键值对
对于按照插入顺序排序的双向链表,查找键值对不会影响双向链表的有序性。但是,对于按照访问顺序排序的双向链表,查找键值对时,会将键值对对应的节点,移动到双向链表的尾部。其对应的代码实现,也是上述的afterNodeAccess()函数。
四、利用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;
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;
}
综上所述,我们定义了一个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(这里的加一是考虑到无法整除,存在四舍五入的情况),这样就不会再触发动态扩容了。
除了以上实现方式之外,我们还可以使用匿名内部类来实现LRUCache,具体代码如下所示。
public class LRUCache<K, V> {
private int cacheMaxSize;
private LinkedHashMap<K, V> lmap;
public LRUCache(int size) {
this.lmap = 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 lmap.get(key);
}
public void put(K key, V value) {
lmap.put(key, value);
}
public void remove(K key) {
lmap.remove(key);
}
}
五、课后思考题
1)请借助HashMap和LinkedList两个容器,重新实现LinkedHashMap。
2)对于LRU缓存,除了哈希表加双向有序链表这种实现方式之外,还有其他方法哪些实现方式呢?