JDK8中的HashMap

前置知识 —— 红黑树

红黑树,本质上是一棵不完全平衡二叉树。通过各个节点满足其五大特性的方式来实现相对平衡,达到高效率读写的目的。其插入、删除、查找等操作的时间复杂度都为logN(其中一些操作是近似logN)

图1 红黑树

红黑树的五大特性

  1. 每个节点不是黑色就是红色
  2. 根节点都是黑色的
  3. 所有叶子都是黑色的NIL节点(这点需要注意的是,红黑树不像普通的二叉树那样,它在普通二叉树的基础上加了一层NIL节点,如图1所示)
  4. 不可能存在连在一起的红色节点(黑色可以)
  5. 每个节点,从该节点到其可达的叶子节点的所有路径,都包含相同数目的黑色节点

红黑树的前三条特性都容易满足,重点是最后两条。另外需要注意的是:红黑树插入的新节点都是红色的,只有经过变换才有可能发生变化。

红黑树的三大变换操作

为了满足红黑树的五大特性,在树的节点发生变动时,就有必要对这棵树做一些操作来满足其特性,主要有三大变换操作:

  1. 改变颜色(红->黑;黑->红)
  2. 左旋
  3. 右旋

图2 左旋图3 右旋

红黑树的五大变换规则

至于需要如何变换,满足下面五大规则

  1. 当前节点是红色,父节点也是红色,叔叔也是红色 ——> 父节点跟叔叔结点都变为黑色,再把爷爷变为红色,最后将指针从当前结点指向爷爷结点,继续根据五大变换规则判断
  2. 父节点是爷爷节点的左子树时:
    1. 当前节点是红色,父节点是红色,叔叔是黑色,且当前节点是右子树时 (左右情况) ——> 以父节点为基础进行左旋,最后将指针从当前结点指向父结点,也就是左旋动图中的E点,继续根据五大变换规则判断
    2. 当前结点是红色,父节点是红色,叔叔是黑色,且当前结点是左子树时 (左左情况) ——> 首先将父结点变为黑色,爷爷变为红色,最后以爷爷结点为基础进行右旋
  3. 父节点是爷爷节点的右子树时:
    1. 当前节点是红色,父节点是红色,叔叔是黑色,且当前节点是左子树时 (右左情况) ——>以父节点为基础进行右旋,最后将指针从当前节点指向父节点,也就是右旋动图中的S点,继续根据五大变换规则判断
    2. 当前节点是红色,父节点是红色,叔叔是黑色,且当前节点是右子树时 (右右情况) ——>首先将父节点变为黑色,爷爷节点变为红色,最后以爷爷节点为基础进行左旋

具体操作实现分析——以JDK8中HashMap实现的红黑树为例

左旋

static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
                                              TreeNode<K,V> p) { // 左旋
    TreeNode<K,V> r, pp, rl;
    /*
     * 左旋前提条件:
     * 1.判断基础节点是否为空,为空不做任何处理
     * 2.将基础节点的右节点赋值给r,并判断右节点是否为空,因为要进行左旋,右节点是不能为空的
     */
    if (p != null && (r = p.right) != null) {
        // 这里做了两件事:1.将基础节点p的右子节点的左子节点rl转移成p的右子节点,如图4所示;
        if ((rl = p.right = r.left) != null)
            rl.parent = p; // 2.如果rl不为null,则将rl的父节点指向基础节点p,如图5所示;
        /*
         * 这个判断主要做了两件事:
         * 1.判断p的父亲是否是空(防止传递进来的一些非法的参数)
         * 2.将p的父节点改为r的父节点,如图6所示
         */
        if ((pp = r.parent = p.parent) == null)
            // 如果p的父节点是null,那么原来p就是根节点,那么左旋后,r就是根节点了
            (root = r).red = false; // 更新根节点颜色为黑色
        else if (pp.left == p) // 如果p的父节点pp不为空,再判断p是pp的左节点还是右节点
            pp.left = r; // 左节点,更新pp的左节点为r,如图7所示
        else
            pp.right = r; // 右节点,更新pp的右节点为r
        // 更新p的父节点为r,r的左节点为p,如图8所示
        r.left = p;
        p.parent = r;
    }
    return root;
}

图4

图5

图6

图7

至此,左旋就完成了,可以参照左旋动态图与上面的步骤图来理解。

右旋

理解了左旋后,右旋自然也不难理解了。此处就不一一画步骤图进行理解了,可以通过代码注释与右旋动图进行理解。

static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,
                                       TreeNode<K,V> p) { // 右旋
    TreeNode<K,V> l, pp, lr;
    /**
     * 右旋前提条件
     * 1.基础节点p不为空
     * 2.p的左子节点l不为空,因为要进行右旋,所以左子节点不能为空
     */
    if (p != null && (l = p.left) != null) {
        // 这里做了两件事:1.将p的左子节点由原来的l变更为l的右子节点lr,随后判断lr是否为空
        if ((lr = p.left = l.right) != null)
            lr.parent = p; // 2.lr不为空,则将lr的父节点由原来的l变更为p
        // 这部分逻辑与左旋类似:将l的父节点由p变更为p的父节点pp,随后判断pp是否为空
        if ((pp = l.parent = p.parent) == null)
            (root = l).red = false;// pp为空,说明原来p就是root,左旋后就应该是l为root
        else if (pp.right == p) // pp不为空,看看p是pp的左子节点还是右子节点
            pp.right = l; // 右子节点,将pp的右子节点由原来的p变更为l
        else
            pp.left = l; // 左子节点,将pp的左子节点由原来的p变更为l
        // 将l的右节点变更为p,p的父节点变更为l
        l.right = p;
        p.parent = l;
    }
    return root;
}

