并发编程原理与实战(三十九)并发基石ArrayBlockingQueue与LinkedBlockingQueue的底层实现与API设计解析

ArrayBlockingQueue与LinkedBlockingQueue底层解析

本文继续学习另外两个并发工具类ArrayBlockingQueue和LinkedBlockingQueue。

ArrayBlockingQueue核心特性与原理

ArrayBlockingQueue是Java并发编程中一个重要的有界阻塞队列,基于数组实现,采用单个ReentrantLock控制线程安全,非常适合生产者-消费者场景。具有有界队列、线程安全、先进先出等特性。

有界队列‌

在这里插入图片描述

ArrayBlockingQueue的三个构造函数都必须传入队列容量参数,创建对比时必须指定容量,无法动态扩容。

线程安全‌

/*
 * Concurrency control uses the classic two-condition algorithm
 * found in any textbook.
 */

/** Main lock guarding all access */
final ReentrantLock lock;

/** Condition for waiting takes */
@SuppressWarnings("serial")  // Classes implementing Condition may be serializable.
private final Condition notEmpty;

/** Condition for waiting puts */
@SuppressWarnings("serial")  // Classes implementing Condition may be serializable.
private final Condition notFull;

......

    /**
 * Creates an {@code ArrayBlockingQueue} with the given (fixed)
 * capacity and the specified access policy.
 *
 * @param capacity the capacity of this queue
 * @param fair if {@code true} then queue accesses for threads blocked
 *        on insertion or removal, are processed in FIFO order;
 *        if {@code false} the access order is unspecified.
 * @throws IllegalArgumentException if {@code capacity < 1}
 */
public ArrayBlockingQueue(int capacity, boolean fair) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    this.items = new Object[capacity];
    lock = new ReentrantLock(fair);
    notEmpty = lock.newCondition();
    notFull =  lock.newCondition();
}

从源码可以看出,ArrayBlockingQueue采用了经典的两条件算法,通过ReentrantLock和Condition实现线程间的阻塞与唤醒,队列满时阻塞生产者,队列空时阻塞消费者。notFull‌条件控制生产者线程的等待和唤醒,notEmpty‌条件控制消费者线程的等待和唤醒。

FIFO原则‌

遵循先进先出的队列顺序。 FIFO 特性是通过一个‌循环数组‌来实现的,其核心在于两个指针:takeIndex 和 putIndex。

/** The queued items */
@SuppressWarnings("serial") // Conditionally serializable
final Object[] items;

/** items index for next take, poll, peek or remove */
int takeIndex;

/** items index for next put, offer, or add */
int putIndex;

/** Number of elements in the queue */
int count;

item 是存储队列元素的数组,takeIndex是下一个被取出元素的索引,putIndex是下一个被放入元素的索引,count为队列中的元素数量。

ArrayBlockingQueue核心API方法

入队方法

ArrayBlockingQueue提供了多种入队的方法,其主要区别在队列满时行为、返回值。

在这里插入图片描述

(1)add(E e)方法

/**
 * Inserts the specified element at the tail of this queue if it is
 * possible to do so immediately without exceeding the queue's capacity,
 * returning {@code true} upon success and throwing an
 * {@code IllegalStateException} if this queue is full.
 *
 * @param e the element to add
 * @return {@code true} (as specified by {@link Collection#add})
 * @throws IllegalStateException if this queue is full
 * @throws NullPointerException if the specified element is null
 */
public boolean add(E e) {
    return super.add(e);
}

add(E e)方法往队列尾部插入一个元素,如果队列满时将抛出IllegalStateException异常,插入的元素为空则抛出NullPointerException,插入成功返回true,否则返回false,立即返回。其内部最终调用offer(E e)实现。

(2)offer(E e)方法

/**
 * Inserts the specified element at the tail of this queue if it is
 * possible to do so immediately without exceeding the queue's capacity,
 * returning {@code true} upon success and {@code false} if this queue
 * is full.  This method is generally preferable to method {@link #add},
 * which can fail to insert an element only by throwing an exception.
 *
 * @throws NullPointerException if the specified element is null
 */
