入门进阶之JAVA多线程

多线程经典面试题

  1. 多进程的方式也可以实现并发,为什么我们要使用多线程?
    多进程方式确实可以实现并发,但使用多线程,有以下几个好处:
    • 进程间的通信比较复杂,而线程间的通信比较简单,通常情况下,我们需要使用共享资源,这些资源在线程间的通信比较容易。
    • 进程是重量级的,而线程是轻量级的,故多线程方式的系统开销更小。
  2. 进程和线程的区别是什么?
    进程是一个独立的运行环境,而线程是在进程中执行的一个任务,它们两个本质的区别是否单独占有内存地址空间及其他系统资源(比如I/O)
    • 进程单独占有一定的内存地址空间,所以进程间存在内存隔离,数据是分开的,数据共享复杂但是数据同步简单,各个进程之间互不干扰;而线程共享所属进程占有的内存地址空间和资源,数据共享简单,但是同步复杂。
    • 进程单独占有一定的内存地址空间,一个进程出现问题不会影响其他进程,不影响主进程的稳定性,可靠性高;一个线程崩溃可能影响整个程序的稳定性,可靠性较低。
    • 进程单独占有一定的内存地址空间,进程的创建和销毁不仅需要保存寄存器和栈信息,还需要资源的分配回收以及页调度,开销巨大;线程只需要保存寄存器和栈信息,开销较小。
    • 重 要 的 区 别 : \color{orange}{重要的区别:} : 进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位,即CPU分配时间的单位。
  3. 实现一个自定义的线程类,可以有继承Thread类或实现Runnable接口这两种方式,他们之间有什么优劣呢?
    • 由于Java “单继承,多实现” 的特性,Runnable接口使用起来比Thread更灵活。
    • Runnable 接口出现更符合面向对象,将线程单独进行对象的封装。
    • Runnable 接口出现,降低了线程对象和线程任务的耦合性。
    • 如果使用线程时不需要使用Thread类的诸多方法,显然使用Runnable接口更为轻量。
    • t i p : \color{orange}{tip:} tip: 所以通常优先使用“实现 Runnable 接口” 这种方式来自定义线程类。
  4. FutureTask 类有什么用?为什么要有一个 FutureTask 类?
    Future 只是一个接口,而它里面的 cancel、get、isDone 等方法要自己实现起来都是非常复杂的。所以JDK提供了一个 FutureTask 类来供我们使用。
  5. 能否采用线程优先级来指定线程执行的先后顺序?
    不能,Java中的优先级不是特别可靠,Java程序中对线程所设置的优先级只是给操作系统一个建议,操作系统不一定会采纳,而真正的调用顺序,是由操作系统的线程调度算法决定的。
  6. 一个线程存在于一个线程组中,当线程和线程组的优先级不一致的时候将会怎么样呢?
    如果某个线程优先级大于线程所在线程组的最大优先级,那么该线程的优先级将会失效,取而代之的是线程组的最大优先级。
  7. 反复调用同一个线程的 start() 方法是否可行?假如一个线程执行完毕(此时处于TERMINATED状态),再次调用这个线程的 start() 方法是否可行?
    查看 start() 的源码后,两个问题的答案都是不可行的,在调用一次 start() 之后,threadStatus 的值会改变(threadStatus != 0), 所以不能再次调用,否则会抛出 IllegalThreadStateException 异常。

基本概念

时间片:CPU为每个进程分配一个时间段,称作它的时间片。

上下文: 是指某一时间点CPU寄存器和程序计数器的内容。

上下文切换:(有时候也称做进程切换或任务切换)是指CPU从一个进程(或线程)切换到另一个进程(或线程)。如果在时间片结束时进程还在运行,则暂停这个进程的运行,并且CPU分配给另一个进程,这个过程就叫做上下文交换。

线程:指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

进程:一个进程包括由操作系统分配的内存空间,包含一个或多个线程,一个线程不能独立的存在,它必须是进程的一部分,一个进程一直运行,直到所有的非守护线程都结束运行后才能结束。

