您好,欢迎访问代理记账网站
移动应用 微信公众号 联系我们

咨询热线 -

电话 15988168888

联系客服
  • 价格透明
  • 信息保密
  • 进度掌控
  • 售后无忧

《一步一步看源码:HashMap》容器源码系列之三(其1,数据结构,put方法,树化方法)

Hashmap

​ 底层数据结构:JDK1.7散列表(数组+链表),JDK1.8数组+链表/红黑树,这里我详细给大家介绍一下,当然想了解树的演变过程可以看我的下一篇文章。下面,先开始讲数据结构:

底层数据结构

散列表

先带大家认识下散列表,散列表就是数组+链表:

  • 当数据进入时,会先拿到该元素的hash值给到数组,然后再有相同的hash值的数据进入到数组中的话,就会进行哈希碰撞,碰撞有两个方法(拉链法和开放定址法)。
    • 拉链法:当一个元素存储进散列表的时候,会先计算哈希值,然后放到数组里。遇到下一个放进来的元素和它的哈希值相同,就放到相同的链表里。
    • 开放定址法:当一个元素存储进散列表的时候,会先计算哈希值,然后放到数组里。遇到下一个放进来的元素和它的哈希值相同,就放到这个元素的下一个数组里(所以这个方法和底下这个图不匹配,不用看了)。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u3mbw9kW-1624594388713)(容器源码/1624429702119.png)]

红黑树

hashmap无非就是数组+链表(JDK1.7)或者数组+链表/红黑树(JDK1.8)散列表已经说完了,下面讲一讲红黑树,可能入门有点难度,如果看不懂的话,可以先看我的文章《树的演变》:

首先红黑树先记下来五个特点:

  • 每个节点或者是黑色,或者是红色。
  • 根节点是黑色。
  • 每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
  • 如果一个节点是红色的,则它的子节点必须是黑色的。
  • 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。

注意:

  • 特性(3)中的叶子节点,是只为空(NIL或null)的节点。

  • 特性(5),确保没有一条路径会比其他路径长出俩倍。因而,红黑树是相对是接近平衡的二叉树。

    这里解释下什么是 nil、Nil、NULL、NSNull (不区分大小写):

nil:指向一个对象的空指针,对objective c id 对象赋空值.
Nil:指向一个类的空指针,表示对类进行赋空值.
NULL:指向其他类型(如:基本类型、C类型)的空指针, 用于对非对象指针赋空值.
NSNull:在集合对象中,表示空值的对象.

以下是对应五个特点画出来的红黑树图:画的不好看,见谅。。。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LLQW8ZYl-1624594388715)(容器源码/NP9ET[9]NZ4LM30NM[YTNXA.png)]

任何用来存储的数据结构,最主要的还是添加删除。在对红黑树进行添加或删除之后,都会用到旋转方法。为什么呢?道理很简单,添加或删除红黑树中的节点之后,红黑树就发生了变化,可能不满足红黑树的5条性质,也就不再是一颗红黑树了,而是一颗普通的树。而通过旋转,可以使这颗树重新成为红黑树。简单点说,旋转的目的是让树保持红黑树的特性。
旋转包括两种:左旋右旋。下面分别对它们进行介绍。

左旋

步骤如下:(左旋左子树,改变左子树,并变成左子树)

  • 先将Y的{左}子树给X。
  • 将X父亲给Y ,这时候有三种情况,如图所示。
  • 最后完成了左旋。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4BSe9KdW-1624594388716)(容器源码/左旋 .jpg)]

右旋

步骤如下:(右旋,改变右子树,并变成右子树)

  • 先将X的{右}子树给Y。
  • 将Y父亲给X ,这时候有三种情况,如图所示。
  • 最后完成了右旋。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4VYtnNUh-1624594388717)(容器源码/右旋.jpg)]

足够细心的话会发现,左右旋是对称的,即左旋以后再右旋就能回到原本的样子。

  • 左旋就提旋转目标的右子树上去,使自身变成右子树。

  • 右旋就提旋转目标的左子树上去,使自身变成左子树。

    举例:以下都以X为旋转目标:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qxert1s1-1624594388718)(容器源码/image-20210623203807844.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8hfEammX-1624594388719)(容器源码/image-20210623203819697.png)]

