Java集合源码之HashMap及相关问题

HashMap简介

HashMap是Java程序员使用频率最高的用于映射(键值对)处理的数据类型。

它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。

HashMap的存储结构

在JDK1.7的时候,HashMap的存储结构是数组加链表。

在这里插入图片描述

然而在JDK1.8中,对H爱上Map的存储结构进行了优化,使其的结构实现变成了数组+链表+红黑树,如下图:

在这里插入图片描述

JDK1.8的HashMap源码

HashMap的几个属性

//初始化桶大小,因为底层是数组,所以这是数组默认的大小。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//数组桶最大值。
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认的负载因子(0.75)
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;
//HashMap中实际存在的键值对数量
transient int size;
//HashMap中所能存放的最多键值对/阈值
int threshold;
//负载因子,可在初始化时显式指定。
final float loadFactor;

​ 首先,Node[] table的初始化长度length(默认值是16),Load factor为负载因子(默认值是0.75),threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。

​ 也就是说threshold就是负载因子和桶数组长度对应条件下允许的最大元素数目,如果HashMap中存储的键值对超过这个阈值,就要调用resize()扩容。

  • 默认的负载因子0.75是对空间和时间效率的一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。
  • 哈希桶数组table的长度length大小必须为2的n次方(一定是合数),这是一种非常规的设计,主要是为了在取模和扩容时做优化,同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。

HashMap的构造方法

空参构造

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; 
}

这里只把默认的负载因子/满载率赋值给loadFactor。

一个参数的构造方法

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

这个构造方法会传入一个初始化的容积,然后会调用HashMap的双参数构造方法,传入这个容积和默认的负载因子/满载率。

双参数的构造方法

public HashMap(int initialCapacity, float loadFactor) {
    //初始容量小于0,抛出异常
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);
    //如果设置的初始容量大于数组长度的最大值,则将初始化容量变为最大值    
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    //负载因子/满载率小于0则抛出异常
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +loadFactor);
    //把传入的负载因子赋值给全局的负载因子  
    this.loadFactor = loadFactor;
    //这里对传入进来的初始化容量进行了一个tableSizeFor操作,以保证容量为2^n,以便于后期移位的操作
    this.threshold = tableSizeFor(initialCapacity);
}

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

带Map类的构造方法

public HashMap(Map<? extends K, ? extends V> m) {
    //给负载因子赋默认值
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    //把传入的map集合当作参数调用这个方法
    putMapEntries(m, false);
}

我们看出这个构造方法中主要调用了putMapEntries(m, false)方法,所以具体的向当前的HashMap中添加一个集合的数据的过程是在putMapEntries(m, false)方法中:

final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
    int s = m.size();
    if (s > 0) {
        //如果没有初始化table数组
        if (table == null) {
            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();
        
       //遍历参数集合数据,调用putVal方法将其添加到当前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);
        }
    }
}

HashMap中的table桶数组

我们看见HashMap中有一个数组为transient Node<K,V>[] table,这个就是HashMap真正存数据的数组,table数组存储Node,而Node的本质就是一个映射(键值对),我们看一下Node的实现:

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) {  /*......*/  }

        public final boolean equals(Object o) {  /*......*/  }
    }

Node是HashMap的一个内部类,它的成员变量也很好理解:

  • hash为这个Node的哈希值,也就是其存放在table数组中的下标。
  • key就是我们写入的键值。
  • value就是我们写入的key对应的值。
  • next存放的是当前Node的next,因为我们知道HashMap的存储结构是链表加红黑树,所以这个next就是实现链表结构的。

HashMap计算哈希值/索引的方法

​ 在说HashMap的两个重要的put/get方法之前,我们一定要说以下如何通过hash算法来计算传入的键值对在table数组中存储的位置。前面说过,HashMap的存储结构是数组+链表+红黑树,这个结构的目的就是避免过多的哈希冲突导致查询数据变慢,所以我们对于每个传入的键值对进行哈希算法,目的就是让他们在HashMap桶数组中分布的更加均匀,便于查找。

JDK1.7的hash算法

static final int hash(int h) {
    h ^= k.hashCode(); 		//第一步:取hashCode值
    h ^= (h >>> 20) ^ (h >>> 12);	//高位参与运算
    return h ^ (h >>> 7) ^ (h >>> 4);
 }

static int indexFor(int h, int length) {  //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的
     return h & (length-1);  //第三步 取模运算
}

在JDK1.7中,先将key传换成哈希值,然后再进行4次位运算+5次异或运算进行了9次的扰动处理,使得根据key生成的哈希码(hash值)分布更加均匀、更具备随机性,避免出现hash值冲突(即指不同key但生成同1个hash值)。

