JVM八股文,面试会被问到什么?都在这里啦 ~

目录

1、JVM内存划分

1.1、程序计数器(Program Counter Register)

1.2、方法区(Method Area)

1.3、本地方法栈(Native Method Stacks)

1.4、虚拟机栈(JVM Stacks)

1.5、Java堆(Heap)

2、JVM类加载

2.1、加载过程

2.2、类加载流程

2.3、类加载器【双亲委派模型】

3、JVM的垃圾回收

3.1、如何确定对象已死

3.1.1、回收哪些内存

3.1.2、基于引用计数找垃圾(Java不采取该方案)

3.1.3、基于可达性分析找垃圾(Java采取方案)

3.1.4、再谈引用

3.2、垃圾回收算法

3.3.1标记-清除算法

3.3.2标记-复制算法

3.3.3标记-整理算法

3.3.4分代回收

3.3、垃圾收集器

1)、Serial收集器

2)、ParNew收集器

 3)、CMS收集器

 4)、G1收集器(唯一一款全区域的垃圾回收器)


1、JVM内存划分

        JVM区域包括以下几个:程序计数器,栈,堆,方法区,图中的元数据区可以理解为方法区 【1.8之后搞了个元数据区,在这之前都是方法区的】,毕竟JVM有很多版本,不同版本的运行时区域划分也不一样。

运行时区域划分

1.1、程序计数器(Program Counter Register)

        它的作用就是记录当前线程所执行的位置。 这样,当线程重新获得CPU的执行权的时候,就直接从记录的位置开始执行,分支、循环、跳转、异常处理也都依赖这个程序计数器来完成。

1.2、方法区(Method Area)

方法区也是一块被重点关注的区域,主要特点如下:

  • 线程共享区域,因此这是线程不安全的区域。

  • 它用于存储类对象(已被虚拟机加载的类信息、常量、静态变量),即时编译器编译后的代码等数据。

  • 当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

1.3、本地方法栈(Native Method Stacks)

        本地方法栈与虚拟机栈所发挥的作用是非常相似的(放的是方法之间的调用关系),其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

        虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

1.4、虚拟机栈(JVM Stacks)

  • Java虚拟机栈是线程私有的,每一个线程都有独享一个虚拟机栈,它的生命周期与线程相同。

  • 虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  • 存放基本数据类型(boolean、byte、char、short、int、float、long、double)以及对象的引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

  • 这个区域可能有两种异常:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩,当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。

1.5、Java堆(Heap)

栈管运行,堆管存储。则虚拟机栈负责运行代码,而虚拟机堆负责存储数据。

Java堆区具有下面几个特点:

  • 存储的是我们new来的对象,不存放基本类型和对象引用。

  • 由于创建了大量的对象,垃圾回收器主要工作在这块区域。

  • 线程共享区域,因此是线程不安全的。

  • 能够发生OutOfMemoryError。

        其实,Java堆区还可以划分为新生代和老年代,新生代又可以进一步划分为Eden区、Survivor 1区、Survivor 2区

举例来说:

  1. 代码里局部变量---栈
  2. 代码里成员变量---堆
  3. 代码里静态变量---方法区

例如:

​​​​​​​


2、JVM类加载

2.1、类加载是干啥

        如果JVM想要执行这个.class文件,我们需要将其装进一个类加载器中,它就像一个搬运工一样,会把所有的.class文件全部搬进JVM里面来

 知识点:

  • Java文件经过编译后变成了.class字节码文件(java程序在运行之前,需要先编译。.java->.class文件--二进制字节码文件)
  • 字节码文件通过类加载器被搬运到JVM虚拟机中(运行的时候,java进程[JVM]就会读取对应的.class文件,并且解析内容,在内存中构造出类对象并进行初始化等---总结:就是要把类加载到内存当中)
  • 虚拟机主要的5大部分:

         方法区、堆都是线程共享区域,有线程安全问题;栈、本地方法栈、计数器都是线程独享区域,不存在线程安全问题;而JVM的调优主要是围绕堆、栈两大块进行的。

  • 总结来看,JVM主要通过分为以下4个部分,来执行Java程序的,分别是:类加载器(ClassLoader)、运行时数据区(Runtime Data Area)、执行引擎(Execution Engine)、本地库接口(Native Interface)

