回归java11-java进阶-多线程编程

多线程编程

基础知识

进程

一般可以在同一时间内执行多个程序的操作系统都有进程的概念。
一个进程就是一个执行中的程序, 而每一个进程都有自己独立的一块内存空间、一组系统资源
每一个进程的内部数据和状态都是完全独立的。
在Windows操作系统下通过Ctrl+Alt+Del组合键查看进程,在UNIX和 Linux操作系统下通过ps命令查看进程的。

在Windows操作系统中一个进程就是一个exe或者dll程序,它们相互独立,互相也可以通信。

线程

线程与进程相似,是一段完成某个特定功能的代码,是程序中单个顺序控制的流程。
但与进程不同的是,同类的多个线程是共享一块内存空间和一组系统资源。所以系统在各个线程之间切换时,开销要比进程小的多,线程被称为轻量级进程。
一个进程中可以包含多个线程。

主线程

Java程序至少会有一个线程,这就是主线程,程序启动后是由JVM创建主线程,程序结束时由JVM停止主线程。
负责管理子线程,即子线程的启动、挂起、停止等等操作。

public class HelloWorld {
    
    public static void main(String[] args) {
        // 获取主线程
        Thread mainThread = Thread.currentThread();
        System.out.println("主线程名:" + mainThread.getName());
    }
}

第5行Thread.currentThread()获得当前线程,在main()方法中当前线程就是主线程

Thread是Java线程类,位于java.lang包中。getName()方法获得线程的名字,主线程名是main,由JVM分配。

创建子线程

Java中创建一个子线程涉及到java.lang.Thread类和java.lang.Runnable接口。

Thread是线程类,创建一 个Thread对象就会产生一个新的线程。

而线程执行的程序代码是在实现Runnable接口对象的run()方法中编写的,实现Runnable接口对象是线程执行对象。
线程执行对象实现Runnable接口的run()方法,run()方法是线程执行的入口,该线程要执行程序代码都在此编写,run()方法称为线程体。

主线程中执行入口是main(String[] args)方法,这里可以控制程序的流程,管理其他的子线程等。
子线程执行入口是线程执行对象(实现Runnable接口对象)的run()方法,在这个方法可以编写子线程相关处理代码。

实现Runnable接口

创建线程Thread对象时,可以将线程执行对象传递给它。

Thread构造方法:
Thread(Runnable target, String name) target是线程执行对象,实现Runnable接口。name为线程指定一个名字。
Thread(Runnable target) target是线程执行对象,实现Runnable接口。线程名字由JVM分配。

实现Runnable接口的线程执行对象Runner:

// 线程执行对象
public class Runner implements Runnable {
    
    // 编写执行线程代码
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            // 打印次数和线程的名字
            System.out.printf("第 %d次执行 - %s\n", i,
                              Thread.currentThread().getName());
            try {
                // 随机生成休眠时间
                long sleepTime = (long) (1000 * Math.random());
                // 线程休眠
                Thread.sleep(sleepTime);
            } catch (InterruptedException e) {
            }
        }
        // 线程执行结束
        System.out.println("执行完成! " + Thread.currentThread().getName());
    }
}

15行的Thread.sleep(sleepTime),休眠当前线程:
static void sleep(long millis) 在指定的毫秒数内让当前正在执行的线程休眠。
static void sleep(long millis, int nanos) 在指定的毫秒数加指定的纳秒数内让当前正在执行的线程休眠。

public class HelloWorld {
    
    public static void main(String[] args) {
        // 创建线程t1,参数是一个线程执行对象Runner
        Thread t1 = new Thread(new Runner());
        // 开始线程t1
        t1.start();
        // 创建线程t2,参数是一个线程执行对象Runner
        Thread t2 = new Thread(new Runner(), "MyThread");
        // 开始线程t2
        t2.start();
    }
}

7行,线程创建完成还需要调用start()方法才能执行,start()方法一旦调用线程进入可以执行状态,可以执行状态下的线程等待CPU调度执行,CPU调用后线程进行执行状态,运行run()方法。

