首页 > 编程语言 >万字长文之HashMap源码解析(包含红黑树)

万字长文之HashMap源码解析(包含红黑树)

时间:2023-05-23 12:07:53浏览次数:66  
标签:那么 HashMap 链表 插入 源码 数组 红黑树 table 节点

万字长文之HashMap源码解析(包含红黑树)_后端

〇、储备知识之红黑树

0.1> 2-3树

  • 红黑树是一种自平衡的二叉树,它可以避免二分搜索树在极端的情况下蜕化成链表的情况。那么什么是红黑树呢?要想便于了解红黑树,我们先了解一下跟它息息相关的2-3树。
  • 2-3树是一种绝对平衡的多叉树,在这棵树中,任意一个节点,它的左右子树的高度是相同的。如下所示:

万字长文之HashMap源码解析(包含红黑树)_后端_02

  • 正如上面介绍过的,2-3树是一个多叉树。那为什么叫做2-3树呢? 因为规则定义,2-3树分为两种节点,分别为:2-节点和3-节点。其中,2-节点表示节点中保存一个元素,3-节点则表示节点中保存两个元素。
  • 我们来演示一下如何生成一个2-3树。
  • 首先:向2-3树中插入30和25

万字长文之HashMap源码解析(包含红黑树)_后端_03

  • 当再插入37的时候,一个节点就容纳了3个元素了,那么就要进行分裂操作了,如下所示:

万字长文之HashMap源码解析(包含红黑树)_后端_04

  • 然后,我们再插入20和33,可以正常的容纳这两个元素

万字长文之HashMap源码解析(包含红黑树)_数组_05

  • 我们再继续插入17和43,那么出现了两个节点都分别容纳了3个元素,那么这两个节点都需要进行分裂操作了

万字长文之HashMap源码解析(包含红黑树)_赋值_06

  • 插入27和35,两个节点都可以容纳这两个新插入的元素

万字长文之HashMap源码解析(包含红黑树)_红黑树_07

  • 那么再最后插入22,结果发现,一个节点容纳了3个元素,要进行分裂,但是分裂后,叶子节点的高度不一致了,那么就要再进行聚合操作,如下所示:

万字长文之HashMap源码解析(包含红黑树)_红黑树_08

  • 那么,我们了解完2-3树之后,我回过头来看一下红黑树,也就是说,2-3树怎么转变成红黑树呢?方式很多,此处我们可以采用左倾红黑树的方式,来将2-3树转换为红黑树,转换规则如下:

万字长文之HashMap源码解析(包含红黑树)_后端_09

  • 我们可以根据上面的转换规则,进行转换操作。下图是我们上面讲2-3树的时候,构造的。

万字长文之HashMap源码解析(包含红黑树)_后端_10

  • 那么我们按照规则进行转换,如下所示:

万字长文之HashMap源码解析(包含红黑树)_后端_11

  • 我们按照树形结构进行修整,那么就是我们今天要介绍的红黑树。如下所示:

万字长文之HashMap源码解析(包含红黑树)_赋值_12

0.2> 红黑树

  • 我们已经了解了如何从2-3树转变为红黑树,那么,什么样的树才叫红黑树呢?难道把节点标记为红色和黑色就是红黑树了吗?当然不是!如果想称为红黑树的一员,一定要满足以下五个条件:(面试很重要!!!)
  • 条件一:每个节点要么是红色,要么是黑色。
  • 条件二:根节点一定是黑色的。
  • 条件三:每个叶子节点一定是黑色。
  • 条件四:如果一个节点是红色,那么它的左右子节点一定都是黑色的。
  • 条件五:从任意一个节点到叶子节点,所经过的黑色节点的数量一样多。
  • 那有的同学肯定会问,条件三里面的描述,说每个叶子节点一定是黑色,但是你上面从2-3树转变的红黑树,叶子节点也不全都是黑色啊。比如33这个节点不就是红色吗?这个问题其实很好,那难道上面的红黑树是错误的吗?其实不是的,因为没有画上空叶子(即:NULL LEAF),所以,33并不是叶子节点。红黑树的叶子节点是黑色的NULL LEAF。完整的红黑树,如下所示:

万字长文之HashMap源码解析(包含红黑树)_赋值_13

  • 那么HashMap中是如何构建红黑树的呢?我们不要着急,下面的源码解析中,你会看到它的身影。

一、源码概述

  • 当我们掌握了红黑树的理论知识之后,下面我们就来开始分析HashMap的源码了。那我们从哪里开始入手呢?要回答这个问题,那么就要从我们最常使用HashMap的场景出发了。当我们想要是用HashMap的时候,我们首先会通过HashMap的构造方法创建HashMap,然后通过put方法向HashMap对象赋值。那么我们就可以通过构造函数+put这两点进行源码的切入点。

1.1> HashMap的构造函数

  • 我们先来看HashMap的构造方法。

万字长文之HashMap源码解析(包含红黑树)_红黑树_14

  • 通过源码我们可以看到只有一行代码,即:给loadFactor赋值。
  • 那么_loadFactor是什么呢?_它是HashMap的加载因子,也就是说,元素所占的空间达到加载因子的规定值的时候,那么就会执行扩容。
  • 那么初始化加载因子的时候,赋值给它DEFAULT_LOAD_FACTOR属性了。_DEFAULT_LOAD_FACTOR这个值是多少呢?_我们去源码中寻找答案。

万字长文之HashMap源码解析(包含红黑树)_数组_15

  • 通过上面截图,我们知道了,加载因子默认被赋值为0.75f,那么其实大部分同学都是知道HashMap的结构在JDK8之前是【数组+链表】,而从JDK8之后,存储结构就变为了【数组+链表+红黑树】了。那么这个0.75的含义就是:如果数组中存储的元素长度达到了原长度的75%或者3/4的话,那么就需要执行扩容操作了。
  • 构造函数就这么一行代码,即:给loadFactor赋值为0.75f。是不是很简单。那我们看完了构造函数的代码,我们就来把视角转到put方法吧。

