# 技术栈知识点巩固——Java并发、多线程


文章目录

开发中锁的使用


synchronized 的实现原理以及锁优化

实现原理

Synchronized通过一个monitor的对象来完成,在进入该方法之前先获取相应的锁,锁的计数器加1,方法结束后计数器-1,如果获取失败就阻塞,直到该锁被释放。直到monitor的进入数为0,再重新尝试获取monitor的所有权。

锁优化

  • 锁消除:当资源不存在竞争问题的时候,可以取消锁。例如 没有必要使用一些线程安全的对象,没有必要加一些多余的锁。
  • 锁粗化:锁粗化是虚拟机对另一种极端情况的优化处理,通过扩大锁的范围,避免反复加锁和释放锁。例如:for循环中的加锁操作可以放在for循环外面进行加锁操作。
  • 自旋锁:让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。怎么等待呢?执行一段无意义的循环即可(自旋)。
  • 重量级锁:监视器锁(Monitor)依赖于底层的操作系统的MutexLock来实现的,依赖于操作系统MutexLock所实现的锁我们称之为 重量级锁。

AQS(AbstractQueuedSynchronizer)

  • 同步队列:双向链表,里面储存的是处于等待状态的线程,正在排队等待唤醒去获取锁。
  • 条件队列:单向链表,里面储存的也是处于等待状态的线程,这些线程唤醒的结果是加入到了同步队列的队尾。
  • AQS所做的就是管理这两个队列里面线程之间的等待状态——唤醒的工作。

AQS组件,实现原理

  • 图片来自:https://cloud.tencent.com/developer/article/1710265

在这里插入图片描述


CAS

  • CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。

CAS 有什么缺陷,如何解决?

  • 线程t1将它的值从A变为B,再从B变为A。同时有线程t2要将值从A变为C。但CAS检查的时候会发现没有改变,但是实质上它已经发生了改变 。可能会造成数据的缺失。

synchronized和ReentrantLock的区别

共同点

  • 可重入,同一线程可以多次获得同一个锁。
  • 保证共享对象的可见性、互斥性。
  • 都可以协调多线程对共享对象、变量的访问。

不同

  • synchronized 代码执行完后系统会自动让线程释放对锁的占用。
  • ReentrantLock则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象
  • synchronized是不可中断类型的锁。ReentrantLock则可以中断。
  • synchronized为非公平锁 ReentrantLock则即可以选公平锁也可以选非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁。
  • synchronzied锁的是对象,ReentrantLock锁的是线程。

公平锁、非公平锁

  • 公平锁与非公平锁的差异主要在获取锁:公平锁就相当于买票,后来的人需要排到队尾依次买票,不能插队。而非公平锁则没有这些规则,是抢占模式,每来一个人不会去管队列如何,直接尝试获取锁。
  • ReentrantLock公平锁和非公平锁:
static final class NonfairSync extends Sync {}
static final class FairSync extends Sync {}

可重入锁

  • 可重入就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁。
  • 加锁时判断锁是否已经被获取,如果已经被获取,则判断获取锁的线程是否是当前线程。如果是当前线程,则给获取次数加1。如果不是当前线程,则需要等待。
  • 释放锁时,需要给锁的获取次数减1,然后判断,次数是否为0了。如果次数为0了,则需要调用锁的唤醒方法,让锁上阻塞的其他线程得到执行的机会。
  • ReentrantLocksynchronized都是可重入锁。

独享锁、共享锁

  • 独享锁:该锁每一次只能被一个线程所持有。ReentrantLock 是互斥锁,ReadWriteLock 中的写锁是互斥锁
  • 共享锁 :该锁可被多个线程共有。SemaphoreCountDownLatch 是共享锁,ReadWriteLock 中的读锁是共享锁。

偏向锁、轻量级锁、重量级锁

  • 偏向锁:偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

  • 轻量级:轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

  • 重量级锁:重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。


Lock使用