这一块想看代码可以看我的《树的演变》一章,里面会详细告诉大家,树的相关操作的所有代码。

添加

在讲添加时,我们先讲一下,插入元素应该要做的事:

第一步: 将红黑树当作一颗二叉查找树,将节点插入。
红黑树本身就是一颗二叉查找树,将节点插入后,该树仍然是一颗二叉查找树。也就意味着,树的键值仍然是有序的。此外,无论是左旋还是右旋,若旋转之前这棵树是二叉查找树,旋转之后它一定还是二叉查找树。这也就意味着,任何的旋转和重新着色操作,都不会改变它仍然是一颗二叉查找树的事实。
好吧?那接下来,我们就来想方设法的旋转以及重新着色,使这颗树重新成为红黑树!

第二步:将插入的节点着色为"红色"。
为什么着色成红色,而不是黑色呢?为什么呢?在回答之前,我们需要重新温习一下红黑树的特性:
(1) 每个节点或者是黑色,或者是红色。
(2) 根节点是黑色。
(3) 每个叶子节点是黑色。 [注意:这里叶子节点,是指为空的叶子节点!]
(4) 如果一个节点是红色的,则它的子节点必须是黑色的。
(5) 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
将插入的节点着色为红色,不会违背"特性(5)"!少违背一条特性,就意味着我们需要处理的情况越少。接下来,就要努力的让这棵树满足其它性质即可;满足了的话,它就又是一颗红黑树了。o(∩∩)o…哈哈

第三步: 通过一系列的旋转或着色等操作,使之重新成为一颗红黑树。
第二步中,将插入节点着色为"红色"之后,不会违背"特性(5)"。那它到底会违背哪些特性呢?
对于"特性(1)",显然不会违背了。因为我们已经将它涂成红色了。
对于"特性(2)",显然也不会违背。在第一步中,我们是将红黑树当作二叉查找树,然后执行的插入操作。而根据二叉查找数的特点,插入操作不会改变根节点。所以,根节点仍然是黑色。
对于"特性(3)",显然不会违背了。这里的叶子节点是指的空叶子节点,插入非空节点并不会对它们造成影响。
对于"特性(4)",是有可能违背的!
那接下来,想办法使之"满足特性(4)",就可以将树重新构造成红黑树了。

下去这部分可能有点难度,大家跟好车,先看一下这部分伪代码,这里我们是要插入z,然后在节点Y下插入的,我将插入数据分为两步,插入染色

  • 插入:这里是假设存在Y这个节点,然后分为三种情况

    • Y是空节点,则插入的Z就变成了唯一节点,即根
    • Z<Y,把Z设为左孩子。
    • Z>Y,把Z设为右孩子。

    最后进行插入节点的左右孩子设空,还有插入节点着色,这部分相对简单,就不给图了。

RB-INSERT(T, z)  
  y ← nil[T]                        // 新建节点“y”,将y设为空节点。
  x ← root[T]                       // 设“红黑树T”的根节点为“x”
  while x ≠ nil[T]                  // 找出要插入的节点“z”在二叉树T中的位置“y”
      do y ← x                      
         if key[z] < key[x]  
            then x ← left[x]  
            else x ← right[x]  
  p[z] ← y                          // 设置 “z的父亲” 为 “y”
  if y = nil[T]                     
     then root[T] ← z               // 情况1:若y是空节点,则将z设为根
     else if key[z] < key[y]        
             then left[y] ← z   // 情况2:若“z所包含的值” < “y所包含的值”,则将z设为“y的左孩子”
             else right[y] ← z  // 情况3:(“z所包含的值” >= “y所包含的值”)将z设为“y的右孩子” 
  left[z] ← nil[T]              // z的左孩子设为空
  right[z] ← nil[T]             // z的右孩子设为空。至此,已经完成将“节点z插入到二叉树”中了。
  color[z] ← RED                // 将z着色为“红色”
  RB-INSERT-FIXUP(T, z)         // 通过RB-INSERT-FIXUP对红黑树的节点进行颜色修改以及旋转,让树T仍然是一颗红黑树
  • 染色:分为三种情况
    • 如果插入的元素是根节点,那么直接插入然后染色黑色
    • 插入节点的父节点是黑色,因为我们插入的是红色,满足5个特性,所以不需要染色
    • 被插入的节点的父节点是红色。,因为和特性不符,所以分为以下三种情况处理:
