JAVA多线程原理浅谈

目录

一、线程的实现

1、继承Thread类实现多线程

2、实现Runnable接口方式实现多线程

3、通过Callable和FutureTask创建线程

4、通过线程池创建线程

Callable和Runnable接口的区别

二、线程池的原理

1、为什么要创建线程池

2、线程池的好处

3、线程池的注意事项

4、创建线程池核心参数

5、线程池任务调度

6、推荐配置

7、个性配置

8、总结

三、线程生命周期

新建(new Thread)

就绪(runnable)

运行(running)

堵塞(blocked)

死亡(dead)

四、线程安全

1、定义

2、多线程编程中的三个核心概念

原子性

可见性

有序性

3、如何实现线程安全

互斥同步

如何实现?

非阻塞同步

无同步方案

ThreadLocal的使用场景和原理

ThreadLocal中的内存泄露问题


 

 

 

 

 

 

一、线程的实现

1.继承Thread类,重写run方法
2.实现Runnable接口,重写run方法,实现Runnable接口的实现类的实例对象作为Thread构造函数的target
3.通过Callable和FutureTask创建线程
4.通过线程池创建线程 

 

1、继承Thread类实现多线程

public class ThreadDemo01 extends Thread{
    public ThreadDemo01(){
        //编写子类的构造方法,可缺省
    }
    public void run(){
        //编写自己的线程代码
        System.out.println(Thread.currentThread().getName());
    }
    public static void main(String[] args){ 
        ThreadDemo01 threadDemo01 = new ThreadDemo01(); 
        threadDemo01.setName("我是自定义的线程1");
        threadDemo01.start();       
        System.out.println(Thread.currentThread().toString());  
    }
}

2、实现Runnable接口方式实现多线程

public class ThreadDemo02 {
    public static void main(String[] args){ 
        System.out.println(Thread.currentThread().getName());
        Thread t1 = new Thread(new MyThread());
        t1.start(); 
    }
}
class MyThread implements Runnable{
    @Override
    public void run() {
        // TODO Auto-generated method stub
        System.out.println(Thread.currentThread().getName()+"-->我是通过实现接口的线程实现方式!");
    }   
}

 

3、通过Callable和FutureTask创建线程

a:创建Callable接口的实现类 ,并实现Call方法 
b:创建Callable实现类的实现,使用FutureTask类包装Callable对象,该FutureTask对象封装了Callable对象的Call方法的返回值 
c:使用FutureTask对象作为Thread对象的target创建并启动线程 
d:调用FutureTask对象的get()来获取子线程执行结束的返回值

public class ThreadDemo03 {
    public static void main(String[] args) {
        // TODO Auto-generated method stub
        Callable<Object> oneCallable = new Tickets<Object>();
        FutureTask<Object> oneTask = new FutureTask<Object>(oneCallable);
        Thread t = new Thread(oneTask);
        System.out.println(Thread.currentThread().getName());
        t.start();
    }
}
class Tickets<Object> implements Callable<Object>{
    //重写call方法
    @Override
    public Object call() throws Exception {
        // TODO Auto-generated method stub
        System.out.println(Thread.currentThread().getName()+"-->我是通过实现Callable接口通过FutureTask包装器来实现的线程");
        return null;
    }   
}

4、通过线程池创建线程

public class ThreadDemo05{
    private static int POOL_NUM = 10;     //线程池数量
    public static void main(String[] args) throws InterruptedException {
        // TODO Auto-generated method stub
        ExecutorService executorService = Executors.newFixedThreadPool(5);  
        for(int i = 0; i<POOL_NUM; i++)  
        {  
            RunnableThread thread = new RunnableThread();
            //Thread.sleep(1000);
            executorService.execute(thread);  
        }
        //关闭线程池
        executorService.shutdown(); 
    }   
}
class RunnableThread implements Runnable  
{     
    @Override
    public void run()  
    {  
        System.out.println("通过线程池方式创建的线程:" + Thread.currentThread().getName() + " ");
 }  

}  

