Java核心技术之虚拟机字节码执行引擎---方法调用

 方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。

 在程序运行时,进行方法调用是最普遍、最频繁的操作,但Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。

解析

 所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)

 在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

在Java虚拟机里面提供了5条方法调用字节码指令:

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
  • invokevirtual:调用所有的虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

方法静态解析演示

/**
*方法静态解析演示
*
*@author zzm
*/
public class StaticResolution{
	public static void sayHello(){
		System.out.println("hello world");
	}
	public static void main(String[]args){
		StaticResolution.sayHello();
	}
}

使用javap命令查看这段程序的字节码,会发现的确是通过invokestatic命令来调用sayHello()方法的。

D:\Develop\>javap-verbose StaticResolution
public static void main(java.lang.String[]);
Code:
Stack=0,Locals=1,Args_size=1
0:invokestatic#31//Method sayHello:()V
3return
LineNumberTable:
line 150
line 163

 Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的

在Java语言规范中明确说明了final方法是一种非虚方法。

分派

 解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。

 Java具有面向对象的三个基本特征:封装、继承和多态,而分派,是多态性特征(如“重载”、“重写”)在虚拟机层面的一种体现。具体来说,分派是虚拟机确定正确的目标方法的一个过程

1、静态分派

方法静态分派演示

package org.fenixsoft.polymorphic;
/**
*方法静态分派演示
*@author zzm
*/
public class StaticDispatch{
	static abstract class Human{
	}
	static class Man extends Human{
	}
	static class Woman extends Human{
	}
	public void sayHello(Human guy){
		System.out.println("hello,guy!");
	}
	public void sayHello(Man guy){
		System.out.println("hello,gentleman!");
	}
	public void sayHello(Woman guy){
		System.out.println("hello,lady!");
	}
	public static void main(String[]args){
		Human man=new Man();
		Human woman=new Woman();
		StaticDispatch sr=new StaticDispatch();
		sr.sayHello(man);
		sr.sayHello(woman);
	}
}

运行结果:

hello,guy!
hello,guy!

 上面的代码中有这么一句:Human man=new Man(); ,其实其中的“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type)
 静态类型和实际类型在程序中都可以发生一些变化,区别是:

  • 静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的
  • 而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么
//实际类型变化
Human man=new Man();
man=new Woman();
//静态类型变化
sr.sayHello((Man)man)
sr.sayHello((Woman)man)

 main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但 虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

 所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。

 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。

重载方法匹配优先级

package org.fenixsoft.polymorphic;
public class Overload{
	public static void sayHello(Object arg){
		System.out.println("hello Object");
	}
	public static void sayHello(int arg){
		System.out.println("hello int");
	}
	public static void sayHello(long arg){
		System.out.println("hello long");
	}
	public static void sayHello(Character arg){
		System.out.println("hello Character");
	}
	public static void sayHello(char arg){
		System.out.println("hello char");
	}
	public static void sayHello(char……arg){
		System.out.println("hello char……");
	}
	public static void sayHello(Serializable arg){
		System.out.println("hello Serializable");
	}
	public static void main(String[]args){
		sayHello('a');
	}
}

运行结果

hello char

'a’是一个char类型的数据,自然会寻找参数类型为char的重载方法,如果
注释掉sayHello(char arg)方法,那输出会变为:

hello int

这时发生了一次自动类型转换,'a’除了可以代表一个字符串,还可以代表数字97(字符’a’的Unicode数值为十进制数字97),因此参数类型为int的重载也是合适的。我们继续注释掉sayHello(int arg)方法,那输出会变为:

hello long

这时发生了两次自动类型转换,'a’转型为整数97之后,进一步转型为长整数97L,匹配了参数类型为long的重载。笔者在代码中没有写其他的类型如float、double等的重载,不过实际上自动转型还能继续发生多次,按照char->int->long->float->double的顺序转型进行匹配。但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的。我们继续注释掉sayHello(long arg)方法,那输出会变为:

hello Character

这时发生了一次自动装箱,'a’被包装为它的封装类型java.lang.Character,所以匹配到了参数类型为Character的重载,继续注释掉sayHello(Character arg)方法,那输出会变为:

hello Serializable

一个字符或数字与序列化有什么关系?出现helloSerializable,是因为java.lang.Serializable是java.lang.Character类实现的一个接口,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生一次自动转型。char可以转型成int,但是Character是绝对不会转型为Integer的,它只能安全地转型为它实现的接口或父类。Character还实现了另外一个接口java.lang.Comparable<Character>,如果同时出现两个参数分别为Serializable和Comparable<Character>的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显式地指定字面量的静态类型,如:sayHello((Comparable<Character>)‘a’),才能编译通过。下面继续注释掉sayHello(Serializable arg)方法,输出会变为:

hello Object

这时是char装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用传入的参数值为null时,这个规则仍然适用。我们把sayHello(Object arg)也注释掉,输出将会变为:

hello char……

7个重载方法已经被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时候
字符’a’被当做了一个数组元素。

2、动态分派

动态分派和多态性的另外一个重要体现——重写(Override)有着很密切的关联。

方法动态分派演示

package org.fenixsoft.polymorphic;
/**
*方法动态分派演示
*@author zzm
*/
public class DynamicDispatch{
	static abstract class Human{
		protected abstract void sayHello();
	}
	static class Man extends Human{
		@Override
		protected void sayHello(){
			System.out.println("man say hello");
		}
	}
	static class Woman extends Human{
		@Override
		protected void sayHello(){
			System.out.println("woman say hello");
		}
	}
	public static void main(String[]args){
		Human man=new Man();
		Human woman=new Woman();
		man.sayHello();
		woman.sayHello();
		man=new Woman();
		man.sayHello();
	}
}

运行结果:

man say hello
woman say hello
woman say hello

使用javap命令输出这段代码的字节码

public static void main(java.lang.String[]);
Code:
Stack=2,Locals=3,Args_size=1
0new#16//class org/fenixsoft/polymorphic/Dynamic-
Dispatch $Man
3:dup
4:invokespecial#18//Method org/fenixsoft/polymorphic/Dynamic-
Dispatch $Man."<init>":()V
7:astore_1
8new#19//class org/fenixsoft/polymorphic/Dynamic-
Dispatch $Woman
11:dup
12:invokespecial#21//Method org/fenixsoft/polymorphic/DynamicDispa
tch $Woman."<init>":()V
15:astore_2
16:aload_1
17:invokevirtual#22//Method org/fenixsoft/polymorphic/Dynamic-
Dispatch $Human.sayHello:()V
20:aload_2
21:invokevirtual#22//Method org/fenixsoft/polymorphic/Dynamic-
Dispatch $Human.sayHello:()V
24new#19//class org/fenixsoft/polymorphic/Dynamic-
Dispatch $Woman
27:dup
28:invokespecial#21//Method org/fenixsoft/polymorphic/Dynam
icDispatch $Woman."<init>":()V
31:astore_1
32:aload_1
33:invokevirtual#22//Method org/fenixsoft/polymorphic/
DynamicDispatch $Human.sayHello:()V
36return

 0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表Slot之中,这个动作也就对应了代码中的这两句:

Human man=new Man();
Human woman=new Woman();

 接下来的16~21句是关键部分,16、20两句分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21句是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。
 原因就需要从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

  • 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  • 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
  • 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
  • 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

 由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

3、单分派与多分派

方法的接收者与方法的参数统称为方法的宗量。

 根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

单分派和多分派

/**
*单分派、多分派演示
*@author zzm
*/
public class Dispatch{
	static class QQ{}
	static class_360{}
	public static class Father{
		public void hardChoice(QQ arg){
			System.out.println("father choose qq");
		}
		public void hardChoice(_360 arg){
			System.out.println("father choose 360");
		}
	}
	
	public static class Son extends Father{
		public void hardChoice(QQ arg){
			System.out.println("son choose qq");
		}
		public void hardChoice(_360 arg){
			System.out.println("son choose 360");
		}
	}
	
	public static void main(String[]args){
		Father father=new Father();
		Father son=new Son();
		father.hardChoice(new _360());
		son.hardChoice(new QQ());
	}
}

运行结果:

father choose 360
son choose qq

 在main函数中调用了两次hardChoice()方法,这两次hardChoice()方法的选择结果在程序输出中已经显示得很清楚了。

 我们来看看编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型

 再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(newQQ())”这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型

4、虚拟机动态分派的实现

 动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。

最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。

虚方法表结构
在这里插入图片描述
 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

 为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

 方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

动态类型语言支持
1、动态类型语言

 什么是动态类型语言?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk和Tcl等。相对的,在编译期就进行类型检查过程的语言(如C++和Java等)就是最常用的静态类型语言。

示例代码

public static void main(String[]args){
	int[][][]array=new int[1][0][-1]}

 该代码可以正常编译,但运行的时候会报NegativeArraySizeException异常。在Java虚拟机规范中明确规定了NegativeArraySizeException是一个运行时异常,通俗一点来说,运行时异常就是只要代码不运行到这一行就不会有问题。与运行时异常相对应的是连接时异常,例如很常见的NoClassDefFoundError便属于连接时异常,即使会导致连接时异常的代码放在一条无法执行到的分支路径上,类加载时(Java的连接过程不在编译阶段,而在类加载阶段)也照样会抛出异常。

在C语言中,含义相同的代码会在编译期报错:

int main(void{
	int i[1][0][-1]//GCC拒绝编译,报“size of array is negative”
	return 0}

 符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机可以翻译出这个方法的直接引用

2、JDK 1.7与动态类型

 Java虚拟机层面对动态类型语言的支持一直都有所欠缺,主要表现在方法调用方面:JDK 1.7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在Java虚拟机上实现的动态类型语言就不得不使用其他方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。尽管可以利用一些办法(如Call Site Caching)让这些开销尽量变小,但这种底层问题终归是应当在虚拟机层次上去解决才最合适,因此在Java虚拟机层面上提供动态类型的直接支持就成为了Java平台的发展趋势之一。

3、java.lang.invoke包

 JDK 1.7实现了JSR-292,新加入的java.lang.invoke包就是JSR-292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle。
 拥有Method Handle之后,Java语言也可以拥有类似于函数指针或者委托的方法别名的工具了。

MethodHandle演示

import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
/**
*JSR-292 Method Handle基础用法演示
*@author zzm
*/
public class MethodHandleTest{
	static class ClassA{
		public void println(String s){
			System.out.println(s);
		}
	}
	
	public static void main(String[]args)throws Throwable{
		Object obj=System.currentTimeMillis()%2==0?System.out:new ClassA();
		/*无论obj最终是哪个实现类,下面这句都能正确调用到println方法
		getPrintlnMH(obj).invokeExact("icyfenix");
		}
		private static MethodHandle getPrintlnMH(Object reveiver)throws Throwable{
		/*MethodType:代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及以后的参数)*/
		MethodType mt=MethodType.methodType(void.class,String.class);
		/*lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄*/
		/*因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,也即是this指向的对象,这个参数以前是放在参数列表中进行传递的,而现在提供了bindTo()方法来完
		成这件事情*/
		return lookup().findVirtual(reveiver.getClass(),"println",mt).bindTo(reveiver);
	}
}

 实际上,方法getPrintlnMH()中模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上,而是通过一个具体方法来实现。而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个“引用”。以此为基础,有了MethodHandle就可以写出类似于下面这样的函数声明:

void sort(List list,MethodHandle compare)

从上面的例子可以看出,使用MethodHandle并没有什么困难。

4、invokedynamic指令

 在某种程度上,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(包含其他语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,可以把它们想象成为了达成同一个目的,一个采用上层Java代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。因此,如果理解了前面的MethodHandle例子,那么理解invokedynamic指令也并不困难。

invokedynamic指令演示

import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.CallSite;
import java.lang.invoke.ConstantCallSite;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
public class InvokeDynamicTest{
public static void main(String[]args)throws Throwable{
INDY_BootstrapMethod().invokeExact("icyfenix");
}
public static void testMethod(String s){
System.out.println("hello String:"+s);
}
public static CallSite BootstrapMethod(MethodHandles.Lookup lookup,String name,MethodType mt)throws Throwable{
return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class,name,mt));
}
private static MethodType MT_BootstrapMethod(){
return MethodType
.fromMethodDescriptorString(
"(Ljava/lang/invoke/MethodHandles $Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;",
null);
}
private static MethodHandle MH_BootstrapMethod()throws Throwable{
return lookup().findStatic(InvokeDynamicTest.class"BootstrapMethod",MT_BootstrapMethod());
}
private static MethodHandle INDY_BootstrapMethod()throws Throwable{
CallSite cs=(CallSite)MH_BootstrapMethod().invokeWithArguments(lookup(),"testMethod",
MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V",null));
return cs.dynamicInvoker();
}
}

5、掌控方法分派规则

 invokedynamic指令与前面4条“invoke*”指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。

方法调用问题

class GrandFather{
	void thinking(){
		System.out.println("i am grandfather");
	}
}

class Father extends GrandFather{
	void thinking(){
		System.out.println("i am father");
	}
}

class Son extends Father{
	void thinking(){
		//请读者在这里填入适当的代码(不能修改其他地方的代码)
		//实现调用祖父类的thinking()方法,打印"i am grandfather"
	}
}

在Java程序中,可以通过“super”关键字很方便地调用到父类中的方法。

使用MethodHandle来解决相关问题

import static java.lang.invoke.MethodHandles.lookup;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
class Test{
class GrandFather{
void thinking(){
System.out.println("i am grandfather");
}
}
class Father extends GrandFather{
void thinking(){
System.out.println("i am father");
}
}
class Son extends Father{
void thinking(){
try{
MethodType mt=MethodType.methodType(void.class);
MethodHandle mh=lookup().findSpecial(GrandFather.class"thinking",mt,getClass());
mh.invoke(this);
}catch(Throwable e){
}
}
}
public static void main(String[]args){new Test().new Son()).thinking();
}
}

运行结果:

i am grandfather
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值