Java 集合 - LinkedHashMap 解析

一、前言


上一篇《Java 集合 - HashMap 解析》我们学习了 HashMap,HashMap 是无序的,当我们希望有顺序地去存储 key-value 时,就需要使用 LinkedHashMap 了。LRU 算法就是利用 LinkedHashMap 的有序性。下面我们来学习一下 LinkedHashMap。

 

二、源码解析


我们先看一下 LinkEdHashMap 的数据结构图:

LinkedHashMap 继承了 HashMap,所以它们有很多相似的地方,它也是提供了key-value的存储方式,并提供了put和get方法来进行数据存取。

public class LinkedHashMap<K,V>
    extends HashMap<K,V>
    implements Map<K,V>
{

LinkedHashMap 提供了多个构造方法:

我们先看最后一个构造方法。

    public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);// 调用HashMap对应的构造函数
        this.accessOrder = accessOrder;    // 迭代顺序的默认值
    }

可以看到首先使用 super 调用了父类 HashMap 的构造方法,其实就是根据初始容量、负载因子去初始化 Entry[] table。然后自定义设置 accessOrder 的值,这就跟存储的顺序有关了。LinkedHashMap 存储数据是有序的,而且分为两种:

  • 插入顺序(默认顺序,accessOrder 为 false):迭代的顺序为 put 的顺序
  • 访问顺序(accessOrder 为 true):迭代的初始顺序为 put 的顺序,但当调用了 get 方法获取 entry 后,这个 entry 就放到迭代的最后一个位置上。

这里再解释一下访问顺序,当我们没有访问(调用 get 方法)时,存储的顺序仍然为插入(put)顺序,然后每调用一次 get 方法,只要获取到 entry 就把这个 entry 放到链表尾。Lru 算法中就是利用到 LinkedHashMap 的访问顺序。 

在 HashMap 的构造函数中调用了 init 方法,在 HashMap 中 init 方法是空实现,但 LinkedHashMap 重写了该方法,所以在 LinkedHashMap 的构造方法里,调用了自身的 init 方法,init 的重写实现如下:

    @Override
    void init() {
        // 创建了一个hash=-1,key、value、next都为null的Entry
        header = new Entry<>(-1, null, null, null);
        // 让创建的Entry的before和after都指向自身,注意after不是之前提到的next
        // 其实就是创建了一个只有头部节点的双向链表
        header.before = header.after = header;
    }

可以看到,LinkedHashMap 中的 Entry 多了 before 和 after,这是因为 LinkedHashMap 有自己的静态内部类 Entry,它继承了HashMap.Entry,定义如下:

    /**
     * LinkedHashMap entry.
     */
    private static class Entry<K,V> extends HashMap.Entry<K,V> {
        // These fields comprise the doubly linked list used for iteration.
        Entry<K,V> before, after;

        Entry(int hash, K key, V value, HashMap.Entry<K,V> next) {
            super(hash, key, value, next);
        }
    }

所以 LinkedHashMap 的构造函数,主要就是调用 HashMap 构造函数初始化了一个Entry[] table,然后调用自身的 init 初始化了一个只有头结点(header)的双向链表。完成了如下操作:

下面我们来看看 LinkedHashMap 的一些方法:

插入 put(key, vlaue)

public V put(K key, V value) {
        //当key为null时,调用putForNullKey方法,并将该键值对保存到table的第一个位置 
        if (key == null)
            return putForNullKey(value); 
        //根据key的hashCode计算hash值
        int hash = hash(key.hashCode());           
        //计算该键值对在数组中的存储位置(哪个桶)
        int i = indexFor(hash, table.length);              
        //在table的第i个桶上进行迭代,寻找 key 保存的位置
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {      
            Object k;
            //判断该条链上是否存在hash值相同且key值相等的映射,若存在,则直接覆盖 value,并返回旧value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this); // LinkedHashMap重写了Entry中的recordAccess方法--- (1)    
                return oldValue;    // 返回旧值
            }
        }
        modCount++; //修改次数增加1,快速失败机制
        //原Map中无该映射,将该添加至该链的链头
        addEntry(hash, key, value, i);  // LinkedHashMap重写了HashMap中的createEntry方法 ---- (2)    
        return null;
    }

