JUC:ConcurrentHashMap(并发容器)

本文深入探讨了Java并发容器ConcurrentHashMap的设计与实现,包括其使用了细粒度的synchronized和CAS操作提高并发性能,以及在JDK8中通过红黑树解决Hash冲突。在扩容过程中,ConcurrentHashMap采用多线程并发迁移策略,避免全表锁,保证了高效性和线程安全性。文章详细分析了构造方法、初始化、put操作以及扩容的实现细节,展示了其高并发场景下的优秀性能。
摘要由CSDN通过智能技术生成

JUC:ConcurrentHashMap(并发容器)


关键词

  • synchronized:并发度,头节点加锁;cas:初始化竞争 / transferIndex多线程扩容进度
  • sizeCtl(Hash表处于不同状态变量),进行CAS操作
  • transferIndex(扩容的进度),CAS进行操作(每次减stride)
  • ForwardingNode(转发节点),记录新ConcurrentHashMap引用
  • tab.length 是2的整数次方,每次扩容又是2倍(新index位置)

同步容器并发容器特点
HashMapConcurrentHashMapJDK6中采用一种更加细粒度的加锁机制Segment“分段锁”,JDK8中采用CAS无锁算法
ArrayListCopyOnWriteArrayList对读操作不加锁,对写操作,先复制一份新的集合,在新的集合上面修改,然后将新集合赋值给旧的引用,并通过volatile 保证其可⻅性,当然写操作的锁是必不可少的了。
HashSetCopyOnWriteArraySet利用高并发往往是读多写少的特性,对读操作不加锁,对写操作,先复制一份新的集合,在新的集合上面修改,然后将新集合赋值给旧的引用,并通过volatile 保证其可⻅性,当然写操作的锁是必不可少的了。
HashSetCopyOnWriteArraySet基于CopyOnWriteArrayList实现,其唯一的不同是在add时调用的是CopyOnWriteArrayList的addIfAbsent方法,其遍历当前Object数组,如Object数组中已有了当前元素,则直接返回,如果没有则放入Object数组的尾部,并返回。
TreeMapConcurrentSkipListMapSkip list(跳表)是一种可以代替平衡树的数据结构,默认是按照Key值升序的。
TreeSetConcurrentSkipListSet内部基于ConcurrentSkipListMap实现
QueueConcurrentLinkedQueue基于链表实现的FIFO队列(LinkedList的并发版本)
BlockingQueueLinkedBlockingQueue、ArrayBlockingQueue、PriorityBlockingQueue通过ReentrantLock实现线程安全,通过Condition实现阻塞和唤醒

一、 数据结构

HashMap通常的实现方式是“数组+链表”,这种方式被称为“拉链法”。ConcurrentHashMap在这个基本原理之上进行了各种优化。

首先是所有数据都放在一个大的HashMap中;其次是引入了红黑树

其原理如下图所示:
在这里插入图片描述

如果头节点是Node类型,则尾随它的就是一个普通的链表;如果头节点是TreeNode类型,它的后面就是一颗红黑树,TreeNode是Node的子类。

链表和红黑树之间可以相互转换:初始的时候是链表,当链表中的元素超过某个阈值时,把链表转换成红黑树;反之,当红黑树中的元素个数小于某个阈值时,再转换为链表。

那为什么要做这种设计呢?

  1. 使用红黑树,当一个槽里有很多元素时,其查询和更新速度会比链表快很多,Hash冲突的问题由此得到较好的解决。

  2. 加锁的粒度,并非整个ConcurrentHashMap,而是对每个头节点分别加锁,即并发度,就是Node数组的长度,初始长度为16。

  3. 并发扩容,这是难度最大的。当一个线程要扩容Node数组的时候,其他线程还要读写,因此处理过程很复杂,后面会详细分析。

由上述对比可以总结出来:这种设计一方面降低了Hash冲突,另一方面也提升了并发度

二、 源码剖析

从构造方法开始,一步步深入分析其实现过程

1.构造方法分析

在这里插入图片描述

在这里插入图片描述

在上面的代码中,变量cap就是Node数组的长度,保持为2的整数次方。tableSizeFor(…)方法是根据传入的初始容量,计算出一个合适的数组长度。具体而言:1.5倍的初始容量+1,再往上取最接近的2的整数次方,作为数组长度cap的初始值。

