《实战 Java 高并发程序设计》笔记——第2章 Java 并行程序基础(二)

声明:

本博客是本人在学习《实战 Java 高并发程序设计》后整理的笔记,旨在方便复习和回顾,并非用作商业用途。

本博客已标明出处,如有侵权请告知,马上删除。

2.3 volatile 与 Java 内存模型(JMM)

之前已经简单介绍了 Java 内存模型(JMM),Java 内存模型都是围绕着原子性、有序性和可见性展开的。大家可以先回顾一下上一章中的相关内容。为了在适当的场合,确保线程间的有序性、可见性和原子性。Java 使用了一些特殊的操作或者关键字来申明、告诉虚拟机,在这个地方,要尤其注意,不能随意变动优化目标指令。关键字 volatile 就是其中之一。

如果你查阅一下英文字典,有关 volatile 的解释,你会得到最常用的解释是 “易变的,不稳定的”。这也正是使用 volatile 关键字的语义。

当你用 volatile 去申明一个变量时,就等于告诉了虚拟机,这个变量极有可能会被某些程序或者线程修改。为了确保这个变量被修改后,应用程序范围内的所有线程都能够 “看到” 这个改动,虚拟机就必须采用一些特殊的手段,保证这个变量的可见性等特点

比如,根据编译器的优化规则,如果不使用 volatile 申明变量,那么这个变量被修改后,其他线程可能并不会被通知到,甚至在别的线程中,看到变量的修改顺序都会是反的。但一旦使用 volatile,虚拟机就会特别小心地处理这种情况。

大家应该对上一章中介绍原子性时,给出的 MultiThreadLong 案例还记忆犹新吧!我想,没有人愿意就这么把数据 “写坏”。那这种情况,应该怎么处理才能保证每次写进去的数据不坏呢?最简单的一种方法就是加入 volatile 申明,告诉编译器,这个 long 型数据,你要格外小心,因为他会不断地被修改。

下面的代码片段显示了 volatile 的使用,限于篇幅,这里不再给出完整代码:

在这里插入图片描述

从这个案例中,我们可以看到,volatile 对于保证操作的原子性是有非常大的帮助的。但是需要注意的是,volatile 并不能代替锁,它也无法保证一些复合操作的原子性。比如下面的例子,通过 volatile 是无法保证 i++ 的原子性操作的:

在这里插入图片描述

执行上述代码,如果第 6 行 i++ 是原子性的,那么最终的值应该是 100000(10 个线程各累加 10000 次)。但实际上,上述代码的输出总是会小于 100000。

此外,volatile 也能保证数据的可见性和有序性。下面再来看一个简单的例子:

在这里插入图片描述

上述代码中,ReaderThread 线程只有在数据准备好时(ready 为 true),才会打印 number 的值。它通过 ready 变量判断是否应该打印。在主线程中,开启 ReaderThread 后,就为 number 和 ready 赋值,并期望 ReaderThread 能够看到这些变化并将数据输出。

在虚拟机的 Client 模式下,由于 JIT 并没有做足够的优化,在主线程修改 ready 变量的状态后,ReaderThread 可以发现这个改动,并退出程序。但是在 Server 模式下,由于系统优化的结果,ReaderThread 线程无法 “看到” 主线程中的修改,导致 ReaderThread 永远无法退出(因为代码第 7 行判断永远不会成立),这显然不是我们想看到的结果。这个问题就是一个典型的可见性问题

注意: 可以使用 Java 虚拟机参数 -server 切换到 Server 模式。

和原子性问题一样,我们只要简单地使用 volatile 来申明 ready 变量,告诉 Java 虚拟机,这个变量可能会在不同的线程中修改。这样,就可以顺利解决这个问题了

2.4 分门别类的管理:线程组

在一个系统中,如果线程数量很多,而且功能分配比较明确,就可以将相同功能的线程放置在一个线程组里。打个比方,如果你有一个苹果,你就可以把它拿在手里,但是如果你有十个苹果,你就最好还有一个篮子,否则不方便携带。对于多线程来说,也是这个道理。想要轻松处理几十个甚至上百个线程,最好还是将它们都装进对应的篮子里。

