Java中的STL-HashMap

10 篇文章 0 订阅

Java中的STL-HashMap

image

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

JDK1.8是这样定义的HashMap的,这里我有一个问题。AbstractMap实现了Map接口,HashMap是继承AbstractMap的,为什么还要实现Map接口呢?我也是今天刚发现。查了资料主要有下面两种说法。

  • 在动态代理的时候会用到getInterfaces。因为Java的动态代理必须要求被代理类实现接口,Proxy.newProxyInstance()的第二个参数就是要接口数组,getInterfaces会在这里用到的。
  • 这就是一个写法上的错误,并没有深意。

我觉得第一种说法应该是对的。

主要API
void                 clear()
Object               clone()
boolean              containsKey(Object key)
boolean              containsValue(Object value)
Set<Entry<K, V>>     entrySet()
V                    get(Object key)
boolean              isEmpty()
Set<K>               keySet()
V                    put(K key, V value)
void                 putAll(Map<? extends K, ? extends V> map)
V                    remove(Object key)
int                  size()
Collection<V>        values()
HashMap的数据结构
  • HashMap是通过"拉链法"实现的哈希表。它包括几个重要的成员变量:table, size, threshold, loadFactor, modCount。
  • table是一个Entry[]数组类型,而Entry实际上就是一个单向链表。哈希表的"key-value键值对"都是存储在Entry数组中的。
  • size是HashMap的大小,它是HashMap保存的键值对的数量。
  • threshold是HashMap的阈值,用于判断是否需要调整HashMap的容量。threshold的值=“容量*加载因子”,当HashMap中存储数据的数量达到threshold时,就需要将HashMap的容量加倍。
  • loadFactor就是加载因子。
  • modCount是用来实现fail-fast机制的。
HashMap源码解析
HashMap的几个成员变量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

static final int MAXIMUM_CAPACITY = 1 << 30;

static final float DEFAULT_LOAD_FACTOR = 0.75f;

static final int TREEIFY_THRESHOLD = 8;

static final int UNTREEIFY_THRESHOLD = 6;


static final int MIN_TREEIFY_CAPACITY = 64;

transient Node<K,V>[] table;

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

transient int size;

transient int modCount;

int threshold;

final float loadFactor;
  • DEFAULT_INITIAL_CAPACITY 默认初始容量16个箱子(Hash值相同的放到一个箱子里)
  • MAXIMUM_CAPACITY 最大容量是1<<30个Entry
  • DEFAULT_LOAD_FACTOR 默认的加载因子是0.75,也就是说当HashMap中的键值对数目大于当前容量的0.75倍时,就要进行扩容和rehash了。
  • TREEIFY_THRESHOLD 如果哈希函数不合理,即使扩容也无法减少箱子中链表的长度,因此 Java 的处理方案是当链表太长时,转换成红黑树。这个值表示当某个箱子中,链表长度大于 8 时,有可能会转化成树。
  • UNTREEIFY_THRESHOLD 如果发现链表长度小于 6,则会由树重新退化为链表。
  • MIN_TREEIFY_CAPACITY 在转变成树之前,还会有一次判断,只有键值对数量大于 64 才会发生转换。这是为了避免在哈希表建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。
  • table Node类型的数组
  • entrySet Map中的所有Node
  • size Map中Node的数目
  • threshold 阀值,Node的数目到达这个数目,就要扩容了。
  • loadFactor 加载因子,capacity * loadFactor = threshold

学过概率论的读者也许知道,理想状态下哈希表的每个箱子中,元素的数量遵守泊松分布,在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)). 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

大意是说,HashMap中每个桶内的Entry数量是服从泊松分布的(Poisson distribution),当加载因子是0.75时,概率公式为

P(k) = exp(-0.5) * pow(0.5, k) / k!

计算出k > 8的概率是小于0.00000001的,所以当出现桶中的Entry数量大于8,基本上可以判定为使用不恰当的Hash函数,于是自动将链表结构的Entry变成红黑树结构。

Entry

Entry是HashMap中保存键值对的内部类, 用来存放键值对

interface Entry<K,V> {
    K getKey();

    V getValue();

    V setValue(V value);

    boolean equals(Object o);

    int hashCode();