这里的 sizeCtl,其含义是用于控制在初始化或者并发扩容时候的线程数,只不过其初始值设置成cap。

2.初始化

在上面的构造方法里只计算了数组的初始大小,并没有对数组进行初始化。当多个线程都往里面放入元素的时候,再进行初始化。这就存在一个问题:多个线程重复初始化。下面看一下是如何处理的。

private final Node<K,V>[] initTable() {
  Node<K,V>[] tab; int sc;
  while ((tab = table) == null || tab.length == 0) {
    if ((sc = sizeCtl) < 0)
      Thread.yield(); // 自旋等待
    else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {  // 重点:将sizeCtl设置为-1原子操作,谁抢到-1谁进行初始化操作
      try {
        if ((tab = table) == null || tab.length == 0) {
          int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
          @SuppressWarnings("unchecked")
          Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n]; // 初始化
          table = tab = nt;
          // sizeCtl不是数组长度,因此初始化成功后,就不再等于数组长度
          // 而是n-(n>>>2)=0.75n,表示下一次扩容的阈值:n-n/4
          sc = n - (n >>> 2);
       }
     } finally {
        sizeCtl = sc;  // 设置sizeCtl的值为sc。
     }
      break;
   }
 }
  return tab;
}

通过上面的代码可以看到,多个线程的竞争是通过对sizeCtl进行CAS操作实现的。如果某个线程成功地把 sizeCtl 设置为-1,它就拥有了初始化的权利,进入初始化的代码模块,等到初始化完成,再把sizeCtl设置回去;其他线程则一直执行while循环,自旋等待,直到数组不为null,即当初始化结束时,退出整个方法。

因为初始化的工作量很小,所以此处选择的策略是让其他线程一直等待,而没有帮助其初始化。

3.put(…)实现分析

在这里插入图片描述

final V putVal(K key, V value, boolean onlyIfAbsent) {
  if (key == null || value == null) throw new NullPointerException();
  int hash = spread(key.hashCode());
  int binCount = 0;
  for (Node<K,V>[] tab = table;;) {
    Node<K,V> f; int n, i, fh; K fk; V fv;
    // 分支1:整个数组初始化
    if (tab == null || (n = tab.length) == 0)
      tab = initTable();
    // 分支2:第i个元素初始化
    else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
      if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
        break;          // no lock when adding to empty bin
   }
    // 分支3:扩容
    else if ((fh = f.hash) == MOVED)
      tab = helpTransfer(tab, f);
    //
    else if (onlyIfAbsent // check first node without acquiring lock
        && fh == hash
        && ((fk = f.key) == key || (fk != null && key.equals(fk)))
        && (fv = f.val) != null)
      return fv;
    // 分支4:放入元素(加锁)
    else {
      V oldVal = null;
      // 重点:加锁
      synchronized (f) {
        // 链表
        if (tabAt(tab, i) == f) {
          if (fh >= 0) {
            binCount = 1;
            for (Node<K,V> e = f;; ++binCount) {
              K ek;
              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;
              if ((e = e.next) == null) {
                pred.next = new Node<K,V>(hash, key, value);
                break;
             }
           }
         }
          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;
           }
         }
          else if (f instanceof ReservationNode)
            throw new IllegalStateException("Recursive update");
       }
     }
      // 如果是链表,上面的binCount会一直累加
      if (binCount != 0) {
        if (binCount >= TREEIFY_THRESHOLD) //TREEIFY_THRESHOLD默认8
          treeifyBin(tab, i); // 超出阈值,转换为红黑树
        if (oldVal != null)
          return oldVal;
        break;
     }
   }
 }
  addCount(1L, binCount); // 总元素个数累加1
  return null;
}                

上面的for循环有4个大的分支:

  • 第1个分支,是整个数组的初始化,前面已讲;

  • 第2个分支,是所在的槽为空,说明该元素是该槽的第一个元素,直接新建一个头节点,然后返回;

  • 第3个分支,说明该槽正在进行扩容,帮助其扩容;

  • 第4个分支,就是把元素放入槽内。槽内可能是一个链表,也可能是一棵红黑树,通过头节点的类型可以判断是哪一种。第4个分支是包裹在synchronized (f)里面的,f对应的数组下标位置的头节点,意味着每个数组元素有一把锁,并发度等于数组的长度。

    上面的binCount表示链表的元素个数,当这个数目超过TREEIFY_THRESHOLD=8时,把链表转换成红黑树,也就是 treeifyBin(tab,i)方法。

    但在这个方法内部,不一定需要进行红黑树转换,可能只做扩容操作,所以接下来从扩容讲起。

