并发编程之AQS(抽象队列同步器)独占锁

前言

在介绍AQS之前,我专门写了CAS、Volatile、synchronized、阻塞队列,链表以及线程中断的笔记,因为在学习AQS之前,如果对于这些基础的东西不了解的话,那么AQS你是看不懂的,AQS我自己认为还是比较难的,但是只要是学习技术,那么如果都那么简单的话,那也就没有专门来学习的必要了,我也难得写笔记来记录它了,因为如果简单,就想1+1=2,没有必要来记录,来记录的目的也是对自己这么多年工作所得的经验的一个记录,不管写的怎么样,既然写出来和大家分享,那么可能写的不是那么好,但是你要是喜欢就看,不喜欢就可以退出,不喜勿喷。我只是将自己这么多年的学习的技术栈记录下来,我本人很喜欢研究底层,所以大多数都是我底层研究所得,不会为了写一个技术去网络上各种搜索,说实话,与其去网络上搜索来记录,还不如自己去研究底层,因为底层的实现原理不会骗人,你自己研究懂了,你就是最专业的,同一个技术原理,可能网络上五花八门,把你都绕晕了,你都不知道该信谁的,要是你自己去研究底层所得,那么它就是对的,你就是专业的,所以写笔记或者博客也只是为了记录自己的所得,也可以作为生活的轨迹,人生轨迹的一部分吧;好了,今天废话有点多,下面开始今天的主题,AQS同步器的记录。

AQS原理

Java并发编程的核心在于java.util.concurrent包。而juc当中大多数同步器实现都是围绕着共同的基础行为,比如等待队列、条件队列、独占获取、共享获取等,而这个行为的抽象就是基于AbstractQueuedSynchronizer,简称AQS。AQS定义了一套多线程访问共享资源的同步器框架,是一个依赖状态(state)的同步器
AQS具备特性:
阻塞等待队列
共享/独占
公平/非公平
可重入
允许中断

这些特性是怎么实现的,以ReentrantLock为例:
一般通过定义内部类Sync [sɪŋk] 继承AQS
将同步器所有调用都映射到Sync对应的方法

同步器队列结构

同步器实现的数据结构为双向链表结构,我们先来了解下双向链表结构,比如下图:
在这里插入图片描述
双向链表的几个基本元素:
head:头结点(指向第一个节点)
tail:尾结点(指向最后一个节点)
prev:前驱节点
nex:后驱节点
双向链表结构在在内存中不是连续的,可能分布在内存的不同区域cell中,它们是通过指针引用关系进行联系的;

ReentrantLock

ReentrantLock是可重入,公平,非公平,可独占锁,它和synchronized的区别这里就不说了,前几篇文章已经说得够多了,这里先看一段程序代码:

public class AqsTest {


    private final static Lock lock = new ReentrantLock();
    static int sum = 0;

    public static void main(String[] args) throws InterruptedException {

        for (int i = 1; i <= 3; i++) {
           Thread thread =  new Thread(new Runnable() {
                @Override
                public void run() {
                    lock.lock();
                    try {
                        for (int j = 1; j <= 10000; j++) {
                            sum ++;
                        }
                    } catch (Exception e) {
                        e.printStackTrace();
                    } finally {
                        lock.unlock();
                    }
                }
            }, "Thread-" + i);
           thread.start();
        }

        Thread.sleep(5000);
        System.out.println(sum);


    }


}

上面的程序代码中启动而来3个线程,每个线程给sum+=10000,如果是线程安全的,那么最后sum的结果肯定是30000,如果我们去掉lock,那么肯定没有办法输出正确结果的,所以lock和synchronized想要达到的目的是一样的,就看在什么场景下使用,在jdk的底层代码阶段,synchronized、lock、cas、Unsafe用的非常多,可以说是开发jdk api的基础,通过上面的例子来分析下ReentrantLock

类构造

public class ReentrantLock implements Lock, java.io.Serializable {
    private static final long serialVersionUID = 7373984872572414699L;
    /** Synchronizer providing all implementation mechanics */
    private final Sync sync;