上述源码反映了 LinkedHashMap 与 HashMap 保存数据的过程。特别地,在 LinkedHashMap 中,它对 addEntry 方法和 Entry 的 recordAccess 方法进行了重写。下面我们对比地看一下 LinkedHashMap 和 HashMap 的 addEntry 方法的具体实现:

     /**
     * LinkedHashMap中的addEntry方法
     */
    void addEntry(int hash, K key, V value, int bucketIndex) {   

        //创建新的Entry,并插入到LinkedHashMap中  
        createEntry(hash, key, value, bucketIndex);  // 重写了HashMap中的createEntry方法

        //双向链表的第一个有效节点(header后的那个节点)为最近最少使用的节点,这是用来支持LRU算法的
        Entry<K,V> eldest = header.after;  
        //如果有必要,则删除掉该近期最少使用的节点,  
        //这要看对removeEldestEntry的覆写,由于默认为false,因此默认是不做任何处理的。  
        if (removeEldestEntry(eldest)) {  
            removeEntryForKey(eldest.key);  
        } else {  
            //扩容到原来的2倍  
            if (size >= threshold)  
                resize(2 * table.length);  
        }  
    } 
    -------------------------------我是分割线------------------------------------
     /**
     * HashMap中的addEntry方法
     */
    void addEntry(int hash, K key, V value, int bucketIndex) {
        //获取bucketIndex处的Entry
        Entry<K,V> e = table[bucketIndex];

        //将新创建的 Entry 放入 bucketIndex 索引处,并让新的 Entry 指向原来的 Entry 
        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);

        //若HashMap中元素的个数超过极限了,则容量扩大两倍
        if (size++ >= threshold)
            resize(2 * table.length);
    }

 由于 LinkedHashMap 本身维护了插入的先后顺序,因此其可以用来做缓存,14~19 行的操作就是用来支持LRU算法的,这里暂时不用去关心它。此外,在 LinkedHashMap 的 addEntry 方法中,它重写了 HashMap 中的 createEntry 方法,我们接着看一下 createEntry 方法:

    void createEntry(int hash, K key, V value, int bucketIndex) { 
        // 向哈希表中插入Entry,这点与HashMap中相同 
        //创建新的Entry并将其链入到数组对应桶的链表的头结点处, 
        HashMap.Entry<K,V> old = table[bucketIndex];  
        Entry<K,V> e = new Entry<K,V>(hash, key, value, old);  
        table[bucketIndex] = e;     

        //在每次向哈希表插入Entry的同时,都会将其插入到双向链表的尾部,  
        //这样就按照Entry插入LinkedHashMap的先后顺序来迭代元素(LinkedHashMap根据双向链表重写了迭代器)
        //同时,新put进来的Entry是最近访问的Entry,把其放在链表末尾 ,也符合LRU算法的实现  
        e.addBefore(header);  
        size++;  
    }  

由以上源码我们可以知道,在 LinkedHashMap 中向哈希表中插入新 Entry 的同时,还会通过 Entry 的 addBefore 方法将其链入到双向链表中。其中 addBefore 方法本质上是一个双向链表的插入操作,其源码如下:

    //在双向链表中,将当前的Entry插入到existingEntry(header)的前面  
    private void addBefore(Entry<K,V> existingEntry) {  
        after  = existingEntry;  
        before = existingEntry.before;  
        before.after = this;  
        after.before = this;  
    }  

到此为止,我们分析了在 LinkedHashMap 中 put 一条键值对的完整过程。总的来说,相比 HashMap 而言,LinkedHashMap 在向哈希表添加一个键值对的同时,也会将其链入到它所维护的双向链表中,以便设定迭代顺序。

扩容操作 : resize()

