HashMap墨迹详解-由浅入深

HashMap是Key-Value存储的集合,其默认长度为16,容量始终保持2的N次方,保证索引计算时(n-1) & hash的有效性。负载因子是0.75,用于计算阈值,当元素数量达到阈值时触发扩容。HashMap在扩容时,为保证元素均匀分布,使用rehash策略。文章详细讲解了HashMap的容量选择、负载因子的作用以及扩容机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

HashMap集合

HashMap为Key-Value键值对存储的集合,本文不会说出HashMap所有的原理,尤其是红黑树的原理,我没去看,太麻烦了,其他的我觉得也够用了!一起来看!墨迹起来。。。
 

关键字段及含义

//Map默认的开发的容量大小16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//没看
static final int TREEIFY_THRESHOLD = 8;
//没看
static final int UNTREEIFY_THRESHOLD = 6;
//没看
static final int MIN_TREEIFY_CAPACITY = 64;

1. 为什么默认16个长度?

查询了好多文章,没有一个特别有说服力的,做一个简单的总结吧,加上个人想法!!

1.1 先说为什么容量一直是2的N次方?

第一点是2的N次方,可以保证Hash值的二进制位都是1,HashMap在计算元素索引的时候,代码是这个样子滴:(n - 1) & hash,假设n=16,那么n-1=1111,可以保证n-1的二进制数都是1。为什么要保证都为1呢?这就涉及到一个概率的问题,也就是常说的为了HashMap中的元素能够均匀的分布到每个槽位上,至于算法概率上,不太好举例子,只能做一个简单的假设:

  1. 假如新加入一个元素,Key的hash值的后四位为1001,那么与1111做运算,1001 & 1111 = 1001;
  2. 再加入一个元素,key的hash值后四位是1011,做运算,1011 & 1111 = 1011
  3. 可以知道,这两次的索引计算结果不同。再看下面的假设
  4. 假设我们规定Hash的默认长度为10,那么10-1=9=1001
  5. 然后我们分别看一下计算结果

              长度   key的hash值           结果

              1001   1101                        1001

              1001   1011                        1001

 可以看出来,不同的hash值,计算的结果一样,这就是一个概率的问题,为了尽量让元素均匀分布,如果长度值为16,那么二进制             全是1,则可以缩小索引碰撞的几率。

1.2 为什么是16长度,为什么不是4、32、64?

这就是一个“合适”的问题,可能觉得4太小,32有点浪费!其他我想不出来有什么原因了!4的话,没加入几个元素就要再次扩容,32的话,不一定有这么多元素会被设置到map中,所以取了16作为默认长度。

2. 负载因子作用是什么?为什么是0.75?

负载因子是为了计算阈值的,阈值就是Map扩充的一个判断值,当元素的数量达到阈值,会扩容。初始化时:阈值=负载因子*默认容量;扩容时:阈值=当前阈值<<1,即2倍

2.1为什么是0.75?

探讨这个问题,首先要知道如果负载因子大了或者小了会有什么问题!

     1. 假如负载因子为1,那么会导致下面的情况:

         Map中的元素达到当前Map的容量时才会扩容,那么此时,会出现一个效率问题,什么问题呢?第一点,因为没有提前扩容,哈希碰撞的几率增加,链表深度过长。这样就带来两个问题,由于碰撞几率增加,插入会变慢!链表深度过长,查询会变慢!

     2. 假如负载因子过小,那么会导致提前扩容,hash冲突几率倒是变小了,但在没有更多的元素加入情况下,空间是一种浪费!

好,到这里,那么为啥子非要是0.75呢?为啥子不是0.5、0.6呢?答:泊松分布!!!面试官要特么问我这个,我日他大爷,老子不会。

HashMap扩容

1. 什么时候会扩容?

直接上代码看:HashMap的putVal方法,主要两个地方涉及到扩容!

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
//省略部分代码,看重点
        if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

resize为HashMap的扩容方法!

  1. 如果当前Map没有元素,则扩容
  2. 如果当前Map的元素大于阈值,则扩容。阈值=负载因子*当前Map容量
  3. 还有树化的时候,也会扩容,没看

2. 扩容机制及原理

