Java 容器源码分析之 LinkedHashMap
同 HashMap 一样,LinkedHashMap 也是对 Map 接口的一种基于链表和哈希表的实现。实际上, LinkedHashMap 是 HashMap 的子类,其扩展了 HashMap 增加了双向链表的实现。相较于 HashMap 的迭代器中混乱的访问顺序,LinkedHashMap 可以提供可以预测的迭代访问,即按照插入序 (insertion-order) 或访问序 (access-order) 来对哈希表中的元素进行迭代。
1
|
public class LinkedHashMap<K,V>
|
从类声明中可以看到,LinkedHashMap 确实是继承了 HashMap,因而 HashMap 中的一些基本操作,如哈希计算、扩容、查找等,在 LinkedHashMap 中都和父类 HashMap 是一致的。
但是,和 HashMap 有所区别的是,LinkedHashMap 支持按插入序 (insertion-order) 或访问序 (access-order) 来访问其中的元素。所谓插入顺序,就是 Entry 被添加到 Map 中的顺序,更新一个 Key 关联的 Value 并不会对插入顺序造成影响;而访问顺序则是对所有 Entry 按照最近访问 (least-recently) 到最远访问 (most-recently) 进行排序,读写都会影响到访问顺序,但是对迭代器 (entrySet(), keySet(), values()) 的访问不会影响到访问顺序。访问序的特性使得可以很容易通过 LinkedHashMap 来实现一个 LRU(least-recently-used) Cache,后面会给出一个简单的例子。
之所以 LinkedHashMap 能够支持插入序或访问序的遍历,是因为 LinkedHashMap 在 HashMap 的基础上增加了双向链表的实现,下面会结合 JDK 8 的源码进行简要的分析。
底层结构
LinkedHashMap 是 HashMap 的子类,因而 HashMap 中的成员在 LinkedHashMap 中也存在,如底层的 table 数组等,这里就不再说明了。我们重点关注一下 LinkedHashMap 中节点发生的变化。
1
|
/**
|
为了实现双向链表,LinkedHashMap 的节点在父类的基础上增加了 before/after 引用,并且使用 head 和 tail 分别保存双向链表的头和尾。同时,增加了一个标识来保存 LinkedHashMap 的迭代顺序是插入序还是访问序。
由于父类 HashMap 的节点中存在 next 引用,可以将每个桶中的元素都当作一个单链表看待;LinkedHashMap 的每个桶中当然也保留了这个单链表关系,不过这个关系由父类进行管理,LinkedHashMap 中只会对双向链表的关系进行管理。LinkedHashMap 中所有的元素都被串联在一个双向链表中。
双向链表
为了简化对双向链表的操作,LinkedHashMap 中提供了 linkNodeLast 和 transferLinks 方法,分别如下:
1
|
// link at the end of list
|
LinkedHashMap 重写了父类新建节点的方法,在新建节点之后调用 linkNodeLast 方法将新添加的节点链接到双向链表的末尾:
1
|
//覆盖父类方法
|
我们知道,HashMap 中单个桶中的元素可能会在单链表和红黑树之间进行转换,LinkedHashMap 中当然也是一样,不过在转换时还要调用 transferLinks 来改变双向链表中的连接关系:
1
|
//覆盖父类方法
|
如何维护插入序和访问序?
在 LinkedHashMap 中,所有的 Entry 都被串联在一个双向链表中。从上一节的代码中可以看到,每次在新建一个节点时都会将新建的节点链接到双向链表的末尾。这样从双向链表的尾部向头部遍历就可以保证插入顺序了,头部节点是最早添加的节点,而尾部节点则是最近添加的节点。那么,访问顺序要怎么实现呢?
之前我们在分析 HashMap 的源码的时候,在添加及更新、查找、删除等操作中可以看到 afterNodeAccess、afterNodeInsertion、afterNodeRemoval 等几个方法的调用,不过在 HashMap 中这几个方法中没有任何操作。实际上,这几个方法就是供 LinkedHashMap 的重写的,我们不妨看一下在 HashMap 中这几个方法的声明:
1
|
// Callbacks to allow LinkedHashMap post-actions
|
在 LinkedHashMap 中对这几个方法进行了重写:
1
|
//移除节点的回调函数
|
在插入节点、删除节点和访问节点后会调用相应的回调函数。可以看到,在 afterNodeAccess
方法中,如果该 LinkedHashMap 是访问序,且当前访问的节点不是尾部节点,则该节点会被置为双链表的尾节点。即,在访问序下,最近访问的节点会是尾节点,头节点则是最远访问的节点。
在 afterNodeInsertion
中,如果 removeEldestEntry(first)
节点返回 true,则会将头部节点删除。如果想要实现一个固定容量的 Map,可以在继承 LinkedHashMap 后重写 removeEldestEntry
方法。在 LinkedHashMap 中,该方法始终返回 false。
1
|
//返回false
|
在 HashMap 中,在 putVal 和 removeNode 中都调用了相应的回调函数,而 get 则没有,因而在 LinkedHahsMap 中进行了重写:
1
|
public V get(Object key) {
|
遍历及迭代器
因为 LinkeHashMap 的所有的节点都在一个双向链表中,因而可以通过该双向链表来遍历所有的 Entry。而在 HashMap 中,要遍历所有的 Entry,则要依次遍历所有桶中的单链表。相比较而言,从时间复杂度的角度来看,LinkedHashMap 的复杂度为 O(size()),而 HashMap 则为 O(capacity + size())。
1
|
//因为所有的节点都被串联在双向链表中,迭代器在迭代时可以利用双向链表的链接关系进行
|
可以看到,在遍历所有节点时是通过节点的 after 引用进行的。这样,可以双链表的头部遍历到到双链表的尾部,就不用像 HahsMap 那样访问空槽了。
在 containsValue
和 internalWriteEntries
中也使用了双向链表进行遍历。
1
|
public boolean containsValue(Object value) {
|
使用 LinkedHashMap 实现 LRU Cache
LinkedHashMap 的访问序可以方便地用来实现一个 LRU Cache。在访问序模式下,尾部节点是最近一次被访问的节点 (least-recently),而头部节点则是最远访问 (most-recently) 的节点。因而在决定失效缓存的时候,将头部节点移除即可。
但是,由于链表是无界的,但缓存往往是资源受限的,如何确定何时移除最远访问的缓存呢?前面分析过,在 afterNodeInsertion
中,会调用 removeEldestEntry
来决定是否将最老的节点移除,因而我们可以使用 LinkedHashMap 的子类,并重写 removeEldestEntry
方法,当 Enrty 的数量超过缓存的容量是返回 true 即可。
下面给出基于 LinkedHashMap 实现的 LRU Cache 的代码:
public class CacheImpl<K,V> { private Map<K, V> cache; private int capacity; public enum POLICY { LRU, FIFO } public CacheImpl(int cap, POLICY policy) { this.capacity = cap; cache = new LinkedHashMap<K, V>(cap, 0.75f, policy.equals(POLICY.LRU)){ //超出容量就删除最老的值 @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > capacity; } }; } public V get(K key) { if (cache.containsKey(key)) { return cache.get(key); } return null; } public void set(K key, V val) { cache.put(key, val); } public void printKV() { System.out.println("key value in cache"); for (Map.Entry<K,V> entry : cache.entrySet()) { System.out.println(entry.getKey() + ":" + entry.getValue()); } } public static void main(String[] args) { CacheImpl<Integer, String> cache = new CacheImpl(5, POLICY.LRU); cache.set(1, "first"); cache.set(2, "second"); cache.set(3, "third"); cache.set(4, "fourth"); cache.set(5, "fifth"); cache.printKV(); cache.get(1); cache.get(2); cache.printKV(); cache.set(6, "sixth"); cache.printKV(); } }
小结
本文对 JDK 8 中的 LinkedHashMap
的源码及实现进行了简单的分析。LinkedHashMap 继承自 HashMap,并在其基本结构上增加了双向链表的实现,因而 LinkedHashMap 在内存占用上要比 HashMap 高出许多。LinkedHashMap 仍然沿用了 HashMap 中基于桶数组、桶内单链表和红黑树结构的哈希表,在哈希计算、定位、扩容等方面都和 HashMAp 是一致的。LinkedHashMap 同样支持为 null 的键和值。
由于增加了双向链表将所有的 Entry 串在一起,LinkedHashMap 的一个重要的特点就是支持按照插入顺序或访问顺序来遍历所有的 Entry,这一点和 HashMap 的乱序遍历很不相同。在一些对顺序有要求的场合,就需要使用 LinkedHashMap 来替代 HashMap。
由于双向链表的缘故,在遍历时可以直接在双向链表上进行,因而遍历时间复杂度和容量无关,只和当前 Entry 数量有关。这点相比于 HashMap 要更加高效一些。