public static ExecutorService newFixedThreadPool(int nThreads) 
创建固定数目线程的线程池。
public static ExecutorService newCachedThreadPool() 
创建一个可缓存的线程池,调用execute 将重用以前构造的线程(如果线程可用)。如果现有线程没有可用的,则创建一个新线程并添加到池中。终止并从缓存中移除那些已有 60 秒钟未被使用的线程。
public static ExecutorService newSingleThreadExecutor() 
创建一个单线程化的Executor。
public static ScheduledExecutorService newScheduledThreadPool(int 
corePoolSize) 
创建一个支持定时及周期性的任务执行的线程池,多数情况下可用来替代Timer类。
ExecutoreService提供了submit()方法,传递一个Callable,或Runnable,返回Future。如果Executor后台线程池还没有完成Callable的计算,这调用返回Future对象的get()方法,会阻塞直到计算完成。

 

 

Callable和Runnable接口的区别

区别1: 两者最大的区别,实现Callable接口的任务线程能返回执行结果,而实现Runnable接口的任务线程不能返回执行结果

注意点:Callable接口支持返回执行结果,此时需要调用FutureTask.get()方法实现,此方法会阻塞线程直到获取“将来”的结果,当不调用此方法时,主线程不会阻塞


区别2:Callable接口实现类中run()方法允许将异常向上抛出,也可以直接在内部处理(try...catch); 而Runnable接口实现类中run()方法的异常必须在内部处理掉,不能向上抛出

 

二、线程池的原理

1、为什么要创建线程池

在面向对象编程中,对象创建和销毁是很费时间的,因为创建一个对象要获取内存资源或者其它更多资源。在Java中更是如此,虚拟机将试图跟踪每一个对象,以便能够在对象销毁后进行垃圾回收。所以提高服务程序效率的一个手段就是尽可能减少创建和销毁对象的次数,特别是对一些很耗资源的对象创建和销毁。如何利用已有对象来服务就是一个需要解决的关键问题,其实这就是一些"池化资源"技术产生的原因。比如大家所熟悉的数据库连接池就是遵循这一思想而产生的,下面将介绍的线程池技术同样符合这一思想。

多线程技术主要解决处理器单元内多个线程执行的问题,它可以显著减少处理器单元的闲置时间,增加处理器单元的吞吐能力。但如果对多线程应用不当,会增加对单个任务的处理时间。可以举一个简单的例子:

假设一台服务器完成一项任务的时间为T

 T1 创建线程的时间
 T2 在线程中执行任务的时间,包括线程间同步所需时间> 
 T3 线程销毁的时间

显然T = T1+T2+T3。注意这是一个极度简化的假设。

可以看出T1,T3是多线程本身附加的开销,用户希望减少T1,T3所用的时间,从而减少T的时间。但一些线程的使用者并没有注意到这一点,所以在应用程序中频繁的创建或销毁线程,这导致T1和T3在T中占有非常大的比例。

线程池技术正是关注如何缩短或调整T1,T3时间的技术,从而提高服务器程序性能的。它把T1,T3分别安排在服务器程序的启动和结束的时间段或者一些空闲的时间段,这样在服务器程序处理客户请求时,不会有T1,T3的开销了,线程池不仅调整T1,T3产生的时间,而且它还显著减少了创建线程的数目。

2、线程池的好处

通过重复利用已创建的线程,减少在创建和销毁线程上所花的时间以及系统资源的开销。

提高响应速度,当任务到达时,任务可以不需要等到线程创建就可以立即执行。 提高线程的可管理性,使用线程池可以对线
程进行统一的分配和监控。

如果不使用线程池,有可能造成系统创建大量线程而导致消耗完系统内存。

3、线程池的注意事项