t i p : \color{orange}{tip:} tip: 多线程是多任务的一种特别的形式,但多线程使用了更小的资源开销。

t i p : \color{orange}{tip:} tip: 上下文切换通常是计算密集型的,意味着此操作会消耗大量的CPU时间,故线程也不是越多越好,如何减少系统中上下文切换次数,是提升多线程性能的一个重点课题。

线程的生命周期

线程是一个动态执行的过程,它也有一个从产生到死亡的过程
线程的生命周期

Java 线程的 6 个状态

// Thread.State 源码
public enum State {
    NEW,
    RUNNABLE,
    BLOCKED,
    WAITING,
    TIMED_WAITING,
    TERMINATED;
}

新建状态 (NEW)

使用 new 关键字和 Thread 类或其子类建立一个线程对象后,该线程对象就处于新建状态,它保持这个状态直到程序 start() 这个线程。

处于 NEW 状态的线程此时尚未启动。这里的尚未启动指的是还没有调用 Thread 实例的start() 方法。

// example:
private void testStateNew() {
    Thread thread = new Thread(() -> {});
    System.out.println(thread.getState()); // 输出 NEW 
}
// start() 源码
public synchronized void start() {
    if (threadStatus != 0)
        throw new IllegalThreadStateException();

    group.add(this);

    boolean started = false;
    try {
        start0();
        started = true;
    } finally {
        try {
            if (!started) {
                group.threadStartFailed(this);
            }
        } catch (Throwable ignore) {

        }
    }
}

t i p : \color{orange}{tip:} tip: 在 start() 内部, 有一个 threadStatus 变量,如果它不等于0,调用 start() 会直接抛出异常。

// Thread.getState方法源码:
public State getState() {
    // get current thread state
    return sun.misc.VM.toThreadState(threadStatus);
}

// sun.misc.VM 源码:
public static State toThreadState(int var0) {
    if ((var0 & 4) != 0) {
        return State.RUNNABLE;
    } else if ((var0 & 1024) != 0) {
        return State.BLOCKED;
    } else if ((var0 & 16) != 0) {
        return State.WAITING;
    } else if ((var0 & 32) != 0) {
        return State.TIMED_WAITING;
    } else if ((var0 & 2) != 0) {
        return State.TERMINATED;
    } else {
        return (var0 & 1) == 0 ? State.NEW : State.RUNNABLE;
    }
}

t i p : \color{orange}{tip:} tip: 查看源码,结论是同一个线程不能多次调用 start() 方法。

就绪状态和运行状态 ( RUNNABLE )

当线程对象调用了start()方法之后,该线程就进入了就绪状态。就绪状态的线程处于就绪队列中,要等待JVM里线程调度器的调度。

如果就绪状态的线程获取CPU资源,就可以执行 run(), 此时线程便处于运行状态。处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态。

RUNNABLE 状态,表示线程在Java虚拟机中运行,也有可能在等待CPU分配资源。

阻塞状态 (BLOCKED)

如果一个线程执行了sleep(睡眠)、suspend(挂起)等方法。失去所占用的资源之后,该线程就从运行状态进入阻塞状态。在睡眠时间已到或获得设备资源后可以重新进入就绪状态。可以分为三种:

  • 等待阻塞:运行状态中的线程执行 wait() 方法,使线程进入等待阻塞状态。
  • 同步阻塞:线程在获取 synchronized 同步锁失败(因为同步锁被其他线程占用)。
  • 其他阻塞:通过调用线程的 sleep() 或 join() 发出了 I/O 请求时,线程就会进入到阻塞状态,当 sleep() 状态超时,join() 等待线程终止或超时,或者 I/O 处理完毕,线程重新转入就绪状态。

example:

假如今天你下班后准备去食堂吃饭。你来到食堂仅有的一个窗口,发现前面已经有个人在窗口前了,此时你必须等前面的人从窗口离开才行。

假设你是线程t2,你前面的那个线程是t1,此时t1占有了锁(食堂的唯一窗口),t2正在等待锁的释放,所以t2就处于 BLOCKED 状态。