运行结果:
第 0次执行 - MyThread
第 0次执行 - Thread-0
第 1次执行 - Thread-0
第 1次执行 - MyThread
第 2次执行 - MyThread
第 2次执行 - Thread-0
第 3次执行 - MyThread
第 3次执行 - Thread-0
第 4次执行 - Thread-0
第 5次执行 - Thread-0
第 6次执行 - Thread-0
第 4次执行 - MyThread
第 7次执行 - Thread-0
第 5次执行 - MyThread
第 8次执行 - Thread-0
第 6次执行 - MyThread
第 9次执行 - Thread-0
第 7次执行 - MyThread
执行完成! Thread-0
第 8次执行 - MyThread
第 9次执行 - MyThread
执行完成! MyThread

分析运行结果,发现两个线程是交错运行的,感觉就像是两个线程在同时运行。但是实际上一台PC通常就只有一颗CPU,在某个时刻只能是一个线程在运行,而Java语言在设计时就充分考虑到线程的并发调度执行。对于程序员来说,在编程时要注意给每个线程执行的时间和机会,主要是通过让线程休眠的办法(调用sleep()方法)来让当前线程暂停执行,然后由其他线程来争夺执行的机会。如果上面的程序中没有用到sleep()方法,就是第一个线程先执行完毕,然后第二个线程再执行完毕。所以用活sleep()方法是多线程编程的关键。

继承Thread线程类

Thread类也实现了Runnable接口,也可以作为线程执行对象,需要继承Thread类,覆盖run()方法。

自定义线程类MyThread:

// 线程执行对象
public class MyThread extends Thread {
    
    public MyThread() {
        super();
    }
    
    public MyThread(String name) {
        super(name);
    }
    
    // 编写执行线程代码
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            // 打印次数和线程的名字
            System.out.printf("第 %d次执行 - %s\n", i, getName());
            try {
                // 随机生成休眠时间
                long sleepTime = (long) (1000 * Math.random());
                // 线程休眠
                sleep(sleepTime);
            } catch (InterruptedException e) {
            }
        }
        // 线程执行结束
        System.out.println("执行完成! " + getName());
    }
}

Thread类构造方法:
Thread(String name) name为线程指定一个名字
Thread() 线程名字是JVM分配的。

public class HelloWorld {
    
    public static void main(String[] args) {
        // 创建线程t1
        Thread t1 = new MyThread();
        // 开始线程t1
        t1.start();
        // 创建线程t2
        Thread t2 = new MyThread("MyThread");
        // 开始线程t2
        t2.start();
    }
}

由于Java只支持单继承,继承Thread类就不能再继承其他父类。当开发一些图形界面的应用时,需要一个类既是一个窗口(继承JFrame)又是一个线程体,那么只能采用实现Runnable接口方式。

使用匿名内部类和Lambda表达式实现线程体

如果线程体使用的地方不是很多,可以不用单独定义一个类。可以使用匿名内部类或Lambda表达式直接实现Runnable接口。Runnable中只有一个方法是函数式接口,可以使用Lambda表达式。

public class HelloWorld {
    
    public static void main(String[] args) {
        
        // 创建线程t1,参数是实现Runnable接口的匿名内部类
        Thread t1 = new Thread(new Runnable() {
            // 编写执行线程代码
            @Override
            public void run() {
                for (int i = 0; i < 10; i++) {
                    // 打印次数和线程的名字
                    System.out.printf("第 %d次执行 - %s\n", 
                                      i, Thread.currentThread().getName());
                    try {
                        // 随机生成休眠时间
                        long sleepTime = (long) (1000 * Math.random());
                        // 线程休眠
                        Thread.sleep(sleepTime);
                    } catch (InterruptedException e) {
                    }
                }
                // 线程执行结束
                System.out.println("执行完成! " + Thread.currentThread().getName());
            }
        });
        
        // 开始线程t1
        t1.start();
        
        // 创建线程t2,参数是实现Runnable接口的Lambda表达式
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                // 打印次数和线程的名字
                System.out.printf("第 %d次执行 - %s\n", 
                                  i, Thread.currentThread().getName());
                try {
                    // 随机生成休眠时间
                    long sleepTime = (long) (1000 * Math.random());
                    // 线程休眠
                    Thread.sleep(sleepTime);
                } catch (InterruptedException e) {
                }
            }
            // 线程执行结束
            System.out.println("执行完成! " + Thread.currentThread().getName());
        }, "MyThread");
        
        // 开始线程t2
        t2.start();
    }
}