private ReentrantLock lock = new ReentrantLock();
public void run() {
    lock.lock();
    try {
        //TODO
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}

ReentrantLock实现原理

  • ReentrantLock()
private final Sync sync;
public ReentrantLock() {
    sync = new NonfairSync();
}

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
  • ReentrantLock 就是一个普通的类,它是基于AQS来实现的。是一个重入锁:一个线程获得了锁之后仍然可以反复的加锁,不会出现自己阻塞自己的情况。

ReentrantLock获取锁的过程

  • ReentrantLock先通过CAS尝试获取锁。
  • 如果此时锁已经被占用,该线程加入AQS队列并wait()
  • 当前驱线程的锁被释放,挂在CLH队列为首的线程就会被notify(),然后继续CAS尝试获取锁,此时:非公平锁,如果有其他线程尝试lock(),有可能被其他刚好申请锁的线程抢占。公平锁,只有在CLH队列头的线程才可以获取锁,新来的线程只能插入到队尾。

同步方法和同步代码块的区别是什么?

  • 同步方法:有synchronized关键字修饰的方法,关键字 修饰方法。
  • 同步代码块: 有synchronized关键字修饰的语句块,修饰代码块。

Lock接口比synchronized块的优势是什么

  • lock接口在多线程和并发编程中最大的优势是它们为读和写分别提供了锁
  • 读锁
private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();

public void read() {
    ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
    try {
        // 加读锁
        readLock.lock();
        logger.info("读锁加锁成功!......");
        Thread.sleep(1000);
        logger.info("===> num:{}", num);
    } catch (Exception e) {
        logger.error("Error Occur:{}", e.getMessage(), e);
    } finally {
        // 释放锁
        readLock.unlock();
    }
}

volatile

  • volatile让变量每次在使用的时候,都从主存中取。
  • 禁止指令重排序:解决单例双重锁乱序的问题。
  • synchronized 关键字可以修饰方法或者以同步块的形式来进行使用,它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中,它保证了线程对变量访问的可见性和互斥性。

Java 中能创建 volatile数组吗

  • Java 中可以创建 volatile 类型数组,不过只是一个指向数组的引用,而不是整个数组。
// volatile 数组
volatile int[] num = {1, 2, 3};

并发与并行

  • 并发:是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行。
  • 并行:当系统有一个以上CPU时,当一个CPU执行一个进程时,另一个CPU可以执行另一个进程,两个进程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行。

继承Thread和实现Runnable方式创建线程有什么区别

  • Thread实现了Runnable接口。
  • java只能单继承,因此如果是采用继承Thread的方法,那么在以后进行代码重构的时候可能会遇到问题
  • 如果一个类继承Thread,则不适合资源共享。但是如果实现了Runable接口的话,则很容易的实现资源共享。

保证线程的执行顺序


Runnable和 Callable

  • Callable的核心是call方法,允许返回值,runnable的核心是run方法,没有返回值。
  • call方法可以抛出异常,但是run方法不行。
public class DoOneCallable implements Callable<Object> {

    private static final Logger logger = LoggerFactory.getLogger(DoOneCallable.class);

    @Override
    public Object call() throws Exception {
        try {
            logger.info("test Thread!");
            // 该异常可以在主线程中拿到
            return 4 / 0;
        } catch (Exception e) {
            throw e;
        }
    }
}

怎么预防死锁?死锁四个必要条件

  • 当资源的使用是互斥的、资源占用后不能释放、循环等待请求资源、当进程请求的资源没有满足又去请求但是没有释放,也不能请求到资源这四种情况会发生死锁。
  • 通过破坏发生死锁的条件预防死锁的产生。

FutureTask


CountDownLatch与CyclicBarrier 区别

  • CountDownLatch : 一个线程(或者多个), 等待另外N个线程完成某个事情之后才能执行。
  • CyclicBarrier : N个线程相互等待,任何一个线程完成之前,所有的线程都必须等待。
  • 使用方法见:https://blog.csdn.net/qq_37248504/article/details/110502016

ThreadLocal原理,使用注意点,应用场景有哪些?

原理

  • 当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。

注意场景

  • 用完之后就调用remove()进行释放内存,防止内存泄漏。

场景

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

ThreadLocal如何解决Hash冲突?

  • HashMap的最大的不同在于,ThreadLocalMap结构非常简单,没有next引用,也就是说ThreadLocalMap中解决Hash冲突的方式并非链表的方式,而是采用线性探测的方式,所谓线性探测,就是根据初始keyhashcode值确定元素在table数组中的位置,如果发现这个位置上已经有其他key值的元素被占用,则利用固定的算法寻找一定步长的下个位置,依次判断,直至找到能够存放的位置。

ThreadLocal 的内存泄露

  • 每一个Thread维护一个ThreadLocalMapkey为使用弱引用的ThreadLocal实例,value为线程变量的副本。
  • 如果一个ThreadLocal不存在外部强引用时,ThreadLocal势必会被GC回收,这样就会导致ThreadLocalMapkeynull, 而value还存在着强引用,只有thead线程退出以后,value的强引用链条才会断掉。
  • 如果当前线程再迟迟不结束的话,这些keynullEntryvalue就会一直存在一条强引用链。

为什么ThreadLocalMap 的 key是弱引用,设计理念是?

  • hreadLocalMapkey为强引用回收ThreadLocal时,因为ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
  • ThreadLocalMapkey为弱引用回收ThreadLocal时,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。当keynull,在下一次ThreadLocalMap调用set(),get(),remove()方法的时候会被清除value值。

Fork、Join框架

  • 用于并行执行任务的框架, 是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。

调用start()方法、调用run()方法

  • 通过调用Thread类的start()方法来启动一个线程,这时此线程处于就绪(可运行)状态。当线程调用了start( )方法后,一旦线程被CPU调度,处于运行状态,那么线程才会去调用这个run()方法。
  • run()方法只是类的一个普通方法而已,如果直接调用Run方法,程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行。
public class ThreadDemoTest {

    /**
     * 调用start方法方可启动线程,而run方法只是thread的一个普通方法调用,还是在主线程里执行
     */

    private static final Logger logger = LoggerFactory.getLogger(ThreadDemoTest.class);

    @Test
    public void test() throws InterruptedException {
        DemoOneThread demoOneThread = new DemoOneThread();
        // 调用 run() 会打印出主线程的信息
        demoOneThread.run();

        // 调用 start() 会打印出新启的线程的信息
        new Thread(demoOneThread).start();

        Thread.sleep(2000);
        Thread thread = Thread.currentThread();
        logger.info("当前线程信息:{}-{}", thread.getId(), thread.getName());
    }

}

class DemoOneThread implements Runnable {

    private static final Logger logger = LoggerFactory.getLogger(DemoOneThread.class);

    @Override
    public void run() {
        Thread thread = Thread.currentThread();
        logger.info("当前线程信息:{}-{}", thread.getId(), thread.getName());
    }
}
  • 打印线程信息如下:
    在这里插入图片描述

如果线程过多,会怎样?

  • CPU飙升
  • 内存溢出
  • 性能不稳定

信号量(Semaphore)

  • 通常用于那些资源有明确访问数量限制的场景,常用于限流 。
public class SemaphoreTest {

    public static void main(String[] args) {
        // 每次 2 个 线程 acquire
        Semaphore semaphore = new Semaphore(2);
        for (int i = 0; i < 5; i++) {
            SemaphoreThread semaphoreThread = new SemaphoreThread(semaphore);
            new Thread(semaphoreThread).start();
        }
    }

}

class SemaphoreThread implements Runnable {

    private static final Logger logger = LoggerFactory.getLogger(SemaphoreThread.class);

    private Semaphore semaphore;

    public SemaphoreThread(Semaphore semaphore) {
        this.semaphore = semaphore;
    }
    @Override
    public void run() {
        try {
            semaphore.acquire();
            logger.info(Thread.currentThread().getId() + " acquire");
            Thread.sleep(1000);
            semaphore.release();
            logger.info(Thread.currentThread().getId() + " release ");

        } catch (Exception e) {
            logger.error("Error Occur:{}", e.getMessage(), e);
        }

    }
}

Condition接口及其实现原理

  • Condition接口提供了与Object阻塞(wait())与唤醒(notify()notifyAll())相似的功能,只不过Condition接口提供了更为丰富的功能,如:限定等待时长等。Condition需要与Lock结合使用,需要通过锁对象获取Condition

为什么要用线程池?

  • 可以重用线程,减少创建和销毁线程带来的消耗。
  • 管理手动创建的线程,减少开销。
  • 提高响应速度,对线程进行统一的分配和监控。

Java的线程池内部机制,参数作用

@Test
public void test1() {
    // Cpu 核数
    int cpuNum = Runtime.getRuntime().availableProcessors();
    // 核心线程数:线程池中默认可用的线程数
    int coreSize = cpuNum;
    // 最大线程数:当阻塞队列中放满之后,使用最大线程数中的线程
    int maxSize = 2 * coreSize + 1;

    // 有界的阻塞队列
    ArrayBlockingQueue arrayBlockingQueue = new ArrayBlockingQueue(20);
    ThreadFactory threadFactory = Executors.defaultThreadFactory();

    // 当没有可用的线程的时候,在来任务则执行策略
    ThreadPoolExecutor.AbortPolicy abortPolicy = new ThreadPoolExecutor.AbortPolicy();
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(coreSize,
                                                                   maxSize,
                                                                   THREAD_TIME_OUT,
                                                                   TimeUnit.SECONDS,
                                                                   arrayBlockingQueue,
                                                                 threadFactory,
                                                                   abortPolicy
                                                                  );

}

线程池都有哪几种工作队列?

  • ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序
  • LinkedBlockingQueue一个基于链表结构的阻塞队列,此队列按FIFO (先进先出) 排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列
  • SynchronousQueue:不存储元素的阻塞队列。每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。
  • PriorityBlockingQueue:具有优先级的无限阻塞队列。

线程池的拒绝策略

​ 当提交的任务数大于workQueue.size() + maximumPoolSize就会触发线程池的拒绝策略。

  • AbortPolicyThreadPoolExecutor中默认的拒绝策略就是AbortPolicy,直接抛出异常。
  • CallerRunsPolicy :使用该策略时线程池饱和后将由调用线程池的主线程自己来执行任务,因此在执行任务的这段时间里主线程无法再提交新任务,从而使线程池中工作线程有时间将正在处理的任务处理完成。
  • DiscardPolicy :不做任何处理直接抛弃任务。
  • DiscardOldestPolicy :先将阻塞队列中进入最早的任务丢弃,再尝试提交任务。

线程池如何调优,最大数目如何确认?

  • NCPU的个数。
  • 如果是CPU密集型应用,则线程池大小设置为N+1
  • 如果是IO密集型应用,则线程池大小设置为2N+1

java并发包concurrent及常用的类


线程池中 submit()和 execute()方法有什么区别?

  • execute()方法只能接收实现Runnable接口类型的任务。

  • submit()方法则既可以接收Runnable类型的任务,也可以接收Callable类型的任务。

  • execute()的返回值是void,线程提交后不能得到线程的返回值。

  • submit()的返回值是Future


说说几种常见的线程池及使用场景?

  • newFixedThreadPool (固定数目线程的线程池)
  • newCachedThreadPool(可缓存线程的线程池)
  • newSingleThreadExecutor(单线程的线程池)
  • newScheduledThreadPool(定时及周期执行的线程池)
  • 详见博客:https://blog.csdn.net/qq_37248504/article/details/107850480

使用无界队列的线程池会导致内存飙升吗?

  • 使用无界队列的线程池会导致内存飙升 , newFixedThreadPool使用了无界的阻塞队列LinkedBlockingQueue,如果线程获取一个任务后,任务的执行时间比较长,会导致队列的任务越积越多,导致机器内存使用不停飙升, 最终导致OOM

为什么阿里发布的 Java开发手册中强制线程池不允许使用 Executors 去创建?

  • FixedThreadPool SingleThreadPool允许请求队列长度为Integer.MAX_VALUE可能会堆积大量请求从而导致OOM

  • CachedThreadPool ScheduledThreadPool允许创建线程数量为Integer.MAX_VALUE可能会创建大量线程从而导致OOM


如何保证多线程下 i++ 结果正确?

  • 使用Lock
  • 使用Synchronized

Thread.sleep(1000)

  • Thread.sleep()Thread类的一个静态方法,使当前线程休眠,进入阻塞状态(暂停执行),如果线程在睡眠状态被中断,将会抛出IterruptedException中断异常。

说说线程的生命周期和状态?

  • 图片来自:https://blog.csdn.net/houbin0912/article/details/77969563

img

  • 新建状态
  • 就绪状态
  • 阻塞状态
  • 运行状态
  • 死亡状态

Java 内存模型

  • 每个线程都有自己的工作内存,线程都有自己的共享变量副本。
  • 在主内存中有共享变量。

怎么实现所有线程在等待某个事件的发生才会去执行?

  • 使用CountDownLatch计数器机制。
  • 使用Semaphore信号量机制。

wait()、notify()、suspend()、resume()

  • wait()使当前线程阻塞,前提是 必须先获得锁,一般配合synchronized 关键字使用,即,一般在synchronized 同步代码块里使用 wait()、notify、notifyAll() 方法。
  • notify方法只唤醒一个等待(对象的)线程并使该线程开始执行。
  • notifyAll 会唤醒所有等待(对象的)线程。
  • suspend方法使得线程进入阻塞状态,并且不会自动恢复,必须其对应的resume方法被调用,才能使得线程重新进入可执行状态。

一个线程如果出现了运行时异常会怎么样

  • 如果该异常被捕获或抛出,则程序继续运行。
  • 如果异常没有被捕获该线程将会停止执行。

生产者消费者模型的作用是什么

  • 为了达到生产者和消费者生产数据和消费数据之间的平衡,需要一个缓冲区用来存储生产者生产的数据,所以就引入了生产者-消费者模式。

用Java写代码来解决生产者——消费者


Java中用到的线程调度算法是什么?

  • 分时调度模型是指让所有的线程轮流获得 cpu 的使用权,并且平均分配每个线程占用的 CPU 的时间片这个也比较好理解。
  • Java虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU

Java中你怎样唤醒一个阻塞的线程?

  • suspendresume
  • waitnotify
  • awaitsingal
  • parkunpark

什么是不可变对象,它对写并发应用有什么帮助?

  • 对象创建之后其状态就不可改变。对象所有域都是final类型。

  • java中可以使用Collections工具类中的方法得到不可变对象。

@Test
public void testOne() {
    Map<String, String> map = new HashMap<>();
    map.put("one", "one");
    map.put("two", "one");
    map.put("three", "one");
    Map<String, String> mp = Collections.unmodifiableMap(map);
    // 会报错
    mp.put("one", "two");
}

你是如何调用 wait()方法的?使用 if 块还是循环?

  • wait() 方法应该在循环调用,因为当线程获取到 CPU 开始执行的时候,其他条件可能还没有满足,所以在处理前,循环检测条件是否满足会更好。
class ProducerRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            try {
                Thread.sleep(3000);
            } catch (Exception e) {
                e.printStackTrace();
            }
            synchronized (LOCK) {
                while (count == FULL) {
                    try {
                        LOCK.wait();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
                count++;
                System.out.println(Thread.currentThread().getName() + "生产者生产,目前总共有" + count);
                LOCK.notifyAll();
            }
        }
    }
}