等待状态 (WAITING)

等待状态,处于等待状态的线程变成RUNNABLE状态需要其他线程唤醒。
调用如下3个方法会使线程进入等待状态:

  • Object.wait(): 使当前线程处于等待状态直到另一个线程唤醒;
  • Thread.join(): 等待线程执行完毕,底层调用的是Object实例的wait方法;
  • LockSupport.park(): 除非获得调用许可,否则禁用当前线程进行线程调度;

example:

你等待好几分钟现在终于轮到你了,突然你们有一个“不懂事”的经理突然来了。你看到他你就有一种不祥的预感,果然,他是来找你的。

他把你拉到一旁叫你待会儿再吃饭,说他下午要去作报告,赶紧来找你了解一下项目的情况。你心里虽然有一万个不愿意但是你还是从食堂窗口走开了。

此时,假设你还是线程t2,你的经理是线程t1。虽然你此时都占有锁(窗口)了,“不速之客”来了你还是要放弃锁。此时你t2的状态就是WAITING。然后经理t1获得锁,进入RUNNABLE状态。

要是经理t1不主动唤醒你t2(notify、notifyAll、...),可以说你t2只能一直等待了。

超时等待状态 (TIMED_WAITING)

超时等待状态,线程等待一个具体的时间,时间到后会被自动唤醒。

调用以下方法会让线程进入超时等待状态:

  • Thread.sleep(): 使当前线程睡眠指定时间;
  • Object.wait(long timeout): 线程休眠指定时间,等待期间可以通过notify()、notifyAll()唤醒;
  • Thread.join(long millis): 等待当前线程最多执行millis毫秒,如果millis为0,则会一直执行;
  • LockSupport.parkNanos(long nanos): 除非获得调用许可,否则禁用当前线程进行线程调度指定时间;
  • LockSupport.parkUntil(long deadline): 同上,也是禁止线程进行调度指定时间。

example:

到了第二天中午,又到了饭点,你还是到了窗口前。

突然间想起你的同事叫你等他一起,他说让你等他十分钟他改个bug。

好吧,你说那你就等等吧,你就离开了窗口,很快十分钟过去了,你见他还没来,你想都等了这么久了还不来,那你还是先去吃饭好了。

这时你还是线程t1,你改bug的同事是线程t2,t2让t1等待了指定时间,此时t1等待期间就属于 TIMED_WAITING 状态。

死亡状态 (TERMINATED)

一个运行状态的线程完成任务或在其他终止条件发生时,该线程就切换到终止状态。

线程状态转换

线程的状态转换

创建线程的方法

Java 提供了三种创建线程的方法

  • 通过继承Thread 类本身
  • 通过实现Runnable接口
  • 通过 Callable 和 Future 创建线程

继承Thread类

example:

public class Demo {
    public static class MyThread extends Thread {
        @Override
        public void run() {
            System.out.println("MyThread");
        }
    }

    public static void main(String[] args) {
        Thread myThread = new MyThread();
        myThread.start();
    }
}

调用了start方法后,虚拟机会为我们先创建一个线程,然后等到这个线程第一次得到时间片时,再调用run方法。

t i p : \color{orange}{tip:} tip: 注意不可多次调用start() 方法。在第一次调用start()方法后,再次调用start()方法会抛出IllegalThreadStateException异常。

实现Runnable接口

// Runnable接口(JDK 1.8 +):

@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

可以看到Runnable接口是一个函数式接口,这意味着我们可以使用Java 8 的函数式编程来简化代码。

example:

public class Demo {
    public static class MyThread implements Runnable {
        @Override
        public void run() {
            System.out.println("MyThread");
        }
    }

    public static void main(String[] args) {

        new Thread(new MyThread()).start();

        // Java 8 函数式编程,可以省略MyThread类
        new Thread(() -> {
            System.out.println("Java 8 匿名内部类");
        }).start();
    }
}

Callable、Future 与 FutureTask

