• HashMap实现原理, 扩容机制,面试题和总结


    1.讲下对HashMap的认识

    HashMap 存储的是键值对 key - value,key 具有唯一性,采用了链地址法来处理哈希冲突。当往 HashMap 中添加元素时,会计算 key 的 hash 值取余得出元素在数组中的的存放位置。

    1. HashMap底层的数据结构在 JDK1.8 中有了较大的变化,1.8之前采用数组加链表的数据结构,1.8采用数组加链表加红黑树的数据结构。
    2. HashMap 是线程不安全的,线程安全可以使用 HashTable 和 ConcurrentHashMap 。
    3. 在 1.8 版本的中 hash() 和 resize( ) 方法也有了很大的改变,提升了性能。
    4. 键和值都可存放null,键只能存放一个null,键为null时存放入table[0]。

    2.HashMap的一些参数

    	//HashMap的默认初始长度16
    	static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
    	
    	//HashMap的最大长度2的30次幂
    	static final int MAXIMUM_CAPACITY = 1 << 30;
    	
    	//HashMap的默认加载因子0.75
    	static final float DEFAULT_LOAD_FACTOR = 0.75f;
    	
    	//HashMap链表升级成红黑树的临界值
    	static final int TREEIFY_THRESHOLD = 8;
    	
    	//HashMap红黑树退化成链表的临界值
    	static final int UNTREEIFY_THRESHOLD = 6;
    	
    	//HashMap链表升级成红黑树第二个条件:HashMap数组(桶)的长度大于等于64
    	static final int MIN_TREEIFY_CAPACITY = 64;
    	
    	//HashMap底层Node桶的数组
    	transient Node<K,V>[] table;
    	
    	//扩容阈值,当你的hashmap中的元素个数超过这个阈值,便会发生扩容
    	//threshold = capacity * loadFactor
    	int threshold;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    3.为什么HashMap的长度必须是2的n次幂?

    在计算存入结点下标时,会利用 key 的 hsah 值进行取余操作,而计算机计算时,并没有取余等运算,会将取余转化为其他运算。

    当n为2次幂时,会满足一个公式:(n - 1) & hash = hash % n,就可以用位运算代替取余运算,计算更加高效。

    4.HashMap 为什么在获取 hash 值时要进行位运算

    换种问法:能不能直接使用key的hashcode值计算下标存储?

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    
    • 1
    • 2
    • 3
    • 4
    1. 如果使用直接使用hashCode对数组大小取余,那么相当于参与运算的只有hashCode的低位,高位是没有起到任何作用的,所以我们的思路就是让 hashCode取值出的高位也参与运算,进一步降低hash碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动。
    2. (h >>> 16)是无符号右移16位的运算,右边补0,得到 hashCode 的高16位。
      (h = key.hashCode()) ^ (h >>> 16) 把 hashCode 和它的高16位进行异或运算,可以使得到的 hash 值更加散列,极可能减少哈希冲突,提升性能。
    3. 而这么来看 hashCode 被散列 (异或) 的是低16位,而 HashMap 数组长度一般不会超过2的16次幂,那么高16位在大多数情况是用不到的,所以只需要拿 key 的 HashCode 和它的低16位做异或即可利用高位的hash值,降低哈希碰撞概率也使数据分布更加均匀。

    5.HashMap在JDK1.7和JDK1.8中有哪些不同? HashMap的底层实现

    在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。

    JDK1.8主要解决或优化了以下问题:

    1. resize 扩容和 计算hash 优化
    2. 引入了红黑树,目的是避免单条链表过长而影响查询效率,红黑树算法请参考
    3. 解决了多线程死循环问题,但仍是非线程安全的,多线程时可能会造成数据丢失问题。

    6.HashMap的put方法的具体流程?

    源码

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
     
     /**
     * Implements Map.put and related methods.
     * 实现了map的put和相关方法
     * @param hash  key的hash值(key的hash高16位+高16位与低16位的异或运算)
     * @param key 键
     * @param value 值  
     * @param onlyIfAbsent onlyIfAbsent为true的时候不要修改已经存在的值,如果onlyIfAbsent为false,当插入的元素已经在HashMap中已经拥有了与其key值和hash值相同的元素,仍然需要把新插入的value值覆盖到旧value上。如果nlyIfAbsent为true,则不需要修改
     * @param evict evict如果为false表示构造函数调用
     * @return 返回旧的value值(在数组桶或链表或红黑树中找到存在与插入元素key值和hash值相等的元素,就返回这个旧元素的value值),如果没有发现相同key和hash的元素则返回null
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                       boolean evict) {
        // tab用来临时存放数组table引用   p用来临时存放数组table桶中的bin
        // n存放HashMap容量大小   i存放当前put进HashMap的元素在数组中的位置下标
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // table未初始化或者长度为0,进行扩容
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        // 桶中已经存在元素
        else {
            // e记录当前节点  k记录key值
            Node<K,V> e; K k;
            // 比较桶中第一个元素(数组中的结点)的hash值相等,key相等
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                    // 将第一个元素赋值给e,用e来记录。直接将插入的新元素覆盖旧元素
                    e = p;
            // hash值不相等,即key不相等并且该节点为红黑树结点,将元素插入红黑树
            else if (p instanceof TreeNode)
                // 放入树中
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 为链表结点
            else {
                // 在链表最末插入结点(尾插法)
                for (int binCount = 0; ; ++binCount) {
                    // 到达链表的尾部
                    if ((e = p.next) == null) {
                        // 在尾部插入新结点
                        p.next = newNode(hash, key, value, null);
                        // 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法
                        // 这个treeifyBin()方法会根据 HashMap 数组情况来决定是否转换为红黑树。
                        // 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少执行效率。否则,就是只是对数组扩容。
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    					// 树化操作
                            treeifyBin(tab, hash);
                        // 跳出循环  此时e=null,表示没有在链表中找到与插入元素key和hash值相同的节点
                        break;
                    }
                    // 判断链表中结点的key值和Hash值与插入的元素的key值和Hash值是否相等
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        // 若相等,则不用将其插入了,直接跳出循环
                        break;
                    // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                    p = e;
                }
            }
            // 当e!=null时,表示在数组桶或链表或红黑树中存在key值、hash值与插入元素相等的结点。此时就直接用原有的节点就可以了,不用插入新的元素了。此时e就代表原本就存在于HashMap中的元素
            if (e != null) {
                // 记录e的value,也就是旧value值
                V oldValue = e.value;
                // onlyIfAbsent为false或者旧值为null,则需要用新的value值对旧value值进行覆盖
                if (!onlyIfAbsent || oldValue == null)
                    //用新值替换旧值
                    e.value = value;
                // 替换旧值时会调用的方法(默认实现为空)
                afterNodeAccess(e);
                // 返回旧值
                return oldValue;
            }
        }
        // 结构性修改,记录HashMap被修改的次数,主要用于多线程并发时候
        ++modCount;
        // 实际大小大于阈值则扩容    ++size只有在插入新元素才会执行,如果发现HashMap中已经存在了相同key和hash的元素,就不会插入新的元素,在上面就已经执行return了,也就不会改变size大小
        if (++size > threshold)
            resize();
        // 插入成功时会调用的方法(默认实现为空)
        afterNodeInsertion(evict);
        // 没有找到原有相同key和hash的元素,则直接返回Null
        return null;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88

    HashMap是懒加载,只有在第一次put时才会创建数组。
    总结
    ①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
    ②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
    ③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
    ④.判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值
    对,否则转向⑤;
    ⑤.遍历table[i],并记录遍历长度,如果遍历过程中发现key值相同的,则直接覆盖value,没有相同的key则在链表尾部插入结点,插入后判断该链表长度是否大等于8,大等于则考虑树化,如果数组的元素个数小于64,则只是将数组resize,大等于才树化该链表;
    ⑥.插入成功后,判断数组中的键值对数量size是否超多了 大容量threshold,如果超过,进行扩容。

    7.HashMap 的 get 方法的具体流程?

    public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
    
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        
        //Node数组不为空,数组长度大于0,数组对应下标的Node不为空
        if ((tab = table) != null && (n = tab.length) > 0 &&
            //也是通过 hash & (length - 1) 来替代 hash % length 的
            (first = tab[(n - 1) & hash]) != null) {
            
            //先和第一个结点比,hash值相等且key不为空,key的第一个结点的key的对象地址和值均相等
            //则返回第一个结点
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            //如果key和第一个结点不匹配,则看.next是否为空,不为null则继续,为空则返回null
            if ((e = first.next) != null) {
                //如果此时是红黑树的结构,则进行处理getTreeNode()方法搜索key
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                //是链表结构的话就一个一个遍历,直到找到key对应的结点,
                //或者e的下一个结点为null退出循环
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    总结

    1. 首先根据 hash 方法获取到 key 的 hash 值
    2. 然后通过 hash & (length - 1) 的方式获取到 key 所对应的Node数组下标 ( length对应数组长度 )
    3. 首先判断此结点是否为空,是否就是要找的值,是则返回空,否则判断第二个结点是否为空,是则返回空,不是则判断此时数据结构是链表还是红黑树
    4. 链表结构进行顺序遍历查找操作,每次用 == 符号 和 equals( ) 方法来判断 key 是否相同,满足条件则直接返回该结点。链表遍历完都没有找到则返回空。
    5. 红黑树结构执行相应的 getTreeNode( ) 查找操作。

    8.HashMap的扩容操作是怎么实现的?

    不管是JDK1.7或者JDK1.8 当put方法执行的时候,如果table为空,则执行resize()方法扩容。默认长度为16。

    JDK1.7扩容

    条件:发生扩容的条件必须同时满足两点

    1. 当前存储的数量大于等于阈值
    2. 发生hash碰撞

    因为上面这两个条件,所以存在下面这些情况

    1. 就是hashmap在存值的时候(默认大小为16,负载因子0.75,阈值12),可能达到最后存满16个值的时候,再存入第17个值才会发生扩容现象,因为前16个值,每个值在底层数组中分别占据一个位置,并没有发生hash碰撞。
    2. 当然也有可能存储更多值(超多16个值,最多可以存26个值)都还没有扩容。原理:前11个值全部hash碰撞,存到数组的同一个位置(这时元素个数小于阈值12,不会扩容),后面所有存入的15个值全部分散到数组剩下的15个位置(这时元素个数大于等于阈值,但是每次存入的元素并没有发生hash碰撞,所以不会扩容),前面11+15=26,所以在存入第27个值的时候才同时满足上面两个条件,这时候才会发生扩容现象。

    特点:先扩容,再添加(扩容使用的头插法)

    缺点:头插法会使链表发生反转,多线程环境下可能会死循环

    扩容之后对table的调整:

    table容量变为2倍,所有的元素下标需要重新计算,newIndex = hash (扰动后) & (newLength - 1)

    JDK1.8扩容

    条件

    1. 当前存储的数量大于等于阈值
    2. 当某个链表长度>=8,但是数组存储的结点数size() < 64时

    特点:先插后判断是否需要扩容(扩容时是尾插法)

    缺点:多线程下,1.8会有数据覆盖

    举例:
    线程A:往index插,index此时为空,可以插入,但是此时线程A被挂起
    线程B:此时,对index写入数据,A恢复后,就把B数据覆盖了

    扩容之后对table的调整:

    table容量变为2倍,但是不需要像之前一样计算下标,只需要将hash值和旧数组长度相与即可确定位置。

    1. 如果 Node 桶的数据结构是链表会生成 low 和 high 两条链表,是红黑树则生成 low 和 high 两颗红黑树
    2. 依靠 (hash & oldCap) == 0 判断 Node 中的每个结点归属于 low 还是 high。
    3. 把 low 插入到 新数组中 当前数组下标的位置,把 high 链表插入到 新数组中 [当前数组下标 + 旧数组长度] 的位置
    4. 如果生成的 low,high 树中元素个数小于等于6退化成链表再插入到新数组的相应下标的位置

    9.HashMap 在扩容时为什么通过位运算 (e.hash & oldCap) 得到下标?

    从下图中我们可以看出,计算下标通过(n - 1) & hash,旧table的长度为16,hash值只与低四位有关,扩容后,table长度为32(两倍),此时只与低五位有关。

    所以此时后几位的结果相同,前后两者之间的差别就差在了第五位上。

    同时,扩容的时候会有 low 和 high 两条链表或红黑树来记录原来下标的数据和原来下标 + 旧table下标的数据。
    在这里插入图片描述
    如果第五位 b 是 0,那么只要看低四位 (也就是原来的下标);如果第五位是 1,只要把低四位的二进制数 + 1 0 0 0 0 ,就可以得到新数组下标。前面的部分刚好是原来的下标,后一部分就是旧table的长度 。那么我们就得出来了为什么把 low 插入扩容后 新数组[原来坐标] 的位置,把 high 插入扩容后 新数组[当前坐标 + 旧数组长度] 的位置。

    那为什么根据 (e.hash & oldCap) == 0 来做判断条件呢?是因为旧数组的长度 length 的二进制数的第五位刚好是 1,hash & length 就可以计算 hash 值的第五位是 0 还是 1,就可以区别是在哪个位置上。

    10.链表升级成红黑树的条件

    链表长度大于8时才会考虑升级成红黑树,是有一个条件是 HashMap 的 Node 数组长度大于等于64(不满足则会进行一次扩容替代升级)。

    11.红黑树退化成链表的条件

    1. 扩容 resize( ) 时,红黑树拆分成的 树的结点数小于等于临界值6个,则退化成链表。
    2. 删除元素 remove( ) 时,在 removeTreeNode( ) 方法会检查红黑树是否满足退化条件,与结点数无关。如果红黑树根 root 为空,或者 root 的左子树/右子树为空,root.left.left 根的左子树的左子树为空,都会发生红黑树退化成链表。

    12.HashMap是怎么解决哈希冲突的?

    1. 使用链地址法(使用散列表)来链接拥有相同下标的数据;
    2. 使用2次扰动函数(hash函数)来降低哈希冲突的概率,使得数据分布更平均;
    3. 引入红黑树进一步降低遍历的时间复杂度,使得遍历更快;

    13.HaspMap的初始化时数组长度和加载因子的约束范围

    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))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    可以看到如果初始化数组长度 initialCapacity 小于 0 的话会跑出 IllegalArgumentException 的异常,initialCapacity 大于 MAXIMUM_CAPACITY 即 2 的 30 次幂的时候最大长度也只会固定在 MAXIMUM_CAPACITY ,在扩容的时候,如果数组的长度大等于MAXIMUM_CAPACITY,会将阈值设置为Integer.MAX_VALUE。

    加载因子小于等于0时,或者加载因子是NaN时 (NaN 实际上就是 Not a Number的简称) 会抛出 IllegalArgumentException 的异常。

  • 相关阅读:
    哈希表6——四数相加
    2022年CCNA面试题库和答案
    不止跑路,拯救误操作rm -rf /*的小伙儿
    linux杀毒软件clamav安装
    第十一章《Java实战常用类》第1节:包装类
    个人和企业如何做跨境电商?用API电商接口教你选平台选品决胜跨境电商
    2023/09/19 qt day3
    AWS 中文入门开发教学 39- AWS CLI - AWS认证 必须会的命令行工具
    媒体查询?
    营收连续上涨,一脉阳光能否打造“医学影像独角兽”?
  • 原文地址:https://blog.csdn.net/qq_49217297/article/details/126304736