小度拆卸_拆卸invokedynamic

小度拆卸

许多Java开发人员认为JDK的第七版有些令人失望。 从表面上看,仅少数语言和库扩展使它成为了发行版,即Project CoinNIO2 。 但在幕后,该平台的第七个版本对JVM类型系统进行了最大的扩展,这是其最初发行后引入的。 添加invokedynamic指令不仅为在Java 8中实现lambda表达式奠定了基础,而且还是将动态语言转换为Java字节码格式的规则改变者。

虽然invokedynamic指令是用于在Java虚拟机上执行语言的实现细节,但了解该指令的功能可以真正洞悉执行Java程序的内部工作原理。 本文提供了有关invokedynamic指令解决什么问题以及如何解决它的初学者的见解。

方法句柄

方法句柄通常被描述为Java反射API的改进版本,但这并不是它们所代表的意思。 尽管方法句柄确实表示方法,构造函数或字段,但它们并不旨在描述这些类成员的属性。 例如,不可能直接从方法句柄中提取元数据,例如所表示方法的修饰符或注释值。 虽然方法句柄允许引用方法的调用,但它们的主要目的是与invokedynamic调用站点一起使用。 为了更好地理解方法句柄,将它们视为反射API的不完美替代是一个合理的起点。

方法句柄无法实例化。 而是使用指定的查找对象创建方法句柄。 这些对象本身是使用MethodHandles类提供的工厂方法创建的。 每当调用工厂时,它都会首先创建一个安全上下文,以确保所生成的查找对象只能定位对调用工厂方法的类也可见的方法。 然后可以按以下方式创建查找对象:

class Example {
  void doSomething() {
    MethodHandles.Lookup lookup = MethodHandles.lookup();
  }
}

如前所述,以上查找对象只能用于定位对Example类也可见的方法。 例如,不可能查找另一个类的私有方法。 这是使用反射API的第一个主要区别,反射API可以像定位其他任何方法一样定位外部类的私有方法,并且在将此类方法标记为可访问之后甚至可以调用这些方法。 因此,方法句柄对它们的创建上下文很敏感,这是与反射API的第一个主要区别。

除此之外,方法句柄通过描述特定类型的方法而不是仅代表任何方法,比反射API更具体。 在Java程序中,方法的类型是该方法的返回类型及其参数类型的组合。 例如,以下Counter类的only方法返回一个int,它表示唯一的String型参数的字符数:

class Counter {
  static int count(String name) {
    return name.length();
  }
}

可以使用另一个工厂来创建此方法类型的表示形式。 该工厂位于MethodType类中,该类还表示创建的方法类型的实例。 使用该工厂,可以通过移交方法的返回类型及其捆绑为数组的参数类型来创建Counter::count的方法类型:

MethodType methodType = MethodType.methodType(int.class, new Class<?>[] {String.class});

描述上述方法的类型时,将方法声明为静态是很重要的。 编译Java方法时,非静态Java方法的表示方式类似于静态方法,但带有一个表示此伪变量的附加隐式参数。 因此,在为非静态方法创建MethodType时,需要传递代表该方法的声明类型的附加参数。 因此,对于上述Counter::count方法的非静态版本,方法类型将变为以下类型:

MethodType.methodType(int.class, Example.class, new Class<?>[] {String.class});

通过使用之前创建的查找对象以及上面的方法类型,现在可以找到代表Counter::count方法的方法句柄,如以下代码所示:

MethodType methodType = MethodType.methodType(int.class, new Class<?>[] {String.class});
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle methodHandle = lookup.findStatic(Counter.class, "count", methodType);
int count = methodHandle.invokeExact("foo");
assertThat(count, is(3));

乍一看,使用方法句柄似乎是使用反射API的过于复杂的版本。 但是,请记住,使用句柄直接调用方法并不是其使用的主要目的。

上面的示例代码和通过反射API调用方法的主要区别仅在研究Java编译器将两次调用转换为Java字节码的方式的区别时才显示出来。 当Java程序调用方法时,该方法由其名称,其(非通用)参数类型以及其返回类型唯一标识。 因此,可以重载Java中的方法。 即使Java编程语言不允许这样做,但JVM在理论上确实允许通过其返回类型来重载方法。

