Java并发系列源码分析(二)--ReentrantLock

简介

​ReentrantLock是一个可重入的锁,当一个线程持有锁的时候,再去调用加锁的方法则不需要发生锁竞争,因为持有锁的线程就是当前的线程,此时只需要修改线程加锁次数即可,在ReentrantLock中锁的类型分为非公平锁和公平锁,默认创建的锁为非公平锁,非公平锁不管等待队列中是否有线程在等待加锁都会尝试获取锁,而不是先进入等待队列中,公平锁则是会先进入到等待队列中,等待前面的线程加完锁并释放锁之后才会获取锁。

构造方法

/**
 * 公平锁和非公平锁的父类对象同步器
 */
private final Sync sync;
    
/**
 * 创建非公平的锁
 */
public ReentrantLock() {
    sync = new NonfairSync();
}/**
 * 创建锁
 * 根据参数fair来确定创建的锁类型
 * true 创建公平锁
 * false 创建非公平锁
 *
 * @param fair
 */
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

ReentrantLock中有两个构造方法,不带参的方法创建的是非公平锁,而带参的则根据传入的值来确定是创建非公平锁还是公平锁。

非公平锁

NonfairSync

final void lock() {
    //通过CAS操作将state的值0修改成1
    //如果修改成功则说明加锁成功
    if (compareAndSetState(0, 1))
        //加锁成功则将当前线程设置成锁的持有者线程
        setExclusiveOwnerThread(Thread.currentThread());
    else
        //如果加锁失败或者是重入锁则走acquire方法再次获取锁
        //如果是重入锁则对state的值加1
        //如果不是重入锁则再次尝试获取锁
        //如果再次获取锁失败则将当前线程放入等待队列中并挂起
        //等待锁的持有的线程释放锁并唤醒等待队列中的头节点获取锁
        acquire(1);
}

NonfairSync、FairSync、Sync都是ReentrantLock中的内部类,而NonfairSync和FairSync继承了Sync,重写了lock方法,先看NonfairSync非公平锁的加锁方法,该方法一上来就直接通过CAS操作修改锁的状态,如果修改成功则加锁成功,再将当前线程设置为持有锁的线程,如果通过CAS操作修改锁状态失败,则说明加锁失败,加锁失败分为两种情况:1.锁已经被其它线程获取了,2.当前线程可能是多次加锁(重入锁),加锁失败则会走acquire方法执行后续操作。

compareAndSetState

/**
 * 执行CAS操作将state修改成指定的值
 * @param expect 预期state的值
 * @param update 待修改的state值
 * @return
 */
