彻底搞懂 HashMap 底层原理

HashMap绝对是最常用,也是面试时最常问的集合之一,只有把所有的要点都要烂熟于心,再面大厂时才能胸有成竹,应对自如。接下来就带你慢慢揭开 HashMap 面纱。

一、提出问题

学习一个知识点,最好的方式就是带着问题学习,那么我们首先抛出几个面试中常见的问题,然后带着这些问题我们一点一点的剖析HashMap的原理。

  • HashMap 的底层数据结构?
  • Java7 和 Java8的区别?
  • 为啥会线程不安全?
  • 有什么线程安全的类代替么?
  • 默认初始化大小是多少?为啥是这么多?为啥大小都是2的幂?
  • HashMap的扩容方式?负载因子是多少?为什是这么多?
  • HashMap是怎么处理hash碰撞的?
  • hash的计算规则?

二、HashMap 的底层数据结构

HashMap 的底层数据结构主要是,数组 + 链表 的形式,在JDK8 中还会用到红黑树。具体结构如下图所示:在这里插入图片描述

HasMap 为什么要使用 数组 + 链表的数据结构呢?JDK8中为什么又引入了红黑树呢?

1、为什么使用数组。

我们知道数组的好处是可以根据下标快速的找到对应的元素。在 HashMap 中可以根据 keyhashCode值计算出,所在数组的下标,能够更快的定位到节点所在的位置。

2、为什么需要链表

Java中的hashCode的类型是 int 类型,其取值范围为-232~231 (-2147483648 ~ 2147483647)。这么大的范围,不可能直接使用。那么就需要使用HashCode与数组长度做与运算,得到一个可以在数组中出现的位置。如果说有两个元素得到同样的index,那么这个数组index下就存放两个值。不同的值存在数组的同一个位置,又不能覆盖,链表的插入和删除速度优势比较快的,这样就形成了一个链表结构。

这样数组和链表相结合即提高了查找速度,又提高了添加和删除的速度。

3、JDK8 后为什么又引入了红黑树

首先我们先看看链表和红黑树的性能对比,如下所示:

  • 链表:插入复杂度O(1),查找复杂度O(n)
  • 红黑树:插入复杂度O(logn),查找复杂度O(logn)
  • HashMap数组元素为链表的时候,插入直接使用头插,插入复杂度O(1);当链表较短时候,查找数据时对性能并没有什么影响,如果链表一长,查找起来就很影响性能了。
  • Java8中,如果数组和链表的长度达到一定长度,就会转化为红黑树,提高了查找的性能,但每次插入新的数据,都得维护红黑树的结构,复杂度为O(logn)。这样算是对查找和插入元素时性能的一个权衡,毕竟存起来就是用来查的。

4、什么时候链表会转换为红黑树

看了很多博客文章都说是在链表的长度达到 8个以后,链表就会转换为红黑树。其实,这种说法不是完全正确的。正取的说法应该是当 数组的长度大于64,且链表的长度达到 8个后才会转换为红黑树
HashMapputVal() 中转换红黑树的代码(如下所示)
在这里插入图片描述
大部分人可能是看到了上图红框中的代码就说,当链表长度大于8个时就会转换红黑树。那么,我们在看看treeifyBin方法的代码。如下所示:
在这里插入图片描述
通过treeifyBin的源码我们看到。当 数组的长度(tab.length)小于MIN_TREEIFY_CAPACITY的时候,调用了resize()方法进进行扩容。

三、初始化容量和负载因子

我们在使用 HashMap 时,可能习惯性的使用new HashMap();创建。此种情况 HashMap 的默认大小为 DEFAULT_INITIAL_CAPACITY = 1 << 4; 也就是 16。那么,如果我们创建时传入的长度为 17(即:new HashMap(17);),HashMap 又是如何处理的呢?

3.1、寻找2的次幂最小值

在HashMap的初始化中,有这样一段方法;

public HashMap(int initialCapacity, float loadFactor) {
    
    
        ...
        this.loadFactor = loadFactor; // 负载因子
        // 关键点:
        this.threshold = tableSizeFor(initialCapacity);
    }
  • 阀值threshold,通过方法tableSizeFor进行计算,是根据初始化来计算的。
  • 这个方法也就是要寻找比初始值大的,最小的那个2的n次方数值。比如传了17,那么这个值就是32。

