HashMap红黑树原理详解及源码分析

红黑树的性质

性质1:每个节点要么是黑色,要么是红色。
性质2:根节点是黑色。
性质3:每个叶子节点(NIL)是黑色。
性质4:每个红色结点的两个子结点一定都是黑色。(不能有两个连续的红色节点)
性质5:任意一结点到每个叶子结点的路径都包含数量相同的黑结点(黑色平衡)。

叶子节点(NIL节点):为了红黑树平衡而添加的空节点

红黑树是一种自平衡二叉查找树

由于以上提到的性质约束确保了红黑树的关键特性:从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。 这就保证了这个树大致上是平衡的

  1. 因为操作比如插入、删除和查找某个值的最坏情况时间都要求与树的高度成比例,这个在高度上的理论上限允许红黑树在最坏情况下都是高效的,而不同于普通的二叉查找树。要知道为什么这些性质确保了这个结果,注意到性质4导致了路径不能有两个相连的红色节点就足够了
  2. 最短的可能路径都是黑色节点,最长的可能路径有交替的红色和黑色节点。因为根据性质5所有最长的路径都有相同数目的黑色节点,这就表明了没有路径能多于任何其他路径的两倍长。
  3. 在很多树数据结构的表示中,一个节点有可能只有一个子节点,而叶子节点包含数据。用这种范例表示红黑树是可能的,但是这会改变一些性质并使算法复杂。为此,本文中我们使用”nil叶子”或”空(null)叶子”,如上图所示,它不包含数据而只充当树在此结束的指示。(这些节点在绘图中经常被省略,导致了这些树好像同上述原则相矛盾,而实际上不是这样。与此有关的结论是所有节点都有两个子节点,尽管其中的一个或两个可能是空叶子

红黑树节点如图所示:
红黑树节点图
红黑树的自平衡的处理可以总结为:

自己能搞定的自消化;
自己不能搞定的叫兄弟帮忙;
兄弟都帮忙不了的,通过父母,找远方亲戚。

红黑树的插入原理

新插入节点默认是红色,如果是黑色的话那么当前分支上就会多出一个黑色节点出来,从而破坏了黑色平衡

  1. 如果插入的是第一个节点(根节点),红色变黑色。
  2. 如果父节点为黑色,则直接插入,不需要变色。
  3. 如果父节点是红色,没有叔叔节点或者叔叔节点是黑色,则以爷爷节点为支点旋转,旋转之后原来的爷爷节点变红色,原来的父节点变黑色。
  4. 如果父节点为红色,叔叔节点也是红色(此种情況爷爷节点一定是黑色),则父节点和叔叔节点变黑色,爷爷节点变红色(如果爷爷节点是根节点,则再变成黑色),爷爷节点此时需要递归(把爷爷节点当做新插入的节点再次进行比较)。

红黑树的生成

1. 一个节点
当插入一个元素为5的节点时,由于是新插入的节点,所以应该是红色。但是该树只有一个节点,也就是root根节点,根据红黑树定义2可得,该节点变为黑色。

一个节点时的红黑树生成
2. 两个节点
当已经有一个根节点插入第二个节点元素为x时,分为两种情况。当x>5时,该节点为右节点。当x<5时,该节点为左节点。
两个节点时的红黑树生成
3.三个节点
在已存在的两个节点产生的这两种情况来看,再添加一个元素,会有以下6种情况
3.1第二个节点作为root右子树情况
第二个节点作为root右子树情况
3.2第二个节点作为root左子树情况

第二个节点作为root左子树情况
上面存在的六种情况,由于其中两种已经是平衡的红黑树所以不需要旋转。其余的四种情况我们要进一步分析,如何旋转才能让他成为红黑树。

左旋和右旋

1. 左旋

左旋

左旋:以某个节点作为旋转点,其右子节点变为旋转节点的父节点,右子节点的左子节点变为旋转节点的右子节点,左子节点保持不变。

2. 右旋

右旋

右旋:以某个节点作为旋转点,其左子节点变为旋转节点的父节点,左子节点的右子节点变为旋转节点的左子节点,右子节点保持不变。

四种情况分析

四种情况分析

1. 情况一变红黑树
由图可知,明显该树左边太重了,所有的节点都是左子树,那我们应该向右旋转。以元素为10的节点为旋转点,左子节点5变成他的父节点。左子节点5的右子节点变为旋转节点的左子节点,由于是NIL节点所以在此不再画出。然后进行变色。
在这里插入图片描述

2. 情况二变红黑树
由图可知,情况二的树右边太重了,所有的节点都是右子树,那我们应该向左旋转。以元素为5的节点为旋转点,右子节点10变成他的父节点。右子节点10的左子节点变为旋转节点的右子节点,由于是NIL节点所以在此不再画出。然后进行变色。
在这里插入图片描述
3. 情况三变红黑树
如图所示,情况三刚开始我们无法判定是向左旋还是向右旋。那我们就看他的部分子树,元素10节点和元素x节点如果向右旋转生成的树结构那是不是就和情况二一样了。此时节点为5的右子树为x节点,x节点右子树是元素为10的节点。这就与情况二一样了,再通过左旋并变色处理变成红黑树。
在这里插入图片描述
4. 情况四变红黑树
如图所示,元素5的节点和元素x节点先进行左旋,然后整个树结构与情况一一样,再进行右旋,并进行变色处理,就成为了一个红黑树。
在这里插入图片描述
5. 四种情况总结

  • 以上情况都是在节点插入原理的前三条基本原理基础上进行分析的。
  • 无论一个红黑树的节点多少,深度多大,当它新增节点的时候,发生颜色冲突,如果符合节点插入原理的第四条那就无需旋转,只要变色就可以成为新的红黑树。其它需要旋转才能解决的场景都是以上四种情况的变形。
  • 红黑树的形成有两个阶段:成为二叉搜索树旋转变色

JDK8的HashMap红黑树源码分析

在分析HashMap红黑树部分源码之前我们需要先搞懂 “HashMap 的 hash 方法的原理是什么?”
首先看一下hash方法的源码(JDK 8 中的 HashMap):

static final int hash(Object key) {
    int h;
    // key.hashCode():返回散列值也就是hashcode
    // ^ :按位异或
    // >>>:无符号右移,忽略符号位,空位都以0补齐
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

其中看到在获得hash值时将key的hashCode异或上其无符号右移16位,Hashmap这么做原因:
防止一些实现比较差的 hashCode() 方法,使用扰动函数之后可以减少碰撞,进一步降低hash冲突的几率。

打个比方, 当我们的数组长度n为16的时候,哈希码(字符串“abcabcabcabcabc”的key对应的哈希码)对(16-1)与操作,对于多个key生成的hashCode,只要哈希码的后4位为0,不论高位怎么变化,最终的结果均为0。 如下图所示:

1954974080(HashCode)111 0100 1000 0110 1000 1001 1000 0000
2^4-1=15(length-1)000 0000 0000 0000 0000 0000 0000 1111
&运算000 0000 0000 0000 0000 0000 0000 0000

而加上高16位异或低16位的“扰动函数”后,结果如下:

原HashCode1954974080111 0100 1000 0110 1000 1001 1000 0000
(>>>16)无符号右移16位29830000 0000 0000 0000 0111 0100 1000 0110
^(异或)运算1955003654111 0100 1000 0110 1111 1101 0000 0110
2^4-1=15(length-1)15000 0000 0000 0000 0000 0000 0000 1111
&(与)运算6000 0000 0000 0000 0000 0000 0000 0110

可以看到: 扰动函数优化前:1954974080 % 16 = 1954974080 & (16 - 1) = 0 扰动函数优化后:1955003654 % 16 = 1955003654 & (16 - 1) = 6 很显然,减少了碰撞的几率。
右移16位,自己的高半区和低半区异或,就是为了混合原始哈希码的高位和低位,以此来加大低位随机性。

思考:为什么这里还需要取模运算呢?为什么hash % n 等价于 hash & (n - 1)呢?
这是因为,key.hashCode()是用来获取键位的哈希值的,理论上,哈希值是一个 int 类型,范围从-21474836482147483648。前后加起来大概 40 亿的映射空间,只要哈希值映射得比较均匀松散,一般是不会出现哈希碰撞的。
但问题是一个 40 亿长度的数组,内存是放不下的。HashMap 扩容之前的数组初始大小只有 16,所以这个哈希值是不能直接拿来用的,用之前要和数组的长度做取模运算,用得到的余数来访问数组下标才行。
其中的 (n - 1) & hash 正是取模运算,就是把哈希值和(数组长度-1)做了一个“与”运算。

可能大家在疑惑:取模运算难道不该用 % 吗?为什么要用 & 呢?

这是因为 & 运算比 % 更加高效,并且当 n 为 2 的 整次数幂时,存在下面这样一个公式。

a % n = a & (n-1)

用2n 替换n 就是:

a % n = a & (2n -1)

我们来验证一下,假如 a = 14,n = 16,也就是 24

14%16, 14的二进制为1110,16的二进制为1 0000,16 - 1 = 15的二进制为1111,1110&1111=1110,也就是0*20+1*21+1*22+1*23=14, 14%16刚好也等于14。

这也正好解释了为什么 HashMap 的数组长度要取 2 的整次方。

因为(数组长度-1)正好相当于一个“低位掩码”——这个掩码的低位最好全是 1,这样 & 操作才有意义,否则结果就肯定是 0,那么 & 操作就没有意义了。

a&b 操作的结果是:a、b 中对应位同时为 1,则对应结果位为 1,否则为 0

2 的整次幂刚好是偶数,偶数-1 是奇数,奇数的二进制最后一位是 1,保证了 hash &(length-1) 的最后一位可能为 0,也可能为 1(这取决于 h 的值),即 & 运算后的结果可能为偶数,也可能为奇数,这样便可以保证哈希值的均匀性。

& 操作的结果就是将哈希值的高位全部归零,只保留低位值,用来做数组下标访问。

HashMap数据存储的过程先根据key获得hash值,通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过链地址法解决冲突。

链表转换为半成品树

当满足散列表上的一条链表节点数大于等于8时会进入treeifyBin(tab, hash)方法。将Node节点转换为TreeNode节点,但是TreeNode节点之间通过前后指针相连,并不是左右子树相连。所以我称它为半成品树,源码如下:

// hash:Key 的散列值(经过扰动)
// onlyIfAbsent:如果为 true,不会覆盖旧值
// evict:是否驱逐最早的节点(在 LinkedHashMap 中使用,我们先忽略)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    // 数组
    Node<K,V>[] tab; 
    // 目标桶(同一个桶中节点的散列值有可能不同)
    Node<K,V> p; 
    // 数组长度
    int n;
    // 桶的位置
    int i;
    // 1. 如果数组为空,则使用扩容函数创建(说明数组的创建时机在首次 put 操作时)
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 2. (n - 1) & hash:散列值转数组下标,与 Java 7 的 indexFor() 方法相似
    if ((p = tab[i = (n - 1) & hash]) == null)
        // 3. 如果是桶中的第一个节点,则创建并插入 Node 节点
        tab[i] = newNode(hash, key, value, null);
    else {
        // 4. 如果不是桶中的第一个节点(即发生哈希冲突),需要插入链表或红黑树
        // e:最终匹配的节点
        Node<K,V> e; 
        // 节点上的 Key
        K k;
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            // 4.1 如果桶的根节点与 Key 相等,则将匹配到根节点
            // p.hash == hash:快捷比较(同一个桶中节点的散列值有可能不同,如果散列值不同,键不可能相同)
            // (k = p.key) == key:快捷比较(同一个对象)
            // key != null && key.equals(k):判断两个对象 equals 相同
            e = p;
        else if (p instanceof TreeNode)
        // 4.2 如果桶是红黑树结构,则采用红黑树的插入方式
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 4.3 如果桶是链表结构,则采用链表的插入方式:
            // 4.3.1 遍历链表找到 Key 相等的节点
            // 4.3.2 否则使用尾插法添加新节点
            // 4.3.3 链表节点数超过树化阈值,则将链表转为红黑树
            for (int binCount = 0; ; ++binCount) {
                // 尾插法(Java 7 使用头插法)
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        // 链表节点数超过树化阈值,则将链表转为红黑树
                        treeifyBin(tab, hash);
                    break;
                }
                // 找到 Key 相等的节点
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 4.4 新 Value 替换旧 Value(新增节点时不会走到这个分支)
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 访问节点回(用于 LinkedHashMap,默认为空实现)
            afterNodeAccess(e);
            return oldValue;
        }
    }
    // 修改记录
    ++modCount;
    // 5. 如果键值对数量大于扩容阈值,则触发扩容
    if (++size > threshold)
        resize();
    // 新增节点回调(用于 LinkedHashMap,默认为空实现)
    afterNodeInsertion(evict);
    return null;
}