匿名内部类和Lambda表达式代码虽然很多,但是它只是一个参数,实现了Runnable接口线程执行对象。

线程状态

在线程的生命周期中,线程会有几种状态:

  1. 新建状态
    新建状态(New)是通过new等方式创建线程对象,它仅仅是一个空的线程对象。

  2. 就绪状态
    当主线程调用新建线程的start()方法后,它就进入就绪状态(Runnable)。此时的线程尚未真正开始执行run()方法,它必须等待CPU的调度。

  3. 运行状态
    CPU调度就绪状态的线程,线程进入运行状态(Running),处于运行状态的线程独占CPU,执行run()方法。

  4. 阻塞状态
    因为某种原因运行状态的线程会进入不可运行状态,即阻塞状态(Blocked),处于阻塞状态的线程JVM系统不能执行该线程,即使CPU空闲,也不能执行该线程。

    几个原因会导致线程进入阻塞状态:
    当前线程调用sleep()方法,进入休眠状态。
    被其他线程调用了join()方法,等待其他线程结束。
    发出I/O请求,等待I/O操作完成期间。
    当前线程调用wait()方法。

    处于阻塞状态可以重新回到就绪状态,如:休眠结束、其他线程加入、I/O操作完成和调用notify或notifyAll唤醒wait线程。

  5. 死亡状态
    线程退出run()方法后,就会进入死亡状态(Dead),线程进入死亡状态有可能是正常实现完成run()方法进入,也可能是由于发生异常而进入的。

线程管理

线程的难点…

线程优先级

线程的调度程序根据线程决定每次线程应当何时运行,Java提供了10种优先级,分别用1~10整数表 示,
最高优先级是10,用常量MAX_PRIORITY表示;最低优先级是1,用常量MIN_PRIORITY;默认优先级是5,用常量NORM_PRIORITY表示。

Thread类提供了setPriority(int newPriority)方法来设置线程优先级,通过getPriority()方法获得线程优先级。

设置线程优先级实例:

public class HelloWorld {
    
    public static void main(String[] args) {
        // 创建线程t1,参数是一个线程执行对象Runner
        Thread t1 = new Thread(new Runner());
        t1.setPriority(Thread.MAX_PRIORITY);
        // 开始线程t1
        t1.start();
        // 创建线程t2,参数是一个线程执行对象Runner
        Thread t2 = new Thread(new Runner(), "MyThread");
        t2.setPriority(Thread.MIN_PRIORITY);
        // 开始线程t2
        t2.start();
    }
}

第6行设置线程t1优先级最高,第11行设置线程t2优先级最低。
多次运行上面的示例会发现,t1线程经常先运行,但是偶尔t2线程也会先运行。说明影响线程获得CPU时间的因素,除了线程优先级外,还与操作系统有关。

等待线程结束

当前线程调用t1线程的join()方法,则阻塞当前线程,等待t1线程结束,如果t1线程结束或等待超时,则当前线程回到就绪状态。

Thread类提供了多个版本的join():
void join() 等待该线程结束。
void join(long millis) 等待该线程结束的时间最长为millis毫秒。如果超时为0意味着要一直等下去。
void join(long millis, int nanos) 等待该线程结束的时间最长为millis毫秒加nanos纳秒。

public class HelloWorld {
    
    //共享变量
    static int value = 0;
    
    public static void main(String[] args) throws InterruptedException {
        
        System.out.println("主线程 开始...");
        
        // 创建线程t1,参数是一个线程执行对象Runner
        Thread t1 = new Thread(() -> {
            System.out.println("ThreadA 开始...");
            for (int i = 0; i < 2; i++) {
                System.out.println("ThreadA 执行...");
                value++;
            }
            System.out.println("ThreadA 结束...");
        }, "ThreadA");
        
        // 开始线程t1
        t1.start();
        // 主线程被阻塞,等待t1线程结束
        t1.join();
        System.out.println("value = " + value);
        System.out.println("主线程 结束...");
    }
}

