【JUC并发编程系列】深入理解Java并发机制:深度解析Java并发控制工具(十、Semaphore、 CyclicBarrier、CountDownLatch)


【JUC并发编程系列】深入理解Java并发机制:深度解析Java并发控制工具(十、Semaphore、 CyclicBarrier、CountDownLatch)

1. Semaphore

1.1 概念

Semaphore也是一个线程同步的辅助类,可以维护当前访问自身的线程个数,并提供了同步机制。使用Semaphore可以控制同时访问资源的线程个数,例如,实现一个文件允许的并发访问数。

Semaphore的主要方法摘要:

  1. void acquire():从此信号量获取一个许可,在提供一个许可前一直将线程阻塞,否则线程被中断。

  2. void release():释放一个许可,将其返回给信号量。

  3. int availablePermits():返回此信号量中当前可用的许可数。

  4. boolean hasQueuedThreads():查询是否有线程正在等待获取。

public static void main(String[] args) {
    Semaphore semaphore = new Semaphore(3);
    for (int i = 0; i < 10; i++) {
        int finalI = i;
        new Thread(() -> {
            try {
                // 请求获取票据
                semaphore.acquire();
                System.out.println(Thread.currentThread().getName() + ",拿到了票据。");
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                //释放票据
                semaphore.release();
            }

        }).start();
    }
}

1.2 原理

Semaphore 基于 AQS 实现的原理

AQS 回顾

  1. AQS 中的状态值: AQS 使用一个整型成员变量 state 来表示同步状态。
  2. 双向链表结构: 当线程尝试获取锁失败时,会被插入到 AQS 维护的双向链表中等待。
  3. CAS 结合自旋: 所有的修改操作都是通过 CAS (Compare and Swap) 操作来实现,并且可能会伴随着自旋以保证线程安全。

Semaphore 的实现细节

当创建一个 Semaphore 对象时,例如 Semaphore semaphore = new Semaphore(3);,它实际上创建了一个基于 AQS 的非公平锁实现,初始状态值设为 3,这代表了可用的许可数量。

获取许可

  1. 尝试获取许可:

    if (tryAcquireShared(arg) < 0) {
        doAcquireSharedInterruptibly(arg);
    }
    

    这里 arg 表示请求的许可数量,默认情况下是 1。

  2. 非公平获取共享许可:

    final int nonfairTryAcquireShared(int acquires) {
        for (;;) {
            int available = getState();
            int remaining = available - acquires;
            if (remaining < 0 || compareAndSetState(available, remaining)) {
                return remaining;
            }
        }
    }
    

    这个方法会检查当前状态是否足够提供所需的许可数量。如果不够,则会尝试通过 CAS 更新状态。

  3. 更新状态:
    如果剩余许可不足或成功更新状态,则返回新的剩余许可数。如果剩余许可数变为负数,则说明资源已耗尽,此时线程将被添加到等待队列中并进入阻塞状态。

释放许可

protected final boolean tryReleaseShared(int releases) {
    for (;;) {
        int current = getState();
        int next = current + releases;
        if (next < current) {
            throw new Error("Maximum permit count exceeded");
        }
        if (compareAndSetState(current, next)) {
            return true;
        }
    }
}

这段代码用于释放许可。它首先读取当前状态,然后加上释放的许可数量,再次使用 CAS 尝试更新状态。

示例流程

  1. 创建一个 Semaphore 实例,初始化状态值为 3。
  2. 当调用 acquire() 方法时,尝试减少状态值 1。如果状态值减小后仍然大于等于 0,则获取许可成功;否则,线程将被加入到等待队列中,并进入阻塞状态。
  3. 当调用 release() 方法时,状态值增加 1,如果之前有线程因为获取许可失败而阻塞,则可能会唤醒其中一个线程继续执行。

总结来说,Semaphore 通过 AQS 提供了一种基于许可的并发控制机制。线程可以尝试获取许可来执行某个操作,如果许可不足,则该线程会被挂起直到有足够的许可可用。这种方式非常适合用来实现限流等场景。