RB-INSERT-FIXUP(T, z)
while color[p[z]] = RED                   // 若“当前节点(z)的父节点是红色”,则进行以下处理。
    do if p[z] = left[p[p[z]]]     // 若“z的父节点”是“z的祖父节点的左孩子”,则进行以下处理。
          then y ← right[p[p[z]]]  // 将y设置为“z的叔叔节点(z的祖父节点的右孩子)”
               if color[y] = RED   // Case1 父节点是红色,叔叔节点(父节点的兄弟节点)是红色的。
                  then color[p[z]] ← BLACK         情况1//(01) 将“父节点”设为黑色。
                       color[y] ← BLACK            情况1//(02) 将“叔叔节点”设为黑色。
                       color[p[p[z]]] ← RED        情况1//(03) 将“祖父节点”设为“红色”。
                       z ← p[p[z]]                 情况1//(04) 将“祖父节点”设为“当前节点”(红色节点)
                  else if z = right[p[z]]          // Case 2 叔叔是黑色,且当前节点是右孩子
                          then z ← p[z]            情况2//(01)将“父节点”作为“新的当前节点”。
                               LEFT-ROTATE(T, z)   情况2//(02)以“新的当前节点”为支点进行左旋。
                          color[p[z]] ← BLACK      // Case 3 叔叔是黑色,且当前节点是左孩子。(01) 将“父节点”设为“黑色”。
                          color[p[p[z]]] ← RED     情况3//(02) 将“祖父节点”设为“红色”。
                          RIGHT-ROTATE(T, p[p[z]]) 情况3//(03) 以“祖父节点”为支点进行右旋。
       else (same as then clause with "right" and "left" exchanged)   // 若“z的父节点”是“z的祖父节点的右孩子”,将上面的操作中“right”和“left”交换位置,然后依次执行。
color[root[T]] ← BLACK

三种情况图解

  • 父节点是红色,叔叔节点(父节点的兄弟节点)是红色的,下图是左右孩子。
    • 将父节点和叔叔节点染成黑色,祖父节点染成红色即可。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UFkjXYtS-1624594388719)(容器源码/bl树添加case1.jpg)]

  • 父节点是红色,叔叔节点是黑色,添加的节点是父节点的左孩子。
    • 先将父节点染成黑色;
    • 将祖父节点染成红色;
    • 将父节点进行右旋;

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Vcg5U9bQ-1624594388720)(容器源码/bl树添加case2.jpg)]

  • 父节点是红色,叔叔节点是黑色,添加的节点是父节点的右孩子。
    • 先将父节点左旋
    • 先将子节点染色
    • 将祖父节点染色
    • 把祖父节点右旋

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IwvEHOpH-1624594388720)(容器源码/bl树添加case3.jpg)]

至此,添加操作完成了,当然最难的还不是添加操作,而是删除操作,因为删除操作不仅得满足五点原则,还得满足平衡二叉树。

删除

这边粗略先介绍下二叉树节点删除思路,如果需要看更详细内容,可以看我下一篇文章《树的演变》。

二叉树节点删除的思路

  • 如果要删除的节点正好是叶子节点,直接删除即可;
  • 如果要删除的节点还有子节点,就需要建立父节点和子节点的关系:
    • 如果只有左孩子或者右孩子,直接把这个孩子上移放到要删除的位置就好了;
    • 如果有两个孩子,就需要选一个合适的孩子节点作为新的根节点,该节点称为 继承节点。(新节点要求比所有左子树要大、比右子树要小,我们可以选择左子树中的最大节点,或者选择右子树中的最小的节点。)