线程组的使用非常简单,如下:

在这里插入图片描述

上述代码第 3 行,建立一个名为 “PrintGroup” 的线程组,并将 T1 和 T2 两个线程加入这个组中。第 8、9 两行,展示了线程组的两个重要的功能,activeCount() 可以获得活动线程的总数,但由于线程是动态的,因此这个值只是一个估计值,无法确定精确,list() 方法可以打印这个线程组中所有的线程信息,对调试有一定帮助。代码中第 4、5 两行创建了两个线程,使用 Thread 的构造函数,指定线程所属的线程组,将线程和线程组关联起来。

线程组还有一个值得注意的方法 stop() ,它会停止线程组中所有的线程。这看起来是一个很方便的功能,但是它会遇到和 Thread.stop() 相同的问题,因此使用时也需要格外谨慎。

此外,对于编码习惯,我还想再多说几句。强烈建议大家在创建线程和线程组的时候,给它们取一个好听的名字。对于计算机来说,也许名字并不重要,但是在系统出现问题时,你很有可能会导出系统内所有线程,你拿到的如果是一连串的 Thread-0、Thread-1、Thread-2,我想你一定会抓狂。但取而代之,你看到的如果是类似 HttpHandler、FTPService 这样的名字,会让你心情倍爽。

2.5 驻守后台:守护线程(Daemon)

守护线程是一种特殊的线程,就和它的名字一样,它是系统的守护者,在后台默默地完成一些系统性的服务,比如垃圾回收线程、JIT 线程就可以理解为守护线程。与之相对应的是用户线程,用户线程可以认为是系统的工作线程,它会完成这个程序应该要完成的业务操作。如果用户线程全部结束,这也意味着这个程序实际上无事可做了。守护线程要守护的对象已经不存在了,那么整个应用程序就自然应该结束。因此,当一个 Java 应用内,只有守护线程时,Java 虚拟机就会自然退出。

下面简单地看一下守护线程的使用:

在这里插入图片描述

上述代码第 16 行,将线程 t 设置为守护线程。这里注意,设置守护线程必须在线程 start() 之前设置,否则你会得到一个类似以下的异常,告诉你守护线程设置失败。但是你的程序和线程依然可以正常执行。只是被当做用户线程而已。因此,如果不小心忽略了下面的异常信息,你就很可能察觉不到这个错误。那你就会诧异为什么程序永远停不下来了呢?

在这里插入图片描述

在这个例子中,由于 t 被设置为守护线程,系统中只有主线程 main 为用户线程,因此在 main 线程休眠 2 秒后退出时,整个程序也随之结束。但如果不把线程 t 设置为守护线程,main 线程结束后,t 线程还会不停地打印,永远不会结束。

2.6 先干重要的事:线程优先级

Java 中的线程可以有自己的优先级。优先级高的线程在竞争资源时会更有优势,更可能抢占资源,当然,这只是一个概率问题。如果运气不好,高优先级线程可能也会抢占失败。由于线程的优先级调度和底层操作系统有密切的关系,在各个平台上表现不一,并且这种优先级产生的后果也可能不容易预测,无法精准控制,比如一个低优先级的线程可能一直抢占不到资源,从而始终无法运行,而产生饥饿(虽然优先级低,但是也不能饿死它呀)。因此,在要求严格的场合,还是需要自己在应用层解决线程调度问题。

在 Java 中,使用 1 到 10 表示线程优先级。一般可以使用内置的三个静态标量表示:

在这里插入图片描述

数字越大则优先级越高,但有效范围在 1 到 10 之间。下面的代码展示了优先级的作用。高优先级的线程倾向于更快地完成。

在这里插入图片描述

上述代码定义两个线程,分别为 HightPriority 设置为高优先级,LowPriority 为低优先级。让它们完成相同的工作,也就是把 count 从 0 加到 10000000。完成后,打印信息给一个提示,这样我们就知道谁先完成工作了。这里要注意,在对 count 累加前,我们使用 synchronized 产生了一次资源竞争。目的是使得优先级的差异表现得更为明显。

