HashMap源码分析

声明:以下内容均基于JDK1.8

HashMap类图

HashMap是java.util包下的Map接口下的一个子类,也是我们常用的容器,是一个支持自动扩容、无序的key-value映射容器。

  • AbstractMap抽象类:Map接口下的默认实现抽象类,实现一些Map通用的方法。
  • Cloneable接口:实现对象拷贝必须要实现的接口。
  • Serializable接口:实现序列化必须要实现的接口。

HashMap属性

HashMap的属性如上所示,下面一一分析:

serialVersionUID

    private static final long serialVersionUID = 362498820763181265L;

序列化ID

DEFAULT_INITIAL_CAPACITY

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

默认初始容量大小,1右移4位,即16

MAXIMUM_CAPACITY 

    static final int MAXIMUM_CAPACITY = 1 << 30;

最大可以扩容的容量,即数组的最大长度,1右移30位

DEFAULT_LOAD_FACTOR 

    static final float DEFAULT_LOAD_FACTOR = 0.75f;

默认负载因子0.75

TREEIFY_THRESHOLD 

    static final int TREEIFY_THRESHOLD = 8;

树化临界点,在一个数组槽里面,链表长度超过8个会进行树化,将链表转换为红黑树

UNTREEIFY_THRESHOLD

    static final int UNTREEIFY_THRESHOLD = 6;

反树化临界点,在一个数组槽里,当链表长度低于6个时,会将红黑树重新转换为链表,之所以留下两个空位是避免频繁树化和反树化消耗性能

MIN_TREEIFY_CAPACITY 

    static final int MIN_TREEIFY_CAPACITY = 64;

最小树化容量,想要树化,除了需要满足之前的链表长度超过8的条件之外,还需要满足此时数组长度至少64。如果数组长度小于64,他会认为并非数据量大导致Hash冲突变多,而是认为存储的数据有问题,导致Hash冲突过大,并不会选择转换红黑树而是扩大容量。

table

    transient Node<K,V>[] table;

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        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数组,用来存储数据的地方,Node是HashMap定义的内部类,一个典型的单链表结点,里面保存了key、value、key的hash值和后指针。

entrySet

    transient Set<Map.Entry<K,V>> entrySet;

    final class EntrySet extends AbstractSet<Map.Entry<K,V>>

作为entry()的缓存,Entry是Map接口定义的一个接口,HashMap实现了该接口,里面保存一个键值对,以及实现了一些通用方法。

size

    transient int size;

当前容器里元素的个数

modCount

    transient int modCount;

修改次数,用于迭代器迭代时执行快速失败策略

threshold

    int threshold;

当数组中的元素达到多少时进行扩容,threshold = capacity * loadFactor

loadFactor

    final float loadFactor;

负载因子

TreeNode

    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;
        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);
        }
    }

HashMap的内部类,继承于LinkedHashMap的Entry,是一个典型的树型节点,里面保存了父节点、左右孩子和prev指针,prev是链表中的节点,用于删除元素时可以快速找到它的前置节点。

LinkedHashMap的Entry是一个典型的双链表,拥有前后指针。

HashMap常用API

    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;
        this.threshold = tableSizeFor(initialCapacity);
    }
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

HashMap的构造函数

  • 空参:使用默认的初始容量16和默认负载因子0.75
  • 一个参数:可以指定初始化容量大小,负载因子还是用默认的0.75
  • 两个参数:可以指定初始化容量大小和负载因子

初始化步骤:

  • 判断传入参数合理性
    • 初始容量小于0则抛异常,大于最大值则使用最大容量
    • 负载因子小于0或者非数字则抛出异常
  • 将传入负载因子赋值给loadFactor属性
  • 调用tableSizeFor方法,将传入的初始化容量变为2的n次方(具体就是将数字先减一,然后通过右移和或运算全部变为1,然后再加上1就可以变成1后面全是0,也就是2的n次方,之所以将容量设置为2的n次方,原因是在将元素的hashCode转换为数组的下标时,用的用非取模,而是与操作,只有容量为2的n次方,才能用与操作替代取模)

