【源码分析】并发容器-ConcurrentHashMap

- 添加元素

    /** Implementation for put and putIfAbsent */
    final V putVal(K key, V value, boolean onlyIfAbsent) {
        //concurrenthashmap的key、value都不允许为null
        if (key == null || value == null) throw new NullPointerException();
        //spread方法对key的hash值进行二次hash(为啥进行二次hash,参考hashmap)
        int hash = spread(key.hashCode());
        //binCount用于链表节点的的计数,以便后面判断链表是否达到转红黑的阈值(新增元素都需要判
        //断一下),可以看下面的代码只有当前元素为链表时通过遍历链表节点才会自增binCount,以达
        //到链表节点计数的目的,binCount一直计数(自增)到找到与待插入的节点的插入位置,有两种
        //可能:一是存在与待插入节点key相同的链表节点,二是没有key相同的节点,直接插到链表尾
        //部。这两种情况都会break跳出for循环,第一种情况binCount值为截止到key相同节点之前的
        //链表的节点数量,第二种情况binCount值为插入新节点后的链表的节点数量(链表长度)。第一
        //种情况只是替换key的值,不会新增链表节点(链表长度不变)后面判断binCount也不会超过链
        //表转树的阈值,第二种情况binCount有可能超过该阈值从而导致进行扩容或者链表转红黑树。
        int binCount = 0;
        //for循环遍历table的i处的元素(可能是链表、红黑树、null)
        for (Node<K,V>[] tab = table;;) {
            Node<K,V> f; int n, i, fh;
            //条件满足,说明table未初始化,则进行初始化
            if (tab == null || (n = tab.length) == 0)
                tab = initTable();
            //条件满足,说明i处的元素为null,则直接尝试插入到该位置(用待插入的key、value创
            //建Node),注意这里使用cas方式插入,防止并发修改
            else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
                if (casTabAt(tab, i, null,
                             new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to empty bin
            }
            //i处元素当前节点的hash值为MOVED,表示map正在扩容,该处元素已经被复制到扩容后的
            //table,此时当前线程会调用helpTransfer方法尝试协助进行table扩容(扩容支持多线
            //程扩容,提高扩容效率,参考transfer方法),
            else if ((fh = f.hash) == MOVED)
                tab = helpTransfer(tab, f);
            else {
                V oldVal = null;
                //对f加锁,防止并发操作
                synchronized (f) {
                    //再次判断i处元素是否与f相等(整个hashmap的代码有很多这种二次判断逻辑)
                    if (tabAt(tab, i) == f) {
                        //fh>=0说明i处(当前)元素是链表
                        if (fh >= 0) {
                            binCount = 1;
                            //for循环遍历链表节点,查找(key,value)对应的放置位置,有两
                            //种场景,一是有相同的key,则直接把用待插入的值把value替换掉;
                            //二是没有找到相同的key,则直接把待插入的元素放置在链表的末尾
                            for (Node<K,V> e = f;; ++binCount) {
                                K ek;
                                //有和待插入的key相同的的key
                                if (e.hash == hash &&
                                    ((ek = e.key) == key ||
                                     (ek != null && key.equals(ek)))) {
                                    oldVal = e.val;
                                    if (!onlyIfAbsent)
                                        e.val = value;
                                    break;
                                }
                                Node<K,V> pred = e;
                                //没有找到和待插入的key相同的key,将待插入元素插入到链表
                                //尾部
                                if ((e = e.next) == null) {
                                    pred.next = new Node<K,V>(hash, key,
                                                              value, null);
                                    break;
                                }
                            }
                        }
                        //判断i处元素是否为红黑树,进行红黑树的插入操作
                        else if (f instanceof TreeBin) {
                            Node<K,V> p;
                            binCount = 2;
                            if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
                                                           value)) != null) {
                                oldVal = p.val;
                                if (!onlyIfAbsent)
                                    p.val = value;
                            }
                        }
                    }
                }
                //插入元素后,校验i处元素个数(binCount)是否大于等于链表转红黑树的阈值,然
                //后调用treeifyBin尝试进行链表转树操作,这里说尝试的意思是不一定进行转树操
                //作,treeifyBin中还会进行table容量的判断,如果超过64则进行转树操作,否则
                //优先进行table扩容操作而不进行转树操作。
                if (binCount != 0) {
                    if (binCount >= TREEIFY_THRESHOLD)
                        treeifyBin(tab, i);
                    if (oldVal != null)
                        return oldVal;
                    break;
                }
            }
        }
        //第一个参数表示键值对个数的变化值,如果为正,表示新增了元素,如果为负,表示删除了元素。
        //ConcurrentHashMap内部所有改变键值对个数的方法都会调用addCount方法更新键值对的计
        //数。addCount方法有2个作用,一是计数,二是check是否扩容。
        addCount(1L, binCount);
        return null;
    }
    /**
     * Adds to count, and if table is too small and not already
     * resizing, initiates transfer. If already resizing, helps
     * perform transfer if work is available.  Rechecks occupancy
     * after a transfer to see if another resize is already needed
     * because resizings are lagging additions.
     *
     * @param x the count to add
     * @param check if <0, don't check resize, if <= 1 only check if uncontended
     */
    private final void addCount(long x, int check) {
        CounterCell[] as; long b, s;
        //addCount方法记录size 变化的过程可以分为两类情况,一是counterCells数组未初始化
        //(参考(一)处if判断逻辑)通过CAS尝试修改一次baseCount,如果CAS失败,则调用
        //fullAddCount方法。二是counterCells数组已初始化(参考(一)处if判断逻辑),尝试通
        //过CAS修改一次当前线程探针哈希到的数组元素,如果CAS失败,则调用fullAddCount方法。
        //baseCount是全局的map节点计数器,counterCells是为了多线程并发增删map元素时,提高
        //并发的,counterCells数组记录每个线程增删的元素数量,计算map的size时会将baseCount 
        //字段值与所有counterCells数组的非空元素的和相加(具体参考size代码)。这里
        //counterCells如果为null,说明counterCells没有被初始化,也就是没有其他线程增删map
        //元素;那么直接CAS尝试更新baseCount,如果失败,则说明此时有其他线程并发修改
        //baseCount,则进入(二)处,当counterCells不为null,随机访问counterCells的一个
        //元素判断是否为null,如果不为null,通过CAS尝试修改当前数组元素的value,失败则调用
        //fullAddCount继续尝试修改counterCells或baseCount。
        //(一)
        if ((as = counterCells) != null ||
            !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
            CounterCell a; long v; int m;
            boolean uncontended = true;
            //as为null,或者as长度<=0,或者,或者
            //(二)
            //ThreadLocalRandom功能是获取随机数,由于每个线程的probe值不一样,因此大概率 
            //每个线程对应的数组中的元素也是不一样的,每个线程对应了不同的元素,就可以没有冲突
            //的进行完全的并发操作,因此探针probe在这里 就起到了防止冲突的作用。
            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代码注解
                fullAddCount(x, uncontended);
                return;
            }
            if (check <= 1)
                return;
            s = sumCount();
        }
        //check是传入的map元素个数,不会小于0
        if (check >= 0) {
            Node<K,V>[] tab, nt; int n, sc;
            //扩容条件,map元素数量大于等于sizeCtl(sizeCtl含义参考下面扩容说明),table不
            //为null,table容量小于MAXIMUM_CAPACITY
            while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
                   (n = tab.length) < MAXIMUM_CAPACITY) {
                //resizeStamp参考文章【2】
                int rs = resizeStamp(n);
                if (sc < 0) {
                    if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                        sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                        transferIndex <= 0)
                        break;
                    if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))
                        transfer(tab, nt);
                }
                //SIZECTL设置当前有一个线程正在扩容,rs左移动16个字节
                //(RESIZE_STAMP_SHIFT),一定为负数,第16位数一定都是0,+2表示有一个线
                //程再扩容
                else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                             (rs << RESIZE_STAMP_SHIFT) + 2))
                    transfer(tab, null);
                s = sumCount();
            }
        }
    }
    // See LongAdder version for explanation
    private final void fullAddCount(long x, boolean wasUncontended) {
        int h;
        //这里主要是通过ThreadLocalRandom.getProbe得到一个非0的h值作为下标
        if ((h = ThreadLocalRandom.getProbe()) == 0) {
            ThreadLocalRandom.localInit();      // force initialization
            h = ThreadLocalRandom.getProbe();
            wasUncontended = true;
        }
        boolean collide = false;                // True if last slot nonempty
        //经典的死循环,自旋操作
        for (;;) {
            CounterCell[] as; CounterCell a; int n; long v;
            //判断counterCells是否被初始化
            if ((as = counterCells) != null && (n = as.length) > 0) {
                //判断随机的h下标处的数据是否为null
                if ((a = as[(n - 1) & h]) == null) {
                    //cellsBusy用于同步 counterCells 数组结构修改的乐观锁资源,如果h下标
                    //处为null,判断cellsBusy是否0(当有线程更新counterCells数
                    //组时,会把该值置为1,更新完counterCells数据再置回0),为0才尝试CAS
                    //修改counterCells
                    if (cellsBusy == 0) {            // Try to attach new Cell
                        CounterCell r = new CounterCell(x); // Optimistic create
                        if (cellsBusy == 0 &&
                            U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                            boolean created = false;
                            try {               // Recheck under lock
                                CounterCell[] rs; int m, j;
                                //更新counterCells的h处元素前,做二次判断
                                if ((rs = counterCells) != null &&
                                    (m = rs.length) > 0 &&
                                    rs[j = (m - 1) & h] == null) {
                                    rs[j] = r;
                                    created = true;
                                }
                            } finally {
                                cellsBusy = 0;
                            }
                            //counterCells元素更新成功,跳出自旋循环,否则继续自旋
                            if (created)
                                break;
                            continue;           // Slot is now non-empty
                        }
                    }
                    collide = false;
                }
                else if (!wasUncontended)       // CAS already known to fail
                    wasUncontended = true;      // Continue after rehash
                else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))
                    break;
                else if (counterCells != as || n >= NCPU)
                    collide = false;            // At max size or stale
                else if (!collide)
                    collide = true;
                else if (cellsBusy == 0 &&
                         U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                    try {
                        if (counterCells == as) {// Expand table unless stale
                            CounterCell[] rs = new CounterCell[n << 1];
                            for (int i = 0; i < n; ++i)
                                rs[i] = as[i];
                            counterCells = rs;
                        }
                    } finally {
                        cellsBusy = 0;
                    }
                    collide = false;
                    continue;                   // Retry with expanded table
                }
                //这里advanceProbe修改一个线程的探针值,这样可以进一步避免未来可能得冲突,
                //从而减少竞争,提高并发性能。
                h = ThreadLocalRandom.advanceProbe(h);
            }
            //来到这一步,表示counterCells未被初始化,则尝试通过CAS修改cellsBusy为1,成功
            //后对counterCells进行初始化。
            else if (cellsBusy == 0 && counterCells == as &&
                     U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) {
                boolean init = false;
                try {                           // Initialize table
                    if (counterCells == as) {
                        CounterCell[] rs = new CounterCell[2];
                        rs[h & 1] = new CounterCell(x);
                        counterCells = rs;
                        init = true;
                    }
                } finally {
                    cellsBusy = 0;
                }
                if (init)
                    break;
            }
            //上面对counterCells的修改都未成功(未竞争到锁),则继续尝试通过CAS修改
            //baseCount
            else if (U.compareAndSwapLong(this, BASECOUNT, v = baseCount, v + x))
                break;                          // Fall back on using base
        }
    }