红黑树节点删除的思路

我们需要在二叉树删除的思路上,再考虑对删除完后的树进行调整。 二叉树的平衡分为两打点或三个小点,而二叉树还要再去进行相应调整,这样对我们来说工作量太大了,所以我们将这些东西合并在一起来讲述。

  • 先进行二叉树的删除,分为三种情况:

    • 如果删除节点没有孩子,则说明是叶子,直接删除即可。(步骤A)
    • 如果删除的节点只有一个孩子,则用这个唯一的儿子去代替它即可。(步骤B)
    • 如果删除的节点有两个孩子,则要进行两步处理:
      • 如果有两个孩子,就需要选一个合适的孩子节点作为新的根节点,该节点称为继承节点。 选择孩子节点中,左子树中的最大节点(左树最右),或者选择右子树中的最小的节点(右树最左),成为继承节点。
      • 然后再将选择的继承节点的孩子节点重复步骤A或步骤B即可。(不用考虑两个孩子的情况,因为如果有孩子就不可能是子树最左或者最右)。

    如下图所示,删除的是80这个元素,如果你能推出可以成为继承节点的是75或者90,就说明可以了。(忽略颜色)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9yndkyzz-1624594388721)(容器源码/1624522369300.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pelSrshp-1624594388721)(容器源码/1624522408379.png)]

因为染色需要对比五个性质是否被改变,所以这里提一嘴五个性质:

* 每个节点或者是黑色,或者是红色。
* 根节点是黑色。
* 每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
* 如果一个节点是红色的,则它的子节点必须是黑色的。
* 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
  • 染色(DelFixUp):染色或者叫做调整,大体分为两种情况:

    • 删除红色节点,不会破坏红黑树原有性质,所以只要把要调整上的节点赋值给删除的节点,然后删除原有节点即可。这里我们删除40作为尝试,会发现35上位后,仅删除了原有的35。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G18JM1AZ-1624594388721)(容器源码/image-20210624205033044.png)]

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yqoEbjca-1624594388722)(容器源码/image-20210624205129268.png)]

    • 删除黑色节点,这里分为四部分:因为删除黑色节点会破坏红黑树的五大性质

      • 兄弟节点是黑色,且其子节点都是黑色:将兄弟节点染色(这样只解决了当前部分的五个性质),然后把目标节点的父亲节点当成新的目标节点递归,直到x为红色时或者x为根节点时退出,退出时,再将X变为黑色

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ncep8QO3-1624594388722)(容器源码/情况1.jpg)]

      • 兄弟节点是黑色,其右子结点为红色:
        • 纠错:(1、将X兄弟节点W染色成和父亲节点一个颜色---->1、将W进行染红色处理)

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-naOC1Ahe-1624594388723)(容器源码/情况2.jpg)]

      • 兄弟节点是黑色,其子节点左红右黑

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PF7hpXiA-1624594388723)(容器源码/情况3.jpg)]

      • 兄弟节点是是红色

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ot4d4Bbp-1624594388723)(容器源码/情况4.jpg)]

    至此,红黑树的数据结构介绍完毕,我们可以开始讲Hashmap,这部分有点长,我在想要不要单独抽取出来放在数据结构中。。。(这边纯手打的,图也是我自己画的,引用的时候麻烦给上个名字,求求了,呜呜呜~~~)

术语:先给大家解释下术语,防止看不懂

​ 这里说一下桶,网上大部分博客意见不一,有的把数组叫做桶,有的把整个数组和链表叫做桶,个人拙见,桶应该是单个数组对应的链表称之为桶:

  • 桶:就是这一条,看图,是一个数组里的元素和对应的一条链表,两个合起来称之为桶。
  • 桶长:是这个数组里的某个元素对应的链表长度。
  • 位桶:这个数组。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WVbIER1C-1624594388724)(容器源码/1624430400201.png)]