注意:

  • 在构造方法中,我们发现并没有进行数组的初始化,只是进行了相关属性的赋值操作,HashMap采用了懒加载策略,具体的初始化操作是在第一次调用put方法的时候。
  • 刚才我们介绍了threshold属性,说他是容器扩容的临界值,等于capacity * loadFactor,但此时却直接将容器的容量赋值给了他,并没有乘负载因子,其实在第一次调用put方法时会重新计算。
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    // tab:内部数组
    // p:hash对应的索引位中的首节点
    // n:内部数组的长度
    // i:hash对应的索引位
    HashMap.Node<K,V>[] tab; HashMap.Node<K,V> p; int n, i;
    
    // 首次put时,内部数组为空,扩充数组。
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 计算数组索引,获取该索引位置的首节点,如果为null,添加一个新的节点
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {   
        HashMap.Node<K,V> e; K k;
        // 如果首节点的key和要存入的key相同,那么直接覆盖value的值。
        if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果首节点是红黑树的,将键值对插添加到红黑树
        else if (p instanceof HashMap.TreeNode)
            e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 此时首节点为链表,如果链表中存在该键值对,直接覆盖value。
        // 如果不存在,则在末端插入键值对。然后判断链表是否大于等于7,尝试转换成红黑树。
        // 注意此处使用“尝试”,因为在treeifyBin方法中还会判断当前数组容量是否到达64,
        // 否则会放弃次此转换,优先扩充数组容量。
        else {
        	// 走到这里,hash碰撞了。检查链表中是否包含key,或将键值对添加到链表末尾
            for (int binCount = 0; ; ++binCount) {
                // p.next == null,到达链表末尾,添加新节点,如果长度足够,转换成树结构。
                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;
            }
        }

        // 覆盖value的方法。
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount; // fail-fast机制
    
    // 如果元素个数大于阈值,扩充数组。
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

final HashMap.Node<K,V>[] resize() {
    HashMap.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;
        }
        // 否则数组容量扩充一倍。(2的N次方)
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 如果数组还没创建,但是已经指定了threshold(这种情况是带参构造创建的对象),threshold的值为数组长度
    // 在 "构造函数" 那块内容进行过说明。
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 这种情况是通过无参构造创建的对象
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 可能是上面newThr = oldThr << 1时,最高位被移除了,变为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"})
    HashMap.Node<K,V>[] newTab = (HashMap.Node<K,V>[])new HashMap.Node[newCap];
    table = newTab;
    
    // 下面代码是将原来数组的元素转移到新数组中。问题在于,数组长度发生变化。 
    // 那么通过hash%数组长度计算的索引也将和原来的不同。
    // jdk 1.7中是通过重新计算每个元素的索引,重新存入新的数组,称为rehash操作。
    // 这也是hashMap无序性的原因之一。而现在jdk 1.8对此做了优化,非常的巧妙。
    if (oldTab != null) {
        
        // 遍历原数组
        for (int j = 0; j < oldCap; ++j) {
            // 取出首节点
            HashMap.Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // 如果链表只有一个节点,那么直接重新计算索引存入新数组。
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果该节点是红黑树,执行split方法,和链表类似的处理。
                else if (e instanceof HashMap.TreeNode)
                    ((HashMap.TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                
                // 此时节点是链表
                else { // preserve order
                    // loHead,loTail为原链表的节点,索引不变。
                    HashMap.Node<K,V> loHead = null, loTail = null;
                    // hiHeadm, hiTail为新链表节点,原索引 + 原数组长度。
                    HashMap.Node<K,V> hiHead = null, hiTail = null;
                    HashMap.Node<K,V> next;
                    
                   // 遍历链表
                    do {
                        next = e.next;
                        // 新增bit为0的节点,存入原链表。
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // 新增bit为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;
}

/**
 * Replaces all linked nodes in bin at index for given hash unless
 * table is too small, in which case resizes instead.
 */
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) {
        // 将链表转成红黑树,略 
    }
}

put方法:向HashMap里插入一个元素。

put方法底层会调用putVal方法去执行操作,传入的参数如下:

  • hash(key):将key进行hash处理
  • key:元素的键
  • value:元素的值
  • onlyIfAbsent:当存入键值对时,如果该key已存在,是否覆盖它的value。false为覆盖,true为不覆盖。 参考putIfAbsent()方法。
  • evict:用于子类LinkedHashMap。

HashMap对计算Key的HashCode值时,并非直接调用key的hashCode方法,而是自己定义了hash方法,hash方法的作用是将

hashCode进一步的混淆,增加其“随机度”,试图减少插入HashMap时的hash冲突,换句更专业的话来说就是提高离散性能。

  • 首先先判断key是否为null,如果为null,则将hashCode置为0
  • 否则,将key的hashCode右移16位然后进行与或运算,即将key的HashCode的高低16进行与或运算

那么,为什么将高低16位进行异或运算能减少hash冲突?

这和putVal方法中将HashCode转换为数组索引的方法有关,他的转换方法如下:

i = (n - 1) & hash

n是数组长度,hash就是通过hash()方法进行高低位异或运算得出来的hash值。

这个表达式就是hash值的取模运算,上面已经说过当除数数为2的次方时,可以用与运算提高性能。

那么我们想想,大多数情况下,内部数组的容量一般都不会很大,基本分布在16~256之间。所以一个32位的hashCode,一直都用最低的4到8位进行与运算,而高位几乎没有参与。

所以通过hash()方法,将hashCode高16位与低16位进行异或运算,能有效的提高离散性能。

putVal方法是执行具体的插入操作,具体如下:

  • 首先判断是否是第一次插入元素,如果是,调用resize方法进行扩容,并将扩容后的容量大小返回
  • 通过hash值计算数组索引,获取首节点
  • 判断首节点是否为空,如果为空,则创建节点插入进去
  • 如果不为空,那么有以下三种情况
    • 判断首节点的key是否和传入的key重复,如果重复则覆盖之前的值
    • 如果不重复,则判断首节点是否是红黑树节点,如果是,则调用putTreeVal方法添加节点
    • 如果不是红黑树节点,则一定是链表节点,遍历链表,如果发现已经存在key,则覆盖之前的节点,如果没有,则创建节点添加在末尾,判断当前链表长度是否>=7,如果是,则进行树化(其实树化的还有一个条件是数组容量大于64,具体在treeifyBin方法的逻辑里,后面会分析)

resize方法是进行扩容相关的操作,具体如下:

  • 首先判断是否是第一次put元素,如果是,则判断传入了哪些参数,然后进行相应的初始化,创建一个Node数组,并重新计算threshold
  • 如果不是第一次put元素,则计算以前的容量和threshold,扩容后的容量为之前的容量左移一位,即以前容量的2倍,扩容完成后,更新threshold等相关参数,然后将之前数组里的数据转移到新数组里面去

扩充数组不单单只是让数组长度翻倍,将原数组中的元素直接存入新数组中这么简单。

因为元素的索引是通过hash&(n - 1)得到的,那么数组的长度由n变为2n,重新计算的索引就可能和原来的不一样了。

在jdk1.7中,是通过遍历每一个元素,每一个节点,重新计算他们的索引值,存入新的数组中,称为rehash操作。

而java1.8对此进行了一些优化,因为当数组长度是通过2的次方扩充的,那么会发现以下规律:

元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为数组的长度,图(a)表

示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1

是key1对应的哈希与高位运算结果。

元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:

这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此

resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中

rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8

不会倒置。

treeifyBin方法:在链表中插入元素大于等于7个时,会调用该方法。具体逻辑如下:

  • 首先判断数组的长度,如果小于64,则取消树化,进行扩容
  • 否则,进行树化,先将要传入的元素插入链表,然后调用treeify方法进行树化

因此,要触发树化,需要满足两个条件,数组长度大于等于64,并且链表长度大于等于7进行树化。

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

    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        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) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

get方法:获得一个key的value值。

get方法的逻辑很简单,定义一个Node节点用来接收getNote方法查询的结果,如果没有找到则返回null,否则返回该元素的value。

getNode方法:具体的元素操作。

  • 首先通过hash值确定数组的槽位,拿到首节点,然后判断该位置是否为空,如果为空,直接返回null
  • 如果不为空,判断该节点的key值是否和查找的key相等,如果相等,则就是要查找的元素,直接返回
  • 如果不相等,则说明该节点并不是要查找的元素,存在hash冲突,判断首节点还有没有后续节点,如果没有,直接返回null
  • 如果有,判断是树节点还是链表节点,如果是树节点,调用getTreeNode方法去查找,如果是链表,循环遍历查找
    public boolean isEmpty() {
        return size == 0;
    }

isEmpty方法:判断容器是否为空。

判断容器里的元素个数是否为0。

    public int size() {
        return size;
    }

size方法:获取容器中元素的数量。

返回容器里存储的元素个数。

    public V remove(Object key) {
        Node<K,V> e;
        return (e = removeNode(hash(key), key, null, false, true)) == null ?
            null : e.value;
    }

    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;
        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;
            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 {
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            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;
    }

remove方法:根据key移除容器中指定的元素。

定义一个Node节点用来接收removeNode方法的返回值,如果没有找到,则返回null,否则,返回移除的元素的值

removeNode方法:查找元素并移除的具体操作。

removeNode和之前的get方法逻辑类似,都是先判断数组是否有元素,如果有元素,则计算数组索引获取首节点,如果首节点key相等则返回,反之判断是树节点还是链表,进行相应的处理,如果没有找到,直接返回null,如果找到,则根据是树节点还是链表进行相应的删除操作,最后modcount++,size--。

    public Collection<V> values() {
        Collection<V> vs = values;
        if (vs == null) {
            vs = new Values();
            values = vs;
        }
        return vs;
    }

values方法:返回容器里所有的value集合。

Values是HashMap定义的一个内部类,用来保存所有的value属性。

    public Set<K> keySet() {
        Set<K> ks = keySet;
        if (ks == null) {
            ks = new KeySet();
            keySet = ks;
        }
        return ks;
    }

keySet方法:返回容器里所有的key集合。KeySet也是内部类,用来保存所有key集合。

    public Set<Map.Entry<K,V>> entrySet() {
        Set<Map.Entry<K,V>> es;
        return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
    }

entrySet方法:返回所有键值对。EntrySet也是内部类。

总结

  • HashMap是一个key-value形式的容器,非线程安全。
  • 默认初始容量16,负载因子0.75,可以调用构造函数指定,容量必须为2的n次方,每次扩容2倍。
  • HashMap采用了懒加载策略,初始化时并不会创建一个数组,第一次put操作时会创建。
  • HashMap利用了大量的移位、与或运算替代乘除取模运算来提高性能。
  • HashMap将hashCode转换为数组索引时,并非用hashCode对数组长度N取模,而是根据规律,当N为2的n次方时,hashCode%N = hashCode & (N - 1),用与运算来代替取模,这也是HashMap容量必须为2的n次方的原因。同时,HashMap定义了hash函数,通过将hashCode的高低16位进行异或运算来对key的hashCode进一步混淆,降低冲突,之所以高低16异或是因为在计算数组索引时,是将hashCode&(N-1),而HashMap的容量一般为16~256之间,也就是4~8位,因此高位几乎都不参与运算,因此高低位异或后可以降低冲突。
  • HashMap在JDK1.8时做了一些优化,一方面引入了红黑树,当链表的元素大于等于7个并且数组长度大于等于64时,会将链表转为红黑树。另一方面,在数组扩容时,以前HashMap会将旧数组遍历rehash放到新数组中,而JDK1.8中,由于数组都是2的n次方,扩容之后索引比之前多了一个高bit位,只需要计算高bit位是0还是1即可,如果是0则放到原下标,如果是1,则新下标位置为原下标加旧数组长度。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值