LinkedHashMap的源码解析及与LRU缓存实现

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的大概结构图:

LinkedHashMap的源码解析及与LRU缓存实现

感谢你看到这里,我是程序员麦冬,一个java开发从业者,深耕行业六年了,每天都会分享java相关技术文章或行业资讯

欢迎大家关注和转发文章,后期还有福利赠送!

你可能感兴趣的:(LinkedHashMap的源码解析及与LRU缓存实现)