这就看出if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;HashMap属于懒加载

解释一下 p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))
这个问题等价于问 HashMap 如何确定键值对的位置:

1、首先,HashMap 会对键 Key 计算 hashCode() 并添加扰动,得到扰动后的散列值 hash。随后通过对数组长度取余映射到数组下标中;
2、然后,当数组下标的桶中存在多个节点时,HashMap 需要遍历桶找到与 Key 相等的节点,以区分是更新还是添加。为了提高效率,就有了 if 语句中的多次判断:

  • 2.1 p.hash == hash 快捷判断: 同一个桶中节点的散列值有可能不同,如果散列值不同,键一定不相等:
  • 2.2 (k = p.key) == key 快捷判断:同一个对象;
  • 2.3 key != null && key.equals(k) 最终判断:判断两个键 Key 是否相等,即 equals 相等。

再解释一下为什么转换红黑树的条件是binCount >= TREEIFY_THRESHOLD - 1;所以阈值是7还是8?大于等于还是大于?

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        // 链表节点数超过树化阈值,则将链表转为红黑树
                        treeifyBin(tab, hash);

重点在这一段代码

// 遍历链表,只在两种情况下才会跳出循环
			 for (int binCount = 0; ; ++binCount) {
            
            	//第一种:已经遍历到尾部,在最后插入新节点跳出,因节点数量+1 判断是否需要树化
                if ((e = p.next) == null) { 
                
                    // 在尾部插入新结点
                    p.next = newNode(hash, key, value, null);
                    
                    // 判断是否需要树化
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                        
                    // 跳出循环
                    break;
                }
                
                
                // 第二种:e指向的节点与要插入节点的key相同,此次put为覆盖操作
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 相等,跳出循环
                    break;


                // 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
                p = e;
            }