public boolean offer(E e) {
    Objects.requireNonNull(e);
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        if (count == items.length)
            return false;
        else {
            enqueue(e);
            return true;
        }
    } finally {
        lock.unlock();
    }
}

offer(E e)采用了ReentrantLock对插入元素的过程进行加锁。插入的元素为空则抛出NullPointerException,插入成功返回true,否则返回false,立即返回。该方法通常优于方法add,因为add方法在无法插入元素时只能通过抛出异常来反馈失败。

(3)offer(E e, long timeout, TimeUnit unit)方法

/**
 * Inserts the specified element at the tail of this queue, waiting
 * up to the specified wait time for space to become available if
 * the queue is full.
 *
 * @throws InterruptedException {@inheritDoc}
 * @throws NullPointerException {@inheritDoc}
 */
public boolean offer(E e, long timeout, TimeUnit unit)
    throws InterruptedException {

    Objects.requireNonNull(e);
    long nanos = unit.toNanos(timeout);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length) {
            if (nanos <= 0L)
                return false;
            nanos = notFull.awaitNanos(nanos);
        }
        enqueue(e);
        return true;
    } finally {
        lock.unlock();
    }
}

offer(E e, long timeout, TimeUnit unit)方法则在队列满时等待指定的时间,直到时间消耗完后才返回,插入成功返回true,否则返回false。

(4)put(E e)方法

/**
 * Inserts the specified element at the tail of this queue, waiting
 * for space to become available if the queue is full.
 *
 * @throws InterruptedException {@inheritDoc}
 * @throws NullPointerException {@inheritDoc}
 */
public void put(E e) throws InterruptedException {
    Objects.requireNonNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length)
            notFull.await();
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

put(E e)方法插入时,判断队列是否已满,满了就一直阻塞等待直到能插入元素,该方法没有返回值。从上述源码可以看出,入队操作的这几个方法最终都是调用私有的enqueue(E x)方法。

private void enqueue(E x) {
    final Object[] items = this.items;
    items[putIndex] = x; // 将元素放入 putIndex 位置
    if (++putIndex == items.length) // putIndex 指针后移,如果到达数组末尾...
        putIndex = 0; // ...则绕回数组开头(循环)
    count++; // 元素数量增加
    notEmpty.signal(); // 唤醒可能正在等待获取元素的消费者线程
}

出队方法

ArrayBlockingQueue同样提供了多个元素出队方法,主要区别在是否立即返回、阻塞等待、超时控制。

在这里插入图片描述

// 有元素立即返回,否则返回null
public E poll() {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        return (count == 0) ? null : dequeue();
    } finally {
        lock.unlock();
    }
}
// 有元素立即返回,否则等待指定长的时间
public E poll(long timeout, TimeUnit unit) throws InterruptedException {
    long nanos = unit.toNanos(timeout);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == 0) {
            if (nanos <= 0L)
                return null;
            nanos = notEmpty.awaitNanos(nanos);
        }
        return dequeue();
    } finally {
        lock.unlock();
    }
}
//阻塞等待直到有元素可以返回
public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == 0)
            notEmpty.await();
        return dequeue();
    } finally {
        lock.unlock();
    }
}

这四个方法最终都是调用私有的核心出队方法dequeue()。

private E dequeue() {
    final Object[] items = this.items;
    @SuppressWarnings("unchecked")
    E x = (E) items[takeIndex]; // 从 takeIndex 位置取出元素
    items[takeIndex] = null; // 清空该位置,帮助GC
    if (++takeIndex == items.length) // takeIndex 指针后移,如果到达数组末尾...
        takeIndex = 0; // ...则绕回数组开头(循环)
    count--; // 元素数量减少
    if (itrs != null)
        itrs.elementDequeued(); // 更新迭代器状态
    notFull.signal(); // 唤醒可能正在等待放入元素的生产者线程
    return x;
}

