LinkedHashMap源码解析

LinkedHashMap源码解析

简介

LinkedHashMap 继承于 HashMap,其内部的 Entry 多了两个前驱后继指针,内部额外维护了一个双向链表,能保证元素按插入的顺序访问,也能以访问顺序访问,可以用来实现 LRU 缓存策略

LinkedHashMap 实现了 Map 接口,即允许放入 keynull 的元素,也允许插入 valuenull 的元素。

LinkedHashMap 可以看成是 LinkedList + HashMap,从名字上可以看出该容器是 LinkedList 和 HashMap 的混合体,也就是说它同时满足 HashMap 和 LinkedList 的某些特性。可将 LinkedHashMap 看作采用 LinkedList 增强的 HashMap。

继承体系

6f839a253fa646e099426b81be61300d

LinkedHashMap 继承与 HashMap,核心的增删改查基本还是 HashMap 中的方法,只是 LinkedHashMap 实现了几个钩子函数,可以在添加删除等最后一步调用 LinkedHashMap 实现的钩子函数进行额外的操作,下面会详细讲解。

存储结构

LinkedHashMap-structure

我们知道 HashMap 使用 数组 + 单链表 + 红黑树 的存储结构,那 LinkedHashMap 是怎么存储的呢?

通过上面的继承体系,我们知道它继承了 HashMap,所以它的内部也有这三种结构,但是它还额外添加了一种 “双向链表” 的结构存储所有元素的顺序。

添加删除元素的时候需要同时维护在 HashMap 中的存储,也要维护在 LinkedList 中的存储,所以性能上来说会比 HashMap 稍慢。

简单使用

public class Main {
    public static void main(String[] args) {
        HashMap<String, String> hashMap = new HashMap<>();
        hashMap.put("name", "张三");
        hashMap.put("age", "13");
        hashMap.put("gender", "男");
        System.out.println(hashMap);

        LinkedHashMap<String, String> linkedHashMap = new LinkedHashMap<>();
        linkedHashMap.put("name", "张三");
        linkedHashMap.put("age", "13");
        linkedHashMap.put("gender", "男");
        System.out.println(linkedHashMap);
    }
}

/*
 * 控制台打印
 * {gender=男, name=张三, age=13} // HashMap 无序
 * {name=张三, age=13, gender=男} // LinkedHashMap(默认)按照元素添加的顺序遍历
 */

源码解析

内部类Entry

  	/*
  	 * LinkedHashMap中的Entry节点,继承了HashMap中的Node。
   	 */
	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);
        }
    }

	/*
	 * HashMap中的Node节点
	 */
    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;
        }
    }

存储节点 Entry,继承自 HashMap 的 Node 类,next 用于单链表存储于桶中,before 和 after 用于双向链表存储所有元素。

属性

    /*
     * 双向链表头节点,旧数据存在头节点。
     */
    transient LinkedHashMap.Entry<K,V> head;

    /*
     * 双向链表尾节点,新数据存在尾节点。
     */
    transient LinkedHashMap.Entry<K,V> tail;

    /*
     * 是否需要按访问顺序排序
	 * true:双向链表按照元素的访问顺序排序(LRU)。
	 * false:按照元素添加顺序排序。
	 */
    final boolean accessOrder;

构造方法

  	/*
  	 * 传入初始容量和加载因子。
  	 */
	public LinkedHashMap(int initialCapacity, float loadFactor) {
        // 调用HashMap的构造器
        super(initialCapacity, loadFactor);
        // accessOrder置为false,表示双向链表按照元素的添加顺序构建。
        accessOrder = false;
    }

    public LinkedHashMap(int initialCapacity) {
        super(initialCapacity);
        accessOrder = false;
    }

    public LinkedHashMap() {
        super();
        accessOrder = false;
    }

    public LinkedHashMap(Map<? extends K, ? extends V> m) {
        super();
        accessOrder = false;
        putMapEntries(m, false);
    }
	
	/*
	 * 上面的4个构造器基本都差不多,都直接将accessOrder置为了false。
	 * 而此构造器可以自定义accessOrder。
	 */
    public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        // 为初始容量和加载因子赋值
        super(initialCapacity, loadFactor);
        // 为accessOrder赋值
        this.accessOrder = accessOrder;
    }

前四个构造方法 accessOrder 都等于 false,说明双向链表是按插入顺序存储元素。

最后一个构造方法 accessOrder 从构造方法参数传入,如果传入 true,则就实现了按访问顺序存储元素,这也是实现 LRU 缓存策略的关键

扩展:LRU 缓存机制

在解析核心方法之前,我们先来了解一下什么是 LRU(Least Recently Used)最近最少使用算法,是操作系统中一种常用的页面置换算法,有兴趣的同学们可以去做一下这道题:LeetCode146.LRU缓存,这道题考察的就是数据结构封装的能力,本题使用 HashMap + 手动构建双向链表 实现了一个简单的 LRU 算法,题解参考。做到了题目中要求的 get() 和 put() 方法的平均时间复杂度为 O ( 1 ) O(1) O(1)

