Hashmap 原理源码面试题(史上最全)

Posted 架构师-尼恩

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Hashmap 原理源码面试题(史上最全)相关的知识,希望对你有一定的参考价值。

文章很长,建议收藏起来慢慢读!疯狂创客圈总目录 语雀版 | 总目录 码云版| 总目录 博客园版 为您奉上珍贵的学习资源 :

  • 免费赠送 经典图书:《Java高并发核心编程(卷1)》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领

  • 免费赠送 经典图书:《Java高并发核心编程(卷2)》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领

  • 免费赠送 经典图书:《Netty Zookeeper Redis 高并发实战》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领

  • 免费赠送 经典图书:《SpringCloud Nginx高并发核心编程》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领

  • 免费赠送 资源宝库: Java 必备 百度网盘资源大合集 价值>10000元 加尼恩领取


  • 尼恩Java面试宝典(持续更新 + 史上最全 + 面试必备)

    尼恩Java面试宝典,31个最新pdf,含2000多页不断更新、持续迭代 具体详情,请点击此链接


    Hashmap 面试题 + Hashmap 原理 + Hashmap 源码

    HashMap作为我们日常使用最频繁的容器之一,相信你一定不陌生了。今天我们就从HashMap的底层实现讲起,深度了解下它的设计与优化。

    常用的数据结构

    一起来温习下常用的数据结构,这样也有助于你更好地理解后面地内容。

    数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1),但在数 组中间以及头部插入数据时,需要复制移动后面的元素。

    链表:一种在物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。

    链表由一系列结点(链表中每一个元素)组成,结点可以在运行时动态生成。每个结点都包含“存储数据单元的数据域”和“存储下一个结点地址的指针域”这两个部分。

    由于链表不用必须按顺序存储,所以链表在插入的时候可以达到O(1)的复杂度,但查找一个结点或者访问特定编号的结点需要O(n)的时间。

    哈希表:根据关键码值(Key value)直接进行访问的数据结构。通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做哈希函数,存放记录的数组就叫做哈希表。

    :由n(n≥1)个有限结点组成的一个具有层次关系的集合,就像是一棵倒挂的树。

    什么是哈希表

    从根本上来说,一个哈希表包含一个数组,通过特殊的关键码(也就是key)来访问数组中的元素。

    哈希表的主要思想是:

    • 存放Value的时候,通过一个哈希函数,通过 关键码(key)进行哈希运算得到哈希值,然后得到 映射的位置, 去寻找存放值的地方 ,

    • 读取Value的时候,也是通过同一个哈希函数,通过 关键码(key)进行哈希运算得到哈希值,然后得到 映射的位置,从那个位置去读取。

    最直接的例子就是字典,例如下面的字典图,如果我们要找 “啊” 这个字,只要根据拼音 “a” 去查找拼音索引,查找 “a” 在字典中的位置 “啊”,这个过程就是哈希函数的作用,用公式来表达就是:f(key),而这样的函数所建立的表就是哈希表。

    哈希表的优势:加快了查找的速度。

    比起数组和链表查找元素时需要遍历整个集合的情况来说,哈希表明显方便和效率的多。

    常见的哈希算法

    哈希表的组成取决于哈希算法,也就是哈希函数的构成,下面列举几种常见的哈希算法。

    1) 直接定址法

    • 取关键字或关键字的某个线性函数值为散列地址。
    • 即 f(key) = key 或 f(key) = a*key + b,其中a和b为常数。

    2) 除留余数法

    • 取关键字被某个不大于散列表长度 m 的数 p 求余,得到的作为散列地址。
    • 即 f(key) = key % p, p < m。这是最为常见的一种哈希算法。

    3) 数字分析法

    • 当关键字的位数大于地址的位数,对关键字的各位分布进行分析,选出分布均匀的任意几位作为散列地址。
    • 仅适用于所有关键字都已知的情况下,根据实际应用确定要选取的部分,尽量避免发生冲突。

    4) 平方取中法

    • 先计算出关键字值的平方,然后取平方值中间几位作为散列地址。
    • 随机分布的关键字,得到的散列地址也是随机分布的。

    5) 随机数法

    • 选择一个随机函数,把关键字的随机函数值作为它的哈希值。
    • 通常当关键字的长度不等时用这种方法。

    什么是哈希冲突(hash碰撞)

    哈希表因为其本身的结构使得查找对应的值变得方便快捷,但也带来了一些问题,

    以上面的字典图为例,key中的一个拼音对应一个字,那如果字典中有两个字的拼音相同呢?

    例如,我们要查找 “按” 这个字,根据字母拼音就会跳到 “安” 的位置,这就是典型的哈希冲突问题。

    哈希冲突问题,用公式表达就是:

    key1 ≠  key2  , f(key1) = f(key2)
    

    一般来说,哈希冲突是无法避免的,

    如果要完全避免的话,那么就只能一个字典对应一个值的地址,也就是一个字就有一个索引 (就是两个索引),

    这样一来,空间就会增大,甚至内存溢出。

    需要想尽办法,减少 哈希冲突(hash碰撞)为啥呢?Hash碰撞的概率就越小,map的存取效率就会越高

    哈希冲突的解决办法

    常见的哈希冲突解决办法有两种:

    • 开放地址法
    • 链地址法。

    一、开放地址法

    开发地址法的做法是,当冲突发生时,使用某种探测算法在散列表中寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到。

    按照探测序列的方法,一般将开放地址法区分为线性探查法、二次探查法、双重散列法等。

    这里为了更好的展示三种方法的效果,我们用以一个模为8的哈希表为例,采用除留余数法

    往表中插入三个关键字分别为26,35,36的记录,分别除8取模后,在表中的位置如下:

    这个时候插入42,那么正常应该在地址为2的位置里,但因为关键字30已经占据了位置,

    所以就需要解决这个地址冲突的情况,接下来就介绍三种探测方法的原理,并展示效果图。

    1) 线性探查法:

    fi=(f(key)+i) % m ,0 ≤ i ≤ m-1

    探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+1],…,直到 T[m-1],此后又循环到 T[0],T[1],…,直到探查到有空余的地址或者到 T[d-1]为止。

    插入42时,探查到地址2的位置已经被占据,接着下一个地址3,地址4,直到空位置的地址5,所以39应放入地址为5的位置。

    缺点:需要不断处理冲突,无论是存入还是査找效率都会大大降低。

    2) 二次探查法

    fi=(f(key)+di) % m,0 ≤ i ≤ m-1

    探查时从地址 d 开始,首先探查 T[d],然后依次探查 T[d+di],di 为增量序列12,-12,22,-22,……,q2,-q2 且q≤1/2 (m-1) ,直到探查到 有空余地址或者到 T[d-1]为止。

    缺点:无法探查到整个散列空间。

    所以插入42时,探查到地址2被占据,就会探查T[2+1^2]也就是地址3的位置,被占据后接着探查到地址7,然后插入。

    3) 双哈希函数探测法

    fi=(f(key)+i*g(key)) % m (i=1,2,……,m-1)

    其中,f(key) 和 g(key) 是两个不同的哈希函数,m为哈希表的长度

    步骤:

    双哈希函数探测法,先用第一个函数 f(key) 对关键码计算哈希地址,一旦产生地址冲突,再用第二个函数 g(key) 确定移动的步长因子,最后通过步长因子序列由探测函数寻找空的哈希地址。

    比如,f(key)=a 时产生地址冲突,就计算g(key)=b,则探测的地址序列为 f1=(a+b) mod m,f2=(a+2b) mod m,……,fm-1=(a+(m-1)b) % m,假设 b 为 3,那么关键字42应放在 “5” 的位置。

    开发地址法的问题:

    开发地址法,通过持续的探测,最终找到空的位置。

    上面的例子中,开发地址方虽然解决了问题,但是26和42,占据了一个数组同一个元素,42只能向下,此时再来一个取余为2 的值呢,只能向下继续寻找,同理,每一个来的值都只能向下寻找。

    为了解决这个问题,引入了链地址法。

    二、链地址法:

    在哈希表每一个单元中设置链表,某个数据项对的关键字还是像通常一样映射到哈希表的单元中,而数据项本身插入到单元的链表中。

    链地址法简单理解如下:

    来一个相同的数据,就将它插入到单元对应的链表中,在来一个相同的,继续给链表中插入。

    链地址法解决哈希冲突的例子如下:

    (1)采用除留余数法构造哈希函数,而 冲突解决的方法为 链地址法

    (2)具体的关键字列表为(19,14,23,01,68,20,84,27,55,11,10,79),则哈希函数为H(key)=key MOD 13。则采用除留余数法和链地址法后得到的预想结果应该为:

    (3)哈希造表完成后,进行查找时,首先是根据哈希函数找到关键字的位置链,然后在该链中进行搜索,如果存在和关键字值相同的值,则查找成功,否则若到链表尾部仍未找到,则该关键字不存在。

    哈希表性能

    哈希表的特性决定了其高效的性能,大多数情况下查找元素的时间复杂度可以达到O(1), 时间主要花在计算hash值上,

    然而也有一些极端的情况,最坏的就是hash值全都映射在同一个地址上,这样哈希表就会退化成链表,例如下面的图片:

    当hash表变成图2的情况时,查找元素的时间复杂度会变为O(n),效率瞬间低下,

    所以,设计一个好的哈希表尤其重要,如HashMap在jdk1.8后引入的红黑树结构就很好的解决了这种情况。

    HashMap的类结构

    类继承关系

    Java为数据结构中的映射定义了一个接口java.util.Map,此接口主要有四个常用的实现类,分别是HashMap、Hashtable、LinkedHashMap和TreeMap,

    类继承关系如下图所示:

    下面针对各个实现类的特点做一些说明:

    (1) HashMap:

    它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。

    HashMap 最多只允许一条记录的键为null,允许多条记录的值为null。

    HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。

    如果需要满足线程安全,可以用:

    • Collections的synchronizedMap方法使HashMap具有线程安全的能力,

    • 或者使用ConcurrentHashMap。

    (2) Hashtable:

    Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的。

    这个是老古董,Hashtable不建议在代码中使用

    不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。

    为何不建议用呢?

    任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap。后者使用了 分段保护机制,也就是 分而治之的思想。

    (3) LinkedHashMap:

    LinkedHashMap是HashMap的一个子类,其优点在于: 保存了记录的插入顺序

    在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。

    (4) TreeMap:

    TreeMap实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器

    当用Iterator遍历TreeMap时,得到的记录是排过序的。

    如果使用排序的映射,建议使用TreeMap。

    在使用TreeMap时,key必须实现Comparable接口, 或者在构造TreeMap传入自定义的Comparator,

    否则会在运行时抛出java.lang.ClassCastException类型的异常。

    注意:

    对于上述四种Map类型的类,要求映射中的key是不可变的。

    在创建内部的Entry后, key的哈希值不会被改变。

    为啥呢?

    如果对象的哈希值发生变化,Map对象很可能就定位不到映射的位置了。

    static class Node<K,V> implements Map.Entry<K,V> 
            final int hash;  //key的哈希值不会被改变
            final K key; // 映射中的key是不可变的
            V value;
            Node<K,V> next;
    

    HashMap存储结构

    通过上面的比较,我们知道了HashMap是Java的Map家族中一个普通成员,鉴于它可以满足大多数场景的使用条件,所以是使用频度最高的一个。

    下文我们主要结合源码,从存储结构、常用方法分析、扩容以及安全性等方面深入讲解HashMap的工作原理。

    HashMap的重要属性:table 桶数组

    从HashMap的源码中,我们可以发现,HashMap有一个非常重要的属性 —— table,

    这是由一个Node类型的元素构成的数组:

    transient Node<K,V>[] table;
    

    table 也叫 哈希数组哈希槽位 数组table 桶数组散列表, 数组中的一个 元素,常常被称之为 一个 槽位 slot

    Node类作为HashMap中的一个内部类,每个 Node 包含了一个 key-value 键值对。

    static class Node<K,V> implements Map.Entry<K,V> 
            final int hash;
            final K key;
            V value;
            Node<K,V> next;
    
            Node(int hash, K key, V value, Node<K,V> next) 
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        
    
        public final int hashCode() 
     	   return Objects.hashCode(key) ^ Objects.hashCode(value);
        
        ..........
    
    

    Node 类作为 HashMap 中的一个内部类,除了 key、value 两个属性外,还定义了一个next 指针。

    next 指针的作用:链地址法解决哈希冲突。

    当有哈希冲突时,HashMap 会用之前数组当中相同哈希值对应存储的 Node 对象,通过指针指向新增的相同哈希值的 Node 对象的引用。

    JDK1.8的table结构图

    从结构实现来讲,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如下如所示。

    问题:

    HashMap的有什么特点呢?

    HashMap的有什么特点

    (1)HashMap采用了链地址法解决冲突

    HashMap就是使用哈希表来存储的。

    Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。

    上图中的每个黑色圆点就是一个Node对象。

    Java中HashMap采用了链地址法。链地址法,简单来说,就是 数组加链表 的结合。

    在每个数组元素上都一个链表结构, 当数据被Hash后,首先得到数组下标,然后 , 把数据放在对应下标元素的链表上。

    例如程序执行下面代码:

    map.put("keyA","value1");
    map.put("keyB","value2");
    

    对于 第一句, 系统将调用"keyA"的hashCode()方法得到其hashCode ,然后再通过Hash算法来定位该键值对的存储位置,然后将 构造 entry 后加入到 存储位置 指向 的 链表中

    对于 第一句, 系统将调用"keyB"的hashCode()方法得到其hashCode ,然后再通过Hash算法来定位该键值对的存储位置,然后将 构造 entry 后加入到 存储位置 指向 的链表中

    有时两个key会定位到相同的位置,表示发生了Hash碰撞。

    Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。

    (2)HashMap有较好的Hash算法和扩容机制

    哈希桶数组的大小, 在空间成本和时间成本之间权衡,时间和空间 之间进行 权衡:

    • 如果哈希桶数组很大,即使较差的Hash算法也会比较分散, 空间换时间

    • 如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞, 时间换空间

    所以, 就需要在空间成本和时间成本之间权衡,

    其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。

    那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?

    答案就是好的Hash算法和扩容机制。

    HashMap的重要属性:加载因子(loadFactor)和边界值(threshold)

    HashMap还有两个重要的属性:

    • 加载因子(loadFactor)

    • 边界值(threshold)。

    在初始化 HashMap时,就会涉及到这两个关键初始化参数。

    loadFactor和threshold的源码如下:

         int threshold;             // 所能容纳的key-value对极限 
         final float loadFactor;    // 负载因子
    
    

    Node[] table的初始化长度length(默认值是16),

    loadFactor 为负载因子(默认值是0.75),

    threshold是HashMap所能容纳的最大数据量的Node 个数。

    threshold 、length 、loadFactor 三者之间的关系:

    threshold = length * Load factor。

    默认情况下 threshold = 16 * 0.75 =12。

    threshold就是允许的哈希数组 最大元素数目,超过这个数目就重新resize(扩容),扩容后的哈希数组 容量length 是之前容量length 的两倍。

    threshold是通过初始容量和LoadFactor计算所得,在初始HashMap不设置参数的情况下,默认边界值为12。

    如果HashMap中Node的数量超过边界值,HashMap就会调用resize()方法重新分配table数组。

    这将会导致HashMap的数组复制,迁移到另一块内存中去,从而影响HashMap的效率。

    HashMap的重要属性:loadFactor 属性

    为什么loadFactor 默认是0.75这个值呢?

    loadFactor 也是可以调整的,默认是0.75,但是,如果loadFactor 负载因子越大,在数组定义好 length 长度之后,所能容纳的键值对个数越多。

    LoadFactor属性是用来间接设置Entry数组(哈希表)的内存空间大小,在初始HashMap不设置参数的情况下,默认LoadFactor值为0.75。

    为什么loadFactor 默认是0.75这个值呢?

    这是由于 加载因子的两面性导致的

    加载因子越大,对空间的利用就越充分,碰撞的机会越高,这就意味着链表的长度越长,查找效率也就越低。

    因为对于使用链表法的哈希表来说,查找一个元素的平均时间是O(1+n),这里的n指的是遍 历链表的长度

    如果设置的加载因子太小,那么哈希表的数据将过于稀疏,对空间造成严重浪费。

    当然,加载因子小,碰撞的机会越低, 查找的效率就搞,性能就越好。

    默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下。

    分为两种情况:

    • 如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;
    • 相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。

    HashMap的重要属性:size属性

    size这个字段其实很好理解,就是HashMap中实际存在的键值对数量。

    注意: size和table的长度length的区别,length是 哈希桶数组table的长度

    在HashMap中,哈希桶数组table的长度length大小必须为2的n次方,这是一定是一个合数,这是一种反常规的设计.

    常规的设计是把桶数组的大小设计为素数。相对来说素数导致冲突的概率要小于合数,

    比如,Hashtable初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩容后不能保证还是素数)。

    HashMap采用这种非常规设计,主要是为了方便扩容。

    而 HashMap为了减少冲突,采用另外的方法规避:计算哈希桶索引位置时,哈希值的高位参与运算。

    HashMap的重要属性:modCount属性

    我们能够发现,在集合类的源码里,像HashMap、TreeMap、ArrayList、LinkedList等都有modCount属性,字面意思就是修改次数,

    首先看一下源码里对此属性的注释

    HashMap部分源码:

        /**
         * The number of times this HashMap has been structurally modified
         * Structural modifications are those that change the number of mappings in
         * the HashMap or otherwise modify its internal structure (e.g.,
         * rehash).  This field is used to make iterators on Collection-views of
         * the HashMap fail-fast.  (See ConcurrentModificationException).
         */
        transient int modCount;
    

    汉译:

    此哈希表已被结构性修改的次数,结构性修改是指哈希表的内部结构被修改,比如桶数组被修改或者拉链被修改。

    那些更改桶数组或者拉链的操作如,重新哈希。 此字段用于HashMap集合迭代器的快速失败。

    所以,modCount主要是为了防止在迭代过程中某些原因改变了原集合,导致出现不可预料的情况,从而抛出并发修改异常,

    这可能也与Fail-Fast机制有关: 在可能出现错误的情况下提前抛出异常终止操作。

    HashMap的remove方法源码(部分截取):

    if (node != null && (!matchValue || (v = node.value) == value ||
                                     (value != null && value.equals(v)))) 
                    if (node instanceof TreeNode)
                        ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
                    else if (node == p)
                        tab[index] = node.next;
                    else
                        p.next = node.next;
                    ++modCount;  //进行了modCount自增操作
                    --size;
                    afterNodeRemoval(node);
                    return node;
    

    remove方法则进行了modCount自增操作,

    然后来看一下HashMap的put方法源码(部分截取):

                if (e != null)  // existing mapping for key
                    V oldValue = e.value;
                    if (!onlyIfAbsent || oldValue == null)
                        e.value = value;
                    afterNodeAccess(e);
                    return oldValue;
                
           
            ++modCount;  //对于之前不存在的key进行put的时候,对modCount有修改
            if (++size > threshold)
                resize();
            afterNodeInsertion(evict);
            return null;
    

    对于已经存在的key进行put修改value的时候,对modCount没有修改

    对于之前不存在的key进行put的时候,对modCount有修改

    通过比较put方法和remove方法可以看出,所以只有当对HashMap元素个数产生影响的时候才会修改modCount。

    也是是说:modCount表示 HashMap集合的元素个数,导致集合的结构发生变化。

    那么修改modCount有什么用呢?

    这里用HashMap举例,大家知道当用迭代器遍历HashMap的时候,调用HashMap.remove方法时,

    会产并发修改的异常ConcurrentModificationException

    这是因为remove改变了HashMap集合的元素个数,导致集合的结构发生变化。

    public static void main(String args[]) 
            Map<String, String> map = new HashMap<>();
            map.put("1", "zhangsan");
            map.put("2", "lisi");
            map.put("3", "wangwu");
    
            Iterator<String> iterator = map.keySet().iterator();
            while(iterator.hasNext()) 
                String name = iterator.next();
                map.remove("1");
            
        
    

    执行结果: 抛出ConcurrentModificationException异常

    Exception in thread "main" java.util.ConcurrentModificationException
    	at java.util.HashMap$HashIterator.nextNode(HashMap.java:1442)
    	at java.util.HashMap$KeyIterator.next(HashMap.java:1466)
    	at com.cesec.springboot.system.service.Test.main(Test.java:14)
    

    我们看一下抛出异常的KeyIterator.next()方法源码:

    final class KeyIterator extends HashIterator
            implements Iterator<K> 
            public final K next()  return nextNode().key; 
        
    final Node<K,V> nextNode() 
                Node<K,V>[] t;
                Node<K,V> e = next;
                if (modCount != expectedModCount) //判断modCount和expectedModCount是否一致
                    throw new ConcurrentModificationException();
                if (e == null)
                    throw new NoSuchElementException();
                if ((next = (current = e).next) == null && (t = table) != null) 
                    do  while (index < t.length && (next = t[index++]) == null);
                
                return e;
            
    

    在迭代器初始化时,会赋值expectedModCount,

    在迭代过程中判断modCount和expectedModCount是否一致,如果不一致则抛出异常,

    可以看到KeyIterator.next()调用了nextNode()方法,nextNode()方法中进行了modCount与expectedModCount判断。

    这里更详细的说明一下,在迭代器初始化时,赋值expectedModCount,

    假设与modCount相等,都为0,在迭代器遍历HashMap每次调用next方法时都会判断modCount和expectedModCount是否相等,

    当进行remove操作时,modCount自增变为1,而expectedModCount仍然为0,再调用next方法时就会抛出异常。

    需要通过迭代器的删除方法进行删除

    所以迭代器遍历时, 如果想删除元素, 需要通过迭代器的删除方法进行删除, 这样下一次迭代操作,才不会抛出 并发修改的异常ConcurrentModificationException

    那么为什么通过迭代器删除就可以呢?

    HashIterator的remove方法源码:

    public final void remove() 
                Node<K,V> p = current;
                if (p == null)
                    throw new IllegalStateException();
                if (modCount != expectedModCount)
                    throw new ConcurrentModificationException();
                current = null;
                K key = p.key;
                removeNode(hash(key), key, null, false, false);
                expectedModCount = modCount;
            
    

    通过迭代器进行remove操作时,会重新赋值expectedModCount。

    这样下一次迭代操作,才不会抛出 并发修改的异常ConcurrentModificationException

    hashmap属性总结

    HashMap通过哈希表数据结构的形式来存储键值对,这种设计的好处就是查询键值对的效率 高。

    我们在使用HashMap时,可以结合自己的场景来设置初始容量和加载因子两个参数。当查询操 作较为频繁时,我们可以适当地减少加载因子;如果对内存利用率要求比较高,我可以适当的增加加载因子。

    我们还可以在预知存储数据量的情况下,提前设置初始容量(初始容量=预知数据量/加载因 子)。这样做的好处是可以减少resize()操作,提高HashMap的效率。

    HashMap还使用了数组+链表这两种数据结构相结合的方式实现了链地址法,当有哈希值冲突 时,就可以将冲突的键值对链成一个链表。

    但这种方式又存在一个性能问题,如果链表过长,查询数据的时间复杂度就会增加。HashMap 就在Java8中使用了红黑树来解决链表过长导致的查询性能下降问题。以下是HashMap的数据结 构图:

    HashMap源码分析

    HashMap构造方法:

    HashMap有两个重要的属性:加载因子(loadFactor)和边界值(threshold)。

    loadFactor 属性是用来间接设置 Entry 数组(哈希表)的内存空间大小,在初始 HashMap 不设置参数的情况下,默认 loadFactor 为0.75。

    为什么是0.75这个值呢?

    这是因为对于使用链表法的哈希表来说,查找一个元素的平均时间是 O(1+n),这里的 n 指的是遍历链表的长度,

    因此加载因子越大,对空间的利用就越充分,这就意味着链表的长度越长,查找效率也就越低。

    如果设置的加载因子太小,那么哈希表的数据就过于稀疏,对空间造成严重浪费。

    有什么办法可以来解决因链表过长而导致的查询时间复杂度高的问题呢?

    在JDK1.8后就使用了将链表转换为红黑树来解决这个问题。

    Entry 数组(哈希槽位数组)的 threshold 阈值 是通过初始容量和 loadFactor计算所得,

    在初始 HashMap 不设置参数的情况下,默认边界值为12(16*0.75)。

    如果我们在初始化时,设置的初始化容量较小,HashMap 中 Node 的数量超过边界值,HashMap 就会调用 resize() 方法重新分配 table 数组。

    这将导致 HashMap 的数组复制,迁移到另一块内存中去,从而影响 HashMap 的效率。

    public HashMap() //默认初始容量为16,加载因子为0.75
            this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
        
        public HashMap(int initialCapacity) //指定初始容量为initialCapacity
            this(initialCapacity, DEFAULT_LOAD_FACTOR);
        
        static final int MAXIMUM_CAPACITY = 1 << 30;//最大容量
    
        //当size到达threshold这个阈值时会扩容,下一次扩容的值,根据capacity * load factor进行计算,
        int threshold;
        /**由于HashMap的capacity都是2的幂,因此这个方法用于找到大于等于initialCapacity的最小的2的幂(initialCapacity如果就是2的幂,则返回的还是这个数)
         * 通过5次无符号移位运算以及或运算得到:
         *    n第一次右移一位时,相当于将最高位的1右移一位,再和原来的n取或,就将最高位和次高位都变成1,也就是两个1;
         *    第二次右移两位时,将最高的两个1向右移了两位,取或后得到四个1;
         *    依次类推,右移16位再取或就能得到32个1;
         *    最后通过加一进位得到2^n。
         * 比如initialCapacity = 10 ,那就返回16, initialCapacity = 17,那么就返回32
         *    10的二进制是1010,减1就是1001
         *    第一次右移取或: 1001 | 0100 = 1101 ;
         *    第二次右移取或: 1101 | 0011 = 1111 ;
         *    第三次右移取或: 1111 | 0000 = 1111 ;
         *    第四次第五次同理
         *    最后得到 n = 1111,返回值是 n+1 = 2 ^ 4 = 16 ;
         * 让cap-1再赋值给n的目的是另找到的目标值大于或等于原值。这是为了防止,cap已经是2的幂。如果cap已经是2的幂,又没有执行这个减1操作,则执行完后面的几条无符号右移操作之后,返回的capacity将是这个cap的2倍。
         * 例如十进制数值8,二进制为1000,如果不对它减1而直接操作,将得到答案10000,即16。显然不是结果。减1后二进制为111,再进行操作则会得到原来的数值1000,即8。
         * 问题:tableSizeFor()最后赋值给threshold,但threshold是根据capacity * load factor进行计算的,这是不是有问题?
         * 注意:在构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算。
         * 问题:既然put会重新计算threshold,那么在构造初始化threshold的作用是什么?
         * 答:在put时,会对table进行初始化,如果threshold大于0,会把threshold当作数组的长度进行table的初始化,否则创建的table的长度为16。
         */
        static final int tableSizeFor(int cap) 
            int n = cap - 1;
            n |= n >>> 1;
            n |= n >>> 2;
            n |= n >>> 4;
            n |= n >>> 8;
            n |= n >>> 16;
            return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
        
        public HashMap(int initialCapacity, float loadFactor) //指定初始容量和加载因子
            if (initialCapacity < 0)
                throw new IllegalArgumentException("Illegal initial capacity: " +
                                                   initialCapacity);
            if (initialCapacity > MAXIMUM_CAPACITY)//大于最大容量,设置为最大容量
                initialCapacity = MAXIMUM_CAPACITY;
            if (loadFactor <= 0 || Float.isNaN(loadFactor))//加载因子小于等于0或为NaN抛出异常
                throw new IllegalArgumentException("Illegal load factor: " +
                                                   loadFactor);
            this.loadFactor = loadFactor;
            this.threshold = tableSizeFor(initialCapacity);//边界值
         
    

    put方法源码:

    当将一个 key-value 对添加到 HashMap 中,

    • 首先会根据该 key 的 hashCode() 返回值,再通过 hash() 方法计算出 hash 值,

    • 除留余数法,取得余数,这里通过位运算来完成。 putVal 方法中的 (n-1) & hash 就是 hash值除以n留余数, n 代表哈希表的长度。余数 (n-1) & hash 决定该 Node 的存储位置,哈希表习惯将长度设置为2的 n 次方,这样可以恰好保证 (n-1)&hash 计算得出的索引值总是位于 table 数组的索引之内。

    public V put(K key, V value) 
            return putVal(hash(key), key, value, false, true);
        
    

    hash计算:

    key的hash值高16位不变低16位与高16位异或,作为key的最终hash值。

    static final int hash(Object key) 
            int h;
            return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
        
    
    // 要点1: h >>>  16,表示无符号右移16位,高位补0,任何数跟0异或都是其本身,因此key的hash值高16位不变。
    
    //  要点2: 异或的运算法则为:0⊕0=0,1⊕0=1,0⊕1=1,1⊕1=0(同为0,异为1)
    

    即取 int 类型的一半,刚好可以将该二进制数对半切开,

    利用异或运算(如果两个数对应的位置相反,则结果为1,反之为0),这样可以避免哈希冲突。

    底16位与高16位异或,其目标:

    尽量打乱 hashCode 真正参与运算的低16位,减少hash 碰撞

    之所以要无符号右移16位,是跟table的下标有关,位置计算方式是:

    (n-1)&hash 计算 Node 的存储位置

    **假如n=16,**从下图可以看出:

    table的下标仅与hash值的低n位有关,hash值的高位都被与操作置为0了,只有hash值的低4位参与了运算。

    putVal方法源码

    putVal:

    而当链表长度太长(默认超过 8)时,链表就进行转换红黑树的操作。

    这里利用红黑树快速增删改查的特点,提高 HashMap 的性能。

    当红黑树结点个数少于 6 个的时候,又会将红黑树转化为链表。

    因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) 
            Node<K,V>[] tab; Node<K,V> p; int n, i;
            //此时 table 尚未初始化,通过 resize 方法得到初始化的table
            if ((tab = table) == null || (n = tab.length) == 0)
                n = (tab = resize()).length;
            // (n-1)&hash 计算 Node 的存储位置,如果判断 Node 不在哈希表中(链表的第一个节点位置),新增一个 Node,并加入到哈希表中
            if ((p = tab[i = (n - 1) & hash]) == null)
                tab[i] = newNode(hash, key, value, null);
            else //hash冲突了
                Node<K,V> e; K k;
                if (p.hash == hash &&
                    ((k = p.key) == key || (key != null && key.equals(k))))
                    e = p;//判断key的条件是key的hash相同和eqauls方法符合,p.key等于插入的key,将p的引用赋给e
                else if (p instanceo

    以上是关于Hashmap 原理源码面试题(史上最全)的主要内容,如果未能解决你的问题,请参考以下文章

    史上最全Hashmap面试总结,51道附带答案,持续更新中...

    史上最全Hashmap面试总结,51道附带答案,持续更新中...

    最强福利!史上最全的JAVA面试题

    Caffeine 源码架构原理(史上最全,10W字 超级长文)

    史上最全的大厂Mysql面试题在这里!

    史上最全前端vue面试题!推荐收藏