java基础复习(五):线程和线程池

操作系统的线程与虚拟机的线程

线程本质上就是对程序某个执行流的抽象,不同的操作系统具有不同的线程实现,而java是一个跨平台的语言,java虚拟机基于硬件、操作系统的再次抽象,JVM是一种规范,它为不同的平台同一抽象出公共的模型:内存模型、运行时数据区、基于栈的执行引擎,使得java程序在不同平台的运行都可以达到同样的执行效果。而java中的线程也是一样,java线程底层直接映射到了操作系统层面的线程(主类OS大多数采用一对一的线程映射模型),java线程可以看作对操作系统线程抽象,用户不需要关心操作系统线程的阻塞、创建等,而是要去关注java线程的创建、阻塞等

java线程不一定是抢占式的,只不过主流操作系统都是抢占式调度线程。

线程的状态

操作系统线程的状态:
【1】创建(new)
【2】销毁(terminated)
【3】运行(running):获得CPU
【4】就绪(ready):获得除了CPU之外的所有必要资源
【5】等待(waiting):等待某个事件发生而无法继续执行(IO设备就绪、系统调用执行完毕、请求到达等),进程主动申请阻塞,CPU被调度给别的就绪进程

而java线程的状态:
【1】runnable:调用start()方法后
【2】blocked:阻塞,阻塞进同步队列。(进入同步块且没有成功抢占锁将进入该状态)
【3】waiting:无限期等待,如果等待的事件不发生就一直等待
【4】time_waiting:有限期等待,到底一定时间后自动返回
【5】new:创建线程对象,但是没有调用start()方法
【6】terminated:线程任务执行完毕

start与run

调用start()方法后,java线程进入runnable状态,而jvm会为之创建线程并且调用run()方法。(如果用户调用run()那么就是一个普通的方法调用,而如果调用start()就是委托JVM创建一个线程,而JVM为委托OS创建一个线程,最终将执行run()方法的任务交给子线程而不是主线程)

为什么java只有runnable状态?

操作系统层面,处于running状态的线程如果放弃CPU则进入runnable状态,而java为我们屏蔽了线程切换(CPU调度)的细节,我们只需要知道创建一个线程,然后调用start()方法该线程进入runnable状态即可。如果将runnable拆分为两个状态,那么就相当于打破了操作系统和虚拟机之间的隔离性,违背了jvm设计的初衷,本末倒置了。
java线程执行的过程中,总是不断的发生CPU调度,但是对于用户是透明的,因为java屏蔽了这一切的细节,对于用户来说,一旦一个线程被start()调用,那么它便是runnable的。

创建线程的几种方式

【1】最基本的一组方式是直接继承thread类,然后重写run方法,那么当前这个类就可以被看作是一个“线程类型”
【2】另一种就是实现runnable接口,并且实现run方法。最后将runnable实例传递给thread实例

class Thread implements Runnable 

以上两种创建方式本质上都是一样的,因为thread本身实现了runnable接口,因而默认对runnable进行了空实现。如果直接继承thread那么相当于重写了默认run()实现,而如果传入runnable实例,相当于使用构造函数为thread的runnable成员赋值。

    @Override
    public void run() {
        if (target != null) {
            target.run();
        }
    }

可以看到,如果target为空就什么也不做,否则就执行runnable成员的run方法。

thread类本质是就是对线程的抽象、建模,和其他的类没有本质区别,只不过当调用start()方法时,底层涉及系统调用,创建线程执行thread实例的run方法。(可以理解为主线程的执行的是main方法,而子线程的执行的是run方法)

总结:基于继承thread需要重写run方法,而基于实现runnable需要实现run方法,二者本质上都是编写线程需要执行的代码,而且最终需要创建thread对象并调用start方法,jvm会创建线程并且调用对象的run方法。

推荐使用实现runnable接口的方式,因为实现runnable接口本质上是完成“布置任务”的行为,可以减少“线程细节”与“任务”本身的耦合度——线程是载体,任务是主要关注点,不是让某类线程绑定某个任务,而是产生一个任务后创建一个线程实例去执行
【3】以上两种方式很大程度上取决于runnable接口定义的行为,其中runnable接口规定的run方法有两个问题:没有返回值不能抛异常
jdk5引入了callable接口

    V call() throws Exception;