ArrayBlockingQueue总结

入队方法这个多个,究竟用哪个?通过上面的分析,不难发现,add()方法在队列满时抛出异常,对于日常的排队需求,很明显不符合要求;offer()方法相对add()方法友好一点,通过返回true或false表示插入结果,但是不会等待;通常很多排队场景是需要等待的,一直等待直到队列能插入元素为止,所以put()方法最符合日常需求,这也是为什么叫阻塞队列原因。

方法队列满时行为返回值
add(E e)抛出IllegalStateExceptionboolean
offer(E e)立即返回falseboolean
put(E e)阻塞直到队列有空位void
offer(E e, long timeout, TimeUnit unit)阻塞指定时间后返回falseboolean

同理,出队方法中具有阻塞特性的是take()方法,取元素时等待直到有元素可以取为止。

方法队列空时行为返回值
poll()立即返回nullE
take()阻塞直到队列有元素E
poll(long timeout, TimeUnit unit)阻塞指定时间后返回nullE

LinkedBlockingQueue核心特性与原理

LinkedBlockingQueue 是 Java 并发包中基于链表的阻塞队列实现,同样采用生产者-消费者模式,是处理多线程数据交换的重要工具。具有链表结构、容量灵活、线程安全与双锁设计、生产者-消费者模型等特性。

底层数据结构

/**
 * Linked list node class.
 */
static class Node<E> {
    E item;

    /**
     * One of:
     * - the real successor Node
     * - this Node, meaning the successor is head.next
     * - null, meaning there is no successor (this is the last node)
     */
    Node<E> next;

    Node(E x) { item = x; }
}

/** The capacity bound, or Integer.MAX_VALUE if none */
private final int capacity;

/** Current number of elements */
private final AtomicInteger count = new AtomicInteger();

/**
 * Head of linked list.
 * Invariant: head.item == null
 */
transient Node<E> head;

/**
 * Tail of linked list.
 * Invariant: last.next == null
 */
private transient Node<E> last;

Node类是单向链表‌的节点,存储队列元素‌;Node类型的head 和 last节点 分别指向链表首尾‌
原子计数器‌count 记录当前队列元素个数‌。

容量灵活

/**
 * Creates a {@code LinkedBlockingQueue} with a capacity of
 * {@link Integer#MAX_VALUE}.
 */
public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}

/**
 * Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity.
 *
 * @param capacity the capacity of this queue
 * @throws IllegalArgumentException if {@code capacity} is not greater
 *         than zero
 */
public LinkedBlockingQueue(int capacity) {
    if (capacity <= 0) throw new IllegalArgumentException();
    this.capacity = capacity;
    last = head = new Node<E>(null);
}

创建LinkedBlockingQueue时,可以指定容量,不指定容量时默认为 Integer.MAX_VALUE,可视为无界队列‌。

线程安全与双锁设计

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();

/** Wait queue for waiting takes */
@SuppressWarnings("serial") // Classes implementing Condition may be serializable.
private final Condition notEmpty = takeLock.newCondition();

/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();

/** Wait queue for waiting puts */
@SuppressWarnings("serial") // Classes implementing Condition may be serializable.
private final Condition notFull = putLock.newCondition();

takeLock是取元素用的锁,putLock是插入元素用的锁。notEmpty:队列为空时,出队线程在此条件变量上等待‌;notFull:队列满时,入队线程在此条件变量上等待‌。

核心API方法

入队方法

和ArrayBlockingQueue一样,LinkedBlockingQueue同样提供了add/offer/put这几个方法,功能同ArrayBlockingQueue。我们截取其中的阻塞方法分析下:

/**
 * 将指定元素插入此队列的尾部,必要时等待空间可用。
 * 这是阻塞队列的核心方法之一,当队列满时会阻塞当前线程。
 *
 * @throws InterruptedException 如果在线程等待时被中断
 * @throws NullPointerException 如果指定的元素为 null
 */