2.2、类加载流程

        类加载的过程包括了加载、验证、准备、解析、初始化5个阶段。在这5个阶段中,加载、验证、准备和初始化这4个阶段发生的顺序是确定的,而解析阶段就不一定了,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始的,而不是按顺序进行或完成的,因为这些阶段通常都是互相交叉的混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段 

  •  加载:查找并加载类的二进制数据;在Java堆中也创建一个java.lang.Class类的对象,作为方法区这个类的各种数据的访问入口【找到.class文件,读取文件内容,并且按照.class规范的格式来解析】
  • 连接:连接包含了三个部分:验证、准备、初始化   
  • 验证:文件格式、元数据、字节码、符号引用验证【检查看当前的.class里的内容格式是否符合要求,.class文件长啥样都是有要求,不同jdk版本之间,要求也会稍微有偏差】
  • 准备为类的静态变量分配内存,并将其初始化为默认值【例:public static int a = 12;它的默认初始值为00000而不是12】
  • 解析把类中的符号引用(占位符)转换为直接引用(内存地址),也就是初始化常量的过程【例如有一行代码是String s = "hello"; 但是在类加载之前,“hello”这个字符串常量是没有分配内存空间,是需要类加载完了之后才有内存空间的,那这个时候还没有内存空间,s里就无法保存字符串变量的真实地址,只能先使用一个占位符,标记一下这个“hello”常量的地址,等到真正的“hello”分配过内存之后,然后就可以用这个真正的地址替代之前的占位符了】
  • 初始化:为类的静态变量赋予正确的初始值,初始化阶段就是执行类构造器方法的过程【针对类进行初始化,初始化静态成员、执行静态代码块、并且加载父类等...】

        这里真正的对类对象进行初始化,特别是静态成员。类加载过程中是在执行某方法(如main方法)之前执行的,类加载的时候会进行静态代码块的执行,想要创建实例,必然先得类加载,静态代码块只会执行一次,构造方法与普通代码块每次实例对象都会执行,并且普通代码块比静态代码块先执行

        所以可以得到结论,静态的代码块,普通代码块,构造方法执行顺序为:静态代码块--普通代码块--构造方法

  • 使用:new出对象程序中使用
  • 卸载:执行垃圾回收

何时出发类加载?

        当使用到这个类时,就会被触发加载(类并不一定是程序一启动就加载了,而是在第一次使用才加载),例如以下场景:

  1. 创建这个类的实例
  2. 使用了类的静态方法/静态属性
  3. 使用类的子类(加载子类会先加载父类)

2.3、类加载器【双亲委派模型】

         双亲委派模型是类加载中的一个环节,属于Loading阶段,它是描述如何根据类的全限定名找到class文件的过程。

在JVM里面提供了一组专门的对象,用来进行类的加载,即类加载器,当然既然双亲委派模型是类加载中的一部分,所以其所描述找.class文件的过程也是类加载器来负责的

但是想要找全class文件可不容易,毕竟.class文件可能在jdk目录里面,可能在项目的目录里面,还可能在其他特定的位置,因此JVM提供了多了类加载器,每一个类加载负责在一个片区里面找,毕竟分工明确,才能事半功倍

默认的类加载器主要有三个:

  • BootStrapClassLoading:负责加载标准库里面的类,如:String,Random,Scanner等
  • ExtensionClassLoader:负责加载JDK扩展的类,现在基本上很少使用了
  • ApplicationClassLoader:负责加载当前项目目录中的类

        除了默认的几个类加载器,程序员还可以自定义类加载器,来加载其他目录的类,如Tomcat就自定义了类加载器,用来专门加载webapp目录中的.class文件,但是自定义的类加载器未必要遵守双亲委派模型,毕竟你在自己特定的目录下还没有找到对应的.class文件,再去标准库去找基本也是未果,Tomcat中的自定义的类加载器就没有遵守双亲委派模型

        而双亲委派模型就描述了类加载过程中的找目录的环节,它的内容如下:

        如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。

        因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(去自己的片区搜索)。

举两个栗子:第一个,我们要去找标准库里面的String.class文件,它的过程大致如下:

  • 首先ApplicationClassLoader类收到类加载请求,但是它先询问父类加载器是否加载过,即询问ExtensionClassLoader类是否加载过。
  • 如果ExtensionClassLoader类没有加载过,请求就会向上传递到ExtensionClassLoader类,然后同理,询问它的父加载器BootstrapClassLoader是否加载过。
  • 如果BootstrapClassLoader没有加载过,则加载请求就会到BootstrapClassLoader加载器这里,由于BootstrapClassLoader加载器是最顶层的加载器,它就会去标准库进行搜索,看是否有String类,我们知道String是在标准库中的,因此可以找到,请求的加载任务完成,这个过程也就结束了。
     