参数介绍

  • DEFAULT_INITIAL_CAPACITY:初始化容量,或者叫初始化位桶的数量。
  • 1<<4:位运算表示左移4位,即2^4。
  • MAXIMUM_CAPACITY:最大容量,不能超过2^30。
  • DEFAULT_LOAD_FACTOR:初始化因子,作为扩容依据的一个乘法变量。
  • TREEIFY_THRESHOLD:链表最大容量转化容量,当链表要变为红黑树时,必须满足位存储数量大于64且桶长大于8。为什么是8?因为经过计算,哈希值相同的数,同时出现8个的可能性仅为0.00000006,所以不会反复发生变树的情况。
  • UNTREEIFY_THRESHOLD:红黑树退化成链表的阈值。为什么是6,因为如果设置为7的话,反复进行树的拆解,非常的消耗内存。
  • MIN_TREEIFY_CAPACITY:最小化树存储数量,应该为4*树化阈值,即64。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //16 初始化容量为16个位桶,英文注解上写着必须是2的倍数
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;

Put方法

  • 直接进入put方法查看,解释一下参数:
    • hash(key):对键值取hash
    • key、value:键值对
    • false是onlyIfAbsent的值:下面进入方法会说。
    • true是evict的值:其他构造器创建map之后再调用put方法,该参数则为true,表示不在创造者模式。
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
  • 进入hash方法查看一下发生了什么:

    • h >>> 16,h >>> 16是用来取出h的高16位,(>>>是无符号右移)。
    • h = key.hashCode():就是取key的hash值。
    • (h = key.hashCode()) ^ (h >>> 16):是让hash值和高16位进行异或,目的是为了让其哈希的结果更加的随机。

    为什么用高16位,因为高16位在我们平时的使用中较少用到,大多数情况下低16位的数值在65536以内就已经足够我们使用了。

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • 下面进入putVal,这部分内容较remove而言较为简单。
    • onlyIfAbsent:表示如果hash冲突时,新值是否替换旧值,false表示替换。(这里可以用于)
    • evict:表示创造者模式,只有在调用map的构造参数的时候为false,表示启用。其他时刻为true,不启用。
    • table:表示当前的散列表,初始化为0,是一个节点数组,阅读注解得知,每次扩容也是2的指数幂。
    • n:用来存储扩容后的长度。
    • p:表示寻址到的相同哈希的节点。
    • tab[i = (n - 1) & hash]: 通过取模运算,来获得寻址结果。也就是传入的key-value键值对在数组中的存储位置。
    • treeifyBin:表示树化,后面会讲。
    • threshold:在官方文档注解中,代表扩容阈值,初始化为0,但规定了就算为0也代表成当前最大容量*负载因子。(The next size value at which to resize (capacity * load factor))
    • resize():扩容方法,下面会讲。
    • treeifyBin(tab, hash):树化方法,下面会讲。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;					//创造节点数组,和一个p节点,还有常量n和i
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length; //如果table表未初始化,或者数组长度为0,就会进行扩容操作,并返回扩容后的数组长度
    if ((p = tab[i = (n - 1) & hash]) == null)//如果为null,说明此处还没有存储元素,将key-value包装成Node设置在i处
        tab[i] = newNode(hash, key, value, null);
    else { 	   //else说明寻址结果i已经存储的有元素了,哈希冲突了
        Node<K,V> e; K k;	//两个临时变量,node和key
        if (p.hash == hash &&  //表示你要插入的key和原位置的key完全相同,这里将p赋值给e,便于后文的替换操作
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)     //此时说明p已经树化,调用红黑树的方法添加到指定位置
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {//走到这里说明,hash寻址冲突了,并且和寻址结果i处的key不同,也不是树,说明此时要在链表上操作了
            	//找到链表的尾节点,插入新节点
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {  //寻找尾节点
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) //桶长大于等于7,说明要树化
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&//此时在链表找尾节点时,发现了和新插入节点完全一致的key,所以记录,跳出,后文替换
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e; //用p来记载冲突节点,
            }
        }
        if (e != null) { // 替换操作,如果e!=null,说明e记录了冲突的节点
            V oldValue = e.value; //记录冲突节点的老值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value; //如果不是创造模式,则e的value会等于新值,进行替换操作。
            afterNodeAccess(e);
            return oldValue;  //返回被替换的旧值
        }
    }
    ++modCount; //这个和集合一样,记录add和remove操作次数
    if (++size > threshold)	  //如果插入后元素超过了扩容阈值,就会进行扩容操作
        resize();
    afterNodeInsertion(evict);
    return null;
}
  • 进入resize方法,学习hashmap是怎么扩容的:

    先看一下扩容的注释:大体就是这个方法会进行初始化或者加倍扩容。根据字符的阈值进行扩容,如果阈值为0,则进行初始化,初始化容量为容器的默认容量16,否则使用二次幂扩展。

     * Initializes or doubles table size.  If null, allocates in
     * accord with initial capacity target held in field threshold.
     * Otherwise, because we are using power-of-two expansion, the
     * elements from each bin must either stay at same index, or move
     * with a power of two offset in the new table.
  • 从上面的源码注释我们可以看出,扩容分为两种大情况:
    • 初始化容量,原本容器为0。(oldCap > 0)
    • 扩容容器,原本容器有阈值,根据2的指数幂去拓展。
