AQS源码分析

要研究AQS源码,需要从两个方向入手:

1.他是啥?能干啥?

2.怎么干?

第一个问题,他是啥,能干啥:

为实现依赖于先进先出 (FIFO) 等待队列的阻塞锁和相关同步器(信号量、事件,等等)提供一个框架。此类的设计目标是成为依靠单个原子 int 值来表示状态的大多数同步器的一个有用基础。子类必须定义更改此状态的受保护方法,并定义哪种状态对于此对象意味着被获取或被释放。假定这些条件之后,此类中的其他方法就可以实现所有排队和阻塞机制。子类可以维护其他状态字段,但只是为了获得同步而只追踪使用 getState()setState(int) 和 compareAndSetState(int, int) 方法来操作以原子方式更新的 int 值。

应该将子类定义为非公共内部帮助器类,可用它们来实现其封闭类的同步属性。类 AbstractQueuedSynchronizer 没有实现任何同步接口。而是定义了诸如 acquireInterruptibly(int) 之类的一些方法,在适当的时候可以通过具体的锁和相关同步器来调用它们,以实现其公共方法。

首先他是有一个先进先出的等待队列,该等待队列实现类似于ConcurrentLinkedQueue,该队列的目的是,当tryAcquire失败时需要将当前线程入等待队列,因此需要一定的并发准确性,也可以凭此实现公平锁和非公平锁,公平锁就是上来先不尝试tryAcuqire,而是先入队,当轮到的时候在进行acquire,非公平就是上来就tryAcquire,失败之后入队,这对其他正在等待的线程来说显然是不公平的。我们的重点也是看它实现的支持并发的队列机制独占和共享的不同实现,通知实现

 

首先是AQS的公共方法列表如下:

voidacquire(int arg)
          以独占模式获取对象,忽略中断。
 voidacquireInterruptibly(int arg)
          以独占模式获取对象,如果被中断则中止。
 voidacquireShared(int arg)
          以共享模式获取对象,忽略中断。
 voidacquireSharedInterruptibly(int arg)
          以共享模式获取对象,如果被中断则中止。
protected  booleancompareAndSetState(int expect, int update)
          如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。
 Collection<Thread>getExclusiveQueuedThreads()
          返回包含可能正以独占模式等待获取的线程 collection。
 ThreadgetFirstQueuedThread()
          返回队列中第一个(等待时间最长的)线程,如果目前没有将任何线程加入队列,则返回 null. 在此实现中,该操作是以固定时间返回的,但是,如果其他线程目前正在并发修改该队列,则可能出现循环争用。
 Collection<Thread>getQueuedThreads()
          返回包含可能正在等待获取的线程 collection。
 intgetQueueLength()
          返回等待获取的线程数估计值。
 Collection<Thread>getSharedQueuedThreads()
          返回包含可能正以共享模式等待获取的线程 collection。
protected  intgetState()
          返回同步状态的当前值。
 Collection<Thread>getWaitingThreads(AbstractQueuedSynchronizer.ConditionObject condition)
          返回一个 collection,其中包含可能正在等待与此同步器有关的给定条件的那些线程。
 intgetWaitQueueLength(AbstractQueuedSynchronizer.ConditionObject condition)
          返回正在等待与此同步器有关的给定条件的线程数估计值。
 booleanhasContended()
          查询是否其他线程也曾争着获取此同步器;也就是说,是否某个 acquire 方法已经阻塞。
 booleanhasQueuedThreads()
          查询是否有正在等待获取的任何线程。
 booleanhasWaiters(AbstractQueuedSynchronizer.ConditionObject condition)
          查询是否有线程正在等待给定的、与此同步器相关的条件。
protected  booleanisHeldExclusively()
          如果对于当前(正调用的)线程,同步是以独占方式进行的,则返回 true
 booleanisQueued(Thread thread)
          如果给定线程的当前已加入队列,则返回 true。
 booleanowns(AbstractQueuedSynchronizer.ConditionObject condition)
          查询给定的 ConditionObject 是否使用了此同步器作为其锁。
 booleanrelease(int arg)
          以独占模式释放对象。
 booleanreleaseShared(int arg)
          以共享模式释放对象。
protected  voidsetState(int newState)
          设置同步状态的值。
 StringtoString()
          返回标识此同步器及其状态的字符串。