大家可以尝试执行上述代码,可以看到,高优先级的线程在大部分情况下,都会首先完成任务(就这段代码而言,试运行多次,HightPriority 总是比 LowPriority 快,但这不能保证在所有情况下,一定都是这样)。

2.7 线程安全的概念与 synchronized

并行程序开发的一大关注重点就是线程安全。一般来说,程序并行化是为了获得更高的执行效率,但前提是,高效率不能以牺牲正确性为代价。如果程序并行化后,连基本的执行结果的正确性都无法保证,那么并行程序本身也就没有任何意义了。因此,线程安全就是并行程序的根本和根基。大家还记得那个多线程读写 long 型数据的案例吧!这就是一个典型的反例。但在使用 volatile 关键字后,这种错误的情况有所改善。但是,volatile 并不能真正的保证线程安全。它只能确保一个线程修改了数据后,其他线程能够看到这个改动。但当两个线程同时修改某一个数据时,却依然会产生冲突。

下面的代码演示了一个计数器,两个线程同时对 i 进行累加操作,各执行 10000000 次。我们希望的执行结果当然是最终 i 的值可以达到 20000000,但事实并非总是如此。如果你多执行几次下述代码,你会发现,在很多时候,i 的最终值会小于 20000000。这就是因为两个线程同时对 i 进行写入时,其中一个线程的结果会覆盖另外一个(虽然这个时候 i 被声明为 volatile 变量)。

在这里插入图片描述

图 2.8 展示了这种可能的冲突,如果在代码中发生了类似的情况,这就是多线程不安全的恶果。线程 1 和线程 2 同时读取 i 为 0,并各自计算得到 i=1,并先后写入这个结果,因此,虽然 i++ 被执行了 2 次,但是实际 i 的值只增加了 1。

在这里插入图片描述

要从根本上解决这个问题,我们就必须保证多个线程在对 i 进行操作时完全同步。也就是说,当线程 A 在写入时,线程 B 不仅不能写,同时也不能读。因为在线程 A 写完之前,线程 B 读取的一定是一个过期数据。Java 中,提供了一个重要的关键字 synchronized 来实现这个功能。

关键字 synchronized 的作用是实现线程间的同步。它的工作是对同步的代码加锁,使得每一次,只能有一个线程进入同步块,从而保证线程间的安全性(也就是说在上述代码的第 5 行,每次应该只有一个线程可以执行)。

关键字 synchronized 可以有多种用法。这里做一个简单的整理

  • 指定加锁对象:对给定对象加锁,进入同步代码前要获得给定对象的锁。
  • 直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
  • 直接作用于静态方法:相当于对当前类加锁,进入同步代码前要获得当前类的锁。

下述代码,将 synchronized 作用于一个给定对象 instance,因此,每次当线程进入被 synchronized 包裹的代码段,就都会要求请求 instance 实例的锁。如果当前有其他线程正持有这把锁,那么新到的线程就必须等待。这样,就保证了每次只能有一个线程执行 i++ 操作。

在这里插入图片描述

当然,上述代码也可以写成如下形式,两者是等价的:

在这里插入图片描述

上述代码中,synchronized 关键字作用于一个实例方法。这就是说在进入 increase() 方法前,线程必须获得当前对象实例的锁。在本例中就是 instance 对象。在这里,我不厌其烦地再次给出 main 函数的实现,是希望强调第 14、15 行代码,也就是 Thread 的创建方式。这里使用 Runnable 接口创建两个线程,并且这两个线程都指向同一个 Runnable 接口实例(instance 对象),这样才能保证两个线程在工作时,能够关注到同一个对象锁上去,从而保证线程安全

一种错误的同步方式如下:

在这里插入图片描述

上述代码就犯了一个严重的错误。虽然在第 3 行的 increase() 方法中,申明这是一个同步方法。但很不幸的是,执行这段代码的两个线程都指向了不同的 Runnable 实例。由第 13、14 行可以看到,这两个线程的 Runnable 实例并不是同一个对象。因此,线程 t1 会在进入同步方法前加锁自己的 Runnable 实例,而线程 t2 也关注于自己的对象锁。换言之,这两个线程使用的是两把不同的锁。因此,线程安全是无法保证的。