JDK8中的HashMap

由于jdk8版本的HashMap与jdk7版本的HashMap、ConcurrentHashMap发生了很大改动(例如jdk8中加入了红黑树来提高HashMap的效率等),因此将两个版本的HashMap分开来分析。此处主要分析jdk8的HashMap。

在jdk8中,HashMap的数据结构除了数组加链表外,还新引入了红黑树进行存储。在特定情况下,链表会树化为红黑树,红黑树也会链化为链表,具体情况下面会进行分析。

图8 JDK8中HashMap大致数据结构

创建HashMap

在JDK8中,HashMap一共有三种构造方法:

public HashMap() {
    // 负载因子为默认,其余属性也为默认
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0) // 数组大小边界判断
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    // 如果给定的数组大小大于最大值,则直接赋值为最大值
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor)) // 负载因子边界判断
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor; // 初始化负载因子
    // 获取刚好大于等于传递进来的数组大小的2次幂,并赋值给阈值
    this.threshold = tableSizeFor(initialCapacity);
}

通过构造函数的源代码可知,实际存储数据的数组在HashMap构造时并没有被初始化,那么它是在什么时候初始化的呢?这里不妨提前告知答案:存储元素的数组是在第一次put元素时进行数组初始化的

存储数据(put方法、putVal方法)

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

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) // 如果数组为空,或者数组长度为0
        n = (tab = resize()).length; // 创建新数组(数组初始化),n为数组长度
    // 1.计算当前key对应的数组index,尝试获取对应index的数据,赋值给p,看看是否为空
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null); // 为空则直接创建新节点,并放进数组对应的index中
    else { // p不为空
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k)))) // 判断当前key是否与p(第一个节点)的key一致
            e = p; // 一致则将p赋值给变量e,等待后续使用
        // 如果不等于p,则判断这个index里存储的是不是一棵红黑树
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 是红黑树,则用红黑树的插入方式
        // 不等于p,数组内存储的也不是红黑树,那么就是链表了,按照链表来进行插入
        else { 
            for (int binCount = 0; ; ++binCount) { // binCount是用来统计当前这个链表里有多少个元素了,用来判断是否需要树化为红黑树
                // 判断当前节点的下一个节点是否为空,如果为空说明已经到链表尾部了,但还是没找到相同key的Node节点
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null); // 创建一个新的Node节点用来存储新数据,并插入到链表中【尾插法】
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st // 判断没插入元素前的链表个数是否已经大于等于8了
                        treeifyBin(tab, hash); // 如果大于等于8,则将链表进行树化
                    break;
                }
                if (e.hash == hash && // 如果在链表中找到了相同的key,则退出循环。此时e就是这个节点了,等待后续操作
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e; // 准备遍历下一个节点
            }
        }
        if (e != null) { // existing mapping for key // 看看是否找到了相同key的Node节点
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null) // 找到了,看看是否需要进行替换(onlyIfAbsent是否为false或者之前的值是否为空)
                e.value = value;
            afterNodeAccess(e); // 此方法在HashMap中没什么用,在LinkedHashMap中才会被使用到
            return oldValue; // 返回旧值给调用方
        }
    }
    ++modCount;
    if (++size > threshold) // 更新HashMap元素个数,并判断是否已经超过阈值
        resize(); // 超过阈值则需要进行扩容
    afterNodeInsertion(evict); // 此方法在HashMap中没什么用,在LinkedHashMap中才会被使用到
    return null;
}