在 HashMap 中,我们知道随着 HashMap 中元素的数量越来越多,发生碰撞的概率将越来越大,所产生的子链长度就会越来越长,这样势必会影响 HashMap 的存取速度。为了保证 HashMap  的效率,系统必须要在某个临界点进行扩容处理,该临界点就是 HashMap 中元素的数量在数值上等于 threshold(table数组长度*加载因子)。但是不得不说,扩容是一个非常耗时的过程,因为它需要重新计算这些元素在新 table 数组中的位置并进行复制处理。所以,如果我们能够提前预知 HashMap 中元素的个数,那么在构造 HashMap 时预设元素的个数能够有效的提高HashMap的性能。

同样的问题也存在于 LinkedHashMap 中,因为 LinkedHashMap 本来就是一个 HashMap,只是它还将所有 Entry 节点链入到了一个双向链表中。LinkedHashMap 完全继承了 HashMap 的 resize() 方法,只是对它所调用的 transfer 方法进行了重写。我们先看resize()方法源码:

    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        // 若 oldCapacity 已达到最大值,直接将 threshold 设为 Integer.MAX_VALUE
        if (oldCapacity == MAXIMUM_CAPACITY) {  
            threshold = Integer.MAX_VALUE;
            return;             // 直接返回
        }
        // 否则,创建一个更大的数组
        Entry[] newTable = new Entry[newCapacity];
        //将每条Entry重新哈希到新的数组中
        transfer(newTable);  //LinkedHashMap对它所调用的transfer方法进行了重写
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);  // 重新设定 threshold
    }

从上面代码中我们可以看出,Map 扩容操作的核心在于重哈希。所谓重哈希是指重新计算原 HashMap 中的元素在新 table 数组中的位置并进行复制处理的过程。鉴于性能和 LinkedHashMap 自身特点的考量,LinkedHashMap 对重哈希过程(transfer方法)进行了重写,源码如下:

    void transfer(HashMap.Entry[] newTable) {
        int newCapacity = newTable.length;
        // 与HashMap相比,借助于双向链表的特点进行重哈希使得代码更加简洁
        for (Entry<K,V> e = header.after; e != header; e = e.after) {
            int index = indexFor(e.hash, newCapacity);   // 计算每个Entry所在的桶
            // 将其链入桶中的链表
            e.next = newTable[index];
            newTable[index] = e;   
        }
    }

如上述源码所示,LinkedHashMap 借助于自身维护的双向链表轻松地实现了重哈希操作。

读取 :get(Object key)

  相对于 LinkedHashMap 的存储而言,读取就显得比较简单了。LinkedHashMap 中重写了 HashMap 中的 get 方法,源码如下:

    public V get(Object key) {
        // 根据key获取对应的Entry,若没有这样的Entry,则返回null
        Entry<K,V> e = (Entry<K,V>)getEntry(key); 
        if (e == null)      // 若不存在这样的Entry,直接返回
            return null;
        e.recordAccess(this);
        return e.value;
    }

    /**
     * HashMap 中的方法
     *     
     */
    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

在 LinkedHashMap 的 get 方法中,通过 HashMap 中的 getEntry 方法获取 Entry 对象。注意这里的 recordAccess 方法,如果链表中元素的排序规则是按照插入的先后顺序排序的话,该方法什么也不做;如果链表中元素的排序规则是按照访问的先后顺序排序的话,则将 e 移到链表的末尾处,后面会专门阐述这个问题。

另外同样地,调用 LinkedHashMap 的 get(Object key) 方法后,若返回值是 NULL,则也存在如下两种可能:该 key 对应的值就是 null;HashMap 中不存在该 key。

