HashMap源码解析、jdk7和8之后的区别、相关问题分析

一、概览

HashMap<String, Integer> map = new HashMap<>();

这个语句执行起来,在 jdk1.8 之前,会创建一个长度是 16 的 Entry[] 数组,叫 table,用来存储键值对。

在 jdk 1.8 后,不在这里创建数组了,而是在第一次 put 的时候才会创建数组叫 Node[] table ,用来存储键值对。

二、源码的成员变量分析

声明部分

在这里插入图片描述
HashMap 实现了 Map 接口,又继承了 AbstractMap,但是 AbstractMap 也是实现了 Map 接口的,而且很多集合类都是这种实现,这是一个官方失误造成的冗余,不过一直流传了下来。

  1. 继承 AbstractMap ,这个父类作为抽象类,实现了 Map 的很多方法,为了减少直接实现类的工作;
  2. 实现 Cloneable 接口和 Serializable 接口,这个问题在 原型模式 里面说过,就是深拷贝的问题,但是值得注意的是,HashMap 实现这两个接口,重写的方法仍然不是深拷贝,而是浅拷贝

属性部分

在这里插入图片描述

2.1 序列号serialVersionUID

序列化默认版本号,不重要。

2.2 默认初始化容量DEFAULT_INITIAL_CAPACITY

集合默认初始化容量,注释里写了必须是 2 的幂次方数,默认是 16。

问题 1 : 为什么非要是 2 的次方数呢?

答:第一方面为了均匀分布,第二方面为了扩容的时候重新计算下标值的方便。

这个涉及到了插入元素的时候对每一个 node 的应该在的桶位置的计算:

在这里插入图片描述
核心在这个方法里,会根据 (n - 1) & hash 这个公式计算出 ihash 是提前算出的 key 的哈希值,n 则是整个 map 的数组的长度。

那么这个节点应该放在哪个桶,这就是散列的过程,我们当然希望散列的过程是尽量均匀的,而不会出现都算出来进入了 table[] 的同一个位置。那么,可以选择的方法有取余啊、之类的,这里采用的方法是位运算来实现取余。

就是(n - 1) & hash 这个位运算,2 的幂 -1 都是11111结尾的:


2 进制,所以 2 的几次方都是 1 00000(很多个 0 的情况),然后 -1, 就会变成 000 11111(很多个1)

那么和 本来计算的具有唯一性的 hash 值相与,

  1. 用高位的 0 把hash 值的高位都置为了 0 ,所以限制在了 table 的下标范围内。
  2. 保证了 hash 值的尽量散开。


对于第 2 点,如果不是 2 的幂次方,那么 -1 就不会得到 1111 结尾,甚至如果是个基数,-1 后就会变成形如 0000 1110
这样的偶数,那么相与的结果岂不是永远都是偶数了?这样 table 数组就会有一半的位置永远利用不上的。所以 2 的幂次方以及 -1 的操作,才能保证得到和取模一样的效果。

因此得出结论,如果 n 是 2 的幂次方,计算出的位置会很均匀,相反则会干扰这个运算,导致计算出的位置不均匀。

第二个方面的原因就是扩容的时候,重新要计算下标值 hash2 的幂次方带给了好处,下面的扩容部分有详细说明。

注意到我们初始化 HashMap 的时候可以指定容量。

问题 2 那么如果传入的容量并不是 2 的次方,怎么办呢?

在这里插入图片描述
从构造方法可以看到,调用指定加载因子和 容量的方法,如果大于最大容量,就会改为最大容量,接着对于容量,调用 tableSizeFor 方法,此时传入的参数已经肯定是 <= 最大容量的数字了。

在这里插入图片描述

tableSizeFor 这个方法会产生一个大于传入数字的、最小的 2 的幂次方数。

2.3 最大容量MAXIMUM_CAPACITY

最大 hashMap 的容量就是 1 左移 30 位,也就是 2 的 30 次方

2.4 默认加载因子DEFAULT_LOAD_FACTOR

默认加载因子为 0.75 ,也就是说,如果键值对超过了当前的容量 * 0.75 ,就会触发扩容。

问题 为什么是 0.75 而不是别的数呢?

答:如果加载因子越大,对空间的利用更充分,但是查找效率会降低(链表长度会越来越长);如果加载因子太小,那么表中的数据将过于稀疏(很多空间还没用,就开始扩容了),对空间造成严重浪费。

其实 0.75 是一个统计的结果,比较理想的值,根据旧版源码里面的注释,和概率的泊松分布有关系,当负载因子是 0.75 的情况下,哈希碰撞的概率遵循参数约为 0.5 的泊松分布,因此选择它是一个折衷的办法来满足时间和空间。

2.5 转树的阈值TREEIFY_THRESHOLD

默认为 8 ,也就是说一个桶内的链表节点数多于 8 的时候,结合数组当前长度会把链表转换为红黑树。

问题 为什么是超过 8 就转为红黑树?