该方法主要做了以下几件事情:

  1. 判断数组是否为空,为空则初始化数组(数组初始化的地方
  2. 计算当前key对应的数组index,并获取对应index内的对象,判断是否为空
    1. 为空则直接创建一个新的Node节点用来存储需要插入的数据,并将Node节点直接存到对应数组的index中【便捷插入逻辑
    2. index内存储了对象,则分情况来继续插入【数据插入逻辑
      1. 先看看index内对象的key是否与当前需要插入的key相同,如果相同则直接将该对象赋值给e,等待后续操作
      2. 如果首节点的key不等于需要插入的key,并且首节点对象的类型属于TreeNode,说明此index内存储的是一棵红黑树,则采用红黑树的插入方式进行数据的存储(具体逻辑可以看后续分析)
      3. 如果首节点的key不等于需要插入的key,并且首节点对象的类型不属于TreeNode,说明此index内存储的是链表,则采用链表的插入方式进行数据的存储(具体逻辑可以看下面分析)
    3. 看看经过上面的所有操作后,是否找到了相同key的Node节点e。如果找到了,则看看是否需要进行替换,不需要就直接将旧值直接返回给调用方【数据替换逻辑
    4. 最后更新HashMap的元素大小,并判断是否超过阈值,超过阈值就需要进行扩容【判断是否需要扩容

链表插入逻辑如下:

  1. 首先遍历链表查找是否有与key相等的Node节点,如果有的话将此节点赋值给e,随后退出循环
  2. 如果遍历完链表都没找到相同的Node节点,则新建一个Node节点用来存储数据,并将其插入链表尾部(尾插法,与jdk7不同,jdk7是头插法
  3. 判断没有插入新节点前链表节点个数是否大于等于8了,如果大于等于8,则将链表树化为红黑树(具体逻辑后续分析)
    1. 换句话说,HashMap树化其实是发生在元素插入时的,且最开始树化的时候,链表的总长度(包括新插入的元素)为9
    2. 至于为什么要TREEIFY_THRESHOLD - 1,是因为binCount是从0开始统计的
  4. 插入完新节点后则跳出循环

HashMap中的红黑树

在分析HashMap采用红黑树的插入方式进行插入前,先分析一下HashMap是如何实现红黑树的。

HashMap红黑树大致实现与变量:

// 红黑树节点类,继承自LinkedHashMap.Entry,继而继承HashMap.Node
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links 父节点
    TreeNode<K,V> left; // 左子树
    TreeNode<K,V> right; // 右子树
    TreeNode<K,V> prev;    // needed to unlink next upon deletion 隐藏双链表前驱节点
    boolean red; // 当前节点颜色(是否为红色,true为红,false为黑)
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
    // ………………
}

static class Entry<K,V> extends HashMap.Node<K,V> {
    Entry<K,V> before, after;
    Entry(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next; // 隐藏双链表后继节点
    // …………
}

通过上面的源代码可以知晓:在jdk8中,红黑树不仅仅是一棵红黑树,也是一个双链表,只不过双链表是隐藏在红黑树中的。

往红黑树中存储数据(putTreeVal方法)

在put方法中,需要插入新节点时会判断需要插入的是一棵红黑树还是链表。如果是红黑树则会选择红黑树的插入方式,也就是调用putTreeVal方法

final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab, // map:HashMap对象;tab:存储数据的数组;h:hashcode;
                                       int h, K k, V v) { // k:key;v:value
    Class<?> kc = null; // key class,用来后面比较key的大小使用(可能会用到)
    boolean searched = false;
    TreeNode<K,V> root = (parent != null) ? root() : this; // 这里是获取当前这棵红黑树的root节点,
    for (TreeNode<K,V> p = root;;) { // 遍历整棵红黑树,尝试找到与要插入的key一致的Node节点,如果找不到则寻找Node节点插入的位置
        int dir, ph; K pk;
        if ((ph = p.hash) > h) // 如果需要插入的key的hash值小于当前节点的hash值,则向左继续寻找
            dir = -1;
        else if (ph < h) // 如果需要插入的key的hash值大于当前节点的hash值,则向右继续寻找
            dir = 1;
        else if ((pk = p.key) == k || (k != null && k.equals(pk))) // 如果两个hash值相同,首先判断key是否相等
            return p; // key相等,说明找到了相同的key的Node节点,直接返回给调用方,也就是putVal方法,再由putVal方法赋值给变量e进行后续操作(是否覆盖)
        else if ((kc == null && // 如果key不相同,但hash值相同,说明特殊情况,要用其他方案来决定大小
                  (kc = comparableClassFor(k)) == null) || // 尝试获取一下key的clazz对象:如果该clazz实现了Comparable接口就能获取到,否则获取到的是null
                 (dir = compareComparables(kc, k, pk)) == 0) { // 如果获取到了key的clazz对象,该类的compareTo方法来进行key之间的比较,看看是否能够比较成功
            if (!searched) { // 如果上面的方法都比较失败了,并且还没有再次搜索过的话,则再次尝试搜索相同key的节点
                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); // 如果上面的方法都不能比较成功,最后调用System.identityHashCode方法来比较两个key之间的大小
        }

        TreeNode<K,V> xp = p; // 暂时将当前遍历到的节点p赋值给xp
        // 1.根据上面比较出来的dir的结果,决定应该继续往哪里遍历:-1往左,1往右;同时判断需要遍历的方向的下一个节点是否为空,如果为空,说明需要插入的位置就是这里了
        if ((p = (dir <= 0) ? p.left : p.right) == null) {
            Node<K,V> xpn = xp.next;
            // 创建一个TreeNode节点,用来存储新数据;同时将新TreeNode节点的next设置为xp的next(维护隐藏双向链表)
            TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
            if (dir <= 0) // dir小于等于0,向左插入
                xp.left = x;
            else // 否则向右插入
                xp.right = x;
            xp.next = x; // 维护一下双向链表,并且设置新节点的父节点为xp
            x.parent = x.prev = xp;
            if (xpn != null)
                ((TreeNode<K,V>)xpn).prev = x; // 维护一下双向链表
            moveRootToFront(tab, balanceInsertion(root, x)); // 平衡一下红黑树,并将平衡后的红黑树的root节点移动到隐藏双向链表的最顶端
            return null;
        }
    }
}

该方法主要做了下面两件事情:

  1. 寻找需要插入的key的位置,在遍历的过程中看看是否有相同key的TreeNode节点,如果有则直接返回
  2. 在找到了插入的位置后,则创建一个TreeNode节点并插入进红黑树中,在做好红黑树平衡的同时也维护好隐藏的双链表

下面具体分析一下

寻找key的插入位置

boolean searched = false;

// 以下代码是在for循环中的
int dir, ph; K pk;
if ((ph = p.hash) > h) // 如果需要插入的key的hash值小于当前节点的hash值,则向左继续寻找
    dir = -1;
else if (ph < h) // 如果需要插入的key的hash值大于当前节点的hash值,则向右继续寻找
    dir = 1;
else if ((pk = p.key) == k || (k != null && k.equals(pk))) // 如果两个hash值相同,首先判断key是否相等
    return p; // key相等,说明找到了相同的key的Node节点,直接返回给调用方,也就是putVal方法,再由putVal方法赋值给变量e进行后续操作(是否覆盖)
else if ((kc == null && // 如果key不相同,但hash值相同,说明特殊情况,要用其他方案来决定大小
          (kc = comparableClassFor(k)) == null) || // 尝试获取一下key的clazz对象:如果该clazz实现了Comparable接口就能获取到,否则获取到的是null
         (dir = compareComparables(kc, k, pk)) == 0) { // 如果获取到了key的clazz对象,该类的compareTo方法来进行key之间的比较,看看是否能够比较成功
    if (!searched) { // 如果上面的方法都比较失败了,并且还没有再次搜索过的话,则再次尝试搜索相同key的节点
        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); // 如果上面的方法都不能比较成功,最后调用System.identityHashCode方法来比较两个key之间的大小
}

在向红黑树插入节点时,首先就需要找到需要插入的位置。说直白点就是要比较需要插入的节点与当前遍历到的节点的大小,从而决定是向左继续寻找还是向右寻找。

HashMap主要采用了三种方式来判断节点之间的大小:

  1. 直接比较两个节点的hash值大小
  2. 看看当前key的类是否实现了Comparable接口。如果实现了就调用compareTo方法来进行比较节点间的大小
  3. 通过调用System.identityHashCode方法来比较两个key之间的大小

具体逻辑如下:

  • 先直接比较两个节点的hash值大小
    • 需要插入的节点的hash值小于当前节点的hash值,说明需要向左继续寻找。dir赋值为-1。
    • 需要插入的节点的hash值大于当前节点的hash值,说明需要向右继续寻找。dir赋值为1。
    • 如果两个节点的hash值相同,看看key是否相同,是的话直接返回当前节点。
  • 如果hash值相同,并且key不相同。则看看当前key的类是否实现了Comparable接口。如果实现了就调用compareTo方法来进行比较节点间的大小
    • 类似的,方法返回-1,说明需要向左继续寻找
    • 方法返回1,说明需要继续向右寻找
    • 方法返回0,说明通过Comparable接口进行比较得出的结果还是相同的
  • 如果通过<font style="color:#DF2A3F;">Comparable</font>接口进行比较得出的结果还是相同的,或者key的类没有实现<font style="color:#DF2A3F;">Comparable</font>接口。那就需要考虑一下是不是真的有key相同的节点存在,只是没找到。再次查找一下是否有相同的节点(注意:这里的再次查找只会找一次,如果再次查找后都没找到相同的节点,后续就不会进行再次查找了。当然遍历时的判断相同还是会有的
  • 再次查找也还是找不到key相同的节点,最后调用System.identityHashCode来判断大小,并以其返回结果作为最后的结果(这里最后的结果的意思是上面的几种方法都比较失败的情况下,如果上面有任何一个方法比较成功了,都不会走到该分支来

插入节点

// 以下代码是在for循环中的
TreeNode<K,V> xp = p; // 暂时将当前遍历到的节点p赋值给xp
// 1.根据上面比较出来的dir的结果,决定应该继续往哪里遍历:-1往左,1往右;同时判断需要遍历的方向的下一个节点是否为空,如果为空,说明需要插入的位置就是这里了
if ((p = (dir <= 0) ? p.left : p.right) == null) {
    Node<K,V> xpn = xp.next;
    // 创建一个TreeNode节点,用来存储新数据;同时将新TreeNode节点的next设置为xp的next(维护隐藏双向链表)
    TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
    if (dir <= 0) // dir小于等于0,向左插入
        xp.left = x;
    else // 否则向右插入
        xp.right = x;
    xp.next = x; // 维护一下双向链表,并且设置新节点的父节点为xp
    x.parent = x.prev = xp;
    if (xpn != null)
        ((TreeNode<K,V>)xpn).prev = x; // 维护一下双向链表
    moveRootToFront(tab, balanceInsertion(root, x)); // 平衡一下红黑树,并将平衡后的红黑树的root节点移动到隐藏双向链表的最顶端
    return null;
}

根据比较出来的dir,能够决定继续向哪个方向遍历寻找。通过判断需要遍历的方向的下一个节点是否为空,,如果为空,说明需要插入的位置就是这里了,此时就进入插入节点的逻辑。

  1. 创建一个TreeNode节点x,用来存储新数据;同时将新TreeNode节点的next设置为xp的next(维护x到xpn的隐藏双向链表)
  2. 判断dir的值,用来决定插入到当前节点的左子节点还是右子节点
  3. 维护xp与x的双向链表,同时设置x的父节点为xp(xp在最开始就设置为了遍历到的当前节点)
  4. 设置xpn的前驱为x
  5. 进行二叉树的平衡,同时将平衡后的root节点设置到双向链表的头部

平衡插入(balanceInsertion方法)

PutTreeVal方法创建了新节点并插入后,就会调用balanceInsertion方法重新检查一下插入节点后的树是否还满足红黑树的五大特性,如果不满足的话就需要进行调整。

/**
 * root 树的根节点
 * x 新插入的节点
 */
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
                                            TreeNode<K,V> x) { // 插入节点并平衡整棵树(平衡插入)
    x.red = true; // 新节点默认为红色
    for (TreeNode<K,V> xp, xpp, xppl, xppr;;) { // xp:当前节点的父节点;xpp:当前节点的爷爷节点;xppl:爷爷节点的左子节点;xppr:爷爷节点的右子节点
        if ((xp = x.parent) == null) { // 当前节点的父节点是否为空,为空说明当前节点就是root节点
            x.red = false; // 设置root节点的颜色为黑色
            return x; // 直接返回
        }
        else if (!xp.red || (xpp = xp.parent) == null) // 当前节点的父节点是否是黑色,或者当前节点的父节点是不是root
            return root; // 是的话不用做任何操作,直接返回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); // 这里做了两件事:1.以父节点xp为基础进行左旋;2.将表示当前节点的指针指向父节点xp
                    xpp = (xp = x.parent) == null ? null : xp.parent; // 这里相当于xp与x进行了一个交换,xp变更为了原来的x(难理解的画可以画图理解),xpp还是原来的xpp
                }
                if (xp != null) { // xp是否为空
                    xp.red = false; // 不为空则将xp的颜色变更为黑色
                    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); // 这里做了两件事:1.以父节点xp为基础进行右旋;2.将表示当前节点的指针指向父节点xp
                    xpp = (xp = x.parent) == null ? null : xp.parent; // 这里相当于xp与x进行了一个交换,xp变更为了原来的x(难理解的画可以画图理解),xpp还是原来的xpp
                }
                if (xp != null) { // xp是否为空
                    xp.red = false; // 不为空将xp的颜色变更为黑色
                    if (xpp != null) { // 判断爷爷节点是否为空
                        xpp.red = true; // 不为空则将爷爷节点变更为红色
                        root = rotateLeft(root, xpp); // 再以爷爷节点为基础进行左旋
                    }
                }
            }
        }
    }
}

