一篇博客搞懂HashMap相关知识

HashMap

HashMap概念和背景

HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,主要用来存放键值对。HashMap的实现不是同步的,这意味着它不是线程安全的。它的key、value都可以为null,此外,HashMap中的映射不是有序的(存储元素和取出元素的顺序有可能不一致)。

数组:查询速度快,可以根据索引查询;但插入和删除比较困难

链表:查询速度慢,需要遍历整个链表,但插入和删除操作比较容易。

HashMap底层是一个hash表(数组+链表),这种结构集合了数组和链表的好处 。

HashMap的组成结构

JDK版本实现方式节点数>=8节点数<=6链表实现方式多线程死循环问题线程安全
1.8以前数组+单向链表数组+单向链表数组+单向链表头插法不安全
1.8以后数组+单向链表/红黑树数组+红黑树数组+单向链表尾插法不会不安全
  • jdk1.8之前HashMap由数组+链表组成,数组是HashMap的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode方法计算的哈希值相同)——不用拉链法(引入链表),会在数组hash值所在的地址(已有元素)按你规定的步长(一般是1),向后找,直至有空位就放入元素

  • jdk1.8之后由数组+链表/红黑树组成,在解决哈希冲突时,当链表长度大于阈值(或者红黑树边界值,默认为8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。

  • jdk1.8后再单个数组元素上看,红黑树和链表是不共存的,当是从这个HashMap的角度出发,红黑树和链表是共存的。

注意:将链表转换为红黑树前会判断,即便阈值大于8,但是数组长度小于64,此时并不会将链表转变为红黑树,而是选择进行数组扩容(resize方法)

当数组比较小的时候,尽量避免使用红黑树的结构,因为红黑树也是一个平衡二叉树,我们在使用红黑树的时候,必须等保证这个二叉树的平衡,那么就会伴随着会有很多大量的保持红黑树平衡的操作,比如左旋,右旋等操作,如果数组比较小,且每一个数组对应的链表比较短的时候使用红黑树,与直接查找链表比起来,查询效率还更低。

所以为了综合提高性能和减少搜索时间,链表长度大于8并且数组长度大于64时,链表才转换为红黑树

注意:红黑树的节点(元素)的个小于6个,红黑树就会链表化

问题:jdk8后,为什么HashMap的使用了尾插法

主要是为了安全,防止环化

因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

出现了环化,取值就会出现无限循环

image-20220908210354628

使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了

image-20220908210539995

HashMap的PUT原理

HashMap 基于 Hash 算法实现的

当我们往Hashmap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标存储时,

  • 如果散列表为空时,调用resize()初始化散列表

  • 如果没用出现hashcode值相同的key,则直接添加元素到散列表中去

  • 如果出现hashcode值相同的key,此时有两种情况。

    (1)如果key相同,则覆盖原始值;

    (2)如果key不同(出现冲突),则将当前的key-value放入链表中获取时,直接找到hashcode值对应的下标,在进一步判断key是否相同,从而找到对应值。此时为红黑树结构,则直接用红黑树插入。如果是链表,如果链表长度大于8,且数组大于64,大于则进行转换为红黑树,插入键值对;如果链表长度大于8,数组没有大于64,则调用resize()进行扩容数组后插入,都不满足,直接插入链表

理解了以上过程就不难明白HashMap是如何解决hash冲突的问题,核心就是使用了数组的存储方式,然后将冲突的key的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。

HashMap源码分析

下面的源码时jdk8版本下的

静态常量

serialVersionUID

序列化版本号

private static final long serialVersionUID = 362498820763181265L;

(1)默认初始容量-必须是2的幂

16位,0000 0001左移4位 0001 0000

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

(2)集合最大容量最大容量。必须是2的幂

static final int MAXIMUM_CAPACITY = 1 << 30;

(3)默认负载因子(0.75)

static final float DEFAULT_LOAD_FACTOR = 0.75f;

负载因子过大(1)表示数组满了,才扩容,这样避免不了哈希碰撞

负载因子较小(0.5)当数组中的元素达到了一半就开始扩容,既然填充的元素少了,Hash冲突也会减少,那么底层的链表长度或者是红黑树的高度就会降低。查询效率就会增加。但是,此时空间利用率就会大大的降低,原本存储1M的数据,现在就意味着需要2M的空间。

0.75的负载因子符合统计学,可以兼容空间利用率和查询效率,还降低了哈希碰撞

(4)红黑树转换相关常量

链表的长度大于等于8

static final int TREEIFY_THRESHOLD = 8;

红黑树元素(节点)小于大于6

static final int UNTREEIFY_THRESHOLD = 6;

数组最小容量 64

static final int MIN_TREEIFY_CAPACITY = 64;

成员变量

table

table 用来初始化(必须是二的n次幂)、

// 存储元素的数组 
transient Node<K,V>[] table;

在 jdk1.8 中我们了解到 HashMap 是由数组加链表加红黑树来组成的结构,其中 table 就是 HashMap 中的数组,jdk8 之前数组类型是 Entry<K,V> 类型。从 jdk1.8 之后是 Node<K,V> 类型。只是换了个名字,都实现了一样的接口:Map.Entry<K,V>。负责存储键值对数据

entrySet
// 存放具体元素的集合
transient Set<Map.Entry<K,V>> entrySet;
size

HashMap 中存放元素的个数(重点)

// 存放元素的个数,注意这个不等于数组的长度
 transient int size;
modCount

用来记录 HashMap 的修改次数

// 每次扩容和更改 map 结构的计数器
 transient int modCount;  
threshold(*)

用来调整大小下一个容量的值计算方式为(容量*负载因子) 如16 * 0.75 =12

// 临界值 当实际大小(容量*负载因子)超过临界值时,会进行扩容
int threshold;
loadFactor(*)

哈希表的负载因子

// 负载因子
final float loadFactor;

构造方法

无参构造
public HashMap() {
    // 将默认的负载因子0.75赋值给loadFactor,并没有创建数组
   this.loadFactor = DEFAULT_LOAD_FACTOR; 
}
HashMap(int initialCapacity)

指定初始化容量和默认的负载因子的有参构造函数

 // 指定“容量大小”的构造函数
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
HashMap(int initialCapacity, float loadFactor)

构造一个执行容量和指定的负载因子的构造函数

/*
	 指定“容量大小”和“负载因子”的构造函数
	 initialCapacity:指定的容量
	 loadFactor:指定的负载因子
*/
public HashMap(int initialCapacity, float loadFactor) {
    	// 判断初始化容量initialCapacity是否小于0
        if (initialCapacity < 0)
            // 如果小于0,则抛出非法的参数异常IllegalArgumentException
            throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
    	// 判断初始化容量initialCapacity是否大于集合的最大容量MAXIMUM_CAPACITY
        if (initialCapacity > MAXIMUM_CAPACITY)
            // 如果超过MAXIMUM_CAPACITY,会将MAXIMUM_CAPACITY赋值给initialCapacity
            initialCapacity = MAXIMUM_CAPACITY;
    	// 判断负载因子loadFactor是否小于等于0或者是否是一个非数值
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            // 如果满足上述其中之一,则抛出非法的参数异常IllegalArgumentException
            throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
     	// 将指定的负载因子赋值给HashMap成员变量的负载因子loadFactor
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }
// 最后调用了tableSizeFor,来看一下方法实现:
     /*
     	返回比指定初始化容量大的最小的2的n次幂
     */
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

说明:指定初始化容量不一定就是最终的集合容量,因为初始化容量必须要满足2的n次幂,这里会设置一个大于等于指定容量的最小的一个2的n次幂的数值。

HashMap(Map<? extends K, ? extends V> m)
// 构造一个映射关系与指定 Map 相同的新 HashMap。
public HashMap(Map<? extends K, ? extends V> m) {
    	// 负载因子loadFactor变为默认的负载因子0.75
         this.loadFactor = DEFAULT_LOAD_FACTOR;
         putMapEntries(m, false);
 }

putMapEntries()

        /**
         * 实现了 Map.putAll 和 Map 的构造
         * 该函数用于将一个map赋值给新的HashMap
         * @param m 传入map的集合
         * @param evict 最初构建此映射时为false,否则为true
         */
        final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
            // 获取传入map集合的大小
            int s = m.size();
            // 如果大小大于0
            if (s > 0) {
                // 判断"表"节点是否有初始化
                if (table == null) { // pre-size
                    // 将map集合大小除以负载因子h后+1,可以得到HashMap所需的最大负载容量,也就是阈值
                    // 因为会计算出小数因此+1.0F向上取整
                    float ft = ((float)s / loadFactor) + 1.0F;
                    // 当不大于最大容器值的时候使用计算出来的ft的长度
                    // 否则如果大于,则使用的是最大容器的值
                    int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                            (int)ft : MAXIMUM_CAPACITY);
                    // threshold 要调整大小的下一个大小值(容量*负载系数)
                    // 如果t大于当前最大负载容量,则进行调整
                    if (t > threshold)
                        // 返回给定目标容量的两个大小的幂
                        threshold = tableSizeFor(t);
                }
                //说明table已经初始化过了.判断传入map的size是否大于当前map的threshold,如果是,必须要resize
                //这种情况属于预先扩大HashMap容量,再put元素
                else if (s > threshold)
                    // 将表格大小初始化或加倍
                    resize();
                // 将map中的元素逐一添加到HashMap中
                for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                    K key = e.getKey();
                    V value = e.getValue();
                    putVal(hash(key), key, value, false, evict);
                }
            }
        }
  • float ft = ((float)s / loadFactor) + 1.0F这里的加1是因为,size / loadFactor = capacity,但如果算出来的capacity是小数,却又向下取整,会造成容量不够大,所以,如果是小数的capacity,那么必须向上取整
  • 计算出来的容量必须小于最大容量MAXIMUM_CAPACITY,否则直接让capacity等于MAXIMUM_CAPACITY
  • if (t > threshold)这里的threshold成员实际存放的值是capacity的值。因为在table还没有初始化时(table还是null),用户给定的capacity会暂存到threshold成员上去(毕竟HashMap没有一个成员叫做capacity,capacity是作为table数组的大小而隐式存在的)
  • else if (s > threshold)说明传入map的size都已经大于当前map的threshold了,即当前map肯定是装不下两个map的并集的,所以这里必须要执行**resize()**操作
  • putval也是使用的默认修饰符,因此只能被本类或者该包下的类访问到,最后循环里的putVal可能也会触发resize操作

