【巨人的肩膀】AbstractQueuedSynchronizer框架源码剖析

本文详细介绍了Java.util.concurrent包中的AbstractQueuedSynchronizer(AQS)的设计原理和实现机制。AQS是一个重要的同步器框架,用于构建线程安全的同步组件,如ReentrantLock和Semaphore。AQS的核心是state字段,通过CAS和volatile保证其原子性和可见性。线程的阻塞与解除阻塞通过LockSupport的park()和unpark()实现,队列管理采用CLH非阻塞FIFO队列。AQS提供了独占模式和共享模式的API,自定义同步器需要实现tryAcquire和tryRelease等方法。文章还展示了Mutex类如何基于AQS实现简单的锁机制。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1 拜神

Java.util.concurrent 包都是 Doug Lea 写的,来混个眼熟
在这里插入图片描述
是的,就是他,提出了 JSR166(Java Specification RequestsJava 规范提案),该规范的核心就是 AbstractQueuedSynchronizer 同步器框架(AQS)。这个框架为构造同步器提供一种通用的机制,并且被 j.u.c 包中大部分类使用

包结构如下图,其中 AbstractOwnableSynchronizer 是其父类,而 AbstractQueuedLongSynchronizer 是其32位状态的升级版64位的实现,适用于多级屏障(CyclicBarrier)
在这里插入图片描述
AQS的继承关系如下图,可见老李头对它多重视了。老李头的论文解析飞机票:《The java.util.concurrent Synchronizer Framework》 JUC同步器框架(AQS框架)原文翻译
在这里插入图片描述

2 AQS架构设计原理
2.1 需求分析

为了使框架能得到广泛应用,AQS 同步器定义两种资源共享方式:

  1. Exclusive:独占模式,同时只有一个线程能执行,如 ReentrantLock
  2. Share:共享模式,多个线程可同时执行,如 Semaphore/CountDownLatch

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现 tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared 中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock

自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

  1. isHeldExclusively():该线程是否正在独占资源。只有用到 condition 才需要去实现它
  2. tryAcquire(int):独占方式。尝试获取资源,成功则返回 true,失败则返回 false
  3. tryRelease(int):独占方式。尝试释放资源,成功则返回 true,失败则返回 false
  4. tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0 表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源
  5. tryReleaseShared(int):共享方式。尝试释放资源,成功则返回 true,失败则返回 false

AQS 为了实现上述操作,需要下面三个基本组件的相互协作:

  1. 同步状态的原子性管理
  2. 线程的阻塞与解除阻塞
  3. 队列的管理
2.2 同步状态的原子性管理

state 字段, 用于同步线程之间的共享状态。通过 CAS 和 volatile 保证其原子性和可见性

    /**
     * The synchronization state.
     */
     // volatile 修饰state:线程内的工作内存修改数据后会强制刷新到主存中去,且使其他线程中的工作内存中的该变量失效,下次只能从主存读取。实现了多线程数据可见性
    private volatile int state;

    /**
     * Returns the current value of synchronization state.
     * This operation has memory semantics of a {@code volatile} read.
     * @return current state value
     */
    protected final int getState() {
        return state;
    }

    /**
     * Sets the value of synchronization state.
     * This operation has memory semantics of a {@code volatile} write.
     * @param newState the new state value
     */
    protected final void setState(int newState) {
        state = newState;
    }

    /**
     * Atomically sets synchronization state to the given updated
     * value if the current state value equals the expected value.
     * This operation has memory semantics of a {@code volatile} read
     * and write.
     *
     * @param expect the expected value
     * @param update the new value
     * @return {@code true} if successful. False return indicates that the actual
     *         value was not equal to the expected value.
     */
     // CAS操作state: unsafe.compareAndSwapInt(this, stateOffset, expect, update); 根据对象的state同步状态偏移量是否和expect值相同,相同则更新。标准的CAS操作
    protected final boolean compareAndSetState(int expect, int update) {
        // See below for intrinsics setup to support this
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }
2.3 线程的阻塞与解除阻塞

利用 LockSupport.park() 和 LockSupport.unpark() 实现线程的阻塞和唤醒(底层调用Unsafe的native park和unpark实现),同时支持超时时间

public abstract class AbstractQueuedSynchronizer
    extends AbstractOwnableSynchronizer
    implements java.io.Serializable {
    ...
    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }
    ...
}

public class LockSupport {
    ...
    private static void setBlocker(Thread t, Object arg) {
        // Even though volatile, hotspot doesn't need a write barrier here.
        UNSAFE.putObject(t, parkBlockerOffset, arg);
    }