protected final boolean compareAndSetState(int expect, int update) {
    // 根据state的偏移量从当前this对象中获取state的值并校验state的值是否与预期的值相同
    // 如果与预期的值相同则将state的值修改成新的值
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

CAS操作都是通过Unsafe类来进行操作的,该类是一个底层类,能对操作系统执行许多操作,Unsafe的CAS操作则是先通过字段的offset在当前类中获取到该字段的值,再校验获取到的值是否与预期的值相同,如果相同则将值修改成指定的值,如果不相同则执行CAS操作失败。

acquire

/**
 * 尝试获取锁
 * 如果获取失败则将当前线程挂起并放入等待队列中
 * 等待锁的持有者释放锁并唤醒队列中的头节点获取锁
 * @param arg
 */
public final void acquire(int arg) {
    /**
     * tryAcquire 根据调用方自己实现的方法来重试获取锁
     * 在ReentrantLock中的非公平锁中返回false则说明再次尝试获取锁失败
     * 返回true分为两种情况:
     * 1.其它线程释放了锁,当前线程尝试获取锁成功
     * 2.锁已经被线程持有并持有的线程是当前线程则state加1,说明当前线程多次加锁
     *
     * 在ReentrantLock中的公平锁中返回false则说明尝试获取锁失败
     * 返回true分为两种情况:
     * 1.
     *  1.1等待队列中没有在等待获取锁的线程,当前线程直接获取锁成功
     *  1.2等待队列中有在等待获取锁的线程并且等待队列中下一个获取锁的线程就是当前线程
     * 2.锁已经被线程持有并持有的线程是当前线程则state加1,说明当前线程多次加锁
     *
     *
     * 在tryAcquire方法中尝试加锁失败时
     * 则会调用addWaiter方法为当前线程创建一个独占模式的节点并将该节点添加到等待队列中
     * 如果等待队列中为空则先创建一个头节点并将当前线程的节点设置为尾节点
     *
     */
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

acquire方法的先是调用tryAcquirc方法尝试获取锁,获取锁成功则不再执行后面的操作,如果获取锁失败则调用addWaiter方法为当前线程创建一个独占模式的节点并将该节点添加到等待队列中,线程不会一直在等待其它线程释放锁,如果一直等待就会造成资源的浪费,此时就会调用acquireQueued方法将线程挂起,等待队列中当前线程前面的线程释放了锁并将当前线程唤醒了,当前线程就会尝试去获取锁。

tryAcquire&nonfairTryAcquire

/**
 * 再次尝试获取锁
 * @param acquires
 * @return
 */
protected final boolean tryAcquire(int acquires) {
    //尝试再次获取锁
    //如果其它线程已经释放了锁当前线程则尝试去获取锁
    //如果获取失败则返回false
    //如果锁已经被其它线程持有则校验锁的持有者的线程是否是当前线程
    //如果是当前线程则更新state,说明当前线程多次加锁
    return nonfairTryAcquire(acquires);
}/**
 * 尝试再次获取锁
 * 如果其它线程已经释放了锁当前线程则尝试去获取锁
 * 如果获取失败则返回false
 * 如果锁已经被其它线程持有则校验锁的持有者的线程是否是当前线程
 * 如果是当前线程则更新state,说明当前线程多次加锁
 * @param acquires
 * @return
 */
final boolean nonfairTryAcquire(int acquires) {
    //获取当前线程
    final Thread current = Thread.currentThread();
    //获取锁状态 0:未加锁 1:已加锁 >1:当前线程多次加锁
    int c = getState();
    //校验锁是否未被其它线程持有
    if (c == 0) {
        //锁未被其它线程持有则再次通过CAS操作修改state获取锁
        if (compareAndSetState(0, acquires)) {
            //获取锁成功则将当前线程设置为锁的持有者
            setExclusiveOwnerThread(current);
            //获取锁成功则返回
            return true;
        }
    }
    //如果锁已被其它线程持有则校验持有锁的线程是否是当前线程
    else if (current == getExclusiveOwnerThread()) {
        //加锁的是当前线程则重入加锁
        //获取加锁次数
        int nextc = c + acquires;
        //校验加锁次数是否小于0
        if (nextc < 0)
            //如果小于0则说明加锁次数过多已超出int的最大值
            throw new Error("Maximum lock count exceeded");
        //更新锁状态
        setState(nextc);
        //重入锁成功则返回true
        return true;
    }
    //如果再次尝试加锁失败并且已经持有锁的线程不是当前线程则返回false
    return false;
}

主要是看nonfairTryAcquire方法,先获取锁的状态来判断锁是否已经被其它或当前线程持有,如果锁未被持有则可以通过CAS操作进行加锁并将当前线程设置为锁的持有者线程,如果加锁失败则有可能是已经被其它线程获取到了锁,如果锁已经被线程持有,此时先判断这个持有锁的线程是否是当前线程,如果是当前线程则会更新加锁的次数,线程加锁多少次,后面就要释放锁多少次。

addWaiter

/**
 * 为当前线程创建给定模式的节点并入队
 * @param mode Node.EXCLUSIVE 独占模式, Node.SHARED 共享模式
 * @return the new node
 */
private Node addWaiter(Node mode) {
    //使用当前线程与指定的模式创建一个新的节点
    Node node = new Node(Thread.currentThread(), mode);
    //获取尾节点
    Node pred = tail;
    if (pred != null) {
        //如果尾节点不为空则将新创建的节点的上一个节点的指针指向尾节点
        node.prev = pred;
        //通过CAS操作将新创建的节点设置为尾节点
        if (compareAndSetTail(pred, node)) {
            //新节点设置为了尾节点则将原尾节点的下一个节点的指针指向新尾节点
            pred.next = node;
            //返回新创建的节点
            return node;
        }
    }
    //等待队列中没有节点
    //则通过enq方法创建头节点并将当前线程所在的节点设置为尾节点
    enq(node);
    return node;
}/**
 * 等待队列中没有节点则先创建头节点
 * 再将当前线程所在节点设置为尾节点
 * 并将头节点和尾节点进行指针关联
 * @param node
 * @return
 */
private Node enq(final Node node) {
    for (;;) {
        //获取尾节点
        Node t = tail;
        if (t == null) {
            //通过CAS操作修改头节点
            if (compareAndSetHead(new Node()))
                //将尾节点的指针指向头节点
                tail = head;
        } else {
            //将当前线程的节点的上一个节点的指针指向尾节点
            node.prev = t;
            //通过CAS操作将当前线程的节点设置为尾节点
            if (compareAndSetTail(t, node)) {
                //将头节点的下一个节点的指针指向当前线程的节点
                t.next = node;
                return t;
            }
        }
    }
}

在调用tryAquire方法获取锁失败之后就会调用addWaiter方法为当前线程创建给定模式的节点并添加到队列中,模式分为独占模式和共享模式,从字面上就可以理解这两种模式的区别,独占模式就是独占锁而共享模式就是共享锁,如果等待队列中有节点则将当前线程的节点设置为尾节点,如果等待队列中没有节点则会调用enq方法初始化队列,先创建头节点并将头节点和尾节点的指针指向头节点,再将当前线程所在的节点设置为尾节点并将节点的prev指针指向头节点,头节点的next指针指向当前线程所在的节点。

acquireQueued

/**
 * 将线程挂起并等待其它线程释放锁的时候唤醒
 * 并再次尝试获取锁,如果获取锁失败则会继挂起线程
 * @param node 当前线程的节点
 * @param arg the acquire argument
 */
final boolean acquireQueued(final Node node, int arg) {
    //是否出现故障
    boolean failed = true;
    try {
        //默认当前线程未被打断
        boolean interrupted = false;
        for (;;) {
            //获取当前线程的节点的上一个节点
            final Node p = node.predecessor();
            //校验当前线程的节点的上一个节点是否是头节点
            //如果是头节点则说明当前线程在等待队列中是最靠前的一个线程节点
            //则调用tryAcquire方法再次尝试获取锁
            if (p == head && tryAcquire(arg)) {
                //尝试获取锁成功将node设置为头节点
                //并使原头节点出队并将node中的线程置为null
                setHead(node);
                //与原头节点取消关联
                p.next = null;
                failed = false;
                return interrupted;
            }
            //当前线程所在的节点不是头节点
            //或当前所在的节点是头节点但是尝试加锁失败,被其它线程获取到了锁
            //为什么是头节点的时候还是会加锁失败被其它线程获取到锁?
            //是因为在非公平锁中不是说在头节点中的线程是百分百加锁成功的
            //当头节点准备去获取锁的时候,此时来了一个新线程,该线程不会进入等待队列中而是直接获取锁
            //获取锁失败的时候才会进入等待队列中,如果这个新的线程直接获取锁就可能被这个线程拿到锁
            //头节点的线程就会获取锁失败
            //shouldParkAfterFailedAcquire方法如果返回true则说明当前线程后续需要解除挂起状态
            //此时则会调用parkAndCheckInterrupt方法将当前线程挂起,等待后续解除挂起状态
            //shouldParkAfterFailedAcquire方法如果返回false则说明将上一个节点的等待状态设置为了-1
            //或者说上一个节点的线程已被取消加锁并已从等待队列中删除该节点
            if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
                //线程已被中断
                interrupted = true;
        }
    } finally {
        if (failed)
            //只有出现异常的情况下才会执行该方法
            //当出现异常则会将异常的节点取消加锁
            cancelAcquire(node);
    }
}

当线程所在的节点入队之后,会先校验该节点的上一个节点是否是头节点,如果是则会尝试获取锁,如果获取锁成功则将当前线程所在的节点设置为头节点并将原头节点与队列中的节点取消关联并将当前节点中的线程置为空,如果获取锁失败则调用shouldParkAfterFailedAcquire方法修改节点的状态,再调用parkAndCheckInterrupt方法将线程挂起。

shouldParkAfterFailedAcquire&parkAndCheckInterrupt

/**
 * 如果当前线程所在的节点的上一个节点的等待状态为0
 * 则需要将上一个节点的等待状态设置为-1
 * 设置为-1说明当前线程需要在后续解除线程挂起状态
 * 如果上一个节点的状态大于0,从当前类中的常量来看只有1是大于0的
 * 如果大于0则说明该节点已被取消加锁并从等待队列中将节点删除
 * @param pred 当前节点的上一个节点
 * @param node 当前节点
 */
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    //获取上一个节点的等待状态 默认为0
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        //上一个节点的等待状态为-1则说明当前线程后续需要解除挂起
        return true;
    if (ws > 0) {
        //上一个节点的等待状态大于0则说明上一个节点线程已被取消加锁
        //则需要从等待队列中将节点删除
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        //上一个节点的等待状态为0则需要通过CAS操作将上一个节点的等待状态更新为-1
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}/**
 * 挂起当前线程并返回当前线程是否被中断
 */
private final boolean parkAndCheckInterrupt() {
    //挂起当前线程
    LockSupport.park(this);
    //是否被中断
    return Thread.interrupted();
}

在队列中添加了一个新的等待节点的时候,之后就会调用shouldParkAfterFailedAcquire方法将前一个节点的状态修改为SIGNAL,前一个节点的状态为SIGNAL时则说明后续还有节点并且节点需要在前一个节点释放锁之后唤醒并尝试去获取锁,如果上一个节点的等待状态大于0则说明节点中的线程已被取消加锁则需要将节点从等待队列中删除,parkAndCheckInterrupt方法中挂起线程的方法最终调用的是Unsafe。

cancelAcquire

/**
 * 将出现异常的节点取消获取锁
 * 如果出现异常的节点的前一个节点是头节点
 * 则唤醒异常节点的后续的一个节点
 * 唤醒的后续节点的等待状态必须是SIGNAL
 * 如果后续的节点不是SIGNAL则继续获取后续节点
 * 直到后续节点为SIGNAL
 */
private void cancelAcquire(Node node) {
    if (node == null)
        return;
    //将节点中的线程置空
    node.thread = null;
    //获取上一个节点
    Node pred = node.prev;
    //如果节点中的等待状态大于0则说明已经被取消加锁
    //则获取已被取消加锁的节点的上一个节点
    //直到上一个节点未被取消加锁
    while (pred.waitStatus > 0)
        node.prev = pred = pred.prev;
    //上一个节点的下一个节点
    //如果等待状态大于0该节点则是与node节点相关联并连续被取消加锁的节点的最靠前的一个节点
    //如果等待状态不大于0该节点则是当前node节点
    Node predNext = pred.next;
    //将当前节点的等待状态设置为取消加锁状态
    node.waitStatus = Node.CANCELLED;
    //如果当前节点是尾节点则通过CAS操作将尾节点的上一个节点设置为尾节点
    if (node == tail && compareAndSetTail(node, pred)) {
        //将新的尾节点与原尾节点取消关联
        compareAndSetNext(pred, predNext, null);
    } else {
        //当前节点不是尾节点或通过CAS操作将节点设置成尾节点失败
        //上一个节点的等待状态
        int ws;
        //校验pred节点是否是头节点并且pred节点的等待状态为-1(pred状态小于0并且CAS操作成功修改等待状态为-1)
        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)
                /**        1                    2                     3                        4
                 *      +------+     prev    +-----+      prev    +--------+      prev      +-----+
                 * head |      |    <----   | pred |     <----   |  node   |     <----     | next |  tail
                 *      |SIGNAL|    ---->   |SIGNAL|             |CANCELLED|               |SIGNAL|
                 *      +------+    next    +-----+              +--------+                +-----+ <---
                 *                             |                                                      |
                 *                             |                                                      |
                 *                             ——————————————————————next——————————————————————-——————
                 */
                //下一个节点不为null并且等待状态小于等于0
                //则通过CAS操作将当前节点的上一个节点的next指针指向当前节点的下一个节点
                //从上面的图可以看出如果当前节点3为取消加锁状态则会将节点2的next指针指向节点4
                //在节点2获取到锁并释放锁的时候则会唤醒节点4
                //在acquireQueued方法中节点4要获取锁的时候只有节点4的上一个节点为头节点才能获取锁
                //此时节点4的上一个节点3并不是头节点
                //此时会获取锁失败并调用shouldParkAfterFailedAcquire方法校验上一个节点的等待状态
                //此时会发现节点3的等待状态为1则会走到do while中将节点4的prev指针指向节点
                //此时会继续校验节点4的上一个节点是否是头节点,此时节点2则是头节点
                //节点4则会获取锁成功,则会将节点4设置为头节点并取消与前面节点的关联
                compareAndSetNext(pred, predNext, next);
        } else {
            //pred节点为头节点或等待状态不为-1
            unparkSuccessor(node);
        }
        //将当前节点的next指针指向自己
        node.next = node;
    }
}