final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table; //引用扩容前的哈希表
        int oldCap = (oldTab == null) ? 0 : oldTab.length; //旧的容量是否为空,空为0否则赋值
        int oldThr = threshold;//表示扩容前的扩容阈值,触发本次扩容的阈值
        int newCap, newThr = 0;//创造两个变量作为新容量和新阈值
        if (oldCap > 0) { //如果旧的容量大于0,则说明不是初始化容量,而要进行扩容
            if (oldCap >= MAXIMUM_CAPACITY) { //如果旧容量大于容量最大值,则说明无法扩容了
                threshold = Integer.MAX_VALUE; //把阈值改为最大值
                return oldTab; //返回老数组
            }
                      //新的容量等于旧的容量左移一位,小于的容量最大值
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && 
                     oldCap >= DEFAULT_INITIAL_CAPACITY) 
          			  //并且旧的容量大于等于初始化容量
                newThr = oldThr << 1; // double threshold(扩容阈值,左移一位,等于乘2)
        }
        else if (oldThr > 0) 
            // 如果旧的阈值大于0,这里你会很奇怪容器为什么没有容量,还有旧的阈值
            //因为调用1、newHashMap(initCap,loadFactor) 2、newHashMap(initCap) 
            //3、newHashMap(map)这三种方法底层调用tableSizeFor()方法,返回threshold
            newCap = oldThr;  //新的容器就会等于旧的阈值
        else { // zero initial threshold signifies using defaults(没有阈值就用初始化的)
            newCap = DEFAULT_INITIAL_CAPACITY; //新容量为默认容量为16.
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
            //新的阈值等于负载因子*初始化容量=0.75*16
        }
        if (newThr == 0) { //新的阈值为0
            float ft = (float)newCap * loadFactor;//ft等于新的容量*负载因子
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
            //新阈值等于ft(需要同时满足新容量小于最大容量并且得到的ft小于最大容量)或容量最大值
        }
        threshold = newThr; //内部存储的阈值会等于扩容后的新阈值