    /**
     * Base of synchronization control for this lock. Subclassed
     * into fair and nonfair versions below. Uses AQS state to
     * represent the number of holds on the lock.
     */
    abstract static class Sync extends AbstractQueuedSynchronizer {

ReentrantLock 实现了Lock接口,Lock接口中定义了锁的基本操作接口方法,比如我们自己要实现一个自己的锁,那么我们也可以实现Lock接口即可;然后ReentrantLock 中有个属性Sync,Sync是继承AbstractQueuedSynchronizer (AQS)抽象队列同步器,而AQS中定义了很多同步队列操作方法,为什么说AQS是抽象队列同步器,因为AQS中定义了很多同步操作方法,包括数据队列双向链表结构,以及很多同步操作的方法,是一个标准;简单来说就是AQS采用了模板设计方法的设计模式来设计AQS,后面的同步器都是基于它的标准来的。
我这边基于3个线程来把ReentrantLock的lock和unlock分析下,首先看构造方法,我们知道ReentrantLock是可公平,非公平的,就是你在构造的时候如果传入true就是公平锁,如果不传入fair或者传入的false就是非公平锁,非公平锁意思就是插队,公平的话就每次进入队列,不能进行插队,我们看下构造:

//下面这个构造就是非公平锁的实现
//非公平的锁是由NonfairSync实现的
public ReentrantLock() {
    sync = new NonfairSync();
}

/**
 * Creates an instance of {@code ReentrantLock} with the
 * given fairness policy.
 *
 * @param fair {@code true} if this lock should use a fair ordering policy
 */
 //下面这个构造可以构造公平或者非公平,根据你传入的
 //fair是否为true,公平锁是由FairSync来实现的
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
static final class FairSync extends Sync {
    private static final long serialVersionUID = -3000897897090466540L;

//公平锁直接放入队列
    final void lock() {
        acquire(1);
    }
    
    
    static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;

    /**
     * Performs lock.  Try immediate barge, backing up to normal
     * acquire on failure.
     */
     //非公平锁,lock的时候下面的cas和setExclusiveOwnerThread就是
     //插队
    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }

队列的数据结构信息Node

static final class Node {
    //共享模式,资源可以同时去拿
    static final Node SHARED = new Node();
    //独占模式,只能有一个线程去拿
    static final Node EXCLUSIVE = null;

   //表示当前线程被中断了,在队列中没有任何意义,可以被剔除了
    static final int CANCELLED =  1;
    /**
     * 后继节点的线程处于等待状态,而当前节点如果释放了同步状态或者被取消,
     * 将会通知后继节点,使后继节点得以运行
     */
    static final int SIGNAL    = -1;

    /**
     * 节点在等待队列中,节点的线程等待在Condition上,当其他线程对Condition调用了signal()方法后,
     * 该节点会从等待队列中转移到同步队列中,加入到同步状态的获取中
     */
    static final int CONDITION = -2;
    /**
     * 表示下一次共享方式同步状态获取将会被无条件的传播下去
     */
    static final int PROPAGATE = -3;

    /**
     * 标记当前节点的信号量状态(1,0,-1,-2,-3)5种状态
     * 使用CAS更改状态,volatile保证线程可见性,并发场景下,
     * 即被一个线程修改后,状态会立马让其他线程可见
     */
    volatile int waitStatus;

    /**
     * 前驱节点,当前节点加入到同步队列中被设置
     */
    volatile Node prev;

    /**
     * 后继节点
     */
    volatile Node next;

    /**
     * 节点同步状态的线程
     */
    volatile Thread thread;

