java并发学习之五:读JSR133笔记

在写线程池的时候,遇到了很多的问题,特别是happen-before应该怎么去理解,怎么去利用,还有reorder,哪些操作有可能会被reorder?在这一点上,发现其实《concurrency in practice》也没描述得太清晰。

在网上搜了一遍,发现JSR133的faq相对而言,还算稍微解释了一下,发现JSR133其实也就40多页,所以也就顺带看了一遍,[color=red]因为大部分的内容都比较简单[/color](越往后看发现越复杂~),但是里面的定义比较难理解,所以只记录了定义和一些个人认为比较重要的地方,或者比较难理解的地方
这里是阅读笔记,以备以后查阅


[b][size=medium]前言部分[/size][/b]

[b]1.下面的网站提供了附加的信息,帮助进一步地理解JSR133[/b]
http://www.cs.umd.edu/~pugh/java/memoryModel/

[b]2.在JLS中很可能需要JVM(TM)实现的两个原始的定义的改变:[/b]
[list]
[*]volatile变量的语义被加强了,以前的语义是允许自由地被reorder的
[*]final的语义也被加强了,现在可以不需要显性得同步,就可以获得线程安全的不变性。这可能需要在含有设置final field的构造函数的结尾,加入一些存储屏障的步骤
[/list]

[b][size=medium]一.introduction[/size][/b]

[b]3.JSR133并不是描述多线程程序应该怎么去执行,而是描述多线程程序允许怎么去显示的,他包括一些规则,这些规则定义了一个被多线程更新的共享变量的值,是否是可见的。(比如读一个共享变量的值,根据规则,应该显示的是什么)[/b]

[b]4.还是synchronized的问题,该问题在之前的文章中也提到过,在方法上的时候,它锁的是this,如果是静态方法,那么锁的是方法所在类的class[/b]

[b][size=medium]二.Incorrenctly Synchronized Programs Exhibit Surprising Behaviors[/size][/b]

[b]5.不合适的同步(improperly synchronized):(注:并不意味着错误)[/b][list]
[*]一个线程写
[*]另一个线程读
[*]读和写没有用synchronized来保证顺序
[/list]当发生这些,我们就说有数据竞争(data race),包含有数据竞争的代码,可能会出现一些违反直觉的结果。

[b][size=medium]三.Informal Semantics[/size][/b]

[b]6.正确的同步(correct synchronization)(严格地保证多线程访问的正确性,但吞吐率很低的)[/b]
理解一个程序是否被正确地同步,有两个关键点:
[list]
[*]冲突的访问(Conflicting Accesses):对同一个共享域或共享数组的多个访问,并且这些访问至少有一个是写,就说明有冲突。
[color=darkred][*][b]Happens-Before关系[/b][/color]:如果一个动作happens-before另一个,前者对后者是可见的,并且在执行顺序上也会在后者的前面。
这点必须强调一下:一个happens-before关系,并不是暗示这些动作在java平台实现中,必须按这样的顺序去执行。(这里并不是很理解,难道也会是一个幻象?)原话是这样的:It should be stressed that a happens-before relationship between two actions does not imply that those actions must occur in that order in a Java platform implementation.
Happens-before关系最主要是强调并定义了两个有竞争的动作之间的顺序(当数据竞争出现时)
happens-before的规则包括:(注意,这些动作在直觉上是本该如此的,但在多线程中,就不一定了,所以才有这些规则)
a.在这个线程中,每个动作happens-before每个之后的动作。
b.一个对固有锁(monitor)的unlock操作happens-before每个之后的对monitor的lock操作。
c.一个对volatile filed的写happens-before每个之后的对该volatile的读(注意,这里没有线程的限制)
d.一个对线程的start()的调用(call)happens-before该线程中任何动作。
e.在线程中的所有动作happens-before任何其他线程成功地调用对该线程的join()返回
f.如果a happens-before b,b happens-before c,那么a happens-before c,即具有传递性
[/list]happens-before在第五章会更详细彻底地定义。
这里有一个很有意思的图片,可能对别人没太多的意义,但对我自己而言,真是解释了不少以前的迷惑,最主要是第二幅图片的,之前有一个误解:数据只有在同步块中,而另一个线程必须也进入一个相同的锁的同步块中,才能保证其可见性。但其实不是这样的,只要能够保证顺序一致性,就是可见的,如图a
[img]http://dl.iteye.com/upload/attachment/454464/8e45bdab-447f-3a94-9415-79a12654cd51.png[/img]