其实这道题我们可以直接使用 LinkedHashMap 来实现,(ps:毕竟是算法题,还是得自己实现,不要直接使用LinkedHashMap来实现,不然面试时候直接gg)

  • 使用 LinkedHashMap 就能实现的原理就是 HashMap 中留给 LinkedHashMap 实现的钩子函数。
    • 当进行 put 操作后,如果当前的元素个数大于了规定容量 (LRU 场景下有最大容量) ,那么就调用对应的钩子函数将最不经常使用的节点从链表和 map 中删除(这里是头结点)。
    • 当访问了某个节点后 (一般是修改和查询) 调用对应的钩子函数将此节点插入到链表的结尾,表示当前节点是当前最热门的节点。

当对 LRU 有了一定的认识后,下面的几个钩子方法我们就能很简单的搞定了!

HashMap#put()

LinkedHashMap 没有重写 put() 方法,即调用的还是 HashMap#put() 方法,只是重写了一些方法。

所以我们我们大致看一下 HashMap#put() 方法,详细讲解参考HashMap源码解析

	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) {
       		
        	// 省略了四种情况的添加操作。
        	// 这里是插入操作,调用了newNode()方法。

        	// 这里是替换操作
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                // 钩子方法:访问了该节点后该干什么
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
		// 钩子方法:插入节点后该干什么
        afterNodeInsertion(evict);
        return null;
    }

我们再来看一下 LinkedHashMap 重写的 newNode 方法:

  • 就是先构建一个 LinkedHashMap 内部的 Entry 节点,然后将节点插入到双向链表的末尾。
    Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
    	// 创建的不是HashMap中的Node,而是LinkedHashMap中的Entry节点
        LinkedHashMap.Entry<K,V> p =
            new LinkedHashMap.Entry<K,V>(hash, key, value, e);
        // 将p插入到双向链表的尾部
        linkNodeLast(p);
        return p;
    }
				||
				\/
	// 将节点p插入到双向链表的尾部。
    private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
        // tail是双向链表尾结点,用last存一下
        LinkedHashMap.Entry<K,V> last = tail;
        // tail指向插入节点
        tail = p;
        // last = null 说明双向链表中没有节点
        if (last == null)
            // 将head(头节点)指向p
            head = p;
        // last != null 说明双向链表不为null
        else {
            // 将p的前驱指向原尾结点
            p.before = last;
            // 原尾结点的后继指向p,完成插入。
            last.after = p;
        }
    }

afterNodeInsertion(boolean evict)

  • 在节点插入之后做些什么,在 HashMap 中的 putVal() 方法中被调用,HashMap 中这个方法的实现为空,就是留给 LinkedHashMap 实现的。
  • 即在插入一个节点后是否需要将头结点从链表和 Map 中移除(在实现 LRU Cache 时,如果当前元素个数大于规定的容量,需要将最不常使用的节点删除,此时最不常使用的节点就是头节点)。
   /*
    * 表示在插入节点后该做什么。
    */
	void afterNodeInsertion(boolean evict) { 
        LinkedHashMap.Entry<K,V> first;
        /*
         * evict固定为true。
         * removeEldestEntry默认为false,即不删除头节点,留给我们重写。
         */
        if (evict && (first = head) != null && removeEldestEntry(first)) {
            // 头结点
            K key = first.key;
            /*
             * 调用HashMap的removeNode()方法,将节点从哈希表中删除
             * 此方法中也有一个钩子方法afterNodeRemoval,即将此节点从链表中删除。
             */
            removeNode(hash(key), key, null, false, true);
        }
    }

	/*
	 * 此方法是LinkedHashMap的方法,默认是返回false,留给我们重写,后面实现LRU的核心就是此方法。
	 */
    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }

evict,驱逐的意思。

  1. 如果 evict 为 true,且头节点不为空,且确定移除最老的元素,那么就调用 HashMap.removeNode() 把头节点移除(这里的头节点是双向链表的头节点,而不是某个桶中的第一个元素);
  2. HashMap.removeNode() 从 HashMap 中把这个节点移除之后,会调用 afterNodeRemoval() 方法;
  3. afterNodeRemoval() 方法在 LinkedHashMap 中也有实现,用来在移除元素后修改双向链表,见下文;
  4. 默认 removeEldestEntry() 方法返回 false,也就是不删除元素。