2. CyclicBarrier

2.1 概念

CyclicBarrier 是 Java 并行包 (java.util.concurrent) 中的一个类,它为一组线程提供了一种同步机制。CyclicBarrier 可以想象成一个所有参与线程都需要等待的栅栏或障碍。当所有参与线程到达这个障碍时,它们都会被阻塞,直到最后一个线程到达,这时所有线程会被释放并继续执行。

主要用途:

  • 同步多个线程:确保一组线程在某个点上全部完成自己的工作后才一起继续。
  • 循环使用CyclicBarrier 之所以被称为“循环”障碍是因为它可以被重用。一旦所有线程都通过了障碍,这个障碍就被重新设置好了,可以再次使用。

基本使用方法:

  1. 构造函数

    • CyclicBarrier(int parties):创建一个 CyclicBarrier,参数 parties 指定了需要等待的线程数量。
    • CyclicBarrier(int parties, Runnable barrierAction):创建一个 CyclicBarrier,参数 barrierAction 是一个在所有线程到达屏障后执行的操作。
  2. 主要方法

    • await():让线程等待其他线程到达障碍。
    • await(long timeout, TimeUnit unit):让线程等待其他线程到达障碍,但等待有超时限制。
  3. 异常处理

    • 如果某个线程在等待过程中抛出了异常或者超时,则其他线程会收到 BrokenBarrierException

示例代码:

CyclicBarrier barrier = new CyclicBarrier(3, new Runnable() {
    public void run() {
        System.out.println("所有线程已完成,开始执行后续操作");
    }
});

Thread t1 = new Thread(() -> {
    try {
        System.out.println("线程1开始工作...");
        Thread.sleep(1000);
        System.out.println("线程1到达障碍");
        barrier.await(); // 等待其他线程
    } catch (InterruptedException | BrokenBarrierException e) {
        e.printStackTrace();
    }
});

Thread t2 = new Thread(() -> {
    try {
        System.out.println("线程2开始工作...");
        Thread.sleep(2000);
        System.out.println("线程2到达障碍");
        barrier.await();
    } catch (InterruptedException | BrokenBarrierException e) {
        e.printStackTrace();
    }
});

Thread t3 = new Thread(() -> {
    try {
        System.out.println("线程3开始工作...");
        Thread.sleep(500);
        System.out.println("线程3到达障碍");
        barrier.await();
    } catch (InterruptedException | BrokenBarrierException e) {
        e.printStackTrace();
    }
});

t1.start();
t2.start();
t3.start();

在这个示例中,三个线程都将执行一些任务,并且在完成这些任务后调用 barrier.await()。当所有三个线程都到达 await() 方法时,它们将被释放,并且在构造 CyclicBarrier 时提供的 Runnable 将被执行。

2.2 原理

CyclicBarrier基于 AQS 实现的原理

/**
 * 让当前线程等待直到所有参与线程都到达了屏障。
 *
 * @return 返回一个整数,表示到达屏障的线程数量减去当前线程。
 * @throws InterruptedException 如果当前线程被中断。
 * @throws BrokenBarrierException 如果屏障被打破(即有一个线程由于超时或中断而提前退出了等待)。
 */
