关于HashMap底层实现的一些理解

目录
  1. HashMap基本概念
  2. HashMap在JDK1.8之后的改动
  3. MIN_TREEIFY_CAPACITY属性
  4. 为什么capacity要是2的n次幂
  5. 为什么loadFactor要是0.75f

1. HashMap基本概念

HashMap采用数组+链表的形式存储键值对,这里不多做解释。我们先来理一下HashMap中遇到的一些名词。

capacity:容量

这个容量指的不是Entry的数量,而是数组的长度:
在这里插入图片描述

capacity表示的并不是Map所能存放的最大KV数,而是数组的长度,也就是桶的大小。

size:大小

这个大小很好理解,表示的就是Map中真实存放的Entry的数量。

loadFactor:加载因子

他描述的是HashMap满的程度。接近0表示很空,1表示填满了。

threshold:阈值

当HashMap中的键值对超过了该值,HashMap就会进行扩容,这个值的大小和上面的capacity以及loadFactor息息相关,为容量*加载因子,即 threshold=capacity * loadFactor。比如:threshold = 16 * 0.75 = 12。那么当KV数量超过12时,HashMap就会进行扩容。
下面来看一下源码,对这些属性的处理。
首先是capacity在这里插入图片描述
可见,HashMap帮我们设定了一个默认初始值——16(1左移四位即乘4次2,即24)。还设置了一个最大值——230
其次的size和加载因子比较好理解,不过多解释。下图代码表示了当我们不指定加载因子时,会采用默认的加载因子——0.75。
在这里插入图片描述
上面调用的this(),正是下图中的另外一个构造方法。3个if分别处理了传入的参数小于0,大于230以及加载因子小于0或非法运算(0.0f/0.0f)的异常。
在这里插入图片描述
上面说过,不指定容量时,会使用默认容量16。但如果我们指定,容量将会被设为第一个不小于设置值的2的幂次方数。比如我们指定3,那么容量就是4;指定16,容量就是16,指定17,容量就是32。这个过程,是在上图代码中的最后一行的:tableSiezFor()函数里完成的(不是一次完成,通过一系列的位运算达到效果)。下面的代码可以试一下传入不同的值时,capacity的数值会被设置成多少:

public static void main(String[]args) throws Exception {
	HashMap<String,String> m = new HashMap<>(16);
	Class c1 = m.getClass();
    Method m1 = c1.getDeclaredMethod("capacity");
    m1.setAccessible(true);
    System.out.println("capacity的数值是:" + m1.invoke(m));
}

现在我们就在看一下最后这个tableSiezFor(int cap)函数到底干了什么。源码如图:
在这里插入图片描述
形参cap就是容量的缩写。最前面也提到了,我们这里说的容量就是指数组的长度,而这个数组就是table,所以也可以成为table的size。代码只有两行,总得来说作用就是通过一系列的位运算,找到那个合适的2的n次幂,并把他赋值给阈值threshold(后面会说为什么直接赋给阈值而不是乘以加载因子再赋值)。

threshold阈值。当集合中的键值对数量超过了该值,集合就会扩容。当然,阈值也会相应的重新计算。比如有一个集合,它的容量是16,阈值自然就是12,此时集合里面有11个键值对对象,当再增加一个的时候,将会达到阈值,但并没有超过,所以不会扩容,而如果再加一个的话,集合的容量就将被扩大为32,阈值也会被修改为32*0.75=24。(jdk1.7的时候还会做一个判断:如果插入元素刚好放在一个空的数组位,那么将不会扩容(resize()),但1.8之后不做这个判断了)
测试代码和结果如下:

	public static void main(String[]args) throws Exception {
        for (int j = 11; j <= 13; j++) {
            HashMap<String,String> m = new HashMap<>(16,0.75f);
            for (int i = 0; i < j; i++) {
                m.put(""+i,"1");
            }
            f(m);
        }
    }
    public static void f(HashMap<String,String> m) throws Exception {
        Class c1 = m.getClass();
        Method capacity = c1.getDeclaredMethod("capacity");
        capacity.setAccessible(true);
        System.out.println("capacity:" + capacity.invoke(m));

        Field size = c1.getDeclaredField("size");
        size.setAccessible(true);
        System.out.println("size : " + size.get(m));

        Field threshold = c1.getDeclaredField("threshold");
        threshold.setAccessible(true);
        System.out.println("threshold : " + threshold.get(m));
    }