    /**
     * Makes available the permit for the given thread, if it
     * was not already available.  If the thread was blocked on
     * {@code park} then it will unblock.  Otherwise, its next call
     * to {@code park} is guaranteed not to block. This operation
     * is not guaranteed to have any effect at all if the given
     * thread has not been started.
     *
     * @param thread the thread to unpark, or {@code null}, in which case
     *        this operation has no effect
     */
    public static void unpark(Thread thread) {
        if (thread != null)
            UNSAFE.unpark(thread);
    }

    /**
     * Disables the current thread for thread scheduling purposes unless the
     * permit is available.
     *
     * <p>If the permit is available then it is consumed and the call returns
     * immediately; otherwise
     * the current thread becomes disabled for thread scheduling
     * purposes and lies dormant until one of three things happens:
     *
     * <ul>
     * <li>Some other thread invokes {@link #unpark unpark} with the
     * current thread as the target; or
     *
     * <li>Some other thread {@linkplain Thread#interrupt interrupts}
     * the current thread; or
     *
     * <li>The call spuriously (that is, for no reason) returns.
     * </ul>
     *
     * <p>This method does <em>not</em> report which of these caused the
     * method to return. Callers should re-check the conditions which caused
     * the thread to park in the first place. Callers may also determine,
     * for example, the interrupt status of the thread upon return.
     *
     * @param blocker the synchronization object responsible for this
     *        thread parking
     * @since 1.6
     */
    public static void park(Object blocker) {
        Thread t = Thread.currentThread();
        setBlocker(t, blocker);
        UNSAFE.park(false, 0L);
        setBlocker(t, null);
    }
    ...
}
2.4 队列的管理

根据论文里描述, AQS 里将阻塞线程封装到一个内部类 Node 里。并维护一个 CLH Node FIFO 队列。 CLH 队列是一个非阻塞的 FIFO 队列,也就是说往里面插入或移除一个节点的时候,在并发条件下不会阻塞,而是通过自旋锁和 CAS 保证节点插入和移除的原子性。AQS 里的 CLH 是一个双向链表,数据结构如下图:

// 作者画的图我给删了,我直接从源码注释里把老李头画的图粘贴过来了,具体 CLH 看源码吧,老李头解释了很多
/**
            +------+  prev +-----+       +-----+
       head |      | <---- |     | <---- |     |  tail
            +------+       +-----+       +-----+
  */     
3 AQS源码实现

本节开始讲解 AQS 的源码实现。依照 acquire-release、acquireShared-releaseShared 的次序来

3.1 acquire(int) 独占模式获取资源

此方法是独占模式下线程获取共享资源的顶层入口。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。这也正是 lock() 的语义,当然不仅仅只限于 lock()。获取到资源后,线程就可以去执行其临界区代码了。下面是 acquire() 的源码

// Acquires in exclusive mode, ignoring interrupts. Implemented by invoking at least once tryAcquire, returning on success. Otherwise the thread is queued, possibly repeatedly blocking and unblocking, invoking tryAcquire until success. This method can be used to implement method Lock.lock.
 public final void acquire(int arg) {
     if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
         selfInterrupt();
 }

函数流程如下:

  1. tryAcquire():尝试直接去获取资源,如果成功则直接返回
  2. addWaiter():将该线程加入等待队列的尾部,并标记为独占模式
  3. acquireQueued():使线程在等待队列中获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回 true,否则返回false
  4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断 selfInterrupt(),将中断补上

这时单凭这 4 个抽象的函数来看流程还有点朦胧,不要紧,看完接下来的分析后,你就会明白了

3.1.1 tryAcquire(int)

此方法尝试去获取独占资源。如果获取成功,则直接返回 true,否则直接返回 false。这也正是 tryLock() 的语义,还是那句话,当然不仅仅只限于 tryLock()。如下是 tryAcquire() 的源码:

  protected boolean tryAcquire(int arg) {
       throw new UnsupportedOperationException();
  }

什么?直接 throw 异常?说好的功能呢?好吧,还记得概述里讲的 AQS 只是一个框架,具体资源的获取/释放方式交由自定义同步器去实现吗?就是这里了!!!AQS 这里只定义了一个接口,具体资源的获取交由自定义同步器去实现了(通过state的get/set/CAS)!!!至于能不能重入,能不能加塞,那就看具体的自定义同步器怎么去设计了!!!当然,自定义同步器在进行资源访问时要考虑线程安全的影响