public int await() throws InterruptedException, BrokenBarrierException {
    try {
        // 调用 dowait 方法进行等待,参数 false 表示无限期等待,
        // 第二个参数 0L 是超时时间的占位符,在这里不适用,因为已经指定了 false 表示无限期等待。
        return dowait(false, 0L);
    } catch (TimeoutException toe) {
        // TimeoutException 在这里是不会发生的,因为 await() 方法没有指定超时时间。
        // 因此,这里的 catch 子句实际上是为了编译器的要求而存在的,但它永远不会被触发。
        // throw new Error(toe); 这一行代码只是形式上的,理论上不应该达到这一行。
        throw new Error(toe); // cannot happen
    }
}
private int dowait(boolean timed, long nanos)
        throws InterruptedException, BrokenBarrierException,
               TimeoutException {
    final ReentrantLock lock = this.lock;  // 获取锁对象
    lock.lock();  // 加锁,确保线程安全
    try {
        final Generation g = generation;  // 获取当前的 Generation 对象

        if (g.broken)  // 如果屏障已经被打破
            throw new BrokenBarrierException();  // 抛出 BrokenBarrierException

        if (Thread.interrupted()) {  // 检查线程是否被中断
            breakBarrier();  // 打破屏障
            throw new InterruptedException();  // 抛出 InterruptedException
        }

        int index = --count;  // 减少计数器 count 的值,index 表示当前线程的索引位置
        if (index == 0) {  // 如果这是最后一个到达屏障的线程
            boolean ranAction = false;
            try {
                final Runnable command = barrierCommand;  // 获取在所有线程到达后要执行的操作
                if (command != null)
                    command.run();  // 执行该操作
                ranAction = true;
                nextGeneration();  // 初始化下一个 Generation
                return 0;  // 返回 0 表明当前线程是最后一个到达屏障的线程
            } finally {
                if (!ranAction)
                    breakBarrier();  // 如果操作没有执行成功,打破屏障
            }
        }

        // 循环等待,直到所有条件满足其中之一:屏障被触发、被打破、线程被中断或超时
        for (;;) {
            try {
                if (!timed)  // 如果没有设置超时时间
                    trip.await();  // 等待所有线程到达
                else if (nanos > 0L)  // 如果设置了超时时间并且还有剩余时间
                    nanos = trip.awaitNanos(nanos);  // 等待剩余时间
            } catch (InterruptedException ie) {
                if (g == generation && ! g.broken) {  // 如果屏障没有被打破
                    breakBarrier();  // 打破屏障
                    throw ie;  // 抛出 InterruptedException
                } else {
                    // 如果屏障已经被打破或者当前线程即将被释放,记录中断状态
                    Thread.currentThread().interrupt();
                }
            }

            if (g.broken)  // 如果屏障已经被打破
                throw new BrokenBarrierException();  // 抛出 BrokenBarrierException

            if (g != generation)  // 如果已经进入下一个 Generation
                return index;  // 返回当前线程的索引位置

            if (timed && nanos <= 0L) {  // 如果设置了超时时间并且超时
                breakBarrier();  // 打破屏障
                throw new TimeoutException();  // 抛出 TimeoutException
            }
        }
    } finally {
        lock.unlock();  // 释放锁
    }
}

通知所有等待的线程,使它们从等待状态中醒来

private void breakBarrier() {
    generation.broken = true;  // 标记当前 Generation 为已打破状态
    count = parties;  // 重置计数器 count 为初始值(即参与线程的数量)
    trip.signalAll();  // 通知所有等待的线程,使它们从等待状态中醒来
}

3. CountDownLatch

3.1 概念

CountDownLatch 是 Java 并发库 (java.util.concurrent) 中的一个类,它提供了一个简单的计数器机制,用于协调多个线程的执行顺序。CountDownLatch 允许一个或多个线程等待其他线程完成某些操作。

主要用途:

  • 等待一组操作完成:允许一个或多个线程等待一组操作完成。
  • 控制线程启动时机:可以用来控制一个线程或一组线程何时开始执行。

基本概念:

  • 计数器CountDownLatch 有一个内部计数器,表示需要等待的事件数量。
  • 等待:线程可以通过调用 await() 方法来等待计数器减至零。
  • 倒计数:其他线程通过调用 countDown() 方法来减少计数器的值。

常用方法:

  1. 构造函数

    • CountDownLatch(int count):创建一个具有给定计数器初始值的 CountDownLatch
  2. 主要方法

    • await():阻塞当前线程,直到计数器减为零或当前线程被中断。
    • await(long timeout, TimeUnit unit):阻塞当前线程,直到计数器减为零或超过了指定的等待时间或当前线程被中断。
    • countDown():将计数器减一。
  3. 辅助方法

    • getCount():返回当前计数器的值。

