玩转java线程池之ThreadPoolExecutor

玩转java线程池之ThreadPoolExecutor

概述

线程采用池化的思想,帮助我们管理线程而获取并发性的工具。线程池主要关心的是任务的调度和线程资源的管理这两件事情。线程池的实现也是围绕着这两件事情。接下来结合ThreadPoolExecutor实例来阐述。

线程池的优点

在介绍ThreadPoolExecutor具体实现得适合,首先说一下使用线程池得优点:

  • 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗。
  • 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
  • 提高线程的可管理性:线程是稀缺资源,如果无限制创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。
  • 提供更多更强大的功能:线程池具备可拓展性,允许开发人员向其中增加更多的功能。比如延时定时线程池ScheduledThreadPoolExecutor,就允许任务延期执行或定期执行。

总体设计

Java中的线程池核心实现类是ThreadPoolExecutor,本章基于JDK 1.8的源码来分析Java线程池的核心设计与实现。我们首先来看一下ThreadPoolExecutor的UML类图,了解下ThreadPoolExecutor的继承关系。

在这里插入图片描述

  1. Executor是ThreadPoolExecutor实现的顶层接口。顶层接口Executor提供了一种思想:将任务提交和任务执行进行解耦。用户无需关注如何创建线程,如何调度线程来执行任务,用户只需提供Runnable对象,将任务的运行逻辑提交到执行器(Executor)中,由Executor框架完成线程的调配和任务的执行部分。Executor接口仅有一个public void execute(Runnable r)方法。Executor接口并不严格要求执行是异步的。 在最简单的情况下,执行者可以立即在调用者的线程中运行提交的任务(实现Executor接口,然后重写execute方法让提交任务线程直接执行任务即可)。

    class DirectExecutor implements Executor {
     public void execute(Runnable r) {
     	 r.run();
    	 }
     }
    

    一般情况下,任务在调用者线程之外的某个线程中执行。 下面的执行程序为每个任务生成一个新线程(将任务提交线程与任务执行解耦)。

    class ThreadPerTaskExecutor implements Executor {
        public void execute(Runnable r) {
          new Thread(r).start();
        }
     }
    
  2. ExecutorService接口增加了一些能力:

    • 扩充执行任务的能力,补充可以为一个或一批异步任务生成Future的方法;
    • 提供了管控线程池的方法,比如停止线程池的运行。

    ExecutorService可以shutdown线程池,此时它会拒绝新加入的任务。shutdown一个ExecutorService有两种方式:shutdown允许执行完之前提交的任务,而shutdownNow会终止所有当前没有执行的任务。终止时,ExecutorService没有主动执行的任务、没有等待执行的任务并且不能提交新的任务。 应当关闭不使用的 ExecutorService以回收其资源。其方法 public Future<?> submit(Runnable task) 扩展自Executor#execute(Runnable),可以创建和返回可用于取消执行或等待完成的 Future 任务 。方法 invokeAny 和 invokeAll 是批量执行最常用的形式,它可以执行一组任务 然后等待至少一个或全部完成。

  3. AbstractExecutorService则是上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可。何为将执行任务的流程串联起来?上述我们已经知道接口Executor、ExecutorService提供了执行任务(execute)、提交任务(submit)的接口,但是这些接口之间没有任务调用关系,但是在AbstractExecutorService中进行串联。提供了 ExecutorService 方法的默认实现。 这个类实现了submit、 invokeAny 和 invokeAll 方法。使用 newTaskFor方法封装Callable接口实现对象并返回一个RunnableFuture实例,默认为该包中提供的 FutureTask 类。 例如,代码 submit(Runnable)的实现创建了一个关联的 RunnableFuture,它被执行并返回。 子类可以覆盖 newTaskFor 方法以返回 RunnableFuture 实现,而不是FutureTask 。

  4. 最下层的实现类ThreadPoolExecutor实现最复杂的运行部分,ThreadPoolExecutor将会一方面维护自身的生命周期,另一方面同时管理线程和任务,使两者良好的结合从而执行并行任务。线程池减少了每个任务的调度开销,通常在执行大量异步任务时改进了性能;它也提供了限制和管理资源的方法,包括线程、执行任务集合时的消耗。 每个 ThreadPoolExecutor 还维护一些基本的统计信息,例如已完成的任务数。

ThreadPoolExecutor 实现