---------------------------以上代码就干了两件事,得到新阈值和得到了新的容量-------------------
 
    @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//使用新容量得到新的哈希表
        table = newTab;当前表变成 //把旧表变成新表
            //如果临时存储的旧表不为空(说明不是初始化,初始化就没必要进行下面操作)
        if (oldTab != null) {  
            for (int j = 0; j < oldCap; ++j) { //遍历旧表
                Node<K,V> e;  //临时节点e
                if ((e = oldTab[j]) != null) { //e等于老列表的节点,且不为空
                    oldTab[j] = null;//把老列表节点置空,方便GC,GC我会在《JVM垃圾回收机制》说
                     //第一种情况:桶位只有一个元素,没有发生过hash碰撞
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;//计算hash值,在新数组复制这个元素
                     //第二种情况:桶位已经树化
                    else if (e instanceof TreeNode)  
                        //调用树化方法,《树的演变会讲》
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
          //第三种情况:桶位链化,对链表的一些操作,这里的话,先看我下面介绍再回来看上面的
                    else { // preserve order   
                        //走到这里希望你先把下面文章中的链表扩容操作看明白,接下来具体实施
						//将链表分成了根据哈希值某一位分成高低两条链表,分别记录头尾节点
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                         //循环插入
                        do {
                            next = e.next;
                              //分配到低位链表,插入到lo链表中
                            if ((e.hash & oldCap) == 0) {
                                //e的哈希和容量发生与操作为0,说明e是头部节点
                                if (loTail == null)//
                                {  loHead = e;}  //低链的头是e
                                else  //不是0的话,说明低链的头不是e
                                {loTail.next = e;} //低链的下一个节点为e
                                loTail = e;	//低链成为e,方便次循环操作
                            }
                            //分配到高位链表,插入到高位链表中
                            else {
                                if (hiTail == null)
                                    hiHead = e;//判断是不是头部节点
                                else //否则插入到下一个节点中
                                    hiTail.next = e;  //低链的下一个节点为e
                                hiTail = e; //高链成为e,方便次循环操作
                            }
                        } while ((e = next) != null);
                        //上面的循环操作形成了两条链表,但还没有接到桶数组上
						//将链表接到桶数组上
                        if (loTail != null) {//如果低链不为空
                            loTail.next = null; 
                            newTab[j] = loHead; //新表插入低链
                        }
                        if (hiTail != null) {///如果高链不为空
                            hiTail.next = null;
                             //还是假设新容量是32
                            //因为生成了高低位两条链表,高位链表比低位链表多16位
                            //所以j+16表示高位链表的桶位,将高位链表和桶连接
                            newTab[j + oldCap] = hiHead;//新表插入高链
                        }
                    }
                }
            }
        }
        return newTab;
    }