callable接口规范的call方法允许返回值和异常。
而futureTask类似一个适配器,它有一个callable成员,同时(间接)实现了runnable接口,而run方法实现了本质就是call方法的调用。
同时futureTask提供了get方法,是一个阻塞调用,一旦得到结果,方法便返回。

如此,实现callable接口可以算是第三种实现线程的方式:call方法定义任务、thread.start()启动线程,futureTask.get()拿到结果或异常

        FutureTask<Integer> futureTask = new FutureTask<>(() -> {//布置任务
            throw new IndexOutOfBoundsException("233");
        });
        new Thread(futureTask).start(); //线程启动
        System.out.println(futureTask.get());//阻塞,直到返回结果

如果正常执行,get()会得到返回值,如果出现异常,最终get()会抛出executionException。

run方法中调用了call(),而将返回值和异常对象都保存到成员字段outcome中,在get()中判断outcome的类型,如果是结果就返回否则就抛出异常

总结:callable/call()计算出结果,而通过futureTask/get()向外暴露/公布结果。futureTask表示一个异步运算的结果,对这个异步运算的任务可以等待获取、判断是否完成以及取消任务。

注意:同一个callable()实例对应的任务只会执行一次,不会执行第二遍

FutureTask f =new FutureTask(new CallableDemo());
new Thread(f,"A").start();//执行call方法
new Thread(f,"B").start();//不会执行

每个futureTask实例都有状态,没当一个futureTask被实例化后,状态为NEW,而执行完毕状态就变为normal。只有当futureTask状态为NEW时run方法才会执行

【4】另一种方式就是通过线程池来索引线程,而不是主动去创建。我们创建一个线程池,创建、销毁都有线程池来管理(和我们制定的策略也有一定关系)
可以细分为使用工具类和构造函数创建

        //工具类创建
        Executors.newFixedThreadPool(4);
        //指定每个每个参数
        new ThreadPoolExecutor(5,10,60, TimeUnit.SECONDS,new ArrayBlockingQueue<>(100)
        ,Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());

推荐使用构造函数创建,因为使用工具类创建的都是经过封装后的,即我们的控制有限,而且都是基于无界队列的,一旦并发量升高将占用很大的内存

有了线程池对象,然后创建好要执行的“任务”实例,就通过submit或execute方法提交任务即可

线程池的引入

线程池的核心作用:
【1】通过预先创建线程和复用线程来避免频繁的创建和销毁线程,比较创建、销毁线程底层涉及系统调用,系统调用会造成CPU上下文切换,我们的目标是让CPU更多去执行用户代码,而减少执行系统代码,提升CPU的利用率(执行用户代码的吞吐量)
【2】时延,一个请求/任务到达如果还需要先申请内存、创建线程那么这将占用一定时间,如果任务到达直接就可以对应一个线程去执行,那么响应时间将会大大缩短,用户体验上升
【3】统一管理,以前线程都是临时工,每次使用都是“一次性”的,而使用线程池可以集中管理一组线程,并且可以统一分析、调优和监控。

一开始,我们通过继承thread重写run方法完成线程的设计。但是这是耦合的,因为thread不但包含线程开启、销毁等与线程调度、生命周期相关的方法,而且通过重写run方法,直接将线程执行逻辑嵌入了一类方法中。而使用runnable方法单独规划任务有一定改善,thread类通过组合runnable类型成员,run方法本质上执行的是外界传入的runnable的run方法。
但是以上两种方式,线程本身和任务本身都由用户直接管理,引入线程后,线程交给线程池对象管理,我们只需要设计任务对象本身即可。

简述线程池原理

线程池设计的核心思想:将任务单元与执行单元相分离,用户负责提交任务,而线程池负责“调度”线程执行任务
用户向线程池中提交一个任务,这个任务如果是无返回值的(runnable),那么用户就不用管别的了,如果是由返回值的(callable),那么线程池会给用户返回一个接口(future),用户可以使用这个接口获得异步计算的结果。
线程池收到用户提交的任务后,如果池中线程数量没有达到核心线程数,那么就创建新线程去执行任务,创建新线程后直接去执行任务。线程执行完毕后就会从工作队列中取任务,工作队列中没有任务线程就会阻塞。