遵循此原理,将反射方法调用作为Method :: invoke方法的公共方法调用执行。 此方法由其两个参数(类型为Object和Object [])标识。 除此之外,该方法还通过其对象返回类型来标识。 由于具有此签名,因此必须始终将此方法的所有参数装箱并包含在数组中。 同样,如果返回值是原始值,则需要将其装箱;如果该方法无效,则返回null。

方法句柄是该规则的例外。 不是通过引用MethodHandle::invokeExact签名的方法来调用方法句柄,该签名将Object[]作为其单个参数并返回Object ,而是使用所谓的多态签名来调用方法句柄。 Java编译器根据调用现场的实际参数类型和期望的返回类型来创建多态签名。 例如,当使用

int count = methodHandle.invokeExact("foo");

Java编译器将转换此调用,就像将invokeExact方法定义为接受String类型的单个单个参数并返回int类型一样。 显然,这种方法不存在,并且对于(几乎)任何其他方法,这将在运行时导致链接错误。 对于方法句柄,Java虚拟机确实将此签名识别为多态的,并且将方法句柄的调用视为该句柄所引用的Counter::count方法直接插入到调用站点中。 因此,可以调用该方法而无需将原始值装箱或返回类型的开销,也无需将参数值放在数组内。

同时,在使用invokeExact调用时,向Java虚拟机保证方法句柄在运行时始终引用与多态签名兼容的方法。 对于该示例,JVM期望所引用的方法实际上接受String作为其唯一参数,并且它返回原始int 。 如果未满足此约束,则执行将导致运行时错误。 但是,任何其他接受单个String并返回原始int的方法都可以成功地填充到方法句柄的调用站点中,以替换Counter::count

相反,即使代码成功编译,在以下三个调用中使用Counter::count方法句柄也会导致运行时错误:

int count1 = methodHandle.invokeExact((Object) "foo");
int count2 = (Integer) methodHandle.invokeExact("foo");
methodHandle.invokeExact("foo");

第一条语句导致错误,因为传递给句柄的参数过于笼统。 尽管JVM期望将String作为方法的参数,但Java编译器建议该参数为Object类型。 重要的是要理解,Java编译器将强制转换作为创建不同的多态签名的提示,该签名将Object类型作为单个参数类型,而JVM在运行时期望使用String 。 请注意,此限制也适用于处理过于具体的参数,例如,将参数强制转换为Integer ,该方法的句柄需要使用Number类型作为其参数。 在第二条语句中,Java编译器向运行时建议,句柄的方法将返回Integer包装器类型,而不是原始int 。 而且,在第三条语句中根本不建议返回类型,Java编译器将调用隐式转换为void方法调用。 因此, invokeExact确实意味着精确。

这种限制有时可能太苛刻。 出于这个原因,方法句柄不需要进行确切的调用,还允许在应用了诸如类型转换和拳击等转换的情况下更为宽容的调用。 可以通过使用MethodHandle::invoke方法来应用MethodHandle::invoke 。 使用此方法,Java编译器仍会创建一个多态签名。 但这一次,Java虚拟机确实在运行时测试了实际参数和返回类型的兼容性,并在适当时通过应用装箱或转换来转换它们。 显然,这些转换有时会增加运行时的开销。

字段,方法和构造函数:作为统一接口处理

除了反射API的Method实例之外,方法句柄可以同样引用字段或构造函数。 因此,可以将MethodHandle类型的名称视为太窄。 实际上,在运行时通过方法句柄引用哪个类成员并不重要,只要它的MethodType (具有误导性名称的另一种类型)与在关联的调用站点传递的参数匹配即可。

使用MethodHandles.Lookup对象的适当工厂,可以查找一个字段以表示一个getter或setter。 在此上下文中使用getter或setter并不表示调用遵循Java Bean规范的实际方法。 而是,基于字段的方法句柄直接从字段读取或写入字段,但通过调用方法句柄以方法调用的形式出现。 通过经由方法句柄表示此类字段访问,可以互换使用字段访问或方法调用。

以此类交换为例,采用以下类:

class Bean {
  String value;
  void print(String x) {
    System.out.println(x);
  }
}