[b]7.final field[/b]
在这一节,并没有解释final field是怎么保证其同步的(根据之前的理解,如果有final field,应该在对象的构造完成之后做了一些事来保证同步,但到底做了什么事?还得看下文第9章),只是定义了final field在构造完成后,就不会再改变,所以只需要在构造器中,保证其没有escape,就可以正确地在并发环境中无限制地使用了。

[b][size=medium]四.What is a Memory Model?[/size][/b]

[b]8.存储模型(memory model)的定义:[/b]
一个存储模型(memory model)描述的是,给定一个程序和这个程序的执行轨迹(trace),就可以判断这个执行轨迹(trace)是否是合法的。在Java程序语言中,存储模型(memory model)是这样工作的:检查在运行轨迹(trace)中的每个读,并且根据一定的规则,校验这个读观察到的写是否是有效的。
存储模型(memory model)描述一个程序可能的举止。一个存储模型(memory model)实现可以随意地产生任何的代码(像reorder,删除一些没必要的同步),只要所有的程序运行结果可以根据存储模型(memory model)来预测。

[b]9.JVM做的一些事[/b]
当我们说读(read),我们只是说的是一些这样的动作:如读一些fields或者数组。其他操作的语义,像读一个数组的长度,执行一个类型转换(checked casts),或者调用一个虚拟的方法(invocations of virtual methodds,个人的理解应该是调用接口的一个方法),是不会直接受数据竞争的影响的。JVM的实现保证了数据竞争不会导致错误的举动,像返回一个错误的数组长度,或者调用虚拟方法的错误(在竞争的数据中,应该是可能发生的)。

[b][size=medium]五.Definitions[/size][/b]

[b]10.共享变量/堆内存(Shared variables/Heap memory):[/b]
能够在线程间被共享的内存被叫做共享内存或者堆内存。所有的实例域(instance fields),静态域(static fileds)和数组都被存在堆内存。我们用变量(variable)来引用所有的域和数组。在一个方法中的本地变量不会在线程中共享,也不会受存储模型(memory model)影响。

[b]11.线程交互动作(Inter-thread Actions):[/b]
一个线程交互动作(inter-thread action)是这样的动作:它在一个线程中被调用,可以被其他线程检测或者直接影响。inter-thread actions包括读和写共享变量和同步动作,像lock或者unlock,读或者写一个volatile变量,和开始一个线程。同时,也包括一些与外部世界交互的动作(external actions),和可以导致一个线程进入无限循环的动作(thread divergence actions)。
每一个线程交互动作(inter-thread action)都是与这个动作相关的信息联系在一起的。所有的动作都与它被调用的线程联系在一起,并且和线程中的程序顺序(Program order)联系在一起。附加的联系信息包括:
write The variable written to and the value written.
read The variable read and the write seen (from this, we can determine
the value seen).
lock The monitor which is locked.
unlock The monitor which is unlocked.

[b]12.程序顺序(Program order):[/b]
根据线程内语义(intra-thread semantics),包括所有的线程t中的线程交互动作(inter-hread actions),和所有的动作在内,t的程序顺序(program order)是唯一影响执行顺序的。(这个定义应该是说在线程中,表现出来的执行顺序永远是一致的,即使某些动作与其他线程有交互)

[b]13.线程内语义(Intra-thread semantics):[/b]
线程内语义(Intra-thread semantics)是一个单线程程序的基本语义,它允许根据在线程中读操作看到的值对线程的动作的完整的预测。为了确定线程中的动作是否是合法的,我们只是简单地评估在单线程中它的正确性,像定义在JLS中的。
每次线程t的评估产生一个线程交互动作(inter-thread action),它必须匹配这个程序顺序中的下一个动作a。如果a是一个读操作,那么进一步的评估会根据存储模型(memory model)并使用看到的a的值做决定。
简单地说:线程内语义(intra-thread semantics)决定了在一个单独的线程中的执行。当值是从堆里面读出来的,他们就由存储模型(memory model)来决定