先判断是否达到coreSize,因此是否有空闲线程都会创建。“取出并执行”其实就是工作线程去调用任务的run(),因为线程已经“跑起来”了(线程池创建一个线程start()后JVM为其分配OS线程并且调用run()方法),而它的run方法就是一个循环,去扫描工作队列提交的任务。一个核心线程被创建后向去执行一个被提交的任务,然后就去执行扫描队列的循环。非核心线程被创建后也是先执行“入队失败”的任务,然后去扫描工作队列

如果一任务提交到池中,但是线程池的线程数量已经达到核心线程数,这个任务将被提交到工作队列,后续空闲线程会从工作队列取出并执行。一旦队列满了(入队offer失败)则尝试创建非核心线程,如果无法继续创建线程,将对当前任务执行某项拒绝策略。

简单来说:线程池中的线程是“懒创建”的,每个线程创建后总是先执行一个提交的任务,随后去扫描工作队列——工作队列满了,核心线程忙不过来了就创建一些非核心线程,如果无法创建那么必须要对当前的任务一个处理办法,那就是执行拒绝策略

当队列为空时,核心线程和非核心线程其实都是阻塞在工作队列的,一旦有任务添加,便会唤醒线程(然后抢着执行任务,加锁),其中非核心线程的阻塞是超时阻塞,如果一段时间没有任务到达那么非核心线程将被超时唤醒,CAS减少线程数量,如果成功那么这个线程就被释放了(线程的run方法返回,同时指向线程对象的引用被移出线程池维护线程引用的哈希表),失败则说明“已经有线程被销毁了”,那么继续新一轮阻塞(下一轮循环)

if (isRunning(c) && workQueue.offer(command)) {
    int recheck = ctl.get();
    if (! isRunning(recheck) && remove(command))
        reject(command);
    else if (workerCountOf(recheck) == 0)
        addWorker(null, false);
}
else if (!addWorker(command, false))
    reject(command);

源码中,这里还有一个细节:
如果向队列中放入一个任务,但是队列已经满了,则尝试创建非核心线程去执行当前任务,如果创建失败则说明已经达到最大线程数量,执行拒绝策略。
当一个任务成功放入队列之后,会执行一个核实操作(再次检查),如果发现此时线程池已经进入非running状态,则尝试移除这个任务,从队列移除成功后则执行拒绝策略。
如果当前线程池处于running状态或者移除command失败(可能被别的线程拿走了),且此时的工作线程数量为0,这时线程池执行一个保守策略,向线程池中放入一个非核心线程。
(以上情况,可能在coreSize=0的线程池遇到,例如newCachedThreadPoolExecutor创建一个核心线程数为0的线程池,每次提交任务都会创建一个非核心线程)

Worker本身是对thread和task的封装,可以看作高层次的thread

Worker(Runnable firstTask) {
    setState(-1); // inhibit interrupts until runWorker
    this.firstTask = firstTask;
    this.thread = getThreadFactory().newThread(this);
}

如果向工作线程队列,成功添加worker实例,则调用worker.thead的start()方法(委托底层分配一个载体去运行),而run方法则是任务的执行逻辑。(执行用户传入的runnable任务或者callable任务)

if (workerAdded) {
    t.start();
    workerStarted = true;
}

线程池核心参数

【1】corePoolSize:核心线程数,也就是常驻线程池的线程最大数量
【2】maximumPoolSize:最大线程数,线程池的最大线程数,包含核心线程与非核心线程
【3】keepAliveTime:非核心线程的最大存活时间
【4】timeUnit:单位,是一个枚举,可以指定keepAliveTime的单位
【5】workQueue:工作队列,通常是阻塞队列
【6】threadFactory:线程工厂,通过工厂模式创建线程,一般使用executors提供的默认工厂,可以指定线程命名规则等
【7】rejectedExecutionHandler:拒绝策略,当任务无法被处理的处理方案,一般使用ThreadPoolExecutor提供的静态内部类

拒绝策略类型

