并发编程八(线程池、ThreadPoolExecutor、参数设置、优雅关闭线程池、使用建议)


线程是调度CPU资源的最小单位,线程模型分为KLT模型与ULT模型,JVM使用的KLT模型,Java线程与OS线程保持1:1的映射关系,也就是说 有一个java线程也会在操作系统里有一个对应的线程

1. 线程池

创建线程和销毁线程的代价是非常高的,而使用线程池就可以很好的提高性能。线程池在系统启动时创建大量空闲的线程。程序将一个Runable对象或Callable对象传给线程池,线程池就会启动一个线程来执行他们的run()方法或call()方法,当run()方法或call()方法执行结束后,线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个run()方法或call()方法。线程池对线程进行统一分配、调优和监控。

线程池的优点:

  • 降低资源消耗:避免频繁地创建和销毁线程,达到线程对象的重用;
  • 提高响应速度:当任务到达时,任务可以不需要等到线程创建就能立即执行;
  • 防止服务器过载:使用线程池可以根据项目灵活地控制并发的数量(maximunPoolSize),防止内存溢出或者CPU耗尽;

2. 创建线程池

Executor接口是线程池的顶层接口,里面只定义了用于执行任务的execute()方法。

Executors类:Executors是一个创建线程池的工具类,它的底层是调用ThreadPoolExecutor类的构造函数来创建线程池的。可以方便快速的创建很多种类的线程池。配置一个线程池是比较复杂的,尤其对于线程池的原理不清楚的情况下,很有可能配置的线程池不是最优的,因此,在Executors类里定义了一些静态方法,生成一些常用的线程池。如下:

//创建具有缓存功能的线程池,数目无限制
ExecutorService pool1 = Executors.newCachedThreadPool();
//创建具有固定数目的、可重用的线程池
ExecutorService pool2 = Executors.newFixedThreadPool(6);
//创建只有一个线程的线程池
ExecutorService pool3 = Executors.newSingleThreadExecutor();

//创建具有指定线程数的线程池,可以在指定延迟后执行线程任务
ScheduledExecutorService  pool4=Executors.newScheduledThreadPool(6);
//创建具有一个线程的线程池,可在指定延迟后执行线程任务
ScheduledExecutorService  pool5=Executors.newSingleThreadScheduledExecutor();

/*
 *  Java 8 新增的两个方法,这两个方法可以充分利用多CPU并行的能力,
 *    这两个方法生成的work stealing池,相当于后台线程池,如果所有的前台线程死亡了
 *  work stealing线程池中的线程会自动死亡
 */
ExecutorService pool6=Executors.newWorkStealingPool(6);
ExecutorService pool7=Executors.newWorkStealingPool();
  • newSingleThreadExecutor:创建一个单线程的线程池。这个线程池中只有一个线程在工作,即相当于单线程串行执行所有任务;如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。任务的执行顺序按照任务的提交顺序执行。
  • newCachedThreadPool:创建一个缓存池大小可根据需要伸缩的线程池,但是在以前构造的线程可用时将重用它们。对于执行很多短期异步执行的程序而言,这些线程池可提高程序性能。
  • newFixedThreadPool(int n):创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程

ExecutorService接口ExecutorService接口继承了父接口Executor,里面提供了关闭线程池的shutdown()方法和其他方法,关闭后的线程池将不再接受新的任务。

ThreadPoolExecutor类ThreadPoolExecutor类是创建线程池的核心,部分源码如下:

public class ThreadPoolExecutor extends AbstractExecutorService {

    public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
               Executors.defaultThreadFactory(), defaultHandler);
    }

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

    public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler) {
	    if (corePoolSize < 0 ||
	        maximumPoolSize <= 0 ||
	        maximumPoolSize < corePoolSize ||
	        keepAliveTime < 0)
	        throw new IllegalArgumentException();
	    if (workQueue == null || threadFactory == null || handler == null)
	        throw new NullPointerException();
	    this.acc = System.getSecurityManager() == null ?
	            null :
	            AccessController.getContext();
	    this.corePoolSize = corePoolSize;
	    this.maximumPoolSize = maximumPoolSize;
	    this.workQueue = workQueue;
	    this.keepAliveTime = unit.toNanos(keepAliveTime);
	    this.threadFactory = threadFactory;
	    this.handler = handler;
     }

     ......
	
}

由ThreadPoolExecutor类的源码可以看出,可以使用它的构造器传参来创建线程池,如下:

