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

相关文章

网页解析 lxml 库--实战

lxml库使用流程 lxml 是 Python 的第三方解析库,完全使用 Python 语言编写,它对 XPath表达式提供了良好的支 持,因此能够了高效地解析 HTML/XML 文档。本节讲解如何通过 lxml 库解析 HTML 文档。 pip install lxml lxm| 库提供了一个 etree 模块,该模块专门用来解析 HTML/XML 文档,下面来介绍一下 lxml 库

JVM 的类初始化机制

前言 当你在 Java 程序中new对象时,有没有考虑过 JVM 是如何把静态的字节码(byte code)转化为运行时对象的呢,这个问题看似简单,但清楚的同学相信也不会太多,这篇文章首先介绍 JVM 类初始化的机制,然后给出几个易出错的实例来分析,帮助大家更好理解这个知识点。 JVM 将字节码转化为运行时对象分为三个阶段,分别是:loading 、Linking、initialization

Spring Security 基于表达式的权限控制

前言 spring security 3.0已经可以使用spring el表达式来控制授权,允许在表达式中使用复杂的布尔逻辑来控制访问的权限。 常见的表达式 Spring Security可用表达式对象的基类是SecurityExpressionRoot。 表达式描述hasRole([role])用户拥有制定的角色时返回true (Spring security默认会带有ROLE_前缀),去

浅析Spring Security认证过程

类图 为了方便理解Spring Security认证流程,特意画了如下的类图,包含相关的核心认证类 概述 核心验证器 AuthenticationManager 该对象提供了认证方法的入口,接收一个Authentiaton对象作为参数; public interface AuthenticationManager {Authentication authenticate(Authenti

Spring Security--Architecture Overview

1 核心组件 这一节主要介绍一些在Spring Security中常见且核心的Java类,它们之间的依赖,构建起了整个框架。想要理解整个架构,最起码得对这些类眼熟。 1.1 SecurityContextHolder SecurityContextHolder用于存储安全上下文(security context)的信息。当前操作的用户是谁,该用户是否已经被认证,他拥有哪些角色权限…这些都被保

Spring Security基于数据库验证流程详解

Spring Security 校验流程图 相关解释说明(认真看哦) AbstractAuthenticationProcessingFilter 抽象类 /*** 调用 #requiresAuthentication(HttpServletRequest, HttpServletResponse) 决定是否需要进行验证操作。* 如果需要验证,则会调用 #attemptAuthentica

Spring Security 从入门到进阶系列教程

Spring Security 入门系列 《保护 Web 应用的安全》 《Spring-Security-入门(一):登录与退出》 《Spring-Security-入门(二):基于数据库验证》 《Spring-Security-入门(三):密码加密》 《Spring-Security-入门(四):自定义-Filter》 《Spring-Security-入门(五):在 Sprin

Java架构师知识体认识

源码分析 常用设计模式 Proxy代理模式Factory工厂模式Singleton单例模式Delegate委派模式Strategy策略模式Prototype原型模式Template模板模式 Spring5 beans 接口实例化代理Bean操作 Context Ioc容器设计原理及高级特性Aop设计原理Factorybean与Beanfactory Transaction 声明式事物

Java进阶13讲__第12讲_1/2

多线程、线程池 1.  线程概念 1.1  什么是线程 1.2  线程的好处 2.   创建线程的三种方式 注意事项 2.1  继承Thread类 2.1.1 认识  2.1.2  编码实现  package cn.hdc.oop10.Thread;import org.slf4j.Logger;import org.slf4j.LoggerFactory

JAVA智听未来一站式有声阅读平台听书系统小程序源码

智听未来,一站式有声阅读平台听书系统 🌟&nbsp;开篇:遇见未来,从“智听”开始 在这个快节奏的时代,你是否渴望在忙碌的间隙,找到一片属于自己的宁静角落?是否梦想着能随时随地,沉浸在知识的海洋,或是故事的奇幻世界里?今天,就让我带你一起探索“智听未来”——这一站式有声阅读平台听书系统,它正悄悄改变着我们的阅读方式,让未来触手可及! 📚&nbsp;第一站:海量资源,应有尽有 走进“智听