在多线程环境下,SimpleDateFormat是线程安全的吗

  • SimpleDateFormat 类并不是线程安全的,但在单线程环境下是没有问题的。
  • 下面代码有问题:
public class ThreadSafetyTest {

    private static final Logger logger = LoggerFactory.getLogger(ThreadSafetyTest.class);

    private SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    @Test
    public void test() {
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                try {
                    String dateString = simpleDateFormat.format(new Date());
                    Date parse = simpleDateFormat.parse(dateString);
                    logger.info(simpleDateFormat.format(parse));
                } catch (Exception e) {
                    logger.error("Error Occur:{}", e.getMessage(), e);
                }
            }).start();
        }
    }
}

为什么Java中 wait 方法需要在 synchronized 的方法中调用?

  • Java 中的 synchronized 方法或 synchronized 块调用 Java 中的 wait(),notify()notifyAll() 方法来避免:Java 会抛出 IllegalMonitorStateException

怎么检测一个线程是否持有对象监视器

  • Thread类提供了一个holdsLock(Object obj)方法,当且仅当对象obj的监视器被某条线程持有的时候才会返回true

什么情况会导致线程阻塞

  • 在某一时刻某一个线程在运行一段代码的时候,这时候另一个线程也需要运行,但是在运行过程中的那个线程执行完成之前,另一个线程是无法获取到CPU执行权的(调用sleep方法是进入到睡眠暂停状态,但是CPU执行权并没有交出去,而调用wait方法则是将CPU执行权交给另一个线程),这个时候就会造成线程阻塞。

如何在两个线程间共享数据

  • 如果每个线程执行的代码相同,可以使用同一个Runnable对象,这个Runnable对象中有那个共享数据,例如,卖票系统就可以这么做。

  • 如果每个线程执行的代码不同,这时候需要用不同的Runnable对象,例如上面提及的生产者消费者模式。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

全栈程序员

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

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

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

打赏作者

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

抵扣说明:

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

余额充值