第二个栗子,我要加载搜索项目目录中的Test类,过程如下:

  • 首先ApplicationClassLoader类收到类加载请求,但是它先询问父类加载器是否加载过,即询问ExtensionClassLoader类是否加载过。
  • 如果ExtensionClassLoader类没有加载过,请求就会向上传递到ExtensionClassLoader类,然后同理,询问它的父加载器BootstrapClassLoader是否加载过。
  • 如果BootstrapClassLoader没有加载过,则加载请求就会到BootstrapClassLoader加载器这里,由于BootstrapClassLoader加载器是最顶层的加载器,它就会去标准库进行搜索,看是否有Test类,我们知道Test类不在标准库,所以会回到子加载器里面搜索。
  • 同理,ExtensionClassLoader加载器也没有Test类,会继续向下,到ApplicationClassLoader加载器中寻找,由于ApplicationClassLoader加载器搜索的就是项目目录,因此可以找到Test类,全过程结束。

当然,如果在ApplicationClassLoader还没有找到,就会抛出异常。

 总的来说,双亲委派模型就是找class文件的过程,只是名字挺高大上的,其实也没啥。

 1

双亲委派模型的优点:

  • 当自定义类与标准库中的类重名时,一定会加载标准库中的那个类,保证了Java的核心API不会被篡改。
  • 使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一。
  • 避免重复加载类:比如 A 类和 B 类都有一个父类 C 类,那么当 A类 启动时就会将 C 类加载起来,那么在 B 类进行加载时就不需要在重复加载 C 类了。
     

3、JVM的垃圾回收

3.1、如何确定对象已死

3.1.1、回收哪些内存

        我们知道我们运行时,内存分为四个区,分别是程序计数器,栈,堆,方法区。
对于程序计数器,它占据固定大小的内存,不涉及释放,那么也就用不到GC;对于栈空间,函数执行完毕,对应的栈帧自动释放了,也不需要GC;对于方法区,主要进行类加载,虽然需要进行"类卸载",此时需要释放内存,但是这个操作的频率是非常低的;最后对于堆空间,经常需要释放内存,是最需要进行GC的。

        在堆空间,内存的分布有三种,一是正在使用的内存,二是未使用且未回收的内存,三是未分配的内存,那内存中的对象,也有三种情况,对象内存全部在使用(相当于对象整体全部在使用),对象的内存部分在使用(相当于对象的一部分在使用),对象的内存不使用(对象也就使用完毕了),对于这三类对象,前两类不需要回收,最后一类需要回收。

        所以,垃圾回收的基本单位是对象,而不是字节,对于如何找到垃圾,常用有引用计数法与可达性分析法两种方式。

3.1.2、基于引用计数找垃圾(Java不采取该方案)

         所谓基于引用计数判断垃圾,就是给每一个对象带上一个计数器,来记录该对象被多少个引用变量所指,如果这个计数器的值为0则表示该对象需要回收,比如有一个Test对象,它被两个引用所指,所以这个Test对象所带计数器的值就是2

        如果上述的伪代码是在一个方法中,待方法执行完毕,方法中的局部引用变量被销毁,那么Test对象的引用计数变为0,此时就会被回收。

由此可见,基于引用计数的方案非常简单高效并且可靠,但是它拥有两个致命缺陷:

  • 当对象的内存大小较小时,由于每个对象都需要一个计数器,因此空间利用率较低。
  • 存在循环引用的问题,会出现对象既不使用也不释放的情况。

第一点好理解,就是当计数器与对象所需内存差不多的情况下,空间利用率只有50%,并不高。
第二点,难理解一点,下面我们来举例子进行分析。

class Test {
	Test t = null;
}

//main方法中:
Test t1 = new Test();
Test t2 = new Test();
t1.t = t2;
t2.t = t1;

执行上述伪代码,运行时内存图如下:

然后,我们把变量t1t2置为null,伪代码如下:

//伪代码:
t1 = null;
t2 = null;

 执行完上面伪代码,运行时内存图如下:

         根据图可知,两个对象的属性相互指向另一个对象,使得计数器的值都为1,由于对象外界没有指向这两个对象的引用,于是这两个对象处于既不被使用,也不被释放的尴尬场景当中,这就是循环引用问题。