[b]14.同步动作(Synchronization Actions):[/b]
同步动作包括lock,unlock,对volatile变量的读和写,开始一个线程的动作start,检测一个线程是否结束的动作join等。任何在一个synchronizes-with边缘(edge),包括开始和结束点,都是一个同步动作(synchronization action)。这样的动作会在后面的happens-before边缘(edge)更详细地列出来

[b]15.同步顺序(Synchronization order):[/b]
每个执行有一个同步顺序(怎么理解?)。一个同步顺序是包括所有的在该执行中的同步动作的所有顺序。(也不是太理解)

[b]16.Happens-Before and Synchronizes-With Edges:[/b]
同步动作(synchronized action)也会引发happen-before边缘,我们将结果指向边缘(directed edges)叫做synchroized-with edges。他们定义在下面:
[list]
[*]一个unlock动作synchronizes-with所有之后的在同一个锁上的lock动作(这里“之后的”的含义根据synchronization order定义)
[*]一个对volatile变量的写synchronizes-with所有之后的对该变量的读操作(任何线程)(这里“之后的”的含义根据synchronization order定义)
[*]一个开始一个线程的动作synchronizes-with线程开始后的第一个操作
[*]线程T1的最后一个动作synchronizes-with另一个线程T2检测到T1已经停止后的任何动作。T2可以通过调用T1.isAlive()或者join动作来完成这个操作。
[*]如果线程T1中断(interrupts)线程T2,T1的interrupt动作synchronizes-with任何其他的线程(包括T2)检测到T2被中断interrupted。
[*]对每个变量的默认值的写synchronizes-with每个线程的第一个动作。
[*]对一个对象的finalizer的调用,有一个隐性的对该对象引用的读操作。在对象的构造器的结尾和这个读操作之间,有一个happens-before edge。
注意:所有的对这个对象的冻结(freezes)happen-before这个happens-before edge的开始点。(这段比较难理解,在9.2会重新提及,暂时先记录下来)
[/list]
[b]17.happen-before规则的稍微详细的解释:[/b]
注意这一点:两个动作间happens-before关系的存在,并不意味着他们在实现上也按这个顺序来执行。如果一个重排序产生的结果与合法的操作一致,它就不是非法的。举个例子:对一个对象中所有变量(field)默认值的写操作并不一定要发生在线程开始后的动作之前,只要没有任何的读操作观察到这个事实。(也就是说,如果没有操作去读,即使有happen-before关系,也是允许重排序的,这其实也解释了为什么我们在其他线程中能观察到该线程的乱序操作,因为在本线程中虽然有重排序操作,但是没有读操作,所以允许它重排序)
进一步说,如果两个操作享有一个happens-before的关系,另一个没有享有该happen-before关系的动作,不一定能观察到他们这个happen-before的顺序,有可能仍然是乱序的。

[b][size=medium]六.Approximations to a Java Memory Model[/size][/b]

这一章看了几遍,感觉还是有点模糊,大概的意思算是有点理解了(如果有偏差,还请指出)

[b]18.Happens-Before Memory Model[/b]
在定义JMM之前,先定义一个能满足JMM的所有要求,但还存在因果循环问题(causal loops,即因为A得出B,因为B得出C,因为C得出D,因为D得出A,因为这整条链没有一个起因,而实际上又会发生,这就叫因果循环问题)的一个模型。
对这个模型的描述在这里就不翻译了,很简单,也很模糊,翻译肯定翻译不清楚的。
这里就列举一下该模型存在的问题的例子:(这些问题都是由对模型的定义推出的合法的而且是正确的问题,但可以看到存在明显的因果循环,我们是不能接受的)
[img]http://dl.iteye.com/upload/attachment/455054/e6b5fbc5-3803-309b-a099-b489eee1b2ef.png[/img]
在Happens-Before Memory Model中,会出现这样的结果x=y=1

[img]http://dl.iteye.com/upload/attachment/455060/c7077487-4899-3abd-9eb2-f8e365e75008.png[/img]
在Happens-Before Memory Model中,JVM允许这样的优化,所以也会导致因果循环的问题

很明显,我们应该允许写操作的提前提交,以得到效能优化的效果(如上图的右图),但有些操作应该不允许的。不正式地说:当涉及到数据竞争的时候,就不应该允许提前提交,否则,就是允许的。