答:首先,红黑树的节点在内存中是普通链表节点方式存储的 2 倍,成本是比较高的,那么对于太少的节点数目就没必要转化,继续扩容就行了。

结合负载因子 0.75泊松分布结果,每个链表有 8 个节点的概率已经到达可以忽略的程度,所以将这个值设置为 8 。为了避免出现恶意的频繁插入,除此之外还会判断数组长度是否达到了 64。

所以到这里我个人的理解是:
-> 最开始hashmap的思想就是数组加链表;
-> 因为数组里的各个链表长度要均匀,所以就有了哈希值的算法,以及适当的扩容,扩容的加载因子定成了 0.75 ;
-> 而扩容只能根据总共的节点数来计算,可能没来得及扩容的时候还是出现了在同一个链表里元素变得很多,所以要转红黑树,而这个数量就根据加载因子结合泊松分布的结果,决定了是8.

2.6 重新退化为链表的阈值UNTREEIFY_THRESHOLD

默认为 6, 也就死说如果操作过程发现链表的长度小于 6 ,又会把树退回链表。

2.7 转树的最小容量

不仅仅是说有链表的节点多于 8 就转换,还要看 table 数组的长度是不是大于 64 ,只有大于 64 了才转换。为了避免开始的时候,正好一些键值对都装进了一个链表里,那只有一个链表,还转了树,其实没必要。

还有属性的第二部分:

在这里插入图片描述
第一个是容器 table 存放键值对的数组,就是保存链表或者树的数组,可以看到 Node 类型也是实现了 Entry 接口的,在 1.8 之前这个节点是不叫 Node 的,就叫的 Entry,因为就是一个键值对,现在换成了 Node,是因为除了普通的键值对类型,还可能换成红黑树的树节点TreeNode 类型,所以不是 Entry了。

在这里插入图片描述
第二个是保存所有键值对的一个 set 集合,是一个存放缓存的;
第三个 size 是整个hashmap 里的键值对的数目;
第四个是 modCount 是记录集合被修改的次数,有助于在多个线程操作的时候报根据一致性保证安全;
第五个 threshold 是扩容的阈值,也就是说大于阈值的时候就开始扩容,也就是 threshold = 当前的 capacity * loadfactor
第六个 loadFactor 也是对应前面的加载因子。

三、源码的核心方法分析

3.1 构造方法

在这里插入图片描述
可以看到,这几个重载的构造方法做的事就是设置一些参数。

事实上,在 jdk1.8 之后,并不会直接初始化 hashmap,只是进行加载因子、容量参数的相关设定,真正开始将 table 数组空间开辟出来,是在 put 的时候才开始的。

第一个:

public HashMap()

是我们平时最常用的,只是设置了默认加载因子,容量没有设定,那显然就是 16

第二个:

public HashMap(int initialCapacity)

为了尽量少扩容,这个构造方法是推荐的,也就是指定 initialCapacity,在这个方法里面直接调用的是

第三个构造方法:

public HashMap(int initialCapacity, float loadFactor)

用指定的初始容量和加载因子,确保在最大范围内,也调整了 threshold 容量是 2 的幂次方数

这里就是一个问题,把 capcity 调整成 2 的幂次方数,计算 threshold 的时候不应该要乘以 loadfactor 吗,怎么能直接赋给 threshold 呢?

原因是这里没有用到 threshold ,还是在 put 的时候才进行 table 数组的初始化的,所以这里就没有操作。

最后一个构造方法是,将本来的一个 hashmap 放到一个新的 map 里。

3.2 put 和 putVal 方法

在这里插入图片描述
put 方法是直接调用了计算 hash 值的方法计算哈希值,然后交给 putVal 方法去做的。

hash 方法就是调用本地的 hashCode 方法再做一个位移操作计算出哈希值。

在这里插入图片描述
为什么采用这种右移 16 位再异或的方式计算 hash 值呢?

因为 hashCode 值一般是一个很大的值,如果直接用它的话,实际上在运算的时候碰撞的概率会很高,所以要充分利用这个二进制串的性质:int 类型的数值是 4 个字节的,右移 16 位,再异或可以同时保留高 16 位低 16 位的特征,进行了混合得到的新的数值中,高位与低位的信息都被保留了 。

另外,因为,异或运算能更好的保留各部分的特征,如果采用 & 运算计算出来的值会向 1 靠拢,采用 | 运算计算出来的值会向 0 靠拢, ^ 正好。

最后的目的还是一样,为了减少哈希冲突。

算出 hash 值后,调用的是 putVal 方法:
在这里插入图片描述
传入哈希值;要插入的 key 和 value;然后两个布尔变量,onlyIfAbsent 代表当前要插入的 value 是否存在了如果是 true,就不修改;evict 代表这个 hashmap 是否处于创建模式,如果是 false,就是创建模式。

下面是源码及具体注释:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
   
    Node<K,V>[
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值