这里之所以没有定义成 abstract,是因为独占模式下只用实现 tryAcquire-tryRelease,而共享模式下只用实现 tryAcquireShared-tryReleaseShared。如果都定义成 abstract,那么每个模式也要去实现另一模式下的接口。说到底,Doug Lea 还是站在咱们开发者的角度,尽量减少不必要的工作量

3.1.2 addWaiter(Node)

此方法用于将当前线程加入到等待队列的队尾,并返回当前线程所在的结点。还是上源码吧:

 private Node addWaiter(Node mode) {
     //以给定模式构造结点。mode有两种:EXCLUSIVE(独占)和SHARED(共享)
     Node node = new Node(Thread.currentThread(), mode);
     
     //尝试快速方式直接放到队尾。
     Node pred = tail;
     if (pred != null) {
         node.prev = pred;
         if (compareAndSetTail(pred, node)) {
             pred.next = node;
             return node;
         }
     }
     
     //上一步失败则通过enq入队
     enq(node);
     return node;
 }

 private Node enq(final Node node) {
     //CAS"自旋",直到成功加入队尾
     for (;;) {
         Node t = tail;
         if (t == null) { // 队列为空,创建一个空的标志结点作为head结点,并将tail也指向它。
             if (compareAndSetHead(new Node()))
                 tail = head;
         } else {//正常流程,放入队尾
             node.prev = t;
             if (compareAndSetTail(t, node)) {
                 t.next = node;
                 return t;
             }
         }
     }
 }
3.1.3 acquireQueued(Node, int)

通过 tryAcquire() 和 addWaiter(),该线程获取资源失败,已经被放入等待队列尾部了。聪明的你立刻应该能想到该线程下一部该干什么了吧:进入等待状态休息,直到其他线程彻底释放资源后唤醒自己,自己再拿到资源,然后就可以去干自己想干的事了。没错,就是这样!是不是跟医院排队拿号有点相似~~,acquireQueued() 就是干这件事:在等待队列中排队拿号(中间没其它事干可以休息),直到拿到号后再返回。这个函数非常关键,还是上源码吧:

 final boolean acquireQueued(final Node node, int arg) {
     boolean failed = true;//标记是否成功拿到资源
     try {
         boolean interrupted = false;//标记等待过程中是否被中断过
         
         //又是一个“自旋”!
         for (;;) {
             final Node p = node.predecessor();//拿到前驱
             //如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源(可能是老大释放完资源唤醒自己的,当然也可能被interrupt了)。
             if (p == head && tryAcquire(arg)) {
                 setHead(node);//拿到资源后,将head指向该结点。所以head所指的标杆结点,就是当前获取到资源的那个结点或null。
                 p.next = null; // setHead中node.prev已置为null,此处再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队了!
                 failed = false;
                 return interrupted;//返回等待过程中是否被中断过
             }
             
             //如果自己可以休息了,就进入waiting状态,直到被unpark()
             if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                 interrupted = true;//如果等待过程中被中断过,哪怕只有那么一次,就将interrupted标记为true
         }
     } finally {
         if (failed)
             cancelAcquire(node);
     }
 }

 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
     int ws = pred.waitStatus;//拿到前驱的状态
     if (ws == Node.SIGNAL)
         //如果已经告诉前驱拿完号后通知自己一下,那就可以安心休息了
         return true;
     if (ws > 0) {
         /*
          * 如果前驱放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
          * 注意:那些放弃的结点,由于被自己“加塞”到它们前边,它们相当于形成一个无引用链,稍后就会被保安大叔赶走了(GC回收)!
          */
         do {
             node.prev = pred = pred.prev;
         } while (pred.waitStatus > 0);
         pred.next = node;
     } else {
          //如果前驱正常,那就把前驱的状态设置成SIGNAL,告诉它拿完号后通知自己一下。有可能失败,人家说不定刚刚释放完呢!
         compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
     }
     return false;
 }
 
 private final boolean parkAndCheckInterrupt() {
     LockSupport.park(this);//调用park()使线程进入waiting状态
     return Thread.interrupted();//如果被唤醒,查看自己是不是被中断的。
 }
3.1.4 小结

