Java线程池实现原理和源码分析

public DiscardOldestPolicy() { }

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {

if (!e.isShutdown()) {

e.getQueue().poll();

e.execute®;

}

}

}

内核

前面讲了 线程池的外观 ,接下来讲述它的 内核 。

线程池在内部实际上构建了一个 生产者消费者模型 ,将 线程 和 任务 两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。

线程池的运行主要分成两部分:任务管理、线程管理。

任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:

  1. 直接申请线程执行该任务;

  2. 缓冲到队列中等待线程执行;

  3. 拒绝该任务。

线程管理部分是消费者,它们被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候,线程就会被回收。

接下来,我们会按照以下三个部分去详细讲解线程池运行机制:

  1. 线程池如何维护自身状态。

  2. 线程池如何管理任务。

  3. 线程池如何管理线程。

线程池的生命周期


线程池运行的状态,并不是用户显式设置的,而是伴随着线程池的运行,由内部来维护。

线程池内部使用一个变量维护两个值:运行状态( runState )和线程数量 ( workerCount )。

在具体实现中,线程池将运行状态( runState )、线程数量 ( workerCount )两个关键参数的维护放在了一起:

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

ctl 这个 AtomicInteger 类型,是对线程池的运行状态和线程池中有效线程的数量进行控制的一个字段.

它同时包含两部分的信息:线程池的运行状态 ( runState ) 和线程池内有效线程的数量 ( workerCount ),高 3位 保存 runState ,低 29 位保存 workerCount ,两个变量之间互不干扰。

用一个变量去存储两个值,可避免在做相关决策时,出现不一致的情况,不必为了维护两者的一致,而占用锁资源。通过阅读线程池源代码也可以发现,经常出现要同时判断线程池运行状态和线程数量的情况。线程池也提供了若干方法去供用户获得线程池当前的运行状态、线程个数。这里都使用的是位运算的方式,相比于基本运算,速度也会快很多(PS:这种用法在许多源代码中都可以看到)。

关于内部封装的获取生命周期状态、获取线程池线程数量的计算方法如以下代码所示:

private static final int COUNT_BITS = Integer.SIZE - 3;//32-3

private static final int CAPACITY = (1 << COUNT_BITS) - 1;//低29位都为1,高位都为0

// runState is stored in the high-order bits

private static final int RUNNING = -1 << COUNT_BITS;//111

private static final int SHUTDOWN = 0 << COUNT_BITS;//000

private static final int STOP = 1 << COUNT_BITS;//001

private static final int TIDYING = 2 << COUNT_BITS;//010

private static final int TERMINATED = 3 << COUNT_BITS;//011

// Packing and unpacking ctl

//计算当前运行状态,取高三位

private static int runStateOf(int c) { return c & ~CAPACITY; }

//计算当前线程数量,取低29位

private static int workerCountOf(int c) { return c & CAPACITY; }

//通过状态和线程数生成ctl

private static int ctlOf(int rs, int wc) { return rs | wc; }

ThreadPoolExecutor 的运行状态有5种,分别为:

| 运行状态 | 状态描述 |

| — | — |

| RUNNING | 能接受新提交的任务,并且也能处理阻塞队列中的任务 |

| SHUTDOWN | 不能接受新提交的任务,但却可以继续处理阻塞队列中的任务 |

| STOP | 不能接受新任务,也不能处理队列中的任务同时会中断正在处理的任务线程 |

| TIDYING | 所有的任务都已经终止,workCount(有效线程数)为0 |

| TERMINATED | 在terminated方法执行完之后进入该状态 |

任务调度机制


任务调度是线程池的主要入口,当用户提交了一个任务,接下来这个任务将如何执行都是由这个阶段决定的。了解这部分就相当于了解了线程池的核心运行机制。

