zoukankan      html  css  js  c++  java
  • 刨死你系列——LinkedHashMap剖析(基于jdk1.8)

    一、概述

      1.8版本的LinkedHashMap 继承自 HashMap,在 HashMap(数组链表+红黑树) 基础上,通过维护一条双向链表,解决了 HashMap 不能随时保持遍历顺序和插入顺序一致的问题。除此之外,LinkedHashMap 对访问顺序也提供了相关支持。在一些场景下,该特性很有用,比如缓存。在实现上,LinkedHashMap 很多方法直接继承自 HashMap,仅为维护双向链表覆写了部分方法。所以,在学习LinkedHashMap前,你要先了解HashMap。其结构可能如下图:

    二、源码解析

    2.1 Entry的继承体系

      LinkedHashMap数据结构相比较于HashMap来说,添加了双向指针,分别指向前一个节点——before和后一个节点——after,从而将所有的节点已链表的形式串联一起来,让我们来看一下它们的继承关系。

       HashMap 的内部类 TreeNode 不继承它的了一个内部类 Node,却继承自 Node 的子类 LinkedHashMap 内部类 Entry。这里这样做是有一定原因的,这里先不说。先来简单说明一下上面的继承体系。LinkedHashMap 内部类 Entry 继承自 HashMap 内部类 Node,并新增了两个引用,分别是 before 和 after。这两个引用的用途不难理解,也就是用于维护双向链表。同时,TreeNode 继承 LinkedHashMap 的内部类 Entry 后,就具备了和其他 Entry 一起组成链表的能力。

    2.2成员变量

    具体看下面代码:

    private static final long serialVersionUID = 3801124242820219131L;
    
    // 用于指向双向链表的头部
    transient LinkedHashMap.Entry<K,V> head;
    //用于指向双向链表的尾部
    transient LinkedHashMap.Entry<K,V> tail;
    /**
     * 用来指定LinkedHashMap的迭代顺序,
     * true则表示按照基于访问的顺序来排列,意思就是最近使用的entry,放在链表的最末尾
     * false则表示按照插入顺序来
     */ 
    final boolean accessOrder;

    2.3构造方法

    由于LinkedHashMap继承HashMap,构造方法基本类似,唯一的区别就是添加了前面提到的accessOrder,默认赋值为false——按照插入顺序来排列,这里主要说明一下不同的构造方法。

    //多了一个 accessOrder的参数,用来指定按照LRU排列方式还是顺序插入的排序方式
    public LinkedHashMap(int initialCapacity,
                             float loadFactor,
                             boolean accessOrder) {
       super(initialCapacity, loadFactor);
       this.accessOrder = accessOrder;
     }

    LRU(Least Recently Used)最近最久未使用算法。会在后面介绍该算法.

    2.4 put()方法

    让我们来看一下LinkedHashMap是怎么插入Entry的:LinkedHashMap的put方法调用的还是HashMap里的put,不同的是重写了里面的部分方法,一起来看一下:

    //HashMap的put方法
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
        ...
        tab[i] = newNode(hash, key, value, null);
        ...
        e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        ...
        if ((e = p.next) == null) {
          p.next = newNode(hash, key, value, null);
        ...
            afterNodeAccess(e);
        ...
            afterNodeInsertion(evict);
          return null;
    }

    由于在前面的文章HashMap, 分析过了put方法,这里笔者就省略了部分代码,LinkedHashMap将其中newNode方法以及之前设置下的钩子方法afterNodeAccessafterNodeInsertion进行了重写,从而实现了加入链表的目的。一起来看一下:

    Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
      //秘密就在于 new的是自己的Entry类,然后调用了linkedNodeLast
      LinkedHashMap.Entry<K,V> p =
        new LinkedHashMap.Entry<K,V>(hash, key, value, e);
      linkNodeLast(p);
      return p;
    }
    
    //顾名思义就是把新加的节点放在链表的最后面
    private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
      //将tail给临时变量last
      LinkedHashMap.Entry<K,V> last = tail;
      //把new的Entry给tail
      tail = p;
      //若没有last,说明p是第一个节点,head=p
      if (last == null)
        head = p;
      //否则就做准备工作,你懂的 ( ̄▽ ̄)"
      else {
        p.before = last;
        last.after = p;
      }
    }
    
    //把TreeNode的重写也加了进来,因为putTreeVal里有调用了这个
    TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
      TreeNode<K,V> p = new TreeNode<K,V>(hash, key, value, next);
      linkNodeLast(p);
      return p;
    }
    
    //插入后把最老的Entry删除,不过removeEldestEntry总是返回false,所以不会删除,估计又是一个钩子方法给子类用的
    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);
      }
    }
    
    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
      return false;
    }

    2.5 remove()方法

    与插入操作一样,LinkedHashMap 删除操作相关的代码也是直接用父类的实现。在删除节点时,父类的删除逻辑并不会修复 LinkedHashMap 所维护的双向链表,这不是它的职责。那么删除及节点后,被删除的节点该如何从双链表中移除呢?当然,办法还算是有的。上一节最后提到 HashMap 中三个回调方法运行 LinkedHashMap 对一些操作做出响应。所以,在删除及节点后,回调方法 afterNodeRemoval 会被调用。LinkedHashMap 覆写该方法,并在该方法中完成了移除被删除节点的操作。相关源码如下:

    // HashMap 中实现
    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }
    
    // HashMap 中实现
    final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
        Node<K,V>[] tab; Node<K,V> p; int n, index;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (p = tab[index = (n - 1) & hash]) != null) {
            Node<K,V> node = null, e; K k; V v;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                node = p;
            else if ((e = p.next) != null) {
                if (p instanceof TreeNode) {...}
                else {
                    // 遍历单链表,寻找要删除的节点,并赋值给 node 变量
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode) {...}
                // 将要删除的节点从单链表中移除
                else if (node == p)
                    tab[index] = node.next;
                else
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);    // 调用删除回调方法进行后续操作
                return node;
            }
        }
        return null;
    }
    
    // LinkedHashMap 中覆写
    void afterNodeRemoval(Node<K,V> e) {
      //与afterNodeAccess一样,记录e的前后节点b,a
      LinkedHashMap.Entry<K,V> p =
        (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
      //p已删除,前后指针都设置为null,便于GC回收
      p.before = p.after = null;
      //与afterNodeAccess一样类似,一顿判断,然后b,a互为前后节点
      if (b == null)
        head = a;
      else
        b.after = a;
      if (a == null)
        tail = b;
      else
        a.before = b;
    }

    删除的过程并不复杂,上面这么多代码其实就做了三件事:

    1. 根据 hash 定位到桶位置
    2. 遍历链表或调用红黑树相关的删除方法
    3. 从 LinkedHashMap 维护的双链表中移除要删除的节点

    2.6 get()方法

    默认情况下,LinkedHashMap 是按插入顺序维护链表。不过我们可以在初始化 LinkedHashMap,指定 accessOrder 参数为 true,即可让它按访问顺序维护链表。访问顺序的原理上并不复杂,当我们调用get/getOrDefault/replace等方法时,只需要将这些方法访问的节点移动到链表的尾部即可。相应的源码如下:

    public V get(Object key) {
      Node<K,V> e;
      //调用HashMap的getNode的方法,详见上一篇HashMap源码解析
      if ((e = getNode(hash(key), key)) == null)
        return null;
      //在取值后对参数accessOrder进行判断,如果为true,执行afterNodeAccess
      if (accessOrder)
        afterNodeAccess(e);
      return e.value;
    }

    从上面的代码可以看到,LinkedHashMap的get方法,调用HashMap的getNode方法后,对accessOrder的值进行了判断,我们之前提到:

    //accessOrder为true则表示按照基于访问的顺序来排列,意思就是最近使用的entry,放在链表的最末尾

    由此可见,afterNodeAccess(e)就是基于访问的顺序排列的关键,让我们来看一下它的代码:

    //此函数执行的效果就是将最近使用的Node,放在链表的最末尾
    void afterNodeAccess(Node<K,V> e) {
      LinkedHashMap.Entry<K,V> last;
      //仅当按照LRU原则且e不在最末尾,才执行修改链表,将e移到链表最末尾的操作
      if (accessOrder && (last = tail) != e) {
        //将e赋值临时节点p, b是e的前一个节点, a是e的后一个节点
        LinkedHashMap.Entry<K,V> p =
          (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        //设置p的后一个节点为null,因为执行后p在链表末尾,after肯定为null
        p.after = null;
        //p前一个节点不存在,情况一
        if (b == null) //
          head = a;
        else
          b.after = a;
        if (a != null) 
          a.before = b;
        //p的后一个节点不存在,情况二
        else //
          last = b;
        //情况三
        if (last == null) //
          head = p;
        //正常情况,将p设置为尾节点的准备工作,p的前一个节点为原先的last,last的after为p
        else {
          p.before = last;
          last.after = p;
        }
        //将p设置为将p设置为尾节点
        tail = p;
        // 修改计数器+1
        ++modCount;
      }
    }

    标注的情况如下图所示(特别说明一下,这里是显示链表的修改后指针的情况,实际上在桶里面的位置是不变的,只是前后的指针指向的对象变了):

    下面来简单说明一下:

    • 正常情况下:查询的p在链表中间,那么将p设置到末尾后,它原先的前节点b和后节点a就变成了前后节点。

    • 情况一:p为头部,前一个节点b不存在,那么考虑到p要放到最后面,则设置p的后一个节点a为head
    • 情况二:p为尾部,后一个节点a不存在,那么考虑到统一操作,设置last为b
    • 情况三:p为链表里的第一个节点,head=p

    2.7 基于 LinkedHashMap 实现缓存

    在上节中,说到LRU算法,我I们通过继承LinkedHashMap实现了一个简单的 LRU 策略的缓存。在实践前我们要补充部分知识:

    void afterNodeInsertion(boolean evict) { // possibly remove eldest
        LinkedHashMap.Entry<K,V> first;
        // 根据条件判断是否移除最近最少被访问的节点
        if (evict && (first = head) != null && removeEldestEntry(first)) {
            K key = first.key;
            removeNode(hash(key), key, null, false, true);
        }
    }
    
    // 移除最近最少被访问条件之一,通过覆盖此方法可实现不同策略的缓存
    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }

    上面的源码的核心逻辑在一般情况下都不会被执行,所以之前并没有进行分析。上面的代码做的事情比较简单,就是通过一些条件,判断是否移除最近最少被访问的节点。看到这里,大家应该知道上面两个方法的用途了。当我们基于 LinkedHashMap 实现缓存时,通过覆写removeEldestEntry方法可以实现自定义策略的 LRU 缓存。比如我们可以根据节点数量判断是否移除最近最少被访问的节点,或者根据节点的存活时间判断是否移除该节点等。本节所实现的缓存是基于判断节点数量是否超限的策略。在构造缓存对象时,传入最大节点数。当插入的节点数超过最大节点数时,移除最近最少被访问的节点。实现代码如下:

    //作者:https://segmentfault.com/a/1190000012964859
    public
    class SimpleCache<K, V> extends LinkedHashMap<K, V> { private static final int MAX_NODE_NUM = 100; private int limit; public SimpleCache() { this(MAX_NODE_NUM); } public SimpleCache(int limit) { super(limit, 0.75f, true); this.limit = limit; } public V save(K key, V val) { return put(key, val); } public V getOne(K key) { return get(key); } public boolean exists(K key) { return containsKey(key); } /** * 判断节点数是否超限 * @param eldest * @return 超限返回 true,否则返回 false */ @Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) { return size() > limit; } }

    测试代码如下:

    public class SimpleCacheTest {
    
        @Test
        public void test() throws Exception {
            SimpleCache<Integer, Integer> cache = new SimpleCache<>(3);
    
            for (int i = 0; i < 10; i++) {
                cache.save(i, i * i);
            }
    
            System.out.println("插入10个键值对后,缓存内容:");
            System.out.println(cache + "
    ");
    
            System.out.println("访问键值为7的节点后,缓存内容:");
            cache.getOne(7);
            System.out.println(cache + "
    ");
    
            System.out.println("插入键值为1的键值对后,缓存内容:");
            cache.save(1, 1);
            System.out.println(cache);
        }
    }

    测试结果:

     不过笔者自己也通过继承LinkedHashMap实现了LRU算法,感兴趣的小伙伴可以看看!

    2.8 小结

    本文对 LinkedHashMap 的源码put,get,remove进行了分析,并在文章的结尾基于 LinkedHashMap 实现了一个简单的 Cache。在日常开发中,LinkedHashMap 的使用频率虽不及 HashMap,但它也个重要的实现。在 Java 集合框架中,HashMap、LinkedHashMap 和 TreeMap 三个映射类基于不同的数据结构,并实现了不同的功能。HashMap 底层基于拉链式的散列结构,并在 JDK 1.8 中引入红黑树优化过长链表的问题。基于这样结构,HashMap 可提供高效的增删改查操作。LinkedHashMap 在其之上,通过维护一条双向链表,实现了散列数据结构的有序遍历。TreeMap 底层基于红黑树实现,利用红黑树的性质,实现了键值对排序功能。


    由于个人能力问题,先学习这些,数据结构这个大山,我一定要刨平它。

    参考博客:https://segmentfault.com/a/1190000012964859

        https://blog.csdn.net/ShelleyLittlehero/article/details/82957336

  • 相关阅读:
    webpack基本使用笔记
    gulp学习记录
    页面优化
    linux下使用indent整理代码
    C++中的getline()
    Sum of Two Integers
    TwoSum
    IDEA个人常用快捷键总结
    mysql数据库遇到的各种问题
    Python中*args 和**kwargs的用法和区别
  • 原文地址:https://www.cnblogs.com/Young111/p/11482200.html
Copyright © 2011-2022 走看看