线程池的大小:多线程应用并非线程越多越好。需要根据系统运行的硬件环境以及应用本身的特点决定线程池的大小。一般来说,如果代码结构合理,线程数与cpu数量相适合即可。如果线程运行时可能出现阻塞现象,可相应增加池的大小、如果有必要可采用自适应算法来动态调整线程池的大小。以提高cpu的有效利用率和系统的整体性能。
并发错误:多线程应用要特别注意并发错误,要从逻辑上保证程序的正确性,注意避免死锁现象的发生。
线程泄露:这是线程池应用中的一个严重的问题、当任务执行完毕而线程没能返回池中就会发生线程泄露现象。

4、创建线程池核心参数

corePoolSize(核心线程数)

(1)核心线程会一直存在,即使没有任务执行;

(2)当线程数小于核心线程数的时候,即使有空闲线程,也会一直创建线程直到达到核心线程数;
(3)设置allowCoreThreadTimeout=true(默认false)时,核心线程会超时关闭。

(4)先增加线程至corePoolSize,之后放入队列,如队列满则增加线程至maximumPoolSize

maxPoolSize(最大线程数)

(1)线程池里允许存在的最大线程数量;
(2)当任务队列已满,且线程数量大于等于核心线程数时,会创建新的线程执行任务;
(3)线程池里允许存在的最大线程数量。当任务队列已满,且线程数量大于等于核心线程数时,会创建新的线程执行任务。

keepAliveTime(线程空闲时间)

(1)当线程空闲时间达到keepAliveTime时,线程会退出(关闭),直到线程数等于核心线程数;
(2)如果设置了allowCoreThreadTimeout=true,则线程会退出直到线程数等于零。

allowCoreThreadTimeout(允许核心线程超时)

queueCapacity(任务队列容量)

也叫阻塞队列,当核心线程都在运行,此时再有任务进来,会进入任务队列,排队等待线程执行。

rejectedExecutionHandler(任务拒绝处理器)

(1)当线程数量达到最大线程数,且任务队列已满时,会拒绝任务;
(2)调用线程池shutdown()方法后,会等待执行完线程池的任务之后,再shutdown()。如果在调用了shutdown()方法和线程池真正shutdown()之间提交任务,会拒绝新任务。

5、线程池任务调度

6、推荐配置

corePoolSize = 1
queueCapacity = Integer.MAX_VALUE
maxPoolSize = Integer.MAX_VALUE
keepAliveTime = 60秒
allowCoreThreadTimeout = false
rejectedExecutionHandler = AbortPolicy()

7、个性配置

corePoolSize 建议值为:每秒任务数*任务执行时间(例如0.5s) 【100 * 0.2=20】
maxPoolSize 建议和corePoolSize 配置一样、有同学建议直接设置为cpu数量+1
keepAliveTiime 设定值可根据任务峰值持续时间来设定。
其余配置可以走默认值,也可根据情况配置

 

8、总结

- 通过重复利用已创建的线程,减少在创建和销毁线程上所花的时间以及系统资源的开销。
- 提高响应速度,当任务到达时,任务可以不需要等到线程创建就可以立即执行。
- 提高线程的可管理性,使用线程池可以对线程进行统一的分配和监控。
- 如果不使用线程池,有可能造成系统创建大量线程而导致消耗完系统内存。
- 线程池大小,不是越大越好,最好=cpu数量+1
- 还需要注意并发引起的问题,要从逻辑上保证程序的正确性,注意避免死锁现象的发生。

 

三、线程生命周期

新建(new Thread)

当创建Thread类的一个实例(对象)时,此线程进入新建状态(未被启动)。例如:Thread t1=new Thread();

就绪(runnable)

调用Thread类的start方法,线程已经被启动,进入就绪状态,正在等待被分配给CPU时间片,也就是说此时线程正在就绪队列中排队等候得到CPU资源。

运行(running)

线程获得CPU资源正在执行任务(执行run()方法),此时除非此线程自动放弃CPU资源或者有优先级更高的线程进入,线程将一直运行到结束或者时间片结束。

堵塞(blocked)

由于某种原因导致正在运行的线程让出CPU并暂停自己的执行,即进入堵塞状态。阻塞结束后线程进入就绪状态。
堵塞的情况分三种:

(一)等待堵塞:执行的线程执行wait()方法,JVM会把该线程放入等待池中。

(二)同步堵塞:执行的线程在获取对象的同步锁时,若该同步锁被别的线程占用。则JVM会把该线程放入锁池中。

(三)其它堵塞:执行的线程执行sleep()或join()方法,或者发出了I/O请求时。JVM会把该线程置为堵塞状态。
当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完成时。线程又一次转入就绪状态。

死亡(dead)

当线程执行完毕(run方法运行结束)或被其它线程杀死,线程就进入死亡状态,这时线程不可能再进入就绪状态等待执行。

线程放弃CPU

  1. 时间片用完了,java虚拟机让当前线程暂时放弃CPU,转到就绪状态,使其它线程获得运行机会。

  2. 当前线程因为某些原因而进入阻塞状态

  3. 线程结束运行

线程停止

  1. run方法正常执行完毕

  2. run方法执行过程中抛出一个未捕获的异常

  3. 调用stop方法(不推荐使用)

进程的停止,当一个进程中所有的前台线程停止后,该进程结束。

如果希望明确地让一个线程给另外一个线程运行的机会,可以采取以下办法。
1. 调整各个线程的优先级
2. 让处于运行状态的线程调用Thread.sleep()方法
3. 让处于运行状态的线程调用Thread.yield()方法
4. 让处于运行状态的线程调用另一个线程的join()方法

 

四、线程安全

1、定义

《Java并发编程实践》中对线程安全的定义:

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。

其他的定义方式:

当多个线程访问某个方法时,不管你通过怎样的调用方式或者说这些线程如何交替的执行,我们在主程序中不需要去做任何的同步,这个类的结果行为都是我们设想的正确行为,那么我们就可以说这个类时线程安全的。
如果一段代码可以保证多个线程访问的时候正确操作共享数据,那么它是线程安全的

2、多线程编程中的三个核心概念

原子性

这一点,跟数据库事务的原子性概念差不多,即一个操作(有可能包含有多个子操作)要么全部执行(生效),要么全部都不执行(都不生效)。

关于原子性,一个非常经典的例子就是银行转账问题:比如A和B同时向C转账10万元。如果转账操作不具有原子性,A在向C转账时,读取了C的余额为20万,然后加上转账的10万,计算出此时应该有30万,但还未来及将30万写回C的账户,此时B的转账请求过来了,B发现C的余额为20万,然后将其加10万并写回。然后A的转账操作继续——将30万写回C的余额。这种情况下C的最终余额为30万,而非预期的40万。

可见性

可见性是指,当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。可见性问题是好多人忽略
或者理解错误的一点。

CPU从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。每个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。

这一点是操作系统或者说是硬件层面的机制,所以很多应用开发人员经常会忽略。

有序性

顺序性指的是,程序执行的顺序按照代码的先后顺序执行。

以下面这段代码为例

boolean started = false; // 语句1    
long counter = 0L; // 语句2    
counter = 1; // 语句3    
started = true; // 语句4 

从代码顺序上看,上面四条语句应该依次执行,但实际上JVM真正在执行这段代码时,并不保证它们一定完全按照此顺序执行。

处理器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码。

讲到这里,有人要着急了——什么,CPU不按照我的代码顺序执行代码,那怎么保证得到我们想要的效果呢?实际上,大家大可放心,CPU虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。

3、如何实现线程安全

互斥同步

何谓同步?在多线程编程中,同步就是一个线程进入监视器(可以认为是一个只允许一个线程进入的盒子),其他线程必须等待,直到那个线程退出监视器为止。

在实现互斥同步的方式中,最常使用的就是Synchronized 关键字。

synchronized实现同步的基础就是:Java中的每一个对象都可以作为锁。

具体表现为:

1.普通同步方法,锁是当前实例对象

2.静态同步方法,锁是当前类的Class对象

3.同步方法块,锁是Synchronized括号里匹配的对象