- 扩容
扩容相关的几个重要变量:

  1. sizeCtl
    多线程之间,以volatile的方式读取sizeCtl属性,来判断ConcurrentHashMap当前所处的状态。通过cas设置sizeCtl属性,告知其他线程ConcurrentHashMap的状态变更。

不同状态,sizeCtl所代表的含义也有所不同。

  • 未初始化:
    sizeCtl=0:表示没有指定初始容量。
    sizeCtl>0:表示初始容量。
  • 初始化中:
    sizeCtl=-1,标记作用,告知其他线程,正在初始化
  • 正常状态:
    sizeCtl=0.75n ,扩容阈值
  • 扩容中:
    sizeCtl < 0 : 表示有其他线程正在执行扩容
    sizeCtl = (resizeStamp(n) << RESIZE_STAMP_SHIFT) + 2 :表示此时只有一个线程在执行扩容
  1. transferIndex
    扩容索引,表示已经分配给扩容线程的table数组索引位置。主要用来协调多个线程,并发安全地获取迁移任务(hash桶)。
    1 在扩容之前,transferIndex 在数组的最右边 。此时有一个线程发现已经到达扩容阈值,准备开始扩容。
    在这里插入图片描述
    2 扩容线程,在迁移数据之前,首先要将transferIndex左移(以cas的方式修改 transferIndex=transferIndex-stride(要迁移hash桶的个数)),获取迁移任务。每个扩容线程都会通过for循环+CAS的方式设置transferIndex,因此可以确保多线程扩容的并发安全。
    在这里插入图片描述

  2. ForwardingNode
    标记作用,表示其他线程正在扩容,并且此节点已经扩容完毕。关联了nextTable,扩容期间可以通过find方法,访问已经迁移到了nextTable中的数据

