HashMap源码,这一篇给你从盘古开天讲起


前言

十万个为什么

  • 数组长度为什么要是2^n
  • jdk8后为什么要调整转为红黑树,不转为二叉搜索树?什么时候转为红黑树
  • 为什么是大于8
  • 为什么是数组长度大于64的时候
  • 链表插入的时候是选择头插还是尾插呢?
  • HashMap的扩容时机是什么时候

这些问题你都知道么? 如果你不知道那么只须花你10分钟的时间就够了,认真的看完这篇文章, 这些问题,你也能对答如流.

一、HashMap的起源

我们在学一个东西的时候常常上来就是怎么用, 而忘了关注底层. 而有时候关注了底层时又一头扎进去源码里,无法自拔, 我们常常忘了思考这个东西的起源是什么?为什么要有这个东西?

1.1 为什么要有HashMap?

当我们需要存储一个key-value的数据时,我们会这样写(不同类型的key和value肯定要用泛型啊)

class Node<K,V> {
    K key;
    V value;
}

然后我们会提供get,set方法,面向接口编程啊, 搞一个接口

class Node<K,V> implements Map.Entry<K,V>{
    K key;
    V value;
}

1.1.1 总结

我们用Node表示了一个键值对的数据,然后搞了一个接口来抽象一些方法, 实现对键值对数据的查询,新增

1.2 怎么存储Node呢?

我们知道存储数据无外乎数组和链表,

  • 数组: 长度固定, 查询快O(1),增删慢,增删时需要把index后面的数据后移,扩容等
  • 链表: 长度不固定, 增删快,查询慢O(n)
    在实际应用场景中我们会发现查找居多, 所以我们选择数组来存储,即:
Node<K,V>[] table;

1.2.1 怎么实现快速查找呢?

我们的需求是:通过key快速查找value,那么数组怎么查最快呢?当然是通过下标查,于是我们很自然的想到,把Node节点放在key对应的hashCode数组位置不就行了么?这样是实现了快速查找,那么有什么问题么?

  • 这样我们的数组长度要很大很大,这样大的连续内存显然是不可能的

于是,我们的需求就应运而生,就是我们需要用一个较小的数组存储很多的数据,怎么搞?

  • 我们需要一个key->index的映射,这要怎么搞?
  • 我们可以拿key的hashCode对数组长度取余啊,这样就把key分布在固定长度的数组中了啊,
    那么这样的算法有什么问题么?
  • 问题就是同一个index上会有多个数据,这便是hash冲突,那么解决hash冲突有哪些办法呢?(拓展阅读: 解决哈希冲突的常用方法 )
  • 我们采用链地址法,即当出现hash冲突的时候,我们以链表的形式存放这些数据,此时数组的每一个位置我们叫桶. 讲到这看似完美的解决了问题,但这样的实现有哪些问题呢?
    1. 取模是一个耗时的操作,怎么优化
    1. 这样的hash算法肯定会造成一个桶上有很多元素,即hash算法不够好,即jdk自带的key.hashCode()不够好,不够散列,至于说为什么不够散列呢?后面会说
  • 3 . 链表的长度太长,造成查找效率变低

1.2.2 问题的解决

第一个问题看似很好解决,但其实有可以深究的地方.我们知道<算法导论>中提到,对2的n次幂取余时最不好的操作,因为这样只有低n位有作用, 高位没用了. 最好选择素数, 这样会更加散列, 分布更加均匀.
那为什么jdk还要选择2的n次幂呢?因为我们希望插入时快一点啊, 而对2的n次幂做运算时,只需要运算低n位,这显然比素数要快啊, 但是这样hash冲突会变多, 怎么办呢,这就是后面hash函数要讲的了
当一个数是 2^n 时, 任意整数对2^n取模等效于:
h % 2^n = h & (2^n -1) ,于是乎我们知道,当我们把数组长度设置为2^n时,取模效率最高, 这就是数组长度为什么要是2^n

第三个问题也好解决:
链表的查找复杂度是O(n),而二叉查找树的查找时间复杂度是O(logn),比链表快,于是我们便用红黑树代替链表
等等,有两个问题奥:

  • 为什么用红黑树?
    这其实就是二叉树种类的区别和用途了,红黑树是一种非完全平衡的二叉搜索树,比较适合做查找. 至于其他树为啥不适合可以自行百度各种二叉树的区别
  • 链表长度多大时转为红黑树呢?
    这个问题jdk源码中的注释给了我们答案,当链表长度超过8时转为红黑树,为什么?
  • 首先在链表长度很小时,链表和红黑树的查找效率没有差很多,而且TreeNode所占的内存是比Node大的,所以一开始我们没必要用红黑树
  • 链表转红黑树是消耗性能的,我们要尽量减少这种情况的发生,即这个值不能选的太小,即大多数情况,要选择少数情况; 但是也不能太大, 要合理, 于是乎,在jdk的源码注释中写着, 研究表明链表长度大于8的情况是很少的, 这个8是比较合适的,相当于是jdk帮我们做了选择