示例代码:

import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {

    public static void main(String[] args) throws InterruptedException {
        final CountDownLatch latch = new CountDownLatch(3);

        Thread thread1 = new Thread(() -> {
            System.out.println("线程1开始工作...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("线程1完成工作");
            latch.countDown(); // 完成后减少计数器
        });

        Thread thread2 = new Thread(() -> {
            System.out.println("线程2开始工作...");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("线程2完成工作");
            latch.countDown(); // 完成后减少计数器
        });

        Thread thread3 = new Thread(() -> {
            System.out.println("线程3开始工作...");
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("线程3完成工作");
            latch.countDown(); // 完成后减少计数器
        });

        thread1.start();
        thread2.start();
        thread3.start();

        System.out.println("主线程等待所有子线程完成...");
        latch.await(); // 阻塞主线程,直到计数器减为零
        System.out.println("所有子线程已完成,主线程继续执行...");
    }
}

在这个示例中,三个子线程(thread1, thread2, thread3)分别执行一些任务,并在完成后调用 countDown() 方法来减少计数器的值。主线程则调用 latch.await() 来等待所有子线程完成它们的任务。当计数器减为零时,主线程将继续执行。

总结

  • CountDownLatch 提供了一种简单有效的方式,用于等待一组操作完成。
  • 它适用于那些不需要重复使用的场景,因为计数器只能从构造函数初始化时的值递减到零,不能重置。
  • 通常用于控制程序中特定点的线程执行顺序,例如等待数据加载完成或等待所有子任务完成后再继续执行。

3.2 原理

CountDownLatch基于 AQS 实现的原理

await()

/**
 * 阻塞当前线程,直到计数器减为零或当前线程被中断。
 * 如果计数器已经为零,则立即返回。
 * 如果当前线程被中断,则抛出 InterruptedException。
 */
public void await() throws InterruptedException {
    // 调用 sync 对象的 acquireSharedInterruptibly 方法来阻塞当前线程,
    // 直到计数器减为零或当前线程被中断。
    // 参数 1 表示尝试获取的共享资源的数量,对于 CountDownLatch 来说总是为 1。
    sync.acquireSharedInterruptibly(1);
}
/**
 * 尝试获取共享资源,如果当前线程被中断,则抛出 InterruptedException。
 * 如果计数器不为零,则阻塞当前线程,直到计数器减为零或当前线程被中断。
 *
 * @param arg 尝试获取的共享资源的数量,对于 CountDownLatch 来说总是为 1。
 * @throws InterruptedException 如果当前线程被中断。
 */
public final void acquireSharedInterruptibly(int arg)
        throws InterruptedException {
    // 如果当前线程被中断,则抛出 InterruptedException。
    if (Thread.interrupted())
        throw new InterruptedException();

    // 尝试获取共享资源。
    // 如果计数器不为零,则返回值小于零。
    // 如果计数器为零,则返回非负值。
    if (tryAcquireShared(arg) < 0) {
        // 如果计数器不为零,则调用 doAcquireSharedInterruptibly 方法阻塞当前线程,
        // 直到计数器减为零或当前线程被中断。
        doAcquireSharedInterruptibly(arg);
    }
}
/**
 * 尝试获取共享资源。
 * 如果计数器为零,则返回 1 表示可以获取资源;
 * 否则返回 -1 表示当前线程应被阻塞等待。
 *
 * @param acquires 尝试获取的共享资源的数量,对于 CountDownLatch 来说总是为 1。
 * @return 如果计数器为零,则返回 1;否则返回 -1。
 */
protected int tryAcquireShared(int acquires) {
    // 获取当前计数器的值
    return (getState() == 0) ? 1 : -1;
}



/**
 * 在可能被中断的情况下尝试获取共享资源。
 * 如果计数器为零,则设置当前线程为头节点并传播唤醒信号;
 * 否则阻塞当前线程,直到计数器减为零或当前线程被中断。
 *
 * @param arg 尝试获取的共享资源的数量,对于 CountDownLatch 来说总是为 1。
 * @throws InterruptedException 如果当前线程被中断。
 */
private void doAcquireSharedInterruptibly(int arg)
        throws InterruptedException {
    final Node node = addWaiter(Node.SHARED);  // 添加当前线程到等待队列
    boolean failed = true;  // 标记线程是否成功获取资源
    try {
        for (;;) {  // 循环尝试获取资源
            final Node p = node.predecessor();  // 获取当前节点的前驱节点
            if (p == head) {  // 如果前驱节点是队列的头节点
                int r = tryAcquireShared(arg);  // 尝试获取共享资源
                if (r >= 0) {  // 如果计数器为零或非负数
                    setHeadAndPropagate(node, r);  // 设置当前节点为头节点并传播唤醒信号
                    p.next = null;  // 断开前驱节点的后继指针,帮助垃圾回收
                    failed = false;  // 标记线程成功获取资源
                    return;  // 退出方法
                }
            }
            if (shouldParkAfterFailedAcquire(p, node) &&  // 如果前驱节点的状态表明应该阻塞当前线程
                parkAndCheckInterrupt())  // 阻塞当前线程并检查是否被中断
                throw new InterruptedException();  // 如果线程被中断,则抛出 InterruptedException
        }
    } finally {
        if (failed)  // 如果线程未能成功获取资源
            cancelAcquire(node);  // 取消当前线程的获取操作
    }
}
/**
 * 阻塞当前线程并检查是否被中断。
 * 使用 LockSupport.park 方法阻塞当前线程。
 * 返回当前线程是否被标记为已中断。
 *
 * @return 如果当前线程被中断,则返回 true;否则返回 false。
 */
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);  // 阻塞当前线程
    return Thread.interrupted();  // 检查并清除中断标志位
}