成员方法

put(K key, V value) *

集合添加元素

//调用了putVal方法
public V put(K key, V value) {
	return putVal(hash(key), key, value, false, true);
}

 //HashMap.put的具体实现
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //判定table不为空并且table长度不可为0,否则将从resize函数中获取
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
     //这样写法有点绕,其实这里就是通过索引获取table数组中的一个元素看是否为Null
    if ((p = tab[i = (n - 1) & hash]) == null)
        //若判断成立,则New一个Node出来赋给table中指定索引下的这个元素
        tab[i] = newNode(hash, key, value, null);
    else {  //若判断不成立
        Node<K,V> e; K k;
         //对这个元素进行Hash和key值匹配
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode) //如果数组中德这个元素P是TreeNode类型
            //判定成功则在红黑树中查找符合的条件的节点并返回此节点
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else { //若以上条件均判断失败,则执行以下代码
            //向Node单向链表中添加数据
            for (int binCount = 0; ; ++binCount) {
                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;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e; //p记录下一个节点
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold) //判断是否需要扩容
        resize();
    afterNodeInsertion(evict);
    return null;
}

1.首先获取Node数组table对象和长度,若table为null或长度为0,则调用resize()扩容方法获取table最新对象,并通过此对象获取长度大小

2.判定数组中指定索引下的节点是否为Null,若为Null 则new出一个单向链表赋给table中索引下的这个节点