1.2> put方法

  • 在介绍put方法之前,我们先看这个方法的源码,如下所示:

万字长文之HashMap源码解析(包含红黑树)_后端_16

  • put方法里面,只是调用了putVal方法,如下是putVal方法:

万字长文之HashMap源码解析(包含红黑树)_数组_17

  • 正如上面源码截图中所描述的,整个putVal一共执行了三部分内容,分别是:

1> 创建table数组 2> 向table数组中赋值,这里面分为哈希不冲突和哈希冲突两种情况。 3> 如果超过阈值,则进行扩容操作。

  • 那么,下面就针对这三部分进行详细说明。

二、创建table数组

  • 创建table数组的代码如下所示:

万字长文之HashMap源码解析(包含红黑树)_红黑树_18

【解释】

在if判断中,当table数组(即:底层存储HashMap元素的数组)等于null或者table数组的长度为0,那么就执行**resize()**方法进行扩容操作。

  • 那么我们来看resize方法,方法代码如下:

万字长文之HashMap源码解析(包含红黑树)_数组_19


  • resize方法里面代码逻辑比较多,主要分为两大部分:
  • 第一部分:就是我上面截图中的这部分,这部分内容主要是针对旧的数组来确定新扩容的数组容量、阈值,然后创建新的table数组。
  • 第二部分:那么就是if (oldTab != null) {...} 这部分的代码内容,这里主要是要针对旧的table数组及链表或红黑树向新的table数组中迁移的过程,因为新的table数组长度改变了,那么自然而然会导致hash寻址的时候有些元素位置产生了变化,那么就要设计到拆分链表或红黑树的操作。而且,其中如果分裂后的长度变小到一定的程度,那么原本的红黑树也会蜕化为链表,这部分详细的内容,当我们讲到红黑树那块代码的时候再详细去说。
  • 那么针对于我们第一次往HashMap中插入数据这个场景来说呢,本来就没有所谓的旧table数组,所以第二部分的数据迁移跟我们就没什么关系了,所以,我们暂时只需要关注第一部分就可以了。
  • 如果想看懂第一部分,我们需要先把变量的含义介绍清楚,才更有利于我们了解源码的具体逻辑:

【全局变量】

  • table: 当前所使用的table数组。
  • threshold: 当前所使用的table数组的阈值。
  • loadFactor: 当前所使用的table数组的加载因子。

【局部变量】

  • oldTab: 表示旧的table数组。
  • oldThr: 表示旧table数组的阈值。
  • oldCap: 表示旧table数组的容量/长度。
  • newTab: 表示新的table数组。
  • newThr: 表示新table数组的阈值。
  • newCap: 表示新table数组的容量/长度。
  • ok,上面做了这么多的铺垫,我们来分步骤来看一下每段代码。
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;

【解释】

  • 由于我们是第一次调用put方法,所以table还没有被初始化,那么它是为null的,所以oldTab也等于null,oldCap就等于0了。
  • threshold的默认值是0,所以oldThr也等于0。
  • 我们继续来看下面的三个判断代码(为了使代码更美观,此处格式化了代码,并且去除了原有的英文注释)
if (oldCap > 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; 
    }
}
else if (oldThr > 0) {
    newCap = oldThr;
}
else {               
    newCap = DEFAULT_INITIAL_CAPACITY;
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}

【解释】

  • 判断1:如果旧的table数组长度大于0(即:oldCap > 0)
  • case1

如果旧table数组长度大于等于最大容量(MAXIMUM_CAPACITY),那么阈值threshold就被赋值为Integer的最大值,返回旧的table数组。

Integer.MAX_VALUE的值为2的31次方减1,也就是二进制的 0111 1111 1111 1111 1111 1111 1111 1111。

万字长文之HashMap源码解析(包含红黑树)_赋值_20

MAXIMUM_CAPACITY的默认值为1<<30,那么它表示的二进制为1000 0000 0000 0000 0000 0000 0000 0000。

万字长文之HashMap源码解析(包含红黑树)_后端_21

  • case2

newCap = oldCap << 1表示将oldCap左移1位,其实也就是按照oldCap*2来扩容为newCap。

如果满足以下两个条件,则新的阈值(newThr)也扩容为旧阈值(oldThr)的2倍:条件1:新的table数组容量(newCap)小于MAXIMUM_CAPACITY条件2:旧的table数组容量(oldCap)大于等于DEFAULT_INITIAL_CAPACITY(默认值为:16) DEFAULT_INITIAL_CAPACITY的默认值如下所示:

万字长文之HashMap源码解析(包含红黑树)_数组_22

  • 判断2:如果旧的table数组阈值大于0(即:oldThr > 0)

如果旧的table数组容量不大于0并且它的阈值还大于0,那么说明什么呢?

说明table数组太大了,以至于长度越界了,出现了从整数变为了负数的情况。如果这种情况发生了,那么就将旧的table数组的阈值作为新table数组的容量进行赋值,相当于适度的进行长度修复。

  • 判断3:如果上面条件都不满足,就执行判断3

其实通过上面判断1和判断2的分析,我们应该可以得出如下结论:那么就是当table数组没有被初始化创建的时候,就会进入到判断3的代码。在这里,会做两件事:

1> 将新的table数组长度赋值为DEFAULT_INITIAL_CAPACITY。(这个默认值为16,上面源码截图中已经粘贴出来了)2> 将新的table数组的阈值赋值为DEFAULT_INITIAL_CAPACITYDEFAULT_INITIAL_CAPACITY=160.75=12。 DEFAULT_INITIAL_CAPACITY默认值为0.75f,这个也在上面截图中也粘贴出来了。

  • 确定新的table数组容量(newCap)和新的table数组的阈值(newThr)之后,我们来继续看下面的代码:
if (newThr == 0) {
    float ft = (float)newCap * loadFactor;
    newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
}

【解释】

  • 其实这块代码,主要是为上面【**判断2:如果旧的table数组阈值大于0(即:oldThr > 0)】**服务的,因为上面也说过了,逻辑进入到判断2,说明旧的table数组太大了导致越界从超级大的整数变为了负数。那么,由于代码块里只是对newCap赋值了,并没有赋值newThr,所以这块逻辑中,newThr的值依然是0,满足if条件。
  • 首先通过ft=0.75f*newCap,计算出新的阈值,那么下面就要进行一系列的判断,代码挺长的,但是逻辑很简单。就是说如果我这次计算出来的新的阈值(ft)小于最大容量(MAXIMUM_CAPACITY),那么就作为新的阈值了。否则,那么就默认赋值为Integer的最大值了(Integer.MAX_VALUE)
  • 其实我们能够看出来,上面的一堆代码逻辑其实就是在做一件事儿——确定新的table数组的容量(newCap)和阈值(newThr),为了下面真正要创建新的table数组做准备。
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;

【解释】

  • 这部分代码就没什么好说的了,就是创建新的table数组,并且更新全局变量threshold和table,因为这两个值表示的就是当前“生效”或“正在使用”的table数组和阈值。
  • 下面的if (oldTab != null) 代码块我们就不在【二、创建table数组】展开了,因为这部分代码是对于之前已经创建过table数组的逻辑来说的,我们会在下面的章节部分展开说明。那么table数组创建完毕了,就该插入数据了吧?是的,那我们进入【三、向table数组中插入元素】这部分吧。

三、向table数组中插入元素

3.1> 没有发生哈希冲突

  • 当我们向HashMap中插入元素的时候,其实我们最希望看到的就是没有任何的哈希冲突,即可以直接插入到table数组中。那么如果真的非常“幸运”被我们赶上了,那么下面的代码就是:
if ((p = tab[i = (n - 1) & hash]) == null) {
    tab[i] = newNode(hash, key, value, null);
}
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
    return new Node<>(hash, key, value, next);
}

【解释】

  • 通过(n - 1) & hash来寻址,找到待插入的位置i,这里的tab就是table数组,那么发现tab[i]==null,也就是待插入的位置是空的,那就太好了,我们直接插入就可以了。所以,先通过newNode方法构建Node节点,然后放到table数组对应的位置i上面。

3.2> 发生了哈希冲突

  • 但是,大多情况下,插入的元素都会发生哈希冲突,那么如果发生了怎么办呢?对于JDK8来说,就会以链表或红黑树的方式进行数据存储。我们先从整体方面看一下这块代码。

万字长文之HashMap源码解析(包含红黑树)_数组_23

3.2.1> 冲突的节点与待插入的节点key值相同

  • 这种情况,其实也是我们比较喜欢看到的。因为不涉及到红黑树和链表,只是把旧的Node节点取出来赋值给e。
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
    e = p;
}
  • 在putVal方法的最后,会有一段如下的逻辑处理:

万字长文之HashMap源码解析(包含红黑树)_红黑树_24

【解释】

  • 在覆盖旧值之前,需要判断onlyIfAbsent是不是为false,这个值是什么意思呢?其实它就是putVal的第4个入参,我们调用put方法的时候,put方法里默认是传notallow=false的。所以它的含义是,如果我们要覆盖旧值,则notallow=false,如果不覆盖,则notallow=true。

万字长文之HashMap源码解析(包含红黑树)_数组_25

3.2.2> 向红黑树中插入元素

  • 讲完直接覆盖旧值的处理方式之后,那么这部分我们就来先介绍以红黑树的方式解决哈希冲突,这部分代码很多,我们一步步来拆分分析。代码如下所示:
else if (p instanceof TreeNode) {
    e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
}
  • 这里判断,如果p这个值时TreeNode类型的,那么就现在这个结构是红黑树了,那么就往红黑树中插入了。p就是我们上面通过hash寻址获得得应该插入的下标i,然后从tab[i]中获得的元素。讲到这里,我们先来介绍一下HashMap底层的两个数据结构。
  • 首先是TreeNode,它的结构如下所示:

万字长文之HashMap源码解析(包含红黑树)_赋值_26

万字长文之HashMap源码解析(包含红黑树)_数组_27

万字长文之HashMap源码解析(包含红黑树)_后端_28

【解释】

  • 从源码中我们可以看出来,TreeNode其实包含两部分内容:
  • 1> 树结构(父节点:parent,左子节点:left,右子节点:right)
  • 2> 链表结构(前指针:prev,后指针:next)

next指针是在TreeNode祖父类HashMap.Node里面定义的。

  • 我们来继续看putTreeVal方法的具体实现

万字长文之HashMap源码解析(包含红黑树)_红黑树_29

【解释】

根据上面源码截图的红框注释,可以将putTreeVal方法分为5部分来说明,分别是:

  • 1> 寻址根节点
  • 2> 确定插入位置
  • 3> 构造TreeNode并插入到相应的子节点位置
  • 4> 红黑树平衡调整
  • 5> moveRootToFront

下面我们就来一一介绍:

3.2.3.1> 寻址根节点
  • 由于我们要将待插入的节点放到红黑树中,所以我们需要先从根节点出发,寻找待插入的位置,那么下面代码就是负责这部分内容的:
TreeNode<K,V> root = (parent != null) ? root() : this;
  • parent代表父节点,那它是谁的父节点呢?我们是通过调用p的putTreeVal来执行的,那么就是p的父节点,还记得p是吧? 即:p=tab[i],也就是table数组中i位置上的元素。如下所示:

万字长文之HashMap源码解析(包含红黑树)_数组_30

  • 那么如果p的父节点等于null,就说明自己就是root根节点了,如果不等于null,就说明root根节点另有它人。就需要调用root()方法来进行查找了。

万字长文之HashMap源码解析(包含红黑树)_赋值_31

【解释】

  • 那么root()方法的逻辑就是,顺着p的父类往上查找父类,直到找到一个节点它没有父节点,那么这个节点就是root节点了。
3.2.3.2> 确定插入位置
  • 这部分代码如下所示:
for (TreeNode<K,V> p = root;;) {
    int dir, ph; K pk;
    if ((ph = p.hash) > h)
        dir = -1;
    else if (ph < h)
        dir = 1;
    else if ((pk = p.key) == k || (k != null && k.equals(pk)))
        return p;
    else if ((kc == null &&
              (kc = comparableClassFor(k)) == null) ||
             (dir = compareComparables(kc, k, pk)) == 0) {
        if (!searched) {
            TreeNode<K,V> q, ch;
            searched = true;
            if (((ch = p.left) != null &&
                 (q = ch.find(h, k, kc)) != null) ||
                ((ch = p.right) != null &&
                 (q = ch.find(h, k, kc)) != null))
                return q;
        }
        dir = tieBreakOrder(k, pk);
    }
	... ...
}
  • 这部分代码看着挺多的,但是逻辑很简单,就是对比p节点的hash值与待插入元素的hash值,如果p节点hash值大,则说明待插入的元素在p节点的左侧,如果p节点hash值小,则说明待插入的元素在p节点的右侧。如果p节点的key值就是我们待插入的key值,那么就好办了,我们直接把这个p节点作为方法的返回值return就可以了。我们可以看到最外层的for循环是无限循环的,那么就说明,每一次循环,都会慢慢的向下寻找,直到找到一个节点,它的左节点或者右节点是可以插入新Node的,那么就插入了。这个过程就像我们搜索二叉树中某个值的过程是一样的。这块没有什么难点,我们继续往下看。
3.2.3.3> 构造TreeNode并插入到相应的子节点位置
  • 这部分代码如下所示:
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
    Node<K,V> xpn = xp.next;
    TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
    if (dir <= 0)
        xp.left = x;
    else
        xp.right = x;
    xp.next = x;
    x.parent = x.prev = xp;
    if (xpn != null)
        ((TreeNode<K,V>)xpn).prev = x;
    ... ...
}
  • 通过上面的计算,我们得出一个dir的值,它就是用来表示在节点的左侧还是右侧。
  • dir=-1:表示待插入节点在p节点的左侧。
  • dir=1:表示待插入的节点在p节点的右侧。
  • 要讲这块代码,我们还需要再介绍一下局部变量值的含义:
  • x表示待插入的树节点。
  • xp表示x节点的parent节点。
  • xpn表示xp的next节点,即后置节点。
  • 那么此处我们假设dir=-1,也就是说我们要把待插入的节点放到树节点的左侧,那么如果p.left等于null,说明p是没有左子节点的,那么我们就可以执行插入操作了,即:满足了if里面的判断。
  • 然后构建一个全新的TreeNode,并维护双向链表。这里需要关注的是map.newTreeNode(h, k, v, xpn),第四个xpn就表示要我们新建节点x链接到xp节点的后面,然后将xpn链接到x节点的后面,如下所示:

万字长文之HashMap源码解析(包含红黑树)_红黑树_32

  • 当然,除了维护好双向链表之外,最重要的,当然是将x插入到xp的左侧,即:xp.left = x;
  • 好的,新节点也建好了,双向链表也维护好了,树节点也插入完毕了。但是,_这个新的树结构真的满足红黑树的要求吗?不满足怎么办呢?_那么想要说明白这个问题,就需要进入下一章节——红黑树平衡调整balanceInsertion(root, x)来一探究竟了。
3.2.3.4> 红黑树平衡调整
  • 这部分代码主要就是调整树结构,使得可以构建成一个合法的红黑树,代码如下所示:

万字长文之HashMap源码解析(包含红黑树)_赋值_33

  • 这块代码相信大家看到注释后,也觉得有点懵了。但这就是对红黑树进行调整的计算逻辑。针对下面这段代码,应该不用过多解释了:
if ((xp = x.parent) == null) {
    x.red = false;
    return x;
}
else if (!xp.red || (xpp = xp.parent) == null) 
    return root;
  • 主要的逻辑,其实都是在下面的代码块中:
if (xp == (xppl = xpp.left)) {
    if ((xppr = xpp.right) != null && xppr.red) {
        xppr.red = false;
        xp.red = false;
        xpp.red = true;
        x = xpp;
    }
    else {
        if (x == xp.right) {
            root = rotateLeft(root, x = xp);
            xpp = (xp = x.parent) == null ? null : xp.parent;
        }
        if (xp != null) {
            xp.red = false;
            if (xpp != null) {
                xpp.red = true;
                root = rotateRight(root, xpp);
            }
        }
    }
}
else {
    if (xppl != null && xppl.red) {
        xppl.red = false;
        xp.red = false;
        xpp.red = true;
        x = xpp;
    }
    else {
        if (x == xp.left) {
            root = rotateRight(root, x = xp);
            xpp = (xp = x.parent) == null ? null : xp.parent;
        }
        if (xp != null) {
            xp.red = false;
            if (xpp != null) {
                xpp.red = true;
                root = rotateLeft(root, xpp);
            }
        }
    }
}
  • 以上的代码,其实总的分为如下部分:
  • 部分一:如果x的父节点在祖父节点的左侧
  • 操作类型一:变色