public void put(E e) throws InterruptedException {
    // 参数校验:不允许插入 null 元素
    if (e == null) throw new NullPointerException();
    
    // 定义局部变量 c,用于记录插入前的队列元素数量
    final int c;
    
    // 创建新的节点包装要插入的元素
    final Node<E> node = new Node<E>(e);
    
    // 获取入队锁和原子计数器的本地引用,避免后续访问 this 指针
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    
    // 获取入队锁,支持中断响应
    putLock.lockInterruptibly();
    try {
        /*
         * 注意:这里使用 count 作为等待条件判断,即使它没有被当前锁保护。
         * 这样是可行的,因为在持有 putLock 期间,count 只能减少(其他入队操作被锁阻塞),
         * 当 count 从容量值改变时,我们(或其他等待的入队线程)会被通知唤醒。
         * 同样适用于其他等待条件中使用 count 的情况。
         */
        
        // 循环检查队列是否已满(使用循环是为了防止虚假唤醒)
        while (count.get() == capacity) {
            // 队列满,当前线程在 notFull 条件上等待
            notFull.await();
        }
        
        // 队列有空闲空间,将新节点加入链表尾部
        enqueue(node);
        
        // 原子性地增加计数器,并返回增加前的值
        c = count.getAndIncrement();
        
        // 如果插入后队列仍未满,唤醒其他可能正在等待的入队线程
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
        // 无论是否成功,最终都要释放锁
        putLock.unlock();
    }
    
    // 如果插入前队列为空(c == 0),需要唤醒可能正在等待的消费线程
    if (c == 0)
        signalNotEmpty();
}

/**
 * 将节点添加到队列尾部。
 * 该方法由持有putLock的生产者线程调用,确保线程安全。
 * 通过修改last指针实现O(1)时间复杂度操作。
 *
 * @param node 待添加的节点
 */
private void enqueue(Node<E> node) {
    // 调试断言:当前线程必须持有putLock
    // assert putLock.isHeldByCurrentThread();
    // 调试断言:队列原为空(last.next == null)
    // assert last.next == null;
    // 原子操作:将新节点链接到队列尾部
    last = last.next = node;
}

出队方法

和ArrayBlockingQueue一样,LinkedBlockingQueue同样提供了poll/remove/take这几个方法,功能同ArrayBlockingQueue。我们截取其中的阻塞方法分析下:

public E take() throws InterruptedException {
    // 定义局部变量:x用于存储出队元素,c用于记录出队前的队列元素数量
    final E x;
    final int c;
    
    // 获取原子计数器和出队锁的本地引用,优化性能
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    
    // 获取出队锁,支持中断响应
    takeLock.lockInterruptibly();
    try {
        // 循环检查队列是否为空(使用循环防止虚假唤醒)
        while (count.get() == 0) {
            // 队列为空,当前线程在notEmpty条件上等待
            notEmpty.await();
        }
        
        // 队列非空,从链表头部移除元素
        x = dequeue();
        
        // 原子性地减少计数器,并返回减少前的值
        c = count.getAndDecrement();
        
        // 如果出队前队列中还有多于1个元素,唤醒其他可能正在等待的消费线程
        if (c > 1)
            notEmpty.signal();
    } finally {
        // 确保锁被释放,避免死锁
        takeLock.unlock();
    }
    
    // 如果出队前队列是满的(c == capacity),需要唤醒可能正在等待的生产线程
    if (c == capacity)
        signalNotFull();
    
    // 返回出队的元素
    return x;
}

ArrayBlockingQueue与LinkedBlockingQueue性能对比

ArrayBlockingQueue和LinkedBlockingQueue在并发性能上的差异主要源于它们的锁机制设计,这直接影响了生产者和消费者能否同时工作。

ArrayBlockingQueue‌ 使用‌单锁设计‌,生产者和消费者共用一把锁。这意味着在任何时刻,只能有一个线程执行入队或出队操作,两者无法并行。

