源码解析 JDK1.8中 HashMap 扰动函数、负载因子、扩容机制

源码解析 JDK1.8中 HashMap 扰动函数、负载因子、扩容机制

一、前言

HashMap 最早出现在 JDK 1.2中,底层基于散列算法实现。HashMap 允许 null 键和 null 值,在计算哈键的哈希值时,null 键哈希值为 0。HashMap 并不保证键值对的顺序,这意味着在进行某些操作后,键值对的顺序可能会发生变化。另外,需要注意的是,HashMap 是非线程安全类,在多线程环境下可能会存在问题。

HashMap 最早在JDK 1.2中就出现了,底层是基于散列算法实现,随着几代的优化更新到目前为止它的源码部分已经比较复杂,涉及的知识点也非常多,在JDK 1.8中包括;1、散列表实现2、扰动函数3、初始化容量4、负载因子5、扩容元素拆分6、链表树化7、红黑树8、插入9、查找10、删除11、遍历12、分段锁

本文将针对前五项对扰动函数负载因子扩容机制进行分析。

二、 源码分析
1.扰动函数——异或高低位
  • 解决的问题

这里所有的元素存放都需要获取一个索引位置,而如果元素的位置不够散列碰撞严重,那么就失去了散列表存放的意义,没有达到预期的性能。

  • 解决方案

jdk1.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个长度,所以获取的Hash值并不能直接作为下标使用,需要与数组长度进行取模运算得到一个下标值,也就是我们上面做的散列。

那么,hashMap源码这里不只是直接获取哈希值,还进行了一次扰动计算,(h = key.hashCode()) ^ (h >>> 16)。把哈希值右移16位,也就正好是自己长度的一半,之后与原哈希值做异或运算,这样就混合了原哈希值中的高位和低位,增大了随机性减少碰撞


2. 负载因子——默认0.75
  • 解决的问题

数组越小碰撞的越大,数组越大碰撞的越小,时间与空间如何取舍

  • 解决方案
static final float DEFAULT_LOAD_FACTOR = 0.75f;

在HashMap中,负载因子决定了数据量多少了以后进行扩容这里要提到上面做的HashMap例子,我们准备了7个元素,但是最后还有3个位置空余,2个位置存放了2个元素。 所以可能即使你数据比数组容量大时也是不一定能正正好好的把数组占满的,而是在某些小标位置出现了大量的碰撞,只能在同一个位置用链表存放,那么这样就失去了Map数组的性能。

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

3. 数组扩容
  • 解决的问题

随着元素的不断添加,数组长度不足扩容时,怎么把原有的元素,拆分到新的位置上去

  • 解决方案

扩容最直接的问题,就是需要把元素拆分到新的数组中。拆分元素的过程中,原jdk1.7中会需要重新计算哈希值,但是到jdk1.8中已经进行优化,不再需要重新计算,提升了拆分的性能,设计的还是非常巧妙的。

先让我们看看jdk1.8中源码是怎么进行操作的

if ((e.hash & oldCap) == 0) {
    if (loTail == null)
        loHead = e;
    else
        loTail.next = e;
    loTail = e;
}
  • 问:为什么是 e.hash & oldCap == 0 为什么可以判断当前节点是否需要移位, 而不是再次计算hash;

  • 答: 我们假设有一个数组长度为16的HashMap需要扩容到32,为下标是10的元素寻找在新数组中的下标

    • 重新计算hash
     10: 0000 1010
     31: 0001 1111
     &:  0000 1010 
    
    • e.hash & oldCap
     10: 0000 1010
     15: 0000 1111
     &:  0000 1010 
    

从上面的示例可以很轻易的看出, 两次计算索引值的差别只是第二次参与位于比第一次左边有一位从0变为1, 而这个变化的1刚好是 oldCap, 那么只需要判断原key的hash这个位上是否为1: 若是1, 则需要移动至oldCap + i的槽位, 若为0, 则不需要移动;

这也是HashMap的长度必须保证是2的幂次方的原因, 正因为这种环环相扣的设计, HashMap.loadFactor的选值是3/4就能理解了, table.length * 3/4可以被优化为((table.length >> 2) << 2) - (table.length >> 2) == table.length - (table.length >> 2), JAVA的位运算比乘除的效率更高, 所以取3/4在保证hash冲突小的情况下兼顾了效率。

你可能感兴趣的:(软件工程,哈希算法,java,散列表,后端,数据结构)