HashMap 底层实现原理 JDK1.7

一. 底层存储结构

HashMap底层是由数组+链表构成的,HashMap会通过hashcode()为待插入的元素计算存储到的数组下标,在插入到数组中时,会把元素拼装成Entry对象,并构建一个链表。如果同一个数组下标已经存放了Entry,则将后来者插入到链表的头结点上。

横向被称作table[] 数组

纵向被称作bucket哈希桶,实际上就是由Entry组成的链表。

二. 源码分析

1. 成员变量

    /**
      * HashMap底层容器(数组)的默认初始容量 必须是2的幂次方 默认16
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

    /**
     * HashMap底层容器(数组)的最大初始容量 默认2的30次方
     * 用户可以在构造函数中显示的传入数组的最大初始容量,只不过不能超过2的30次方
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * 负载因子 默认0.75  计算公式: 负载因子 = 当前元素个数/容器容量
     * 用户可以在构造函数中显示的指明
     * 当插入数据时导致负载因子大于设定值时,HashMap会对自身容器进行扩容
     * 数值越小看,hash碰撞的可能性就越小,但扩容的频率就越高
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * 初始化一个Entry空数组
     */
    static final java.util.HashMap.Entry<?,?>[] EMPTY_TABLE = {};

    /**
     * 将初始化好的空数组赋值给table table才是真正存储数据的地方
     * 序列化时忽略本属性
     */
    transient java.util.HashMap.Entry<K,V>[] table = (java.util.HashMap.Entry<K,V>[]) EMPTY_TABLE;

    /**
     * HashMap容器中实际存储元素的个数
     */
    transient int size;

    /**
     * 下一个需要调整容器大小的阈值
     * 计算公式: 阈值 = 当前容量 * 负载因子
     */
    int threshold;

    /**
     * 负载因子 //TODO 这个干什么用的?为什么有两个负载因子?
     */
    final float loadFactor;

    /**
     * HashMap的内部结构被修改的次数 modCount用于迭代器
     */
    transient int modCount;

    /**
     * hash计算时阈值的默认值
     */
    static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE;

     /**
     * hash计算时使用到的一个因子
     */
    transient int hashSeed = 0;