其他两个问题都与hash算法有关, 下面详细说说

1.2.2 Hash算法

什么叫hash算法?
首先我们来看维基百科对于hash function的定义:

  • 散列函数(英语:Hash function)又称散列算法、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值(hash values,hash codes,hash sums,或hashes)的指纹。
    我们知道在Object类中有一个native 的hashCode()
public native int hashCode();

我们可以看出把key映射为int,然后对index取余.此时数组长度为2^n.
OK,至此我们确定了是原始的hashCode对2的n次幂取余,那么这样有什么问题呢?

  • 我们会发现,这样的取余操作只与2^n的低n位有关系,高位没有关系(详细的可以看这里),这样一来,则出现hash冲突的概率变大了,即jdk自带的hashCode()并不是那么好了,那么怎么解决呢?
  • 现在我们的需求是要让高位也参与到hash算法中,于是乎
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这便是hashMap中的hash算法,即让hashCode想右移16位然后与hashCode做异或,这样的结果低位便同时有了高位和低位的特征,即比以前更加散列,这便解决了问题二
但是到这还是有问题啊, 即使我们算法写的再好,当我向hashMap中存放大量数据时,还是会有很大的概率hash冲突,难道我在put的时候发现链表上超过8了就转红黑树么? 不是的, 而是链表长度大于8且数组长度大于64的时候才会转为红黑树
大于8我们已经解释了,OK,那为什么数组长度要大于64呢? 我是这样理解的(其实是源码告诉我的)
当数组长度不到64时,此时链表长度又达到了8. 此时问题是查找效率低,我们要解决这个问题, 有两种方案:

  • 变为红黑树,提高查找效率
  • 有没有一种办法让链表长度变小呢? 哎,您还问对了,有啊, 就是把数组长度变大啊, 这样hash冲突就会变少啊(为什么呢?后面告诉你),但是数组长度一直变大也不太好吧, 于是jdk给我们规定了一个值64,
  • 当数组长度不到64时, 链表长度又达到了8,便会扩容,
  • 当数组长度达到了64, 链表长度达到了8时, 此时再扩容为128占用内存空间太大了, 就不扩容了, 变为红黑树吧
    下面贴出源码,这里只贴出部分源码, put的源码分析后续会在后续分析
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
else {
                //死循环遍历
                for (int binCount = 0; ; ++binCount) {

                    //注意,此时给e赋值为桶中第一个节点的下一个节点
                    //如果找到了尾结点,就new一个node放在后面
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //如果链表的长度达到了8个, 就将链表转换成红黑数以提升查找性能
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }

                    //如果链表上有一个node的key和要put的key一样就break了
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }

}

下面是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();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, 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);
        }
    }

OK,那什么是扩容机制呢?

二、扩容机制

扩容就是当HashMap中的空间不够了,放不下了我就增加容量了啊,那有的人就问了,怎么会放不下呢, 我红黑树一直搞下去啊, 对此,我只想说, 幼稚!!!查起来太慢了啊
那么HashMap是什么时候扩容的呢?是当达到了数组长度的3/4就扩容么, 还是达到capacity的3/4呢?
下面我们先介绍几个概念

1. 几个概念

先看下HashMap的结构图:
在这里插入图片描述
感谢这位老哥的图
约定:
约定数组结构的每一个格格称为桶
约定桶后面存放的每一个数据称为bin, bin这个术语来自于JDK 1.8的HashMap注释

  • size: 表示HashMap中存放KV的数量(为链表和树中的KV的总和)。
  • capacity: 桶容量(数组长度). HashMap中桶的数量。默认值为16。扩容后是之前的2倍,且容量都是2的幂。
  • loadFactor: 装载因子。装载因子用来衡量HashMap满的程度。loadFactor的默认值为0.75f。计算HashMap的实时装载因子的方法为:size/capacity,而不是占用桶的数量去除以capacity。
  • threshold: 扩容阈值,当size大于threshold时,就会触发扩容.threshold=capacity*loadFactor

2. put(K key, V value)

扩容函数其实就是resize(),但什么时候会调用这个方法,其实在put的源码中很清楚,所以要讲清楚扩容机制就要搞懂put源码,下面我们一起来看源码

2.1 resize()

我们先来看resize(),即是怎么扩容的