但我们只要简单地修改上述代码,就能使其正确执行。那就是使用 synchronized 的第三种用法,将其作用于静态方法。将 increase() 方法修改如下:

在这里插入图片描述

这样,即使两个线程指向不同的 Runnable 对象,但由于方法块需要请求的是当前类的锁,而非当前实例,因此,线程间还是可以正确同步。

除了用于线程同步、确保线程安全外,synchronized 还可以保证线程间的可见性和有序性。从可见性的角度上讲,synchronized 可以完全替代 volatile 的功能,只是使用上没有那么方便。就有序性而言,由于 synchronized 限制每次只有一个线程可以访问同步块,因此,无论同步块内的代码如何被乱序执行,只要保证串行语义一致,那么执行结果总是一样的。而其他访问线程,又必须在获得锁后方能进入代码块读取数据,因此,它们看到的最终结果并不取决于代码的执行过程,从而有序性问题自然得到了解决(换言之,被 synchronized 限制的多个线程是串行执行的)。

2.8 程序中的幽灵:隐蔽的错误

作为一名软件开发人员,修复程序 BUG 应该说是基本的日常工作之一。作为 Java 程序员,也许你经常会被抛出的一大堆的异常堆栈所困扰,因为这可能预示着你又有工作可做了。但我这里想说的是,如果程序出错,你看到了异常堆栈,那你应该感到格外的高兴,因为这也意味着你极有可能可以在两分钟内修复这个问题(当然,并不是所有的异常都是错误)。最可怕的情况是:系统没有任何异常表现,没有日志,也没有堆栈,但是却给出了一个错误的执行结果,这种情况下,才真会让你抓狂。

2.8.1 无提示的错误案例

我在这里想给出一个系统运行错误,却没有任何提示的案例。让大家体会一下这种情况的可怕之处。我相信,在任何一个业务系统中,求平均值,应该是一种极其常见的操作。这里就以求两个整数的平均值为例。请看下面代码:

在这里插入图片描述

上述代码中,加粗部分试图计算 v1 和 v2 的均值。乍看之下,没有什么问题。目测 v1 和 v2 的当前值,估计两者的平均值大约在 12 亿左右。但如果你执行代码,却会得到以下输出:

在这里插入图片描述

乍看之下,你一定会觉得非常吃惊,为什么均值竟然反而是一个负数。但只要你有一点研发精神,就会马上有所觉悟。这是一个典型的溢出问题!显然,v1+v2 的结果就已经导致了 int 的溢出。

把这个问题单独拿出来研究,也许你不会有特别的感触,但是,一旦这个问题发生在一个复杂系统的内部。由于复杂的业务逻辑,很可能掩盖这个看起来微不足道的问题,再加上程序自始至终没有任何日志或异常,再加上你运气不是太好的话,这类问题不让你耗上几个通宵,恐怕也是难有眉目。

所以,我们自然会恐惧这些问题,我们也希望在程序异常时,能够得到一个异常或者相关的日志。但是,非常不幸的是,错误地使用并行,会非常容易产生这类问题。它们难觅踪影,就如同幽灵一般。

2.8.2 并发下的 ArrayList

我们都知道,ArrayList 是一个线程不安全的容器。如果在多线程中使用 ArrayList,可能会导致程序出错。那究竟可能引起哪些问题呢?试看下面的代码:

在这里插入图片描述

上述代码中,t1 和 t2 两个线程同时向一个 ArrayList 容器中添加容器。他们各添加 1000000 个元素,因此我们期望最后可以有 2000000 个元素在 ArrayList 中。但如果你执行这段代码,你可能会得到三种结果

第一,程序正常结束,ArrayList 的最终大小确实 2000000。这说明即使并行程序有问题,也未必会每次都表现出来。

第二,程序抛出异常

在这里插入图片描述

这是因为 ArrayList 在扩容过程中,内部一致性被破坏,但由于没有锁的保护,另外一个线程访问到了不一致的内部状态,导致出现越界问题。

第三,出现了一个非常隐蔽的错误,比如打印如下值作为 ArrayList 的大小:

在这里插入图片描述