在这里插入图片描述
★★★所以说,这个阈值的判断,不看是看数组中有多少个位置被沾满了,而是看真实的键值对的数量。★★★
在这里插入图片描述
要注意的是:1.8之后,数组的生成被延迟到put函数中,在第一次put时,调用resize()函数进行初始化,阈值也是在此时才被乘以加载因子的,所以如果集合是空的,此时的阈值就是等于容量的。
下图代码为resize()函数中,对数组初始化的一些处理:
在这里插入图片描述

2. HashMap在JDK1.8之后的改动

1 使用红黑树进行优化

原来的HashMap采用的是数组加链表的形式,而jdk1.8之后,改用了数组+链表+红黑树的方式,当我们某个数组元素的链表过长时,就会把链表转化为红色数进行存储,这里的阈值是8:
在这里插入图片描述
我们可以看出,虽然变成树化的阈值是8,但如果要从树经过删减变回链表,这个阈值就是6了。

2 哈希值的计算化简

可能是因为红黑树能够很好得提高效率,所以新的HashMap允许散列值的计算稍微简化一点,虽然散列性变差了一点,但是红黑树完全可以弥补这个不足。我们这里就只具体看一下1.8中的哈希算法是如何计算的。
在这里插入图片描述
这里的代码很简单,就是让key对象的哈希值自身和右移16位后的自己,进行异或运算。我们这里来探究一下为什么要这样做。

原始的哈希值计算出来后,是32位了,而我们的容量很明显不会有这么大,所以需要把原始的哈希值进行运算,使得其在某个固定的区间。比如我们容量只有16,那么下标就是[0 , 15],这样的话,我就必须保证哈希值在此区间。首先最直白的方式肯定是取余,比如对16取余,就可得到0-15这16种结果,刚好可以放入数组中。但是,取余操作的效率很低,所以采用了位运算的方式,其本质上就是一种效率更高的取余。源码如下:
在这里插入图片描述
当进行put操作的时候,底层通过(n - 1) & hash这样一个运算,巧妙得对原始哈希值进行了取余操作。n为数组的长度,(n - 1)减一就是会得到一个低位全为1的二进制数,如15(0000 1111),31(0001 1111)等,某个数对这种数进行与操作,很明显高位全为0,低位全保留了原样,从而把范围限定在了0到n-1之间。(按位运算方法取余只对2n-1才适用,所以我们容量n为2的整次幂,也是十分关键的)。
这个位运算是这样算的,举个实例:
在这里插入图片描述
但是这样有一个问题,就是hash的高位全部都没有参与运算,无论高位是什么,都不能影响到结果,从而加大了哈希碰撞的概率,所以我们看到的hash其实是已经经过一个函数进行扰乱的了,这个函数就是hash()
在这里插入图片描述
可以看出,我们在把hash进行取余操作前,已经经过了一次函数的变化,这个操作的作用是,让自己和右移16位后的自己进行异或运算。由于hash值是32的,所以说白了,右移后的数值,高16位肯定是0,低16位就是原来的高16位。对0进行异或运算,是一个"幺元"运算,结果还是自身,所以新hash的高16位不受影响,而新hash的低16将会是原来hash低16和高16位相异或得到的结果,这样的话,高16位就在一定程度上影响了最终hash的值。实验表明,这样是可以降低哈希碰撞的概率的(不能降低也不会用了)。