/**
 * Moves and/or copies the nodes in each bin to new table. See
 * above for explanation.
 */
private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
    //扩容前原table的长度,stride是每个线程复制数据的步长(table元素个数),默认    
    //MIN_TRANSFER_STRIDE=16,也就是说在他变了扩容的时候,每个线程最少处理的table元素数量为
    //16,
    int n = tab.length, stride;
    if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
        stride = MIN_TRANSFER_STRIDE; // subdivide range
    //nextTab就是扩容后的table,长度为扩容前的2倍
    if (nextTab == null) {            // initiating
        try {
            //初始化nextTab,长度位原table的2倍,n << 1相当于2n
            @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 = nextTab;
        //transferIndex可以看作是一个扩容第二步时,控制多线程复制数据(从原table复制到扩容后
        //的table)的索引,最开始没有线程在复制数据,所以transferIndex指向table的最右端(末
        //尾),
        transferIndex = n;
    }
    int nextn = nextTab.length;
    ForwardingNode<K,V> fwd = new ForwardingNode<K,V>(nextTab);
    //
    boolean advance = true;
    //
    boolean finishing = false; // to ensure sweep before committing nextTab
    //下面for循环开始遍历table,i指当前处理的槽位序号,bound指需要处理的槽位边界,i从table的
    //最右端开始,
    //(一)
    for (int i = 0, bound = 0;;) {
        Node<K,V> f; int fh;
        //(二)这个while循环是线程去争取处理table槽位的
        while (advance) {
            int nextIndex, nextBound;
            //i表示当前线程当前处理的槽位,--i>=bound说明当前线程处理边界bound内的槽位还未
            //全部处理完(从原table复制到扩容后的table),所以advance设置为false,在for循
            //环的下次循环就不会再进入while争取槽位(因为之前争取到的还未处理完);finishing
            //为true表示全部槽位都处理完了,也不需要再次进入while循环争取槽位了,finishing
            //设置是在下面代码中
            if (--i >= bound || finishing)
                advance = false;
            //transferIndex<=0表示原table的所有元素都已经被处理了(复制到扩容后的table
            //了),设置advance=false结束while循环,否则进到下一个if去尝试参与复制(通过
            //cas尝试更新transferIndex)
            //(三)
            else if ((nextIndex = transferIndex) <= 0) {
                i = -1;
                advance = false;
            }
            //尝试参与复制数据,通过CAS修改transferIndex,如果修改成功说明当前线程抢到了处
            //理transferIndex上个值与设置成功后的值,这两个索引之间的table元素的权利,
            //advance设置位false,跳出while循环,开始处理这个区间的数据(从原table复制到扩
            //容后的table),i从table最右端开始,bound从i位置开始向左取stride步长的位置
            //(四)
            else if (U.compareAndSwapInt
                     (this, TRANSFERINDEX, nextIndex,
                      nextBound = (nextIndex > stride ?
                                   nextIndex - stride : 0))) {
                bound = nextBound;
                i = nextIndex - 1;
                advance = false;
            }
        }
        //当前处理的槽位序号满足以下条件时,说明所有槽位都处理完了,就进行收尾操作
        //(五)
        if (i < 0 || i >= n || i + n >= nextn) {
            int sc;
            //finishing为true表示table扩容完成(扩容包括两步,第一步初始化扩容后的table,
            //第二步把原table的元素复制到扩容后的table,第一步是单线程处理,上面开始的时候已
            //完成,第二步多线程处理(提高扩容效率))
            //(六)
            if (finishing) {
                nextTable = null;
                table = nextTab;
                sizeCtl = (n << 1) - (n >>> 1);
                return;
            }
            //(七)
            if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
                if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
                    return;
                finishing = advance = true;
                i = n; // recheck before commit
            }
        }
        //i槽位为null,说明没有元素,直接吧该位置插入一个fwd节点(占位作用,其他线程看到后就知
        //道该节点被处理了),fwd节点的hash为常量MOVED
        //(八)
        else if ((f = tabAt(tab, i)) == null)
            advance = casTabAt(tab, i, null, fwd);
        //(九)
        else if ((fh = f.hash) == MOVED)
            advance = true; // already processed
        else {
            //(十)
            //对i槽位的node节点加锁,加锁的目的是。。。
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                    Node<K,V> ln, hn;
                    //当前槽位节点的hash值>=0说明当前槽位是链表
                    if (fh >= 0) {
                        int runBit = fh & n;
                        Node<K,V> lastRun = f;
                        //for循环遍历当前槽位的链表,
                        for (Node<K,V> p = f.next; p != null; p = p.next) {
                            //取当前链表节点的hash值与原table长度n的二进制按位与的结果,
                            //因为n取值为2的整数次幂,二进制表示只有一位为1,其余为0,比如
                            //100,1000等等,所以不管p.hash值为多少,与的结果就2种,要么
                            //所有二进制位全为0,也就是整数0;要么等于n(自己可以演算一
                            //下)。这样就把链表的节点分成2类,对应的扩容后的table的所有位
                            //置也分成2类,0-n叫做低位,n+1-2n叫做高位,这两类也对应了链
                            //表中元素在扩容后的table中的2类位置,一类是与的结果为0的,在
                            //扩容后链表的
                            //位置和在原链表的位置(下标)保持不变;一类是与的结果为n的,在
                            //扩容后的链表中的位置为原位置i+n(这一点可以通过节点元素确定位
                            //置的方法计算一边验证一下),作者发现了这一点或者采用这种便捷
                            //的方式确定链表中node节点在扩容后table中的位置,比每个元素重
                            //新按照插入的方式计算一遍位置要高效的多。
                            int b = p.hash & n;
                            //经过for循环后,这里最终lastRun取值是一个分界节点,这个节点
                            //之前包含节点hash值和原table长度n取与后结果不同的节点,这个
                            //节点之后所有节点(包括分界节点)取与结果相同,最终的runBit也
                            //就是这个分界节点的取与结果,要么为0,要么为n
                            //
                            if (b != runBit) {
                                runBit = b;
                                lastRun = p;
                            }
                        }
                        //上面for循环后得到的runBit(也就是节点和原table长度n与的结果)为
                        //0的原链表节点放到扩容后的table的低位,否则放到高位
                        if (runBit == 0) {
                            ln = lastRun;
                            hn = null;
                        }
                        else {
                            hn = lastRun;
                            ln = null;
                        }
                        //这里的for循环,遍历原table节点处链表,组装成两个新链表ln、hn,
                        //ln放低位节点,hn放高位节点,循环遍历以lastRun分界节点作为循环结
                        //束条件,原因是lastRun分界节点后的节点全都是同类(参考上面分类),
                        //所以不需要再挨个遍历,直接跟随lastRun分界节点拼在新的链表即可,
                        for (Node<K,V> p = f; p != lastRun; p = p.next) {
                            int ph = p.hash; K pk = p.key; V pv = p.val;
                            if ((ph & n) == 0)
                                //这里构造的新链表节点顺序和原链表中的顺序正好相反
                                //(lastRun分界节点以及之后的节点仍然和原链表顺序一致)
                                ln = new Node<K,V>(ph, pk, pv, ln);
                            else
                                hn = new Node<K,V>(ph, pk, pv, hn);
                        }
                        //将ln设置在扩容后的链表的低位
                        setTabAt(nextTab, i, ln);
                        //将hn设置在扩容后的链表的高位
                        setTabAt(nextTab, i + n, hn);
                        //将原链表的i位置设置为fwd类型节点(标识table该位置元素已经被处理
                        //完了)
                        setTabAt(tab, i, fwd);
                        //设置advance为true,因为该线程已经处理i位置元素,需要继续处理i-1
                        //位置数据,i的前移是在while(advance)循环中,所以把advance设置
                        //为true,进入while循环,--i同时判断--i是否还在bound边界内,如果
                        //在继续处理--i处的元素,否则判断transferIndex是否到达table的头
                        //位置(也就是table元素都处理完了,table元素是多线程处理的,参考以
                        //上代码逻辑),否则尝试获取新的table槽位来处理(把table相应槽位元
                        //素从原表复制到扩容后的表)。
                        advance = true;
                    }
                    //以下是table的i位置为红黑树时的数据处理(元素复制)的逻辑,和上面链表的
                    //方式类似
                    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) {
                                if ((p.prev = loTail) == null)
                                    lo = p;
                                else
                                    loTail.next = p;
                                loTail = p;
                                ++lc;
                            }
                            else {
                                if ((p.prev = hiTail) == null)
                                    hi = p;
                                else
                                    hiTail.next = p;
                                hiTail = p;
                                ++hc;
                            }
                        }
                        ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
                            (hc != 0) ? new TreeBin<K,V>(lo) : t;
                        hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
                            (lc != 0) ? new TreeBin<K,V>(hi) : t;
                        setTabAt(nextTab, i, ln);
                        setTabAt(nextTab, i + n, hn);
                        setTabAt(tab, i, fwd);
                        advance = true;
                    }
                }
            }
        }
    }
}

参考文章
【1】https://www.jianshu.com/p/487d00afe6ca
【2】https://blog.csdn.net/tp7309/article/details/76532366

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值