显然,这是由于多线程访问冲突,使得保存容器大小的变量被多线程不正常的访问,同时两个线程也同时对 ArrayList 中的同一个位置进行赋值导致的。如果出现这种问题,那么很不幸,你就得到了一个没有错误提示的错误。并且,他们未必是可以复现的。

注意: 改进的方法很简单,使用线程安全的 Vector 代替 ArrayList 即可

2.8.3 并发下诡异的 HashMap

HashMap 同样不是线程安全的。当你使用多线程访问 HashMap 时,也可能会遇到意想不到的错误。不过和 ArrayList 不同,HashMap 的问题似乎更加诡异。

在这里插入图片描述

上述代码使用 t1 和 t2 两个线程同时对 HashMap 进行 put() 操作。如果一切正常,我们期望得到的 map.size() 就是 100000。但实际上,你可能会得到以下三种情况(注意,这里使用 JDK 7 进行试验):

第一,程序正常结束,并且结果也是符合预期的。HashMap 的大小为 100000

第二,程序正常结束,但结果不符合预期,而是一个小于 100000 的数字,比如 98868

第三,程序永远无法结束

对于前两种可能,和 ArrayList 的情况非常类似,因此,也不必过多解释。而对于第三种情况,如果是第一次看到,我想大家一定会觉得特别惊讶,因为看似非常正常的程序,怎么可能就结束不了呢?

注意: 请读者谨慎尝试以上代码,由于这段代码很可能占用两个 CPU 核,并使它们的 CPU 占有率达到 100%。如果 CPU 性能较弱,可能导致死机。请先保存资料,再进行尝试。

打开任务管理器,你们会发现,这段代码占用了极高的 CPU,最有可能的表示是占用了两个 CPU 核,并使得这两个核的 CPU 使用率达到 100%。这非常类似死循环的情况。

使用 jstack 工具显示程序的线程信息,如下所示。其中 jps 可以显示当前系统中所有的 Java 进程。而 jstack 可以打印给定 Java 进程的内部线程及其堆栈。

在这里插入图片描述

我们会很容易找到我们的 t1、t2 和 main 线程:

在这里插入图片描述

可以看到,主线程 main 正处于等待状态,并且这个等待是由于 join() 方法引起的,符合我们的预期。而 t1 和 t2 两个线程都处于 Runnable 状态,并且当前执行语句为 HashMap.put() 方法。查看 put() 方法的第 498 行代码,如下所示:

在这里插入图片描述

可以看到,当前这两个线程正在遍历 HashMap 的内部数据。当前所处循环乍看之下是一个迭代遍历,就如同遍历一个链表一样。但在此时此刻,由于多线程的冲突,这个链表的结构已经遭到了破坏,链表成环了!当链表成环时,上述的迭代就等同于一个死循环,如图 2.9 所示,展示了最简单的一种环状结构,Key1 和 Key2 互为对方的 next 元素。此时,通过 next 引用遍历,将形成死循环。

在这里插入图片描述

这个死循环的问题,如果一旦发生,着实可以让你郁闷一把。本章的参考资料中也给出了一个真实的案例。但这个死循环的问题在 JDK 8 中已经不存在了。由于 JDK 8 对 HashMap 的内部实现了做了大规模的调整,因此规避了这个问题。但即使这样,贸然在多线程环境下使用 HashMap 依然会导致内部数据不一致。最简单的解决方案就是使用 ConcurrentHashMap 代替 HashMap

2.8.4 初学者常见问题:错误的加锁

在进行多线程同步时,加锁是保证线程安全的重要手段之一。但加锁也必须是合理的,在 “线程安全的概念与 synchronized” 一节中,我已经给出了一个常见的错误加锁的案例。也就是锁的不正确使用。在本节中,我将介绍一个更加隐晦的错误。

现在,假设我们需要一个计数器,这个计数器会被多个线程同时访问。为了确保数据正确性,我们自然会需要对计数器加锁,因此,就有了以下代码:

在这里插入图片描述

上述代码的第 7~9 行,为了保证计数器 i 的正确性,每次对 i 自增前,都先获得 i 的锁,以此保证 i 是线程安全的。从逻辑上看,这似乎并没有什么不对,所以,我们就满怀信心地尝试运行我们的代码。如果一切正常,这段代码应该返回 20000000(每个线程各累加 10000000 次)。