[b][size=medium]七.Formal Specification of the Java Memory Model[/size][/b]
这里就只总结一下非常概括性的内容吧(因为这些定义都没有例子,难以理解,先看了下文再回来详细理解这里的定义)

[b]19.JMM的正式的定义[/b]
[b]定义动作和执行:[/b]
用一个元组定义动作a的概念
[img]http://dl.iteye.com/upload/attachment/455581/6853156c-b00c-39be-ab55-442f6a84f491.png[/img]
用另一个元组定义一个执行的概念
[img]http://dl.iteye.com/upload/attachment/455583/c784334b-e842-3973-b445-5f813e7b5a9b.png[/img]
然后对一些动作进行了定义:
[b]外部动作(external actions):[/b]略
[b]线程分歧动作(thread divergence action):[/b]略
[b]synchronizes-with:[/b]略
[b]happens-before[/b]略
[b]足够同步边缘(sufficient synchronization edges)[/b]略
[b]偏序和功能的限制(Restrictions of partial orders and functions)[/b]略
[b]设计良好的执行(Well-Formed Executions):[/b]
[list]
[*]每个对变量x的读操作看到一个对x变量的写操作(注意,不是说前一个),所有对volatile变量的读和写都是volatile操作(没理解好~)
[*]同步顺序(Synchronization order)与程序顺序(program order)和互斥(mutual exclusion)保持一致
[*]执行遵守线程内(intra-thread语义,即在线程内与程序顺序一致)一致性
[*]执行遵守同步顺序(synchronization-order,这个也没理解好)一致性
[*]执行遵守同步顺序(happens-before)一致性
[/list]
[color=red][b]对执行的因果关系的需求(即解决上文所说的因果循环问题的需求):[/b][/color]
这是一个推导过程,需要满足9个条件,就可以推出这个结论,全是数学公式过程,在这里就不抄了(下文的例子貌似就是用这些条件作为依据推理的,看了下文后,再看这里应该能清晰点)
[b]能被观察到的举动:[/b]略
[b]永不终止的执行:[/b]略

[b][size=medium]八.Illustrative Test Cases and Behaviors[/size][/b]

[b]20.以下图片中出现的情况都是合法的[/b]
至于为什么合法,有一定的解释:因为他可以满足上文中的“对执行的因果关系的需求”中的9个条件
[img]http://dl.iteye.com/upload/attachment/455814/04e919a5-3586-3008-bffa-b3cb8686ac4c.png[/img]
很明显,将y=1提前了,是允许的(文章中有根据条件对提交步骤的推理,证明是可行的,之后的例子都没有,如果时间空闲,后面将试着补充推导过程)

[img]http://dl.iteye.com/upload/attachment/455819/a6469055-20fe-315f-973f-175dcbbab509.png[/img]
也是一个指令的重排序,但注意:r1==1或者r2==2是不允许的:如果写提前了,他们对本地的读就会看不到这个写。

[img]http://dl.iteye.com/upload/attachment/455826/2a8c3ccf-138d-3149-a841-379ba9914619.png[/img]
编译器会优化这个==操作,变成if(true),所以将b=2提前了,也是允许的

[img]http://dl.iteye.com/upload/attachment/455843/4d5fc5a3-bb53-3115-899f-8e86d4097f5a.png[/img]
编译器会发现分支无论怎么走,都会执行a=1,所以也提前了

[img]http://dl.iteye.com/upload/attachment/455849/2a996079-b1d2-3b0a-9642-157466254ed3.png[/img]
编译器会发现r1要么为1要么为0,所以r2必然=1,所以也提前了

[img]http://dl.iteye.com/upload/attachment/455855/f638fde4-fd05-337f-b305-866ce1f2c8fa.png[/img]
这个现象感觉实在没办法解释了,文中的解释感觉也不通~~
翻译如下:
编译器会发现,唯一有可能分配到x的值是0或者42。根据这个,编译器可以推断:当执行到r1=x,要么刚好执行了一个写x=42,要么刚好读x,并且看到了值42(为什么?根本解释不通的~)。无论是哪种情况,一个对x的读看到了值42,然后它会将r1=x改为r1=42,这也会允许y=r1转变为y=42,并且提早发生了,这样就出现了描述的情况:r1=r2=r3=42