遍历过程中p从第一个节点遍历到最后一个节点
但由于binCount是从0开始计数,所以在做树化判断时binCount的值等于 链表长度 - 1(注意此时的链表长度没有算新插入的节点)
判断条件为 binCount >= TREEIFY_THRESHOLD - 1 => binCount+1(链表长度) >= TREEIFY_THRESHOLD

但此时链表新插入了一个节点

 p.next = newNode(hash, key, value, null);

所以链表树化的那一刻,它的真实长度应该时binCount+1+1 => 链表长度>TREEIFY_THRESHOLD(8)

即:

链表长度大于8时,treeifyBin()方法被调用
(在做树化判断时,链表长度 = binCount+1(从零计数)+1(新插入节点) = bincount +2)
(判断条件: (bincount >= 8-1) => (bincount>=7) => (bincount+2>=9) => (链表长度>=9) 长度是整数 大于等于9也就是大于8)

综上所述,HashMap 是通过 hashCode() 定位桶,通过 equals() 确定键值对。

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

    // 默认数组容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    // 数组最大容量:2^30(高位 0100,低位都是 0)
    static final int MAXIMUM_CAPACITY = 1 << 30;

    // 默认负载因子:0.75
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // (Java 8 新增)桶的树化阈值:8
    static final int TREEIFY_THRESHOLD = 8;

    // (Java 8 新增)桶的还原阈值:6(在扩容时,当原有的红黑树内数量 <= 6时,则将红黑树还原成链表)
    static final int UNTREEIFY_THRESHOLD = 6;

    // (Java 8 新增)树化的最小容量:64(只有整个散列表的长度满足最小容量要求时才允许链表树化,否则会直接扩容,而不是树化)
    static final int MIN_TREEIFY_CAPACITY = 64;

    // 底层数组(每个元素是一个单链表或红黑树)
    transient Node<K,V>[] table;

    // entrySet() 返回值缓存
    transient Set<Map.Entry<K,V>> entrySet;

    // 有效键值对数量
    transient int size;

    // 扩容阈值(容量 * 装载因子)
    int threshold;		

    // 装载因子上限
    final float loadFactor;

    // 修改计数
    transient int modCount;

    // 链表节点(一个 Node 等于一个键值对)
    static class Node<K,V> implements Map.Entry<K,V> {
        // 哈希值(相同链表上 Key 的哈希值可能相同)
        final int hash;
        // Key(一个散列表上 Key 的 equals() 一定不同)
        final K key;
        // Value(Value 不影响节点位置)
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }

        // Node 的 hashCode 取 Key 和 Value 的 hashCode
        public final int hashCode() {
            return Objects.hashCode(key) ^ Objects.hashCode(value);
        }

        // 两个 Node 的 Key 和 Value 都相等,才认为相等
        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }
		
    // (Java 8 新增)红黑树节点
    static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        // 父节点
        TreeNode<K,V> parent;
        // 左子节点
        TreeNode<K,V> left;
        // 右子节点
        TreeNode<K,V> right;
        // 删除辅助节点
        TreeNode<K,V> prev;
        // 颜色
        boolean red;

        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }

        // 返回树的根节点
        final TreeNode<K,V> root() {
            for (TreeNode<K,V> r = this, p;;) {
                if ((p = r.parent) == null)
                    return r;
                r = p;
            }
        }
    }
}