操作条件:如果祖父节点的右节点是红色的(其实作为祖父节点的左节点也是红色的)

  • 操作类型二:旋转+变色
  • 部分二:如果x的父节点在祖父节点的右侧
  • 操作类型一:变色

操作条件:如果祖父节点的做节点是红色的(其实作为祖父节点的右节点也是红色的)

  • 操作类型二:旋转+变色
  • 那么其实我们就可以只针对变色操作和旋转+变色这两种操作逐一分析即可,为了便于大家更好的理解,这块我不以文字而是采用图例的方式来说明了。
a> 变色操作

万字长文之HashMap源码解析(包含红黑树)_后端_34

b> 旋转+变色操作

万字长文之HashMap源码解析(包含红黑树)_后端_35

万字长文之HashMap源码解析(包含红黑树)_赋值_36

c> 左旋操作rotateLeft

万字长文之HashMap源码解析(包含红黑树)_赋值_37

d> 右旋操作rotateRight

万字长文之HashMap源码解析(包含红黑树)_赋值_38

  • 了解了上面的各种场景和左旋右旋,我们可以举个例子,某个横向链表添加了8个元素从A到H,当再插入I的时候,由于超出了8个,所以会执行resize操作,那么我们假设table数组的长度大于等于64,是满足从链表转换为红黑树的条件的。我们来模拟一下转换过程,当然,由于转换过程画得我确实太累了,我实在画不懂了,那么就画到F元素就不画了,不过不影响大家理解。转换图如下所示:

万字长文之HashMap源码解析(包含红黑树)_后端_39

3.2.3.5> moveRootToFront
  • 那么讲完红黑树的平衡操作后,就需要执行moveRootToFront方法的,这个方法从名字上能够看出来,就是将root节点放到整条双向链表的头部,并插入到table数组中。这块的双向链表大家不要理解错,与我们常说的HashMap是有链表+红黑树组成的那个链表不是一个概念哈。那个链表是单向链表。
  • 相关代码如下所示:

万字长文之HashMap源码解析(包含红黑树)_数组_40

  • 这块逻辑就比较简单了,需要讲的内容我都标注到了上方源码截图中了。那么整个处理流程,我们依然采用图例的方式来叙述一下吧。如下所示:

万字长文之HashMap源码解析(包含红黑树)_后端_41

3.2.3> 像单向链表中插入元素

  • 上面介绍的红黑树,是当已经转换完红黑树之后再插入数据的操作。那么就像我们刚刚new了一个HashMap对象,然后开始插入元素的时候,是会先以单向链表方式存储的。那么它所涉及的代码如下所示:

万字长文之HashMap源码解析(包含红黑树)_后端_42

  • 我们可以从上面的源码截图看到,for里面是一个无限循环,也就是说,会从p节点开始,一直调用next去遍历链表中的每一个元素,只要遇到了和待插入的key值相同的节点,则break出无限for循环。如果都没有与待插入的key值相同,则创建新的Node,插入到链表的结尾。
  • 当然,还有一个限制,就是binCount >= TREEIFY_THRESHOLD - 1,首先我们要说明的是,binCount是从0开始的,那么其实对应的是链表中的第2个元素,而TREEIFY_THRESHOLD默认值为8,则只要binCount >= 8-1,则尝试转变红黑树(是否转变,还要看treeifyBin里面的逻辑)。那么当binCount >=7的时候,其实就是链表中的元素已经超过8个了。下面,我们就来着重看一下treeifyBin方法的实现逻辑是什么?
3.2.3.1> treeifyBin
  • 相关源码如下所示:

万字长文之HashMap源码解析(包含红黑树)_赋值_43

万字长文之HashMap源码解析(包含红黑树)_后端_44

【解释】

  • treeifyBin可以分为两部分逻辑:
  • 1> 如果table数组长度小于64,则只扩容table数组,不转换为红黑树。
  • 2> 否则,将链表转换为红黑树。
  • 那么针对这两部分逻辑,我们逐一进行分析。
a> 针对table数组进行扩容
  • 这部分逻辑代码都在resize()方法中,它的源码如下所示:

万字长文之HashMap源码解析(包含红黑树)_数组_45

  • 上面的处理逻辑已经标注到了源码截图上,那么我们暂时先不看split方法,因为涉及到了红黑树的分裂,我们先把视角关注在链表的分裂迁移上,还是按照惯例,我们依然以图示来说明具体处理流程。我们尝试往新建的HashMap里存放数据,直到出发扩容。
  • 首先,我们执行put(0, "a0")和put(1,""a1),那么HashMap的存储方式是这样的:

万字长文之HashMap源码解析(包含红黑树)_数组_46

  • 那么我们继续执行put方法,put(16, "a16"), put(32, "a32"), put(48, "a48"), put(64, "a64"), put(80, "a80"), put(96, "a96"), put(112, "a112") 那么存储结构如下图所示:

万字长文之HashMap源码解析(包含红黑树)_红黑树_47

  • 由于下标为0的位置只是存储了8个节点,并没有出发扩容,那么我们就继续往下标为0的位置插入元素,即:put(128, "a128"),那么下标为0的位置达到了9个元素,满足了触发扩容的条件,但是由于table数组的长度为16,所以不会转为红黑树。扩容和数据迁移后,存储结构如下所示:

万字长文之HashMap源码解析(包含红黑树)_数组_48


  • 从上面的途中我们可以看到,原本长度为9的链表,被拆分成了两条链表,其中:低位链表保存了5个节点的数据,高位链表保存了4个节点的数据。
  • 我们介绍完链表的分裂和迁移之后,就来再回过头看一下 ((TreeNode<K,V>)e).split(this, newTab, j, oldCap)的处理逻辑吧。split的源码如下所示:

万字长文之HashMap源码解析(包含红黑树)_赋值_49