如何实现?

  • synchronized经过编译之后,会在同步块的前后生成 monitorenter 和monitorexit这两个字节码指令。这两个字节码指令之后有一个reference类型(存在于java虚拟机栈的局部变量表中,可以根据reference数据,来操作堆上的具体对象)的参数来指明要锁定和解锁的对象。根据虚拟机规范,在执行monitorenter 指令时,首先会尝试获取对象的锁,如果该对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加一。若获取对象失败,那当前线程就要阻塞等待,知道对象锁被另一个线程释放。

  • synchronized用的锁是存放在对象头里面的,在jdk1.6之后,锁一共有四种状态:无锁状态,偏向锁状态(在对象头和栈帧中的锁记录里存储偏向锁的线程id),轻量级锁状态(将对象头的mark word复制到当前线程的栈帧的锁记录中,使用CAS操作将对象头中的markWord指向栈帧中的锁记录,如果成功,则线程就拥有了对象的锁。如果两个以上的线程争用一把锁的话,则膨胀为重量级锁),重量级锁状态。

大家应该都知道,java 在虚拟机中除了线程计数器,java虚拟机栈 是线程私有的,其余的java堆,方法区,和运行时常量池都是线程共享的内存区域。java堆是存储对象和数组的,但是对象在内存中的存储布局可以分为三块区域:对象头,实例数据(对象真正存储的有效信息,程序代码中所定义的各个类型的字段内容),对齐填充。

为什么说synchronized的锁是存放在对象头里面呢?因为对象头里面也存储了两部分信息:第一部分呢,存储对象自身的运行时数据,包括哈希码,GC分代年龄,锁状态标识位,线程持有的锁,偏向锁Id,偏向时间戳等数据。第二部分是类型指针,虚拟机通过这个来确定该对象是哪个类的实例。

如何判断该对象有没有被锁?对象头里面锁状态的标志位会发生变化,当其他线程查看synchronized 锁定的对象时,会查看该对象的对象头的标志位有没有发生变化,若标志位为01,则表示未锁定,为00时,则表示轻量级锁定,为10时,则为重量级锁定状态。为01时,则为偏向锁,为11时,则为GC标记状态。

非阻塞同步

因为使用synchronized的时候,只能有一个线程可以获取对象的锁,其他线程就会进入阻塞状态,阻塞状态就会引起线程的挂起和唤醒,会带来很大的性能问题,所以就出现了非阻塞同步的实现方法。

  先进行操作,如果没有其他线程争用共享数据,那么操作就成功了,如果共享数据有争用,就采取补偿措施(不断地重试)。
  我们想想哈,互斥同步里实现了 操作的原子性(这个操作没有被中断) 和 可见性(对数据进行更改后,会立马写入到内存中,其他线程在使用到这个数据时,会获取到最新的数据),那怎么才能不用同步来实现原子性和可见性呢? 
  CAS是实现非阻塞同步的计算机指令,它有三个操作数:内存位置,旧的预期值,新值,在执行CAS操作时,当且仅当内存地址的值符合旧的预期值的时候,才会用新值来更新内存地址的值,否则就不执行更新。
     使用方法:使用JUC包下的整数原子类decompareAndSet()和getAndIncrement()方法
  缺点 :ABA 问题  版本号来解决
  只能保证一个变量的原子操作,解决办法:使用AtomicReference类来保证对象之间的原子性。可以把多个变量放在一个对象里。

无同步方案

线程本地存储:将共享数据的可见范围限制在一个线程中。这样无需同步也能保证线程之间不出现数据争用问题。

经常使用的就是ThreadLocal类

ThreadLocal类 最常见的ThreadLocal使用场景为 用来解决数据库连接、Session管理等。

 public T get() { }---获取ThreadLocal在当前线程中保存的变量副本
    public void set(T value) { }---设置当前线程中变量的副本
    public void remove() { }---移除当前线程中变量的副本
    protected T initialValue() { }---protected修饰的方法。
    