我们注意到static final float DEFAULT_LOAD_FACTOR = 0.75f;而且HashMap当中很多地方都有它的身影。为什么HashMap的加载因子一定是0.75?而不是0.8,0.6?

加载因子是用来表示 HashMap 中数据的填满程度:

加载因子 = 填入哈希表中的数据个数 / 哈希表的长度

这就意味着:

  • 加载因子越小,填满的数据就越少,哈希冲突的几率就减少了,但浪费了空间,而且还会提高扩容的触发几率;
  • 加载因子越大,填满的数据就越多,空间利用率就高,但哈希冲突的几率就变大了。

这就必须在“哈希冲突”与“空间利用率”两者之间有所取舍,尽量保持平衡,谁也不碍着谁。

为什么加载因子会选择 0.75 呢?为什么不是0.8、0.6呢?

这跟统计学里的一个很重要的原理——泊松分布有关。

是时候上维基百科了:

泊松分布,是一种统计与概率学里常见到的离散概率分布,由法国数学家西莫恩·德尼·泊松在1838年时提出。它会对随机事件的发生次数进行建模,适用于涉及计算在给定的时间段、距离、面积等范围内发生随机事件的次数的应用情形。

具体是用这么一个公式来表示的:
泊松分布
具体公式详情可以参考:

https://www.ruanyifeng.com/blog/2015/06/poisson-distribution.html

在 HashMap 的 doc 文档里,曾有这么一段描述:

Because TreeNodes are about twice the size of regular nodes, we
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due to
removal or resizing) they are converted back to plain bins.  In
usages with well-distributed user hashCodes, tree bins are
rarely used.  Ideally, under random hashCodes, the frequency of
nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of
resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:
0:    0.60653066
1:    0.30326533
2:    0.07581633
3:    0.01263606
4:    0.00157952
5:    0.00015795
6:    0.00001316
7:    0.00000094
8:    0.00000006
more: less than 1 in ten million

大致的意思就是:

因为 TreeNode(红黑树)的大小约为链表节点的两倍,所以我们只有在一个拉链已经拉了足够节点的时候才会转为tree(参考TREEIFY_THRESHOLD)。并且,当这个hash桶的节点因为移除或者扩容后resize数量变小的时候,我们会将树再转为拉链。如果一个用户的数据的hashcode值分布得很均匀的话,就会很少使用到红黑树。

理想情况下,我们使用随机的hashcode值,加载因子为0.75情况,尽管由于粒度调整会产生较大的方差,节点的分布频率仍然会服从参数为0.5的泊松分布。链表的长度为 8 发生的概率仅有 0.00000006。

这个意思就是一般不会转为红黑树,这是一种保底原则,如果我们重写了不好的hashCode方法很可能会转为红黑树,一般是分布散列良好,分布均匀,符合泊松分布,各个长度命中概率递减,长度为8时,概率为0.00000006,小于千万分之一,通常情况下,红黑树没有时间优势,反而会增加空间负担,所以用8作为默认阈值

那么为什么退化阀值UNTREEIFY_THRESHOLD =6而不是8呢?那么8将成为一个临界值,时而树化,时而退化,此时会非常影响性能,因此,我们需要一个比8小的退化阀值;那为什么是6呢?
源码中也说了,考虑到内存(树节点比普通节点内存大2倍,以及避免反复转化),所以,退化阀值最多为6。

虽然这段话的本意更多的是表示 jdk 8中为什么拉链长度超过8的时候进行了红黑树转换,但提到了 0.75 这个加载因子——但这并不是为什么加载因子是 0.75 的答案。