给定此Bean类,可以使用以下方法句柄将字符串写到value字段或使用与参数相同的字符串调用print方法:

MethodHandle fieldHandle = lookup.findSetter(Bean.class, "value", String.class);
MethodType methodType = MethodType.methodType(void.class, new Class<?>[] {String.class});
MethodHandle methodHandle = lookup.findVirtual(Bean.class, "print", methodType);

只要在返回void同时将方法句柄调用站点与String一起传递给Bean的实例,则两个方法句柄可以互换使用,如下所示:

anyHandle.invokeExact((Bean) mybean, (String) myString);

与字段和方法类似,可以定位和调用构造函数。 此外,只要创建查找工厂的类可以访问此超级方法,则它不仅可以直接调用方法,甚至可以调用超级方法。 相反,在依赖反射API时根本不可能调用超级方法。 如果需要,甚至可以从句柄返回恒定值。

性能指标

方法句柄通常被描述为比Java反射API更高性能。 至少对于最新版本的HotSpot虚拟机而言,这不是事实。 证明这一点的最简单方法是编写适当的基准 。 再说一次,为Java程序编写基准并在执行时进行优化并不是一件容易的事。 编写基准的事实上的标准已成为使用JMH的工具,JMH是OpenJDK旗下的工具。 完整的基准可以在我的GitHub个人资料中找到要点。 本文仅涵盖该基准测试的最重要方面。

从基准来看,很明显反射已经非常有效地实现了。 现代JVM知道一个称为膨胀的概念,其中经常调用的反射方法调用被运行时生成的Java字节代码替换。 剩下的是将拳击用于传递参数和接收返回值的开销。 有时可以通过JVM的即时编译器消除这些拳击,但这并不总是可能的。 因此,如果方法调用涉及大量原始值,则使用方法句柄可能比使用反射API更有效。 但是,这确实要求在编译时已经知道确切的方法签名,以便可以创建适当的多态签名。 对于大多数反射API用例,由于在编译时不知道被调用方法的类型,因此无法提供此保证。 在这种情况下,使用方法句柄不会带来任何性能上的好处,因此不应替换它。

创建一个invokedynamic呼叫站点

通常,仅当Java编译器需要将lambda表达式转换为字节码时,才会创建invokedynamic调用站点。 值得一提的是,lambda表达式可以在没有完全调用动态调用站点的情况下实现,例如通过将它们转换为匿名内部类。 与建议的方法的主要区别是,使用invokedynamic会延迟创建与运行时类似的类。 我们将在下一部分中研究类的创建。 但是,现在请记住,invokedynamic与类创建没有任何关系,它仅允许将如何调度方法的决定延迟到运行时。

为了更好地理解invokedynamic调用站点,它有助于显式创建此类调用站点,以便单独查看机制。 为此,下面的示例利用了我的代码生成框架Byte Buddy ,该框架提供了对invokedynamic调用站点的显式字节代码生成,而无需任何字节代码格式的知识。

任何invokedynamic调用站点最终都会产生一个MethodHandle,该MethodHandle引用要调用的方法。 但是,不是手动调用此方法句柄,而是由Java运行时决定。 因为方法句柄已成为Java虚拟机的已知概念,所以这些调用的优化类似于常见方法调用。 任何这样的方法句柄都是从所谓的引导程序方法接收的,而引导程序方法仅是满足特定签名的普通Java方法。 有关引导方法的简单示例,请查看以下代码:

class Bootstrapper {
  public static CallSite bootstrap(Object... args) throws Throwable {
    MethodType methodType = MethodType.methodType(int.class, new Class<?>[] {String.class})
    MethodHandles.Lookup lookup = MethodHandles.lookup();
    MethodHandle methodHandle = lookup.findStatic(Counter.class, "count", methodType);
    return new ConstantCallSite(methodHandle);
  }
}

目前,我们不太关心该方法的参数。 相反,请注意,该方法是静态的,实际上是必需的。 在Java字节代码中,invokedynamic调用站点引用引导程序方法的完整签名,但不引用可能具有状态和生命周期的特定对象。 调用invokedynamic调用站点后,控制流将移交给引用的引导方法,该方法现在负责标识方法句柄。 从bootstrap方法返回此方法句柄后,它将由Java运行时调用。