首先,所有任务的调度都是由 execute 方法完成的,这部分完成的工作是:检查现在线程池的 运行状态 、 运行线程数 、 运行策略 ,决定接下来执行的流程,是直接 申请线程执行 ,或是 缓冲到队列中执行 ,亦或是 直接拒绝该任务 。其执行过程如下:

  1. 首先检测线程池运行状态,如果不是 RUNNING ,则直接拒绝,线程池要保证在 RUNNING 的状态下执行任务。

  2. 如果 workerCount < corePoolSize ,则创建并启动一个线程来执行新提交的任务。

  3. 如果 workerCount >= corePoolSize ,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。

  4. 如果 workerCount >= corePoolSize && workerCount < maximumPoolSize ,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。

  5. 如果 workerCount >= maximumPoolSize ,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。

接下来进入源代码分析时间~!

提交任务


//AbstractExecutorService.java

public Future submit(Callable task) {

if (task == null) throw new NullPointerException();

RunnableFuture ftask = newTaskFor(task);

execute(ftask);

return ftask;

}

//ThreadPoolExecutor.java

public void execute(Runnable command) {

if (command == null)

throw new NullPointerException();

int c = ctl.get();//获取ctl

//检查当前核心线程数,是否小于核心线程数的大小限制

if (workerCountOf© < corePoolSize) {

//没有达到核心线程数的大小限制,那么添家核心线程执行该任务

if (addWorker(command, true))

return;

//如果添加失败,刷新ctl值

c = ctl.get();

}

//再次检查线程池的运行状态,将任务添加到等待队列中

if (isRunning© && workQueue.offer(command)) {

int recheck = ctl.get();//刷新ctl值

//如果当前线程池的装不是运行状态,那么移除刚才添加的任务

if (! isRunning(recheck) && remove(command))

reject(command);//移除成功后,使用拒绝策略处理该任务;

else if (workerCountOf(recheck) == 0)//当前工作线程数为0

//线程池正在运行,或者移除任务失败。

//添加一个非核心线程,并不指定该线程的运行任务。

//等线程创建完成之后,会从等待队列中获取任务执行。

addWorker(null, false);

}

//逻辑到这里说明线程池已经不是RUNNING状态,或者等待队列已满,需要创建一个新的非核心线程执行该任务;

//如果创建失败,那么非核心线程已满,使用拒绝策略处理该任务;

else if (!addWorker(command, false))

reject(command);

}

添加工作线程和执行任务


private final class Worker

extends AbstractQueuedSynchronizer

implements Runnable

Worker(Runnable firstTask) {

setState(-1); // inhibit interrupts until runWorker

this.firstTask = firstTask;//初始化的任务,可以为null

this.thread = getThreadFactory().newThread(this);//Worker持有的线程

}

/**部分代码省略*/

public void run() {

runWorker(this);

}

}

添加工作线程和执行任务:总体就是创建 Worker ,并且为它找到匹配的 Runnable 。

添加工作线程

增加线程是通过线程池中的 addWorker 方法,该方法的功能就是增加一个线程,该方法不考虑线程池是在哪个阶段增加的该线程,这个分配线程的策略是在上个步骤完成的,该步骤仅仅完成增加线程,并使它运行,最后返回是否成功这个结果。

addWorker 方法有两个参数: firstTask 、 core 。

firstTask 参数用于指定新增的线程执行的第一个任务,该参数可以为空;

core 参数为 true 表示在新增线程时会判断当前活动线程数是否少于 corePoolSize , false表示新增线程前需要判断当前活动线程数是否少于 maximumPoolSize 。

