LinkedHashMap源码解析jdk1.8

前言

上一篇我们分析了HashMap在jdk1.8中的实现。我们知道了HashMap在实际操作中并不会保证键值对的有序。所以针对这一问题jdk提供了LinkedHashMap来实现有序的哈希表。因为是解析所以我们尽量做到详尽,下面对源码类申明语句之前的英文注释做个翻译(不要小看这些东西,他往往介绍了类设计的原则和各种注意事项,对我们阅读源码大有裨益):
1.LinkedHashMap是Map接口有序的哈希表的实现,它的迭代顺序是可以预测的。他与HashMap的区别是它维护着一个贯穿所有条目的双向链表。这个链表定义了迭代次序,通常这个次序就是键插入map的次序。注意当键再次被插入时,插入顺序并不会受到影响(这点我们将会在后文论证)。
2.通常,此实现使客户机免于未指定的由HashMap或者HashTable提供的混乱排序。它可被用于提供一个与原map顺序一致的map的复制,并且忽略原有map的实现
3.如果模块对输入进行映射,这种技术尤为有用。复制它,然后返回次序由被复制对象决定的结果,客户方通常会希望看到被返回的东西和传递时有同样的次序。
4.一个特殊的构造方法LinkedHashMap(int,float,boolean)被提供是为了创建一个map他的次序是他的条目最后一次被访问时的顺序。LinkedHashMap提供两种迭代次序分别是插入顺序和访问顺序。
5.removeEldestEntry(Map.Entry)该方法应该被重写以保证自动地移除过期地键值对当新的键值对被加入map时。
6.此类提供了所有可供选择地map集合地操作,并且允许null值的存在,就像HashMap一样。对于添加、包涵、移除等操作提供了常数时间,前提是假定键值对均匀地分布在map中,跟HashMap比表现稍差。除了一个例外:LinkedHashMap迭代所有的集合视图所需要的时间是跟size正相关,而不是capacity,而HashMap正好相反。所以HashMap的相关操作会更为昂贵。这点也比较容易理解,HashMap中节点与节点的关系是模糊的,只有在长度大于1的Node数组中,节点之间的关系才是确定的,即可以通过当前节点的next属性找到下一个节点。但数组之间并没有什么联系,所以迭代集合视图的时候除了数组内,得扫描map的每个数组地址。而LinkedhashMap通过指定当前节点的前后节点使得迭代视图变得非常迅速和方便,自然性能更加优越。
7.和HashMap一样影响LinkedHashMap性能的也是初始容量和加载因子。只是与HashMap相比,指定较高的初始容量的LinkedHashMap的性能要优于HashMap,因为前者受capacity的影响较小
8.当然,该类也是线程不安全的,当多个线程并发访问,且至少其中一个线程在结构上修改了它,必须需要进行外部的同步。通常会将其委托给已有的线程安全的类来管理。如若这样的类不存在,可以使用包装的方式Collections#synchronizedMap Collections.synchronizedMap,此操作最好是在map创建时期:Map m = Collections.synchronizedMap(new LinkedHashMap(…));
9.结构上发生改变是指任何增加,删除一个或者多个映射,或者在access-ordered 的map中影响到了迭代次序。在insertion-ordered 的map中仅仅修改map中包涵的某个键所对应的值并不是结构上的修改,在access_orderd的map中,仅仅是查询map的get操作也是结构性改变。(什么意思?什么是access-order,什么是inserion-order。前者代表该类中节点的组织方式以最近访问次序为主,后者表示组织方式以插入顺序为主。这样就好理解在访问次序模式下,查询操作为什么是结构性变化了,因为最近访问的总会被置于链表的末尾)
10.返回包涵map视图集合的iterator方法遵循快速失败机制,如果在迭代器被创建之后map发生了结构性的改变,不包括iteartor自己的remove方法,会立即抛出ConcurrentModificationException并发修改异常。
11.注意迭代器的快速失败机制并不能总是如他所保证的那样,通常来说,总是不要作出果断的保证尤其在多线程并发访问下的结构性修改发生时。快速失败机制只是为了更好的调试bug而已。


构造方法

