ConcurrentHashMap的扩容为什么是2的幂

ConcurrentHashMap的扩容为什么是2的幂

之所以是2的幂,在于如下的设计:

  1. 采用key的hashCode与数组长度n减1的逻辑与,将key映射到数组的某个哈希桶中。

    hashCode & (n - 1) 中,由于n是2的幂,减1之后可以保证最后几位都是1,只有n的这一位是0。

  2. 在扩容的时候,根据key的hashCode与n的逻辑与,获取n对应的二进制表示中的1这一位,在hashCode中是1还是0,用于分配该键值对是挂到低位哈希桶中,还是挂到高位哈希桶中。

  3. 位操作比数学运算速度快,效率高。

如,当n=16的时候,二进制表示:

0000 0000 0000 0000 0000 0000 0001 0000

假设有一个key是“hello”,则它的hashCode是:

String key = "hello";
int hashCode = key.hashCode();
int spreadHash = (hashCode ^ (hashCode >>> 16)) & 0x7fffffff;
System.out.println(spreadHash);

结果是:99163451

变量名称变量值
n0000 0000 0000 0000 0000 0000 0001 0000
n - 10000 0000 0000 0000 0000 0000 0000 1111
spreadHash0000 0101 1110 1001 0001 1101 0011 1011
spreadHash & (n - 1)0000 0000 0000 0000 0000 0000 0000 1011

在对该spreadHash分配高低哈希桶的时候:

变量名称变量值
n0000 0000 0000 0000 0000 0000 0001 0000
spreadHash0000 0101 1110 1001 0001 1101 0011 1011
spreadHash & n0000 0000 0000 0000 0000 0000 0001 0000

扩容后的获取:

n = 320000 0000 0000 0000 0000 0000 0010 0000
n - 10000 0000 0000 0000 0000 0000 0001 1111
spreadHash0000 0101 1110 1001 0001 1101 0011 1011
spreadHash & (n - 1)0000 0000 0000 0000 0000 0000 0001 1011
与其差16的低位的值0000 0000 0000 0000 0000 0000 0000 1011

即原来在16中的键值对放到了与原下标相差16的高位哈希桶中,查询的时候在新哈希表中,也可以查询到。

扩容的设计及源码分析

addCount(long x, int check)

参与扩容的第一个线程将sizeCtl设置为扩容戳+2,其他随后的线程在sizeCtl的基础上分别加1。

当扩容线程完成自己负责的哈希桶数组范围元素迁移之后,将sizeCtl设置为sizeCtl - 1,

在哈希桶下标越界的情况下,计算-1之前的值-2是否等于扩展戳,如果等于扩展戳,证明扩容过程结束,处理善后事宜,之后退出扩容过程。

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
    if ((as = counterCells) != null ||
        !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
        CounterCell a; long v; int m;
        boolean uncontended = true;
        if (as == null || (m = as.length - 1) < 0 ||
            (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
            !(uncontended =
              U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
            fullAddCount(x, uncontended);
            return;
        }
        if (check <= 1)
            return;
        s = sumCount();
    }
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            // 每个n的扩容有唯一的rs值
            // 对于n是16的情况,rs的值为:0000 0000 0000 0000 1000 0000 0001 1011  =  32795
            int rs = resizeStamp(n);
            // 如果sc小于0,表示此时
            if (sc < 0) {
                if (      //        16
                        (sc >>> RESIZE_STAMP_SHIFT) != rs // 扩容戳对不上,不参与
                        ||
                        sc == rs + 1 // 当前扩容是当前线程要参与的上一轮的。
                        ||
                        sc == rs + MAX_RESIZERS // 如果扩容线程数达到最大值
                        ||
                        (nt = nextTable) == null // 如果nextTable是null,表示不再扩容,扩容已经结束了
                        ||
                        transferIndex <= 0 // transferIndex小于等于0,表示已经扩容结束
                )
                    // 无须参与扩容的过程
                    break;
                // 如果需要参与扩容,就将sizeCtl的值设置为sc+1
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                    // 扩容
                    transfer(tab, nt);
            }
            // 如果sizeCtl的值不小于0,表示还没开始扩容,此时是第一个参加扩容的线程
            // 将sizeCtl更新为 rs左移16位,再加上2
            // 0000 0000 0000 0000 1000 0000 0001 1011  =  32795
            // 1000 0000 0001 1011 0000 0000 0000 0000  =  -2145714176 + 2
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                // 扩容
                transfer(tab, null);
            s = sumCount();
        }
    }
}

resizeStamp(int n)