该方法除去一些基本操作,例如红黑树节点颜色初始化等,可以分为三大块来分析

简单情况

if ((xp = x.parent) == null) { // 当前节点的父节点是否为空,为空说明当前节点就是root节点
        x.red = false; // 设置root节点的颜色为黑色
        return x; // 直接返回
    }
    else if (!xp.red || (xpp = xp.parent) == null) // 当前节点的父节点是否是黑色,或者当前节点的父节点是不是root
        return root; // 是的话不用做任何操作,直接返回root
    // …………
}

简单情况主要分为两种:

  1. 新插入的节点(当前节点)就是这颗树的根节点
  2. 当前节点为黑色,或者当前节点的父节点就是根节点

具体的处理逻辑见代码注释

父节点是爷爷节点的左子节点

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); // 这里做了两件事:1.以父节点xp为基础进行左旋;2.将表示当前节点的指针指向父节点xp
            xpp = (xp = x.parent) == null ? null : xp.parent; // 这里相当于xp与x进行了一个交换,xp变更为了原来的x(难理解的画可以画图理解),xpp还是原来的xpp
        }
        if (xp != null) { // xp是否为空
            xp.red = false; // 不为空则将xp的颜色变更为黑色
            if (xpp != null) { // 判断爷爷节点是否为空
                xpp.red = true; // 不为空将爷爷节点变为红色
                root = rotateRight(root, xpp); // 再以爷爷节点为基础进行右旋
            }
        }
    }
}