BlockingQueue<Runnable> blockingQueue = new ArrayBlockingQueue(10);
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 10, 50, TimeUnit.SECONDS, blockingQueue);

为什么不建议使用Executors来创建线程池呢?线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

Executors各个方法的弊端:

  • newFixedThreadPool和newSingleThreadExecutor:主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
  • newCachedThreadPool和newScheduledThreadPool:主要问题是线程数最大数量Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。

3. ThreadPoolExecutor的核心参数

ThreadPoolExecutor是创建线程池的核心类,它定义了一些构造函数用来创建线程池,如下是它的其中一个构造函数:

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

下面分别说一下它的这些参数:

  • int corePoolSize: 线程池长期维持的线程数,核心线程会一直存活(即使没有任务需要执行)。除非设置了线程池的allowCoreThreadTimeOut属性为true。
  • BlockingQueue< Runnable> workQueue: 任务的排队阻塞队列,当核心线程数已满时新提交的任务会被存储到阻塞队列中。BlockingQueue的实现类主要有以下几种:
    • ArrayBlockingQueue:基于数组的先进先出队列,有界(创建队列时,指定队列的最大容量)
    • LinkedBlockingQueue:基于链表的先进先出队列,无界,与有界队列相比,除非系统资源耗尽,否则不存在任务入队失败的情况;
    • SynchronousQueue:没有容量,总是将新任务提交给线程执行。如果没有空闲的线程,则尝试创建新的线程。如果线程数大于最大值maximumPoolSize,则执行拒绝策略;
    • PriorityBlockingQueue:一个具有优先级的无界阻塞队列,总是具有高优先级的任务先执行;
  • int maximumPoolSize: 最大线程数,线程池中能够创建的线程的最大数量。当任务队列已满且当前线程数小于最大线程数时,线程池会创建新的线程来处理任务。
    • 当线程数>=corePoolSize,且任务队列已满时。线程池会创建新线程来处理任务
    • 当线程数=maximumPoolSize,且任务队列已满时,线程池会拒绝处理任务而抛出异常
  • long keepAliveTime, TimeUnit unit:超过corePoolSize的线程的存活时长,超过这个时间,多余的线程会被回收。搭配用来限制空闲线程(除核心线程外的其他线程)的存活时间。
TimeUnit.DAYS;               //天
TimeUnit.HOURS;             //小时
TimeUnit.MINUTES;           //分钟
TimeUnit.SECONDS;           //秒
TimeUnit.MILLISECONDS;      //毫秒
TimeUnit.MICROSECONDS;      //微妙
TimeUnit.NANOSECONDS;       //纳秒
  • ThreadFactory threadFactory, // 新线程的产生方式
  • RejectedExecutionHandler handler: 任务队列满时,新任务到达后采用的拒绝策略。
    • AbortPolicy:直接抛出RejectedExecutionException(拒绝执行异常),默认的拒绝策略
    • DiscardPolicy:什么也不做,直接忽略
    • DiscardOldestPolicy:丢弃执行队列中最老的任务,尝试为当前提交的任务腾出位置;
    • CallerRunsPolicy:直接由提交任务者执行这个任务;

4. 任务提交给线程池之后的执行流程

线程池的参数一般会根据业务需求设置一个合理的数值。设置了参数之后,任务提交给线程池之后的执行流程如下:

  1. 当任务被提交到线程池时,线程池会首先检查是否有空闲的核心线程。如果有,它会立即将任务分配给一个核心线程来执行。
  2. 如果所有的核心线程都正在执行任务,并且工作队列未满,线程池会将任务放入工作队列等待执行。
  3. 如果工作队列已满,但是线程池的当前线程数小于最大线程数,线程池会创建一个新的非核心线程来处理任务。
  4. 如果当前线程数已达到最大线程数,并且工作队列已满,线程池将根据所配置的拒绝策略来处理任务。可能的处理方式包括抛出异常、丢弃任务、丢弃最旧的任务或在调用者线程中执行任务。
  5. 当线程池中的线程空闲超过设定的存活时间时,超过核心线程数的空闲线程会被终止,以减少线程池的资源消耗。
  6. 当线程池接收到新的任务时,它会重复执行上述步骤,直到线程池被显式关闭。

两种情况下线程池会拒绝处理任务:

  • 当线程数已经达到maximumPoolSize,并且队列已满,会拒绝执行新任务。
  • 当线程池被调用shutdown()后,会等待线程池里的任务执行完毕,再shutdown关闭。如果在调用shutdown()和线程池真正shutdown之间提交任务,会拒绝新任务。线程池会调用rejectedExecutionHandler来处理这个任务。如果没有设置默认是AbortPolicy,会抛出异常。