3.1.3、基于可达性分析找垃圾(Java采取方案)

        所谓可达性分析就是通过额外的线程,对整个内存空间的对象进行扫描。但扫描不是盲扫,先会找到一些起始位置GCRoots,从该位置开始,以类似深度优先搜索的方式去找对象,它会对所有可以找到的对象进行标记,没有找到的对象自然也就没有标记,那这部分有标记的对象是可达的,未被标记的对象是不可达的,未被标记的对象就会被JVM视为垃圾进行回收。
1

对于这个GCRoots,它来自:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,例如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。【栈上的变量
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。【栈上的变量
  • 常量池中引用所指向的对象。【常量值引用的对象
  • 方法区中静态成员所指向的对象。【方法区,引用类型的静态变量
  • 所有被同步锁(synchronized关键字)持有的对象。
     

3.1.4、再谈引用

        前面的两种方式判断存活时都与‘引用’有关。但是 JDK 1.2 之后,引用概念进行了扩充,下面具体介绍。

下面四种引用强度一次逐渐减弱

强引用

类似于 Object obj = new Object(); 创建的,只要强引用在就不回收。

软引用

SoftReference 类实现软引用。在系统要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行二次回收。

弱引用

WeakReference 类实现弱引用。对象只能生存到下一次垃圾收集之前。在垃圾收集器工作时,无论内存是否足够都会回收掉只被弱引用关联的对象。

虚引用

PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

3.2、垃圾回收算法

垃圾回收的算法最常见的有以下几种:

标记-清除算法
标记-复制算法
标记-整理算法
分代回收算法
(本质就是综合上述算法,在堆的不同区采取不同的策略)

3.3.1标记-清除算法

        标记其实就是可达性分析的过程,在可达性分析的过程中,会标记可达的对象,其他 不可达的对象,都会被视为垃圾进行回收。

比如经过一轮标记后,标记状态如图:

 回收后,内存状态如图:

        我们发现,内存是释放了,但是回收后,未分配的内存空间有点“散”啊,我们知道申请内存的时候得到的内存是连续的,所以说,由于未分配的内存是碎片化的,假设你的主机有1GB为分配的内存,但是这些内存是碎片形式存在的,当申请500MB内存的时候,可能会申请失败,毕竟不能保证有一块大于500MB的连续内存空间,这也是标记-清除算法的缺陷。

3.3.2标记-复制算法


        为了解决标记-清除算法所带来的内存碎片化的问题,引入了复制算法。

        它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况。

        复制算法的第一步还是要通过可达性分析进行标记,得到那一部分需要进行回收,那一部分需要保留,不能回收。

        标记完成后,会将还在使用的内存连续复制到另外一块等大的内存上,这样得到的未分配内存一直都是连续的,而不是碎片化的。

 回收后,复制算法得到的未分配空间是连续的,完美地弥补了清除算法的缺陷。

但是,复制算法也有缺陷:

  • 空间利用率低。
  • 如果可回收的内存少,需保留的内存大,复制的开销也大。

3.3.3标记-整理算法

        标记-整理算法针对复制算法做出进一步改进。其中的标记过程仍然与“标记-清除”算法一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

标记回收前的内存图:

        然后,将存活对象按照某一顺序(比如从左到右,从上到下的顺序)拷贝到非存活对象的内存区域,得到了以下回收后的内存分布图:

解决了标记-复制算法空间利用率低的问题,但是复制的开销问题并没有得到解决。