通常来说,我们使用 Runnable 和 Thread 来创建一个新的线程。但是它们有个弊端,就是run方法是没有返回值的。而有时侯我们希望开启一个线程去执行一个任务,并且这个任务执行完成后有一个返回值。

JDK提供了 Callable 接口与 Future 接口为我们解决这个问题,这就是所谓的“异步”模型。

Callable 接口

Callable 和 Runnable 类似,同样只有一个函数式接口。不同的是,Callable 提供的方法是有返回值的,而且支持泛型。

// Callable 接口

@FunctionalInterface
public interface Callable<V> {
    V call() throws Exception;
}

t i p : \color{orange}{tip:} tip: Callable 一般是配合线程池工具 ExecutorService 来使用,ExecutorService 可以使用submit方法来让一个 Callable 接口执行。它会返回一个 Future,使用 Future 的 get 方法可以得到结果。

example:

// 自定义Callable
class Task implements Callable<Integer>{
    @Override
    public Integer call() throws Exception {
        // 模拟计算需要一秒
        Thread.sleep(1000);
        return 2;
    }
    public static void main(String args[]) throws Exception {
        // 使用
        ExecutorService executor = Executors.newCachedThreadPool();
        Task task = new Task();
        Future<Integer> result = executor.submit(task);
        // 注意调用get方法会阻塞当前线程,直到得到结果。
        // 所以实际编码中建议使用可以设置超时时间的重载get方法。
        System.out.println(result.get()); 
    }
}

Future 接口

Future 接口有几个比较简单的方法

// Future 接口

public abstract interface Future<V> {
    public abstract boolean cancel(boolean paramBoolean);
    public abstract boolean isCancelled();
    public abstract boolean isDone();
    public abstract V get() throws InterruptedException, ExecutionException;
    public abstract V get(long paramLong, TimeUnit paramTimeUnit)
            throws InterruptedException, ExecutionException, TimeoutException;
}

cancel 方法是试图取消一个线程的执行

t i p : \color{orange}{tip:} tip: 注意是试图取消,并不一定能取消成功。因为任务可能已完成、已取消、或者一些其他因素不能取消,存在取消失败的可能。Boolean 类型的返回值是“是否取消成功”的意思。参数 paramBoolean 表示是否采用中断的方式取消线程的执行。

所以有时候,为了让任务有能够取消的功能,就是用 Callable 来代替 Runnable。如果为了取消性而使用 Future 但又不提供可用的结果,则可以声明 Future<?> 形式类型,并返回 null 作为底层任务的结果。

FutureTask 类

Future 接口有一个实现类叫 FutureTask。FutureTask 是实现的 RunnableFuture 接口的,而 RunnableFuture 接口同时继承了 Runnable 接口和 Future 接口。

// RunnableFuture 接口

public interface RunnableFuture<V> extends Runnable, Future<V> {
    /**
     * Sets this Future to the result of its computation
     * unless it has been cancelled.
     */
    void run();
}

example:

// 自定义Callable,与上面一样
class Task implements Callable<Integer>{
    @Override
    public Integer call() throws Exception {
        // 模拟计算需要一秒
        Thread.sleep(1000);
        return 2;
    }
    public static void main(String args[]) throws Exception {
        // 使用
        ExecutorService executor = Executors.newCachedThreadPool();
        FutureTask<Integer> futureTask = new FutureTask<>(new Task());
        executor.submit(futureTask);
        System.out.println(futureTask.get());
    }
}

t i p : \color{orange}{tip:} tip: 使用上与 Future 的 example 有一点区别,首先 submit 方法是没有返回值的,这里实际上调用的是 submit(Runnable task) 方法,而 Future 的 example,调用的是 submit(Callable<T> task) 方法。然后,这里使用 FutureTask 的 get 取值,而 Future 的 example 是通过 submit 的方法返回的 Future 去取值。

t i p : \color{orange}{tip:} tip: 在很多高并发的环境下,有可能 Callable 和 Future 会创建多次。FutureTask 能够在高并发环境下确保任务只执行一次。