protected  booleantryAcquire(int arg)
          试图在独占模式下获取对象状态。
 booleantryAcquireNanos(int arg, long nanosTimeout)
          试图以独占模式获取对象,如果被中断则中止,如果到了给定超时时间,则会失败。
protected  inttryAcquireShared(int arg)
          试图在共享模式下获取对象状态。
 booleantryAcquireSharedNanos(int arg, long nanosTimeout)
          试图以共享模式获取对象,如果被中断则中止,如果到了给定超时时间,则会失败。
protected  booleantryRelease(int arg)
          试图设置状态来反映独占模式下的一个释放。
protected  booleantryReleaseShared(int arg)
          试图设置状态来反映共享模式下的一个释放。

 

其中的五个方法是需要我们去扩展的:如下:

tryAcquire(int),tryRelease(int),tryAcquireShared(int),tryReleaseShared(int),isHeldExclusively()

首先肯定是从几个关键API方法入手,他们是:

 voidacquire(int arg)
          以独占模式获取对象,忽略中断。
 voidacquireInterruptibly(int arg)
          以独占模式获取对象,如果被中断则中止。
 voidacquireShared(int arg)
          以共享模式获取对象,忽略中断。
 voidacquireSharedInterruptibly(int arg)
          以共享模式获取对象,如果被中断则中止。
 booleanrelease(int arg)
          以独占模式释放对象。
 booleanreleaseShared(int arg)
          以共享模式释放对

首先从acquire(int) 入手,看其是怎么实现的:

public final void acquire(long arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        //如果acquireQueued过程当前线程被打断,此时会消耗掉打断状态(Thread.interrupted()方法会消耗打断状态),此时我需  要重现该状态,因此主动调用Thread.currentThread.interrupt(),供其他方法进行判断(大多数情况下用不到,但是这么做确实体现了这段代码的严谨性)
        selfInterrupt();
}

其实现也是,先tryAcuire(arg)成功了就返回,不成功就将当前线程创建一个waiter节点入队具体实现如下:

private Node addWaiter(Node mode) {
    //首先将节点创建出来,这是必须的一步
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

如果casTail失败,或者当前tail为空(即当前队列尚未初始化,需要进行初始化,并将当前节点重新入队,前面casTail也是入队过程,只不过失败了),以下是入队过程:

private Node enq(final Node node) {
   //cas搭配for循环  
   //这里相对于ConcurrentHashMap依赖sizeCtl变量进行状态控制,此处代码处理的并不是很好,有大量竞争的情况下,会新建大量的
   //node对象。当然代码实现上肯定是没问题的,这也是常见的代码逻辑:
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            //添加新节点要同时修改prev,next指针
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

入队完成之后,需要acquireQueued;

前一步入队只是将代表当前thread的节点插入到队列中,但是并没有开始得到signal,循环申请锁的过程,acquireQueued就是实现这个动作:

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        //开一个for循环,每次release的时候都会唤醒,我需要不断检查代表当前线程的节点是否已经是
        //头节点,如果是头节点,tryAcquire,不是头节点,我需要继续阻塞等待下一轮release唤醒,这是其
        //主要处理逻辑。但是因为其中涉及到condition和超时取消,我需要进一步进行判断是否进行阻塞等待(通过                 //LockSupport.part()实现阻塞)
        //在这过程中依然会出现tryAcquire失败的情况,tryAcquire是我们自己实现的方法,一般情况下,对于shared类型,   tryAcquire时会因为state<arg导致分配失败,这跟我们的具体实现有关,或者mutex类型的,当前state为0表示unlocked,但是依然传入一个0,即连续两次unlock,此时tryAcquire也会失败,对于这样的情况,我们只能让第二次的unlock操作阻塞,等待一次lock操作在进行通知。  注意AQS不理解相关的锁的语义或者说我们自己实现的同步器的同步语义,我们必须正确分析state状态流转过程
        
        for (;;) {
            //此处进入一个for无限循环,退出条件有两种,1:当前节点成为头节点并且tryAcquire成功,此时相当于
            //获取锁成功,failed=false;2.park过程被打断抛出InterruptException异常,此时failed=true,但是经过
            //我测试,LockSupport.park时线程被打断,并不会抛出异常,所以此处存疑。
           
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            //如果当前节点不是头节点,或者tryAcquire失败,判断当前节点和之前节点的状态是否进行阻塞等待,这里的状态
            //流转很有意思,需要结合shouldParkAfterFailedAcquire一起看,当判断出来需要阻塞等待的时候(主要原因是当前节点不是头节点)需要进行阻塞,阻塞过程如果被interrupt,需要重新进行该for循环,重新检查当前节点是不是头节点,如果不是,继续park
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        /*
         * This node has already set status asking a release
         * to signal it, so it can safely park.
         */
        return true;
    if (ws > 0) {
        /*
         * Predecessor was cancelled. Skip over predecessors and
         * indicate retry.
         */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
       //初始时,当前节点刚加入队列,waitStatus=0,进入这个分支,将当前节点的前置节点
      //waitStatus置为SIGNAL,之后返回false,结合之前的acquireQueued,将会发起新一轮for循环,如果当前节点还不是头节点或者tryAcquire失败,会再次进入这个方法,此时将会判断前置节点的waitStatus=SIGNAL,返回true,此时当前线程将会阻塞等待被唤醒。为什么是要设置当前节点的前置节点的waitStatus而不是当前节点呢?这个待会再谈
        /*
         * waitStatus must be 0 or PROPAGATE.  Indicate that we
         * need a signal, but don't park yet.  Caller will need to
         * retry to make sure it cannot acquire before parking.
         */
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

为什么是要设置当前节点的前置节点的waitStatus而不是当前节点呢?

这个主要是其队列实现方式决定的,没办法的事。一开始Head节点被设置好了,按照正常逻辑,当当前节点是头节点的时候,此时应该是轮到当前节点所代表的当前线程去tryAcquire,但是在队列实现时,头节点被设置成一个标志位,头节点的下一节点是真实的头节点,而且头节点保存了此节点的waitStatus信息,以此类推,前置节点保存了后置节点的waitStaus信息。尾节点为null,也是个标志位,因此逻辑上的尾节点,不需要保存waitStatus信息。这里不必过度纠结。理解上不会出现问题,当然也可以实现有效节点,但是毕竟不如头节点和尾节点是标志位的实现会更好一些,ConcurrentLinkedQueue的头节点和尾节点也只是标志位

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

这个实现看起来很简单,先是tryRelease,如果成功,唤醒当前节点的下一个节点(如果有的话),注意这个操作是由调用时序决定的, 在进行release之前,必然经历过acquire,而不是头节点的进行acquire将会park,因而要能进行release,当前线程必然位于头节点。如果不满足这个调用时序,按道理将会出错,为了仍保证正确,你需要在tryRelease中对当前状态进行判断。如允许,才返回true.具体的可以看看ReentrantLock的实现

在unparkSuccessor(head)中:

private void unparkSuccessor(Node node) {
    /*
     * If status is negative (i.e., possibly needing signal) try
     * to clear in anticipation of signalling.  It is OK if this
     * fails or if status is changed by waiting thread.
     */
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    /*
     * Thread to unpark is held in successor, which is normally
     * just the next node.  But if cancelled or apparently null,
     * traverse backwards from tail to find the actual
     * non-cancelled successor.
     */
    Node s = node.next;
    //这里由于是头节点,其next节点往往不是null,因此正常情况下都是通知头节点的下一个节点
    if (s == null || s.waitStatus > 0) {
        s = null;
        //对于异常情况,头节点的下一节点的waitStatus>0,即为CANCELLED状态,之前解释过,当failed=true的时候才会触发
        //cancelAcquire,能够触发的也就是带超时时间tryAcquireNanos()和允许打断tryAcquireInteruptibly()的两种,此时
        //触发的是当前节点的取消操作,为什么是从后往前找,需要进一步看cancelAcquire的实现:
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
        LockSupport.unpark(s.thread);
}

方法cancelAcquire(Node node),当tryAcquireNanos超时或者tryAcquireInteruptibly被打断的时候被触发:

按照正常的想法,取消当前节点的acquire,只需要

1.如果当前节点为null,直接返回即可

2.不为null,将该节点从队列里面删除:node.next.prev=node.prev,  node.next=node.prev=null 即可

3.删除该节点之后,unpark当前节点所对应的线程

但是它并没有直接这么做,为什么呢?

首先,这里会存在并发竞争,试想,多个node当tryAcquireInteruptibly同时被打断,或者同时超时时,而且将当前节点从队列里面删除并不是一个原子操作,而且还需要实现这样的定义:

If a node is cancelled, its successor is (normally) relinked to a non-cancelled predecessor.

其具体实现如下:(有心的同学可以去参考ConcurrentLinkedQueue的remove(Object)操作,观察其异同)

private void cancelAcquire(Node node) {
    // Ignore if node doesn't exist
    if (node == null)
        return;

    node.thread = null;

    // Skip cancelled predecessors
    Node pred = node.prev;
    while (pred.waitStatus > 0)
        node.prev = pred = pred.prev;

    // predNext is the apparent node to unsplice. CASes below will
    // fail if not, in which case, we lost race vs another cancel
    // or signal, so no further action is necessary.
    Node predNext = pred.next;

    // Can use unconditional write instead of CAS here.
    // After this atomic step, other Nodes can skip past us.
    // Before, we are free of interference from other threads.
    node.waitStatus = Node.CANCELLED;

    // If we are the tail, remove ourselves.
    if (node == tail && compareAndSetTail(node, pred)) {
        compareAndSetNext(pred, predNext, null);
    } else {
        // If successor needs signal, try to set pred's next-link
        // so it will get one. Otherwise wake it up to propagate.
        int ws;
        if (pred != head &&
            ((ws = pred.waitStatus) == Node.SIGNAL ||
             (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
            pred.thread != null) {
            Node next = node.next;
            if (next != null && next.waitStatus <= 0)
                compareAndSetNext(pred, predNext, next);
        } else {
            unparkSuccessor(node);
        }

        node.next = node; // help GC
    }
}

经过查看源码主要是第二步有差异,虽然node.prev指向了其前方最近的一个状态不是CANCELLED的节点,在取消过程中其他取消节点也会这么做,最终达成了这样一种状态:

这里没有考虑一致性,可能会出现某个cancelled节点的前置节点又被取消,此时其实不要紧,原因是:

1.cancelled状态是不可逆的,当某个节点被取消,虽然该节点仍在队列里,但是在acquire从头节点往后筛的时候,waitStatus>0的节点将会被忽略,从而被删除出队列,

shouldParkAfterFailedAcquire如下:
if (ws > 0) {
    /*
     * Predecessor was cancelled. Skip over predecessors and
     * indicate retry.
     */
    do {
        node.prev = pred = pred.prev;
    } while (pred.waitStatus > 0);
    pred.next = node;

}

一连串的CANCELLED节点将会从队列中删除掉,很骚气。

另外此处也存在几个问题,

1.由于之前cancelAcquire的时候,取消节点没有被真正被删除,可能会造成已取消的节点无法被垃圾回收,队列过长的问题,唤醒下一个节点可能会搜索较长时间

2.如果某个当前节点一直park住,而有没有新节点加入,如果其他节点多次触发cancelAcquire,可能会造成CANCELLED节点不断增长,这个问题和1其实差不多

当然了 上述情况只是极端情况,实际操作中并不存在。就算存在,也不会这么极端,在可控范围之内

上述是exclusive模式下的acquire和release

接下来是shared模式的:

public final void acquireShared(int arg)

以共享模式获取对象,忽略中断。通过至少先调用一次 tryAcquireShared(int) 来实现此方法,并在成功时返回。否则在成功之前,一直调用 tryAcquireShared(int) 将线程加入队列,线程可能重复被阻塞或不被阻塞。

 

参数:

arg - acquire 参数。此值被传送给 tryAcquireShared(int),但它是不间断的,并且可以表示任何内容。

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}

可以看到 也是先调用tryAcquireShared(arg),完成至少先调用一次 tryAcquireShared(int),如不成功,再进行

doAcquireShared(arg); 在该方法中用到了与exclusive类似的逻辑,实现如下:

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) {
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    //这里的r就相当于剩余凭证,尝试将凭证尽可能的传播给更多节点
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

整理来看,与之前的acquireQueued类似,也是先addWaiter进队,然后开一个for循环,for循环内部一旦当前节点的前置节点是头节点了,进行tryAcquireShared,成功之后删除头节点,设置当前节点为头节点,如果在这期间发生过Thread.interrupt,则调用selfInterrupt()恢复标志位,如果当前不是头节点,判断shouldParkAfterFailedAcquire,然后进行阻塞等待被唤醒,与之前的逻辑几乎一样,所不同的在于tryAcquireShared成功之后,原先的setHead变成了setHeadAndPropagate,这也是share和exclusive的重要区别之一,exclusive往往是tryAcquire成功之后,后面的继续等,当release的时候,唤醒后继节点,而shared模式则是当前节点tryAcquireShared成功之后,继续检查后续节点也能否接着进行tryAcquireShared,因为是共享锁嘛,当有一个线程能够tryAcquireShared,意味着release了一批“凭证”,后续节点应尽可能的争夺这些凭证,这就产生了“传播过程”,直到凭证全部被争夺完,即tryAcquireShared返回值小于0。当release的时候亦是如此,先是执行tryReleaseShared,如果释放成功,且返回值为true,代表允许唤醒后续节点,也将执行节点唤醒的传播过程。

共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点

而独占模式则是同一时刻仅允许一个线程执行

传播过程实现在doReleaseShared() (我也不知道这个函数为什么起这个名字,因为真正释放“凭证”的是在tryReleaseShared中)

private void doReleaseShared() {
    /*
     * Ensure that a release propagates, even if there are other
     * in-progress acquires/releases.  This proceeds in the usual
     * way of trying to unparkSuccessor of head if it needs
     * signal. But if it does not, status is set to PROPAGATE to
     * ensure that upon release, propagation continues.
     * Additionally, we must loop in case a new node is added
     * while we are doing this. Also, unlike other uses of
     * unparkSuccessor, we need to know if CAS to reset status
     * fails, if so rechecking.
     */
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

这段代码的状态流转也很骚气,一开始怎么看也看不明白,我们先理一下程序执行流程,分析可能执行的情况:

为了表示share模式的特点,我们这样做

1.线程A直接tryAcquireShared成功,不入队

2.线程B也直接tryAcquireShared成功,不入队

3.线程C执行tryAcquireShared不成功,入队,执行for循环,由于当前节点的前置节点是头节点,再次tryAcquireShared,由于A,B都没有release,因而再次失败,shouldPark将其waitStatus从0->SIGNAL,下一轮for循环由于AB仍然没有释放(或者释放了,try成功,并修改头节点,并通知后续节点,但这种情况较为简单,不予讨论),再次失败,shouldPark判定为true,进行阻塞等待被唤醒

4.线程D执行tryAcquireShared不成功,入队,执行for循环,由于当前节点的前置节点不是头节点,因此不进行try,shouldPark将其waitStatus从0->SIGNAL,再次进行for循环,进行阻塞等待被唤醒

5.线程A,B的任意一个执行完成,或者同时执行完成,并发调用release,为了情况复杂一点,我们假设同时完成,同时调用release,且try都返回true,同时执行doReleaseShared去同时唤醒后继节点

6.由于AB不在队列里面,当然在不在队列里面无所谓,反正唤醒操作都是从队列头节点开始,开启一个for+cas,

此时头节点的waitStatus是SIGNAL(注意,根据前面分析,当前节点的waitStatus保存在其前置节点上),状态为SIGNAL,cas修改头节点的waitStatus为0,相当于一个新进节点,cas成功者线程唤醒线程C,调用成功者线程检查,线程C继续执行,注意此时只可能唤醒队列中的一个线程,一定要注意理解这句话!!!!! 虽然这样,依然有可能有多个新线程来和刚唤醒的线程C去并发执行acquireShared,导致线程C又失败,再次park住。   cas失败者线程继续for循环,执行compareAndSetWaitStatus(h, 0, Node.PROPAGATE)  然后检查当前节点变化情况,如h!=head 说明头节点被移除,我们之前的cas操作都落在了过时节点上,因而对现有队列无影响,此时按道理来说,我们有两个选择,1.退出循环,等待下一波线程去唤醒 2.继续循环,直到方法调用完成前,将cas操作实实在在的落到当前队列头节点。 显然2更符合通知头节点语义,AQS也是这么做的。  

还有一个问题 PROPAGATE状态其含义是什么,怎么处理?

 

未完待续----------------------

 

 

 

 

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值