从上面的示例可以明显看出, MethodHandle不是直接从引导方法返回的。 而是将句柄包装在CallSite对象的内部。 每当调用引导方法时,invokedynamic调用站点便会永久绑定到从该方法返回的CallSite对象。 因此,对于任何呼叫站点仅一次调用引导程序方法。 由于有了这个中间的CallSite对象,因此可以在以后交换引用的MethodHandle 。 为此,Java类库已经提供了CallSite不同实现。 在上面的示例代码中,我们已经看到了ConstantCallSite 。 顾名思义, ConstantCallSite始终引用相同的方法句柄,而不会在以后进行交换。 但是,也可以选择使用MutableCallSite ,它允许在以后的某个时间点更改引用的MethodHandle ,或者甚至有可能实现自定义的CallSite类。

通过上述引导程序方法和Byte Buddy,我们现在可以实现自定义invokedynamic指令。 为此,Byte Buddy提供了InvokeDynamic工具,该工具接受bootstrap方法作为其唯一的强制参数。 然后将这样的仪器输入到Byte Buddy。 假设下面的类:

abstract class Example {
  abstract int method();
}

我们可以使用Byte Buddy来对Example进行子类化,以覆盖method 。 然后,我们将实现此方法以包含单个invokedynamic调用站点。 无需任何进一步配置,Byte Buddy就会创建一个类似于覆盖方法的方法类型的多态签名。 请记住,对于非静态方法,此引用将作为第一个隐式参数处理。 假定我们要绑定将String作为单个参数的Counter::count方法,则无法将此句柄绑定到与方法类型不匹配的Example::method 。 因此,我们需要创建一个不带隐式参数但使用String代替的其他调用站点。 这可以通过使用Byte Buddy的域特定语言来实现:

Instrumentation invokeDynamic = InvokeDynamic
 .bootstrap(Bootstrapper.class.getDeclaredMethod(“bootstrap”, Object[].class))
 .withoutImplicitArguments()
 .withValue("foo");

有了此工具,我们最终可以扩展Example类和重写方法,以实现invokedynamic调用站点,如以下代码片段所示:

Example example = new ByteBuddy()
  .subclass(Example.class)
   .method(named(“method”)).intercept(invokeDynamic)
   .make()
   .load(Example.class.getClassLoader(), 
         ClassLoadingStrategy.Default.INJECTION)
   .getLoaded()
   .newInstance();
int result = example.method();
assertThat(result, is(3));

从上面的断言可以明显看出, "foo"字符串的字符已正确计数。 通过在代码中设置适当的断点,还可以验证是否调用了bootstrap方法,并且控制流进一步到达了Counter::count方法。

到目前为止,使用invokedynamic调用站点并没有带来太多好处。 上面的bootstrap方法将始终绑定Counter::count ,因此只有在invokedynamic调用站点确实想要将String转换为int时才可以产生有效结果。 显然,由于引导方法从invokedynamic调用站点接收到的参数,因此引导方法可以更加灵活。 任何引导方法都至少接收三个参数:

作为第一个参数,bootstrap方法接收一个MethodHandles.Lookup对象。 该对象的安全上下文是包含触发自举的invokedynamic调用站点的类的安全上下文。 如前所述,这意味着可以使用此查找实例将定义类的私有方法绑定到invokedynamic调用站点。

第二个参数是一个表示方法名称的String 。 该字符串用作提示,指示从调用站点应将哪种方法绑定到它。 严格来说,不需要此参数,因为将方法与其他名称绑定是完全合法的。 如果没有另外指定,Byte Buddy仅将覆盖方法的名称用作此参数。

最后,将预期返回的方法句柄的MethodType用作第三个参数。 对于上面的示例,我们明确指定希望将String作为单个参数。 同时,Byte Buddy通过查看重写的方法得出我们需要一个int作为返回值,因为我们再次没有指定任何显式的返回类型。

引导程序方法的实现者应取决于引导程序方法的确切签名,只要它至少可以接受这三个参数即可。 如果引导程序方法的最后一个参数表示Object数组,则该最后一个参数将被视为varargs,因此可以接受任何多余的参数。 这也是上述示例引导程序方法有效的原因。