countDown()

/**
 * 将计数器减一。
 * 如果计数器为零,则释放所有等待的线程。
 */
public void countDown() {
    // 调用 sync 对象的 releaseShared 方法来减少计数器的值。
    // 这里的 sync 是 AbstractQueuedSynchronizer 的一个实例,它是 CountDownLatch 的内部类。
    // releaseShared 方法用于减少计数器,并在计数器变为零时释放等待的线程。
    sync.releaseShared(1);
}
/**
 * 尝试减少共享资源的计数器。
 * 如果计数器变为零,则释放所有等待的线程。
 *
 * @param arg 减少的计数值,通常为 1。
 * @return 如果成功减少了计数器并且释放了等待的线程,则返回 true;否则返回 false。
 */
protected final boolean releaseShared(int arg) {
    // 尝试减少计数器
    if (tryReleaseShared(arg)) {
        // 如果计数器变为零,则释放所有等待的线程
        doReleaseShared();
        return true;
    }
    return false;
}
/**
 * 尝试减少共享资源(计数器)的值。
 * 如果计数器变为零,则返回 true;否则返回 false。
 *
 * @param releases 减少的计数值,通常为 1。
 * @return 如果计数器变为零,则返回 true;否则返回 false。
 */
protected boolean tryReleaseShared(int releases) {
    // Decrement count; signal when transition to zero
    // 减少计数器的值;当计数器变为零时发出信号
    for (;;) {
        int c = getState();  // 获取当前计数器的值
        if (c == 0)  // 如果计数器已经是零
            return false;  // 无需再减少计数器,直接返回 false

        int nextc = c - 1;  // 减少计数器的值
        if (compareAndSetState(c, nextc)) {  // 尝试原子性地更新计数器的值
            return nextc == 0;  // 如果计数器变为零,则返回 true;否则返回 false
        }
    }
}