下面我们来总结一下,LinkedHashMap 的存取过程与 HashMap 基本类似,只是在细节实现上稍有不同,这是由 LinkedHashMap 本身的特性所决定的,因为它要额外维护一个双向链表用于保持迭代顺序。在 put 操作上,虽然 LinkedHashMap 完全继承了 HashMap 的 put 操作,但是在细节上还是做了一定的调整,比如在 LinkedHashMap 中向哈希表中插入新 Entry 的同时,还会通过 Entry 的 addBefore  方法将其链入到双向链表中。在扩容操作上,虽然 LinkedHashMap 完全继承了 HashMap 的 resize 操作,但是鉴于性能和 LinkedHashMap 自身特点的考量,LinkedHashMap 对其中的重哈希过程(transfer 方法)进行了重写。在读取操作上,LinkedHashMap 中重写了 HashMap 中的 get 方法,通过 HashMap 中的 getEntry 方法获取 Entry 对象。在此基础上,进一步获取指定键对应的值。

 

三、LinkedHashMap 与 LRU 算法

到此为止我们已经分析完了 LinkedHashMap 的存取实现,这与 HashMap 大体相同。LinkedHashMap 区别于 HashMap 最大的一个不同点是前者是有序的而后者是无序的。为此 LinkedHashMap 增加了两个属性用于保证顺序,分别是双向链表头结点 header 和标志位 accessOrder。我们知道,header 是 LinkedHashMap 所维护的双向链表的头结点,而 accessOrder 用于决定具体的迭代顺序。实际上,accessOrder 标志位的作用可不像我们描述的这样简单,下面我们来分析一下。

我们知道当 accessOrder 标志位为 true 时,表示双向链表中的元素按照访问的先后顺序排列,可以看到虽然 Entry 插入链表的顺序依然是按照其 put 到 LinkedHashMap 中的顺序,但 put 和 get 方法均有调用 recordAccess 方法(put 方法在key相同时会调用)。 recordAccess 方法判断 accessOrder 是否为true,如果是则将当前访问的Entry(put 进来的 Entry 或 get 出来的 Entry)移到双向链表的尾部(key 不相同时,put 新 Entry 时,会调用 addEntry,它会调用 createEntry,该方法同样将新插入的元素放入到双向链表的尾部,既符合插入的先后顺序,又符合访问的先后顺序,因为这时该 Entry 也被访问了);当标志位 accessOrder 的值为 false 时,表示双向链表中的元素按照 Entry 插入 LinkedHashMap 到中的先后顺序排序,即每次 put 到 LinkedHashMap 中的 Entry 都放在双向链表的尾部,这样遍历双向链表时,Entry 的输出顺序便和插入的顺序一致,这也是默认的双向链表的存储顺序。因此当标志位 accessOrder 的值为 false时,虽然也会调用 recordAccess 方法,但不做任何操作。

注意到我们在前面看到的 LinkedHashMap 的五种构造方法,前四个构造方法都将 accessOrder 设为 false,说明默认是按照插入顺序排序的;而第五个构造方法可以自定义传入的 accessOrder 的值,因此可以指定双向循环链表中元素的排序规则。特别地,当我们要用 LinkedHashMap 实现 LRU 算法时,就需要调用该构造方法并将 accessOrder 置为 true。

从上一节 put 方法源码我们可以看到,当要 put 进来的 Entry 的 key 在哈希表中已经在存在时,会调用 Entry 的 recordAccess 方法;当该 key 不存在时,则会调用 addEntry 方法将新的 Entry 插入到对应桶的单链表的头部。我们先来看 recordAccess 方法:

    void recordAccess(HashMap<K,V> m) {  
        LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;  
        //如果链表中元素按照访问顺序排序,则将当前访问的Entry移到双向循环链表的尾部,  
        //如果是按照插入的先后顺序排序,则不做任何事情。  
        if (lm.accessOrder) {  
            lm.modCount++;  
            //移除当前访问的Entry  
            remove();  
            //将当前访问的Entry插入到链表的尾部  
            addBefore(lm.header);  
          }  
    } 