ThreadPoolExecutor这个类提供了许多可调整的参数和可扩展性钩子函数。但是,JDK建议使用更方便的且常见使用场景的预配置设置。如Executors工厂方法创建的Executors#newCachedThreadPool(无边界线程池,具有自动线程回收功能)、Executors#newFixedThreadPool(固定大小的线程池)和Executors#newSingleThreadExecutor(单后台线程)。否则,在构建线程池时需要根据需要设置参数。

线程池的核心参数

线程池的构造函数如下:

 public ThreadPoolExecutor(int corePoolSize,
      int maximumPoolSize,long keepAliveTime,
      TimeUnit unit,BlockingQueue<Runnable> workQueue,
      ThreadFactory threadFactory, RejectedExecutionHandler handler){}

接下来我们一次阐述个参数的含义:

  • 核心线程数和最大线程数的大小

    ThreadPoolExecutor 将根据 corePoolSize和 maximumPoolSize设置的边界自动调整线程池中的线程个数。当新任务在方法 execute(java.lang.Runnable) 中提交时,如果运行的线程少于 corePoolSize,即遍是在其他辅助线程是空闲的状态下也会创建新线程来处理请求。如果运行的线程多于 corePoolSize 而少于 maximumPoolSize,当且仅当队列满时才会创建新线程。如果设置的 corePoolSize 和 maximumPoolSize 相同,则创建了固定大小的线程池。如果将 maximumPoolSize 设置为基本的无界值(如 Integer.MAX_VALUE),则允许池适应任意数量的并发任务。在大多数情况下,核心和最大池大小仅基于构造来设置,不过也可以使用 setCorePoolSize(int))和 setMaximumPoolSize(int)进行动态更改

    按需构造

    默认情况下,即使核心线程最初只是在新任务到达时才创建和启动的,也可以使用方法 prestartCoreThread()prestartAllCoreThreads()对其进行动态覆盖。如果构造带有非空队列的池,则可能希望预先启动线程

  • 创建新线程的条件

    使用 ThreadFactory创建新线程。如果在创建时没有具体指定,则在同一个 ThreadGroup中一律使用 Executors.defaultThreadFactory()创建线程,并且这些线程具有相同的 NORM_PRIORITY 优先级和非守护线程状态。通过提供不同的 ThreadFactory,可以改变线程的名称、线程组、优先级、守护进程状态等。如果从 newThread 返回 null 时 即ThreadFactory 未能创建线程,则执行程序将继续运行,但不能执行任何任务。

  • 保活时间

    如果线程池中有多于 corePoolSize 的线程,则这些多出的线程在空闲时间超过 keepAliveTime 时将会终止。这提供了当线程池处于空闲时减少资源消耗的方法。如果线程池后来变得繁忙,可以创建新的线程。也可以使用方法 setKeepAliveTime(long, java.util.concurrent.TimeUnit)动态地更改此参数。如果使用 Long.MAX_VALUE TimeUnit.NANOSECONDS来设置此参数,那么会禁止关闭空闲线程。默认情况下,保活策略只在有多于 corePoolSizeThreads 的线程时才会生效。但是只要 keepAliveTime 值非 0,使用allowCoreThreadTimeOut(boolean)方法也可将此超时策略应用于核心线程(核心线程也可以关闭)。

  • 任务缓冲

    所有 BlockingQueue 都可用于传输和保持提交的任务。可以使用此队列与线程池大小进行交互:

    • 如果运行的线程少于 corePoolSize,则 Executor 始终首选添加新的线程,而不进行任务入队操作。
    • 如果运行的线程等于或多于 corePoolSize,则 Executor 始终首选将请求加入队列,而不添加新的线程。
    • 如果无法将请求加入队列,则创建新的线程,除非创建此线程超出 maximumPoolSize,在这种情况下,任务将被拒绝。

    任务排队有三种通用策略:

    1. 直接提交。工作队列的默认选项是 SynchronousQueue,一个不存储元素得队列,一个put操作必须等待一个take操作,否则不能田间元素。
    2. 无界队列。使用无界队列(例如 LinkedBlockingQueue将导致 corePoolSize 线程都有任务执行时新任务在队列中等待而不会创建新线程(永远无法满足无界队列的容量)。这样,创建的线程就不会超过 corePoolSize。(因此,maximumPoolSize 的值也就无效了。)当每个任务完全独立于其他任务,即任务执行互不影响时,适合于使用无界队列;例如,在 Web 页服务器中。这种排队可用于处理瞬态突发请求,当命令以超过队列所能处理的平均数连续到达时,此策略允许无界线程具有增长的可能性。
    3. 有界队列。当使用有限的 maximumPoolSizes 时,有界队列(如 ArrayBlockingQueue。有助于防止资源耗尽,但是可能较难调整和控制。队列大小和最大池大小可能需要相互折衷:使用大容量队列和小线程池可以最大限度地降低 CPU 使用率、操作系统资源和上下文切换开销,但是可能导致人工降低吞吐量。如果任务频繁阻塞(例如,如果它们是 I/O 边界),则系统可能会耗费大量时间在线程调度上面。使用小容量队列通常要求较大的池大小,CPU 使用率较高,但是可能会遇到较大的调度开销,这样也会降低吞吐量。

在这里插入图片描述

  • 拒绝策略

    当 Executor 已经关闭,或者 Executor 配置有限边界情况下最大线程和工作队列容量都已经饱和时,在方法 execute(java.lang.Runnable)中提交的新任务将被拒绝。在以上两种情况下,execute 方法都将调用其 RejectedExecutionHandlerRejectedExecutionHandler.rejectedExecution(java.lang.Runnable, java.util.concurrent.ThreadPoolExecutor) 方法。下面提供了四种预定义的处理程序策略:

    1. 在默认的 ThreadPoolExecutor.AbortPolicy 中,处理程序遭到拒绝将抛出运行时 异常RejectedExecutionException。直接抛异常。
    2. ThreadPoolExecutor.CallerRunsPolicy中,使用提交任务的线程本身执行,让其调用任务的run方法 。此策略提供简单的反馈控制机制,能够减缓新任务的提交速度。
    3. ThreadPoolExecutor.DiscardPolicy中,不处理任务,直接丢弃。
    4. ThreadPoolExecutor.DiscardOldestPolicy] 中,丢弃队列中最近的一个任务,并执行当前任务。

    定义和使用其他种类的 RejectedExecutionHandler 类也是可能的,但这样做需要非常小心,尤其是当策略仅用于特定容量或排队策略时。

总体架构

在这里插入图片描述

线程池在内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。线程池的运行主要分成两部分:任务管理、线程管理。任务管理部分充当生产者的角色,当任务提交后,线程池会判断该任务后续的流转:(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,两个变量之间互不干扰。用一个变量去存储两个值,可避免在做相关决策时,出现不一致的情况,不必为了维护两者的一致,而占用锁资源。通过阅读线程池源代码也可以发现,经常出现要同时判断线程池运行状态和线程数量的情况。线程池也提供了若干方法去供用户获得线程池当前的运行状态、线程个数。这里都使用的是位运算的方式,相比于基本运算,速度也会快很多。

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

private static int runStateOf(int c)     { return c & ~CAPACITY; } //计算当前运行状态
private static int workerCountOf(int c)  { return c & CAPACITY; }  //计算当前线程数量
private static int ctlOf(int rs, int wc) { return rs | wc; }   //通过状态和线程数生成ctl
private static final int CAPACITY   = (1 << COUNT_BITS) - 1;//低29位全为1
private static final int COUNT_BITS = Integer.SIZE - 3;//容量位

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

运行状态状态描述
RUNNING能接受新提交的任务,并且也能处理阻塞队列中的任务。RUNNING = -1 << COUNT_BITS= -536870912。(-536870912,0)
SHUTDOWN关闭状态,不再接受新提交的任务,但可以继续处理阻塞对队列中已经保存的任务。调用shutdown()方法会设置到此状态。SHUTDOWN = 0 << COUNT_BITS=0
STOP不接受新任务、也不再出列队列中的任务会中断正在处理任务的线程。shutdownNow()方法会设置此状态。STOP = 1 << COUNT_BITS = 536870912
TIDYING所有任务都已经终止,workCount(有效线程数)为0. TIDYING = 2 << COUNT_BITS
TERMINATED在terminated()方法执行完后进入此状态。

生命周期流程图:
在这里插入图片描述
当所有的任务已终止,ctl记录的"任务数量"为0,线程池会变为TIDYING状态。当线程池变为TIDYING状态时,会执行钩子函数terminated()。terminated()在ThreadPoolExecutor类中是空的,若用户想在线程池变为TIDYING时,进行相应的处理;可以通过重写terminated()函数来实现。线程池中shutdown()会中断空闲的线程而shutdownnow会终止线程池中所有的线程并且会将任务队列中的所有任务取出,使任务队列变成空。

线程池的执行流程

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

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

  1. 首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。
  2. 如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。
  3. 如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。
  4. 如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。
  5. 如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。

其执行流程如下图所示:
在这里插入图片描述

线程池的线程管理

在线程池中,每一个执行任务的线程会被封装成一个Worker对象。Worker对象便是线程池中的工蜂。线程池为了掌握线程的状态并维护线程的生命周期,设计了线程池内的工作线程Worker。我们来看一下它的部分代码:

private final class Worker extends AbstractQueuedSynchronizer implements Runnable{
    final Thread thread;//Worker持有的线程
    Runnable firstTask;//初始化的任务,可以为null
}

Worker这个工作线程,实现了Runnable接口,并持有一个线程thread,一个初始化的任务firstTask。thread是在调用构造方法时通过ThreadFactory来创建的线程,可以用来执行任务;firstTask用它来保存传入的第一个任务,这个任务可以有也可以为null。如果这个值是非空的,那么线程就会在启动初期立即执行这个任务,也就对应核心线程创建时的情况如果这个值是null,那么就需要创建一个线程去执行任务列表(workQueue)中的任务,也就是非核心线程的创建

Worker执行任务的模型如下图所示:
在这里插入图片描述

线程池需要管理线程的生命周期,需要在线程长时间不运行的时候进行回收。线程池使用一张Hash表去持有线程的引用,这样可以通过添加引用、移除引用这样的操作来控制线程的生命周期。这个时候重要的就是如何判断线程是否正在执行任务。

Worker是通过继承AQS,使用AQS来实现独占锁的功能。没有使用可重入锁ReentrantLock,而是使用AQS,为的就是实现不可重入的特性去反应线程现在的执行状态。当线程执行任务的时候,线程获取独占锁表明当前线程正在执行任务、在执行shutdown方法时表明不可以被中断

  1. lock方法一旦获取了独占锁,表示当前线程正在执行任务中。
  2. 如果正在执行任务,则不应该中断线程。 (shutdownNow会中断所有线程)
  3. 如果该线程现在不是独占锁的状态,也就是空闲的状态,说明它没有在处理任务,这时可以在shutdown方法中对该线程进行中断。
  4. 线程池在执行shutdown方法或tryTerminate方法时会调用interruptIdleWorkers方法来中断空闲的线程,interruptIdleWorkers方法会使用tryLock方法来判断线程池中的线程是否是空闲状态;如果线程是空闲状态则可以安全回收。

在线程回收过程中就使用到了这种特性,回收过程如下图所示:
在这里插入图片描述

Worker线程增加

增加线程是通过线程池中的addWorker方法,该方法的功能就是增加一个线程,该方法不考虑线程池是在哪个阶段增加的该线程,这个分配线程的策略是在线程池的执行流程中完成的,该步骤仅仅完成增加线程,并使它运行,最后返回是否成功这个结果。addWorker方法有两个参数:firstTask、core。firstTask参数用于指定新增的线程执行的第一个任务,该参数可以为空;core参数为true表示在新增线程时会判断当前活动线程数是否少于corePoolSize,false表示新增线程前需要判断当前活动线程数是否少于maximumPoolSize,其执行流程如下图所示:
在这里插入图片描述

Worker线程执行任务

在Worker类中的run方法调用了runWorker方法来执行任务,runWorker方法的执行过程如下:

  1. while循环不断地通过getTask()方法获取任务。
  2. getTask()方法从阻塞队列中取任务。
  3. 如果线程池正在停止,那么要保证当前线程是中断状态,否则要保证当前线程不是中断状态。
  4. 执行任务。
  5. 如果getTask结果为null则跳出循环,执行processWorkerExit()方法,销毁线程。

执行流程如下图所示:

在这里插入图片描述

Worker线程回收

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

try {
  while (task != null || (task = getTask()) != null) {
    //执行任务
  }
} finally {
  processWorkerExit(w, completedAbruptly);//获取不到任务时,主动回收自己
}

线程回收的工作是在processWorkerExit方法完成的。

在这里插入图片描述

事实上,在这个方法中,将线程引用移出线程池就已经结束了线程销毁的部分。但由于引起线程销毁的可能性有很多,线程池还要判断是什么引发了这次销毁,是否要改变线程池的现阶段状态,是否要根据新状态,重新分配线程。

线程池的feature

  • 钩子函数

    ThreadPoolExecutor 类提供 protected 可重写的 beforeExecute(java.lang.Thread, java.lang.Runnable)afterExecute(java.lang.Runnable, java.lang.Throwable)方法,这两种方法分别在执行每个任务之前和之后调用。它们可用于操纵执行环境;例如,重新初始化 ThreadLocal、搜集统计信息或添加日志条目。此外,还可以重写方法 terminated()来执行 Executor 完全终止后需要完成的所有特殊处理。

    如果钩子 (hook) 或回调方法抛出异常,则内部辅助线程将依次失败并终止。beforeExecute、afterExecute与terminated方法简述:

    1、afterExecute,无论任务的run方法是否正常返回,还是因抛出一个异常而返回,afterExecute都会被调用。

    2、在任务执行前执行beforeExecute。

    3、在worker线程完成关闭操作时调用terminated,也就是在所有任务都已经完成并且所有工作者线程也已经关闭后。terminated可以用来释放Executor在其生命周期里分配的各种资源,此外还可以执行发送通知、记录日志等操作

  • 队列维护

    方法 getQueue()允许出于监控和调试目的而访问工作队列。强烈反对出于其他任何目的而使用此方法。remove(java.lang.Runnable)purge()这两种方法可用于在取消大量已排队任务时帮助进行存储回收

  • 终止
    程序中没有引用此线程池并且剩余线程都已经shutdown。如果希望确保回收没有引用的线程池(即使用户忘记调用 shutdown()),则必须设置不被使用的线程要最终终止:设置适当保持活动时间,使用 0 核心线程的下边界和/或设置 allowCoreThreadTimeOut(boolean)。

总结:

  • 接口总结

    Executor接口仅有一个public void execute(Runnable r)方法,定义了线程池最基本的功能即执行任务

    ExecutorService接口继承了Executor接口,对Executor接口进行了扩充,为线程池提供了任务的封装、提交的功能。此接口将用户提交任务与任务的执行解耦,用户只需进行任务的提交,后续的任务调度执行无需关系。

    AbstractExecutorService是上述两个接口的一个抽象实现,此类将任务的提交、执行进行了串联。这样线程池的实现只需关系任务执行即可。

  • 线程池所有的问题都是围绕着任务的调度、线程的管理来开展的。

    • 任务的调度
      • corePoolSize: 当worker数小于corePoolSize时,新提交的任务都是新生成一个worker执行任务。
      • BlockingQueue workQueue:任务的缓冲,当当前任务数大于核心线程数并且小于阻塞队列大小时,新提交的任务会放在阻塞队列中。
      • maximumPoolSize: 当阻塞队列已经满了并且worker数小于maximumPoolSize时,新提交的任务会新生成线程来进行执行。
      • 拒绝策略:当阻塞队列已满并且线程数达到maximumPoolSize使用拒绝策略来处理新提交的任务。
    • 线程的管理
      • 线程的新建: 新建线程与任务的调度细细相关,如上。
      • 线程的回收:非核心线程在无法获取到任务且空闲时间达到keepAliveTime 会退出,当设置核心线程保活时间后也会走如上流程;调用线程池的shutdown等方法也会使线程退出

补充

  1. 什么是ThreadGroup?

    线程组(ThreadGroup)简单来说就是一个线程集合。线程组的出现是为了更方便地管理线程。

    线程组是父子结构的,一个线程组可以集成其他线程组,同时也可以拥有其他子线程组。从结构上看,线程组是一个树形结构,每个线程都隶属于一个线程组,线程组又有父线程组,这样追溯下去,可以追溯到一个根线程组——System线程组

  2. 什么是守护线程?

    是指在程序运行是在后台提供一种通用的线程,这种线程并不属于程序不可或缺的部分。 通俗点讲,任何一个守护线程都是整个JVM中所有非守护线程的“保姆”。

参考:

article,Java线程池实现原理及其在美团业务中的实践 ,2020年04月02日

本文参考了美团技术文章的大量说明,有意者可以看美团技术的原文章

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值