为了方便,对于代码的解释直接在代码中进行

 public LinkedHashMap() {//无参数构造方法
        super();//因为是继承自HashMap所以默认执行HashMap的构造方法,我们知道HashMap中是创建了一个默认初始加载因子为0.75的map
        accessOrder = false;//此处重写了无参构造,accessOrder代表什么呢?这个代表了LinkedHashMap的迭代排序方法。true为access-order,false为insertion-order。
    }
    ——————————————————————————————————————————————————————————————————————
    //构造方法2
    public LinkedHashMap(int initialCapacity) {//接受了一个int类型的初始容量
        super(initialCapacity);//调用HashMap的构造方法,该方法内部调用了
        //HashMap(int initialCapacity, float loadFactor)方法,其实最终也是创建
        //了默认加载因子为0.75,初始化容量为initialCapacity的map只是会重新计算阀值。
        accessOrder = false;
    }
    ——————————————————————————————————————————————————————————————————————————
    //构造方法3
     public LinkedHashMap(int initialCapacity, float loadFactor) {//接受两个参数分别是初始化容量和加载因子
        super(initialCapacity, loadFactor);//创建指定容量和加载因子的map
        accessOrder = false;
    }
    ——————————————————————————————————————————————————————————————————————————
    构造方法4
     public LinkedHashMap(Map<? extends K, ? extends V> m) {//接收一个map
        super();//调用父类无参构造
        accessOrder = false;
        putMapEntries(m, false);//主要操作其实都是在这个里面完成的,首先会对m的size
        //判断,大于0时再通过它确定最大容量、阀值。再通过map.entrySet()方法得到entry集合
        //遍历集合往新的map中put数据
    }
    ——————————————————————————————————————————————————————————————————————————
    //构造方法5
     public LinkedHashMap(int initialCapacity,//此方法接收三个参数初始容量
                         float loadFactor,//加载因子
                         boolean accessOrder) {//排序方式
        super(initialCapacity, loadFactor);//创建指定容量和指定加载因子的map
        this.accessOrder = accessOrder;//指定排序方式
    }
  • 和HashMap一样单看其构造方法除了要设定一个私有的成员变量accessOrder外,并没有什么特别的地方。所以跟HashMap一样,真正在内存开辟空间,并以特有的方式组织数据发生在put操作,让我们来看看put方法。通过翻阅源码我们发现LinkedHashMap并没有重写put方法,那么他是如何实现有序的呢?因为调用put方法会默认调用putVal方法所以我们直接看看putVal方法中做了什么。通过阅读源码我们发现其实区别在于方法中所创建的Node对象的不同。
  • LinkedHashMap中的内部类Entry继承了HashMap中的Node。但是区别在于其新增了两个节点before 和after分别表示当前节点的上一个节点和下一个节点。如下:
	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);
        }
    }
  • 看着这段代码有人肯定会觉得很迷惑,entry中同时定义了3个entry,字面意思分别代表前一个,之后的和下一个。before容易理解,但是有next节点为什么还要有after节点呢。在HashMap中我们知道next节点所指向的节点在节点链表末尾都为null。即只有在节点链表中才能通过某个节点找到下一个节点。并且扩容操作发生时这种关系会重新生成,所以HashMap是无序的,且迭代时间取决于容量而不是size。好说了这么多在LinkedHashMap中到底是如何处理after节点的呢。其实在eclipse中通过断点调试的方式可以轻松地获取到put方法的执行逻辑。通过断点调试我们发现其实LinkedHashMap调用put方法时与其父类的主要区别时在newNode()这个方法。LinkedHashMap中的newNode()源码如下:
 Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {//此方法重写了HashMap的方法
        LinkedHashMap.Entry<K,V> p =
            new LinkedHashMap.Entry<K,V>(hash, key, value, e);//申明了一个内部成员类用来存储键值对,该类也是继承自HashMap.Node
        linkNodeLast(p);//这个方法应该是关键我们看看到底做了什么
        return p;
    }
    ————————————————————————————————————————————————————————————————————————
    //linkNodeLast方法
        private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
        LinkedHashMap.Entry<K,V> last = tail;//申明一个entry并指向tail,什么是tail呢
        //直译过来就是尾巴的意思他是LinkedHashMap的成员变量,代表双向链表中最年轻的节点,我们不难推测出最新添加的节点总会是在双向链表的末尾。
        tail = p;//将tail的引用更新为新的节点。其实上面两句综合起来的作用就是将更新尾节点
        if (last == null)//很显然第一次添加时该条件成立
            head = p;//与tail对应,head则表示双向链表中最先加入的节点。此时双向链表中只有一个节点所以p既是头节点也是尾节点
        else {
            p.before = last;
            last.after = p;//这两句还是比较好理解的,将最新添加的节点的上一个节点指定为原来tail指向的节点,而之前的尾节点的下一个节点指定为新添加的节点
            //
        }
    }
  • 我们在HashMap的源码分析一节中发现了三个没有方法体的函数
    void afterNodeAccess(Node<K,V> p) { }
    void afterNodeInsertion(boolean evict) { }
    void afterNodeRemoval(Node<K,V> p) { }
  • 他们在LinkedHashMap中都有了具体的实现通过追踪putVal()方法我们发现当插入当前map中并不存在的键的键值对时会调用afterNodeInsertion方法,下面是该方法的具体实现:
  void afterNodeInsertion(boolean evict) { // possibly remove eldest
        LinkedHashMap.Entry<K,V> first;//新建一个entry
        if (evict && (first = head) != null && removeEldestEntry(first)) {//此时evict为 true,head不为null,所以只要满足removeEldestEntry(first)为true
        //就可以执行下面的操作我们首先看看该方法做了什么
            K key = first.key;
            removeNode(hash(key), key, null, false, true);//移除头节点
        }
    }
    ————————————————————————————————————————————————————————————————————
     protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }
    //什么!该方法直接返回了false所以说这个if语句中的方法体永远不会执行,那么设计这个方法的用意在哪里,
    //其实之所以提供这个方法是为了继承了LinkedHashMap的类可以重写这个方法以便实现将过老的键值对移除,来降低内存开销。当然前提是该方法返回值为true。
  • 以上我们似乎只是处理了插入时的一种情况,即插入的键在已经插入的map中没有重复。所以当键重复时它与其父类HashMap的区别在哪里呢——afterNodeAccess()方法。为什么要提供这个方法呢。按照文章前言所说的,当键再次被插入时,插入顺序并不会受到影响,newNode()方法也不会被执行,该节点在链表中是旧有的次序。那么为什还要提供这样的一个方法,前面说过LinkedhashMap有两种迭代次序:insertion-order和access-order,当accessOrder为 true时表示当前顺序为access-order,此时,键的再次插入将被看成是对访问顺序的修改所以需要将其移动到链表的末尾。看源码:
void afterNodeAccess(Node<K,V> e) { // move node to last
        LinkedHashMap.Entry<K,V> last;//创建新的
        if (accessOrder && (last = tail) != e) {//当迭代次序为访问顺序
            LinkedHashMap.Entry<K,V> p =
                (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
                //将参数e的值赋给新创建的p,b指向e的上一个节点,a指向e的下一个节点
            p.after = null;//这个很好理解,既然要将e节点置于链表末端,自然他没有下一个节点。
            if (b == null)//这里我们先分析一下e在链表中的位置1.头节点2.尾节点3.在链表中部
            当b为null说明e为头节点,那么他被移走后他的下一个节点理应成为头节点
                head = a;
            else
                b.after = a;//相反则将e的下个节点设为其上一个节点的下一个
            if (a != null)//如果a不为null则表明e不是尾节点
                a.before = b;//指定a的上一节点为b
            else
                last = b;//当e为尾节点 last=b
            if (last == null)//当last为null表明此时链表中只有e一个节点
                head = p;//那么e也是头节点
            else {
                p.before = last;
                last.after = p;//无需赘述
            }
            tail = p;
            ++modCount;
        }
    }
  • 到目前为止关于LinkedHashMap的put操作我们已经解析完毕,他的具体的数据结构想必也早已在我们的心中浮现,我们以一张图来表示(为了能更好的理解它与HashMap的区别我们直接将上一篇中的图拿过来进行改造):
    LinkedHashMap集合结构示意图
    希望上面的图有足够描述清楚LinkedHashMap的内部组织结构,当然,映射插入链表的顺序并不一定如图。上面黑色的线加箭头代表next,至此混乱的after、before、next就算是解释清楚了。

  • 需要注意的是我们上面所做的分析大多是基于插入顺序的LinkedHashMap实现,那么当其次序为访问次序时会发生什么呢。首先既然是访问顺序那么对于某个节点的任何操作都将视为访问次序的变更。下面我们通过源码来看看访问顺序下的LinkedHashMap的初始化操作,首先自然是构造方法,如前言所属我们可以通过LinkedHashMap(int,float,boolean)方法来创建一个map他的次序是他的条目最后一次被访问时的顺序。
 public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        this.accessOrder = accessOrder;//这里当accessOrder为true时表示创建的map次序是访问次序。
    }