为了搞清楚为啥,我查阅大量资料,发现这位大牛给出了他的见解,详情参考:

https://segmentfault.com/a/1190000023308658

里面提到了一个概念:二项分布。

在做一件事情的时候,其结果的概率只有2种情况,和抛硬币一样,不是正面就是反面。

为此,我们做了 N 次实验,那么在每次试验中只有两种可能的结果,并且每次实验是独立的,不同实验之间互不影响,每次实验成功的概率都是一样的。

以此理论为基础,我们来做这样的实验:我们往哈希表中扔数据,如果发生哈希冲突就为失败,否则为成功。

我们可以设想,实验的hash值是随机的,并且经过hash运算的键都会映射到hash表的地址空间上,那么这个结果也是随机的。所以,每次put的时候就相当于我们在扔一个16面(我们先假设默认长度为16)的骰子,扔骰子实验那肯定是相互独立的。碰撞发生即扔了n次有出现重复数字。

然后,我们的目的是啥呢?

就是掷了k次骰子,没有一次是相同的概率,需要尽可能的大些,一般意义上我们肯定要大于0.5(这个数是个理想数,但是我是能接受的)。

于是,n次事件里面,碰撞为0的概率:

在这里插入图片描述
这个概率值需要大于0.5,我们认为这样的hashmap可以提供很低的碰撞率。所以:
在这里插入图片描述
这时候,我们对于该公式其实最想求的时候长度s的时候,n为多少次就应该进行扩容了?而负载因子则是 n/s 的值。所以推导如下:
在这里插入图片描述
所以可以得到
在这里插入图片描述
其中
在这里插入图片描述
这就是一个求 ∞⋅0函数极限问题,这里我们先令s = m + 1(m -> ∞)则转化为
在这里插入图片描述
我们再令x = 1 m \frac{1}{m} m1(x -> 0)则有
在这里插入图片描述

所以
在这里插入图片描述
考虑到 HashMap的容量有一个要求:它必须是2的n 次幂(这个文章前面讲过了)。当加载因子选择了0.75就可以保证它与容量的乘积为整数。

16x0.75=12
32x0.75=24

除了 0.75,0.5~1 之间还有 0.625(5/8)、0.875(7/8)可选,从中位数的角度,挑 0.75 比较完美。另外,维基百科上说,拉链法(解决哈希冲突的一种)的加载因子最好限制在 0.7-0.8以下,超过0.8,查表时的CPU缓存不命中(cache missing)会按照指数曲线上升。

综上,0.75 是个比较完美的选择。

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();
        // 否则将链表转换为半成品树(这些树节点由前指针相连)
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                // 根据Node节点创建新的TreeNode节点
                TreeNode<K,V> p = replacementTreeNode(e, null);
                // 尾指针为null时,说明树还未创建
                if (tl == null)
                    // 头指针赋值给第一个树节点
                    hd = p;
                else {
                    // 新插入的树节点的前指针指向上一个尾节点
                    p.prev = tl;
                    // 尾节点指向新插入的树节点
                    tl.next = p;
                }
                // 尾指针指向最新插入的树节点
                tl = p;
            } while ((e = e.next) != null); // 遍历下一个节点
            // 半成品树的头指针赋值给散列表对应位置
            if ((tab[index] = hd) != null)
                // 转换为红黑树
                hd.treeify(tab);
        }
    }

由以上源码我们可以注意到resize() 扩容方法在多个地方都有出现,主体流程分为 3步:

  1. 计算扩容后的新容量和新扩容阈值;
  2. 创建新数组;
  3. 将旧数组上的键值对再散列到新数组上。

扩容又分为 2 种情况:

  1. 首次添加元素: 会根据构造方法中设置的初始容量和装载因子确定新数组的容量和扩容阈值在无参构造方法中,会使用 16 的数组容量和 0.75 的扩容阈值;
  2. 非首次添加: 将底层数组和扩容阈值扩大为原来的 2 倍,如果旧容量大于等于 2^30 次幂,则无法扩容。此时,将扩容阈值调整到整数最大值。

再散列的步骤不好理解,这里解释下:

  1. 桶的根节点,直接再散列;
  2. 以红黑树的方式再散列,思路与 3.链表的方式相似;
  3. 以链表的形式再散列:hash & oldCap 就是获取 hash 在扩容后新参与映射的 1 个最高有效位。如果这一位是 0,那么映射后的位置还是在原来的桶中,如果这一位是 1,那么映射后的位置就是原始位置 + 旧数组的容量。

oldCap = 0 0 0 0 1 0 0 0 0 0 // 32
oldCap - 1 = 0 0 0 0 0 1 1 1 1 1 // 31
newCap = 0 0 0 1 0 0 0 0 0 0 // 64
newCap - 1 = 0 0 0 0 1 1 1 1 1 1 // 63

               ^增加 1 个有效位参与映射