3.若判定不为Null,我们的判断再做分支

  • 1 首先对hash和key进行匹配,若判定成功直接赋予e
  • 2 若匹配判定失败,则进行类型匹配是否为TreeNode 若判定成功则在红黑树中查找符合条件的节点并将其回传赋给e
  • 3 若以上判定全部失败则进行最后操作,向单向链表中添加数据若单向链表的长度大于等于8,则将其转为红黑树保存,记录下一个节点,对e进行判定若成功则返回旧值

4.最后判定数组大小需不需要扩容

hash(Object key)

位运算获取hash值

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
resize() *
//重新设置table大小/扩容 并返回扩容的Node数组即HashMap的最新数据
final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table; //table赋予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;
            }
             //若新表大小(oldCap*2)小于数组极限大小 并且 老表大于等于数组初始化大小
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                //旧数组大小oldThr 经二进制运算向左位移1个位置 即 oldThr*2当作新数组的大小
                newThr = oldThr << 1; // double threshold
        }
         //若老表中下次扩容大小oldThr大于0
        else if (oldThr > 0)
            newCap = oldThr;  //将oldThr赋予控制新表大小的newCap
        else { //若其他情况则将获取初始默认大小
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //若新表的下表下一次扩容大小为0
        if (newThr == 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) { //若oldTab中有值需要通过循环将oldTab中的值保存到新表中
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {//获取老表中第j个元素 赋予e
                    oldTab[j] = null; //并将老表中的元素数据置Null
                    if (e.next == null) //若此判定成立 则代表e的下面没有节点了
                        newTab[e.hash & (newCap - 1)] = e; //将e直接存于新表的指定位置
                    else if (e instanceof TreeNode)  //若e是TreeNode类型
                        //分割树,将新表和旧表分割成两个树,并判断索引处节点的长度是否需要转换成红黑树放入新表存储
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null; //存储与旧索引的相同的节点
                        Node<K,V> hiHead = null, hiTail = null; //存储与新索引相同的节点
                        Node<K,V> next;
                        //通过Do循环 获取新旧索引的节点
                        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;
    }

扩容机制:

  • 什么时候才需要扩容

初始化或者当HashMap 中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容,loadFactor的默认值是 0.75。

  • HashMap 的扩容是什么

进行扩容,会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,是非常耗时的。在编写程序中,要尽量避免 resize。

HashMap 在进行扩容时,使用的 rehash 方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n - 1) & hash 的结果相比,只是多了一个 bit 位,所以结点要么就在原来的位置,要么就被分配到 “原位置 + 旧容量” 这个位置

image-20220908220211655

我们在扩充 HashMap 的时候,不需要重新计算 hash,只需要看看原来的 hash 值新增的那个 bit 是 1 还是 0 就可以了,是 0 的话索引没变,是 1 的话索引变成 “原位置 + 旧容量”

remove(Object key)

通过key,得到元素位置后删除

// remove方法的具体实现在removeNode方法中,所以我们重点看下removeNode方法
public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
}

removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable)

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
	// 根据hash找到位置 
	// 如果当前key映射到的桶不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        // 如果桶上的结点就是要找的key,则将node指向该结点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        else if ((e = p.next) != null) {
            // 说明结点存在下一个结点
            if (p instanceof TreeNode)
                // 说明是以红黑树来处理的冲突,则获取红黑树要删除的结点
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                // 判断是否以链表方式处理hash冲突,是的话则通过遍历链表来寻找要删除的结点
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        // 比较找到的key的value和要删除的是否匹配
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            // 通过调用红黑树的方法来删除结点
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                // 链表删除
                tab[index] = node.next;
            else
                p.next = node.next;
            // 记录修改次数
            ++modCount;
            // 变动的数量
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}
get(Object key)

通过元素的 key 找到 value

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

getNode(int hash, Object key)

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    // 如果哈希表不为空并且key对应的桶上不为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        /* 
        	判断数组元素是否相等
        	根据索引的位置检查第一个元素
        	注意:总是检查第一个元素
        */
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        // 如果不是第一个元素,判断是否有后续结点
        if ((e = first.next) != null) {
            // 判断是否是红黑树,是的话调用红黑树中的getTreeNode方法获取结点
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                // 不是红黑树的话,那就是链表结构了,通过循环的方法判断链表中是否存在该key
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

问题汇总

在使用 HashMap 的时候,用 String 做 key 有什么好处?

HashMap 内部实现是通过 key 的 hashcode 来确定 value 的存储位置,因为字符串是不可变的,所以当创建字符串时,它的 hashcode 被缓存下来,不需要再次计算,所以相比于其他对象更快。

拉链法导致的链表过深问题为什么不用二叉查找树代替,而选择红黑树?为什么不一直使用红黑树和链表再不同的临界点转换?

选择红黑树是为了解决二叉查找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题,我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树,如果链表长度很短的话,根本不需要引入红黑树,引入反而会慢。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值