该大情况下面又可以分为具体的三个小情况(说是三个,其实第一个情况与下一个大情况是共通的,实际有大区别的就两个)

  • 叔叔节点与父亲节点都是红色的 -> 此时将父亲与叔叔都变为黑色,然后将爷爷变为红色,最后将指针指向爷爷节点根据变换规则继续判断【红黑树五大变换规则之1
  • 父亲节点是红色的,叔叔节点是黑色的,此时根据当前节点的位置进行具体操作
    • 当前节点x是右子树(即左右情况),此时首先将当前节点的指针指向xp,随后以父节点xp为基础进行左旋,左旋完毕后同步更新xp,xpp。这里比较难理解的话可以通过图片示例进行理解,如图9、10、11所示【红黑树五大变换规则之2-a

图9 未进行任何操作前

图10 左旋前,变量x更改后

图11 左旋后

- 当前节点x是左子树(即左左情况),并且xp不为空 -> 首先将xp的颜色变更为黑色,随后判断爷爷节点xpp是否为空,不为空则将爷爷节点颜色变更为红色,最后再以爷爷节点为基础进行右旋。如图12,13所示【<font style="color:#117CEE;">红黑树五大变换规则之2-b</font>】

图12 右旋前

图13 右旋后

父节点是爷爷节点的右子节点

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); // 这里做了两件事:1.以父节点xp为基础进行右旋;2.将表示当前节点的指针指向父节点xp
            xpp = (xp = x.parent) == null ? null : xp.parent; // 这里相当于xp与x进行了一个交换,xp变更为了原来的x(难理解的画可以画图理解),xpp还是原来的xpp
        }
        if (xp != null) { // xp是否为空
            xp.red = false; // 不为空将xp的颜色变更为黑色
            if (xpp != null) { // 判断爷爷节点是否为空
                xpp.red = true; // 不为空则将爷爷节点变更为红色
                root = rotateLeft(root, xpp); // 再以爷爷节点为基础进行左旋
            }
        }
    }
}