总结:提交任务之后,线程池会首先尝试着交给核心线程池中的线程来执行,核心线程是有限的,所以必须要由任务队列来缓存待执行的任务,如果任务队列也满了,这个时候线程池中剩下的线程(除核心线程以外的线程)就会启动来帮助核心线程执行任务。如果线程数已达到上限,这时就没法正常处理新到的任务,新的任务就会被交给饱和策略(拒绝策略)来处理了。

5. 线程池参数设置

默认值:

corePoolSize = 1

maxPoolSize = Integer.MAX_VALUE

queueCapacity = Integer.MAX_VALUE

keepAliveTime = 60s

allowCoreThreadTimeout = false

rejectedExecutionHandler = AbortPolicy()

5.1 实战案例

QPS=60W,大概600台机器,每个线程的处理时间是200ms,线程池参数应该怎么设置合适?

  1. 首先,我们需要计算出每台机器需要处理的QPS。假设负载均衡的情况下,每台机器需要处理的QPS为60W/600=1000QPS。
  2. 然后,我们需要计算出每台机器需要的线程数。由于每个线程的处理时间是200ms,那么每个线程每秒可以处理的请求为1/0.2=5个。所以,每台机器需要的线程数为1000/5=200个。 因此,我们可以将线程池的核心线程数和最大线程数都设置为200。这样,每台机器就可以并发处理200个请求,满足1000QPS的需求。
  3. 至于等待队列的大小,我们可以根据实际情况进行设置。如果希望系统能够应对突然的流量峰值,我们可以设置一个较大的等待队列。如果希望系统能够快速反馈过载情况,我们可以设置一个较小的等待队列。
  4. 线程空闲时间可以设置为默认值,例如60秒。这样,当线程池中的线程数量超过200时,这些超出的线程在空闲60秒后就会被终止。
  5. 线程工厂和拒绝策略可以根据实际需求进行设置。例如,我们可以通过线程工厂设置线程的优先级和名称,通过拒绝策略处理无法处理的请求。 以上是一个基本的设置方案,实际情况可能需要根据系统的具体需求和性能进行调整。

注意:生产环境的线程数设置会稍有冗余,一方面是为了应对流量突峰,另一方面是应对系统变化(比如系统负载变化导致消费变慢…)

5.2 常用的设置方法

核心线程数(corePoolSize):核心线程数的设置应该根据系统的负载情况、处理任务的类型、结合业务场景来设置。主要根据几个值来决定:

  • tasks :每秒的任务数,假设为500~1000
  • taskcost:每个任务花费时间,假设为0.1s
  • responsetime:系统允许容忍的最大响应时间,假设为1s

每秒需要的线程数threadcount = tasks/(1/taskcost) = tasks*taskcout = (500 ~ 1000)*0.1 = 50~100 个线程。corePoolSize设置应该大于50。根据8020原则,如果80%的每秒任务数小于800,那么corePoolSize设置为80即可。

阻塞队列(workQueue):对于短期高并发任务,可以使用有界队列,以避免无限制地接收任务,从而导致资源耗尽。对于长期稳定的任务,可以使用无界队列。queueCapacity = (coreSizePool/taskcost)*responsetime,计算可得queueCapacity = 80/0.1 * 1 = 800。意思是队列里的线程可以等待1s,超过了的需要新开线程来执行。

最大线程数maxPoolSize 在生产环境上我们往往设置成corePoolSize一样,这样可以减少在处理过程中创建线程的开销。

rejectedExecutionHandler:根据具体情况来决定,任务不重要可丢弃,任务重要则要利用一些缓冲机制来处理。

keepAliveTimeallowCoreThreadTimeout采用默认通常能满足。

  • 空闲线程存活时间(keepAliveTime):存活时间的设置应根据任务的类型和预期的请求响应时间来确定。如果任务的响应时间较短,可以将存活时间设置较短,以便及时回收空闲线程。如果任务的响应时间较长,可以适当增加存活时间,避免频繁创建和销毁线程。

设置参数时需要注意的点有以下几个:

  • corePoolSize和maximumPoolSize设置不当会影响效率,甚至耗尽线程;
  • workQueue设置不当容易导致OOM(Out Of Memory);

有一些是根据经验算法来设置的,参考链接:ThreadPoolExecutor 参数设置

5.3 应对流量突峰