运行结果:
主线程 开始…
ThreadA 开始…
ThreadA 执行…
ThreadA 执行…
ThreadA 结束…
value = 2
主线程 结束…

23行在当前线程(主线程)中调用t1的join()方法,因此会导致主线程阻塞,等待t1线程结束。
若去掉23行,则输出结果为:
主线程 开始…
value = 0
主线程 结束…
ThreadA 开始…
ThreadA 执行…
ThreadA 执行…
ThreadA 结束…

使用join()方法的场景:一个线程依赖于另外一个线程的运行结果,所以调用另一个线程的join()方法等它运行完成。

线程让步

线程类Thread提供静态方法yield(),调用yield()方法能够使当前线程给其他线程让步。它类似于 sleep()方法,能够使运行状态的线程放弃CPU使用权,暂停片刻,然后重新回到就绪状态。
与sleep()方法不同的是,sleep()方法是线程进行休眠,能够给其他线程运行的机会,无论线程优先级高低都有机 会运行。而yield()方法只给相同优先级或更高优先级线程机会。

// 线程执行对象
public class Runner implements Runnable {
    
    // 编写执行线程代码
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            // 打印次数和线程的名字
            System.out.printf("第 %d次执行 - %s\n", 
                              i, Thread.currentThread().getName());
            Thread.yield(); // 使当前线程让步
        }
        // 线程执行结束
        System.out.println("执行完成! " + Thread.currentThread().getName());
    }
}

yield()方法只能给相同优先级或更高优先级的线程让步,yield()方法在实际开发中很少使用,大多都使用sleep()方法,sleep()方法可以控制时间,而yield()方法不能。

线程停止

线程体中的run()方法结束,线程进入死亡状态,线程就停止了。但是有些业务比较复杂,例如想开发 一个下载程序,每隔一段执行一次下载任务,下载任务一般会由子线程执行的,休眠一段时间再执行。这个下载子线程中会有一个死循环,但是为了能够停止子线程,设置一个结束变量。

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class HelloWorld {
    
    private static String command = ""; // 设置结束变量
    
    public static void main(String[] args) {
        
        // 创建线程t1,参数是一个线程执行对象Runner
        Thread t1 = new Thread(() -> {
            // 一直循环,直到满足条件在停止线程
            while (!command.equalsIgnoreCase("exit")) {
                // 线程开始工作
                // TODO
                System.out.println("下载中...");
                try {
                    // 线程休眠
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                }
            }
            // 线程执行结束
            System.out.println("执行完成!");
        });
        
        // 开始线程t1
        t1.start();
        try (InputStreamReader ir = new InputStreamReader(System.in);
             BufferedReader in = new BufferedReader(ir)) {
            // 从键盘接收了一个字符串的输入
            command = in.readLine();
        } catch (IOException e) {
        }
    }
}

第12行是在子线程的线程体中判断,用户输入的是否为exit字符串,如果不是则进行循环,否则结束循环,结束循环就结束了run()方法,线程就停止了。

第30行中的System.in是一个很特殊的输入流,能够从控制台(键盘)读取字符。第33行是通过流System.in读取键盘输入的字符串。
测试时:在控制台输入exit,然后敲Enter键。

控制线程的停止有人会想到使用Thread提供的stop()方法,这个方法已经不推荐使用了,这个方法有时会引发严重的系统故障,类似还有suspend()和resume()挂起方法。Java现在推荐的做法就是采用本例的结束变量方式。

线程安全

在多线程环境下,访问相同的资源,有可以会引发线程不安全问题。

临界资源问题

多一个线程同时运行,有时线程之间需要共享数据,一个线程需要其他线程的数据,否则就不能保证 程序运行结果的正确性。

一个模拟销售机票系统(每一天机票数量是有限的,很多售票点同时销售这些机票)

//机票数据库
public class TicketDB {
    
    // 机票的数量
    private int ticketCount = 5;
    
    // 获得当前机票数量
    public int getTicketCount() {
        return ticketCount;
    }
    