扩容戳对每次扩容过程是唯一的,比如16扩容到32,这个过程中不管有多少线程参与,它们的扩容戳是一样的。

sizeCtl的值当发生扩容时,回让扩容戳左移16位,保证时为负值,低16位用于记录参与扩容的线程数以及扩容过程是否结束的状态。

假设n = 16,则二进制表示为:

  • 0000 0000 0000 0000 0000 0000 0001 0000
  • 1左边有27个0,27的二进制表示:
  • 0000 0000 0000 0000 0000 0000 0001 1011
  • 逻辑或:
  • 0000 0000 0000 0000 1000 0000 0000 0000
  • 结果:
  • 0000 0000 0000 0000 1000 0000 0001 1011

对于第一个参与扩容的线程,将sizeCtl设置为:

  1. 首先将扩容戳左移16为:

    1000 0000 0001 1011 0000 0000 0000 0000

  2. 然后再加上2:

    1000 0000 0001 1011 0000 0000 0000 0010

对于随后参与扩容的线程,则在此基础上加1:

  1. sizeCtl+1:

    1000 0000 0001 1011 0000 0000 0000 0011

每个线程执行完自己负责的哈希桶区间的所有哈希桶之后,首先在sizeCtl的基础上 -1,然后再判断减1之前的值减去2,是否等于扩容戳左移16为的值,如果相等,表示扩容已经完成,该处理善后事宜了。

扩容戳的计算:

/**
 * 假设n = 16,则二进制表示为:<br />
 * 0000 0000 0000 0000 0000 0000 0001 0000<br />
 * 1左边有27个0<br />
 * 27的二进制表示:<br />
 * 0000 0000 0000 0000 0000 0000 0001 1011<br />
 * 逻辑或:<br />
 * 0000 0000 0000 0000 1000 0000 0000 0000<br />
 * 结果:<br />
 * 0000 0000 0000 0000 1000 0000 0001 1011<br />
 *
 * @param n 哈希桶数组的长度,2的幂
 * @return 返回n的迁移戳,n不同,迁移戳不同。同一次扩容过程,迁移戳是相同的。
 */
static final int resizeStamp(int n) {
    return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1));
}

transfer方法

该方法首先计算每个线程应该负责迁移的哈希桶数组区间。

然后对当前线程分配区间,确定第一个迁移的哈希桶数组下标。

当前线程开始迁移元素。

对于链表,通过公式计算每个节点应该分到低位哈希桶还是高位哈希桶。

同时为了提高效率,如果链表的从尾节点向前到达某个节点,它们都应该分配到低位或高位哈希桶,则整体迁移,不再对每个链表元素进行拼接。即,直接用链表尾链直接挂过去。

在链表节点挂低位哈希桶或高位哈希桶的时候,要按照在原来链表中的顺序挂到新的链表中。

对于红黑树,就需要对每个元素进行计算,分配低位哈希桶或高位哈希桶,同时低位或高位哈希桶中依然是红黑树。

不过在迁移的过程中对每个新红黑树计算元素的个数,如果元素个数达不到阈值,就转换回链表。

当前线程完成该哈希桶元素的迁移之后,要计算负责区间的下一个哈希桶,进行元素的迁移。

当该线程完成负责区间的元素都迁移结束后,则尝试再次分配区间,如果分配不到,则线程退出扩容过程。

退出的时候需要:

  1. 将sizeCtl的值减1
  2. 判断减1之前减2是否是扩容戳左移16位的值,如果是,则扩容结束;否则仅表示当前线程的工作完成,扩容继续。