LinkedBlockingQueue‌ 采用‌双锁设计‌,拥有独立的入队锁(putLock)和出队锁(takeLock)。这使得生产者和消费者可以‌真正并发执行‌,大幅提升吞吐量。

下面通过一个例子来对比两者的并发性能:创建相同数量的生产者和消费者线程,分别使用ArrayBlockingQueue和LinkedBlockingQueue,统计相同时间内的任务处理数量。

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.atomic.AtomicLong;

public class QueueBenchmark {
    private static final int PRODUCER_COUNT = 4;
    private static final int CONSUMER_COUNT = 4;
    private static final int TEST_DURATION_SECONDS = 10;
    private static final int QUEUE_CAPACITY = 1000;

    private static class Producer implements Runnable {
        private final BlockingQueue<Integer> queue;
        private final AtomicLong counter;
        private volatile boolean running = true;

        public Producer(BlockingQueue<Integer> queue, AtomicLong counter) {
            this.queue = queue;
            this.counter = counter;
        }

        @Override
        public void run() {
            try {
                while (running) {
                    queue.put(1);
                    counter.incrementAndGet();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        public void stop() {
            running = false;
        }
    }

    private static class Consumer implements Runnable {
        private final BlockingQueue<Integer> queue;
        private final AtomicLong counter;
        private volatile boolean running = true;

        public Consumer(BlockingQueue<Integer> queue, AtomicLong counter) {
            this.queue = queue;
            this.counter = counter;
        }

        @Override
        public void run() {
            try {
                while (running) {
                    queue.take();
                    counter.incrementAndGet();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        public void stop() {
            running = false;
        }
    }

    public static long benchmarkQueue(BlockingQueue<Integer> queue, String queueType)
            throws InterruptedException {

        AtomicLong produceCounter = new AtomicLong(0);
        AtomicLong consumeCounter = new AtomicLong(0);

        Producer[] producers = new Producer[PRODUCER_COUNT];
        Consumer[] consumers = new Consumer[CONSUMER_COUNT];

        Thread[] producerThreads = new Thread[PRODUCER_COUNT];
        Thread[] consumerThreads = new Thread[CONSUMER_COUNT];

        // 创建并启动生产者线程
        for (int i = 0; i < PRODUCER_COUNT; i++) {
            producers[i] = new Producer(queue, produceCounter);
            producerThreads[i] = new Thread(producers[i], "Producer-" + queueType + "-" + i);
            producerThreads[i].start();
        }

        // 创建并启动消费者线程
        for (int i = 0; i < CONSUMER_COUNT; i++) {
            consumers[i] = new Consumer(queue, consumeCounter);
            consumerThreads[i] = new Thread(consumers[i], "Consumer-" + queueType + "-" + i);
            consumerThreads[i].start();
        }

        // 运行测试指定时间
        Thread.sleep(TEST_DURATION_SECONDS * 1000);

        // 停止所有线程
        for (Producer producer : producers) {
            producer.stop();
        }
        for (Consumer consumer : consumers) {
            consumer.stop();
        }

        // 中断线程以确保快速停止
        for (Thread thread : producerThreads) {
            thread.interrupt();
        }
        for (Thread thread : consumerThreads) {
            thread.interrupt();
        }

        // 等待所有线程结束
        for (Thread thread : producerThreads) {
            thread.join(1000);
        }
        for (Thread thread : consumerThreads) {
            thread.join(1000);
        }

        long totalOperations = produceCounter.get() + consumeCounter.get();
        System.out.printf("%s 性能结果:%n", queueType);
        System.out.printf("  生产操作数: %,d%n", produceCounter.get());
        System.out.printf("  消费操作数: %,d%n", consumeCounter.get());
        System.out.printf("  总操作数: %,d%n", totalOperations);
        System.out.printf("  吞吐量: %,d 操作/秒%n", totalOperations / TEST_DURATION_SECONDS);
        System.out.println();

        return totalOperations;
    }

    public static void main(String[] args) throws InterruptedException {
        System.out.println("开始队列性能对比测试...");
        System.out.printf("测试配置: %d 生产者, %d 消费者, %d 秒测试时间, 队列容量 %d%n%n",
                PRODUCER_COUNT, CONSUMER_COUNT, TEST_DURATION_SECONDS, QUEUE_CAPACITY);

        // 测试 ArrayBlockingQueue
        BlockingQueue<Integer> arrayQueue = new ArrayBlockingQueue<>(QUEUE_CAPACITY);
        long arrayOps = benchmarkQueue(arrayQueue, "ArrayBlockingQueue");

        // 测试 LinkedBlockingQueue
        BlockingQueue<Integer> linkedQueue = new LinkedBlockingQueue<>(QUEUE_CAPACITY);
        long linkedOps = benchmarkQueue(linkedQueue, "LinkedBlockingQueue");

        // 性能对比分析
        double performanceRatio = (double) linkedOps / arrayOps;
        System.out.println("性能对比分析:");
        System.out.printf("LinkedBlockingQueue 比 ArrayBlockingQueue 快 %.2f 倍%n", performanceRatio);

        if (performanceRatio > 1.0) {
            System.out.println("结论: LinkedBlockingQueue 在并发场景下展现出明显的吞吐量优势");
        } else {
            System.out.println("结论: 在当前测试条件下,两种队列性能相近");
        }
    }
}

运行结果:

开始队列性能对比测试...
测试配置: 4 生产者, 4 消费者, 10 秒测试时间, 队列容量 1000

ArrayBlockingQueue 性能结果:
  生产操作数: 104,607,461
  消费操作数: 104,607,461
  总操作数: 209,214,922
  吞吐量: 20,921,492 操作/LinkedBlockingQueue 性能结果:
  生产操作数: 60,179,525
  消费操作数: 60,179,524
  总操作数: 120,359,049
  吞吐量: 12,035,904 操作/秒

性能对比分析:
LinkedBlockingQueueArrayBlockingQueue0.58 倍
结论: 在当前测试条件下,两种队列性能相近

从运行结果看,线程数量比较少的情况下,ArrayBlockingQueue的性能反而比LinkedBlockingQueue的好。加大生产消费线程的数量到10,再次运行程序:

开始队列性能对比测试...
测试配置: 10 生产者, 10 消费者, 10 秒测试时间, 队列容量 1000

ArrayBlockingQueue 性能结果:
  生产操作数: 56,185,094
  消费操作数: 56,185,094
  总操作数: 112,370,188
  吞吐量: 11,237,018 操作/LinkedBlockingQueue 性能结果:
  生产操作数: 56,596,683
  消费操作数: 56,595,693
  总操作数: 113,192,376
  吞吐量: 11,319,237 操作/秒

性能对比分析:
LinkedBlockingQueueArrayBlockingQueue1.01 倍
结论: LinkedBlockingQueue 在并发场景下展现出明显的吞吐量优势

加大生产消费线程的数量到20,再次运行程序:

开始队列性能对比测试...
测试配置: 20 生产者, 20 消费者, 10 秒测试时间, 队列容量 1000

ArrayBlockingQueue 性能结果:
  生产操作数: 33,684,938
  消费操作数: 33,684,938
  总操作数: 67,369,876
  吞吐量: 6,736,987 操作/LinkedBlockingQueue 性能结果:
  生产操作数: 57,188,358
  消费操作数: 57,187,361
  总操作数: 114,375,719
  吞吐量: 11,437,571 操作/秒

性能对比分析:
LinkedBlockingQueueArrayBlockingQueue1.70 倍
结论: LinkedBlockingQueue 在并发场景下展现出明显的吞吐量优势

从运行结果可以看出,随着并发线程数的增多,LinkedBlockingQueue的性能优势越明显。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

帧栈

您的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值