    // 销售机票
    public void sellTicket() {
        try {
            // 等待用户付款
            // 线程休眠,阻塞当前线程,模拟等待用户付款
            Thread.sleep(1000);
        } catch (InterruptedException e) {
        }
        System.out.printf("第%d号票,已经售出\n", ticketCount);
        ticketCount--;
    }
}


public class HelloWorld {
    
    public static void main(String[] args) {
        
        TicketDB db = new TicketDB();
        
        // 创建线程t1
        Thread t1 = new Thread(() -> {
            while (true) {
                int currTicketCount = db.getTicketCount();
                // 查询是否有票
                if (currTicketCount > 0) {
                    db.sellTicket();
                } else {
                    // 无票退出
                    break;
                }
            }
        });
        
        // 开始线程t1
        t1.start();
        
        // 创建线程t2
        Thread t2 = new Thread(() -> {
            while (true) {
                int currTicketCount = db.getTicketCount();
                // 查询是否有票
                if (currTicketCount > 0) {
                    db.sellTicket();
                } else {
                    // 无票退出
                    break;
                }
            }
        });
        
        // 开始线程t2
        t2.start();
    }
}

可能运行结果:(每次结果不同)
第5号票,已经售出
第5号票,已经售出
第3号票,已经售出
第3号票,已经售出
第1号票,已经售出
第0号票,已经售出

创建两个线程,模拟两个售票网点。
问题:同一张票重复销售、出现第0号票和5张票卖了6次。这些问题的根本原因是多个线程间共享的数据导致数据的不一致性。

多个线程间共享的数据称为共享资源或临界资源,由于是CPU负责线程的调度,程序员无法精确控制多线程的交替顺序。这种情况下,多线程对临界资源的访问有时会导致数据的不一致性。

多线程同步

为了防止多线程对临界资源的访问有时会导致数据的不一致性,Java提供了“互斥”机制,可以为这些资源对象加上一把“互斥锁”,在任一时刻只能由一个线程访问,即使该线程出现阻塞,该对象的被锁定状态也不会解除,其他线程仍不能访问该对象,这就是多线程同步。
线程同步是保证线程安全的重要手段,但是线程同步客观上会导致性能下降。

可以通过两种方式实现线程同步,都涉及到synchronized关键字:
synchronized方法,使用synchronized关键字修饰方法,对方法进行同步;
synchronized语句,使用 synchronized关键字放在对象前面限制一段代码的执行。

synchronized方法

synchronized关键字修饰方法实现线程同步,方法所在的对象被锁定。

// 机票数据库
public class TicketDB {
    
    // 机票的数量
    private int ticketCount = 5;
    
    // 获得当前机票数量
    public synchronized int getTicketCount() {
        return ticketCount;
    }
    
    // 销售机票
    public synchronized void sellTicket() {
        try {
            // 等待用户付款
            // 线程休眠,阻塞当前线程,模拟等待用户付款
            Thread.sleep(1000);
        } catch (InterruptedException e) {
        }
        System.out.printf("第%d号票,已经售出\n", ticketCount);
        ticketCount--;
    }
}

8行13行方法前都使用了synchronized关键字,表明这两个方法是同步的,被锁定的,每一个时刻只能由一个线程访问。
并不是每一个方法都有必要加锁的,要仔细研究加上的必要性,上述代码第8行加锁可以防止出现第0号票情况和5张票卖出6次的情况;代码第13行加锁是防止出现销售两种一样的票。

synchronized语句

synchronized语句方式主要用于第三方类,不方便修改它的代码情况。
可以不用修改TicketDB.java类,只修改调用代码HelloWorld.java实现同步。

public class HelloWorld {
    
    public static void main(String[] args) {
        
        TicketDB db = new TicketDB();
        
        // 创建线程t1
        Thread t1 = new Thread(() -> {
            while (true) {
                synchronized (db) {
                    int currTicketCount = db.getTicketCount();
                    // 查询是否有票
                    if (currTicketCount > 0) {
                        db.sellTicket();
                    } else {
                        // 无票退出
                        break;
                    }
                }
            }
        });
        // 开始线程t1
        t1.start();
        
        // 创建线程t2
        Thread t2 = new Thread(() -> {
            while (true) {
                synchronized (db) {
                    int currTicketCount = db.getTicketCount();
                    // 查询是否有票
                    if (currTicketCount > 0) {
                        db.sellTicket();
                    } else {
                        // 无票退出
                        break;
                    }
                }
            }
        });
        // 开始线程t2
        t2.start();
    }
}