/**
 * 扩容时键值对节点的迁移
 *
 * @param tab 现在的哈希桶数组
 * @param nextTab 新的哈希桶数组
 */
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    // n是旧数组中哈希桶个数,stride表示每个迁移线程负责的哈希桶个数。
    int n = tab.length, stride;
    // 根据CPU数量计算每个线程应该负责的哈希桶个数。
    // 对于一个线程,从transferIndex开始,到transferIndex - stride这个范围的哈希桶由当前线程负责元素迁移。
    // 对于多个线程并发的情况,从更新后的transferIndex-1,到transferIndex - stride范围,由其负责。
    // 因此,stride是一个步长,这么多哈希桶,由某个线程负责元素的迁移
    // 当然,对于单核心处理器,一个线程负责所有的元素迁移
    // 对于多核心处理器,每个线程最少处理MIN_TRANSFER_STRIDE = 16个哈希桶的元素迁移。
    // 后续还会有协助迁移元素的线程加进来。此处计算不包括协助迁移线程。
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    // 如果nextTab是null,则创建新数组,新数组的长度是旧数组的两倍
    if (nextTab == null) {            // initiating
        try {
            // 创建新数组,并将其赋值给nextTab
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n << 1];
            nextTab = nt;
        } catch (Throwable ex) {      // try to cope with OOME
            sizeCtl = Integer.MAX_VALUE;
            return;
        }
        // 将新数组赋值给nextTable属性
        nextTable = nextTab;
        // n是新数组中超过旧数组的第一个元素下标
        transferIndex = n;
    }
    // 新数组长度
    int nextn = nextTab.length;
    // 创建ForwardingNode
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    // 该变量表示是否需要查找下一个哈希桶,执行其元素的迁移
    // 此处,由于当前线程刚进来,还没干活儿呢,所以将其设置为true
    // 如果该变量的值false,则表示查找哈希桶已经有结果了,结果要么是找到了,可以开始干活了
    boolean advance = true;
    // 如果finishing的值为true,表示扩容结束,可以将tab设置为新的数组了,方法可以返回了
    boolean finishing = false;
    // 处理哈希桶迁移的循环,每次处理完一个哈希桶元素的迁移,就使用该循环处理下一个哈希桶的元素迁移。
    for (int i = 0, bound = 0;;) {
        // 用于记录需要进行元素迁移的哈希桶数组元素,该元素要么是链表的头节点,要么是红黑树的TreeBin对象
        // fh表示该节点的hash值
        Node<K,V> f; int fh;
        // while循环用于当前线程查找下一个需要执行元素迁移的哈希桶,即更新i的值,
        // advance=true表示需要接着找哈希桶,找到哈希桶之后,将其数组下标赋值给i。
        while (advance) {
            // nextIndex记录的是找到i之后的值,nextIndex = i + 1
            // nextBound记录的是找到i之后的值,nextBound = nextIndex = i + 1
            int nextIndex, nextBound;
            // 如果i的值不小于bound或者已经完成了,
            // finishing为true表示扩容结束,可以使用新数组替换旧数组了
            // 则将advance设置为false,当前线程已经完成了它的使命。
            // 实际上扩容都结束了,当前线程的advance肯定设置为false。
            if (--i >= bound || finishing)
                advance = false;
            // 如果在新哈希表中的高位索引不大于0,则将advance设置为false,同时i设置为-1
            // 如果transferIndex小于等于0,表示当前线程的迁移哈希桶的使命已经完成
            // 因为该变量记录的是尚未执行迁移的哈希桶索引最大值+1
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            // 如果条件都合适,则将transferIndex减去stride,或者设置为0,前提是nextIndex要大于stride
            // 同时将advance设置为false,表示已经找到当前线程负责处理的哈希桶数组下标了
            // transferIndex减去步长,计算当前线程需要迁移的哈希桶范围下限。即这个范围由该线程负责迁移。
            // 如果不成功,表示有其他线程抢成功了,当前线程抢下一个范围区间,因为advance还是true。
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                // 实际上nextBound就是nextIndex的值
                // 该值满足:i < bound
                bound = nextBound;
                // nextIndex在这里对于第一次进入的线程,该值还是n
                // 如果nextIndex是0,则i小于0
                i = nextIndex - 1;
                // false表示当前线程的向后退缩查找下一个哈希桶以迁移元素,已经有结果了
                advance = false;
            }
        }
        // i记录的是执行迁移的哈希桶在数组中的下标。
        // nextn表示新数组的长度。
        // 显然,i的值应该在 0 <= i < n 范围;i + n 也肯定小于nextn。
        // 如果i的值越界,则可以肯定需要处理扩容如何结束的问题。
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            // 如果迁移结束:
            // 1. 将nextTable设置为null,
            // 2. 用新数组替代老数组,
            // 3. 将sizeCtl设置为下次扩容需要的元素个数
            // 方法返回。
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            // 当前线程负责处理的哈希桶数组区间成功完成
            // CAS将sizeCtl的值
            // 一个线程中途加入扩容,需要将sizeCtl设置为sizeCtl + 1,减去一个线程,则需要:sizeCtl - 1
            // 如果sc - 2 的值不是resizeStamp(n) << RESIZE_STAMP_SHIFT,表示扩容还没有结束,当前线程直接返回。
            // 如果成立,表示扩容结束,将finishing设置为true。
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                // 加入扩容的第一个线程,将sc设置为resizeStamp(n) << 16 + 2
                // 然后每次有一个线程加入,都将sizeCtl+1。
                // 由于resizeStamp(n) << RESIZE_STAMP_SHIFT是固定的
                // 如果该关系式不成立,表示其他参与扩容的线程还没有结束,仅仅是当前线程的扩容任务完成了
                // 它不能将finishing设置为true,因为一旦finishing为true,表示扩容过程结束。
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    // 此时扩容并没有结束,但是当前线程负责的区间已经迁移完成,线程退出。
                    return;
                
                // 如果当前线程是最后一个扩容的线程,则当前线程结束后,扩容结束。
                // 将advance设置为true,表示需要找下一个需要迁移元素的哈希桶
                // finishing设置为true表示哈希表已经扩容结束
                finishing = advance = true;
                // 将i设置为n,下次循环进入该if的外层if,既然finishing已经为true,处理善后适宜,之后扩容结束,线程退出。
                i = n; // recheck before commit
            }
        }
        else if ((f = tabAt(tab, i)) == null)
            // 表示i处的hash桶没有元素需要迁移,那只需要通过CAS将其标识为fwd,即表示处理完成。
            // 如果成功将该数组元素设置为fwd,则表示当前哈希桶处理完了,应该查找下一个哈希桶执行元素迁移了
            advance = casTabAt(tab, i, null, fwd);
        else if ((fh = f.hash) == MOVED)
            // 如果该节点的hash值已经是MOVED了,则表示要么已经处理过了,
            // 要么其他线程正在执行该哈希桶的元素迁移,因此,当前线程不需要处理了
            // 这里将advance设置为true,表示当前线程应该查找下一个需要执行元素迁移的哈希桶了
            advance = true; // already processed
        // 执行元素的迁移过程
        // 如果是红黑树,迁移结束后要判断是否需要退化为链表
        else {
            // 开始干活了,真正执行元素的迁移了
            // 安全起见,先对f节点加锁,别的线程就可以执行其他哈希桶的元素迁移了:分段锁。
            // 为什么加锁?由于CHM是分段锁,能够调用扩容方法的线程可能是多个线程并发的。
            synchronized (f) {
                if (tabAt(tab, i) == f) { // 再次检查数据的一致性
                    Node<K,V> ln, hn;
                    // 如果f节点的hash值大于等于0,表示该节点是链表节点
                    if (fh >= 0) {
                        // 确定头节点16位的值是1还是0,如果是1,表示该节点应该挂高位哈希桶链表
                        // 如果是0,表示该节点应该挂低位哈希桶链表
                        // 该值作为参照,下面的第一个for循环用于查找同属于低位哈希桶链表或低位哈希桶链表的尾链。
                        int runBit = fh & n;
                        // 获取f节点
                        Node<K,V> lastRun = f;
                        // 从链表头节点开始,查找元素相同的尾链,如果链表中最后几个元素都相同
                        // 则在迁移的时候,一起移动过去,不用再挨个儿执行链表元素的拼接操作了。
                        // 尾链头节点用lastRun表示。
                        // 遍历链表中的各个节点,不断地更新runBit和lastRun的值
                        // 这两个值表示什么意思?
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            // 注意:在计算键值对分配到哪个哈希桶的时候,使用的计算公式:hash & (n - 1)。
                            // 此处的计算使用的是 hash & n。
                            // 为什么要使用n?
                            // 以n=16计算,
                            // 16:0000 0000 0000 0000 0000 0000 0001 0000
                            // 15:0000 0000 0000 0000 0000 0000 0000 1111
                            // 32:0000 0000 0000 0000 0000 0000 0010 0000
                            // 31:0000 0000 0000 0000 0000 0000 0001 1111
                            // 举例说明:
                            // 15和31的差值是多少?
                            // 16:0000 0000 0000 0000 0000 0000 0001 0000
                            // 15:0000 0000 0000 0000 0000 0000 0000 1111
                            // 31:0000 0000 0000 0000 0000 0000 0001 1111
                            // 即对于数组长度是32来讲,如果你的hash值对31逻辑与之后第5位是0
                            // 效果等同于hash值对31逻辑与操作之后的值。
                            // 如果与31逻辑与之后的第5位是1,则该结果是hash值对15逻辑与结果加上第5位是1的值,即16
                            // 可以保证旧数组中i的哈希桶中的各个元素只分配到新数组中i的位置和i+n的位置
                            // 有利于充分利用分段锁的能力,不会发生线程竞争
                            // 通过也满足了扩容后这些元素的寻址问题,即rehash的目的。
                            int b = p.hash & n;
                            if (b != runBit) {
                                runBit = b; // 记录应该放到高位哈希桶中还是低位哈希桶中。
                                lastRun = p; // 链表尾链的第一个节点,后面的都一样,整体迁移即可。
                            }
                        }
                        // runBit如果是0,表示16位是1还是0,如果是0,则(hash & n)的值就是0,一定挂低位哈希桶链表
                        // 这也是为什么没有判断高位的值,如果16位是1,则runBit就是16,如果是32呢。。。
                        // 挂高位哈希桶链表的分支直接在else中,优雅。。。
                        if (runBit == 0) {
                            ln = lastRun; // 低位是lastRun
                            hn = null;    // 高位节点是null
                        }
                        else {  // 如果runBit不是0,表示尾链挂高位哈希桶
                            hn = lastRun; // 此时高位节点设置为lastRun
                            ln = null;    // 低位节点是null
                        }
                        // 从链表头节点开始遍历到尾链的第一个节点为止,尾链整体拼接,无须再次遍历。
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            // 如果节点hash值对n逻辑与的值是0,则将该键值对放到新数组的低位哈希桶
                            // 挂低位链表,倒着挂
                            // 保证按照原链表顺序将元素挂到新链表中去
                            // 如果ln是null值,无所谓,因为链表的最后一个元素的next指针一定是null。
                            if ((ph & n) == 0)
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            // 挂高位哈希桶的链表,倒着挂
                            // 如果hn是null,无所谓,因为链表的最后一个元素的next指针一定是null。
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        // 将低位链表的头节点放到低位哈希桶中
                        setTabAt(nextTab, i, ln);
                        // 将高位链表的头节点放到高位哈希桶中
                        setTabAt(nextTab, i + n, hn);
                        // 将旧数组中的i处的节点设置为fwd节点,表示当前哈希桶已完成元素迁移
                        setTabAt(tab, i, fwd);
                        // 表示当前线程已经迁移完i处的哈希桶元素,需要查找下一个哈希桶,一般是i - 1。
                        advance = true;
                    }
                    // 如果f节点是TreeBin类型,表示此哈希桶中的是红黑树
                    else if (f instanceof TreeBin) {
                        TreeBin<K,V> t = (TreeBin<K,V>)f;
                        // 低位哈希桶标记
                        TreeNode<K,V> lo = null, loTail = null;
                        // 高位哈希桶标记
                        TreeNode<K,V> hi = null, hiTail = null;
                        int lc = 0, hc = 0;
                        // 遍历红黑树节点
                        for (Node<K,V> e = t.first; e != null; e = e.next) {
                            int h = e.hash;
                            TreeNode<K,V> p = new TreeNode<K,V>
                                (h, e.key, e.val, null, null);
                            // 同链表,放低位哈希桶中
                            if ((h & n) == 0) {
                                // 如果loTail是null,则赋值为p节点,此处处理的是第一个键值对
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else // 其他键值对向后追加,组成loTail为首的链表
                                    loTail.next = p;
                                // 每次循环都更新loTail的值,最后一个节点就是loTail的值
                                loTail = p;
                                // 低位哈希桶的键值对个数计数+1
                                ++lc;
                            }
                            else {
                                // 处理第一个节点
                                if ((p.prev = hiTail) == null)
                                    // p节点作为hi记录
                                    hi = p;
                                else // 其他的依次向后追加,组成hiTail为首的链表
                                    hiTail.next = p;
                                // 每次都更新hiTail的值,最后一个节点就是hiTail的值
                                hiTail = p;
                                // 高位哈希桶的键值对个数计数+1
                                ++hc;
                            }
                        }
                        // 对低位哈希桶元素计数进行判断,是否需要转换为链表
                        // 如果红黑树中的元素个数小于等于6,则需要转换回链表,否则直接挂t
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                            (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        // 判断高位哈希桶中的元素个数,如果个数小于等于6,则转换回链表,否则直接挂t
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                            (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        // 低位哈希桶数组元素设置为ln
                        setTabAt(nextTab, i, ln);
                        // 高位哈希桶数组元素设置为hn
                        setTabAt(nextTab, i + n, hn);
                        // 将旧数组中的i元素设置为fwd,表示当前哈希桶已完成元素迁移
                        setTabAt(tab, i, fwd);
                        // 表示已经进行完当前哈希桶元素迁移,将advance设置为true,
                        // 表示需要查找下一个需要执行元素迁移的哈希桶了。
                        advance = true;
                    }
                }
            }
        }
    }
}

在这里插入图片描述

在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值