其实在上文中我们已经解释过了访问顺序和插入顺序在执行新键(无冲突)插入时都是一样的,因为最新插入的他的访问次序总是recent-accessed。他俩的主要区别是在于对待键的再次插入时。插入顺序并不会修改原有的排列次序,而访问顺序下,即使执行的是更新操作,但依旧是对原有的节点的最新访问,所以需要将其移至末尾。
看罢put方法我们再来看看其他方法,首先我们看看map的常见操作get:

//该方法重写了HashMap的get方法,增加了if (accessOrder){afterNodeAccess(e)};
//即在访问顺序的map中get方法需要将当前正在访问的节点放在链表的末尾。
public V get(Object key) {
        Node<K,V> e;
        if ((e = getNode(hash(key), key)) == null)//调用了HashMap的方法并没有重写
        //所以get操作的表现和HashMap是一样的,在排序方式为访问顺序的情况下略微有些削弱,
        //因为增加了将其移动至链表末尾的操作
            return null;
        if (accessOrder)
            afterNodeAccess(e);
        return e.value;
    }

我们再来看看他的contain方法,如下,这个方法算是完全重写了父类的方法,我们将HashMap的contain方法列在下方。两者的区别一目了然:LinkedHashMap中该方法使用了一层for循环,而在HashMap中该方法使用了两层for循环,所以在时间复杂度上前者为O(n)线性时间,后者为O(n^2)n的平方时间,所以拥有双向列表的LinkedHashMap此方法的表现要优于HashMap。还有一点要注意,即使是在访问顺序中这个操作也没有更新access-order。

public boolean containsValue(Object value) {
        for (LinkedHashMap.Entry<K,V> e = head; e != null; e = e.after) {
        //注意这里的for 循环是直接从链表开始的,因为所有的映射都包含在链表中。从head节点开始,从前往后
        //其实因为链表是双向的所以可以从后往前迭代,所以继承了LinkedHashMap的子类可以重写这个方法。
            V v = e.value;
            if (v == value || (value != null && value.equals(v)))
                return true;//无需赘述
        }
        return false;
    }
    ————————————————————————————————————————————————————————————————————————
     public boolean containsValue(Object value) {
        Node<K,V>[] tab; V v;
        if ((tab = table) != null && size > 0) {//外层for 循环遍历整个数组
            for (int i = 0; i < tab.length; ++i) {//内层for循环遍历链表
                for (Node<K,V> e = tab[i]; e != null; e = e.next) {
                    if ((v = e.value) == value ||
                        (value != null && value.equals(v)))
                        return true;
                }
            }
        }
        return false;
    }

总结

LinkedHashMap提供了哈希表在jdk中有序的实现。在LinkedHashMap中真正影响键值对次序的是 accessOrder。它提供两种顺序:插入或者访问。在插入顺序中新键总是会被插入到链表的末端,对键的再次插入也不会影响到该建原有的次序;在访问顺序中,对键的查询get,put(键的再次插入)都会使原有的顺序发生改变——当前访问的节点会立刻移动至链表末端。当扩容操作发生后原有的次序依旧保持不变(扩容操作改变了next指向的引用,而决定迭代次序的before和after不会发生改变)。
本编博客基于jdk1.8,所以还加入了对lambda表达式的支持,加入了新的forEach方法,replaceAll方法,接收一个函数式接口作为参数。关于函数式接口和lambda表达式我们会专门写一篇博客来讲解。
谢谢阅读,本人水平有限,难免会有理解或者阐述不对的地方希望见谅。

猜你喜欢

转载自blog.csdn.net/weixin_40606398/article/details/87919631