LinkedHashMap 重写了 HashMap 中的 recordAccess 方法(HashMap中该方法为空),当调用父类的 put 方法发现 key 已经存在时会调用该方法;当调用自己的 get 方法时,也会调用到该方法。该方法提供了 LRU 算法的实现,它将最近使用的 Entry 放到双向循环链表的尾部。也就是说当 accessOrder 为 true 时,get 方法和 put 方法都会调用 recordAccess 方法使得最近使用的 Entry 移到双向链表的末尾;当 accessOrder 为默认值 false 时,从源码中可以看出 recordAccess 方法什么也不会做。我们反过头来,再看一下addEntry方法:

   /**
     * LinkedHashMap中的addEntry方法
     */
    void addEntry(int hash, K key, V value, int bucketIndex) {   
        //创建新的Entry,并插入到LinkedHashMap中  
        createEntry(hash, key, value, bucketIndex);  // 重写了HashMap中的createEntry方法
        //双向链表的第一个有效节点(header后的那个节点)为最近最少使用的节点,这是用来支持LRU算法的
        Entry<K,V> eldest = header.after;  
        //如果有必要,则删除掉该近期最少使用的节点,  
        //这要看对removeEldestEntry的覆写,由于默认为false,因此默认是不做任何处理的。  
        if (removeEldestEntry(eldest)) {  
            removeEntryForKey(eldest.key);  
        } else {  
            //扩容到原来的2倍  
            if (size >= threshold)  
                resize(2 * table.length);  
        }  
    } 

    void createEntry(int hash, K key, V value, int bucketIndex) { 
        // 向哈希表中插入Entry,这点与HashMap中相同 
        //创建新的Entry并将其链入到数组对应桶的链表的头结点处, 
        HashMap.Entry<K,V> old = table[bucketIndex];  
        Entry<K,V> e = new Entry<K,V>(hash, key, value, old);  
        table[bucketIndex] = e;     
        //在每次向哈希表插入Entry的同时,都会将其插入到双向链表的尾部,  
        //这样就按照Entry插入LinkedHashMap的先后顺序来迭代元素(LinkedHashMap根据双向链表重写了迭代器)
        //同时,新put进来的Entry是最近访问的Entry,把其放在链表末尾 ,也符合LRU算法的实现  
        e.addBefore(header);  
        size++;  
    }

同样是将新的 Entry 链入到 table 中对应桶中的单链表中,但可以在 createEntry 方法中看出,同时也会把新put进来的Entry插入到了双向链表的尾部。从插入顺序的层面来说,新的Entry插入到双向链表的尾部可以实现按照插入的先后顺序来迭代Entry,而从访问顺序的层面来说,新put进来的Entry又是最近访问的Entry,也应该将其放在双向链表的尾部。在上面的addEntry方法中还调用了removeEldestEntry方法,该方法源码如下:

protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
}

该方法是用来被重写的,一般地如果用 LinkedHashmap 实现 LRU 算法,就要重写该方法。比如可以将该方法覆写为如果设定的内存已满,则返回true,这样当再次向 LinkedHashMap 中 putEntry 时,在调用的 addEntry 方法中便会将近期最少使用的节点删除掉(header后的那个节点)。

在LinkedHashMap中进行读取操作时,一样也会调用recordAccess方法。此处不再赘述。

使用 LinkedHashMap 实现 LRU 的必要前提是将 accessOrder 标志位设为 true 以便开启按访问顺序排序的模式。我们可以看到,无论是 put 方法还是 get 方法,都会导致目标 Entry 成为最近访问的 Entry,因此就把该 Entry 加入到了双向链表的末尾:get 方法通过调用 recordAccess 方法来实现;put 方法在覆盖已有 key 的情况下,也是通过调用 recordAccess 方法来实现,在插入新的 Entry 时,则是通过 createEntry 中的 addBefore 方法来实现。这样,我们便把最近使用的 Entry 放入到了双向链表的后面。多次操作后,双向链表前面的 Entry 便是最近没有使用的,这样当节点个数满的时候,删除最前面的 Entry(head 后面的那个 Entry)即可,因为它就是最近最少使用的Entry。

 

更详细的解析过程推荐阅读下面两篇:

https://www.jianshu.com/p/8f4f58b4b8ab

https://blog.csdn.net/justloveyou_/article/details/71713781

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值