只有当acquireQueued方法出现异常的时候当前方法才会执行,将出现异常的节点取消加锁,如果出现异常的前一个节点是头节点则唤醒异常节点的后续的一个节点,被唤醒的节点的等待状态必须是SIGNAL,如果后一个节点的等待状态不是SIGNAL则继续获取后续节点直到后续节点为SIGNAL。

公平锁

lock

final void lock() {
    //尝试获取锁
    //如果获取锁失败则将线程挂起并放入等待队列中
    //等待其它线程释放锁并唤醒该线程
    //公平锁加锁与非公平锁加锁主要的区别就是非公平锁在调用lock方法的时候会直接尝试获取锁
    //不管等待队列中是否有线程在等待
    //公平锁不会一上来直接获取锁而是会先查看等待队列中是否有线程在等待获取锁
    //如果有并且不是当前线程则会将自己入队列进行等待
    //直到前面的线程都获取完锁之后才会获取锁
    acquire(1);
}

公平锁不像非公平锁一样直接上来就获取锁,公平锁只有在等待队列中没有线程节点在等待获取锁的时候才会去获取锁,如果等待队列中有线程节点在等待获取锁,则会为当前线程创建节点并入队,等待前面节点都获取完锁并且释放锁之后才能获取锁。

tryAcquire