计算阀值大小的方法;

    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;
    }
  • MAXIMUM_CAPACITY = 1 << 30,这个是临界范围,也就是最大的Map集合。
  • 乍一看可能有点晕怎么都在向右移位1、2、4、8、16,这主要是为了把二进制的各个位置都填上1,当二进制的各个位置都是1以后,就是一个标准的2的倍数减1了,最后把结果加1再返回即可。

我们把 17 这个数演示出来如下所示:
在这里插入图片描述

为什么需要设置为2的次幂方,请查看 为啥 HashMap 初始值是 2 的 n 次幂?

3.2、负载因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

负载因子是和扩容有关的,也就是说当HashMap中的元素个数达到某个阈值时,就需要对当前容器进行扩容。
那么为什么这样设置呢?上文提到HashMap内部是以 数组加链表或红黑树的数据结构存储数据的,我们存储数据时,是通过hash值计算数组的下标的形式进行散列的,其中可能存在数组的同一位置上存在多个元素的情况。不管是链表还是红黑树,当其中的元素个数较多是其查找、插入和删除都会减慢,HashMap的功能就是散列,那么就可以通过扩容的方式,增加散列度,使链表或者红黑树中的元素个数减少,提供性能。

  • 所以,要选择一个合理的大小下进行扩容,默认值0.75就是说当阀值容量占了3/4s时赶紧扩容,减少Hash碰撞。
  • 同时0.75是一个默认构造值,在创建HashMap也可以调整,比如你希望用更多的空间换取时间,可以把负载因子调的更小一些,减少碰撞。

四、hash值得计算规则

我们先看下HashMap计算hash值的源码,如下:

    static final int hash(Object key) {
    
    
        int h;
         // 计算hash 无符号右移 16位,是为了 高位参与运送
        // 减少 hash 冲突。
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

我们可以看到,其中的关键部分为(h = key.hashCode()) ^ (h >>> 16)。把哈希值右移16位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性
举个例子:
比如有两keyhashCode 分别为7C3B0000,1C3C0000 (十六进制表示),HashMap中数组的长度为16。这两个明显不同,但取模后都为 0,就出现冲突了。如果也让高位参与计算结果就不同了,如下所示:
在这里插入图片描述
由上图可知,高位参与运算后的下标分别变成了 11 和 12,减少了冲突。

五、为啥线程不安全

线程不安全主要体现在如下几方面:

  1. 在JDK7中当扩容时,容易造成死循环。
  2. 导致数据丢失。
  3. HashMap中明明有值,但是在 get 时返回null。

具体可参考:为什么说 HashMap 不是线程安全的

六、可替代的线程安全的类

1、HashTable。
HashTable是线程安全的Map,但是其内部方法是通过 synchronized 加锁实现的线程互斥。性能较低。

2、Collections
使用 Collections 提供的synchronizedMap方法构建线程安全的类,其内部也是通过synchronized 加锁实现的线程互斥。性能较低。

3、ConcurrentHashMap
ConcurrentHashMap 也是通过加锁方式,但其是通过分段锁,进行的加锁,性能较上两个要高很多。

七、Java7 和 Java8的区别

Java7 Java8 之后的版本之间的区别主要有:

  1. 数据结构:Java7中采用的是数组+链表的数据结构,而Java8 中采用的是数组 + 链表和红黑树的数据结构。
  2. 插入方式:JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。
  3. 扩容后数据存储位置的计算方式也不一样。在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&。而在JDK1.8的时候直接用了JDK1.7的时候计算的规律,也就是扩容前的原始位置+扩容的大小值=JDK1.8的计算方式,而不再是JDK1.7的那种异或的方法。但是这种方式就相当于只需要判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式。

参考:
https://blog.csdn.net/qq_36520235/article/details/82417949
https://aobing.blog.csdn.net/article/details/103467732
https://bugstack.blog.csdn.net/article/details/107903915

猜你喜欢

转载自blog.csdn.net/small_love/article/details/112528723