1 LinkedHashMap的概述
- public class LinkedHashMap
- extends HashMap
- implements Map
LinkedHashMap来自于JDK1.4,直接继承自 HashMap,在 HashMap 基础上,通过维护一张基于整个哈希表的大双链表,解决了HashMap遍历元素时无序的问题。
LinkedHashMap还能基于元素访问时间的先后顺序迭代元素,可用于实现简单的LRU缓存。LinkedHashMap的默认构造实现是按插入顺序迭代的。
由于继承了HashMap,LinkedHashMap 很多方法直接使用 HashMap的实现,仅为维护总双链表覆写了部分方法。所以,要彻底看懂 LinkedHashMap 的源码,需要先看懂 HashMap 的源码。
2 LinkedHashMap的源码解析
LinkedHashMap的代码很少,主要是大部分方法直接使用的父类HashMap的代码,我们主要看LinkedHashMap自己的源码!
2.1 主要类属性
除了继承了HashMap的属性,比如size、table等,LinkedHashMap类中还增加了3个属性用于实现保证元素顺序,分别是双向链表头节点引用header,双向链表尾节点引用tail和 排序模式标志为accessOrder 。accessOrder值为true时,表示按照访问顺序模式迭代;值为false时,表示按照插入顺序模式迭代。
//用来指向双向链表的头节点transient LinkedHashMap.Entry head;//用来指向双向链表的尾节点transient LinkedHashMap.Entry tail;//排序方式:true:访问顺序迭代,false:插入顺序迭代。final boolean accessOrder;
2.2 构造器
2.2.1 LinkedHashMap()
- public LinkedHashMap()
默认无参构造器,构造一个带默认初始容量 (16) 和加载因子 (0.75) 的空LinkedHashMap 实例。除了调用父类无参构造器之外,还设置accessOrder=false,这表明使用插入顺序遍历元素!
public LinkedHashMap() { //调用父类HashMap的无参构造器 super(); accessOrder = false;}
2.2.2 LinkedHashMap(initialCapacity)
- public LinkedHashMap(int initialCapacity)
构造一个带指定初始容量和默认加载因子 (0.75) 的LinkedHashMap 实例。除了调用父类相应的构造器之外,还设置accessOrder=false,这表明使用插入顺序遍历元素!
public LinkedHashMap(int initialCapacity) { super(initialCapacity); accessOrder = false;}
2.2.3 public LinkedHashMap(initialCapacity, loadFactor)
构造一个带指定初始容量和加载因子的空插入顺序 LinkedHashMap 实例。除了调用父类相应的构造器之外,还设置accessOrder=false,这表明使用插入顺序遍历元素!
public LinkedHashMap(int initialCapacity, float loadFactor) { super(initialCapacity, loadFactor); accessOrder = false;}
2.2.4 LinkedHashMap(initialCapacity,loadFactor,accessOrder)
构造一个带指定初始容量、加载因子和排序模式的空 LinkedHashMap 实例。
public LinkedHashMap(int initialCapacity,float loadFactor, boolean accessOrder) { super(initialCapacity, loadFactor); this.accessOrder = accessOrder;}
2.2.5 LinkedHashMap(m)
public LinkedHashMap(Map m)
构造一个映射关系与指定映射相同的插入顺序 LinkedHashMap 实例。所创建的 LinkedHashMap 实例具有默认的加载因子 (0.75) 和足以容纳指定映射中映射关系的初始容量。设置accessOrder=false,这表明使用插入顺序遍历元素!
public LinkedHashMap(Map m) { super(); accessOrder = false; //调用父类的方法 putMapEntries(m, false);}
2.3 常见API方法
LinkedHashMap的大部分方法的主体结构完全是使用的父类HashMap的方法。比如put、remove:
/** * 父类HashMap实现的方法 */public V put(K key, V value) { return putVal(hash(key), key, value, false, true);}/** * 父类HashMap实现的方法 */public V remove(Object key) { HashMap.Node e; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value;}
那么LinkedHashMap有没有自己的方法呢?当然有,并且还有一个特点,那就是这些方法和自己的三个属性有关,比如get、containsValue、clear方法等:
/** * LinkedHashMap重写的get方法,增加了accessOrder的判断 */public V get(Object key) { Node e; //调用父类的getNode方法,尝试查找key相同的节点 if ((e = getNode(hash(key), key)) == null) return null; //getNode找到节点之后通过判断标志位,来判断是否调用afterNodeAccess回调方法 if (accessOrder) afterNodeAccess(e); return e.value;}/** * LinkedHashMap重写的containsValue方法 * 我们知道,在父类中的containsValue方法实际上也是顺序遍历全部哈希表,由于子类LinkedHashMap将整个哈希表变成了一张大的链表 * 因此只需要遍历这一张大链表就行了! */public boolean containsValue(Object value) { /*循环大链表,尝试查找value相同的节点*/ for (LinkedHashMap.Entry e = head; e != null; e = e.after) { V v = e.value; //判断value相同的要求是==返回true或者equals方法返回true if (v == value || (value != null && value.equals(v))) return true; } return false;}/** * LinkedHashMap重写的clear方法,增加了大链表头尾节点head、tail置空的语句 */public void clear() { //调用父类的clear方法 super.clear(); //自身维护的大链表头尾节点head、tail置空 head = tail = null;}
2.4 大链表与迭代顺序的维护
这个大链表,就是我们所说的基于整张哈希表的链表,维护了LinkedHashMap的迭代顺序。
2.4.1 linkNodeLast方法
LinkedHashMap是通过linkNodeLast方法构建最初的大链表的,该方法是LinkedHashMap自己的方法:
/** * 新节点链接到大链表末尾 * * @param p 新节点 */private void linkNodeLast(LinkedHashMap.Entry p) { LinkedHashMap.Entry last = tail; //如果tail和head都为null,那么新添加第一个节点时,tail和head都指向该节点 tail = p; if (last == null) head = p; /*否则,将新节点链接到大链表末尾,新节点成为新的tail节点*/ else { p.before = last; last.after = p; }}
很明显,新节点被链接到大链表末尾。该方法在newNode和newTreeNode方法中被调用到:
/** * 在插入新普通节点时调用 */Node newNode(int hash, K key, V value, Node e) { LinkedHashMap.Entry p = new LinkedHashMap.Entry(hash, key, value, e); //最终调用linkNodeLast将新节点链接到大链表末尾 linkNodeLast(p); return p;}/** * 在插入新红黑树节点时调用 */TreeNode newTreeNode(int hash, K key, V value, Node next) { TreeNode p = new TreeNode(hash, key, value, next); //最终调用linkNodeLast将新节点链接到大链表末尾 linkNodeLast(p); return p;}
上面的两个方法原本是父类的方法,在插入新节点时,用于创建新节点,LinkedHashMap对其进行了重写,主要是新增了linkNodeLast方法的调用,这样就维护了节点在大链表之中的关系!
2.4.2 afterNodeRemoval方法
上面讲了大链表节点的插入,自然可以删除,能想到,大链表节点的移除也是在remove方法被调用时一并进行的。
LinkedHashMap的remove方法和get方法一样,都是调用父类的方法,父类的删除方法并没有删除大链表节点之间的关系。可以想到,大链表的删除也是重写了某个私有方法,而父类的remove方法中调用了该方法来进行大链表节点的删除!
与大链表的创建不同,大链表节点的删除并没有自己实现方法,而是重写了父类的afterNodeRemoval方法,该方法在节点被成功移除之后调用。
/** * 该方法在父类HashMap的removeNode方法中,在移除节点之后会被调用,但是HashMap是一个空实现 * * @param p 被删除的节点 */void afterNodeRemoval(Node p) {}/** * 子类LinkedHashMap重写了afterNodeRemoval方法,用来实现删除大链表的节点 * * @param e 被删除的节点 */void afterNodeRemoval(HashMap.Node e) { //p保存e,b是p在大链表中的前驱,a是p在大链表中的后继 LinkedHashMap.Entry p = (LinkedHashMap.Entry) e, b = p.before, a = p.after; //前驱后继置空 p.before = p.after = null; //如果前驱为null if (b == null) //那么后继为大链表头节点 head = a; else //p的前驱的后继指向p的后继 b.after = a; //如果后继为null if (a == null) //那么b为大链表尾节点 tail = b; else //p的后继的前驱指向p的前驱 a.before = b;}
2.4.3 afterNodeAccess方法
LinkedHashMap维护了两种迭代顺序,一种是插入迭代顺序,一种是访问迭代顺序,它们是通过标志位accessOrder区分的,accessOrder在构造LinkedHashMap时就设置了值,默认是false,即元素插入顺序,也可以手动设置为true,即元素访问顺序。
我们知道LinkedHashMap使用一张大链表串联起整个哈希表来维护迭代顺序,那么具体怎么实现的呢?在上面的大链表的构建和删除的源码看起来,似乎仅仅是元素插入的顺序,并且也没有使用到accessOrder标志,那么具体怎么实现访问顺序迭代的呢?
实际上,迭代顺序的实现主要是和afterNodeAccess方法有关!
afterNodeAccess方法会在一个元素节点被访问到时被调用,但是HashMap只提供一个空实现。比如put、replace、merge、get等方法,注意一定是在节点被访问到之后调用,比如get查找某个节点,没有找到的话是不会调用的!
LinkedHashMap 中覆写了afterNodeAccess方法。在LinkedHashMap的重写中,当一个节点被访问时,如果 accessOrder 为 true,则会将该节点移到链表尾部。也就是说指定为 LRU 顺序之后,在每次访问一个节点时,会将这个节点移到链表尾部,保证链表尾部是最近最新访问的节点,那么链表首部就是最远最久未使用的节点。
/** * 在元素被访问时,会调用afterNodeAccess方法,HashMap中的方法为空实现 * * @param p 被访问的节点 */void afterNodeAccess(Node p) {}/** * LinkedHashMap 中重写的afterNodeAccess方法,用于将被访问到的节点移动到大链表末尾 * * @param e 被访问的节点 */void afterNodeAccess(Node e) { // move node to last LinkedHashMap.Entry last; /*如果e不是尾节点,那么尝试移动e到尾部*/ if (accessOrder && (last = tail) != e) { //p记录e,b保存p在大链表中的前驱,a保存p在大链表中的后继 LinkedHashMap.Entry p = (LinkedHashMap.Entry) e, b = p.before, a = p.after; //p的后继置空 p.after = null; //如果b为null,表明p为头节点 if (b == null) //头节点设置为p的后继a head = a; else //否则b的后继设置为a b.after = a; /*如果a不为null,a的前驱设置为b*/ if (a != null) { a.before = b; } /*否则,尾节点设置为b*/ else { last = b; } //如果,last为null if (last == null) //那么头节点指向p head = p; else { /*否则,将p链接在链表的最后*/ p.before = last; last.after = p; } //尾节点指向p tail = p; ++modCount; }}
3 LinkedHashMap与LRU缓存
LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。
最常见的实现是使用一个链表保存缓存数据,详细算法实现如下:
- 新数据插入到链表头/尾部;
- 每当缓存命中(即缓存数据被访问),则将数据移到链表头/尾部;
- 指定LRU缓存的容量,当链表长度大于容量时,将链表头/尾部的数据丢弃。
我们的LinkedHashMap已经提供了基于访问顺序的迭代机制,最近被访问的节点在尾部,最远被访问的节点在头部.那么自然可以实现LRU缓存,当然它的实现和下面这两个方法有关。
3.1 afterNodeInsertion方法
在插入元素操作之后,不光会调用linkNodeLast方法,在成功插入节点的情况下,在最后还会调用afterNodeInsertion方法,并传递evict=true(构造器中插入节点是传递evict=false)。同样HashMap同样只提供一个空实现。
比如put方法,存在两个情况,一种是替换value,一种是插入新节点,如果替换value,那么肯定访问到了某个节点,此时调用afterNodeAccess,如果是插入新节点,那么肯定是调用afterNodeInsertion方法,这两个方法不可能同时调用!
在LinkedHashMap重写的实现中,当内部的removeEldestEntry()方法返回 true 时会移除最远最久未访问的节点,也就是链表首部节点 head。evict 只有在构建 Map 的时候才为 false,在单独调用方法时为 true。
这个方法在插入节点之后调用,明显是因为LUR缓存容量有限制,新插入节点之后有可能需要移除最远最久未访问的节点。
/** * HashMap提供的空实现 * * @param evict 构造器中传递false,单独调用方法传递true */void afterNodeInsertion(boolean evict) {}/** * LinkedHashMap重写的实现 * * @param evict 构造器中传递false,单独调用方法传递true */void afterNodeInsertion(boolean evict) { LinkedHashMap.Entry first; //如果evict为true,并且大链表头节点不为null,并且removeEldestEntry(first)方法返回true if (evict && (first = head) != null && removeEldestEntry(first)) { K key = first.key; //那么调用removeNode移除头节点,这一移除方法中具有afterNodeRemoval方法 removeNode(hash(key), key, null, false, true); }}
3.2 removeEldestEntry方法
我们看到afterNodeInsertion方法内部调用了removeEldestEntry方法并以返回值作为是否需要移除头节点的判断条件之一。
removeEldestEntry方法是LinkedHashMap 自己的方法,并且还是一个抽象方法。 默认返回false,如果需要让它返回true或者根据代码返回,需要继承 LinkedHashMap 并且覆盖这个方法的实现。
该方法在实现 LRU 的缓存中特别有用,在该方法中可以设置缓存容量,然后比较节点总数和缓存容量的大小,当节点总数超过缓存容量时可以返回true(因为新增节点成功之后会调用afterNodeInsertion方法),然后通过移除最近最久未使用的节点(头节点),从而保证缓存空间足够,并且缓存的数据都是热点数据。
/** * 移除最近最少被访问条件之一,通过覆盖此抽象方法可实现不同策略的缓存 * * @param eldest 大链表头节点 * @return true,移除 false,不移除 */protected boolean removeEldestEntry(Map.Entry eldest) { return false;}
3.3 LRU缓存实现案例
当我们基于 LinkedHashMap实现缓存时,通过继承LinkedHashMap并且覆写removeEldestEntry方法,再构造对象是设置accessOrder为true,可以实现自定义策略的 LRU 缓存。比如我们可以根据节点数量判断是否移除最近最少被访问的节点,或者根据节点的存活时间判断是否移除该节点等。
案例:
/** * 简单的LRU缓存,通过继承LinkedHashMap来实现 */class LRUCache extends LinkedHashMap { /** * 缓存容量 */ private int maxEntries; /** * 构造器 * * @param maxEntries 最大容量 */ LRUCache(Integer maxEntries) { //调用父类构造器 super(maxEntries, 0.75f, true); this.maxEntries = maxEntries; } /** * 通过重写removeEldestEntry方法,加入一定的条件,满足条件返回true。 * * @param eldest 大链表头节点 * @return true,表示允许移除头节点;false,表示不允许移除头节点 */ @Override protected boolean removeEldestEntry(Map.Entry eldest) { //如果节点数量大于LRU缓存容量,那么返回true return size() > maxEntries; } /** * 测试 */ public static void main(String[] args) { //新建LRUCache,容量为5,首先循环存放十次 LRUCache cache = new LRUCache<>(5); for (int i = 0; i < 10; i++) { cache.put(i, i * i); } System.out.println("调用10次插入方法后,缓存的内容======>"); System.out.println(cache + "\n"); System.out.println("访问键为7的节点后,缓存内容======>"); cache.get(7); System.out.println(cache + "\n"); System.out.println("访问键为1的节点后,缓存内容======>"); cache.get(1); System.out.println(cache + "\n"); System.out.println("插入键值为1的键值对后,缓存内容======>"); cache.put(1, 1); System.out.println(cache); System.out.println("删除键为6的键值对后,缓存内容:"); cache.remove(6); System.out.println(cache); System.out.println("插入键值为7的键值对后,缓存内容:"); cache.put(7, 7); System.out.println(cache); }}
4 LinkedHashMap的总结
LinkedHashMap底层基于整个HashMap的哈希表维护了一张大链表,保证了所有元素的迭代顺序,可以是插入顺序,也可以是访问顺序。有了对LinkedHashMap和HashMap源码的认识,我们可以画出LinkedHashMap的大概结构图:
感谢你看到这里,我是程序员麦冬,一个java开发从业者,深耕行业六年了,每天都会分享java相关技术文章或行业资讯
欢迎大家关注和转发文章,后期还有福利赠送!