万字长文之HashMap源码解析(包含红黑树)_赋值_50

  • 我们看到,其实针对红黑树的拆分方式与单向链表的拆分方式异曲同工,都是将一个整体拆分为高位和低位两部分。那么不同的是,当拆分后的高低位双向链表中存储的数据小于等于6个的时候,那么就没有必要使用红黑树的结构了,因为红黑树的特点是,在大数据量的情况下,查询比链表快太多了,但是由于每次插入或者删除节点,都需要重新调整红黑树的结构,以满足红黑树的约束,所以,这方面没有链表速度快。所以,当元素很少的情况下,就直接采用链表了。这部分涉及了untreeity方法,我们看一下untreeity的源码:

万字长文之HashMap源码解析(包含红黑树)_赋值_51

万字长文之HashMap源码解析(包含红黑树)_红黑树_52

【解释】

  • 这块逻辑其实就没啥说的了,就是遍历TreeNode双向链表,把每个节点转变为Node类型的节点,然后再拼装成一个单向链表即可。
  • 上面说的是将整个链表拆分为高低位两链条表后,元素较少的情况会进行红黑树转为单向链表,那么如果这两条链表数据依然很多怎么办呢?那么就把这两部分创建两个新的红黑树就可以了。这部分设计的方式是treeify(),源码如下所示:

万字长文之HashMap源码解析(包含红黑树)_红黑树_53

  • 看完上图的注释,其实我们应该能够感受到,这个跟我们在【3.2.2 向红黑树中插入元素】中介绍的内容是一样的。其实就是三个步骤:
  • 步骤一:将待插入的节点插入到红黑树中。
  • 步骤二:由于树形结构变化了,所以要对红黑树的平衡进行调整。
  • 步骤三:如果由于对红黑树进行了调整,有可能造成root节点的变化,那么就要把最新的root节点放到双向链表的头部,并插入到table数组中。
b> 链表转换为红黑树
  • 我们介绍完链表的扩容后,来介绍一下红黑树的转换,由于上面介绍resize()方法的内容比较多,担心同学们已经忘记这部分要讲的源码是什么,我们再来用红框标注一下,如下所示:

万字长文之HashMap源码解析(包含红黑树)_赋值_54

  • 在红框标注的部分中,我们又见到了treeify方法了,这个就是我们刚刚介绍完的方法,用来构造红黑树的。
  • 那么这部分代码不多,内容也简单明了,就是将链表中的每个节点Node转换为TreeNode类型,并调用treeify方法构造红黑树。treeify方法由于上面已经有详细的介绍了,此处就不做过多的赘述了。

四、执行table扩容操作

  • 那么当我们一直往HashMap中插入元素的时候,总会有把table数组填满的时候,那么table数组容量越小,针对大量数据就需要构建横向链表或红黑树,也就是说,哈希冲突就越容易发生。为了减少这种情况发生,table会根据约定好的阈值,即总容量的2/3或0.75,如果超过了这个阈值,则会进行table数组的扩容操作,代码如下所示:

万字长文之HashMap源码解析(包含红黑树)_赋值_55

  • 那么resize方法我们在上面的章节中已经介绍完毕了。只是把它拆成了两部分,第一部分是在【二、创建table数组】章节中介绍了,剩下的部分,是在【3.2.3.1 treeifyBin】中有详细的介绍。那么此处,也就不在赘述了。

更多技术文章,欢迎大家关注公众号“爪哇缪斯”(^o^)/~ 「干货分享,每周更新」

本篇是DDD相关文章的第二篇,在《夏洛特烦恼》中有这么的一段剧情:夏洛穿越到了他初中的班级里,当他发现自己在梦中的时候,看着一直讽刺挖苦他的老师说了句经典的台词: “在我梦里,我还能让你把我给欺负了?” 。他能说出这么“男人”的话,就是因为这是他的梦,是他的“领域”,他是这个梦里面的“王”。

万字长文之HashMap源码解析(包含红黑树)_数组_56

\

那么在DDD中也有领域的概念,团队中的同学们也是所负责领域中的“王”。通过领域,我们会引出另外两个概念,即:子域限界上线文。那么,在今天的文章里,我们就好好的聊一聊他们的故事。

一、领域

既然DDD的含义是“领域驱动设计”,那么,什么是领域(Domain)?

从广义上讲,领域就是一个组织所做的事情以及其中所包含的一切。换句话说,就是一个公司或组织,它所涉及的业务范围以及在其中所进行的活动

上面的定义可能较为生涩,那么我们以非常火爆的《斗罗大陆》为例:唐三获得了杀神领域蓝银领域。那么,当施展出领域力量之后,在领域内的自己实力大增,而对手的实力却被大幅度削弱。

万字长文之HashMap源码解析(包含红黑树)_后端_57

\

那么公司也是一样的,我们拿李宁和阿里为例,在运动服饰这个领域中,李宁就远远强于阿里;但是在电商互联网领域中,阿里就强于李宁。所以,用大白话来说解释领域这个词的概念,就是——你所经营的、活动的、擅长的那个圈子。\

万字长文之HashMap源码解析(包含红黑树)_赋值_58

\

“领域”这个词的含义是多样化的,它既可以表示整个业务系统,也可以表示其中的某个“核心域”或者“支撑子域”。

由于“领域模型”包含了“领域”这个词,我们可能会认为应该为整个业务系统创建一个单一的、完整的、内聚的、全功能式的模型,就像《圣斗士星矢》里面的圣衣一样,那么的完整、内聚、功能繁多且强大。

万字长文之HashMap源码解析(包含红黑树)_数组_59

\

然后,其实正好相反,在DDD中,一个领域被分为若干个子域,领域模型在限界上下文中完成开发。事实上,在开发一个领域模型时,我们通常关注的只是这个业务系统的某个方面——某个子域。而无论软件系统本身的复杂度是大还是小,几乎所有软件的领域都包含多个子域。