为了应对突然的流量峰值,线程池参数设置还需要考虑哪些?
在应对突然的流量高峰,我们需要考虑以下几个因素在设置线程池的参数:

  1. 最大线程数(maximumPoolSize): 这是线程池能够容纳的最大线程数量。在流量高峰期,当前的处理能力可能无法满足需求,那么可将最大线程数设置得比核心线程数大,以便能创建更多的线程来处理任务。
  2. 队列容量: 当所有的核心线程都在运行,新的任务会被放进队列等待有空闲线程处理,我们可能需要一个大一些的队列来应对流量抖动。
  3. 拒绝策略: 在队列和最大线程池都满了的情况下,你可能需要一个合适的线程策略来处理这些超出处理能力的任务,JDK提供了4种策略,包括AbortPolicy (直接抛出异常)、DiscardPolicy(静默丢弃无法处理的任务)、DiscardOldestPolicy (丢弃队列里等待最久的任务) 和 CallerRunsPolicy (由调用线程处理该任务)。选择哪种策略取决于你的具体需求。
  4. 动态调整线程数:如果你的系统支持自动伸缩,考虑在流量峰值期间动态增加线程池的大小,流量下降时动态减少线程池的大小。
  5. 监控和告警: 关注线程池的运行情况,例如队列长度,正在运行的线程数等,设置阈值并进行告警,以便提前发现问题。

请注意以上数据并非固定不变,实际应用中可能需要根据业务场景和系统支持的最大并发量动态调节这些参数,以达到最好的效果。

6. 线程池的五种状态

ThreadPoolExecutor中定义的线程池状态如下:

private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN   =  0 << COUNT_BITS;
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;
  • RUNNING:在这个状态的线程池能判断接受新提交的任务,并且也能处理阻塞队列中的任务;
  • SHUTDOWN:处于关闭的状态,该线程池不能接受新提交的任务,但是可以处理阻塞队列中已经保存的任务,在线程处于RUNNING状态,调用shutdown()方法能切换为该状态;
  • STOP:线程池处于该状态时既不能接受新的任务也不能处理阻塞队列中的任务,并且能中断现在线程中的任务。当线程处于RUNNING和SHUTDOWN状态,调用shutdownNow()方法就可以使线程变为该状态;
  • TIDYING(整理):在SHUTDOWN状态下阻塞队列为空,且线程中的工作线程数量为0就会进入该状态,当在STOP状态下时,只要线程中的工作线程数量为0就会进入该状态;
  • TERMINATED(终止):在TIDYING状态下调用terminated()方法就会进入该状态。可以认为该状态是最终的终止状态。

7. 线程池中可用于执行任务的方法

  • execute(Runnable command):Executor提供的,用于执行没有返回结果的任务。
  • submit(Runnable task):用于执行有返回结果的任务,返回一个Future对象。
  • invokeAll(Collection tasks):执行给定的任务集合,当所有任务完成时返回一个Future的集合。
  • invokeAny(Collection tasks):执行给定的任务集合,返回任意一个已完成任务的结果。
  • schedule(Runnable command, long delay, TimeUnit unit):用于执行延迟任务。
  • scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit):用于执行定时任务。指定command任务将在指定initialDelay延迟后执行,而且以设定频率重复执行。即在initialDelay后执行,依次在initialDelay+period、initialDelay+2*period…处重复执行。
  • scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit):用于执行固定延迟的任务。如果执行一次遇到异常,就会取消后续执行,只能通过程序显式取消或终止该任务。

8. 其他

8.1 使用线程池处理任务&使用MoreFutures.tryWait同时获取并返回一批Future的操作结果值

<dependency>
   <groupId>com.github.phantomthief</groupId>
   <artifactId>more-lambdas</artifactId>
</dependency>
同时获取并返回一批Future的操作结果值
典型的使用场景:
List<Future<User>> list = doSomeAsyncTasks();  
Map<Future<User>, User> success;  
try {    
  success = tryWait(list, 1, SECONDS);  
} catch (TryWaitFutureUncheckedException e) {
    success = e.getSuccess(); // there are still some success  
}
/**
  * 测试有入参&出参的执行方法
  * @return
  */
 public static int run3(int a) {
     try {
         Thread.sleep(3000);
         System.out.println("执行...");
         return a + 10;
     } catch (InterruptedException e) {
         throw new RuntimeException(e);
     }
 }