那就必须撸代码啦!上!直接在代码上注释啦!先说简单的部分代码:

final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
    //定义当前容量,为空则是0
int oldCap = (oldTab == null) ? 0 : oldTab.length; 
    //定义当前阈值
int oldThr = threshold;
    //定义新的容量和新的阈值
int newCap, newThr = 0;
    //如果当前容量大于0
if (oldCap > 0) {
//如果当前容量大于最大容量,2的30次方
        if (oldCap >= MAXIMUM_CAPACITY) {
//阈值设置为2的31次方
            threshold = Integer.MAX_VALUE;
//返回旧的容量大小,不再扩充容量
            return oldTab;
        }
//如果旧容量的2倍小于最大容量,并且旧容量大于默认的容量16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
//扩充阈值为原来的2倍
            newThr = oldThr << 1; // double threshold
    }
//如果旧的阈值大于0,则新的容量等于旧的阈值,一般不走到这里,在初始化Map的时候指定容量和负载因子的时候,第二次扩容会走到这。
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
//如果是无参构造创建Map,则走这里
    else {               // zero initial threshold signifies using defaults
//新容量=默认容量16
        newCap = DEFAULT_INITIAL_CAPACITY;
//新的阈值=负载因子*默认容量=0.75*16=12
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
//经过上面的判断,如果新的阈值还是0,也就是说通过指定容量和负载因子初始化Map的时候,会走到这
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
//将新的阈值设置到threshold
    threshold = newThr;
    }

总结:

  1. 通过无参构造初始化Map,默认开发16长度的容量,负载因子0.75,阈值12
  2. 当Map新增元素后,size达到12后,进行扩容,容量提升2倍,阈值提升2倍
  3. 指定构造方法初始化Map,容量和负载因子由用户指定,扩容条件还是和上述一样,大于阈值才会扩容,算法也是一样的,只是初始容量和负载因子不同而已。