再来总结下它的流程吧:

  1. 调用自定义同步器的 tryAcquire() 尝试直接去获取资源,如果成功则直接返回
  2. 没成功,则 addWaiter() 将该线程加入等待队列的尾部,并标记为独占模式
  3. acquireQueued() 使线程在等待队列中休息,有机会时(轮到自己,会被unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回 true,否则返回 false
  4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断 selfInterrupt(),将中断补上
    由于此函数是重中之重,我再用流程图总结一下:
    至此,acquire() 的流程终于算是告一段落了。这也就是 ReentrantLock.lock() 的流程,不信你去看其 lock() 源码吧,整个函数就是一条 acquire(1)
3.2 release(int) 独占模式释放资源

此方法是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。这也正是 unlock() 的语义,当然不仅仅只限于 unlock()。下面是 release() 的源码:

 public final boolean release(int arg) {
     if (tryRelease(arg)) {
         Node h = head;//找到头结点
         if (h != null && h.waitStatus != 0)
             //唤醒等待队列里的下一个线程
             unparkSuccessor(h);
         return true;
     }
     return false;
 }
 
 // 如果已经彻底释放资源(state=0),要返回 true,否则返回 false
 protected boolean tryRelease(int arg) {
     throw new UnsupportedOperationException();
 }

 private void unparkSuccessor(Node node) {
     //这里,node一般为当前线程所在的结点。
     int ws = node.waitStatus;
     if (ws < 0)//置零当前线程所在的结点状态,允许失败。
         compareAndSetWaitStatus(node, ws, 0);
 
     Node s = node.next;//找到下一个需要唤醒的结点s
     if (s == null || s.waitStatus > 0) {//如果为空或已取消
         s = null;
         for (Node t = tail; t != null && t != node; t = t.prev)
             if (t.waitStatus <= 0)//从这里可以看出,<=0的结点,都是还有效的结点。
                 s = t;
     }
     if (s != null)
         LockSupport.unpark(s.thread);//唤醒
 }

逻辑并不复杂。有一点需要注意的是,它是根据 tryRelease() 的返回值来判断该线程是否已经完成释放掉资源了!所以自定义同步器在设计 tryRelease() 的时候要明确这一点

3.3 acquireShared(int) 共享模式获取资源

此方法是共享模式下线程获取共享资源的顶层入口。它会获取指定量的资源,获取成功则直接返回,获取失败则进入等待队列,直到获取到资源为止,整个过程忽略中断。下面是 acquireShared() 的源码

 public final void acquireShared(int arg) {
     if (tryAcquireShared(arg) < 0)
         doAcquireShared(arg);
 }
 
 // 此方法用于将当前线程加入等待队列尾部休息,直到其他线程释放资源唤醒自己,自己成功拿到相应量的资源后才返回
 private void doAcquireShared(int arg) {
     final Node node = addWaiter(Node.SHARED);//加入队列尾部
     boolean failed = true;//是否成功标志
     try {
         boolean interrupted = false;//等待过程中是否被中断过的标志
         for (;;) {
             final Node p = node.predecessor();//前驱
             if (p == head) {//如果到head的下一个,因为head是拿到资源的线程,此时node被唤醒,很可能是head用完资源来唤醒自己的
                 int r = tryAcquireShared(arg);//尝试获取资源
                 if (r >= 0) {//成功
                     setHeadAndPropagate(node, r);//将head指向自己,还有剩余资源可以再唤醒之后的线程
                     p.next = null; // help GC
                     if (interrupted)//如果等待过程中被打断过,此时将中断补上。
                         selfInterrupt();
                     failed = false;
                     return;
                 }
             }
             
             //判断状态,寻找安全点,进入waiting状态,等着被unpark()或interrupt()
             if (shouldParkAfterFailedAcquire(p, node) &&
                 parkAndCheckInterrupt())
                 interrupted = true;
         }
     } finally {
         if (failed)
             cancelAcquire(node);
     }
 }

 // 跟独占模式比,还有一点需要注意的是,这里只有线程是head.next时(“老二”),才会去尝试获取资源,有剩余的话还会唤醒之后的队友。那么问题就来了,假如老大用完后释放了5个资源,而老二需要6个,老三需要1个,老四需要2个。因为老大先唤醒老二,老二一看资源不够自己用继续park(),也更不会去唤醒老三和老四了。独占模式,同一时刻只有一个线程去执行,这样做未尝不可;但共享模式下,多个线程是可以同时执行的,现在因为老二的资源需求量大,而把后面量小的老三和老四也都卡住了
 private void setHeadAndPropagate(Node node, int propagate) {
     Node h = head; 
     setHead(node);//head指向自己
      //如果还有剩余量,继续唤醒下一个邻居线程
     if (propagate > 0 || h == null || h.waitStatus < 0) {
         Node s = node.next;
         if (s == null || s.isShared())
             doReleaseShared();
     }
 }

这里 tryAcquireShared() 依然需要自定义同步器去实现。但是 AQS 已经把其返回值的语义定义好了:负值代表获取失败;0 代表获取成功,但没有剩余资源;正数表示获取成功,还有剩余资源,其他线程还可以去获取。所以这里 acquireShared() 的流程就是:

  1. tryAcquireShared() 尝试获取资源,成功则直接返回
  2. 失败则通过 doAcquireShared() 进入等待队列,直到获取到资源为止才返回
3.4 releaseShared() 共享模式释放资源

此方法是共享模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。下面是 releaseShared() 的源码:

// 释放掉资源后,唤醒后继。跟独占模式下的release()相似,但有一点稍微需要注意:独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于可重入的考量;而共享模式下的releaseShared()则没有这种要求,一是共享的实质--多线程可并发执行;二是共享模式基本也不会重入吧(至少我还没见过),所以自定义同步器可以根据需要决定返回值
 public final boolean releaseShared(int arg) {
     if (tryReleaseShared(arg)) {//尝试释放资源
         doReleaseShared();//唤醒后继结点
         return true;
     }
     return false;
 }

 private void doReleaseShared() {
     for (;;) {
         Node h = head;
         if (h != null && h != tail) {
             int ws = h.waitStatus;
             if (ws == Node.SIGNAL) {//如果头结点状态是signal,即需要唤醒后继节点
                 if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))//CAS一下如果当前状态是signal则重置为0,否则退出当前循环进入下次循环
                     continue;
                 unparkSuccessor(h);//唤醒后继
             }
             //如果头结点状态是0且CAS成功状态重置为传播失败了,退出当前循环进入下次循环
             else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                 continue;
         }
         if (h == head)// head发生变化
             break;
     }
 }