该大情况下可以分为具体的三个小情况(第一个小情况与上一个大情况中的第一个小情况相同):

  • 父节点与叔叔节点都是红色的 -> 将父节点与叔叔节点颜色变更为黑色,然后将爷爷节点的颜色变为红色,最后将当前节点的指针指向爷爷节点根据五大变换规则继续判断【红黑树五大变换规则之1
  • 父亲节点是红色的,叔叔节点是黑色的,此时根据当前节点的位置进行具体操作
    • 当前节点x是左子树(即右左情况),此时首先将当前节点的指针指向父节点xp,随后以父节点xp为基础进行右旋,右旋完毕后同步更新xp,xpp。这里比较难理解的话可以通过图片示例进行理解,如图14,15、16所示【红黑树五大变换规则之3-a

图14 未进行任何操作前

图15 右旋前,变量x更改后

图16 右旋后

- 当前节点x是右子树(即右右情况),并且父节点xp不为空 -> 首先将父节点xp的颜色变为黑色,随后判断爷爷节点xpp是否为空,不为空则将爷爷节点变为红色,最后再以爷爷节点为基础进行左旋。如图17、18所示【<font style="color:#117CEE;">红黑树五大变换规则之3-b</font>】

图17 左旋前

图18 左旋后

链表树化(treeifyBin方法、treeify方法)

treeifyBin

当一个全新的节点准备插入到某个链表后,会判断当前链表没有插入新元素前的元素个数是否大于等于8,如果是的话,就会调用treeifyBin方法尝试将链表树化。

// 链表树化
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 判断当前数组是否为空,为空则进行初始化;不为空则判断数组长度是否小于64,如果小于则进行扩容。
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize(); // 由此可知:不是当某个链表元素长度大于等于8了就一定会树化的,它还要看存储元素的数组长度是否超过了一定的值(即64),超过了才会真正树化
    // 根据hash值计算下标,随后获取下标对应的值(链表头节点),判断它是否为空,不为空则进行树化
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        // 下面的循环是将链表中的Node节点转换成TreeNode节点,同时构建成一个双链表,方便后续的操作
        TreeNode<K,V> hd = null, tl = null; // hd => head; tl => tail;
        do {
            TreeNode<K,V> p = replacementTreeNode(e, null); // 将Node节点替换成TreeNode节点
            if (tl == null)
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        // 暂时将构建好的双链表的头节点存储至数组对应的index中,并判断双链表头部是否为空,不为空则进行真正的树化
        if ((tab[index] = hd) != null)
            hd.treeify(tab); // 真正树化
    }
}

通过分析treeifyBin方法的源码可以直到一些细节:

  1. 链表树化并不是只有链表元素大于等于8个后调用treeifyBin就能够树化成功的。它还需要判断一个重要条件:当前数组长度是否大于等于64。只有满足了数组长度这个条件它才会真正进行树化,否则都会采取扩容的方式进一步分散链表中的元素。
  2. treeifyBin方法中其实并没有实际树化,而是先将单链表构造成一个双链表,方便后续的操作。
  3. 在构建完双链表后,并确认双链表的head不为空,才会调用head的treeify方法进行树化(treeify方法实际上是TreeNode类里的方法),树化的基础就是构建出来的TreeNode双链表。

treeify

final void treeify(Node<K,V>[] tab) { // 树化的过程可以理解为一个个节点插入红黑树的过程
    TreeNode<K,V> root = null; // root默认为null
    for (TreeNode<K,V> x = this, next; x != null; x = next) { // 循环整个双链表
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null; // 初始化当前节点的左右子树都为null
        if (root == null) { // 如果root为空,那么第一个节点就成为root
            x.parent = null;
            x.red = false; // root节点的颜色始终为黑色
            root = x;
        }
        else { // root节点不为空
            K k = x.key; // k为插入节点的key
            int h = x.hash; // h为插入节点的hash值
            Class<?> kc = null; // kc用来存储key的clazz对象,初始为空
            for (TreeNode<K,V> p = root;;) { // 遍历整棵树,寻找插入的位置
                int dir, ph;
                K pk = p.key; // pk为当前树节点的key
                if ((ph = p.hash) > h) // ph为当前树节点是hash值,比较ph与h的大小,h小于ph,继续向左寻找插入位置
                    dir = -1;
                else if (ph < h) // h大于ph,继续向右寻找插入位置
                    dir = 1;
                // 通过单纯比较key的hash值无法得出结果,则看看key的类是否实现了Comparable接口,实现了就使用compareTo方法再次比较
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    // 如果compareTo比较不出来,或者key的类没有实现Comparable接口,那么就直接调用System.identityHashCode方法进行比较
                    dir = tieBreakOrder(k, pk);

                TreeNode<K,V> xp = p; // xp用来存储x的parent节点
                // 根据dir的值继续向下获取节点,如果获取到的节点为null,说明已经找到节点x插入的位置了,开始进行插入
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp; // x的parent节点
                    if (dir <= 0) // 根据dir决定具体插入左子节点还是右子节点
                        xp.left = x;
                    else
                        xp.right = x;
                    root = balanceInsertion(root, x); // 插入完毕后开始平衡红黑树
                    break;
                }
            }
        }
    }
    moveRootToFront(tab, root); // 树化完成,将root节点移动至双链表的头部
}