此外,引导程序方法可以从invokedynamic调用站点接收多个参数,只要这些参数可以存储在类的常量池中即可。 对于任何Java类,常量池都存储在类内部使用的值,主要是数字或字符串值。 从今天起,此类常量可以是至少32位大小的原始值, StringClassMethodHandlMethodType 。 如果查找合适的方法句柄需要此类参数形式的附加信息,则可以使引导方法更灵活地使用。

Lambda表达式

每当Java编译器将lambda表达式转换为字节码时,它都会将lambda的主体复制到定义该表达式的类内部的私有方法中。 这些方法被命名为lambda$X$Y其中X是包含lambda表达式的方法的名称,而Y是从零开始的序列号。 这种方法的参数是lambda表达式实现的功能接口的参数。 假定lambda表达式不使用非静态字段或封闭类的方法,则该方法也定义为静态的。

为了进行补偿,lambda表达式本身被invokedynamic调用站点替代。 在调用时,此调用站点请求为功能接口的实例绑定工厂。 作为此工厂的参数,调用站点提供了在表达式内部使用的lambda表达式的封闭方法的任何值,并在需要时提供对封闭实例的引用。 作为返回类型,要求工厂提供功能接口的实例。

为了引导呼叫站点,当前任何invokedynamic指令都委托给Java类库中包含的LambdaMetafactory类。 然后,该工厂负责创建一个实现功能接口的类,该类调用包含lambda主体的适当方法,该主体如前所述存储在原始类中。 但是,将来这种引导过程可能会更改,这是使用invokedynamic来实现lambda表达式的主要优点之一。 如果有一天,可以使用一种更适合的语言功能来实现lambda表达式,则可以简单地替换掉当前的实现。

为了能够创建实现功能接口的类,任何表示lambda表达式的调用站点都会为bootstrap方法提供其他参数。 对于强制性参数,它已经提供了功能接口方法的名称。 而且,它提供了引导应该产生的工厂方法的MethodType 。 此外,为引导方法提供了另一个MethodType ,它描述了功能接口方法的签名。 为此,它接收到一个MethodHandle该方法引用包含lambda的方法主体的方法。 最后,调用站点提供了功能接口方法的通用签名的MethodType ,即在应用类型擦除之前在调用站点上方法的签名。

调用时,bootstrap方法将查看这些参数,并创建实现功能接口的类的适当实现。 此类是使用ASM库创建的, ASM库是一种低级字节代码解析器和编写器,它已成为直接Java字节代码操作的事实上的标准。 bootstrap方法除了实现功能接口的方法外,还添加了适当的构造函数和静态工厂方法来创建类的实例。 此工厂方法后来绑定到invokedyanmic调用站点。 作为自变量,工厂将接收lambda方法的封闭实例的实例,以防其被访问以及从封闭方法中读取的任何值。

例如,请考虑以下lambda表达式:

class Foo {
  int i;
  void bar(int j) {
    Consumer consumer = k -> System.out.println(i + j + k);
  }
}

为了执行,lambda表达式需要访问Foo的封闭实例及其封闭方法的值j。 因此,上述类的已废止版本看起来类似于以下内容,其中invokedynamic指令由某些伪代码表示:

class Foo {
  int i;
  void bar(int j) {
    Consumer consumer = <invokedynamic(this, j)>;
  }
  private /* non-static */ void lambda$foo$0(int j, int k) {
    System.out.println(this.i + j + k);
  }
}

为了能够调用lambda$foo$0 ,将封闭的Foo实例和j变量都传递到由invokedyanmic指令绑定的工厂。 然后,该工厂接收其所需的变量,以创建所生成类的实例。 然后,此生成的类如下所示:

class Foo$$Lambda$0 implements Consumer {
  private final Foo _this;
  private final int j;
  private Foo$$Lambda$0(Foo _this, int j) {
    this._this = _this;
    this.j = j;
  }
  private static Consumer get$Lambda(Foo _this, int j) {
    return new Foo$$Lambda$0(_this, j);
  }
  public void accept(Object value) { // type erasure
    _this.lambda$foo$0(_this, j, (Integer) value);
  }
}