补充下知识盲区

  • 首先前置知识:桶中的寻址方式:i=(n - 1) & hash

    • n表示长度,是2的整数次幂,假设扩容前的n=16,所以存放在i=15处的元素的寻址结果二进制为i=1111,由于是与运算,所以hash值的最低四位为1:xxxxxx1111,这样hash&(n-1)结果才能是i=1111

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-of3NoMhU-1624594388724)(容器源码/2021040410521249-1624588770356.png)]

  • 在同一个桶位的hash值的后四位肯定是相同的,继续根据上文扩容后的n=32,所以五个bit位决定桶位置,根据倒数第五位来将扩容前的长链表给分开,分为高低两条链表

  • 假设倒数第五位是1:xxxx11111,e.hash & oldCap中oldCap是16(二进制位):10000,所以结果:xxxx10000,存放到桶16的位置

  • 假设倒数第五位是0: xxxx01111, e.hash & oldCap中oldCap是16(二进制位):10000,所以结果:xxxx00000,存放到桶0的位置 。

    这样高低两个链表存储位置差了一个oldCap 。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LylSKtFp-1624594388725)(容器源码/2021040411112372.png)]
    在这里插入图片描述

    看完这里就能返回去,继续看源码了。

  • 下面进入treeifyBin源码,我们来说一下树化函数,即链表转红黑树。

    • tab:要进行树化的节点数组。
    • hash:新增键值对中key的hash值
    • e:用来保存桶内寻址对应的链表存储的节点的位置。
    • n:节点数组长度
    • treeify:树化函数,将链表变为树。
    final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        //1、不转换只扩容
        //如果这个节点数组为空或者小于默认树化容量64
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize(); //扩容即可,扩容上面讲了,不再赘述
        //2、转换
        //桶内寻址对应的链表存储的节点不为空
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;//初始化两个临时变量,头节点和尾节点
            do {//把节点e转换为树状节点
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)//如果尾部节点为空,说明p是第一个节点,就把p给到头部节点
                    hd = p;
                else { //尾部节点不为空,表示原表有节点
                    p.prev = tl;//把p的前置指针给到上一个的尾部节点
                    tl.next = p;//把上一个尾部节点的后置指针给到p
                }
                tl = p;  //尾部节点就变成了p
            } while ((e = e.next) != null); //对链表进行遍历
            if ((tab[index] = hd) != null) 
	//hd不为空,说明p是第一个节点,如果p是第一个树状节点,则代表原本它不是一棵树,则我们继续树化
                hd.treeify(tab);
        }
    }
  • 我们再进入treeify方法,希望这个方法是最后一个树化方法了…
    • dir:标识方向,-1表示左边,1表示右边
        final void treeify(Node<K,V>[] tab) {
            TreeNode<K,V> root = null;  //初始化树的根节点为空
            // 遍历链表,x指向当前节点、next指向下一个节点
            for (TreeNode<K,V> x = this, next; x != null; x = next) {
                next = (TreeNode<K,V>)x.next;//把节点树化后,标记下个next
                x.left = x.right = null;//设置当前节点左右为空
                if (root == null) { //情况1:如果根节点为空
                    x.parent = null; //因为是根节点,设置父节点为空
                    x.red = false;//根节点必须是红色
                    root = x; //设置x为根节点
                }
                else { //情况2:如果根节点不为空,则插入下面节点
                    K k = x.key; //泛型,将x的键取出
                    int h = x.hash;  //取出x的哈希值
                    Class<?> kc = null;   // 定义key所属的Class
                    for (TreeNode<K,V> p = root;;) { //遍历树,无限循环,只能内部跳出
                        int dir, ph;   // dir 标识方向(左右)、ph标识当前树节点的hash值
                        K pk = p.key; //取出当前树节点的key
                        if ((ph = p.hash) > h)//如果当前树节点hash值 大于 当前链表节点的hash值
                            dir = -1; //放到左边
                        else if (ph < h)//如果当前树节点hash值 小于 当前链表节点的hash值
                            dir = 1;//放到右边
                                       
                 //如果两个节点的key的hash值相等,那么还要通过其他方式再进行比较
                 //如果当前链表节点的key实现了comparable接口,并且当前树节点和链表节点是相同Class
                 //的实例,那么通过comparable的方式再比较两者。
                 //如果还是相等,最后再通过tieBreakOrder比较一次
    
                        else if ((kc == null &&
                                  (kc = comparableClassFor(k)) == null) ||
                                 (dir = compareComparables(kc, k, pk)) == 0)
                            dir = tieBreakOrder(k, pk);

                        TreeNode<K,V> xp = p; //  保存当前树节点
                        
               // 如果dir 小于等于0 : 当前链表节点一定放置在当前树节点的左侧,但不一定是该树节点				//	的左孩子,也可能是左孩子的右孩子 或者 更深层次的节点。
               //如果dir 大于0 : 当前链表节点一定放置在当前树节点的右侧,但不一定是该树节点的右			   //孩子,也可能是右孩子的左孩子 或者 更深层次的节点。
               //如果当前树节点不是叶子节点,那么最终会以当前树节点的左孩子或者右孩子 为 起始节点  			   //再从GOTO1 处开始 重新寻找自己(当前链表节点)的位置
               //如果当前树节点就是叶子节点,那么根据dir的值,就可以把当前链表节点挂载到当前树节点				   //的左或者右侧了。
               //挂载之后,还需要重新把树进行平衡。平衡之后,就可以针对下一个链表节点进行处理了。
                        if ((p = (dir <= 0) ? p.left : p.right) == null) {
                            x.parent = xp;
                            if (dir <= 0)
                                xp.left = x;
                            else
                                xp.right = x;
                            root = balanceInsertion(root, x); //树的平衡这块,大家看我的文章《树的演变把》
                            break;
                        }
                    }
                }
            }
            moveRootToFront(tab, root);
        }

分享:

低价透明

统一报价,无隐形消费

金牌服务

一对一专属顾问7*24小时金牌服务

信息保密

个人信息安全有保障

售后无忧

服务出问题客服经理全程跟进