public static void testFor4() {
    List<Future<Integer>> futureList = new ArrayList<>();

    long start = System.currentTimeMillis();
    for (int i = 0; i < 10; i++) {
        int finalI = i;
        final Future<Integer> value = threadPool.submit(() -> run3(finalI));
        futureList.add(value);
    }

    List<Integer> integers = null;
    try {
        integers = new ArrayList<>(MoreFutures.tryWait(futureList, 4, TimeUnit.SECONDS).values());
    } catch (TryWaitFutureUncheckedException e) {
        Map<? extends Future<Integer>, Integer> success = e.getSuccess();
        System.out.println(success);
//            if (success != null) {
//                integers.addAll(success.values());
//            }
        int timeoutCount = e.getTimeout().values().size();
        int failCount = e.getFailed().values().size();
        int cancelCount = e.getCancel().values().size();
        String msg = String.format("success:%s ,timeoutCount:%s,failCount:%s,cancelCount:%s",success,timeoutCount,failCount,cancelCount);
        System.out.println(msg);
    }
    System.out.println(integers);
    long end = System.currentTimeMillis();
    System.out.println((end - start) / 1000);
    threadPool.shutdown();
}

8.2 优雅的关闭线程池

在JVM关闭时优雅关闭线程池,可以使用java.lang.Runtime.addShutdownHook()方法添加一个关闭钩子。当JVM接收到退出信号时,会执行这个钩子中的代码。在这个钩子中,我们可以调用线程池的shutdown()shutdownNow()方法来优雅地关闭线程池。

以下是一个使用ThreadPoolExecutor和关闭钩子的示例代码:

package com.xingze.test.thread;

import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * @author xingze
 */
public class TestShutdownHook {

    public static void main(String[] args) {
        // 创建线程池
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2, // 核心线程数
                4, // 最大线程数
                60, // 空闲线程存活时间(秒)
                TimeUnit.SECONDS, // 时间单位
                new ArrayBlockingQueue<>(10)); // 工作队列

        // 添加关闭钩子
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            System.out.println("Shutting down thread pool gracefully...");
            executor.shutdown(); // 优雅地关闭线程池,等待已提交的任务完成
            try {
                if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { // 等待最多10秒
                    System.out.println("Thread pool didn't terminate gracefully within the given time. Shutting down forcefully.");
                    List<Runnable> runnables = executor.shutdownNow();// 强制停止所有正在执行的任务并中断所有等待的任务
                    runnables.forEach(runnable -> {
                        System.out.println("Task " + runnable.toString() + " undo.");
                    });
                }
            } catch (InterruptedException e) {
                executor.shutdownNow(); // 中断所有任务并停止线程池
                Thread.currentThread().interrupt(); // 恢复中断状态
            }
            System.out.println("Thread pool shut down gracefully.");
        }));

        for (int i = 0; i < 10; i++) {
            final int taskId = i;
            executor.execute(() -> {
                try {
                    Thread.sleep(5000);
                    System.out.println("Task " + taskId + " completed.");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    System.out.println("Task " + taskId + " interrupted.");
                }
            });
        }
        // System.exit(0); 关闭JVM,也可以使用IDEA关闭JVM进程
    }
}
  1. 调用ExecutorService的shutdown方法。这个方法会停止接收新的任务,但是已经提交的任务会继续执行。
executor.shutdown();
  1. 如果你希望等待所有任务都完成,你可以使用awaitTermination方法。这个方法会阻塞直到所有任务都完成或者超时。
try {
    if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
        // 超时后,尝试停止当前正在执行的任务
        List<Runnable> remainingTasks = executor.shutdownNow();
    }
} catch (InterruptedException e) {
    // 如果等待被中断,立即停止当前正在执行的任务
    executor.shutdownNow();
}
  1. 如果awaitTermination方法超时,或者等待被中断,可以调用shutdownNow方法来立即停止当前正在执行的任务。这个方法会尝试停止所有正在执行的任务,并返回一个包含尚未开始执行的任务的列表。 以上就是在Java中优雅地关闭线程池的步骤。

注意:shutdownNow方法并不能保证一定能够停止正在执行的任务,因为它依赖于任务的实现(任务需要正确地处理中断)。

8.3 生产环境使用异步线程池建议

  • 不使用默认线程池:CompletableFuture默认使用的是ForkJoinPool.commonPool()公共线程池中的线程,和Stream一样,所以推荐CompletableFuture使用自定义线程池。详见:记一次生产中使用CompletableFuture遇到的坑,超过最大任务数,会有被丢弃的风险。
  • get时设置超时:防止占用太长时间线程资源
  • 线程池监控:使用RateLimiter每10s打印一次线程池参数。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值