    /**
     * 等待队列中的后继节点,如果当前节点是共享的,那么这个字段是一个SHARED常量
     * 也就是说节点类型(独占和共享)和等待队列中的后继节点公用一个字段
     * (用在条件队列里面)
     */
    Node nextWaiter;
    }

这个笔记目前只介绍独占锁,但是 AQS是有共享锁的,共享锁在下一个笔记中记录

lock过程

我也在想如何写这个笔记,才能让之后的自己或者别人能够很快理解AQS独占锁的原理,我这边通过程序和debug来分析,在idea中设置debug的时候看线程:
在这里插入图片描述
这样设置了,启动了程序过后,就可以看指定的线程
在这里插入图片描述
我这边只有3个线程,所以就可以debug切换看线程,因为循环是从1开始的,所以很肯定线程1先启动,我们看下:在这里插入图片描述
线程1执行到这里,开始上锁,因为我设置的锁是非公平锁,所以这里进入的非公平锁,
我们分析下代码:

final void lock() {
    //线程的有一个state非常重要,因为ReentrantLock是可重入的锁
    //所以这里cas将state由0修改为1,这里肯定是能成功的,因为
    //目前就一个线程到这里,所以它肯定能成功,这个时候state=1
    if (compareAndSetState(0, 1))
    //cas成功过后,这里是独占锁,独占锁,exclusiveOwnerThread
    //表示独占这个线程,也就是说执行了下面的代码,当前只有这个线程
    //可以运行,而Thread.currentThread()目前是Thread-1
        setExclusiveOwnerThread(Thread.currentThread());
    else
    //这里Thread-1肯定就是不能进入的,这里的意思是如果cas失败,cas失败
    //的情况就是当前有线程获取了锁,我没法获取所,我就进入队列中
           acquire(1);
}

现在线程1获取了锁,就可以执行自己的业务逻辑了,现在我们切换到Threa-2,看下
在这里插入图片描述
在这里插入图片描述
看上图,此时,Threa-1获取了锁,正在执行,这个时候Thread-2又来获取所,很显然,Thread-2是没有办法获取锁的,所以进入队列,我们来看下acquire方法

//arg传入的是1
public final void acquire(int arg) {
    //这个if分为了2部分,第一部分是尝试下获取所,万一
    //这个时候Thread-1释放了,但是我这边是用debug,Thread-1还堵塞
    //在,所以第一个条件肯定是false
    //第二个条件中的括号中的是添加到waiter,也就是添加到等待队列
    if (!tryAcquire(arg) &&
    //这个Node.EXCLUSIVE表示锁的类型,这个是独占模式,目前是null
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

在这里插入图片描述
这个添加到等待队列的deubg如上,首先来创建一个节点,这个节点要存放我们的添加的线程的信息,这个时候
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;//null
this.thread = thread;//Thread-1
}

//添加Thread-2到等待队列
private Node addWaiter(Node mode) {
    //构建一个节点,用来存放Threa-2,mode=null
    Node node = new Node(Thread.currentThread(), mode);
    // Try the fast path of enq; backup to full enq on failure
    //声明一个pred节点,将尾结点赋值给pred,但是Thread-2进入到这里
    //Threa-1已经在运行,不在队列,所以这里Thread-2到这里的时候
    //tail肯定为空
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    //进队
    enq(node);
    return node;
}

在这里插入图片描述
在这里插入图片描述
这个时候enq进队,上面创建的一个节点node,看下截图,这个时候前驱,后驱都是空的,因为这个队列刚创建了一个节点

private Node enq(final Node node) {
    for (;;) {
        //首先把尾结点给Node t,这个时候|Thread-2到这里时,
        //看上图就知道这个时候head、tail都是空的,所以这里进入if
        Node t = tail;
        if (t == null) { // Must initialize
        //创建一个空节点,将空节点通过cas设置到head,也就是创建的这个
        //节点是头节点head,因为这个时候在构建整个队列
        //如果只创建一个节点,所以这个时候head=tail
        //也就是说head也是tail,tail也是head
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            //因为这里是一个循环,所以第二次循环肯定能到这里
            //第一次循环已经创建了头节点和尾结点了,
            //这个时候t是指向尾结点的
            //所以这里构建队列的结构的时候,node是上面构建的一个
            //Threa-2的节点,这里将尾结点tail指向node当前前驱节点
            //什么意思呢,就是尾结点发送变化,尾结点为node
            //tail.prev = node
            node.prev = t;
            //将尾结点设置为node
            if (compareAndSetTail(t, node)) {
                //cas成功过后,整个队列就构建成功了
                //head=new Node()
                //tail=node
                //node.prev=t
                //t.next=node
                t.next = node;
                return t;
            }
        }
    }
}

enq执行完成过后到这里,这里其实就是阻塞线程的
在这里插入图片描述

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            //Thread-2到这里node是Threa2,这里获取
            //的p就是node的前驱节点,这里判断前驱节点是否
            //是head节点和尝试获取锁,这个时候获取锁
            //失败的
            final Node p = node.predecessor();
            //获取锁过后,设置当前线程为Thead-2,但是这里不能成功的
            //因为Thead-1还没有释放锁
            if (p == head && tryAcquire(arg)) {
                //获取锁过后,设置头节点的线程为空
                //这里要记得,获取锁过后的前驱节点永远是空的
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }
            //这个时候Thread-2的waitStatus=0,修改状态为-1,表示
            //下次可以被唤醒
            //parkAndCheckInterrupt是调用parak进行阻塞
            //所以线程2在这里就被阻塞在这里,等待下次唤醒
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}
//这个方法就是修改waitStatus,-1表示可以被唤醒启动
//如果waitStatus=0,则修改为-1,下次可启动
//如果waitStatus=-1表示可以启动
//如果waitStatus > 0表示线程无效的,要剔除
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 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;
}

切换线程3,debug如下:
在这里插入图片描述
这个时候tail不为空,所以进入if,cas修改尾结点为新创建的节点

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) {
        //Threa-3进入这里,新创建的节点的前驱节点为尾结点
        //就是入队的操作,原来的尾结点往前移动,新创建的节点作为
        //尾结点,然后把新创建的节点通过cas修改为尾结点
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

然后Threa3和线程2一样的调用acquireQueued进行阻塞,这个过程中我也不知道如何表述才能完全的表述出来,大概的意思就是同时3个线程进入lock的时候,只有一个lock能进入执行,其他两个线程都进入了等待队列阻塞,然后线程1执行完成过后,调用unlock解锁过后,线程2和线程3才能去获取锁,然后执行完解锁,后面的线程才能获取锁,我们先通过图来理解下过程,lock完了之后再进行unlock的解读,我先来看下获取锁的过程

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    //state =0才能进行尝试获取锁,每次获取锁过后+1
    int c = getState();
    if (c == 0) {
        //cas修改state=1
        if (compareAndSetState(0, acquires)) {
            //修改成功设置当前线程为独占线程
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    //下面这个判断是如果已经获取了锁,再次获取锁,也就是
    //重复获取锁,因为lock是可以重入的,所以下面的state+1
    //直到state=0才会释放锁
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

获取锁的逻辑非常简单,就是一般的逻辑代码

lock图解过程

在这里插入图片描述
如上图所示,循环启动3个线程,每个线程循环1万次,上图的过程只是每个线程lock的过程,其中只有第一个线程能够获取锁即Thread-1,Thread-2和Thread-3都是先入队,然后进行各自阻塞,通过上面的分析可知,每次获取不到锁过后,在阻塞之前,都会回去前一个节点然后修改前一个节点的waitStatus=-1,-1表示可以被唤醒的意思;文字描述下3个线程lock的过程:
Thread-1:线程1进入lock时,这个时候没有其他线程在运行,它肯定是能够获取到锁的,所以cas是能够成功的,cas修改的是state由0修改为1,state是控制线程是否能够运行的前置条件,cas成功,设置独占锁为当前线程;
Thread-2:线程2进来就比较悲催了,因为Thread-1获取了锁,那么线程2还是需要尝试去获取锁,也是想把state由0修改为1,但是这个时候cas失败的,因为Thread-1把state已经修改为1了,cas肯定失败,所以失败了就入队,这个时候队列还是空的,所以分两步:
1.创建Node节点对象node,Node属性为:

waitStatus=0
thread=Thread-2
nextWait=null
pre=null

1.创建一个队列的空节点t,这个节点作为队头head和队尾tail,Node属性为

waitStatus=0
thread=null
nextWait=null
pre=null

2.第二次循环将node节点通过cas修改为尾结点,然后node.prev=t,t.next=node;
3.将线程2节点添加的队列中过后,然后进行获取node.prev节点,修改prev的节点的waitStatus由0修改为-1,表示可以被唤醒的线程;
Thread-3:线程3一样的进行lock的时候也要尝试cas,很显然cas也是失败的,和线程2一样去添加到等待队列中,这个时候:
head node

waitStatus=-1
thread=null
nextWait=Thread-1
pre=null

thread-1 node:

waitStatus=-1
thread=Thread
nextWait=thread2 node
pre=head node

thread-2 node(tail):

waitStatus=0
thread=Thread-2
nextWait=null
pre=thread-1 node

最后就完成了这种双向链表的数据结构

unlock过程

通过上面的debug过程过后,我们的3个线程目前是这样的状况:
在这里插入图片描述
Thread-1已经执行完了自己的业务逻辑,准备解锁了
在这里插入图片描述
而线程2和3都是出于这种状况,就是线程被挂起的状态,2和3已经被阻塞挂起了,我们继续debug让Thread-1解锁
在这里插入图片描述
线程1解锁debug进入这里,我先来分析写这个方法的业务逻辑

final boolean release(int arg) {
    //tryRelease解锁的过程
    if (tryRelease(arg)) {
        //解锁成功过后,从对头开始唤醒下一个线程开始执行
        //也就是说每一个线程在结束过后会自动去唤醒下一个线程开始执行
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}
protected final boolean tryRelease(int releases) {
    //线程解锁的过程,需要将state-1
    int c = getState() - releases;
    //如果要解锁的线程和目前独占的线程不一样,则知报错,
    //报的非法monitor状态异常,我们还记得吗?
    //我们直接在非synchronized的代码块中使用wait也会报这个错误
    //所以这里的判断就是你解锁的线程根本不是独占的线程
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        //将独占线程设置为null,下一个获取锁的线程就可以设置当前线程
        setExclusiveOwnerThread(null);
    }
    //将state回写到内存,让所有的线程都可以看到
    setState(c);
    return free;
}
//向被阻塞的线程发一个许可unpark,告诉它可以执行了
//node=head节点,然后唤醒的目标是node.next
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)
    //把node=head的头节点的waitstatus修改为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;
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    if (s != null)
    //唤醒下一个线程
        LockSupport.unpark(s.thread);
}

我们线程1唤醒线程2过后,其实s.thread就是线程2,我们再切到线程2看下线程2在哪里
在这里插入图片描述
可以看到线程2还在原来的哪里阻塞到再,therad-1唤醒它过后,它开始执行了,现在线程2要获取锁是能获取成功了
在这里插入图片描述
线程2执行tryAcquire已经成功了,首先node.prev肯定是等于head的,因为如果从队列中唤醒的线程的节点永远是第二个节点,也就是head.next=node,所以这里会进去
我们着重分析下if的代码块

//下面的代码其实简单来说就是出队,将头节点出队
//设置当前执行的节点为头结点
setHead(node);
//节点往前移动了,原来的节点进行回收,方便gc
p.next = null; // help GC
failed = false;
return interrupted;

private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}