4.扩容

扩容的实现是最复杂的,下面从treeifyBin(Node<K,V>[] tab, int index)讲起。

private final void treeifyBin(Node<K,V>[] tab, int index) {
  Node<K,V> b; int n;
  if (tab != null) {
    if ((n = tab.length) < MIN_TREEIFY_CAPACITY)
	  // 数组长度小于阈值64,不做红黑树转换,直接扩容
      tryPresize(n << 1);
    else if ((b = tabAt(tab, index)) != null && b.hash >= 0) {
      // 链表转换为红黑树
      synchronized (b) {
        if (tabAt(tab, index) == b) {
          TreeNode<K,V> hd = null, tl = null;
          // 遍历链表,初始化红黑树
          for (Node<K,V> e = b; e != null; e = e.next) {
            TreeNode<K,V> p = new TreeNode<K,V>(e.hash, e.key, e.val,null, null);
            if ((p.prev = tl) == null)
              hd = p;
            else
              tl.next = p;
            tl = p;
         }
          setTabAt(tab, index, new TreeBin<K,V>(hd));
       }
     }
   }
 }
}

在上面的代码中,MIN_TREEIFY_CAPACITY=64,意味着当数组的长度没有超过64的时候,数组的每个节点里都是链表,只会扩容,不会转换成红黑树。只有当数组长度大于或等于64时,才考虑把链表转换成红黑树。

在这里插入图片描述

直接扩容:在 tryPresize(int size)内部调用了一个核心方法 transfer(Node<K,V>[] tab,Node<K,V>[] nextTab),先从这个方法的分析说起。

private final void tryPresize(int size) {
  int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
    tableSizeFor(size + (size >>> 1) + 1);
  int sc;
  while ((sc = sizeCtl) >= 0) {
    Node<K,V>[] tab = table; int n;
    if (tab == null || (n = tab.length) == 0) {
      n = (sc > c) ? sc : c;
      if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
        try {
          if (table == tab) {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
            table = nt;
            sc = n - (n >>> 2);
         }
       } finally {
          sizeCtl = sc;
       }
     }
   }
    else if (c <= sc || n >= MAXIMUM_CAPACITY)
      break;
    else if (tab == table) {
      int rs = resizeStamp(n);
      if (U.compareAndSetInt(this, SIZECTL, sc,
                 (rs << RESIZE_STAMP_SHIFT) + 2))
        transfer(tab, null);
   }
 }
    
}    

