HashMap底层实现整理
理解HashMap先要理解HashCode
HashCode
HashCode 为什么使用 31 作为乘数?
HashCode源码
// 获取 hashCode "abc".hashCode();
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
-
乘数是 2 时,hash 的取值范围比较小,基本是堆积到一个范围内了。
-
乘数是 3、5、7、17 等,都有较大的碰撞概率。
-
乘数是 31 的时候,碰撞的概率已经很小了,基本稳定。
-
顺着往下看,你会发现 199 的碰撞概率更小,这就相当于一排奇数的茅坑量多,自然会减少碰撞。但这个范围值已经远超过 int 的取值范围了,如果用此数作为乘数,又返回 int 值,就会丢失数据信息。
关于散列表也就是 hash,还有一个非常重要的点,那就是要尽可能的让数据散列分布。只有这样才能减少hash 碰撞次数。
HashMap
HashMap 最早出现在 JDK 1.2 中,底层基于散列算法实现。HashMap 允许 null 键和 null 值,在计算哈键的哈希值时,null 键哈希值为 0。HashMap 并不保证键值对的顺序,这意味着在进行某些操作后,键值对的顺序可能会发生变化。另外,需要注意的是,HashMap 是非线程安全类,在多线程环境下可能会存在问题。
随着几代的优化更新到目前为止它的源码部分已经比较复杂,涉及的知识点也非常多,在 JDK 1.8 中包括:1、散列表实现、2、扰动函数、3、初始化容量、4、负载因子、5、扩容元素拆分、6、链表树化、7、红黑树、8、插入、9、查找、10、删除、11、遍历、12、分段锁等等。
简单散列存储
将每一个字符串元素通过 Hash 计算索引位置,存放到数组中。
黄色的索引 ID 是没有元素存放、绿色的索引 ID 存放了一个元素、红色的索引 ID 存放了两个元素。
问题
1、这里所有的元素存放都需要获取一个索引位置,而如果元素的位置不够散列碰撞严重,那么就失去了散列表存放的意义,没有达到预期的性能。
2、在获取索引 ID 的计算公式中,需要数组长度是 2 的倍数,那么怎么进行初始化这个数组大小。
3、数组越小碰撞的越大,数组越大碰撞的越小,时间与空间如何取舍。
4、目前存放 7 个元素,已经有两个位置都存放了 2 个字符串,那么链表越来越长怎么优化。
5、随着元素的不断添加,数组长度不足扩容时,怎么把原有的元素,拆分到新的位置上去。
以上这些问题可以归纳为:扰动函数、初始化容量、负载因子、扩容方法以及链表和红黑树转换的使用。
扰动函数
在 HashMap 存放元素时候有这样一段代码来处理哈希值,这是 java 8 的散列值扰动函数,用于优化散列效果。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
为什么使用
理论上来说字符串的 hashCode是一个 int 类型值,那可以直接作为数组下标了,且不会出现碰撞。但是这个 hashCode 的取值范围是[-2147483648, 2147483647],有将近 40 亿的长度,谁也不能把数组初始化的这么大,内存也是放不下的。
我们默认初始化的 Map 大小是 16 个长度 DEFAULT_INITIAL_CAPACITY = 1 << 4,所以获取的 Hash 值并不能直接作为下标使用,需要与数组长度进行取模运算得到一个下标值。
那么,hashMap 源码这里不只是直接获取哈希值,还进行了一次扰动计算,(h = key.hashCode()) ^ (h >>> 16)。把哈希值右移 16 位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性。
使用扰动函数就是为了增加随机性,让数据元素更加均衡的散列,减少碰撞。
初始化容量
HashMap 默认的初始化大小里,散列数组需要一个 2 的倍数的长度。
在初始化 HashMap 的时候,如果传一个 17 的值new HashMap<>(17),它会怎么处理呢?
寻找2的倍数最小值
在 HashMap 的初始化中,有这样一段方法,
public HashMap(int initialCapacity, float loadFactor) {
...
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
阀值 threshold,通过方法 tableSizeFor 进行计算,是根据初始化来计算的。这个方法也就是要寻找比初始值大的,最小的那个 2 进制数值。比如传了 17,应该找到的是 32。
负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
负载因子,可以理解成一辆车可承重重量超过某个阀值时,把货放到新的车上。那么在 HashMap 中,负载因子决定了数据量多少了以后进行扩容。
这里要提到上面做的 HashMap 例子,我们准备了 7 个元素,但是最后还有 3 个位置空余,2 个位置存放了 2 个元素。 所以可能即使你数据比数组容量大时也是不一定能正正好好的把数组占满的,而是在某些小标位置出现了大量的碰撞,只能在同一个位置用链表存放,那么这样就失去了 Map 数组的性能。
所以,要选择一个合理的大小下进行扩容,默认值 0.75 就是说当阀值容量占了3/4 时赶紧扩容,减少 Hash 碰撞。同时 0.75 是一个默认构造值,在创建 HashMap 也可以调整,比如你希望用更多的空间换取时间,可以把负载因子调的更小一些,减少碰撞。
HashMap数据插入整体流程
1、首先进行哈希值的扰动,获取一个新的哈希值。
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
2、判断 tab 是否位空或者长度为 0,如果是则进行扩容操作。
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
3、根据哈希值计算下标,如果对应小标正好没有存放数据,则直接插入即可,否则需要覆盖。
4、判断 tab[i]是否为树节点,否则向链表中插入数据,是则向树中插入节点。
5、如果链表中插入节点的时候,链表长度大于等于 8,则需要把链表转换为红黑树。
6、最后所有元素处理完成后,判断是否超过阈值;threshold,超过则扩容。
7、treeifyBin,是一个链表转树的方法,但不是所有的链表长度为 8 后都会。转成树,还 需 要 判 断 存 放 key 值 的 数 组 桶 长 度 是 否 小 于 64 MIN_TREEIFY_CAPACITY。如果小于则需要扩容,扩容后链表上的数据会被拆分散列的相应的桶节点上,也就把链表长度缩短了。
扩容机制
HashMap 是基于数组+链表和红黑树实现的,但用于存放 key 值得的数组桶的长度是固定的,由初始化决定。
那么,随着数据的插入数量增加以及负载因子的作用下,就需要扩容来存放更多的数据。而扩容中有一个非常重要的点,就是 jdk1.8 中的优化操作,可以不需要再重新计算每一个元素的哈希值。
1、扩容时计算出新的 newCap、newThr,这是两个单词的缩写,一个是 Capacity,另一个是阀 Threshold。
2、newCap 用于创新的数组桶 new Node[newCap]。
3、随着扩容后,原来那些因为哈希碰撞,存放成链表和红黑树的元素,都需要进行拆分存放到新的位置中。
链表树化
HashMap 这种散列表的数据结构,最大的性能在于可以 O(1)时间复杂度定位到元素,但因为哈希碰撞不得已在一个下标里存放多组数据,那么 jdk1.8 之前的设计只是采用链表的方式进行存放,如果需要从链表中定位到数据时间复杂度就是O(n),链表越长性能越差。因此在 jdk1.8 中把过长的链表也就是 8 个,优化为自平衡的红黑树结构,以此让定位元素的时间复杂度优化近似于 O(logn),这样来提升元素查找的效率。但也不是完全抛弃链表,因为在元素相对不多的情况下,链表的插入速度更快,所以综合考虑下设定阈值为 8 才进行红黑树转换操作。
1、链表树化的条件有两点:链表长度大于等于 8、桶容量大于 64,否则只是扩容,不会树化。
2、链表树化的过程中是先由链表转换为树节点,此时的树可能不是一颗平衡树。同时在树转换过程中会记录链表的顺序,tl.next = p,这主要方便后续树转链表和拆分更方便。
3、链表转换成树完成后,在进行红黑树的转换。先简单介绍下,红黑树的转换需要染色和旋转,以及比对大小。在比较元素的大小中,有一个比较有意思的方法,tieBreakOrder 加时赛,这主要是因为 HashMap 没有像 TreeMap 那样本身就有 Comparator 的实现。
红黑树转链
在转换树的过程中,记录了原有链表的顺序。那么,这就简单了,红黑树转链表时候,直接把 TreeNode 转换为 Node 即可。