FutureTask 的几个状态

/**
  *
  * state可能的状态转变路径如下:
  * NEW -> COMPLETING -> NORMAL
  * NEW -> COMPLETING -> EXCEPTIONAL
  * NEW -> CANCELLED
  * NEW -> INTERRUPTING -> INTERRUPTED
  */
private volatile int state;
private static final int NEW          = 0;
private static final int COMPLETING   = 1;
private static final int NORMAL       = 2;
private static final int EXCEPTIONAL  = 3;
private static final int CANCELLED    = 4;
private static final int INTERRUPTING = 5;
private static final int INTERRUPTED  = 6;

state 表示任务的运行状态,初始状态为 NEW。运行状态只会在set、setExecution、cancel 方法中终止。COMPLETING、INTERRUPTING 是任务完成后的瞬时状态。

创建线程的三种方式的对比

  1. 采用实现 Runnable、Callable 接口的方式创建多线程时,线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。
  2. 使用继承 Thread 类的方式创建多线程时,编写简单,如果需要访问当前线程,则无需使用 Thread.currentThread() 方法,直接使用 this 即可获得当前线程。

线程组和线程的优先级

总结来说:线程组是一个树状结构,每个线程组下面可以有多个线程或者线程组,线程组可以起到统一控制线程的优先级和检查线程的权限的作用。

线程组 (ThreadGroup)

Java 中 ThreadGroup 来表示线程组,我们可以使用线程组对线程进行批量控制。

ThreadGroup 和 Thread 的关系如同字面意思一样简单粗暴,每个 Thread 必然存在于一个 ThreadGroup 中,Thread 不能独立于 ThreadGroup 存在。执行 main() 方法线程的名字是 main, 如果在 new Thread 时没有显式指定,那么默认将父线程(当前执行new Thread的线程)线程组设置为自己的线程组。

example:

public class Demo {
    public static void main(String[] args) {
        Thread testThread = new Thread(() -> {
            System.out.println("testThread当前线程组名字:" +
                    Thread.currentThread().getThreadGroup().getName());
            System.out.println("testThread线程名字:" +
                    Thread.currentThread().getName());
        });

        testThread.start();
    System.out.println("执行main所在线程的线程组名字: " + Thread.currentThread().getThreadGroup().getName());
        System.out.println("执行main方法线程名字:" + Thread.currentThread().getName());
    }
}

t i p : \color{orange}{tip:} tip: ThreadGroup 管理着它下面的Thread,ThreadGroup 是一个标准的向下引用的树状结构,这样设计的原因是:防止“上级”线程被“下级”线程引用而无法有效地被GC回收。

线程的优先级

每一个Java线程都有一个优先级,这样有助于操作系统确定线程的调度顺序。

Java 线程的优先级是一个整数,其取值范围是 1(Thread.Min_Priority)-10(Thread.Max_Priority)。
默认情况下,每一个线程都会分配一个优先级 Normal_Priority(5)。
通常情况下,高优先级的线程将会比低优先级的线程有更高的几率得到执行。但是,优先级不能保证线程执行的顺序,而且非常依赖于平台。可以使用 Thread 类的 setPriority() 实例方法来设定线程的优先级。

public class Demo {
    public static void main(String[] args) {
        Thread a = new Thread();
        System.out.println("我是默认线程优先级:"+a.getPriority());
        Thread b = new Thread();
        b.setPriority(10);
        System.out.println("我是设置过的线程优先级:"+b.getPriority());
    }
}

Java 提供一个线程调度器来监视和控制处于RUNNABLE状态的线程,线程的调度策略采用抢占式,优先级高的线程比优先级低的线程会有更大的几率优先执行。在优先级相同的情况下,按照“先到先得”的原则。每个 Java 程序都有一个默认的主线程,就是通过 JVM 启动的第一个线程 main 线程。

还有一种线程称为“守护线程(Daemon)”,守护线程的优先级比较低。