final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;//老数组长度
        int oldThr = threshold;//老的扩容阈值
        int newCap, newThr = 0;
		//如果之前的数组有值
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            //如果扩容后没有超过int范围且老数组长度大于等于16,则数组长度扩容为之前的2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        //这里是如果在构造函数时指定了initialCapacity的话,此时oldThr 不为0,则以改值作为新数组的长度
        //例如:Map<String, String> map = new HashMap<>(5);
        //该构造方法执行后,threshold既是大于指定值的最小的2的n次幂
        else if (oldThr > 0) 
            newCap = oldThr;//8
        else {               
            newCap = DEFAULT_INITIAL_CAPACITY;//16
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//16*0.75=12
        }
        //这种情况也是构造方法指定了initialCapacity,会走到这里,计算扩容阈值
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;//8*0.75=6
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        //设置扩容阈值
        threshold = newThr;
//经过上面的代码我们知道了,如果构造函数时没有指定initialCapacity,则新数组长度为16
//如果指定了initialCapacity,则新数组长度为大于initialCapacity最小的2的n次幂
        
        //初始化数组
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        //如果是构造好map后的第一次put, oldTab 为null,这段代码不走的,直接返回了
        //如果是达到扩容阈值了,走的resize(),会走到这里
        //这里的核心思想是: 把原map中的元素重新放入新的数组中
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                
// 这里注意, table中存放的只是Node的引用, 这里将oldTab[j]=null只是清除旧表的引用, 
//但是真正的node节点还在, 只是现在由e指向它
                    oldTab[j] = null;
                    //如果e没有后继节点了就把e放在新数组中,注意这里是和newCap - 1做与操作
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                     //如果当前节点e是红黑树节点, 这里就是树的一些操作了,感兴趣的自行阅读吧
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
					//走到这就是链表的情况了,且链表长度小于8
                    else {
                    //这里我们先理解为有两个链表,lo,hi,然后搞了4个指针,分别指向头尾节点
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        //do{}while{}的讲解我们单独列出,看下面
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

我们先看下do{}while{}的大致逻辑

do {
    next = e.next;
    ...
} while ((e = next) != null);

这就是循环遍历这个链表,然后我们再来看do块中的if{}else{}

							//插入lo链表
							if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            //插入hi链表
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }

看到这里可能你有点迷糊,别着急, 继续往下看,你会明白的,我保证
最后一段代码

if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

这一段的意思是:
如果lo链表非空, 我们就把整个lo链表放到新table的j位置上
如果hi链表非空, 我们就把整个hi链表放到新table的j+oldCap位置上

综上所述: 我们知道,把原来的链表拆分为两个链表hi和lo,然后分别放在新数组的j和j+oldCap的位置上.拆分的标准就是(e.hash & oldCap) == 0, 那么这个表达式到底是怎么得出来的呢?
大家有没有发现, hi和lo,是不是感觉有点熟悉, 就是hight和low的前两个字母啊, 那么也就是说,
用 (e.hash & oldCap) == 0 这个来判断以前的链表元素中是应该放在低位还是高位, OK,坚持,我们快接近答案了, 只要我们搞懂(e.hash & oldCap) == 0是怎么来的,就豁然开朗了,下面我们专门讲解(e.hash & oldCap) == 0

2.1.1 (e.hash & oldCap) == 0

首先我们要明确三点:

  • oldCap一定是2的整数次幂, 这里假设是2^m
  • newCap是oldCap的两倍, 则会是2^(m+1)
  • hash对数组大小取模(n - 1) & hash 其实就是取hash的低m位

例如:
我们假设 oldCap = 16, 即 2^4,
16 - 1 = 15, 二进制表示为 0000 0000 0000 0000 0000 0000 0000 1111
可见除了低4位, 其他位置都是0(简洁起见,高位的0后面就不写了), 则 (16-1) & hash 自然就是取hash值的低4位,我们假设它为 abcd.
以此类推, 当我们将oldCap扩大两倍后, 新的index的位置就变成了 (32-1) & hash, 其实新的index就是取 hash值的低5位. 那么对于同一个Node, 低5位的值无外乎下面两种情况:0abcd 和 1abcd
那么怎么确定这个第4位(我们把最低为称作第0位)的值呢?只要:

hash & 0000 0000 0000 0000 0000 0000 0001 0000

即hash & oldCap
如果hash & oldCap ==0,说明第4位是0,则该节点在新表的下标位置与旧表一致
如果 (e.hash & oldCap) == 1, 说明第4位是1, 那么新节点了位置就是1abcd, 而1abcd = 0abcd + 10000 = 0abcd + oldCap
故虽然数组大小扩大了一倍,但是同一个key在新旧table中对应的index却存在一定联系: 要么一致,要么相差一个 oldCap。