最终,生成的类的工厂方法通过一个由ConstantCallSite包含的方法句柄绑定到invokedynamic调用站点。 但是,如果lambda表达式是完全无状态的,即它不需要访问包含它的实例或方法,则LambdaMetafactory返回一个所谓的常量方法句柄,该句柄引用一个急切创建的生成类实例。 因此,此实例用作单例,以便每次到达lambda表达式的调用站点时使用。 显然,此优化决策会影响您的应用程序的内存占用,并且在编写lambda表达式时要牢记这一点。 同样,没有工厂方法被添加到无状态lambda表达式的类中。

您可能已经注意到,lambda表达式的方法主体包含在一个私有方法中,该方法现在从另一个类中调用。 通常,这将导致非法访问错误。 为了克服此限制,使用所谓的匿名类加载来加载生成的类。 仅当通过传递字节数组显式加载类时,才可以应用匿名类加载。 另外,通常无法在用户代码中应用匿名类加载,因为匿名类加载已隐藏在Java类库的内部类中。 当使用匿名类加载来加载类时,它会收到一个其继承其完整安全上下文的宿主类。 这涉及方法和字段访问权限以及保护域,因此也可以为签名的jar文件生成lambda表达式。 使用此方法,可以认为lambda表达式比匿名内部类更安全,因为从类外部永远无法访问私有方法。

内幕:lambda表格

Lambda表单是虚拟机如何执行MethodHandles的实现细节。 由于其名称,lambda形式经常与lambda表达式混淆。 取而代之的是,lambda表单受lambda演算的启发,并因此获得了它们的名称,而不是因为其在OpenJDK中实现lambda表达式的实际用法。

在OpenJDK 7的早期版本中,方法句柄可以两种方式之一执行。 方法句柄要么直接呈现为字节码,要么使用Java运行时提供的显式汇编代码进行分派。 字节码呈现已应用于在Java类的整个生命周期中被认为是完全恒定的任何方法句柄。 但是,如果JVM无法证明该属性,则通过将其分配给提供的汇编代码来执行方法句柄。 不幸的是,由于Java的JIT编译器无法优化汇编代码,因此导致了非恒定的方法句柄调用,从而“降低了性能”。 由于这也会影响延迟绑定的lambda表达式,因此,这显然不是令人满意的解决方案。

引入LambdaForm来解决此问题。 粗略地说,lambda形式表示字节码指令,如前所述,可以由JIT编译器进行优化。 在OpenJDK中,今天,方法句柄通过LambdaForm表示MethodHandle的调用语义。 通过这种可优化的中间表示,非恒定MethodHandle的使用变得更加MethodHandle 。 实际上,甚至有可能看到字节码编译的LambdaFormLambdaForm 。 只需将断点放置在bootstrap方法内部或通过MethodHandle调用的方法内部。 断点LambdaForm可以在调用堆栈中找到经过字节码转换的LambdaForm

为什么这对动态语言很重要

应该在Java虚拟机上执行的任何语言都必须转换为Java字节码。 顾名思义,Java字节码与Java编程语言非常接近。 这包括为任何值定义严格类型的要求,并且在引入invokedynamic之前,需要一个方法调用来指定用于调度方法的显式目标类。 查看以下JavaScript代码,但是在将方法转换为字节码时无法指定任何信息:

function (foo) {
  foo.bar();
}

通过使用invokedynamic调用站点,可以将方法的调度程序的标识延迟到运行时,此外,在需要更正先前决策的情况下,可以重新绑定调用目标。 以前,使用具有所有性能缺陷的反射API是实现动态语言的唯一真正选择。

因此,invokedynamic指令的真正受益者是动态编程语言。 添加指令是使字节码格式与Java编程语言保持一致的第一步,这使JVM即使对于动态语言也成为强大的运行时。 而且,正如lambda表达式所证明的那样,这种将重点放在将动态语言托管在JVM上的重点并没有干扰Java语言的发展。 相反,Java编程语言是从这些努力中获得的。

翻译自: https://www.javacodegeeks.com/2015/04/dismantling-invokedynamic.html

小度拆卸

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值