/**
 * 尝试获取锁
 */
protected final boolean tryAcquire(int acquires) {
    //获取当前线程
    final Thread current = Thread.currentThread();
    //获取锁状态
    int c = getState();
    if (c == 0) {
        //如果锁状态为0则锁是空闲状态
        //如果锁是空闲状态则调用hasQueuedPredecessors方法查看等待队列中是否有在等待获取锁的线程
        //如果没有在等待获取锁的线程则当前线程调用compareAndSetState方法获取锁
        //如果有在等待获取锁的线程则会比较一下下一个获取锁的线程是否是当前线程
        //如果是当前线程则会调用compareAndSetState方法获取锁
        //如果不是当前线程则不会去获取锁
        if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
            //将锁的持有者线程设置为当前线程
            setExclusiveOwnerThread(current);
            //加锁成功
            return true;
        }
        //锁状态不为0则校验锁的持有者线程是否是当前线程
    } else if (current == getExclusiveOwnerThread()) {
        //如果锁的持有者线程是当前线程则加锁的次数加1
        int nextc = c + acquires;
        if (nextc < 0)
            //校验加锁的次数是否超出int的最大值
            //如果超出最大值则抛出异常
            throw new Error("Maximum lock count exceeded");
        //更新锁的状态
        setState(nextc);
        //加锁成功
        return true;
    }
    //加锁失败
    return false;
}