通过上面的源码,不难看出,所谓的树化其实就是把双链表中的节点进行插入到红黑树中。其中双链表的头节点作为初始的root节点。具体的插入方式与putTreeVal方法大同小异,唯一不同的地方就是树化的时候没有去寻找相同key的节点

HashMap扩容与数组初始化

在JDK1.8中,HashMap的扩容与数组初始化是由resize方法实现的,该方法主要做了两件事:

  1. 初始化/扩容数组
  2. 如果是扩容数组,则在数组扩容后将旧数组的数据转移至新数组

resize方法完整代码如下

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 新数组的阈值也为旧阈值的两倍
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr; // 看看是哪种初始化,如果已经存在阈值了,那么初始化数组的长度就是阈值的大小(有参构造new出来的HashMap)
    else {               // zero initial threshold signifies using defaults 什么参数都没有,说明是使用无参构造生成的HashMap,数组初始化参数都用默认的就好
        newCap = DEFAULT_INITIAL_CAPACITY; // 默认数组大小:16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 默认阈值 16 * 0.75
    }
    if (newThr == 0) { // 如果新阈值为0,则按照公式来进行计算。会出现0的情况主要有两个:1.移位导致newThr溢出了;2.走了(oldThr > 0)的分支,这个分支压根没去计算新阈值
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr; // 更新一下阈值
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 按照新的容量创建一个新数组
    table = newTab; // 更新一下table
    if (oldTab != null) { // 如果旧数组不为空(不是初始化),则进行元素转移
        for (int j = 0; j < oldCap; ++j) { // 遍历旧数组
            Node<K,V> e;
            if ((e = oldTab[j]) != null) { // 如果当前下标内存储了元素,准备转移了
                oldTab[j] = null; // help GC
                if (e.next == null) // 转移分三种情况:1.下标内只存了一个Node节点的,这种直接转移就好
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode) // 2.数组对应下标存储了一棵红黑树的,调用红黑树的转移(分割)方法进行转移
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order 数组对应下标存储了链表的,按链表的方式进行转移。转移大致思路为:将原链表分为两份(高位与低位),低位链表的index不变,高位的index变为index+oldCap
                    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) { // 如果当前节点的hash值与一下旧数组长度,等于0的说明在其hash值对应的二进制位上是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) { // 如果低位链表不为空,就将低位链表存储至新数组中对应的index(j)中
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) { // 如果高位链表不为空,就将高位链表存储至新数组的index+oldCap中。【因为数组扩容后,某个index下标内元素的存储位置只能是index或者index加扩容大小这两个位置】
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

下面进行拆分分析

数组初始化

第一次put数据到HashMap中,就会真正的调用resize方法进行数组的初始化

数组初始化首先就需要确定两个主要参数:数组大小阈值

部分代码如下

Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length; // 旧数组的长度。如果数组没有初始化则为0
int oldThr = threshold; // 旧数组的阈值
int newCap, newThr = 0;
// 如果旧数组的长度大于0,说明是扩容的情况
if (oldCap > 0) { 
    // ………………
}
// 看看是哪种初始化,如果已经存在阈值了,那么初始化数组的长度就是阈值的大小(有参构造new出来的HashMap)
else if (oldThr > 0) // initial capacity was placed in threshold
    newCap = oldThr; 
// 什么参数都没有,说明是使用无参构造生成的HashMap,数组初始化参数都用默认的就好
else {               // zero initial threshold signifies using defaults
    newCap = DEFAULT_INITIAL_CAPACITY; // 默认数组大小:16
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 默认阈值 16 * 0.75
}
// 如果新阈值为0,则按照公式来进行计算。会出现0的情况主要有两个:
// 1.移位导致newThr溢出了;
// 2.走了(oldThr > 0)的分支,这个分支压根没去计算新阈值
if (newThr == 0) {
    float ft = (float)newCap * loadFactor;
    newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
              (int)ft : Integer.MAX_VALUE);
}
threshold = newThr; // 更新一下阈值

注意点有下面三个:

  • 如果创建HashMap时使用的是有参构造,并且传入了数组大小与负载因子等参数。那么数组大小会暂时存放在<font style="color:#117CEE;">threshold</font>变量内,间接传入<font style="color:#117CEE;">resize</font>方法中进行初始化
  • 如果创建HashMap时使用的是无参构造,那么数组大小与阈值都会使用默认值
  • 在真正初始化数组前,还会判断一下阈值是否为0,为0则重新按照公式进行计算。在初始化的场景中,会出现newThr == 0的情况只有一个:进入了(oldThr > 0)的分支,这个分支压根没去计算新阈值

在确定了需要创建的数组长度,并且更新完阈值后,就会开始真正创建用于存放数据的数组,创建完毕后返回给上层调用方法,至此数组初始化完毕。

HashMap扩容

HashMap主要有两个位置会触发扩容:

  1. put方法中,存储完新元素后会检查当前HashMap中已有的元素个数是否超出了阈值,超出了就会调用resize方法进行扩容
  2. 在尝试将链表树化为红黑树时,会判断当前数组的长度是否超过64,没有超过的也会调用resize方法进行扩容,用以分散链表的元素

而HashMap想要扩容,无外乎两件事:计算新数组大小与新阈值、转移元素

下面具体分析

计算新数组大小与新阈值

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
    • 新阈值大小为旧阈值大小 * 2【注意:此处不是按阈值计算公式进行计算了,而是直接进行移位

在计算完两个变量后,就会按照新数组的大小创建一个数组,随后准备转移元素

转移元素

if (oldTab != null) { // 如果旧数组不为空(不是初始化),则进行元素转移
    for (int j = 0; j < oldCap; ++j) { // 遍历旧数组
        Node<K,V> e;
        if ((e = oldTab[j]) != null) { // 如果当前下标内存储了元素,准备转移了
            oldTab[j] = null; // help GC
            // 转移分三种情况:1.下标内只存了一个Node节点的,这种直接转移就好
            if (e.next == null) 
                newTab[e.hash & (newCap - 1)] = e;
            // 2.数组对应下标存储了一棵红黑树的,调用红黑树的转移(分割)方法进行转移
            else if (e instanceof TreeNode)
                ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
            /* 
             * 数组对应下标存储了链表的,按链表的方式进行转移。
             * 转移大致思路为:将原链表分为两份(高位与低位),
             * 低位链表的index不变,高位的index变为index+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;
                    // 当前节点的hash值和旧数组长度进行与运算,
                    // 如果等于0的说明在其hash值对应的二进制位上是0,将该节点分为低位链表
                    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);
                // 如果低位链表不为空,就将低位链表存储至新数组中对应的index(j)中
                if (loTail != null) { 
                    loTail.next = null;
                    newTab[j] = loHead;
                }
                // 如果高位链表不为空,就将高位链表存储至新数组的index+oldCap中。
                // 【因为数组扩容后,某个index下标内元素的存储位置只能是index或者index加扩容大小这两个位置】
                if (hiTail != null) { 
                    hiTail.next = null;
                    newTab[j + oldCap] = hiHead;
                }
            }
        }
    }
}

在HashMap中,转移元素可以分为三种情况:

  1. 当前数组下标内只存储了一个Node节点
  2. 当前数组下标内存储的是一棵红黑树
  3. 当前数组下标内存储的是一个链表
当前数组下标内只存储了一个Node节点

转移方式:只需要根据node节点的hash值计算出应该存放的新数组的下标,随后直接转移过去即可

当前数组下标内存储的是一棵红黑树

转移方式:调用红黑树的转移(分割)方法进行转移【详情看见后续分析】

当前数组下标内存储的是一个链表

当一个链表需要转移到新数组时,会将当前这个链表依据hash值分割成高、低位链表。分割完毕后将低位链表放入新数组的原下标j中,高位链表放入新数组的第j+oldCap(旧数组长度)个位置。

主要来看看分割原理:

用当前节点的hash值和旧数组长度进行与运算。如果等于0的说明在其hash值对应的二进制位上是0,将该节点分为低位链表,否则就将其放在高位链表中。一句话说明:用当前节点的hash值和旧数组长度进行与运算,等于0的放入低位链表中,大于0的放入高位链表中

why?

我们知道:数组长度始终是2的幂次方数。当一个数字与任何2的幂次方数进行与运算时,要么为0,要么就是对应的2的幂次方,例如:1010 1100 & 0010 0000(32)= 32 | 1000 1100 & 0010 0000(32)= 0

而数组扩容后,某个index下标内元素的存储位置只能是index或者index加扩容大小这两个位置(这点也可以通过运算进行证明,再次就不多证明了)。

因此通过当前节点的hash值和旧数组长度进行与运算就能知道这个节点在扩容后应该存放的位置是哪个。

红黑树转移方式

红黑树的转移方式其实与链表的转移方式特别类似,也是采用了高低位链表的思想

final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) { // map:当前HashMap;tab:新数组;index:当前需要转移的元素的下标;bit:旧数组长度(数组扩容大小)
    TreeNode<K,V> b = this;
    // 红黑树的转移也是找到链表转移的大致思路,分为低位链表与高位链表(红黑树内是隐藏有一个双链表的,就在这里起到作用了)
    // Relink into lo and hi lists, preserving order
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    int lc = 0, hc = 0;
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        if ((e.hash & bit) == 0) {
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
            ++lc;
        }
        else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }

    if (loHead != null) {
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
            tab[index] = loHead;
            if (hiHead != null) // (else is already treeified)
                loHead.treeify(tab);
        }
    }
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

通过之前的分析,可以知道红黑树内部是隐藏着一个双向链表的,而这个双向链表就在转移的时候起到了重要的作用。

红黑树转移的大致逻辑如下:

  • 首先遍历整个双向链表,将其分割成高低位链表【分割方式与链表转移时的分割方式一致】,同时在分割的同时会分别计算高低位链表中元素的个数
  • 在分割完毕后,会分别对高低位链表进行如下操作【前提是高低位链表不为null】
    1. 判断当前高/低位链表的节点个数是否小于等于<font style="color:#DF2A3F;">UNTREEIFY_THRESHOLD</font>(红黑树链化成链表的节点个数边界,大小为6)
      1. 如果小于则会将当前高/低位链表退化成真正的单链表,随后存入计算出的位置
      2. 否则会将当前高/低位链表重新进行树化(因为红黑树被拆分了,节点不同,需要重新树化),随后存入计算出的位置
posted @ 2024-10-21 00:35  墨雨泠星  阅读(43)  评论(0)    收藏  举报