// 扩容
final Node<K,V>[] resize() {
    // 旧数组
    Node<K,V>[] oldTab = table;
    // 旧容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 旧扩容阈值
    int oldThr = threshold;
    // 新容量
    int newCap = 0;
    // 新扩容阈值
    int newThr = 0;
    // 1. 计算扩容后的新容量和新扩容阈值
    // 旧容量大于 0,说明不是第一次添加元素
    if (oldCap > 0) {
        // 如果旧容量大于等于 2^30 次幂,则无法扩容。此时,将扩容阈值调整到整数最大值
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 数组容量和扩容阈值扩大为原来的 2 倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 旧容量为 0,需要初始化数组
    else if (oldThr > 0)
        // (带初始容量和负载因子的构造方法走这里)
        // 使用构造方法中计算的最近 2 的整数幂作为数组容量
        newCap = oldThr;
    else {
        // (无参构造方法走这里)
        // 使用默认 16 长度作为初始容量
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 使用默认的负载因子乘以容量计算扩容阈值
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        //(带初始容量和负载因子的构造方法走这里)
        // 使用负载因子乘以容量计算扩容阈值
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
    }
    // 最终计算的扩容阈值
    threshold = newThr;
    // 2. 创建新数组
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 3. 将旧数组上的键值对再散列到新数组上
    if (oldTab != null) {
        // 遍历旧数组上的每个桶
        for (int j = 0; j < oldCap; ++j) {
            // 桶的根节点
            Node<K,V> e;
            // 桶的根节点不为 null
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    // 3.1 桶的根节点,直接再散列
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    // 3.2 以红黑树的方式再散列,思路与 3.3 链表的方式相似
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { 
                    // 3.3 以链表的形式再散列
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // 3.3.1 若散列值新参与映射的位为 0,那么映射到原始位置上
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 3.3.2 若散列值新参与映射的位为 1,那么映射到原始位置 + 旧数组容量的位置上
                        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;
                    }
                }
            }
        }
    }
    return newTab;
}

由上可知,节点转换为红黑树的两个条件:
1.链表节点数大于等于8
2.散列表长度大于等于64

为什么要在设置桶的树化阈值(8)后,还要设置树化的最小容量(64)?

这是为了避免无效的树化。

在散列表的容量较低时,添加数据时很容易会触发扩容。此时,一部分原本已经树化的桶会由于长度下降而退还回链表。因此,红黑树为树化操作设置了最小容量要求:如果链表长度达到树化阈值,但散列表整体的长度未达到最小容量要求,那么就直接扩容,而不是在桶上树化。

再说一下为什么HashMap的最大容量时230:由于int类型限制了该变量的长度为4个字节共32个二进制位,按理说可以向左移动31位即2的31次幂,这里为什么不是2的31次方,而是2的30次方呢?
事实上由于二进制数字中最高的一位也就是最左边的一位是符号位,用来表示正负之分(0为正,1为负),所以只能向左移动30位,而不能移动到处在最高位的符号位,所以最大容量只能是2的30次方。

半成品树转换为二叉搜索树

treeify(Node<K,V>[] tab)方法就可以分为先成为一个二叉搜索树,再调用balanceInsertion(root, x)方法通过旋转变色成为红黑树。

    final void treeify(Node<K,V>[] tab) {
            TreeNode<K,V> root = null;
            // 遍历循环半成品树节点
            for (TreeNode<K,V> x = this, next; x != null; x = next) {
                // 头节点指针的下一个节点是第一个树节点
                next = (TreeNode<K,V>)x.next;
                x.left = x.right = null;
                // 当没有根节点的时候,创建根节点,并成黑色
                if (root == null) {
                    x.parent = null;
                    x.red = false;
                    root = x;
                }
                // 否则不是根节点的时候
                else {
                    K k = x.key;
                    int h = x.hash;
                    Class<?> kc = null;
                    // 遍历已经存在的树节点
                    for (TreeNode<K,V> p = root;;) {
                        int dir, ph;
                        K pk = p.key;
                        // 所遍历的树节点hash值大于要插入的节点hash值,向左子树继续遍历
                        if ((ph = p.hash) > h)
                            dir = -1;
                        // 所遍历的树节点hash值小于要插入的节点hash值,向右子树继续遍历
                        else if (ph < h)
                            dir = 1;
                        // 如果要插入的节点hash值等于遍历所在节点hash,hash相等时,通过内存地址进行比较
                        else if ((kc == null &&
                                  (kc = comparableClassFor(k)) == null) ||
                                 (dir = compareComparables(kc, k, pk)) == 0)
                            //说明红黑树中没有与之相等的  那就必须进行插入操作。
                            // 分出插入节点是左节点还是右节点
                            dir = tieBreakOrder(k, pk);
 
                        TreeNode<K,V> xp = p;
                        // 根据dir区分要继续遍历左节点还是右节点
                        // 当下一个节点为null的时候说明已经找到要插入的树节点所在的位置
                        if ((p = (dir <= 0) ? p.left : p.right) == null) {
                            // 要插入的树节点父指针 指向 调整成树后遍历所得树节点
                            x.parent = xp;
                            // 根据dir区分出插入节点放入左节点还是右节点
                            if (dir <= 0)
                                xp.left = x;
                            else
                                xp.right = x;
                            // 插入完成后是一个二叉搜索树,需要变色或旋转成为红黑树
                            root = balanceInsertion(root, x);
                            break;
                        }
                    }
                }
            }
            // 检验root节点是不是第一个节点
            moveRootToFront(tab, root);
        }

二叉搜索树变成红黑树

这里是从叶节点遍历到root根节点,从部分到整体一步步满足红黑树的条件。新插入的节点根据是父节点的左子树还是右子树,以及父节点、爷爷节点和叔叔节点的颜色可以分为不同的情况,根据不同的情况分别进行左旋和右旋。