4 简单应用

下面我们就以 AQS 源码里的 Mutex 为例,讲一下 AQS 的简单应用

同步类自己(Mutex)则实现某个接口,对外服务。同步类在实现时一般都将自定义同步器(sync)定义为内部类,只用实现 state 的获取-释放方式 tryAcquire-tryRelelase,至于线程的排队、等待、唤醒等,上层的 AQS 都已经实现好了,我们不用关心

 class Mutex implements Lock, java.io.Serializable {
     // 自定义同步器
     private static class Sync extends AbstractQueuedSynchronizer {
         // 判断是否锁定状态
         protected boolean isHeldExclusively() {
             return getState() == 1;
         }
 
         // 尝试获取资源,立即返回。成功则返回true,否则false。
         public boolean tryAcquire(int acquires) {
             assert acquires == 1; // 这里限定只能为1个量
             if (compareAndSetState(0, 1)) {//state为0才设置为1,不可重入!
                 setExclusiveOwnerThread(Thread.currentThread());//设置为当前线程独占资源
                 return true;
             }
             return false;
         }
 
         // 尝试释放资源,立即返回。成功则为true,否则false。
         protected boolean tryRelease(int releases) {
             assert releases == 1; // 限定为1个量
             if (getState() == 0)//既然来释放,那肯定就是已占有状态了。只是为了保险,多层判断!
                 throw new IllegalMonitorStateException();
             setExclusiveOwnerThread(null);
             setState(0);//释放资源,放弃占有状态
             return true;
         }
     }
 
     // 真正同步类的实现都依赖继承于AQS的自定义同步器!
     private final Sync sync = new Sync();
 
     //lock<-->acquire。两者语义一样:获取资源,即便等待,直到成功才返回。
     public void lock() {
         sync.acquire(1);
     }
 
     //tryLock<-->tryAcquire。两者语义一样:尝试获取资源,要求立即返回。成功则为true,失败则为false。
     public boolean tryLock() {
         return sync.tryAcquire(1);
     }
 
     //unlock<-->release。两者语文一样:释放资源。
     public void unlock() {
         sync.release(1);
     }
 
     //锁是否占有状态
     public boolean isLocked() {
         return sync.isHeldExclusively();
     }
 }
5 总结

了解了老李头的 AQS,再去看J UC 下的类就简单明了啦,如下:

  1. 独占模式:
    1. ReentrantLock:可重入锁。state=0 独占锁,或者同一线程可多次获取锁(获取+1,释放-1)
    2. Worker(java.util.concurrent.ThreadPoolExecutor类中的内部类) 线程池类。shutdown 关闭空闲工作线程,中断 worker 工作线程是独占的,互斥的
  2. 共享模式:
    1. Semaphore:信号量。 控制同时有多少个线程可以进入代码段。(互斥锁的拓展)
    2. CountDownLatch:倒计时器。 初始化一个值,多线程减少这个值,直到为 0,倒计时完毕,执行后续代码
  3. 独占+共享模式:
    1. ReentrantReadWriteLock:可重入读写锁。独占写+共享读,即并发读,互斥写
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值