调用完hash方法后,调用indexFor方法来计算该对象应该保存在table数组的哪个索引处,我们看一下indexFor方法中的这一句h & (length-1)。因为table数组的长度总是2的n次方,所以h & (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。

在这里插入图片描述

JDK1.8的hash算法

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

在JDK1.8中,先将key转换成哈希值,然后进行1次位运算+1次异或运算进行了2次扰动处理尽量避免hash冲突。没有了indexFor方法,而是在hash中优化了高位运算的算法。通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的。下面是图解:

img

HashMap的put方法

对于HashMap的操作,最重要的就是它的put和get方法,我们先看一下put的源码:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

在put方法中,我们先对key进行哈希运算,然后作为参数传到putval方法中,putVal方法的后两个参数一个是是否不覆盖旧的值,一个是给HashMap的子类LinkedHashMap用的。

也就是说添加数据的操作都在putVal方法中:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //步骤1:如果table数组为空,则通过resize扩容操作创建出table数组
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //步骤2:计算出索引值index,如果table数组中该索引值无数据,即没有链接一个节点,则通过newNode方法给他连上一个节点
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        //步骤3:判断是否第一个节点就是节点key,如果是,则直接覆盖value值
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //步骤4:如果该链是红黑树的形式存在,则调用putTreeVal方法,进行红黑树中添加节点。
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //步骤5:如果该链为链表
        else {
            for (int binCount = 0; ; ++binCount) {
                //如果一直没找到Key,则创建一个结点存到链表最后面
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //如果链表长度达到树化的阈值,则调用treeifyBin方法进行树化处理
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                //如果遍历时找到了Key,则break跳出循环,在下面会进行值的覆盖
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //步骤6:存在该Key,覆盖原值
        if (e != null) { 
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    //步骤7:判断是否需要扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

由上面的分析可知,putVal方法答题总共分为7步:

  • 第一步:判断是否存在table数组,没有则通过resize方法创建。
  • 第二步:通过哈希值计算出在table中的索引,如果该索引处为空,则直接使用newNode方法创建节点连接到这里。
  • 第三步:如果该索引处的第一个节点Key就是我们要存的,则直接新值替换旧值
  • 第四步:如果该索引的链是红黑树的形式,则调用putTreeVal方法,进行红黑树中添加节点的操作。
  • 第五步:如果该链是链表,则会进行遍历。
    • 如果一直没有找到Key,则newNode方法创建节点放到链的末尾。然后判断是否要树化。
    • 如果找到了则break。
  • 第六步:如果在上面的for中找到了Key,则实现替换。
  • 第七步:比较size和阈值,判断是否需要扩容,是否需要调用resize方法。

在putVal方法中,主要涉及了两个方法,treeifyBin和resize。一个是进行树化操作,一个是进行扩容操作。

HashMap的树化treeifyBin方法

当table上的某一个链达到了树化的条件,就会把这个链表转换成红黑树,转换就是通过treeifyBin方法来进行的,下面我们来看一下treeifyBin方法的源码:

final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //如果table数组的长度小于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 {
            //调用replacementTreeNode方法,把节点由node类型转换成treeNode类型
            TreeNode<K,V> p = replacementTreeNode(e, null);
            //如果尾节点为空,则hd成为首节点
            if (tl == null)
                hd = p;
            //否则就把单向链表转换为双向链表(树的节点形式)
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            //调用treeify方法将准备好的双向链表转换成红黑树
            hd.treeify(tab);
    }
}



//真正的树化操作是hd.treeify(tab)方法,将双向链表转换为红黑树

final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null; // 定义树的根节点
    for (TreeNode<K,V> x = this, next; x != null; x = next) { // 遍历链表,x指向当前节点、next指向下一个节点
        next = (TreeNode<K,V>)x.next; // 下一个节点
        x.left = x.right = null; // 设置当前节点的左右节点为空
        if (root == null) { // 如果还没有根节点
            x.parent = null; // 当前节点的父节点设为空
            x.red = false; // 当前节点的红色属性设为false(把当前节点设为黑色)
            root = x; // 根节点指向到当前节点
        }
        else { // 如果已经存在根节点了
            K k = x.key; // 取得当前链表节点的key
            int h = x.hash; // 取得当前链表节点的hash值
            Class<?> kc = null; // 定义key所属的Class
            for (TreeNode<K,V> p = root;;) { // 从根节点开始遍历,此遍历没有设置边界,只能从内部跳出
                // GOTO1
                int dir, ph; // dir 标识方向(左右)、ph标识当前树节点的hash值
                K pk = p.key; // 当前树节点的key
                if ((ph = p.hash) > h) // 如果当前树节点hash值 大于 当前链表节点的hash值
                    dir = -1; // 标识当前链表节点会放到当前树节点的左侧
                else if (ph < h)
                    dir = 1; // 右侧
 
                /*
                 * 如果两个节点的key的hash值相等,那么还要通过其他方式再进行比较
                 * 如果当前链表节点的key实现了comparable接口,并且当前树节点和链表节点是相同Class的实例,那么通过comparable的方式再比较两者。
                 * 如果还是相等,最后再通过tieBreakOrder比较一次
                 */
                else if ((kc == null &&
                            (kc = comparableClassFor(k)) == null) ||
                            (dir = compareComparables(kc, k, pk)) == 0)
                    dir = tieBreakOrder(k, pk);
 
                TreeNode<K,V> xp = p; // 保存当前树节点
 
                //判断节点在左还是在右
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp; // 当前链表节点 作为 当前树节点的子节点
                    if (dir <= 0)
                        xp.left = x; // 作为左孩子
                    else
                        xp.right = x; // 作为右孩子
                    root = balanceInsertion(root, x); // 重新平衡
                    break;
                }
            }
        }
    }
    //查找确定根节点
    moveRootToFront(tab, root);
}