    public static <K extends Comparable<? super K>, V> Comparator<Map.Entry<K,V>> comparingByKey() {
        return (Comparator<Map.Entry<K, V>> & Serializable)
            (c1, c2) -> c1.getKey().compareTo(c2.getKey());
    }

    public static <K, V extends Comparable<? super V>> Comparator<Map.Entry<K,V>> comparingByValue() {
        return (Comparator<Map.Entry<K, V>> & Serializable)
            (c1, c2) -> c1.getValue().compareTo(c2.getValue());
    }

    public static <K, V> Comparator<Map.Entry<K, V>> comparingByKey(Comparator<? super K> cmp) {
        Objects.requireNonNull(cmp);
        return (Comparator<Map.Entry<K, V>> & Serializable)
            (c1, c2) -> cmp.compare(c1.getKey(), c2.getKey());
    }
    public static <K, V> Comparator<Map.Entry<K, V>> comparingByValue(Comparator<? super V> cmp) {
        Objects.requireNonNull(cmp);
        return (Comparator<Map.Entry<K, V>> & Serializable)
            (c1, c2) -> cmp.compare(c1.getValue(), c2.getValue());
    }
}
Node

Node是HashMap中链表的节点,它的定义方式如下。

static class Node<K,V> implements Map.Entry<K,V> 

Node实现了Map.Entry,需要实现其中的函数。

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

    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    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;
    }
}
  • hash保存当前Node的hash值
  • 重写了equals方法,两个Node相同指的是两个Node中的key和value都相同。
hash函数

下面来看一下HashMap的hash()这个函数是怎么对key进行hash的,很简单

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 如果key是null,则它的hash值是0,否则就是key的hash值h,和h无符号右移16位按位异或。熟悉二进制的同学很清楚,int是4个字节32位,这样操作其实就是将key的hashCode高16位不变,低16位等于key的hashCode高16位和低16位异或。得到的值作为Node的hashCode。

为什么要这么干呢?
这个与HashMap中table下标的计算有关。

n = table.length;
index = (n - 1) & hash

因为 table的长度都是2倍扩增,初始化默认是16,所以n都是2的整数次幂。n - 1转化成二进制后低位全是1,高位全是0。和hash进行 & 操作之后,只有低位参与了index的运算,hash高位都与0按位与,都是0,这样很容易发生哈希碰撞。所以,开发者想了一个办法,使hash值的高位对index的计算结果也有影响,利用key的hashCode的高16位和低16位按位异或得到hash值,效果最好。

tableSizeFor函数
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;
}

这个函数的作用是找到离cap最近的2的整数次幂,因为HashTable的length都是2的整数次幂,所以当调用HashMap的带有capacity的构造函数时,要把hashMap的初始容量规定为距离给定capacity最近的2的整数次幂。我们用一幅图来看这个函数的运行过程。

image

这个算法是不是很优雅、很简洁、很快速!佩服!

我们看看在什么地方会用到这个函数

  • 带有initialCapacity的构造函数中
 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);
}
  • 利用其他Map构造新的Map时
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();
    if (s > 0) {
        if (table == null) { // pre-size
            float ft = ((float)s / loadFactor) + 1.0F;
            int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                     (int)ft : MAXIMUM_CAPACITY);
            if (t > threshold)
                threshold = tableSizeFor(t);
        }
        else if (s > threshold)
            resize();
        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);
        }
    }
}
  • 读取序列化的HashMap时