故得出结论:
如果 (e.hash & oldCap) == 0 则该节点在新表的下标位置与旧表一致都为 j
如果 (e.hash & oldCap) == 1 则该节点在新表的下标位置 j + oldCap
真的是妙蛙种子的妙啊,真的牛*

下面我们再以图示的方式看下链表的重放
在这里插入图片描述
在这里感谢这位大佬的图和讲解,我的讲解大多搬自他这里
下面我们继续看put(),这个方法很重要

2.2 put(K key, V value)

先上源码

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;

        //HashMap的三个构造函数中, 都不会初始Table, 因此第一次put值时,
        // table一定是空的, 需要初始化,table的初始化是延迟到put操作中的,resize()已讲过
        // 扩容时机1: 第一次put时
        //总结:这一步就是初始化map,获取初始化后的数组大小
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;

        //如果计算出来的下标对应的桶没有值,则new一个node放进去
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);

        //走到这就是目标位置桶中已经有东西了
        else {
            Node<K,V> e; K k;
            //注意:p在前一个if块中已经赋值了,p指向数组中index位置的元素
            //判断当前要存储的key和桶中存在的key是否相等,可以看出,key相等的条件是
            //1. key的hashCode相等
            //2. key的 == 为true或者equals为true
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;

            //如果桶中存放的是红黑树,这个自行研究吧
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

            //走到这就是链表了
            else {
                //死循环遍历链表
                for (int binCount = 0; ; ++binCount) {
                    //如果找到了尾结点,就new一个node放在后面
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        //如果链表的长度达到了8个, 就将链表转换成红黑数以提升查找性能
                        //下面单独将treeifyBin()
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    //如果链表上有一个node的key和要put的key一样就break了
                    //也就是说当put的时候,如果发现了要put的key和节点的key一样,就不往下遍历了
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
		// 到这里说明要么待存储的key存在, e保存已经存在的值
        // 要么待存储的key不存在, 则已经新建了Node将key值插入, e的值为Null


           // 如果待存储的key值已经存在,替换旧值,并返回旧值
            if (e != null) { // existing mapping for key
                V oldValue = e.value;

                //如果onlyIfAbsent为false
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);//这个函数只在LinkedHashMap中用到, 这里是空函数
                return oldValue;
            }
        }
        //记录map被修改的次数
        ++modCount;

        // 因为又插入了新值, 所以我们得把数组大小加1, 并判断是否需要重新扩容
        //扩容时机2: 如果等于阈值,还没扩容,下次put的时候就扩容了
        if (++size > threshold)
            resize();

        //这个函数只在LinkedHashMap中用到, 这里是空函数
        afterNodeInsertion(evict);
        return null;
    }
2.2.1 treeifyBin(Node<K,V>[] tab, int hash)

这是在链表长度达到8个的前提下

final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        //扩容时机3: 如果数组长度小于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 {
                TreeNode<K,V> p = replacementTreeNode(e, 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);
        }
    }

3 扩容时机

经过上面的讨论,下面的几种情况下会发生扩容

  • 第一次put时
  • 如果达到阈值,则下次put的时候就扩容了
  • 如果链表长度达到8了,但数组长度小于64时,则扩容来减小链表长度,而不是转为红黑树,注意:在扩容完成之后,如果某个index是树,同时现在该树的节点个数又小于等于6个了,则会将该树转为链表。

四、补充

4.1 头插和尾插

写到这,我们就剩一个问题没有解决了,就是在解决hash冲突时, jdk8为什么要把头插改为尾插呢?

  • 头插法: 在链表的头部插入元素,并将next指针指向之前的头部
  • 尾插法: 新插入的元素在链表的尾部
    直接说结论: 因为头插法在多线程环境下扩容, 会出现链表的死循环的情况.而采用尾插法则不会有这个问题

4.2 负载因子0.75

负载因子的含义是:当map中的key和容量(数组长度)的比值达到负载因子时,触发扩容操作.

  • 如果当负载因子设置的很大时,那么空间利用率会提高, 但随之而来的是hash冲突的概率会变大,
  • 如果负载因子设置的很小, 那么hash冲突的概率会变小,但是会有频繁的扩容操作,而且空间利用率变低,
  • 所以,负载因子要定一个空间利用率和hash冲突的折中
    那么这个值应该是多少呢? 这个在hashMap的源码注释中告诉了我们:
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)).

这个意思是说当负载因子设置为0.75时,根据泊松分布, 每个桶的数据量(即链表长度)很少会达到8,这样查找的效率会比较高

总结

这些就是我对HashMap的理解,后续有新的理解后会更新在这篇中,读者有什么疑问亦可写在评论中,我们共同进步!!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值