rotateLeft(TreeNode<K,V> root,TreeNode<K,V> p)是进行左旋转。

rotateRight(TreeNode<K,V> root,TreeNode<K,V> p)是进行右旋转。

static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
                                                    TreeNode<K,V> x) {
            // 此处就是节点新增原理提到的新插入节点默认为红色
            x.red = true;
            // 遍历树x节点一直到root节点
            for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
                // 如果是根节点
                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)) {
                    // 如果叔叔节点不为空并且是红色
                    //      xpp
                    //     /   \
                    //   xp(R) Red
                    if ((xppr = xpp.right) != null && xppr.red) {
                        xppr.red = false;
                        xp.red = false;
                        xpp.red = true;
                        x = xpp;
                    }
                    // 如果叔叔节点为空或者不为空是黑色
                    else {
                        // 如果该节点是右节点
                        //      xpp             xpp
                        //     /   \           /
                        //   xp(R) black     xp(R)
                        //     \              \
                        //      x(R)           x(R)
                        if (x == xp.right) {
                            // 左旋
                            root = rotateLeft(root, x = xp);
                            xpp = (xp = x.parent) == null ? null : xp.parent;
                        }
                        // 如果该节点是左节点
                        //      xpp             xpp
                        //     /   \            /
                        //   xp(R) black     xp(R)
                        //   /              /
                        //  x(R)           x(R)
                        if (xp != null) {
                            xp.red = false;
                            if (xpp != null) {
                                xpp.red = true;
                                //      xpp(R)          xpp(R)
                                //     /   \            /
                                //    xp(B) black   xp(B)
                                //   /              /
                                //  x(R)           x(R)
 
                                // 右旋将的到的新树赋给root,再次遍历
                                root = rotateRight(root, xpp);
                            }
                        }
                    }
                }
                // 如果父节点是爷爷节点的右子树
                else {
                    // 如果叔叔节点不为空并且是红色
                    //             xpp
                    //           /   \
                    //         Red  xp(R)
                    if (xppl != null && xppl.red) {
                        xppl.red = false;
                        xp.red = false;
                        xpp.red = true;
                        x = xpp;
                    }
                    // 如果叔叔节点为空或者不为空是黑色
                    else {
                        // 如果该节点是左节点
                        //      xpp             xpp
                        //         \           /   \
                        //        xp(R)     black xp(R)
                        //          /              /
                        //        x(R)           x(R)
                        if (x == xp.left) {
                            // 右旋
                            root = rotateRight(root, x = xp);
                            xpp = (xp = x.parent) == null ? null : xp.parent;
                        }
                        // 如果该节点是右节点
                        //      xpp             xpp
                        //         \           /   \
                        //        xp(R)     black xp(R)
                        //          \               \
                        //          x(R)            x(R)
                        if (xp != null) {
                            xp.red = false;
                            if (xpp != null) {
                                xpp.red = true;
                                //      xpp(R)          xpp(R)
                                //         \           /   \
                                //        xp(B)     black xp(B)
                                //          \               \
                                //          x(R)            x(R)
 
                                // 左旋
                                root = rotateLeft(root, xpp);
                            }
                        }
                    }
                }
            }
        }

旋转

我根据源码将不同的情况下的左旋或右旋结果,用注释表示了出来。大家可以与那四种情况结合分析。

左旋
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
                                              TreeNode<K,V> p) {
            TreeNode<K,V> r, pp, rl;
            if (p != null && (r = p.right) != null) {
                // p的右节点指向r的左孩子(即rl),如果rl不为空,其父节点指向p;
                //            p
                //             \
                //              r
                //             /
                //            rl
                if ((rl = p.right = r.left) != null)
                    rl.parent = p;
                //         r
                //       /
                //      p
                //       \
                //        rl
                //------------------------------------
                
                // p节点为根节点,直接root指向r,同时颜色置为黑色(根节点颜色都为黑色)
                if ((pp = r.parent = p.parent) == null)
                    (root = r).red = false;
 
                // 如果该节点是右节点
                //       pp             pp
                //     /   \           /
                //    p(R) black     p(R)
                //     \              \
                //      r(R)           r(R)
                else if (pp.left == p)
                    pp.left = r;
                // 走完该方法后的图形
                //        pp             pp
                //      /   \           /
                //    r(R) black     r(R)
                //    /              /
                //  p(R)            p(R)
                //---------------------------------
 
                //      pp              pp
                //       \               \
                //      p(R)            p(R)
                //         \           /   \
                //         r(B)     black  r(B)
                //          \               \
                //          x(R) t           x(R)
                else
                    pp.right = r;
                    
                r.left = p;
                p.parent = r;
                // 走完该方法后的图形
                //      pp              pp
                //       \               \
                //       r(B)           r(B)
                //      /  \           /   \
                //   p(R)  x(R)     p(R)  x(R)
                //                   /
                //                black
            }
            return root;
        }