/**
 * 释放所有等待的线程。
 * 确保在存在其他正在进行的获取或释放操作的情况下,释放操作也能传播。
 * 这个方法试图唤醒头部节点的后继节点,如果头部节点需要信号。
 * 如果头部节点不需要信号,则将其状态设置为 PROPAGATE,以确保在释放时传播继续。
 * 此外,必须循环处理,以防在执行此操作时有新节点加入队列。
 * 与其它使用 unparkSuccessor 的情况不同,这里需要知道 CAS 更新状态是否失败,
 * 如果失败,则需要重新检查。
 */
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))  // 尝试将头部节点的状态从 SIGNAL 设置为 0
                    continue;            // 如果 CAS 失败,则继续循环重新检查
                unparkSuccessor(h);  // 唤醒头部节点的后继节点
            }
            else if (ws == 0 &&  // 如果头部节点的状态为 0
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))  // 尝试将头部节点的状态从 0 设置为 PROPAGATE
                continue;                // 如果 CAS 失败,则继续循环重新检查
        }
        if (h == head)                   // 如果头部节点没有改变
            break;  // 结束循环
    }
}
/**
 * 唤醒给定节点的后继节点。
 * 如果节点的状态为负数(即可能需要信号),尝试清除状态以准备发送信号。
 * 如果后继节点被取消或看起来为空,则从尾部向头部遍历找到实际未取消的后继节点。
 *
 * @param node 给定的节点。
 */
private void unparkSuccessor(Node node) {
    // 如果节点的状态为负数(可能需要信号),尝试清除状态以准备发送信号。
    // 即使清除状态失败或状态被等待线程更改也没有关系。
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    // 要唤醒的线程通常位于给定节点的后继节点中。
    // 但如果后继节点被取消或看起来为空,则从尾部向头部遍历找到实际未取消的后继节点。
    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);  // 唤醒该线程
}

3.3 CountDownLatch与Join的区别

CountDownLatchThread.join() 都是用来控制线程之间的执行顺序的工具,但它们在使用场景和功能上有所不同。下面是一些关键区别:

CountDownLatch

  • 用途

    • CountDownLatch 主要用于等待一组操作完成。
    • 它允许一个或多个线程等待其他线程完成一组操作。
  • 计数器

    • CountDownLatch 有一个可配置的内部计数器,表示需要等待的事件数量。
    • 计数器的值只能从构造函数初始化时的值递减到零,不能重置。
  • 灵活性

    • 更灵活,可以在多个线程之间使用。
    • 可以等待任意数量的操作完成。
  • API

    • 提供了 await()countDown() 方法。
    • await() 方法阻塞当前线程,直到计数器减为零或当前线程被中断。
    • countDown() 方法用于减少计数器的值。
  • 示例

    • 一个典型的使用场景是在启动一组工作线程之后,主线程等待所有工作线程完成它们的任务。
    • 例如,启动多个线程进行文件下载,主线程等待所有下载完成。

Thread.join()

  • 用途

    • Thread.join() 用于等待单个线程的完成。
    • 它通常用于确保某个线程在其终止之前不会继续执行。
  • 计数器

    • 没有显式的计数器概念。
    • join() 方法等待指定的线程结束。
  • 灵活性

    • 较为简单,仅适用于等待单个线程的完成。
  • API

    • 提供了 join()join(long millis) 方法。
    • join() 方法阻塞当前线程,直到被调用的线程完成。
    • join(long millis) 方法阻塞当前线程,直到被调用的线程完成或超时。
  • 示例

    • 一个典型的使用场景是主线程等待一个工作线程完成。
    • 例如,启动一个线程进行长时间运行的任务,然后主线程调用 join() 方法等待任务完成。

总结

  • 适用场景

    • CountDownLatch 更适合于等待一组操作完成,特别是当有多个线程参与时。
    • Thread.join() 适用于等待单个线程完成。
  • 灵活性

    • CountDownLatch 更加灵活,可以适应更多种情况。
    • Thread.join() 较为简单,适用于基本的等待场景。
  • 可重用性

    • CountDownLatch 的计数器一旦达到零就不能重置,因此它通常是一次性的。
    • Thread.join() 可以多次调用,但通常只在特定场景下使用。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值