3.3.4分代回收

        上述的回收算法都有缺陷,分代回收就是将上述三种算法结合起来分区使用,分代回收会针对对象进行分类,以熬过的GC扫描轮数作为“年龄”,然后针对不同年龄采取不同的方案。

        我们知道GC主要是回收堆上的无用内存,我们先来了解一下堆的划分,堆包括新生代(Young)、老年代(Old),注意这个名称不是固定的,在IBM J9虚拟机中对应称为婴儿区(Nursery)和长存区(Tenured),名字不同但其含义是一样的。而新生代包括一个伊甸区(Eden)与两个幸存区(Survivor,分代回收算法就会根据不同的代去采取不同的标记-xx算法。
堆

        在新生代,包括一个伊甸区与两个幸存区,伊甸区存储的是未经受GC扫描的对象,也就是刚刚创建的对象。

        幸存区存储了经过若干轮存储的对象,通过实际经验得出,新生代的对象具有“朝生夕灭”的特点,也就是说只有少部分的伊甸区对象才能熬过第一轮的GC扫描,所以到幸存区的对象相比于伊甸区少的多,正因为大部分新生代的对象熬不过JVM第一轮扫描,所以伊甸区与幸存区的分配比例并不是1:1的关系,HotSpot虚拟机默认一个Eden和一个Survivor的大小比例是8∶1,正因为新生代的存活率较小,所以新生代使用的垃圾回收算法为标记-复制算法最优,毕竟存活率越小,对于标记-复制算法,复制的开销也就很小。

        不妨我们将第一个Survivor称为活动空间,第二个Survivor称为空闲空间,一旦发生GC,将10%的活动区间与另外80%中存活的对象复制到10%的空闲空间,接下来,将之前90%的内存全部释放,以此类推。

        在后续几轮GC中,幸存区的对象在两个Survivor中进行标记-复制算法。

        在继续持续若干轮GC后,幸存区的对象就会被转移到老年代,老年代中都是年龄较老的对象,根据经验,一个对象越老,继续存活的可能性就越大,因此老年代的GC扫描频率远低于新生代,所以老年代采用标记-整理的算法进行内存回收,毕竟老年代存活率高,对于标记-整理算法,复制转移的开销很低。

        特殊情况:如果对象是一个非常大的对象,则直接进入老年代【1、大对象进行复制算法,开销太大   2、既然是一个很大的对象,费劲创建出来,肯定不是立即就销毁的】

3.3、垃圾收集器

1)、Serial收集器

这一部分,我们了解即可,首先有请历史最悠久的Serial收集器(新生代收集器,串行GC)与 Serial Old收集器(老年代收集器,串行GC)登场,这两类收集器前者是新生代收集器,后者是老年代收集器,采用串行GC的方式进行垃圾收集,由于串行GC开销较大,会产生较严重的STW。

STW是什么?

        Stop The World (STW),你可以理解为你打游戏的时候,你的xxx来干xxx,使得你不得不中断游戏,这段中断的时间就相当于STW,或者你理解为由于设备原因使得你打游戏很卡,这些卡顿的时间就是STW。(这也算是GC带来的最大的问题,会引入额外的空间和时间上的开销,反应在用户这里就是明显卡顿,但是GC又能提高我们的开发效率,所以java就采用了这种方式)

2)、ParNew收集器

        然后就是 ParNew收集器(新生代收集器,并行GC),Parallel Scavenge收集器(新生代收集器,并行GC),Parallel Old收集器(老年代收集器,并行GC),前两个是新生代的收集器,最后一个是老年代的收集器,这组收集器引入了多线程,并发情况下,GC处理效率相比于前一组更高,但是如果在单线程情况下,可能不会比Serial收集器要好,此外,Parallel Scavenge收集器相比于 ParNew收集器只是多了些参数而已。

 3)、CMS收集器

CMS收集器,该收集器设计的初衷是尽量使得STW时间尽量地短, 特点:

  • 初始标记,过程速度很快,只是找到GCRoots,只会引起短暂的STW。
  • 并发标记,虽然速度很慢,但是它可以和业务线程并发执行,不会产生STW。
  • 重新标记,在并发标记过程中,业务代码可能会改变标记的结果,需要进行一次微调,由于是微调,引起的STW很短。
  • 回收内存,也是和业务代码并发。

        前三部分的标记过程就是将可达性分析给拆开了,回收内存主要用于标记-整理,老年代专属。

 4)、G1收集器(唯一一款全区域的垃圾回收器)

        G1收集器,它把内存分为分成了很多的小区域(Region),并且给这些Region做了标记,有些Region放新生代对象,有些Region放老年代对象。

        GC扫描的时候,只扫描一部分Region,不追求一次扫描完,分多次来扫描,这样对业务代码执行影响更小。

        G1收集器可以优化STW的时间小于1ms。本质上CMS与G1都是化整为零的思想。

        最后,做个小总结,垃圾回收本质靠运行时环境,来帮助程序员完成内存释放的工作,但是它有以下缺点:

  • 产生额外的开销。
  • 可能会影响程序的流畅运行(STW造成)

下期见!!! 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

龙洋静

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

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

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

打赏作者

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

抵扣说明:

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

余额充值