ThreadPoolExecutor提供了四种拒绝策略,同时还可以实现RejectedExecutionHandler接口自定义拒绝策略。
【1】丢弃最老的(出队最老的,然后重新提交任务)

                e.getQueue().poll();
                e.execute(r);

【2】丢弃当前任务(对当前任务什么也不做,相当于丢弃了)

        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        }

【3】直接抛出异常

            throw new RejectedExecutionException("Task " + r.toString() +
                                                 " rejected from " +
                                                 e.toString());

【4】让提交任务的线程去执行(任务提交相当于直接去执行这个任务了,相当于在main方法中调用线程对象的run方法)

                r.run();

工作队列

工作队列首先是一个队列,它实现了队列的接口,因此它具有先进先出的行为。
而工作队列实现了阻塞队列的接口,是阻塞的:当队列空获取元素操作将被阻塞。当队列满,添加元素的操作会被阻塞。如果是单线程,使用阻塞队列不就会死锁了?因此阻塞队列是基于多线程环境下使用的,而且访问队列是需要加锁的。(具体细节还有看具体的实现)

queue接口搜索jdk5之后的接口,是collection的子接口,与list和set同级,规定了队列先进先出的行为offer/poll/peek,而blockingQueue是queue的子接口,提供了两个阻塞的行为put/take
(线程池中,向工作队列添加元素时使用offer,非阻塞,添加失败有返回值)

jdk提供了
【1】基于数组的阻塞队列arrayBlockingQueue
【2】基于链表的阻塞队列linkedBlockingQueue
【3】不存储元素的阻塞队列(一旦put就会阻塞等待另一个线程take,因此称为同步队列)synchronousQueue
【4】支持优先级排序的队列priorityBlockingQueue(相当于优先队列的阻塞实现)
【5】使用优先级队列实现的延迟无界阻塞队列delayQueue(每次取都会计算时间值,根据时间值判断是否允许取出,不到时间就阻塞),可以实现定时任务、数据缓存、设置过期值等。

推荐使用构造函数创建线程池对象的一部分原因,就是因为默认创建的线程池对象的工作队列都是无界的,如果短时间内提交过多任务,任务被无限地保存在内存的队列中——任务不会被拒绝,内存可能会被耗尽

写一个阻塞队列

阻塞队列其实就是普通的队列+锁+通信。一旦队列满了,线程就阻塞。一旦队列有空闲,阻塞线程就被唤醒。

    private Object[] array;

    private int tail;
    private int head;
    private int cap;

    private ReentrantLock lock =new ReentrantLock();
    private Condition full = lock.newCondition();
    private Condition empty = lock.newCondition();

这里使用环形数组作为底层数据结构,并且使用两个指针指向首位,使用两个condition标志“队列空”和“队列满”,使用一个全局锁管理对队列的访问

    public void put(E e) {
     lock.lock();
     try {
         while ((tail+1)%cap==tail){//队列满,阻塞
             try {
                 full.await();
             } catch (InterruptedException e1) {
                 e1.printStackTrace();
             }
         }
         array[tail]=e;
         tail=(tail+1)%cap;
         //队列不空,唤醒因为empty而阻塞的线程
         empty.signal();
     }finally {
         lock.unlock();
     }
    }