HashMap的扩容resize()

这个resize()方法有两种使用情况:

  1. 初始化哈希表
  2. 当前数组容量过小,需扩容

扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。

我们看一下JDK1.8的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;
        }
        //如果没达到/超过最大值,则直接扩容两倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; 
    }
    
    //针对初始化哈希表操作,对阈值和初始化长度赋默认值或者指定值
    else if (oldThr > 0) 
        newCap = oldThr;
    else {               
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    //通过负载因子和容量计算新的阈值
    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;
    //进行老数组向新数组的拷贝操作
    if (oldTab != null) {
        //这里是table数组的遍历,不是对链表的遍历
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                //如果老数组上这个索引处只有这一个值,则直接通过位运算计算下标存放到新数组中
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //判断是不是树节点,如果是树节点则通过树的方式去转移
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                //如果是链表
                else { 
                    //定义了一些低位/高位链表头尾
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    //将原来一个链表转换成一个高位链表和一个低位链表
                    do {
                        next = e.next;
                        //注意这里计算在新数组索引时,没有令oldCap-1,这是因为这样可以计算出高低位
                        //为0去低位
                        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);
                    //将转换的链表赋值给新的table数组
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
  • 下面我们附一张图片来概括put方法

img

HashMap的get()方法

HashMap操作中,我们可以根据键key,向HashMap获取对应的值:map.get(key)。

下面我们就看一下HashMap的get方法的源码:

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

由此可见,get方法中,通过key去调用getNode方法寻找value,如果没有找到node就返回null,所以主要的查找方法是getNode方法:

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //计算存放在数组table中的位置
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        //如果数组中直接存在Key相等的情况,直接返回
        if (first.hash == hash && 
            ((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;
}

上述方法大体分为如下几步:

  • 先根据hash值算出在数组中存放的位置
  • 看是否数组的索引处存的链表或者树的头就是我们要查的Key,如果是直接返回
  • 遍历红黑树
  • 遍历链表

以上就是get方法的源码解析。

JDK1.8 HashMap的源码总结

下面,用3个图总结整个源码内容:

  • 数据结构 & 主要参数

img

示意图

  • 添加 & 查询数据流程

img

示意图

  • 扩容机制

img

HashMap常见问题

JDK1.8相比于JDK1.7有什么变化?

  1. JDK1.7中对table数组中的链表采用的是头插法,而JDK1.8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7采用头插法时,在并发扩容时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树,使用尾插法,能够避免出现逆序且链表死循环的问题。HashMap的死循环问题
  2. 扩容后数据存储位置的计算方式不一样:JDK1.7全部按照原来的方法来进行计算,即HashCode()->扰动处理->与运算;而JDK1.8则是用哈希值&旧的容量(而不是旧的容量-1)计算出高低位即新的存储位置。
  3. JDK1.7是先扩容在插入,JDK1.8是先插入在扩容:在JDK1.7的时候是先扩容后插入的,这样就会导致无论这一次插入是不是发生hash冲突都需要进行扩容,如果这次插入的并没有发生Hash冲突的话,那么就会造成一次无效扩容;但是在1.8的时候是先插入再扩容的,优点其实是因为为了减少这一次无效的扩容,原因就是如果这次插入没有发生Hash冲突的话,那么其实就不会造成扩容,但是在1.7的时候就会急造成扩容。
  4. hash值的计算方式:JDK1.7中进行了hashcode()+ 9次扰动处理(4次位运算+5次异或运算),然而JDK1.8中进行了hashcode()+ 2次扰动处理(1次位运算+1次异或运算)。

其他问题

如何避免哈希冲突?

  • Hash算法:通过hashCode()方法和扰动处理。
  • 扩容机制:当哈希表容量大于阈值(容量 * 负载因子)时,会扩容,以避免同一个索引处数据太多即哈希冲突。

如何解决哈希冲突?

  • 数据结构:JDK1.7为数组 + 链表。JDK1.8为数组 + 链表 + 红黑树。
  • 良好的数据存储结构:JDK1.7中,采用链地址法 + 头插法;JDK1.8中,采用链地址法 + 尾插法 + 红黑树

为什么 HashMap 中 String、Integer 这样的包装类适合作为 key 键

  1. 因为String和Integer等包装类中重写了equals和hashCode方法,不容易出现hash值得计算错误,有效减少了发生Hash冲突的几率。
  2. String和Integer为final类型,具有不可变性,即保证Key的不可更改性,保证了Hash值得不可更改性和计算准确性。

为什么HashMap具备下述特点:键-值(key-value)都允许为空、线程不安全、不保证有序、存储位置随时间变化

这里写图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值