接着往下看:

  Node<K,V> e;
  if ((e = oldTab[j]) != null) {
       oldTab[j] = null;
       if (e.next == null)
           newTab[e.hash & (newCap - 1)] = e;

  1. 创建一个临时变量,Node E节点,来保存当前循环节点的链表,oldTab[j]就是当前索引下的链表体。
  2. 如果当前索引不为空,则将当前的索引置为空
  3. 如果节点E没有后续节点,则进行rehash,就是重新计算该元素的索引位置。这里就比较有意思了,不经赞叹开发者对算法的高超啊。如果E有后续节点,后续再说。
  4. 那么rehash是不是一定会变更索引的位置呢,答:不一定!存在两种情况,要么保持原有的位置不动,要么一当前索引为基准向后移动旧容量数目。
  5. 看下面计算,我们向map中设置一个key为42,对应代码 newTab[e.hash & (newCap - 1)]来看,4次扩容后的索引位置变化。注意:如果newCap-1的二进制位高位不够则补0,比如第一次计算1010101&1111=101010&001111,也可以说newCap-1有多少二进制位,e.hash的后几位才算有效的。

                     e(十进制)       e.hash                       newCap-1         newCap-1(二进制)          扩容后索引位置

                     42                  101010                       15                       1111                                     1010 = 10

                     42                  101010                       31                       11111                                   1010 = 10

                     42                  101010                       63                       111111                                101010 = 42

                     42                  101010                       127                     1111111                              101010 = 42    

2.1 扩容索引移位规律

根据上述计算可以得知,rehash的索引位置移动存在两种情况,要么保持原有的位置不动,要么以当前索引为基准向后移动旧容量数目。比如第三次扩容的时候,索引向后移动了2的5次方,即是32(我们原来的旧容量大小)。这是一个规律,因为每次扩容量是2倍,那么在二进制位看来,也就是前面的一个位多了一个1,好比第三次的扩容计算,容量由32扩充到64,这样一来高位多了一个1,高位多出来的1与e.hash做运算,e.hash高位的1就保留了下来,而恰巧这个高位的1就是2的5次方=我们的旧容量大小32。所以如果我们key的哈希值过短,则在几次扩容之后,不会再变更索引位置;如果够长,则可能还会变更位置。

好了!!上述代码已经解析完毕,接下来的情况,就是索引上的节点存在深度为2以上的链表,这种情况是怎么rehash的呢?分两种情况:

第一种情况:树化节点的rehash,这个要看红黑树TreeNode!!!太恶心,略过!!!

第二种情况:普通链表的rehash, 那么前提我们要知道,元素在put的时候,发生了什么,put是一个怎么样的操作,put一个元素后,数据结构是怎样的!!请先看put方法解析,在来看多节点链表的重组过程!

3. 深度节点扩容过程

接着上面的扩容代码说:

Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
    next = e.next;
    if ((e.hash & oldCap) == 0) {
        if (loTail == null)
            loHead = e;
        else
            loTail.next = e;
        loTail = e;
    }
    else {
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);
if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

这个代码,是扩容中else的代码,也就是说如果索引处存在2个以上的节点,会走到这里。来看一下下面两个图,初始容量16, 位置为0索引处的节点有3个,节点分别用ABC代替:

我们假设有一种情况,注意这是逻辑的条件,看懂了:

  1. 我们打算扩容到32
  2. 扩容到32位之后,会重新计算每个key的索引
  3. 假设A和C通过e.hash & oldCap == 0 为真,则说明A和C的索引不变
  4. 假设B通过e.hash & oldCap == 0 为假,则说明B的索引要变化
  5. B的索引变化,就是j+oldCap。
  6. 所以扩容之后各节点的结构如下图

这里可以得知,e.hash & oldCap是用来判定索引是否要发生变化的,又是算法的巧妙。总之不论哪种方式,索引要么不变,要不移动旧数量的数目

put方法解析

代码就不贴上了,直接看HashMap的putVal方法

  1. 如果当前map是空的,则调用扩容方法进行初次扩容
  2. 扩容后,计算元素在数祖上的索引,公式index=(容量-1)&hash值
  3. 如果当前索引处没有节点,则new一个Node节点存放key和value,再把这个Node设置到当前索引处
  4. 如果当前索引处存在节点是普通节点,取出第一个节点Node,与新添加的元素相比较,如果hash值和equals方法都是相同的,则替换该元素的value
  5. 如果当前索引处存在节点是树节点,调用putTreeVal方法
  6. 如果当前索引处不是树节点,也与新加入的元素不相等(hash和equals不相同),则进入死循环
  7. 死循环跳出的条件:一:后续再无节点,在最后的一个Node尾部插入新节点。二:循环的当前节点与新加入的元素相等,替换当前节点的Value。两种情况任意一种,可break当前循环。
  8. ++modCount, 这个和迭代有关,迭代器迭代时候不会有异常,就是因为这个。
  9. 如果++size>threshold,也就是新加入节点后,size大于当前阈值,则调用扩容。

可以看一个put之后Map的数据结构:

这样put操作就完成了!!!咱们回头可以看扩容的剩余部分代码啦!看上一节。。

hash算法

粘贴来的,了解下

  1. 加法Hash;把输入元素一个一个的加起来构成最后的结果
  2. 位运算Hash;这类型Hash函数通过利用各种位运算(常见的是移位和异或)来充分的混合输入元素
  3. 乘法Hash;这种类型的Hash函数利用了乘法的不相关性(乘法的这种性质,最有名的莫过于平方取头尾的随机数生成算法,虽然这种算法效果并不好);jdk5.0里面的String类的hashCode()方法也使用乘法Hash;32位FNV算法
  4. 除法Hash;除法和乘法一样,同样具有表面上看起来的不相关性。不过,因为除法太慢,这种方式几乎找不到真正的应用
  5. 查表Hash;查表Hash最有名的例子莫过于CRC系列算法。虽然CRC系列算法本身并不是查表,但是,查表是它的一种最快的实现方式。查表Hash中有名的例子有:Universal Hashing和Zobrist Hashing。他们的表格都是随机生成的。
  6. 混合Hash;混合Hash算法利用了以上各种方式。各种常见的Hash算法,比如MD5、Tiger都属于这个范围。它们一般很少在面向查找的Hash函数里面使用

HashMap的hash算法

static final int hash(Object key) {

   int h;

   return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值