ThreadLocal提供的只是一个浅拷贝,如果变量是一个引用类型,
那么就要重写该函数来实现深拷贝。建议在使用ThreadLocal一开始时就重写该函数

 

其实引起线程不安全最根本的原因就是 :线程对于共享数据的更改会引起程序结果错误。线程安全的解决策略就是:保护共享数据在多线程的情况下,保持正确的取值。

ThreadLocal的使用场景和原理

首先ThreadLocal 是一个线程的局部变量(其实就是一个Map),ThreadLocal会为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,将对象的可见范围限制在同一个线程内,而不会影响其它线程所对应的副本。
这样做其实就是以空间换时间的方式(与synchronized相反),以耗费内存为代价,单大大减少了线程同步(如synchronized)所带来性能消耗以及减少了线程并发控制的复杂度。

ThreadLocal的设计初衷就是为了避免多个线程去并发访问同一个对象,尽管它是线程安全的。因此如果用普遍的方法,通过一个全局的线程安全的map来存储多个线程的变量副本就违背了ThreadLocal的本意。在每个Thread中存放与它关联的ThreadLocalMap是完全符合其设计思想的。当想对线程局部变量进行操作时,只要把Thread作为key来获取Thread中的ThreadLocalMap即可。这种设计相比采用一个全局map的方法会占用很多内存空间,但其不需要额外采取锁等线程同步方法而节省了时间上的消耗。

Synchronized却正好相反,它用于在多个线程间通信时能够获得数据共享。即Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离。所以ThreadLocal并不能代替synchronized,Synchronized的功能范围更广(同步机制)。

ThreadLocal中的内存泄露问题

在这里插入图片描述

ThreadLocal里面使用了一个存在弱引用的map,当释放掉threadlocal的强引用以后,map里面的value却没有被回收.而这块value永远不会被访问到了. 所以存在着内存泄露. 最好的做法是将调用threadlocal的remove方法.

如果ThreadLocal被设置为null后,并且没有任何强引用指向它,根据垃圾回收的可达性分析算法,ThreadLocal将被回收。这样的话,ThreadLocalMap中就会含有key为null的Entry,而且ThreadLocalMap是在Thread中的,只要线程迟迟不结束,这些无法访问到的value就会形成内存泄露。为了解决这个问题,ThreadLocalMap中的getEntry()、set()和remove()函数都会清理key为null的Entry,以下面的getEntry()函数为例。

private Entry getEntry(ThreadLocal<?> key) {
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
            if (e != null && e.get() == key)
                return e;
            else
                return getEntryAfterMiss(key, i, e);
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
            Entry[] tab = table;
            int len = tab.length;

            while (e != null) {
                ThreadLocal<?> k = e.get();
                if (k == key)
                    return e;
                if (k == null)
                    expungeStaleEntry(i);
                else
                    i = nextIndex(i, len);
                e = tab[i];
            }
}

要注意的是ThreadLocalMap的key是一个弱引用。在这里我们分析一下强引用key和弱引用key的差别

强引用key:ThreadLocal被设置为null,由于ThreadLocalMap持有ThreadLocal的强引用,如果不手动删除,那么ThreadLocal将不会回收,产生内存泄漏。

弱引用key:ThreadLocal被设置为null,由于ThreadLocalMap持有ThreadLocal的弱引用,即便不手动删除,ThreadLocal仍会被回收,ThreadLocalMap在之后调用set()、getEntry()和remove()函数时会清除所有key为null的Entry。

ThreadLocalMap仅仅含有这些被动措施来补救内存泄露问题,如果在之后没有调用ThreadLocalMap的set()、getEntry()和remove()函数的话,那么仍然会存在内存泄漏问题。在使用线程池的情况下,如果不及时进行清理,内存泄漏问题事小,甚至还会产生程序逻辑上的问题。所以,为了安全地使用ThreadLocal,必须要像每次使用完锁就解锁一样,在每次使用完ThreadLocal后都要调用remove()来清理无用的Entry。

 

 

 

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值