2. 构造函数

 /**
     * 构造一个空的HashMap,指定容器初始化大小和负载因子
     */
    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;
        threshold = initialCapacity;
        init();
    }

    /**
     * 构造一个空的HashMap 指定容器初始化大小 使用默认负载因子0.75
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * 构造一个空的HashMap,默认容器初始化大小为16,默认负载因子0.75
     */
    public HashMap() {
        this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

    /**
     * 创建一个与传入Map具有相同数据结构的Map,初始容量大小为原Map的容量大小+1,如果比16小,则取16。
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        inflateTable(threshold);

        putAllForCreate(m);
    }

3. 成员方法 

1. roundUpToPowerOf2( )  输入期望的数组长度,返回经过计算得出的合理的数组长度,确保数组长度是2的N次幂。

    1. 数组长度最大不得超过MAXIMUM_CAPACITY,也就是2的30次方。

    2. Integer.highestOneBit(int n):将n转换成二进制数,只取最高位(非符号位),其余位数补0,计算出新的值。

                                                        比如9的二进制为1001,取最高位并补0后为1000,也就是8。

    3. 如果这个数的二进制数为0,则返回1。

    4. Integer.bitCount(int n): 将n转换成二进制数,返回1的个数。比如7的二进制为0111,,因此返回3。

    5. 不难注意到,2的N次幂比如2,4,8,16,它们转换成二进制数后都只会出现一个1,比如8->1000,16->10000。如果期望数组长度不是2的N次幂,那么会将其取最高位,其余位数补0后,再乘以2,否则直接返回期望数组长度。

    本方法实际上就是在确保数组长度为2的幂,如果期望的长度不是2的幂,就去找到比它大且最邻近的2的幂。比如期望的数组长度为9,对应二进制数1001,由于9不是2的幂,且在2的3次方和2的4次方之间,因此会返回2^3 * 2 = 16。再比如8,由于满足了上述所有约束要求,因此直接返回8。

    private static int roundUpToPowerOf2(int number) {

        int rounded = number >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (rounded = Integer.highestOneBit(number)) != 0
                ? (Integer.bitCount(number) > 1) ? rounded << 1 : rounded
                : 1;

        return rounded;
    }

2.  初始化容器

    通过观察initHashSeedAsNeeded( )方法不难发现,绝大部分情况下hashSeed的值都为0,有可能促使hashSeed重新初始化的因素只有useAltHashing。也就是说,除非人为的在jvm中手动设置可选阈值jdk.map.althashing.threshold,并且要比预期初始化容器的长度小,才可能会重新计算hashSeed的值。(因为在默认情况下,ALTERNATIVE_HASHING_THRESHOLD的值为Integer.MAX_VALUE,capacity一般都比它小,因此useAltHashing=false)

   计算hashSeed的方法是: sun.misc.Hashing.randomHashSeed(Object var0)  

    private void inflateTable(int toSize) {
        // 找到一个>=toSize且是2的N次幂的数
        int capacity = roundUpToPowerOf2(toSize);
        // 重新计算阈值
        threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
        // 重新初始化table
        table = new java.util.HashMap.Entry[capacity];
        initHashSeedAsNeeded(capacity);
    }
    final boolean initHashSeedAsNeeded(int capacity) {
        boolean currentAltHashing = hashSeed != 0;
        boolean useAltHashing = sun.misc.VM.isBooted() &&
                (capacity >= java.util.HashMap.Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean switching = currentAltHashing ^ useAltHashing;
        if (switching) {
            hashSeed = useAltHashing
                    ? sun.misc.Hashing.randomHashSeed(this)
                    : 0;
        }
        return switching;
    }

 3. hash算法

     HashMap使用hash算法来计算某一个元素被存放的位置(数组的下标)。前文提到,HashMap是由数组和链表构成的,为了使元素均匀分布,在理想情况下,希望数组中一个下标中只存放一个元素,换句话说,每一条链表中至多只会有一个元素,这样一来,如果想得知某一个元素被存放的位置,只需要计算它的hash值,就可以直接定位到元素,而不需要再去遍历该下标上对应的链表。

     1. 只有在hashSeed不为0,且k的类型为string时,才会使用stringHash32算法,这个算法是JDK7新引入的。

     2. indexFor(int h, int length): 传入hascode值和数组长度,返回hashcode对应的数组下标。除非length=1,否则此方法一定不会返回0。

final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
}
    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }

4. put( )

     1. 当Hash值相同,key的内存地址和内容都相同,但value不相同时,后者覆盖前者,这个操作不会改变HashMap的数据结构,不会使modeCount++
     2. key为null的元素只能存储在table[0]中,但并不意味着table[0]只能存null。当table的长度为1时,唯一的元素只能存储在table[0]当中。

    public V put(K key, V value) {
        // 如果底层容器为空,则重新初始化容器
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        // 如果key为null,则取出数组中的第一个bucket,也即table[0],这个bucket对应的链表专门用于存放key为null的元素
        // 只有一种情况下,table[0]装载着非null元素,那就是容器本身的初始化长度为1。
        if (key == null)
            return putForNullKey(value);
        // 计算key对应的hash值
        int hash = hash(key);
        // 计算元素应当存储的数组下标
        int i = indexFor(hash, table.length);
        // 遍历table[i]上的Entry对象,判断该链表中是否有相同key值的元素存在
        for (java.util.HashMap.Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            // Hash值相同、equals相同或key的内存地址相同
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                // 鸠占鹊巢 后来者的value覆盖前者的value
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        // HashMap数据结构修改的次数加一
        modCount++;
        // 目前的table中不存在与待新增的key相同的元素,因此将当前元素添加到table[i]中
        addEntry(hash, key, value, i);
        return null;
    }

       put()操作中,HashMap会使用hash()将key计算成hash值,再通过indexFor()找到待新增元素将被存储到数组中的下标,也即找到目标bucket。

       如果不同的key对应的hash值放入indexFor()计算出的bucket下标相同,则说明产生冲突。jdk1.7使用单链表来解决冲突。冲突时,HashMap分成两种情况来解决问题:

       1. bucket下标相同,key值相同(内存地址或者equals相同)。处理方式: 后者的value将覆盖前者的value。
       2. bucket下标相同,key值不同。处理方式: 将本次put()的key和value组装成Entry对象,从当前bucket内已有的Entry链表的头结点处添加Entry对象。

5. Entry

      Entry是HashMap数据结构中的最小单元,用于存储数据信息。Entry有四个属性: 

      1. final K key;     键

      2. V value;          值

      3. Entry<K, V> next    指向下一个Entry的指针

      4. int hash;        当前元素的hash值

      通过以上属性,我们不难得知两条信息: 1. Entry是一个单向链表 2. 同一个bucket内,同一条Entry链表中的hash值一定相同。

      值得注意的方法: 

      1. addEntry( )  

    void addEntry(int hash, K key, V value, int bucketIndex) {
        // 如果新增后容器内存放元素的个数大于等于阈值,并且要待新增的bucket从未添加过元素
        if ((size >= threshold) && (null != table[bucketIndex])) {
            // 对数组进行扩容 期望扩大为原先两倍的容量大小
            resize(2 * table.length);
            // 由于数组的长度发生了变化,因此需要重新计算待新增元素的hash值
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
        createEntry(hash, key, value, bucketIndex);
    }

      2. createEntry( )

          Step1: 找到table[i]位置对应的bucket上原先存在的Entry对象A。

          Step2: 通过hash值、key、value,构造一个新的Entry对象,将它的next指针指向对象A,这样一来,一个新的头结点就构造完毕了,并且旧数据得以保留。

          Step3: 让table[i]指向将新的Entry。

          Step4: HashMap存储的元素个数加1。

    void createEntry(int hash, K key, V value, int bucketIndex) {
        java.util.HashMap.Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new java.util.HashMap.Entry<>(hash, key, value, e);
        size++;
    }

      3. Entry(int h, K k, V v, Entry<K,V> n) 

Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            // next指针指向了旧链表的表头节点
            next = n;
            key = k;
            hash = h;
}

6.  get(Object key)

    首先,通过hash算法计算出待查询key的hash值,接着通过indexFor找到目标bucket在数组容器中的位置(下标)。如果目标bucket中的Entry链表为null,则返回null,否则循环遍历链表中的每一个Entry对象,直到某个Entry对应的key值与待查询的key相等(equals或者内存地址相同)时,返回当前Entry对应的value值。

    这种 bucket单向链表的数据结构的缺陷在查询时被暴露的玲离尽致: 如果待查询的key恰好在单向链表的尾端,并且这个key对应的hash碰撞问题非常严重,那么在极端情况下,数组+单向链表的数据结构将退化成普通的单向链表,时间复杂度由对数阶变成了线性阶,极大地影响了查询性能。

public V get(Object key) {
        // 若key为null,遍历table[0]处的链表(实际上要么没有元素,要么只有一个Entry对象),取出key为null的value
        if (key == null)
            return getForNullKey();
        // 若key不为null,用key获取Entry对象
        Entry<K,V> entry = getEntry(key);
        // 若链表中找到的Entry不为null,返回该Entry中的value
        return null == entry ? null : entry.getValue();
    }

    final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }
        // 计算key的hash值
        int hash = (key == null) ? 0 : hash(key);
        // 计算key在数组中对应位置,遍历该位置的链表
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            // 若key完全相同,返回链表中对应的Entry对象
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        // 链表中没找到对应的key,返回null
        return null;
    }

7. 扩容 

    当存放元素的个数大于或等于容器容量与负载因子的乘积时,HashMap就会触发扩容(默认情况下是 16 * 0.75 = 12)。通过调用resize()方法,将数组的容量扩展为原来的两倍,并对原先table中存放的元素重新计算hash值,最终迁移到新的table中。以上过程又被称作rehash,因为它为元素重新调用hash方法,计算hash值以及存储在新数组中的位置。

    1. resize()

        1. 每一次扩容,都会给GC带来压力。因为旧数组失去引用后,会被GC在合适的时机回收。

        2. threshold = Integer.MAX_VALUE; 这局代码看似写的有问题, 数组的最大容量是MAXIUM_CAPACITY=2的30次方,而阈值threshold居然能够达到Integer.MAX_VALUE=2的31次方-1,HashMap难道不怕数组越界吗?其实这种担心是多余的,因为这是Entry[]数组,每一个元素实际上是一条链表,又可以挂载多个元素。threadshold阈值希望限制的不是数组内元素的个数,而是HashMap容器中,key(键)的个数。

           这样想来,回过头来看看公式: 负载因子 = 元素个数 / 数组容量,不难发现,负载因子的值实际上是可以大于1的。

    void resize(int newCapacity) {
        java.util.HashMap.Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        // 若旧数组的容量已经达到上限,则把阈值提高到Integer.MAX_VALUE
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
        // 根据新传入的容量创建新数组
        java.util.HashMap.Entry[] newTable = new java.util.HashMap.Entry[newCapacity];
        // 为每一个旧元素重新计算hash值,分配存储位置,并将旧元素迁移至新数组中
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        // table指针指向新数组,旧数组由于失去引用,因此会在合适的时机被GC回收
        table = newTable;
        // 重新计算阈值 数组容量 * 负载因子 最大不能超过MAXIMUM_CAPACITY + 1
        // 这里设置成MAXIMUM_CAPACITY + 1也是有原因的,按照最理想的情况下,元素在数组中均匀分布,每一个元素至占用一个数组空位
        // 此时元素的个数(size)为MAXIMUM_CAPACITY,接下来再新增元素时,会调用addEntry()方法,其中的条件表达式size >= threshold
        // 会使得数组触发扩容机制,尽量避免出现hash值冲突,形成链表的情况。
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }

    2. transfer()

        首先遍历旧数组中的每一个bucket,接着遍历bucket对应链表中的每一个Entry节点。扩容并非意味着一定要为每一个旧的                key-value重新计算hash值,当且仅当initHashSeedAsNeed()==true,新数组的预期容量比可选阈值还要大时,才需要重新            计算hash值(前文提到,可选阈值默认为Integer.MAX_VALUE)。但扩容一定会为每一个key-value重新计算存储在新数组中的位置(使用indexFor())

        注意: 如果不重新计算key的hash值,那么在使用indexFor()重新计算准备存储的bucket下标后,产生的结果只有两种可能: 原下标或原下标+oldCapacity

        原因在于indexFor()的内部实现为hash & (capacity-1),由于capacity本身保证是2的N次幂,因此在二进制下,capacity减1后除最高位外,其余全为1。大家都知道,操作数无论是0还是1,在和1做与运算时,结果仍等于操作数本身。经过扩容后,数组的容量会变成原来的两倍,因此newCapacity-1会比oldCapacity-1除符号位以外多出一个1,这样一来,重新计算indexFor()的关键就在于在二进制下,hash对应oldCapacity最高位N的数字到底是0还是1,如果是0,则计算出的结果不发生变化,如果是1,则转换到十进制后,新值比旧值多出一个oldCapacity。

        比如hash值为44,与31做与运算:   (0)101100 & (0)011111 = (0)001100    -> 12

        扩容后,44 & (64-1)  =>      (0)101100 & (0)111111 = (0)101100     -> 32+12 = 44

        hash对应oldCapacity除符号位以外的最高位(第6位)恰好为1,因此最终计算出的值要比旧值多出2的5次方。

 

    void transfer(java.util.HashMap.Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (java.util.HashMap.Entry<K,V> e : table) {
            while(null != e) {
                java.util.HashMap.Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

三. 为什么是0.75

           为什么HashMap默认的负载因子的值为0.75,而不是0.7,0.8?0.75究竟有什么神奇之处呢?

           关键词: 泊松分布。在理想情况下,使用默认的负载因子和随机的hash码能使得每一个bucket中的节点数量遵循泊松分布,从泊松分布表中可以得知,当某个bucket中存放达到8个元素时,再向容器添加新的元素,几乎不会存放到这个bucket中。也就是说,hash冲突中的单向链表内的节点个数几乎不可能超过8个,不会让链表变得过于长,尽可能的避免HashMap的查询时间复杂度从O(1)变成O(n)。

四.性能问题

          1. 从扩容的问题中,我们可以看到HashMap要不断的调用indexFor(),为元素计算存储在新数组中的下标,这个过程非常消耗性能。因此,我们在使用HashMap之前需要评估可能存储元素的规模,为HashMap底层容器指定初始化大小,尽可能的避免扩容。

          2. 避免尾部遍历。当遇到hash冲突时,HashMap需要把元素添加到链表中,链表可能已经存在多个元素,如果从链表的尾端插入元素,则需要从链表的头结点开始不停地遍历,直到尾节点,最后修改next指针指向新的元素。这么做效率太低了,所以HashMap从头节点开始插入。

五. 线程安全问题

          并发下,扩容时可能产生死循环,根本原因是插入元素时为了避免尾部遍历,采用了头部插入的方式。此处参考链接,这篇文章最后死循环的逻辑似乎有问题。

          截取transfer部分代码:

java.util.HashMap.Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;

          假设有两个线程,同时执行put()操作,此时都需要对HashMap进行扩容。indexFor的过程很简单,使用key对数组长度取模即可。下图所示,key为3,7,5的元素经过hash与indexFor后,都被存储在数组下标为1的链表中。

          Step1: 线程1率先执行,代码执行到Entry next = e.next; 被调度器挂起了。此时,线程一中,e指向key=3,next指向key=7

                     线程2开始执行,它把扩容工作全部做完了。由于单链表LIFO的特性,key7插在了key3的前面。

                     注意: Entry[] table不是一个线程私有的对象,它被所有线程共享。但e和next都是方法内部定义的,因此是线程私有                                  的。不难注意到,虽然线程1中的e和next虽然指向的元素没有发生变化,但元素本身在链表中存放的顺序却被反                                转了。

          

           Step2: 线程1恢复执行,首先执行int i = indexFor(e.hash, newCapacity); 显然,计算的结果与线程二一定相同,都为3。

                      接着,执行e.next = newTable[i]; 要知道,此时的newTable[3]中已经寄存了key=7,因此next=>key(7),也即

                      元素: table[3] -> key(7) -> key(3)                  指针: next指向key(7),e指向key(3)

                      然后,执行newTable[i] = e; 也即 

                      元素: table[3] -> key(3) 且 key(7)->key(3)     指针: next指向key(7),e指向key(3)

                      最后,执行e = next; 也即

                      元素: table[3] -> key(7) -> key(3)                  指针: next指向key(7),e指向key(7)

           Step3: 进入下一轮循环

                      执行next = e.next;  由于e.next指向key(7),而key(7).next指向key(3),因此:

                      table[3] -> key(7) -> key(3)                          指针: next指向key(3),e指向key(7)

                      然后,执行e.next = new Table[i];  由于new Table[i] == key(7) ,而e指向key(7),因此这句话执行后会导致                                  key(7).next指向key(7) ,造成了死循环。比如用户希望查询key(3)的值,首先需要先找到数组下标为3的单链条的头                          结点key(7),接着循环遍历单链表,由于key(7)的next是key(7)本身,永远也找不到key(3),因此就会报出Infinite                              Loop 无限循环的问题。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 9
    评论
HashMapJDK 1.7底层实现原理是采用数组和链表的结合形式。它使用了一个Entry数组来存储键值对,并且每个Entry对象都是一个链表的头节点,用于解决哈希冲突。 具体实现过程如下: 1. 创建一个Entry数组用于存储键值对,初始化大小为默认值(16)或指定的初始容量。 2. 当添加一个键值对时,首先根据键的哈希值计算出在数组中的索引位置。 3. 如果该索引位置上没有任何元素,则直接将键值对作为一个新的Entry插入到该位置。 4. 如果该索引位置上已经存在元素,即发生了哈希冲突,使用链表的方式解决冲突。 - 遍历该位置上的链表,如果找到键与要插入的键相等的节点,则更新对应的值。 - 如果遍历完链表仍未找到相同键的节点,则将新的键值对插入到链表的末尾。 5. 当链表长度达到一定阈值(默认为8),或者数组大小达到扩容门槛(默认为75%),就会触发扩容操作。 6. 扩容操作会创建一个更大的Entry数组,并将旧数组中的元素重新计算索引,插入到新数组中。 7. 扩容后,所有键值对的索引位置都会发生变化,但链表结构不变。 8. 当需要获取某个键对应的值时,根据键的哈希值计算出在数组中的索引位置,并遍历该位置上的链表,找到对应的节点并返回值。 总结起来,JDK 1.7中的HashMap底层实现原理是使用数组和链表的组合结构,通过哈希值计算索引位置,并使用链表解决哈希冲突。但是这种实现方式在处理大量数据时会存在性能问题,因为链表的查询效率较低。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值