公平锁和非公平锁都重写了tryAcquire方法,而公平锁和非公平锁的区别就是tryAcquire方法和lock方法,非公平锁在lock方法中多了一步获取锁的操作,公平锁的tryAcquire方法中如果锁是空闲状态并不会直接去获取锁,而是先校验一下等待队列中是否有线程节点在等待获取锁,如果没有才会去获取锁,如果有则不会去获取锁,而后续的操作都与非公平锁大致相同,只有后续调用的tryAcquire方法是调用的公平锁中的tryAcquire方法而不是非公平锁中的tryAcquire方法。

锁释放

unlock

/**
 * 释放锁
 * 当前线程是锁的持有者线程才能释放锁
 */
public void unlock() {
    sync.release(1);
}/**
 * 尝试释放锁
 * 如果释放锁成功并且锁的状态是空闲的
 * 则会唤醒等待队列中当前节点的后续节点
 * 如果后续节点的等待状态为1则会继续获取后续节点
 * 直到后续节点为-1才进行唤醒
 */
public final boolean release(int arg) {
    /**
     * tryRelease 尝试释放锁,由调用方实现具体的逻辑
     * 只有锁的持有者线程是当前线程才能释放锁
     */
    if (tryRelease(arg)) {
        //获取头节点
        Node h = head;
        //校验头节点是否为空并且头节点的等待状态是否不等于0
        if (h != null && h.waitStatus != 0)
            //如果头节点不为空并且头节点的等待状态不等于0则说明后续节点需要加锁
            //调用unparkSuccessor方法唤醒下一个节点
            //如果下一个节点中的等待状态为-1才进行唤醒
            //如果等待状态为1则不会进行唤醒
            unparkSuccessor(h);
        return true;
    }
    return false;
}/**
 * 尝试释放锁
 * 只有持有锁的线程是当前线程才能锁放锁
 * 如果一个线程多次加锁则只会释放一次锁
 * @param releases
 * @return
 */
