Java 集合 - LinkedHashMap 解析

2024-05-11 00:38

本文主要是介绍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 的重写实现如下:

    @Overridevoid init() {// 创建了一个hash=-1,key、value、next都为null的Entryheader = 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,并返回旧valueif (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处的EntryEntry<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_VALUEif (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,则返回nullEntry<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

这篇关于Java 集合 - LinkedHashMap 解析的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/978014

相关文章

Java实现检查多个时间段是否有重合

《Java实现检查多个时间段是否有重合》这篇文章主要为大家详细介绍了如何使用Java实现检查多个时间段是否有重合,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录流程概述步骤详解China编程步骤1:定义时间段类步骤2:添加时间段步骤3:检查时间段是否有重合步骤4:输出结果示例代码结语作

Java中String字符串使用避坑指南

《Java中String字符串使用避坑指南》Java中的String字符串是我们日常编程中用得最多的类之一,看似简单的String使用,却隐藏着不少“坑”,如果不注意,可能会导致性能问题、意外的错误容... 目录8个避坑点如下:1. 字符串的不可变性:每次修改都创建新对象2. 使用 == 比较字符串,陷阱满

Java判断多个时间段是否重合的方法小结

《Java判断多个时间段是否重合的方法小结》这篇文章主要为大家详细介绍了Java中判断多个时间段是否重合的方法,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录判断多个时间段是否有间隔判断时间段集合是否与某时间段重合判断多个时间段是否有间隔实体类内容public class D

IDEA编译报错“java: 常量字符串过长”的原因及解决方法

《IDEA编译报错“java:常量字符串过长”的原因及解决方法》今天在开发过程中,由于尝试将一个文件的Base64字符串设置为常量,结果导致IDEA编译的时候出现了如下报错java:常量字符串过长,... 目录一、问题描述二、问题原因2.1 理论角度2.2 源码角度三、解决方案解决方案①:StringBui

Java覆盖第三方jar包中的某一个类的实现方法

《Java覆盖第三方jar包中的某一个类的实现方法》在我们日常的开发中,经常需要使用第三方的jar包,有时候我们会发现第三方的jar包中的某一个类有问题,或者我们需要定制化修改其中的逻辑,那么应该如何... 目录一、需求描述二、示例描述三、操作步骤四、验证结果五、实现原理一、需求描述需求描述如下:需要在

Java中ArrayList和LinkedList有什么区别举例详解

《Java中ArrayList和LinkedList有什么区别举例详解》:本文主要介绍Java中ArrayList和LinkedList区别的相关资料,包括数据结构特性、核心操作性能、内存与GC影... 目录一、底层数据结构二、核心操作性能对比三、内存与 GC 影响四、扩容机制五、线程安全与并发方案六、工程

JavaScript中的reduce方法执行过程、使用场景及进阶用法

《JavaScript中的reduce方法执行过程、使用场景及进阶用法》:本文主要介绍JavaScript中的reduce方法执行过程、使用场景及进阶用法的相关资料,reduce是JavaScri... 目录1. 什么是reduce2. reduce语法2.1 语法2.2 参数说明3. reduce执行过程

如何使用Java实现请求deepseek

《如何使用Java实现请求deepseek》这篇文章主要为大家详细介绍了如何使用Java实现请求deepseek功能,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下... 目录1.deepseek的api创建2.Java实现请求deepseek2.1 pom文件2.2 json转化文件2.2

Java调用DeepSeek API的最佳实践及详细代码示例

《Java调用DeepSeekAPI的最佳实践及详细代码示例》:本文主要介绍如何使用Java调用DeepSeekAPI,包括获取API密钥、添加HTTP客户端依赖、创建HTTP请求、处理响应、... 目录1. 获取API密钥2. 添加HTTP客户端依赖3. 创建HTTP请求4. 处理响应5. 错误处理6.

Spring AI集成DeepSeek的详细步骤

《SpringAI集成DeepSeek的详细步骤》DeepSeek作为一款卓越的国产AI模型,越来越多的公司考虑在自己的应用中集成,对于Java应用来说,我们可以借助SpringAI集成DeepSe... 目录DeepSeek 介绍Spring AI 是什么?1、环境准备2、构建项目2.1、pom依赖2.2