put对队列插入的过程看作一个“事务”,需要保证线程安全。如果队列已经满了就阻塞,否则插入并唤醒“空”条件下阻塞的线程

    public E take() {
        lock.lock();
        try {
            while (head==tail){
                try {
                    empty.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            E res= (E) array[head];
            array[head]=null;
            head=(head+1)%cap;
            //不满了,唤醒full条件的阻塞线程
            full.signal();
            return res;
        }finally {
            lock.unlock();
        }
    }

take方法一个道理,不再赘述

    public int size() {
        lock.lock();
        try {
            return (tail-head+cap)%cap;
        }finally {
            lock.unlock();
        }
    }

查询size也是一个事务,当然了使用一个变量实时保存size也是可以的。

线程池的几种类型

说是线程池类型,其实就是工具类executors为我们提供的、预设好的线程池的构造方法。
【1】fixedThreadPool
工作线程是固定的,也就是核心线程数等于最大线程数,指定多少那么线程池最多就只能创建多少。核心线程数+工作队列size()就是最大任务数量。阻塞队列是linkedBlockingQueue,是无界的(默认大小是int最大值),适合负载比较大的服务器,为了资源合理利用需要现在线程数量。

无界队列导致的问题:如果一个任务执行时间特别长,那么队列积攒的任务将越来越多,导致内存占用飙升最终导致OOM。

【2】singleThreadExecutor
线程中只有一个线程,相当于fixedThreadPool的单线程版本,适用于串行执行任务的场景。
【3】scheduledThreadPool
在给定的延迟后执行任务,基于delayWorkQueue。适用于周期性执行任务的场景。
该线程池也称为定时线程池,实现类实现了scheduledExecutorService接口,其中schedule方法可以延迟执行一个任务,可以执行一个runnable或callable任务。
scheduleAtFixedRate方法可以指定一个初始延迟和一个周期,可以用于实现周期任务。scheduleWithFixedDelay方法,按照延迟执行周期任务(前一个是到点就执行,这个是执行完毕后必须延迟一一段时间才能继续执行)

scheduledThreadPool底层,任务被提交到延迟队列(delayWorkQueue),延迟队列内部封装的就是一个线程安全的、阻塞的优先队列,其中根据到期时间排序,如果时间相同就根据提交顺序排序。
任务到期就被线程取出去执行,如果执行结束且属于定时任务,又会重新设置到期时间放入队列。工作线程从队头(开始时间最早的元素)里拿元素,如果执行时间不到线程就阻塞,直到执行时间达到,任务才会出队被执行。
一个定时任务包含:任务开始/定时到期时间、任务执行的间隔、入队序列号,定时任务继承了futureTask,使得任务可以被异步执行和获取结果。(定时任务比普通任务多出了更多功能,例如可以被多次执行、延时执行、定时执行等)

【4】CashedThreadPool
核心线程数量为0,而且不限制最大线程数量,空闲时间60秒,基于同步队列。
这表示每当任务到达,就会提交到同步队列,而创建后的线程总是阻塞在同步队列等待任务到达,一旦超时空闲时间就会回收,因此长时间保存空闲的线程不会占用资源。

但是如果任务执行的很慢,而任务提交的快,将会创建大量的线程,而这些线程都不是空闲的线程,占用资源,极端情况下内存资源和CPU资源将被极大消耗。

适用场景:并发执行大量短小的任务

CPU密集型线程应该配置少一些线程,因为CPU切换十分频繁,少量的线程可以提升CPU利用率。而IO密集型线程应该配置多一点线程,因为一些线程因为等待IO而放弃CPU资源,那么CPU将不被这些状态下的线程利用,因此需要多分配一些线程。
fixedThreadPool适合处理CPU密集型的任务,确保CPU在长期被worker线程使用的情况下,尽可能少的分配线程——适用执行长期的任务。不适合IO密集型场景,因为会堆积大量任务导致内存占用飙升。

线程池中线程数量的确定需要参考CPU数量和应用类型(设CPU数量为N):
【1】CPU密集型,少量线程数可以减少线程切换带来的代价,线程数量为N+1
【2】IO密集型,多一些线程可以防止CPU空闲,设置为2N+1
估算公式:最佳线程数目=((线程等待CPU时间+线程获得CPU的时间)/线程获得CPU时间)X CPU数目。(即CPU数目 X (等待CPU时间/占用CPU时间+1))
线程等待时间占比越高,需要越多线程,CPU持有时间越多,需要越少线程。

线程池异常处理

线程之间的异常是独立的,主线程无法捕获子线程抛出的(未捕获/未处理)异常,而这个异常最终会交由JVM处理——打印堆栈信息。而基于call()可以捕获异常,futureTask封装这个异常。

        pool.submit(()->{	//异常被线程池内部捕获,不输出
            throw new IndexOutOfBoundsException();
        });
        pool.execute(()->{	//异常交给JVM处理,打印堆栈信息
            throw new IndexOutOfBoundsException();
        });

execute中异常堆栈信息将被打印在控制台(因为run()不支持抛异常),而submit则可以捕获异常(call()支持抛异常),因为submit接收的是callable实例,而execute接收的是runnable实例。futureTask是一个适配器,它是runnable与callable的一个转接口,其中它对runnable中run方法的实现中,使用try/catch包裹了call()调用。

try {
    result = c.call(); 保存执行的结果
    ran = true; 保存执行状态
} catch (Throwable ex) {
    result = null;
    ran = false;
    setException(ex); 捕获子任务中的异常
}

因此可以使用返回的futureTask实例的get方法来获得异常信息。

也就是说,虽然两个线程之间不能传递异常,但是子线程可以捕获call()抛出的异常,并将异常信息保存在futureTask的成员成员中,而主线程可以拿到futureTask的引用,当futureTask调用get()方法一旦发现子线程产生了异常,那么futureTask就抛出异常(相当于子线程的代理人),以此告知主线程:子线程出现异常

以上强调的实际上是“线程未捕获的异常”,类似在main函数中抛异常,然后主线程未捕获处理的异常,这个时候JVM会处理这个未处理/捕获异常,如打印这个异常的堆栈信息。因此封装到futureTask的异常一定是call()方法中未经过捕获的异常,而我们使用execute走的仍然是给JVM处理这个未捕获异常的逻辑即打印堆栈信息(因为run方法中如果出现编译异常一定是需要处理的,如果是运行时异常,直接就抛给JVM了)。

也可以为worker线程设置未捕获异常处理器

ExecutorService threadPool = Executors.newFixedThreadPool(1, r -> {
    Thread t = new Thread(r);
    t.setUncaughtExceptionHandler(
            (t1, e) -> System.out.println(t1.getName() + "线程抛出的异常"+e));
    return t;
});

第二个参数使用了匿名函数,其实是接口threadFactory的实现类,该接口只有一个方法newThread()
uncaughtExceptionHandler是thread类的一个成员属性,默认为null,通过实例方法setUncaughtExceptionHandler来设置

在Thread中,Java提供了一个setUncaughtExceptionHandler的方法来设置线程的异常处理函数,你可以把异常处理函数传进去,当发生线程的未捕获异常的时候,由JVM来回调执行

另一种方式:
重写threadPoolExecutor的afterExecutor方法,处理传递的异常引用

public class ThreadReview extends ThreadPoolExecutor{ 
// 相当于通过继承,扩充了方法,加强了原实现类
    public ThreadReview(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
    }
    @Override
    protected void afterExecute(Runnable r, Throwable t) {
    //后处理函数,该方法是ThreadPoolExecutor提供的构造方法/模板方法
        if(t ==null&& r instanceof Future<?> ){
            try {
                Object res = ((Future) r).get();
            } catch (Exception e) {
                t=e;
                Thread.currentThread().interrupt();
            }
        }
        if(t!=null) System.out.println(t.getMessage());
    }
}

注意,一旦线程池某个线程出现异常,线程池会释放这个线程,然后再创建一个新的(非核心)线程放入池中(这个线程创建后没有任务执行,直接去扫描同步队列)

细节

execute与submit

execute()是上层接口executor提供的方法,而submit()是字接口executorService提供的方法。
其中execute()接受一个任务实例(runnable)且没有返回值。而submit()方法具有返回值,是用来接收callable实例的,返回一个futureTask对象,用户可以使用该对象获取异步结果。(阻塞调用)

线程池状态

处于running状态的线程池可以正常提供服务(接收任务提交),当调用shutdown()时变为shutdown状态,而调用shutdownNow则变为stop状态。

shutdown状态下的线程池不会接收新的任务(对新任务执行拒绝策略),但是会继续处理剩余的任务。当池中任务为空(线程执行任务完毕且队列为空)则进入tidying状态。
而stop状态下的线程池(shutdownNow),会中断所有正在执行的任务,丢弃未执行的任务,然后进入tidying状态。
进入tidying状态后,表示所有任务已经终止,且没有剩余的任务需要执行。调用terminate()后进入terminated状态表示线程池彻底关闭

调用shutdown方法后,如果所有线程都被阻塞,会唤醒所有阻塞的线程,线程判断线程池状态为shutdown便主动销毁。否则如果存在正在执行的任务,则等待任务执行完毕后的线程销毁时向阻塞线程发送中断信号。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值