afterNodeAccess(Node<K,V> e)

  • 在节点访问之后被调用,主要在 put() 已经存在的元素或 get() 时被调用,如果 accessOrder 为 true,调用这个方法把访问到的节点移动到双向链表的末尾。
    void afterNodeAccess(Node<K,V> e) { // move node to last
        LinkedHashMap.Entry<K,V> last; // 链表尾结点
        /*
         * 条件1:accessOrder为true,表示按照访问顺序排序
         * 条件2:e这个节点不是尾结点(如果是尾结点的话无需操作)
         */
        if (accessOrder && (last = tail) != e) {
            /*
             * p:当前需要移动的节点
             * b:p的前驱
             * a:p的后继
             */
            LinkedHashMap.Entry<K,V> p =
                (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
            // 将p的后继置为null
            p.after = null;
// ---------将p节点从链表中删除      
            // p的前驱为null,说明p是头结点
            if (b == null)
                // 将头节点变为p的后继
                head = a;
            // p不是头节点
            else
                // 将p的前驱节点的后继指向p的后继,
                b.after = a;
            
            // a不为null,说明p有后继节点
            if (a != null)
                //将p的后继节点的前驱指向p的前驱(完成删除p节点)
                a.before = b;
            else
                last = b;
// ---------将p节点插入到末尾
            if (last == null)
                head = p;
            else {
                // p的前驱指向尾结点
                p.before = last;
                // 尾结点的后继指向p
                last.after = p;
            }
            // 最终的尾结点就是p
            tail = p;
            /*
             * 注意这里modCount会变化(faild-fast机制),
             * 因为当你在遍历LinkedHashMap时,同时有线程访问数据,会造成链表结构发生变化,直接抛异常。
             */
            ++modCount;
        }
    }

(1)如果 accessOrder 为 true,并且访问的节点不是尾节点;

(2)从双向链表中移除访问的节点;

(3)把访问的节点加到双向链表的末尾;(末尾为最新访问的元素)

afterNodeRemoval(Node<K,V> e)

  • 在节点被删除之后调用的方法,在 HashMap 中的 removeNode() 方法中被调用。
    /*
     * 将节点从双向链表中删除。
     */
	void afterNodeRemoval(Node<K,V> e) { 
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        p.before = p.after = null;
        // 把节点p从双向链表中删除。
        if (b == null)
            head = a;
        else
            b.after = a;
        if (a == null)
            tail = b;
        else
            a.before = b;
    }

get(Object key)

  • 查找元素
    public V get(Object key) {
        Node<K,V> e;
        if ((e = getNode(hash(key), key)) == null)
            return null;
        if (accessOrder)
            afterNodeAccess(e);
        return e.value;
    }

如果查找到了元素,且 accessOrder 为 true,则调用 afterNodeAccess() 方法把访问的节点移到双向链表的末尾。

LinkedHashMap 实现 LRU

LinkedHashMap 如何实现 LRU 缓存淘汰策略呢?

关于 LRU,Least Recently Used,最近最少使用算法,也就是优先淘汰最近最少使用的元素。在上面我们已经介绍过了。

经过上方的代码分析,我们发现只需要达成两个条件即可:

  • accessOrder 赋值为 true
  • 重写 removeEldestEntry() 方法

代码实现

import java.util.LinkedHashMap;
import java.util.Map;

public class LRU<K, V> extends LinkedHashMap<K, V> {

    private int maxSize;

    public LRU(int size, float loadFactory) {
        /*
         * 调用LinkedHashMap中唯一的一个可以为accessOrder赋值的构造器
         * 为accessOrder赋值为true,表示链表的顺序为LRU。
         */
        super(size, loadFactory, true);
        // 设定缓存的最大容量
        this.maxSize = size;
    }

    /*
     * 重写removeEldestEntry规定当缓存中元素满时进行删除最不常使用的元素。
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 当元素个数大于了缓存的容量,就移除元素
        return size() > this.maxSize;
    }
}

测试

    public static void main(String[] args) {
        LRU<String, String> cache = new LRU<String, String>(10, 0.75f);
        cache.put("name", "李四"); // name
        cache.put("age", "13");   // name -> age
        cache.put("gender", "男"); // name -> age -> gender
        cache.put("age", "15");   // 这里age使用了 name -> gender -> age
        cache.put("class", "19407"); // name -> gender -> age -> class
        cache.put("name", "张三"); // 这里name使用了 gender -> age -> class -> name

        for (Map.Entry<String, String> e : cache.entrySet()){
        	System.out.println(e.getKey() + "  " + e.getValue());
        }
    }

输出

gender  男
age  15
class  19407
name  张三

如果我们将 LRU 的 size 属性设置为 3,则输出:

age  15
class  19407
name  张三

头结点 gender 被淘汰掉了。

我们使用 LinkedHashMap 实现了 LRU 缓存淘汰策略!

总结

(1)LinkedHashMap 继承自 HashMap,具有 HashMap 的所有特性;

(2)LinkedHashMap 内部维护了一个双向链表存储所有的元素;

(3)如果 accessOrder 为 false,则可以按插入元素的顺序遍历元素;

(4)如果 accessOrder 为 true,则可以按访问元素的顺序遍历元素(LRU);

(5)LinkedHashMap 的实现非常精妙,很多方法都是在 HashMap 中留的钩子(Hook),直接实现这些 Hook 就可以实现对应的功能了,并不需要再重写 put() 等方法;

(6)默认的 LinkedHashMap 并不会移除旧元素,如果需要移除旧元素,则需要重写 removeEldestEntry() 方法设定移除策略;

(7)LinkedHashMap 可以用来实现 LRU 缓存淘汰策略(accessOrder 赋值为 true,重写 removeEldestEntry() 方法);




参考文章

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小成同学_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值