private void readObject(java.io.ObjectInputStream s)
        throws IOException, ClassNotFoundException {
    // Read in the threshold (ignored), loadfactor, and any hidden stuff
    s.defaultReadObject();
    reinitialize();
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new InvalidObjectException("Illegal load factor: " +
                                         loadFactor);
    s.readInt();                // Read and ignore number of buckets
    int mappings = s.readInt(); // Read number of mappings (size)
    if (mappings < 0)
        throw new InvalidObjectException("Illegal mappings count: " +
                                         mappings);
    else if (mappings > 0) { // (if zero, use defaults)
        // Size the table using given load factor only if within
        // range of 0.25...4.0
        float lf = Math.min(Math.max(0.25f, loadFactor), 4.0f);
        float fc = (float)mappings / lf + 1.0f;
        int cap = ((fc < DEFAULT_INITIAL_CAPACITY) ?
                   DEFAULT_INITIAL_CAPACITY :
                   (fc >= MAXIMUM_CAPACITY) ?
                   MAXIMUM_CAPACITY :
                   tableSizeFor((int)fc));
        float ft = (float)cap * lf;
        threshold = ((cap < MAXIMUM_CAPACITY && ft < MAXIMUM_CAPACITY) ?
                     (int)ft : Integer.MAX_VALUE);

        // Check Map.Entry[].class since it's the nearest public type to
        // what we're actually creating.
        SharedSecrets.getJavaOISAccess().checkArray(s, Map.Entry[].class, cap);
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] tab = (Node<K,V>[])new Node[cap];
        table = tab;

        // Read the keys and values, and put the mappings in the HashMap
        for (int i = 0; i < mappings; i++) {
            @SuppressWarnings("unchecked")
                K key = (K) s.readObject();
            @SuppressWarnings("unchecked")
                V value = (V) s.readObject();
            putVal(hash(key), key, value, false, false);
        }
    }
}
resize函数

这个函数非常重要,我觉得是HashMap中最为重要的一个函数了。

当HashMap的size到达阀值threshold时,需要对HashMap中的table进行扩展,在一般情况下,table变为原来的2倍,table的size总是2的整数次幂。为了方便记录,我直接在源代码中写注释的方式进行理解。

resize函数的源代码如下。笔者JDK版本是1.8。

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    
    // oldTab == null时,说明还没有对Map进行过putVal()操作,HashMap对table的初始化是delay的。
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    
    // Map的table已经初始化过,进行过putVal()操作
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) { // 这里的说明原来table的size已经到达最大容量1<<30了,
            threshold = Integer.MAX_VALUE; //直接让threshold等于 (1<<32 - 1) ,放弃治疗,给最大值。。
            return oldTab; 
        }
        // 如果 newCap = old * 2 < 最大容量并且原容量 > 默认初始容量16
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) 
            newThr = oldThr << 1;  // 阀值也随着增大到两倍
    }
    else if (oldThr > 0) // 这里说明 oldCap == 0 但是 oldThr != 0, 也就是虽然HashMap被初始化了(执行了带有initialCapacity的构造函数,此时threshold等于initialCapacity的值),但是没执行过putVal操作。
        newCap = oldThr; 
    else {               // 这里说明 oldCap == 0 && oldThr == 0 ,说明HashMap是通过无参构造函数构造的,并且还没执行putVal操作
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // 为什么newThr会等于0,看上面的3种情况,只有第二种会使得newThr依然等于0,此时新的Capacity等于oldThr
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor; 
        // 检查是否超过 1<<30,超过的话直接给(1<<32 - 1)
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    
    //新建一个newTab,一般情况下,这个newCap等于原来的size * 2,准备rehash
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null; // 删除原来的数据引用
                if (e.next == null) //e.next == null说明第j个箱子里面只有一个元素
                    newTab[e.hash & (newCap - 1)] = e; // 直接对e的hash值 % (newCap - 1), & 表示按位与,newCap - 1的低位全是1,所以相当于取余数。
                else if (e instanceof TreeNode) // 节点是TreeNode,说明此时该Node已经不是个链表,已经转化为红黑树了,这个箱子的元素数大于8了
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); // 先不讨论这个。
                else { 
                    // 此时,e是一个链表结构,从头开始遍历,这里为什么要有两对head和tail呢?因为此时的capacity是原来的两倍,
                    // 原来的hash % oldCapacity被保存早这个位置,现在可能不是这个位置了。举个例子, oldCap = 8,那么原来的
                    // 3,11,19,27都应该在j = 3这个位置上串联成链表,resize之后,newCap = 16了,这个时候3和19还是在3这个
                    // 位置,但是11和27应该在11这个位置。所以原来的一个链表由于扩容被拆成了两个链表,由于扩容是乘2的方式,
                    // 所以最多有两个链表。
                    // loHead j位置链表头
                    // loTail j位置链表尾
                    // hiHead j + oldCap 位置链表头
                    // hiTail j + oldCap 位置链表尾
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        
                        // 这里的(e.hash & oldCap)非常重要,看代码不难理解,如果(e.hash & oldCap) == 0,说明它应该rehash在j位置的链表上,
                        // 否则它应该rehash在j + oldCap位置的链表上。为什么是e.hash & oldCap呢?还拿刚才的例子,oldCap = 8,原链表中3的
                        // 位置3、11、19、27,计算一下
                        // 3 & 8 = 0
                        // 11 & 8 = 8
                        // 19 & 8 = 0
                        // 27 & 8 = 8
                        // 很神奇,如果结果是0,hash的位置不变就是3,比如3,19;如果是8,hash的位置就变成 3 + 8 = 11,比如11,27
                        // 对二进制很熟悉的朋友肯定能琢磨出来为什么这样可以,这样解释不知道会不会好理解:
                        // 因为capacity都是2的整数次幂,只有一位是1,其他都是0,如果2^n = capacity,则这一位就是低位起第n + 1位。当其他位都相同时,
                        // 第n+1是1的二进制数比第n+1位是0的二进制数大2^n。
                        // 3 = 0 * 8 + 3;
                        // 11 = 1 * 8 + 3;
                        // 19 = 2 * 8 + 3;
                        // 27 = 3 * 8 + 3;
                        // hash值等于偶数乘oldCap + j的,在rehash后的位置总是原位置,hash值等于奇数乘oldCap + j的,在rehash后的位置是 原位置+oldCap,
                        // 造成这两种结果的原因就是3,11,19,27的二进制中,第四位数不同,3和19是0,而11和27是1
                        // 勉强这么解释吧。。
                        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;
}