如果某线程是守护线程,那如果所有的非守护线程都结束了,这个守护线程也会自动结束。
应用场景是:当所有非守护线程结束时,结束其余的子线程(守护线程)自动关闭,就免去了还要继续关闭子线程的麻烦。
一个线程默认是非守护线程,可以通过 Thread 的 setDaemon(Boolean on) 来设置。

线程的几个主要的概念

在多线程编程时,你需要了解一下几个概念

  • 线程同步
  • 线程间通信
  • 线程死锁
  • 线程控制:挂起、停止和恢复

线程同步

为什么要线程同步

因为当我们有多个线程要同时访问一个变量或对像时,如果这些线程中既有读又有写操作时,就会导致变量值或对象的状态出现混乱,从而导致程序异常。

实现线程同步的方式

  1. 同步方法
    synchronized 关键字修饰方法,由于Java的每个对像都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就会处于阻塞状态。
    t i p : \color{orange}{tip:} tip: synchronized关键字可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类。
  2. 同步代码块
    即用synchronized修饰的语句块。被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。
    t i p : \color{orange}{tip:} tip: 同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可。
  3. 使用特殊域变量(Volatile)实现线程同步
    每次线程要访问volatile修饰的变量时都是从内存中读取,而不是从缓存中读取,因此每个线程访问到的变量值都是一样的,这样就保证了同步。Volatile关键字为域变量的访问提供了一种免锁机制。使用Volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新,因此每次使用该域就要重新计算,而不是使用寄存器中的值。Volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。
    t i p : \color{orange}{tip:} tip: 因为Volatile不能保证原子操作,因此Volatile不能代替synchronized, 此外Volatile会组织编译器对代码优化。
  4. 使用重入锁实现线程同步
    在javaSE5中新增了一个java.util.concurrent包来支持同步。ReentrantLock类是可重入、互斥、实现了lock接口的锁,它与使用synchronized方法具有相同的基本行为和语义,并且扩展了其能力。
    t i p : \color{orange}{tip:} tip: ReentrantLock类的常用方法有:ReentrantLock(): 创建一个ReentrantLock实例; lock(): 获得锁; unlock(): 释放锁; ReentrantLock()还可以创建公平锁的构造方法,但由于能大幅度降低程序运行效率,不推荐使用。
    t i p : \color{orange}{tip:} tip: 如果synchronized关键字能满足用户的需求,就用synchronized,因为它能简化代码,如果需要更高级的功能,就用ReentrantLock类,此时要注意及时释放锁,否则会出现死锁,通常在finally中释放锁。
  5. 使用局部变量实现线程同步
    ThreadLock的原理:如果使用ThreadLock管理变量,则每一个使用该变量的线程都获得该变量的副本,副本之间相互独立,这样每一个线程都可以随意修改自己的变量副本,而不会对其他线程产生影响。
    t i p : \color{orange}{tip:} tip: ThreadLock与同步机制都是为了解决多线程中相同变量的访问冲突问题,前者采用空间换时间的方法,后者采用时间换空间的方式,各有优劣,各有适用场景。

线程间通信

线程通信的目标是使线程间能够互相发送信号。另一方面,线程通信使线程能够等待其他线程的信号。

线程死锁

多个线程同时被阻塞时,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

java 死锁产生的四个必要条件:

  • 互斥使用,即当资源被一个线程使用(占用)时,别的线程不能使用
  • 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源持有者主动释放
  • 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有
  • 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。

线程控制

一个线程的控制简单包括3种:

  • 开启线程
  • 暂停线程
  • 停止线程

多线程的使用

有效利用多线程的关键是理解程序是并发执行而不是串行执行。
t i p : \color{orange}{tip:} tip: 通过对多线程的使用,可以编写出非常高效的程序,不过请注意,如果你创建太多的线程,程序执行的效率实际上是降低了,而不是提升了。上下文切换开销也很重要,如果你创建了太多的线程,CPU花费在上下文切换的时间将多于执行程序的时间。

参考文献

菜鸟教程 java多线程
RedSpider社区

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

高建伟-joe

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值