执行完上述代码过后,节点信息如下:
head(del head head = node1):

waitStatus=0
thread=null
prev=null
next=node2

node1(del node1 ):没有了,node1就是当前运行的线程1
node2:

waitStatus=0
thread=thread-3
prev=head
next=null

这就是整个解锁的过程,是配合加锁一起完成的,原理就是每个获得lock的线程在完成过后解锁完成过后,要讲队列中head.next的节点进行出队执行,所以aqs独占锁有个特点
就是头结点head用元是空节点,在构建节点的时候第一次创建的也是空节点,所以head节点我觉得就是一个过渡,为了得到next节点的信息的一个数据结构;
还有就是有两个重要的信息需要注意:
state:线程获取锁过后+1,每个线程需要获取锁的条件是state=0
waitStatus:线程被唤醒的状态,-1才能阻塞被唤醒,但是不包括最后一个线程
因为每次入队更新的时候都是prev更新为-1,那么最后一个节点的线程永远是0;

unlock图解过程

在这里插入图片描述
unlock的解锁过程如上图,简单来说就是执行完成过后,释放锁,然后从队列中取第一个节点然后唤醒它可以出队执行了,然后唤醒的节点开始获取锁,获取锁成功过后出队执行就是这么个过程,过程简单,主要是吸收Doug Lea大神的思想,别人为什么要这么做;这其中涉的两个参数state和WaitStatus和和加锁的一样。

总结

AQS独占锁这个原理,我有时候也不知道如何去写,自己懂和表述是两个概念,因为Doug Lea在实现的时候写的非常绕,首先要理解它的设计理念以及原理,否则代码能力再高的人也看不懂,懂了理念和设计原理再看就很轻松了,所以我就采用debug的模式截图和源码解释来解读AQS底层实现原理,如果谁看到了这篇文章,如果有疑问的大家可以进行交流。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值