右旋
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,
                                               TreeNode<K,V> p) {
            TreeNode<K,V> l, pp, lr;
            if (p != null && (l = p.left) != null) {
                // p的左节点指向l的右孩子(即lr),如果lr不为空,其父节点指向p;
                //            p
                //          /
                //         l
                //          \
                //          lr
                if ((lr = p.left = l.right) != null)
                    lr.parent = p;
                //         l
                //          \
                //           p
                //          /
                //        lr
                //------------------------------------
 
                // 如果pp为null,说明p节点为根节点,直接root指向l,同时颜色置为黑色(根节点颜色都为黑色)
                if ((pp = l.parent = p.parent) == null)
                    (root = l).red = false;
 
                //        pp             pp
                //         \           /   \
                //        p(R)     black  p(R)
                //          /              /
                //        l(R)           l(R)
                else if (pp.right == p)
                    pp.right = l;
                // 走完该方法后的图形
                //        pp             pp
                //         \           /   \
                //        l(R)     black  l(R)
                //          \               \
                //          p(R)            p(R)
                // --------------------------------------
 
                //        pp(B)            pp(B)
                //        /               /
                //      p(R)            p(R)
                //     /   \            /
                //    l(B) black     l(B)
                //   /              /
                //  x(R)           x(R)
                else
                    pp.left = l;
                    
                l.right = p;
                p.parent = l;
                // 走完该方法后的图形
                //        pp(B)           pp(B)
                //        /               /
                //      l(B)            l(B)
                //     /   \            /  \
                //    x(R) p(R)      x(R) p(R)
                //           \
                //          black
            }
            return root;
        }

插入新节点

插入新节点从root节点往下遍历分为4种情况:

  1. 要插入的节点hash值小于遍历所在节点hash,遍历左子树
  2. 要插入的节点hash值大于遍历所在节点hash,遍历右子树
  3. 要插入的节点hash值等于遍历所在节点hash,并且key值相等返回该节点
  4. 要插入的节点hash值等于遍历所在节点hash,但是key不等时,发生hash冲突。此时又分为两种情况。
    • 遍历该节点的左右子节点是否存在hash相等,并且key也相等的节点,有则返回该节点
    • 如果没有则调用tieBreakOrder(k, pk)方法,比较key值,确定是遍历左子树还是右子树
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
                                       int h, K k, V v) {
            Class<?> kc = null;
            boolean searched = false;
            // 获取根节点
            TreeNode<K,V> root = (parent != null) ? root() : this;
            for (TreeNode<K,V> p = root;;) {
                int dir, ph; K pk;
                // 如果要插入的节点hash值小于遍历所在节点hash,遍历左子树
                if ((ph = p.hash) > h)
                    dir = -1;
                // 如果要插入的节点hash值大于遍历所在节点hash,遍历右子树
                else if (ph < h)
                    dir = 1;
                // 如果要插入的节点hash值等于遍历所在节点hash,并且
                // key值相等返回该节点
                else if ((pk = p.key) == k || (k != null && k.equals(pk)))
                    return p;
                // 如果要插入的节点hash值等于遍历所在节点hash,但是key不等时,此时发生hash冲突
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0) {
                    // 在左右子树递归的寻找 是否有key的hash相同  并且equals相同的节点
                    if (!searched) {
                        TreeNode<K,V> q, ch;
                        searched = true;
                        // (ch = p.left) != null 左子树不为空
                        // (ch = p.right) != null 右子树不为空
                        // (q = ch.find(h, k, kc)) != null) 递归查找hash值相等的并且key也相等
                        // 如果找到hash值相等的则返回该节点
                        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);
                }
 
                TreeNode<K,V> xp = p;
                // 如果dir小于0,那p等于p的左子树节点,不为null则继续遍历
                // 如果dir大于0,那p等于p的右子树节点,不为null则继续遍历
                // 当为null时说明是叶子节点则执行下面方法
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    Node<K,V> xpn = xp.next;
                    // 由于TreeNode继承了Node,创建一个新的TreeNode节点将要插入的
                    // hash、key、value存入
                    TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
                    // dir小于0,新节点为左节点
                    if (dir <= 0)
                        xp.left = x;
                    // dir大于0,新节点为右节点
                    else
                        xp.right = x;
                    xp.next = x;
                    x.parent = x.prev = xp;
                    if (xpn != null)
                        ((TreeNode<K,V>)xpn).prev = x;
                    // balanceInsertion(root, x)方法让一个树成为红黑树,并返回根节点
                    // moveRootToFront,检验root节点是不是第一个节点
                    moveRootToFront(tab, balanceInsertion(root, x));
                    return null;
                }
            }
        }
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
HashMap中的红黑树树化退化是指当链表中的节点数量较多时,HashMap会将链表转化为红黑树来提高查找效率。红黑树是一种自平衡的二叉查找树,可以在O(logN)的时间复杂度内进行查找、插入和删除操作。 在HashMap中,当某个哈希桶中的链表节点数量超过一个阈值(TREEIFY_THRESHOLD)时,就会触发将链表转化为红黑树的操作。这个阈值的默认值是8。具体的判断条件是binCount >= TREEIFY_THRESHOLD - 1。 转化为红黑树后,原本的链表结构就会被改变成一个更高效的树结构,这样就可以在更快的时间内执行查找、插入和删除操作。当然,如果在某些操作之后,哈希桶中的节点数量减少到一个较小的值(UNTREEIFY_THRESHOLD),则会将红黑树恢复为链表,以节省空间。 总之,在HashMap中,红黑树树化退化是为了提高链表操作的效率而引入的优化机制,它可以在特定的条件下将链表转化为红黑树,并且在节点数量变少时将红黑树恢复为链表。这样可以更好地平衡查找速度和空间的利用。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* *3* [HashMap红黑树原理详解源码分析](https://blog.csdn.net/qq_43207114/article/details/128617285)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_1"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值