但结果却让我们惊呆了,我得到了一个比 20000000 小很多的数字,比如 15992526。这说明什么问题呢?一定是这段程序并没有真正做到线程安全!但把锁加在变量 i 上又有什么问题呢?似乎加锁的逻辑也是无懈可击的。

要解释这个问题,得从 Integer 说起。在 Java 中,Integer 属于不变对象。也就是对象一旦被创建,就不可能被修改。也就是说,如果你有一个 Integer 代表 1,那么它就永远表示 1,你不可能修改 Integer 的值,使它为 2。那如果你需要 2 怎么办呢?也很简单,新建一个 Integer,并让它表示 2 即可。

如果我们使用 javap 反编译这段代码的 run() 方法,我们可以看到:

在这里插入图片描述

在第 19~22 行(对字节码来说,这是偏移量,这里简称为行),实际上使用了 Integer.valueOf() 方法新建了一个新的 Integer 对象,并将它赋值给变量 i。也就是说,i++ 在真实执行时变成了:

在这里插入图片描述

进一步查看 Integer.valueOf(),我们可以看到:

在这里插入图片描述

Integer.valueOf() 实际上是一个工厂方法,它会倾向于返回一个代表指定数值的 Integer 实例。因此,i++ 的本质是,创建一个新的 Integer 对象,并将它的引用赋值给 i

如此一来,我们就可以明白问题所在了,由于在多个线程间,并不一定能够看到同一个 i 对象(因为 i 对象一直在变),因此,两个线程每次加锁可能都加在了不同的对象实例上,从而导致对临界区代码控制出现问题

修正这个问题也很容易,只要将

在这里插入图片描述

改为:

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
代码下载:完整代码,可直接运行 ;运行版本:2022a或2019b或2014a;若运行有问题,可私信博主; **仿真咨询 1 各类智能优化算法改进及应用** 生产调度、经济调度、装配线调度、充电优化、车间调度、发车优化、水库调度、三维装箱、物流选址、货位优化、公交排班优化、充电桩布局优化、车间布局优化、集装箱船配载优化、水泵合优化、解医疗资源分配优化、设施布局优化、可视域基站和无人机选址优化 **2 机器学习和深度学习方面** 卷积神经网络(CNN)、LSTM、支持向量机(SVM)、最小乘支持向量机(LSSVM)、极限学习机(ELM)、核极限学习机(KELM)、BP、RBF、宽度学习、DBN、RF、RBF、DELM、XGBOOST、TCN实现风电预测、光伏预测、电池寿命预测、辐射源识别、交通流预测、负荷预测、股价预测、PM2.5浓度预测、电池健康状态预测、水体光学参数反演、NLOS信号识别、地铁停车精准预测、变压器故障诊断 **3 图像处理方面** 图像识别、图像分割、图像检测、图像隐藏、图像配准、图像拼接、图像融合、图像增强、图像压缩感知 **4 路径规划方面** 旅行商问题(TSP)、车辆路径问题(VRP、MVRP、CVRP、VRPTW等)、无人机三维路径规划、无人机协同、无人机编队、机器人路径规划、栅格地图路径规划、多式联运运输问题、车辆协同无人机路径规划、天线线性阵列分布优化、车间布局优化 **5 无人机应用方面** 无人机路径规划、无人机控制、无人机编队、无人机协同、无人机任务分配 **6 无线传感器定位及布局方面** 传感器部署优化、通信协议优化、路由优化、目标定位优化、Dv-Hop定位优化、Leach协议优化、WSN覆盖优化、播优化、RSSI定位优化 **7 信号处理方面** 信号识别、信号加密、信号去噪、信号增强、雷达信号处理、信号水印嵌入提取、肌电信号、脑电信号、信号配时优化 **8 电力系统方面** 微电网优化、无功优化、配电网重构、储能配置 **9 元胞自动机方面** 交通流 人群疏散 病毒扩散 晶体生长 **10 雷达方面** 卡尔曼滤波跟踪、航迹关联、航迹融合

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

bm1998

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

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

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

打赏作者

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

抵扣说明:

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

余额充值