3 链表插入方式的不同

HashMap的put方法中,在1.7之前,链表元素的插入采用的是头插法,也就是说,当有新结点进来时,会在插入在链表的头部。很明显,由于不用遍历链表,这种插入方式的效率是更高的。但是1.8之后,因为当结点插入的时候,本身就要为了判断元素的个数而遍历链表(看看是否达到了树化的阈值),所以就可以搭一个顺风车,在遍历完之后,把结点插入到链表尾部,即采用的尾插法。
这种方式也解决了多线程下可能引发的死锁问题。因为头插法的链表在扩容移动时,会被逆序,即后插入的先被处理,如果这个时候有另一线程进行get操作,就有可能引发死锁,具体的过程这里就不做解释了。

4 resize()的改动

主要有2个方面,分别是:

  • rehash的方式不同
  • 新数据的插入时机的不同

1.8之后,初始化和扩容合二为一,都放在了resize()函数中。

4.1 rehash的方式不同

因为数组扩容了,key的下标需要重新通过哈希计算。在1.7之前,采用的是按照之前的方式全部重新计算一遍,这样很明显会比较耗费时间。而1.8之后,采用了非常巧妙的一种方式。我们发现,扩容过后,进行与运算的(n - 1),本质上就是把最低的0位变为1而已,比如原来是(0011 1111)就会变为(0111 1111)。那么同一份hash值,经过这2个不同的(n - 1)的与运算结果,是有一些关联的。由于我们的(n-1)就是前面多了一个1,所以原来的hash值经过该运算,和原结果相比,只会有2种结果,要么原hash值对应的那个位置是上原本就算0,那么与运算的结果不变,要么原来是1,现在就发生了变化。举个例子:
在这里插入图片描述
也就算讲,重新计算后的元素,要么就在原来的位置,要么就会在一个新位置这两种选择。而这个新位置,从进制中也可以明显得看出,就是在前面多了一个1而已,也就是在原基础上加上了2n这里的n都是指原来的n),这个2n刚好也是原来的容量,所以说,元素新的位置,要么不变,要么就在(原位置+原容量)这个索引处。而究竟是哪一个,完全就由hash中权值为2n的这个位决定,而这位的数字,在概率上来说,也是随机的,也就是大家都是50%的概率,这样也很好得减小了hash碰撞的概率。

4.2 插入时机不同

1.7之前是扩容后再插入新的数据,并且不会先计算插入值的哈希值,最后单独算。
1.8之后是先插入再扩容,插入的值和大家一起计算新的哈希值。

3. MIN_TREEIFY_CAPACITY属性

在这里插入图片描述
前面提到了1.8之后,当遇到阈值时,链表会发生树化。但是树化还有一个条件,就是此时的容量必须不小于64。
在这里插入图片描述
因为如果桶的数量过少,又发生了严重的hash碰撞,那么根本问题其实是桶的数量太少了,所以此时树化的意义就不大,就会先优先扩容。

4. 为什么capacity要是2的n次幂

为了避免哈希冲突且把值控制在一定范围内,我们会采用取余的方式。而想要高效取余,就得使用位运算,而想要使用位运算,只有当容量是2的整次幂时,才符合要求。同时这种处理方法,可以让rehash的过程,也变得简单很多。
总而言之:为了提高性能,高效得减少哈希冲突。

5. 为什么loadFactor要是0.75f

我们先来看看极端的情况。
当过小时,数组里仅仅有很少的数据,数组就要进行扩容,虽然可以有效减少哈希碰撞,但是消耗的内存及时间过大,得不偿失,过于奢华。
当过大时,数组的内存虽然得到了很好的利用,但是哈希碰撞频繁,从而导致查找,修改效率降低,链表树化明显加重。
所以,需要找到一个平衡点,这个和统计学有关,日后再更新,但肯定是符合科学规律的,所以通常就使用默认值。

  • 5
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值