10行28行,使用synchronized语句,将需要同步的代码用大括号括起来。synchronized 后有小括号,将需要同步的对象括起来。

线程间通信

如果两个线程之间有依 赖关系,线程之间必须进行通信,互相协调才能完成工作。

(例如有一个经典的堆栈问题,一个线程生成了一些数据,将数据压栈;另一个线程消费了这些数据, 将数据出栈。这两个线程互相依赖,当堆栈为空时,消费线程无法取出数据时,应该通知生成线程添 加数据;当堆栈已满时,生产线程无法添加数据时,应该通知消费线程取出数据)

为了实现线程间通信,需要使用Object类中声明的5个方法:
void wait() 使当前线程释放对象锁,然后当前线程处于对象等待队列中阻塞状态,等待其他线程唤醒。
void wait(long timeout) 同wait()方法,等待timeout毫秒时间。
void wait(long timeout, int nanos) 同wait()方法,等待timeout毫秒加nanos纳秒时间。
void notify() 当前线程唤醒此对象等待队列中的一个线程,该线程将进入就绪状态。
void notifyAll() 当前线程唤醒此对象等待队列中的所有线程,这些线程将进入就绪状态。

(线程有多种方式进入阻塞状态,除了通过wait()外,还有加锁的方式和其他方式,加锁方式是前面的使用synchronized加互斥锁;其他方式线程状态时介绍的方式。)

消费和生产示例中堆栈类:
同步堆栈类:

// 堆栈类
class Stack {
    
    // 堆栈指针初始值为0
    private int pointer = 0;
    // 堆栈有5个字符的空间
    private char[] data = new char[5];
    
    // 压栈方法,加上互斥锁
    public synchronized void push(char c) {
        // 堆栈已满,不能压栈
        while (pointer == data.length) {
            try {
                // 等待,直到有数据出栈
                this.wait();
            } catch (InterruptedException e) {
            }
        }
        // 通知其他线程把数据出栈
        this.notify();
        // 数据压栈
        data[pointer] = c;
        // 指针向上移动
        pointer++;
    }
    
    // 出栈方法,加上互斥锁
    public synchronized char pop() {
        // 堆栈无数据,不能出栈
        while (pointer == 0) {
            try {
                // 等待其他线程把数据压栈
                this.wait();
            } catch (InterruptedException e) {
            }
        }
        // 通知其他线程压栈
        this.notify();
        // 指针向下移动
        pointer--;
        // 数据出栈
        return data[pointer];
    }
}

10行压栈方法push(),同步方法,在该方法中首先判断是否堆栈已满,如果已满不能压栈,调用this.wait()让当前线程进入对象等待状态中。如果堆栈未满,程序会往下运行调用this.notify()唤醒对象等待队列中的一个 线程。

调用:

public class HelloWorld {
    
    public static void main(String args[]) {
        
        Stack stack = new Stack();
        
        // 下面的消费者和生产者所操作的是同一个堆栈对象stack
        // 生产者线程
        Thread producer = new Thread(() -> {
            char c;
            for (int i = 0; i < 10; i++) {
                // 随机产生10个字符
                c = (char) (Math.random() * 26 + 'A');
                // 把字符压栈
                stack.push(c);
                // 打印字符
                System.out.println("生产: " + c);
                try {
                    // 每产生一个字符线程就睡眠
                    Thread.sleep((int) (Math.random() * 1000));
                } catch (InterruptedException e) {
                }
            }
        });
        
        // 消费者线程
        Thread consumer = new Thread(() -> {
            char c;
            for (int i = 0; i < 10; i++) {
                // 从堆栈中读取字符
                c = stack.pop();
                // 打印字符
                System.out.println("消费: " + c);
                try {
                    // 每读取一个字符线程就睡眠
                    Thread.sleep((int) (Math.random() * 1000));
                } catch (InterruptedException e) {
                }
            }
        });
        producer.start(); // 启动生产者线程
        consumer.start(); // 启动消费者线程
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值