protected final boolean tryRelease(int releases) {
    //通过getState获取加锁次数
    //使用加锁次数减去1,得到线程剩余加锁次数
    int c = getState() - releases;
    //校验加锁的线程是否是当前线程
    if (Thread.currentThread() != getExclusiveOwnerThread())
        //不是当前线程则抛出异常
        throw new IllegalMonitorStateException();
    //锁是否是空闲的
    boolean free = false;
    if (c == 0) {
        //如果剩余加锁次数为0则说明锁已经被完成释放
        //锁是空闲的
        free = true;
        //将锁的持有者线程置为空
        setExclusiveOwnerThread(null);
    }
    //更新锁的状态
    setState(c);
    return free;
}/**
 * 唤醒节点的后续节点
 * 前提是后续节点存在
 * 如果后续节点已被取消加锁则继续获取后续节点直到节点未被取消加锁
 */
private void unparkSuccessor(Node node) {
    //获取当前节点的等待状态
    int ws = node.waitStatus;
    if (ws < 0)
        //将当前节点的等待状态修改为0
        compareAndSetWaitStatus(node, ws, 0);//获取下一个节点
    Node s = node.next;
    //校验下一个节点是否为空或者等待状态为1
    //如果等待状态为1则说明线程已取消加锁
    //则从尾节点向当前节点所在的位置开始遍历
    //获取当前节点的后续节点并且该节点的未被取消加锁
    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);
}

公平锁和非公平锁释放锁都是相同的,释放锁只能是持有锁的线程才能释放,如果一个线程多次加锁则释放锁的时候只会释放一次锁而不会将所有的都释放,当一个线程的所有锁释放完之后如果后续还有线程节点在等待获取锁则会将节点中的线程唤醒,如果后续线程节点的等待状态大于0则说明后续线程节点已取消加锁则需要将线程节点从等待队列中删除。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值