万字长文之HashMap源码解析(包含红黑树)_数组_60

\

二、子域

子域根据类型的不同,可分为:核心域支撑子域通用子域。以一个音乐网站或者app为例(排除掉版权问题,假设所有数字音乐各大音乐网站都可以平等播放),它一般包含很多的功能,我们暂且只关注音乐品味推荐、会员与权限、促销活动这3部分功能,那么如下就是针对这3部分子域类型的划分:

万字长文之HashMap源码解析(包含红黑树)_数组_61

\

【解释】\

  • 那么如果想要我们的音乐网站或者app脱颖而受收到大众的喜爱,音乐品味推荐会是核心能力,我听了几首歌,后续不知道要听什么歌曲了,而网站给我推荐的歌曲都特别符合我对音乐的品味,那用户自然就更喜欢来这里听歌,那么 “音乐品味推荐”就是核心域
  • 而网站的各个功能其实都会或多或少的使用会有与权限的能力,所以 “会员与权限”就是通用子域
  • 而“促销活动”这部分也属于业务能力的一部分,但是并没有核心域那么重要, “促销活动”便是支撑子域

2.1> 核心域

它是整个业务领域的一部分,也是业务成功的主要促成因素。从战略层面上讲,企业应该在核心域上胜人一筹。我们应该给予核心域最高的优先级、最资深的领域专家和最优秀的开发团队。在实施DDD的过程中,你将主要关注于核心域。

万字长文之HashMap源码解析(包含红黑树)_后端_62

\

2.2> 支撑子域

如果某个限界上下文对应着业务的某些重要方面,但却不是核心,那么它便是一个支撑子域。创建支撑子域的原因在于它们专注于业务的某个方面。

万字长文之HashMap源码解析(包含红黑树)_后端_63

\

2.3> 通用子域

如果一个子域被用于整个业务系统,那么这个子域便是通用子域。

万字长文之HashMap源码解析(包含红黑树)_后端_64

\

三、限界上下文

运用限界上下文(BoundedContext)的战略设计模式来分离领域模型。一个领域被分为若干个子域,领域模型在限界上下文中完成开发

在实施DDD的时候,我们要保证每一个术语应该仅表示一种领域概念,即:将用到的每一个术语进行限界划分。这种基于语言层面上的上下文边界划分,也是实现DDD的关键。如下所示,同样的“售卖”一词在不同的上下文中含义都是不一样的:

万字长文之HashMap源码解析(包含红黑树)_红黑树_65

\

限界上下文是一个显式的边界,领域模型便存在于这个边界之内。领域模型把通用语言表达成软件模型。创建边界的原因在于,每个模型概念,包括它的属性和操作,在边界内都具有特殊的含义。

万字长文之HashMap源码解析(包含红黑树)_数组_66

\

上面我们介绍限界上下文的时候,一直再提边界的问题,边界真的这么重要?没有边界,不是一样不影响我们项目的开发迭代吗?  其实不然,在我们试图创建一个“大而全”的软件模型的时候,要使所有人都对某个概念的定义达成一致几乎不可能。因此,最好的方法是去正视这种不同,然后使用限界上下文对领域模型进行分离。可以举个例子,在广场上有一群人要商量去哪里,大家各抒己见,无法达成一致。

万字长文之HashMap源码解析(包含红黑树)_后端_67

\

那怎么办呢?大家谁都不让步。分析原因,广场上的这群人,彼此之间又都是陌生的,那么陌生人也不会轻易的迁就对方,那么我们以家庭进行分组(建立边界),由一家人(即:在同一限界上下文中)内部去商量要去哪里,确定一致性建议。

万字长文之HashMap源码解析(包含红黑树)_红黑树_68

1\

由上面的例子我们可以看到,将不同的家庭分界开来,由于一家人是有血缘关系的,所以更容易达成一致。而在DDD中的这种“一家人”,就属于限界上下文,它把一个领域(类比:广场)划分为多个限界上下文,在限界上下文中进行领域建模,同时,这样更容易对某个概念的定义达成一致,建立专属这个限界上下文的领域语言。

四、战略设计的重要性

DDD是分为两大部分内容的:宏观上的——战略设计微观上的——战术设计。而在上面我们介绍的“领域”、“子域”和“限界上下文”其实都属于战略设计。那么在实施DDD的过程中,我们也需要遵守先执行战略设计,再执行战术设计的方式。那么,战略设计为什么这么重要呢?

其实一提到战略和战术,大部分同学的第一反应应该是在战场上高频出现的词汇,那么我们就以战场来做解释。下面是辽沈战役攻打锦州的作战部署图:

万字长文之HashMap源码解析(包含红黑树)_数组_69

\

从上面的图中我们可以看到,分为了“2C”、“7D”、“8D”、“8C”等作战部队,并且针对每个部队都标注着要攻打的位置和路线。在这张图里,并没有标注用什么枪、用什么炮、用什么手榴弹、多少医护人员……也就是说,不包含打仗的具体细节(战术模式),只在宏观上(战略模式)划分的队伍和攻打方向。如果没有这个宏观的作战图,那么部队将会一盘散沙,各自为战,没有任何配合可言了。这个跟我们DDD中的战略模式是类似的。如下是对应关系:

  • 领域——>攻打锦城
  • 子域——>部队
  • 核心域——>主力部队,比如:8D
  • 支撑域——>其他非主力部队
  • 通用域——>通信部队,医疗部队,炊事班
  • 限界上下文——>某部队负责攻打的区域

通过上面针对战争的例子来看,战略的重要性远不亚于战术,就像人们常常说的那句话—— “方向不对,努力白废!” ,而战略设计就是DDD中的方向。

五、问题空间&解决方案空间

领域中还同时存在问题空间解决方案空间。它们的含义如下所示:

万字长文之HashMap源码解析(包含红黑树)_红黑树_70

\

软件开发过程,本质上可以看作是问题空间到解决方案空间的一个映射转化。

5.1> 问题空间

在实际项目中,我们可以针对如下问题,对问题空间进行评估

  • 这个核心域的名字是什么?目标是什么?包含哪些概念?
  • 支撑子域和通用子域是什么?
  • 如何安排项目人员?能否组件一只合适的团队?

5.2> 解决方案空间

解决方案空间在很大程度上受到现有系统和技术的影响。在实际项目中,我们可以针对如下问题,对解决方案空间进行评估

  • 有哪些软件资产是存在的?是否可以重用?是否需要创建?是否可以从别的地方获得到?
  • 这些资产是如何集成起来的?需要如何集成?
  • 假设资产都已经ok了,我们还需要做什么?
  • 核心域和支撑项目的成功概率是多少?会不会由于其中一个的失败而导致全盘皆输? 在哪些地方我们使用了完全不同的术语?
  • 限界上下文之间在哪些地方存在概念上的重叠?这些重叠的概念在不同的限界上下文之间是如何映射和翻译的?
  • 哪些限界上下文包含了核心域中的概念,其中使用了哪些[Evans]中的战术模式?

更多DDD趣文,欢迎大家关注公众号“爪哇缪斯”(^o^)/~ 「干货分享,每周更新」

标签:那么,HashMap,链表,插入,源码,数组,红黑树,table,节点
From: https://blog.51cto.com/u_15003301/6330447

相关文章

  • (二)Spring源码解析:默认标签解析
    一、概述还记得我们在上一讲末尾提到的关于默认标签解析和自定义标签解析吧。本讲就来针对默认标签解析进行讲解。为了便于衔接上一讲的内容,我们将源码部分粘贴出来:从上图中的源码中,我们可以看出默认标签的解析是在parseDefaultElement(ele,delegate)方法中实现的。我们来看一下这......
  • (三)Spring源码解析:自定义标签解析
    一、使用示例步骤1:创建User实体步骤2:定义一个XSD文件描述组件内容步骤3:创建BeanDefinitionParser接口的实现类,用来解析XSD文件中的定义和组件定义。步骤4:创建NamespaceHandlerSupport实现类,目的是将组件注册到Spring容器中。步骤5:编写spring.handlers和spring.schemas文件,默认位置......
  • LLvm 源码结构及测试基础
    LLvm源码结构及测试基础https://www.cnblogs.com/ainima/archive/2013/02/27/6331983.htmlhttps://www.cnblogs.com/ainima/archive/2013/02/27/6331985.htmlhttps://www.cnblogs.com/wujianming-110117/p/17128814.html......
  • Abp Vnext 动态(静态)API客户端源码解析
    根据以往的经验,通过接口远程调用服务的原理大致如下:服务端:根据接口定义方法的签名生成路由,并暴露Api。客户端:根据接口定义方法的签名生成请求,通过HTTPClient调用。这种经验可以用来理解ABPVNext自动API的方式,但如果不使用自动API并且控制器定义了路由的情况下,远程调用的路......
  • 为什么 HashMap 会死循环?
    HashMap死循环发生在JDK1.8之前的版本中,它是指在并发环境下,因为多个线程同时进行put操作,导致链表形成环形数据结构,一旦形成环形数据结构,在get(key)的时候就会产生死循环。如下图所示:死循环原因HashMap导致死循环的原因是由以下条件共同导致的:HashMap使用头插法进......
  • drf——反序列化校验源码(了解)、断言、drf之请求和响应、视图之两个视图基类
    1.模块与包#模块与包 模块:一个py文件被别的py文件导入使用,这个py文件称之为模块,运行的这个py文件称之为脚本文件包:一个文件夹下有__init__.py#模块与包的导入问题'''1.导入模块有相对导入和绝对导入,绝对导入的路径是从环境变量开始的2.导入任何模块,如果......
  • 基于JAVA的springboot+vue“智慧食堂”设计与实现,食堂管理系统,附源码+数据库+lw文档+P
    1、项目介绍本系统的用户可分为用户模块和管理员模块两大界面组成。一个界面用于管理员登录,管理员可以管理系统内所有功能,主要有首页,个人中心,用户管理,菜品分类管理,菜品信息管理,留言板管理,系统管理,订单管理等功能;另一界面用于用户登录,用户进入系统可以实现首页,菜品信息,留言板,个人......
  • FreeSWITCH1.10.5源码编译(CentOS 7.10)
    一、安装sofia-sipcd/usr/local/src/freeswitch-1.10.5.-releasegitclonehttps://github.com/freeswitch/sofia-sip.gitcdsofia-sip./configuremakemakeinstallldconfig二、安装spandspcd/usr/local/src/freeswitch-1.10.5.-releasegitclonehttps://github.......
  • ConcurrentHashMap 相关
    为什么ConcurrentHashMap要放弃分段锁?答:1、因为在JDK7中 Segment 继承了重入锁ReentrantLock。在每个 Segment 在增长的时候,这时候锁的粒度也会在不断的增长。每个锁控制的是一段,当分段很多,并且加锁的分段不连续的时候,内存空间的浪费比较严重。在并发操作中,因为分段锁的......
  • 【DSP视频教程】DSP视频教程第12期:TI开源分享IQmath DSP源码,适用于所有Cortex-M内核,本
    视频教程汇总帖:https://www.armbbs.cn/forum.php?mod=viewthread&tid=110519 今年TI推出MSPM0系列产品配套的SDK软件包里面将此库开源了,之前的时候也移植过IQmatb,不过只有库版本,这次竟然开源了,确实是不可多得的好资源。这个是定点库,非常适合用于M0,  M0+,  M3和不带硬件F......