-------------------------------------割小牛子-------------------------------------------------

putVal函数

putVal是向hashMap中增加数据的函数。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
  • hash key的hash值,还记得吗,(hash = key.hashCode()) ^ hash >>> 16;
  • key和value是插入的Node的key和value
  • onlyIfAbsent 如果这个参数是true,则当map中已经存在该key时,不执行插入。
  • evict 如果是false,说明此时的HashMap正处于被构造的状态。比如通过另一个Map来构造一个HashMap,执行putMapEntries函数。

我还是通过源码上注释方式来理解这个函数。JDK1.8

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还没有初始化,这是该Map的处女插
    if ((tab = table) == null || (n = tab.length) == 0)
        // 进行resize()操作,resize函数的解释在上面。
        n = (tab = resize()).length;
        
        //这里进行取余,(n-1) & hash是hash对n的取余操作,确定应该插入的位置
    if ((p = tab[i = (n - 1) & hash]) == null) // 当前盒子是空的。
        tab[i] = newNode(hash, key, value, null); // 新建一个Node插入作为链表的头
    else {
        Node<K,V> e; K k;
        // p.hash == hash, 不仅是hash碰撞,两个hash值都相等了,这种情况概率很低。
        // 并且当前位置的p.key和要插入的key是同一个地址的引用
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode) // 盒子中Node数量 >= 8 ,已经转化位红黑树存储。先不讲,后面的博客会说红黑树。
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            // 下面这个for循环就是将新的节点插入到链表的最后,很好理解
            for (int binCount = 0; ; ++binCount) {
                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和链表中某个Node的key引用相同地址,此时和上面那种情况相同
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // e != null说明没有遍历到链表末尾就跳出了,说明原链表中已经存在该key了
        if (e != null) {
            // 下面的操作就是通过onlyIfAbsent决定是否要进行替换。
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        resize(); // 大于阀值,进行扩容和rehash
    afterNodeInsertion(evict); //这里是一个回调,afterNodeInsertion是一个空方法
    return null;
}
removeNode
final Node<K,V> removeNode(int hash, Object key, Object value ,boolean matchValue, boolean movable)
  • hash 要删除的key的hash值
  • matchValue 删除时是否要匹配value,是否value值也相同才删除
  • movable 针对红黑树结构,插入时是否移动其他节点

JDK1.8中源码如下

 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;
        // 否则往后移动,查找节点,在这个过程中p一直指向e的前驱
        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);
            }
        }
        
        // 找到了这个node,若不用matchValue,或者matchValue且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;
}

HashMap里面重要的方法就是上面这几个,向put(K, V), get(K)之类的一般都是通过上面几个方法实现的,和你想的实现方式一样呵呵。

下一篇博客说HashMap中的箱子中如果Node的个数 >=8(treeify threshold)的话,转化为红黑树的原理。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值