[b]21.以下图片中的情况是不合法的[/b]
[img]http://dl.iteye.com/upload/attachment/455907/849038b3-0386-30af-a525-b6ddb2b1926d.png[/img]
[img]http://dl.iteye.com/upload/attachment/455909/5ec79718-0cd4-3282-829e-9925063e5f8e.png[/img]
但解释挺牵强的,说是由于安全的原因,比如在第二个图中,如果42是对某个对象的引用,而这个引用是Thread4持有的,他打算只有当z=1时,才让Thread1和Thread2看到。
如果发生了tu2的情况,安全就没有保证了,所以不允许~~

[b][size=medium]九.Final Field Semantics[/size][/b]

[b]22.final filed的需求和目标[/b]
[*]final field的值将不会改变
[*]只包含final filed的对象在“成功构造”(注意,这是关键的必要条件,也就是在构造成功之后,this才能被别的线程看到)结束后,在可能有竞争的线程间传递,需要被认为是不变的。
[*]对final field的读,将最小化编译器/结构的消耗(cost)(相对于非final field)
[*]final field的语义需要允许某些场景(如反序列化),在这些场景中,对象中的final field允许构造结束后被修改(顺便浏览了一下反序列化的源码(见以下代码),还真是先创建了对象,然后才读入field的值的,以后要是面试面到了final field,又可以忽悠一下)

Object curObj = curContext.getObj();
ObjectStreamClass curDesc = curContext.getDesc();
bin.setBlockDataMode(false);
defaultReadFields(curObj, curDesc);


[b]23.final field safe context[/b]
为了防止final field在构造器中的设值与发生在之后的读的重排序(reorder),定义了一个叫final field safe context的区域。
文章是这样解释的:如果一个对象在final field safe context中被构造(注意,这是必要条件,如果是在final field safe context中,但对象没有被构造,也就没有该限制),对该对象的final field的值的读,将不会与发生在final field safe context中的写进行重排序。
举个例子:像在clone方法和ObjectInputStream.readObject方法中使用这样一个final field safe context就不会出现上述重排序的问题了(这些例子都是构造成功后修改final field的值的情况)。(但这个final field safe context怎么加?如果我们要人为地利用这个东西,怎么去编码呢?文章没有提及,而浏览了一下clone和readObject方法,也没有调用相应的本地方法,那这个final field safe context是如何被调用的呢?方法覆盖?)
在后文中有一个再详细点的解释,大概意思是可以这样做,用一个动作包含了final field safe context的范围,然后在动作结尾放置一个标志。
至于如何去实现,同样的,书中也只给出了一些定义,也就是说,只要满足这些定义的任何方法,都是可接受的,就可以得到一个满足我们需求的final field,想想也是,JMM就是一个定义,而不是一个实现。推理部分就不贴了,个人觉得,理解起来费劲,而且还没啥用~

[b]24.final field的正式语义[/b]
因为比较重要,这也贴一下原文,以防被小弟误译了
A freeze action on a final field f of an object o takes place when a constructor for o in which f is written exits,either abruptly or normally
如果一个对象o的包含对final field的写操作的构造方法发生,一个对final field的一个固化操作将会被调用,即使这个构造方法可以是突然的,或者正常的。
进一步的解释:
一些特殊的机制像反射,反序列化,是允许在构造结束后修改final field的值的。像可以使用java.lang.reflect中的setX(...)方法来达到这个效果。当这个fiedl是final的,这个方法只有满足2个条件才能设置成功,否则会抛IllegalAccessException异常:这个field不是静态的,并且对这个field设置setAccessible(true)成功。

[b]25.Static Final Fields[/b]
如果final field是静态的,JMM不需要提供任何额外的措施来保证,因为这个final field的值是由class(注意,不是实例instance)的初始化来设置的,而class的初始化与静态变量的读是由虚拟机来保证其同步的。

[b][size=medium]十一.Word Tearing[/size][/b]

[b]26.Word Tearing:[/b]一些处理器不能提供操作一个单独的byte。利用这样一个处理器,实现这样一个操作,读整个word(byte数组),然后更新需要的那个byte,然后将整个word(byte数组)写回去,但这样的操作是非法的,被称为Word Tearing。如果要正确地实现这个操作,还需要一些其他的手段(像锁)。(其实就说了一个意思,)