private boolean addWorker(Runnable firstTask, boolean core) {

retry://break和continue的跳出标签

for (;😉 {

int c = ctl.get();//获取ctl的值

int rs = runStateOf©;//获取当前线程池的状态;

/**

* 1、如果当前的线程池状态不是RUNNING

* 2、当前线程池是RUNNING而且没有添加新任务,而且等待队列不为空。这种情况下是需要创建执行线程的。

* 所以满足1,但不满足2就创建执行线程失败,返回false。

*/

if (rs >= SHUTDOWN &&

! (rs == SHUTDOWN &&

firstTask == null &&

! workQueue.isEmpty()))

return false;

/**进入内层循环 */

for (;😉 {

int wc = workerCountOf©;//获取当前执行线程的数量

/**

* 1、工作线程数量大于或等于计数器的最大阈值,那么创建执行线程失败,返回false。

* 2、如果当前创建的核心线程,那么工作线程数大于corePoolSize的话,创建执行线程失败,返回false。

* 3、如果当前创建的是非核心线程,那么工作线程数大于maximumPoolSize的话,创建执行线程失败,返回false。

*/

if (wc >= CAPACITY ||

wc >= (core ? corePoolSize : maximumPoolSize))

return false;

//用CAS操作让线程数加1,如果成功跳出整个循环

if (compareAndIncrementWorkerCount©)

break retry;

c = ctl.get(); // Re-read ctl

if (runStateOf© != rs)//线程状态前后不一样,重新执行外循环

continue retry;

// else CAS failed due to workerCount change; retry inner loop

//如果CAS操作由于工作线程数的增加失败,那么重新进行内循环

}

}

/**就现在,线程数已经增加了。但是真正的线程对象还没有创建出来。*/

boolean workerStarted = false;

boolean workerAdded = false;

Worker w = null;

try {

w = new Worker(firstTask);

final Thread t = w.thread;

if (t != null) {

final ReentrantLock mainLock = this.mainLock;

mainLock.lock();//加锁

try {

// Recheck while holding lock.

// Back out on ThreadFactory failure or if

// shut down before lock acquired.

int rs = runStateOf(ctl.get());

/**

* 再次检查线程池的运行状态

* 1、如果是RUNNING状态,那么可以创建;

* 2、如果是SHUTDOWN状态,但没有执行线程,可以创建(创建后执行等待队列中的任务)

*/

if (rs < SHUTDOWN ||

(rs == SHUTDOWN && firstTask == null)) {

//检测该线程是否已经开启

if (t.isAlive()) // precheck that t is startable

throw new IllegalThreadStateException();

workers.add(w);//将改工作线程添加到集合中

int s = workers.size();

if (s > largestPoolSize)//更新线程池的运行时的最大线程数

largestPoolSize = s;

workerAdded = true;//标识工作线程添加成功

}

} finally {//释放锁

mainLock.unlock();

}

if (workerAdded) {//如果工作线程添加成功,那么开启线程

t.start();

workerStarted = true;

}

}

} finally {

//如果工作线程添加失败,那么进行失败处理

//将已经增加的线程数减少,将添加到集合的工作线程删除

if (! workerStarted)

addWorkerFailed(w);

}

return workerStarted;

}

执行任务

在 添加工作线程 部分我们看到了,添加成功之后会开启线程执行任务。

final void runWorker(Worker w) {

Thread wt = Thread.currentThread();

Runnable task = w.firstTask;

w.firstTask = null;

//解锁,允许中断

w.unlock(); // allow interrupts

boolean completedAbruptly = true;

try {

//如果当前的工作线程已经有执行任务,或者可以从等待队列中获取到执行任务

//getTask获取任务时候会进行阻塞

while (task != null || (task = getTask()) != null) {

w.lock();//开始执行,上锁

// If pool is stopping, ensure thread is interrupted;

// if not, ensure thread is not interrupted. This

// requires a recheck in second case to deal with

// shutdownNow race while clearing interrupt

//判断线程是否需要中断

//如果线程池状态是否为STOP\TIDYING\TERMINATED,同时当前线程没有被中断那么将当前线程进行中断

if ((runStateAtLeast(ctl.get(), STOP) ||

(Thread.interrupted() &&

runStateAtLeast(ctl.get(), STOP))) &&

!wt.isInterrupted())

wt.interrupt();

try {

beforeExecute(wt, task);

Throwable thrown = null;//异常处理

try {

task.run();

} catch (RuntimeException x) {

thrown = x; throw x;

} catch (Error x) {

thrown = x; throw x;

} catch (Throwable x) {

thrown = x; throw new Error(x);

} finally {

afterExecute(task, thrown);

}

} finally {

task = null;//工作线程的当前任务置空

w.completedTasks++;//当前工作线程执行完成的线程数+1

w.unlock();//执行完成解锁

}

}

completedAbruptly = false;//完成了所有任务,正常退出

} finally {//执行工作线程的退出操作

processWorkerExit(w, completedAbruptly);

}

}

工作线程获取任务


private Runnable getTask() {

boolean timedOut = false; // Did the last poll() time out?

for (;😉 {

int c = ctl.get();//获取ctl的值

int rs = runStateOf©;//获取线程池状态

// Check if queue empty only if necessary.

/**

* 1、rs为STOP\TIDYING\TERMINATED,标识无法继续执行任务

* 2、等待队列中没有任务可以被执行

* 工作线程数量减一

*/

if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {

decrementWorkerCount();

return null;

}

int wc = workerCountOf©;//获取工作线程数量

// Are workers subject to culling?

//如果允许核心线程超时,或者当前工作线程数量大于核心线程数量。标识需要进行超时检测

boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

/**

* 1、如果当前工作线程数是否大于线程池可允许的最大工作线程数(maximumPoolSize可以动态设置)

* ,或者当前需要进行超时控制并且上次从等待队列中获取执行任务发生了超时。

* 2、如果当前不是唯一的线程,并且等待队列中没有需要执行的任务。

* 这两种情况下一起存在就表示,工作线程发生了超时需要回收,所以对线程数进行-1;

*/

if ((wc > maximumPoolSize || (timed && timedOut))

&& (wc > 1 || workQueue.isEmpty())) {

if (compareAndDecrementWorkerCount©)//线程数量减少成功,否则重新执行本次循环

return null;

continue;

}

try {

//如果设置有超时,那么设定超时时间。否则进行无限的阻塞等待执行任务

Runnable r = timed ?

workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :

workQueue.take();

if (r != null)

return r;

timedOut = true;//获取超时,设置标记

} catch (InterruptedException retry) {

timedOut = false;

}

}

}

工作线程的退出


线程池中线程的销毁依赖 JVM自动 的回收,线程池做的工作是根据当前线程池的状态维护一定数量的线程引用,防止这部分线程被JVM回收,当线程池决定哪些线程需要回收时,只需要将其引用消除即可。 Worker 被创建出来后,就会不断地进行轮询,然后获取任务去执行,核心线程可以无限等待获取任务,非核心线程要限时获取任务。当 Worker 无法获取到任务,也就是获取的任务为空时,循环会结束, Worker 会主动消除自身在线程池内的引用。

private void processWorkerExit(Worker w, boolean completedAbruptly) {

//completedAbruptly为true,标识该工作线程执行出现了异常,将工作线程数减一

if (completedAbruptly) // If abrupt, then workerCount wasn’t adjusted

decrementWorkerCount();

//否则标识该工作线程为正常结束,这种情况下getTask方法中已经对工作线程进行了减一

final ReentrantLock mainLock = this.mainLock;

mainLock.lock();//加锁

try {

completedTaskCount += w.completedTasks;//更新线程池的,线程执行完成数量

workers.remove(w);//工作线程容器移除该工作线程

} finally {

mainLock.unlock();//解锁

}

//尝试结束线程池

tryTerminate();

int c = ctl.get();

if (runStateLessThan(c, STOP)) {//如果当前线程池的运行状态是RUNNING\SHUTDOWN

if (!completedAbruptly) {//如果该工作线程为正常结束

/**

* 判断当前需要的最少的核心线程数(如果允许核心线程超时,那么最小的核心线程数为0,否则为corePoolSize)

*/

int min = allowCoreThreadTimeOut ? 0 : corePoolSize;

//如果允许核心线程超时,而且等待队列不为空,那么工作线程的最小值为1,否则为0。

if (min == 0 && ! workQueue.isEmpty())

min = 1;

//当前工作线程数,是否满足最先的核心线程数

if (workerCountOf© >= min)

//如果满足那么直接return

return; // replacement not needed

}

//如果是异常结束,或者当前线程数不满足最小的核心线程数,那么添加一个非核心线程

//核心线程和非核心线程没有什么不同,只是在创建的时候判断逻辑不同

addWorker(null, false);

}

}

特需

线程池的监控


通过线程池提供的参数进行监控。线程池里有一些属性在监控线程池的时候可以使用

  • getTaskCount :线程池已经执行的和未执行的任务总数;

  • getCompletedTaskCount :线程池已完成的任务数量,该值小于等于 taskCount ;

  • getLargestPoolSize :线程池曾经创建过的最大线程数量。通过这个数据可以知道线程池是否满过,也就是达到了 maximumPoolSize ;

  • getPoolSize :线程池当前的线程数量;

  • getActiveCount :当前线程池中正在执行任务的线程数量。

动态调整线程池的大小


JDK 允许线程池使用方通过 ThreadPoolExecutor 的实例来动态设置线程池的核心策略,以 setCorePoolSize 为方法例;

在运行期线程池使用方调用此方法设置 corePoolSize 之后,线程池会直接覆盖原来的 corePoolSize 值,并且基于当前值和原始值的比较结果采取不同的处理策略。

对于当前值小于当前工作线程数的情况,说明有多余的 worker 线程,此时会向当前 idle 的 worker 线程发起中断请求以实现回收,多余的 worker 在下次 idel 的时候也会被回收;对于当前值大于原始值且当前队列中有待执行任务,则线程池会创建新的 worker 线程来执行队列任务(PS: idel 状态为 worker 线程释放锁之后的状态,因为它在运行期间都是上锁的)。

public void setCorePoolSize(int corePoolSize) {

if (corePoolSize < 0)

throw new IllegalArgumentException();

//计算增量

int delta = corePoolSize - this.corePoolSize;

//覆盖原有的corePoolSize

this.corePoolSize = corePoolSize;

//如果当前的工作线程数量大于线程池的最大可运行核心线程数量,那么进行中断工作线程处理

if (workerCountOf(ctl.get()) > corePoolSize)

interruptIdleWorkers();

else if (delta > 0) {//如果增量大于0

// We don’t really know how many new threads are “needed”.

// As a heuristic, prestart enough new workers (up to new

// core size) to handle the current number of tasks in

// queue, but stop if queue becomes empty while doing so.

//等待队列非空,获取等待任务和增量的最小值

int k = Math.min(delta, workQueue.size());

//循环创建核心工作线程执行等待队列中的任务

while (k-- > 0 && addWorker(null, true)) {

if (workQueue.isEmpty())

break;

}

}

}

private void interruptIdleWorkers() {

interruptIdleWorkers(false);

}

private void interruptIdleWorkers(boolean onlyOne) {

final ReentrantLock mainLock = this.mainLock;

mainLock.lock();//加锁

try {

//遍历工作线程的集合

for (Worker w : workers) {

Thread t = w.thread;

//如果当前线程没有被中断,而且能获取到锁,那么尝试进行中断,最后释放锁

if (!t.isInterrupted() && w.tryLock()) {

try {

t.interrupt();

} catch (SecurityException ignore) {

} finally {

w.unlock();

}

}

//是否仅仅中断一个工作线程

if (onlyOne)

break;

}

} finally {//释放锁

mainLock.unlock();

}

}

优雅的关闭线程池


从《线程池声明周期》图上还可以看到,当我们执行 ThreadPoolExecutor#shutdown 方法将会使线程池状态从 RUNNING 转变为 SHUTDOWN 。而调用 ThreadPoolExecutor#shutdownNow 之后线程池状态将会从 RUNNING 转变为 STOP 。

shutdown

停止接收新任务,原来的任务继续执行

  1. 停止接收新的submit的任务;

  2. 已经提交的任务(包括正在跑的和队列中等待的),会继续执行完成;

  3. 等到第2步完成后,才真正停止;

public void shutdown() {

final ReentrantLock mainLock = this.mainLock;

mainLock.lock();

try {

checkShutdownAccess();// 检查权限

advanceRunState(SHUTDOWN);// 设置线程池状态

interruptIdleWorkers();// 中断空闲线程

// 钩子函数,主要用于清理一些资源

onShutdown(); // hook for ScheduledThreadPoolExecutor

} finally {

mainLock.unlock();

}

tryTerminate();

}

shutdown 方法首先加锁,其次先检查系统安装状态。接着就会将线程池状态变为 SHUTDOWN,在这之后线程池不再接受提交的新任务。此时如果还继续往线程池提交任务,将会使用线程池拒绝策略响应,默认情况下将会使用 ThreadPoolExecutor.AbortPolicy ,抛出 RejectedExecutionException 异常。

interruptIdleWorkers 方法在 动态调整线程池大小 部分有源码讲述,它只会中断空闲的线程,不会中断正在执行任务的的线程。空闲的线程将会阻塞在线程池的阻塞队列上。

shutdownNow

停止接收新任务,原来的任务停止执行

  1. 跟 shutdown() 一样,先停止接收新 submit 的任务;

  2. 忽略队列里等待的任务;

  3. 尝试将正在执行的任务 interrupt 中断;

  4. 返回未执行的任务列表;

public List shutdownNow() {

List tasks;

final ReentrantLock mainLock = this.mainLock;

mainLock.lock();

try {

checkShutdownAccess();// 检查状态

advanceRunState(STOP);// 将线程池状态变为 STOP

interruptWorkers();// 中断所有线程,包括工作线程以及空闲线程

tasks = drainQueue();// 丢弃工作队列中存量任务

} finally {

mainLock.unlock();

}

tryTerminate();

return tasks;

}

private void interruptWorkers() {

final ReentrantLock mainLock = this.mainLock;

mainLock.lock();

try {

for (Worker w : workers)

//如果工作线程已经开始,那么调用interrupt进行中断

w.interruptIfStarted();

分享

这次面试我也做了一些总结,确实还有很多要学的东西。相关面试题也做了整理,可以分享给大家,了解一下面试真题,想进大厂的或者想跳槽的小伙伴不妨好好利用时间来学习。学习的脚步一定不能停止!

薪酬缩水,“裸辞”奋战25天三面美团,交叉面却被吊打,我太难了

Spring Cloud实战

薪酬缩水,“裸辞”奋战25天三面美团,交叉面却被吊打,我太难了

Spring Boot实战

薪酬缩水,“裸辞”奋战25天三面美团,交叉面却被吊打,我太难了

面试题整理(性能优化+微服务+并发编程+开源框架+分布式)

找小编(vip1024c)领取
rkers` 方法在 动态调整线程池大小 部分有源码讲述,它只会中断空闲的线程,不会中断正在执行任务的的线程。空闲的线程将会阻塞在线程池的阻塞队列上。

shutdownNow

停止接收新任务,原来的任务停止执行

  1. 跟 shutdown() 一样,先停止接收新 submit 的任务;

  2. 忽略队列里等待的任务;

  3. 尝试将正在执行的任务 interrupt 中断;

  4. 返回未执行的任务列表;

public List shutdownNow() {

List tasks;

final ReentrantLock mainLock = this.mainLock;

mainLock.lock();

try {

checkShutdownAccess();// 检查状态

advanceRunState(STOP);// 将线程池状态变为 STOP

interruptWorkers();// 中断所有线程,包括工作线程以及空闲线程

tasks = drainQueue();// 丢弃工作队列中存量任务

} finally {

mainLock.unlock();

}

tryTerminate();

return tasks;

}

private void interruptWorkers() {

final ReentrantLock mainLock = this.mainLock;

mainLock.lock();

try {

for (Worker w : workers)

//如果工作线程已经开始,那么调用interrupt进行中断

w.interruptIfStarted();

分享

这次面试我也做了一些总结,确实还有很多要学的东西。相关面试题也做了整理,可以分享给大家,了解一下面试真题,想进大厂的或者想跳槽的小伙伴不妨好好利用时间来学习。学习的脚步一定不能停止!

[外链图片转存中…(img-qOwsRU1M-1721633876233)]

Spring Cloud实战

[外链图片转存中…(img-3cXMkFzS-1721633876234)]

Spring Boot实战

[外链图片转存中…(img-8n9o3yPC-1721633876234)]

面试题整理(性能优化+微服务+并发编程+开源框架+分布式)

找小编(vip1024c)领取

  • 10
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值