核心的迁移函数:transfer

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) {
  int n = tab.length, stride;
  if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
    stride = MIN_TRANSFER_STRIDE; // 计算步长
  if (nextTab == null) {       // 初始化新的HashMap
    try {
      @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为旧HashMap的数组长度
    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
  // 此处,i为遍历下标,bound为边界。
  // 如果成功获取一个任务,则i=nextIndex-1
  // bound=nextIndex-stride;
  // 如果获取不到,则i=0,bound=0
  for (int i = 0, bound = 0;;) {
    Node<K,V> f; int fh;
    // advance表示在从i=transferIndex-1遍历到bound位置的过程中,是否一直继续
    while (advance) {
      int nextIndex, nextBound;
     
      // 以下是哪个分支中的advance都是false,表示如果三个分支都不执行,才可以一直while循环
      // 目的在于当对transferIndex执行CAS操作不成功的时候,需要自旋,以期获取一个stride的迁移任务。
      if (--i >= bound || finishing)
        // 对数组遍历,通过这里的--i进行。如果成功执行了--i,就不需要继续while循环了,因为advance只能进一步。
        advance = false;
      else if ((nextIndex = transferIndex) <= 0) {
        // transferIndex <= 0,整个HashMap完成
        i = -1;
        advance = false;
     }
      // 对transferIndex执行CAS操作,即为当前线程分配1个stride。
      // CAS操作成功,线程成功获取到一个stride的迁移任务;
      // CAS操作不成功,线程没有抢到任务,会继续执行while循环,自旋。
      else if (U.compareAndSetInt
          (this, TRANSFERINDEX, nextIndex,
           nextBound = (nextIndex > stride ?
                 nextIndex - stride : 0))) {
        bound = nextBound;
        i = nextIndex - 1;
        advance = false;
     }
   }
    // i越界,整个HashMap遍历完成
    if (i < 0 || i >= n || i + n >= nextn) {
      int sc;
      // finishing表示整个HashMap扩容完成
      if (finishing) {
        nextTable = null;
        // 将nextTab赋值给当前table
        table = nextTab;
        sizeCtl = (n << 1) - (n >>> 1);
        return;
     }
      if (U.compareAndSetInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
        if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
          return;
        finishing = advance = true;
        i = n; // recheck before commit
     }
   }
    // tab[i]迁移完毕,赋值一个ForwardingNode
    else if ((f = tabAt(tab, i)) == null)
      advance = casTabAt(tab, i, null, fwd);
    // tab[i]的位置已经在迁移过程中
    else if ((fh = f.hash) == MOVED)
      advance = true; // already processed
    else {
      // 对tab[i]进行迁移操作,tab[i]可能是一个链表或者红黑树
      synchronized (f) {
        if (tabAt(tab, i) == f) {
          Node<K,V> ln, hn;
          // 链表
          if (fh >= 0) {
            int runBit = fh & n;    
            Node<K,V> lastRun = f;
            for (Node<K,V> p = f.next; p != null; p = p.next) {
              int b = p.hash & n;
              if (b != runBit) {
                runBit = b;
                // 表示lastRun之后的所有元素,hash值都是一样的
                // 记录下这个最后的位置
                lastRun = p;
             }
           }
            if (runBit == 0) {
              // 链表迁移的优化做法
              ln = lastRun;
              hn = null;
           }
            else {
              hn = lastRun;
              ln = null;
           }
            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)
                ln = new Node<K,V>(ph, pk, pv, ln);
              else
                hn = new Node<K,V>(ph, pk, pv, hn);
           }
            setTabAt(nextTab, i, ln);
            setTabAt(nextTab, i + n, hn);
            setTabAt(tab, i, fwd);
            advance = true;
         }
          // 红黑树,迁移做法和链表类似
          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. 扩容的基本原理如下图,首先建一个新的HashMap,其数组长度是旧数组长度的2倍(涉及key的hash取模的问题,节省空间效率高),然后把旧的元素逐个迁移过来。所以,上面的方法参数有2个,第1个参数tab是扩容之前的HashMap,第2个参数nextTab是扩容之后的HashMap。当nextTab=null的时候,方法最初会对nextTab进行初始化。这里有一个关键点要说明:该方法会被多个线程调用,所以每个线程只是扩容旧的HashMap部分,这就涉及如何划分任务的问题
    在这里插入图片描述

  2. 上图为多个线程并行扩容-任务划分示意图。旧数组的长度是N,每个线程扩容一段,一段的长度用变量stride(步长)来表示,transferIndex表示了整个数组扩容的进度。

    stride的计算公式如上面的代码所示,即:在单核模式下直接等于n,因为在单核模式下没有办法多个线程并行扩容,只需要1个线程来扩容整个数组;在多核模式下为 (n>>>3)/NCPU(核芯数量),并且保证步长的最小值是 16。显然,需要的线程个数约为n/stride。

在这里插入图片描述transferIndex是ConcurrentHashMap的一个成员变量,记录了扩容的进度。初始值为n,从大到小扩容,每次减stride个位置,最终减至n<=0,表示整个扩容完成。因此,从[0,transferIndex-1]的位置表示还没有分配到线程扩容的部分,从[transfexIndex,n-1]的位置表示已经分配给某个线程进行扩容,当前正在扩容中,或者已经扩容成功。

因为transferIndex会被多个线程并发修改,每次减stride,所以需要通过CAS进行操作,如下面的代码所示。

在这里插入图片描述

在这里插入图片描述

  1. 在扩容未完成之前,有的数组下标对应的槽已经迁移到了新的HashMap里面,有的还在旧的HashMap 里面。这个时候,所有调用 get(k,v)的线程还是会访问旧 HashMap,怎么处理呢?

    下图为扩容过程中的转发示意图:当Node[0]已经迁移成功,而其他Node还在迁移过程中时,如果有线程要读取Node[0]的数据,就会访问失败。为此,新建一个ForwardingNode(解决上述问题),即转发节点,在这个节点里面记录的是新的 ConcurrentHashMap 的引用。这样,当线程访问到ForwardingNode之后,会去查询新的ConcurrentHashMap。

  2. 因为数组的长度 tab.length 是2的整数次方,每次扩容又是2倍。

    而 Hash 函数是hashCode%tab.length,等价于hashCode&(tab.length-1)。这意味着:处于第i个位置的元素,在新的Hash表的数组中一定处于第i个或者第i+n个位置,如下图所示。

    举个简单的例子:假设数组长度是8,扩容之后是16
    ​ 若hashCode=5,5%8=0,扩容后,5%16=0,位置保持不变;
    ​ 若hashCode=24,24%8=0,扩容后,24%16=8,后移8个位置;
    ​ 若hashCode=25,25%8=1,扩容后,25%16=9,后移8个位置;
    ​ 若hashCode=39,39%8=7,扩容后,39%8=7,位置保持不变;
    ​ ……

在这里插入图片描述

正因为有这样的规律,所以如下有代码:

在这里插入图片描述

也就是把tab[i]位置的链表或红黑树重新组装成两部分,一部分链接到nextTab[i]的位置,一部分链接到nextTab[i+n]的位置,如上图所示。然后把tab[i]的位置指向一个ForwardingNode节点。

同时,当tab[i]后面是链表时,使用类似于JDK 7中在扩容时的优化方法,从lastRun往后的所有节点,不需依次拷贝,而是直接链接到新的链表头部。从lastRun往前的所有节点,需要依次拷贝。

了解了核心的迁移函数transfer(tab,nextTab),再回头看tryPresize(int size)函数。这个函数的输入是整个Hash表的元素个数,在函数里面,根据需要对整个Hash表进行扩容。想要看明白这个函数,需要透彻地理解sizeCtl变量,下面这段注释摘自源码。

在这里插入图片描述

当sizeCtl=-1时,表示整个HashMap正在初始化;
当sizeCtl=某个其他负数时,表示多个线程在对HashMap做并发扩容;
当sizeCtl=cap时,tab=null,表示未初始之前的初始容量(如上面的构造函数所示);

扩容成功之后,sizeCtl存储的是下一次要扩容的阈值,即上面初始化代码中的n-(n>>>2)=0.75n。

所以,sizeCtl变量在Hash表处于不同状态时,表达不同的含义

明白了这个道理,再来看上面的tryPresize(int size)函数。

private final void tryPresize(int size) {
  int c = (size >= (MAXIMUM_CAPACITY >>> 1)) ? MAXIMUM_CAPACITY :
    tableSizeFor(size + (size >>> 1) + 1);
  int sc;
  while ((sc = sizeCtl) >= 0) {
    Node<K,V>[] tab = table; int n;
    if (tab == null || (n = tab.length) == 0) {
      n = (sc > c) ? sc : c;
      if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
        try {
          if (table == tab) {
            @SuppressWarnings("unchecked")
            Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
            table = nt;
            sc = n - (n >>> 2);
         }
       } finally {
          sizeCtl = sc;
       }
     }
   }
    else if (c <= sc || n >= MAXIMUM_CAPACITY)
      break;
    else if (tab == table) {
      int rs = resizeStamp(n);
      if (U.compareAndSetInt(this, SIZECTL, sc,
                 (rs << RESIZE_STAMP_SHIFT) + 2))
        transfer(tab, null);
   }
 }
}

tryPresize(int size)是根据期望的元素个数对整个Hash表进行扩容,核心是调用transfer函数。

在第一次扩容的时候,sizeCtl会被设置成一个很大的负数U.compareAndSwapInt(this,SIZECTL,sc,(rs <<RESIZE_STAMP_SHIFT)+2);之后每一个线程扩容的时候,sizeCtl 就加 1,U.compareAndSwapInt(this,SIZECTL,sc,sc+1),待扩容完成之后,sizeCtl减1。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

穿城大饼

你的鼓励将是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值