[b][size=medium]十二.Non-atomic Treatment of double and long[/size][/b]

[b]27.JVM可以自由地通过两种方式实现double和long的写操作:原子的写,或者分成两部分写。因为64bit的数据分成两个部分的32位写会更有效率。建议对64bit的数据用volatile或者同步。[/b]

[b][size=medium]十三.Fairness[/size][/b]

[b]28.大部分的java虚拟机实现将提供一些形式的公平性的保证(像优先级),但这不是一个硬性的要求,而是一个进一步的服务[/b]

[b][size=medium]十四.Wait Sets and Notification[/size][/b]

[b]29.wait[/b](虽然很早就知道wait的整个流程,第一次看到权威的细节解释,翻译一下)
wait动作通过wait()方法调用,或者wait(long millisecs)或wait(long millisecs, int nanosecs).wait(0)或wait(0,0)跟wait()是完全等效的。
我们做一个假设:线程t是对对象m运行wait()方法的线程,n是t在m上锁的数量(因为固有锁是重入锁,所以你可以锁上这个对象n次),下面动作中的一个会发生
[list]
[*]如果n=0,会抛出一个IllegalMonitorStateException
[*]如果带时间参数的wait的参数nanosecs范围不在0-999999之间或者millisecs是负数,抛出一个IllegalArgumentException
[*]如果线程t的interrupted状态已经被设置,那么会抛出一个一个InterruptedException并且将interruption status设置为false
[*]否则,下面的动作将会按顺序发生
1.t被添加到m的wait set(每个对象都会有一个wait set),并且对m执行n个unlock操作
2.t不会再执行进一步的指令知道它被从wait set中移除。当下面的任意动作发生了,线程将从wait set中移除,并在一段时间之后恢复(注意:不确定的时间)
a.m上发生了一notify动作,并且t被选中了,从wait set中移除
b.m上发生一个notifyAll动作
c.t上发生了一个interrupt动作
d.如果这是一个有时间限制的wait,当从wait动作发生起,到至少(注意,有个至少,也没有很确切的时间保证)经过了指定的时间,一个内部的动作将会t从m的wait set中移除
e.这是一个通过JVM实现的内部动作。JVM允许,但是不鼓励,产生一个“spurious wake-ups”:没有明显的指令调用,就将线程从wait set中移除让线程恢复。注意这个动作有个必须的限制条件:wait在一个循环中,这个循环在某些线程等待持有的逻辑条件变得成立后将会终止(这个动作还是第一次听说)
每个线程必须根据可以让它从wait set中移除的事件,决定一个顺序。这个顺序不一定要与其他的顺序一致,但线程必须与这些事件发生的顺序表现一致。举个例子:如果线程t在m的wait set中,发生了一个对t的interrupt和对m的notify,这两个事件就必须有一个顺序了。
3.线程在m上执行n个lock操作
4.如果线程t在步骤2中是由于interrupt被从wait set移除,就会在线程t的wait方法处抛出一个InterruptedException并将interruption status设置为false
[/list]

[b]29.Notification[/b]
Notification通过notify或者notifyAll被调用,我们做一个假设:线程t是对对象m运行wait()方法的线程,n是t在m上锁的数量(因为固有锁是重入锁,所以你可以锁上这个对象n次),下面动作中的一个会发生
[list]
[*]如果n=0会抛出一个IllegalMonitorStateException,这是由于对m加锁
[*]如果n>0并且这是一个notify动作,这样,进入下一步判断,如果m的wait set不是空的,就会从m当前的wait set中选择一个线程v(注意,选择哪一个线程是没有保证的),这个移除的动作会让v从wait中恢复。注意,然而,只有当t被完全从m的固有锁unlock之后一段时间后,v的lock动作才能成功(也就是说,t是持有锁才能调用notify的,而v一被唤醒,将需要重新获得锁,所以必须要等t将锁完全释放后,v才能获得锁)
[*]如果n>0并且这是一个notifyAll动作,所有的线程会从wait set中移除,然后恢复。注意,一次只有一个线程能成功获得锁,然后从wait中恢复。
[/list]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值