JVM(二):垃圾收集器与内存分配策略

回顾

前面我们已经简单认识了,JVM的运行时内存,下面就认识一下Java里面的GC收集与内存分配

Java对于内存动态分配与内存回收技术已经很成熟,整体上都是自动化的,但我们仍然需要去了解垃圾回收和内存分配,因为我们需要去排查各种内存溢出、内存泄漏等问题,并且垃圾收集可能会成为高并发量的瓶颈,这一系列的问题就需要我们手动地去给垃圾收集、内存分配等进行必要的监控和调节

垃圾收集

要完成垃圾收集首先我们要考虑三件事情

  1. 哪些内存需要进行回收
  2. 什么时候进行回收
  3. 如何进行回收

对象已死

针对第一个问题,哪些内存需要进行回收,前面学习JVM的运行时内存,知道Java堆里面存放着几乎所有的对象实例,而Java堆被称为GC堆,是因为GC回收在这里是很频繁的,垃圾收集器在对堆进行回收前,第一件事就是确定这些对象之中哪些还"存活"着,哪些已经"死去"(这里的死去是指不再被任何途径使用的对象)

引用计数法

引用计数法是指:

  • 在对象中添加一个引用计数器
  • 当出现一个变量或某哥地方引用该对象时,该计数器值就会加1
  • 当引用失效时,计数器值就会减1
  • 当对象中的引用计数器0时,代表已经没有地方或者变量引用它了,可以进行回收了

虽然引用算法需要占用了一些额外的内存去维护引用计数器,但其实现原理简单,判定效率高,在大多数情况下都是一个不错的选择,但是在Java中,大多数主流的虚拟机都是没有选用引用计数法去管理内存的,这是因为使用引用计数法要考虑很多额外的情况,并且必须要配合大量的额外处理才能保证可以正确地进行工作,比如对象之间相互循环引用的问题

举个栗子

package com.mjh.spring;

/**
 * @Author: Ember
 * @Date: 2021/10/18 22:35
 * @Description:
 */
public class CommonPojo {
    public CommonPojo instance;

    public static void main(String[] args) {
        //创建对象,此时如果对于引用计数法来说
        //每个对象的引用计数都为1
        CommonPojo one = new CommonPojo();
        CommonPojo two = new CommonPojo();
        //对象之间互相引用,此时每个对象的引用计数都会变为2
        one.instance = two;
        two.instance = one;
        //结束引用,引用如果简单进行减一,下面GC可以进行吗?
        one = null;
        two = null;
        //进行GC
        System.gc();
    }
}

上一串代码如果使用简单的引用计数法,两个对象都是不可能被GC回收的

可达性分析算法

Java一般都是通过可达性分析算法来判定对象是否存活的

可达性分析算法的基本思路是:

  • 可达性分析算法拥有一系列的GC Roots,这些GC Roots根对象会作为起始节点集,有引用的对象都会与GC Roots进行直接连接或者间接连接,像一棵树一样
  • 算法会从这些GC Roots开始,根据引用关系进行向下搜索对象,搜索过程中所走过的路径就会称为引用链
  • 如果某个对象到GC Roots之间没有任何引用链相连,用图论的说法就是对象不可到达GC Roots时,证明此对象就是无引用的,可以进行回收

举个栗子
在这里插入图片描述
从上面这副图中,可以看到从obj1~obj6对于GC Roots都是可到达的,所以这些对象都会被认为是有引用对象,但对于obj7~obj9,可以看到,这三个对象虽然互相引用,但对于GC Roots是不可达的,所以这三个对象是无引用的,可以进行回收

在Java中,固定作为GC Roots的对象包括以下几种

  • 在虚拟机栈中,栈帧中的本地变量表引用的对象,比如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等(比如上边代码的栗子中,one和two都可以作为GC Root,此时他们为null了,那么其里面的instance都会变成不可达对象)
  • 在方法区中类静态属性引用的对象(方法区存放一些类信息),比如类的静态变量
  • 在方法区中常量引用的对象,比如字符串常量池的引用
  • 在本地方法栈中引用的对象,即Native方法需要用到的对象
  • Java虚拟机内部的引用,比如基本数据类型的class对象,一些常驻的异常对象,比如NullPointException、OOM等,还有类加载器
  • 被同步锁持有的对象,即Synchroniced,即加上了monitor关键字的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的问题、本地代码缓存等

除了这些固定的GC Roots集合之外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象来临时当GC Roots

因为要考虑到Java堆里面也是会分为多个内存区域的,如果只针对某个内存区域进行垃圾收集时,那就得要考虑堆中的其他区域有没有引用你这个内存区域的对象,因为每个区域都不是孤立封闭的,某个区域李的对象完全有可能被堆中其他区域的对象所引用。所以考虑GC Roots时,不仅要针对当前区域,还要考虑其他区域才是完整正确的,如果只考虑当前区域的,当前区域没有GC Roots,导致对象被GC回收了,但其他区域是对该对象是有引用的,那就发生GC错误了

再谈引用

前面已经提到了引用链这个概念,可达性分析算法中根据对象是否有引用链可达GC Roots来判断该对象是否被回收

在JDK1.2之前,Java的引用都是很传统的定义,如果reference类型的数据存储的数值代表某块内存、某个对象的地址,就称该referenct为某个对象的引用,这样的定义仅仅只能给对象两种状态,被引用和不被引用,对于一些特殊对象就无法进行形容了,比如想要一个内存足够就保留(即保留其引用),内存不够就不进行保留的对象(引用删除),所以在JDK1.2之后,Java对于引用的概念进行了补充

在Java中有四种引用,强度从大到小依次如下

  • 强引用:强引用起始就是传统的引用,也就是引用赋值,reference类型的数据存储的是某块内存、某个对象的地址,强引用是永远不会被GC的
  • 软引用:软引用是用来描述一些还有用,但非必须的对象,一般也是不会对软引用进行GC回收的,只有当发生OOM时(发生内存溢出异常时),GC收集器才会考虑回收软引用,并且如果软引用回收完了,还是内存仍然溢出,才会抛出OOM异常,Java使用SoftReference来实现软引用
  • 弱引用:弱引用也是用来形容一些非必须的对象,但是弱引用的强度比软引用还要更低一点,弱引用只能苟活到下一次GC回收,即下一次GC回收肯定会回收弱引用,对于软引用只要不是内存溢出或引用关系消失就不会回收,Java使用WeakReference类来实现弱引用
  • 虚引用:虚引用被称为幽灵引用或者幻影引用,是最弱的一种引用,甚至不会影响对象的生存时间,也就是说虚引用的存在不会影响对象的被GC,也不能通过虚引用去获取对象,虚引用的唯一作用就是当GC回收被虚引用的对象时,可以收到一个系统通知,GC收集器可以针对这个系统通知来进行处理,Java使用PhantomReference来实现虚引用

生存还是死亡

前面提到过,回收对象首先要判断该对象是生存的还是死亡的,可达性分析算法仅仅只是判断该对象是否可达而已,但即使被判定为不可达对象,也并不代表了该对象已经死亡

在Java中,宣告一个对象是否死亡,是要经历两次标记过程的,如果对象在进行可达性分析时,发现没有与GC Roots相连接的引用链,此时将会第一次进行第一次标记,随后对第一次标记的对象进行筛选,筛选的条件是此对象是否有必要去执行finalize方法,假如对象没有重写finalize方法,或者finalize方法已经被虚拟机调用过了,那么虚拟机将这两种情况都会视为没有必要去执行

在这里插入图片描述
判断对象已经死亡的两次标记

  • 第一次标记:Java虚拟机使用可达性分析算法判断出对象对于GC Roots不可达,此时对该对象进行第一次标记
  • 第二次标记:从第一次标记中进行筛选,判断对象的finalize方法(该方法来自Object,gc回收对象都会调用对象的这个方法)是否被覆盖重写、或者是否已经被Java虚拟机执行过,如果进行覆盖重写了,并且还没被Java虚拟机执行过,此时就会进行第二次标记,否则的话,都会Java虚拟机都会视该对象为没有必要去执行
  • 注意这里,第二次标记仅仅只是判断finalize方法是否有必要执行,假如没有覆盖重写,或者已经执行过finalize方法了,那就没必要进行finalize方法了,直接进入“即将进行清除”的集合,所以对象的finalize方法往往只会执行一次

当对象完成了两次标记之后,Java虚拟机就会判定这个对象有必要去执行finalize方法(其实前面的两个标记都是用来判断该对象是否有必要去执行finalize方法),那么这个对象将会被放置在一个为F-Queue的队列之中,并且会稍后由一条虚拟机自动建立的、低调度优先级的Finalizer线程去执行他们的finalize方法,但这里的执行,并不意味着一定会执行完成,也就是不会承诺会等待这个finalize方法执行完毕,这样做的原因是,如果某个对象的finalize方法执行缓慢,甚至发生了死循环,会严重地降低了F-Queue队列中其他对象进行GC,甚至还会导致整个负责内存回收的子系统的发生崩溃

虽然对象的finalize方法已经被判断为有必要去执行,但是并不一定会进行最后的GC,因为可以在finalize方法中进行停止,也就是说finalize方法是对象逃脱死亡命运的最后一次机会

GC收集器会对F-Queue中的所有对象进行第二次小规模的标记,只要对象在finalize方法重新与引用链上的任何一个GC Roots或者对象建立关系即可,比如将自己的this指针给了某个静态常量和变量,那么在第二次标记时,GC就会将其移除“即将回收”的集合;如果对象在finalize方法没有重新与GC Roots建立关联,那就真的要被回收了

但这里要注意一个点,如果在finalize进行自救,仅仅只能自救一次,因为前面提到过,finalize方法往往只会执行一次,假如第一次自救成功,在finalize方法将自己自救,那么第二次在finalize方法自救会失败的

举个栗子,运行下面的代码

public class GCFinalizeDo {
    public static GCFinalizeDo gcFinalizeDo;

    /**
     * 重写finalize方法进行自救
     * @throws Throwable
     */
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        //与静态变量进行关联,自救
        GCFinalizeDo.gcFinalizeDo = this;
        System.out.println("============尝试自救==========");
    }

    public static void main(String[] args) throws InterruptedException {
        GCFinalizeDo.gcFinalizeDo = new GCFinalizeDo();
        GCFinalizeDo.gcFinalizeDo = null;
        System.gc();

        if(gcFinalizeDo != null){
            System.out.println("===========自救成功==========");
        }else{
            System.out.println("===========自救失败==========");
        }
    }
}

结果如下
在这里插入图片描述
有点诡异,先输出自救失败,又进行尝试自救,这是因为Finalizer线程执行finalize方法的优先级比较低,前面提到过Finalizer是一个自动建立的、低调度优先级的线程

改动一下

public class GCFinalizeDo {
    public static GCFinalizeDo gcFinalizeDo;

    /**
     * 重写finalize方法进行自救
     * @throws Throwable
     */
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        //与静态变量进行关联,自救
        GCFinalizeDo.gcFinalizeDo = this;
        System.out.println("============尝试自救==========");
    }

    public static void main(String[] args) throws InterruptedException {
        GCFinalizeDo.gcFinalizeDo = new GCFinalizeDo();
        GCFinalizeDo.gcFinalizeDo = null;
        System.gc();
        //先睡5S
        Thread.sleep(500);
        if(gcFinalizeDo != null){
            System.out.println("===========自救成功==========");
        }else{
            System.out.println("===========自救失败==========");
        }
    }
}

在这里插入图片描述
整个过程如下

  • 修改静态变量的赋值,让其指向一个对象地址
  • 将静态变量指向的地址为null,此时原先指向的对象就需要发生GC
  • 为了避免该对象发生GC,在finalize方法里面对该对象重新进行引用
  • 最后自救成功

下面进行两次GC,看结果会怎样

    public static void main(String[] args) throws InterruptedException {
        GCFinalizeDo.gcFinalizeDo = new GCFinalizeDo();
        GCFinalizeDo.gcFinalizeDo = null;
        System.gc();
        //先睡5S
        Thread.sleep(500);
        if(gcFinalizeDo != null){
            System.out.println("===========自救成功==========");
        }else{
            System.out.println("===========自救失败==========");
        }

        //第二次GC测试
        GCFinalizeDo.gcFinalizeDo = null;
        System.gc();
        //先睡5S
        Thread.sleep(500);
        if(gcFinalizeDo != null){
            System.out.println("===========自救成功==========");
        }else{
            System.out.println("===========自救失败==========");
        }
    }
}

在这里插入图片描述
可以看到,尝试自救只输出了一次,这也证明了每个对象的finalize方法仅仅只会执行一次,也就是自救的机会只有一次

对于finalize方法,并不鼓励使用,因为finalize运行代价高昂,而且具有不确定性,无法保证各个对象的调用顺序,如果说再GC后要进行处理而调用这个方法,那还不如使用finally去完成,所以说这个方法真的除了自救之外没啥用途了,而且自救还会发生不确定性

回收方法区

方法区被称为HotSpot虚拟机中的元空间或者永久代(元空间就是元数据的空间,而元数据其实就是类对象)

方法区一般是没有垃圾收集行为的,但还是存在着一些收集器支持对方法区进行回收,这是因为方法区进行垃圾收集的性价比相对于Java堆来说通常也是比较低的,在Java堆的新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,而方法区由于存放的是元数据和常量,判定条件比较苛刻,所以其区域垃圾收集的回收成果往往会远低于此

方法区的垃圾回收主要关于两部分内容

  • 废弃的常量
  • 不再使用的c类、符号和字段

对于常量来说还比较容易判断,只要判断虚拟机中没有地方引用这个常量即可,但对于类的判断就比较复杂了

对于类的判断,需要判断三个方面

  • 该类所有的实例是不是都已经被回收,也就是Java堆中不存在该类以及任何派生子类的实例
  • 加载该类的类加载器是不是已经被回收
  • 该类的class是不是已经没有地方进行引用,即没有地方通过反射来访问该类

只有满足上面三个条件,Java虚拟机才允许对该无用类进行回收,这里还只是允许而已,还要涉及到垃圾收集器是否支持回收无用类

垃圾收集算法

经过前面的判断,我们已经可以决定出哪些对象可以进行回收了,下面就来看看如何进行垃圾收集

从判断对象消亡的角度出发、垃圾收集算法还可以划分为引用计数式垃圾收集和追踪式垃圾收集,这两类又通常被称为直接垃圾收集和间接垃圾收集,在Java中主要采用追踪式垃圾收集

分代收集理论

分代收集,顾名思义就是按照年龄、年代来进行收集

分代收集又建立在两个分代假说之上

  • 弱分代假说:绝大部分对象都是朝生夕灭的,发生垃圾回收就被回收掉
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难消灭

根据这两个假说,JVM收集器对于Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄用经过的垃圾回收次数来表示)分配到不同的区域之中去存储。

分代分区域的优点就在于,可以将朝生夕灭的对象集中起来,因为这些对象都很难熬过垃圾回收,那么每次对这块区域进行垃圾回收时,只要考虑如何保留少量的存活即可,不需要去标记那些大量将要被回收的对象,这样就能以较低代价回收到大量的空间;相反,如果剩下那些难消灭的对象,那么也可以把这些难以消灭的对象集中起来,只要考虑标记少量被回收的对象,根据年龄来进行这样的区域划分,虚拟机便可以用较低的频率来回收这个区域,同时兼顾乐垃圾收集的时间开销和内存的空间有效利用

因为进行区域划分,让垃圾收集器有了工作范围这一性质,所以根据工作范围,就有了各种的收集器,如下几种

  • 部分收集:PartialGc,目标不是整个Java堆,而是部分
    • MinorGc/YoungGc:新生代收集
    • MajorGc/OldGc:老年代收集,目前只有CMS收集器会单独收集老年代
    • MixedGc:混合收集,目标是整个新生代和部分老年代,目前只有GI收集器支持
  • 整堆收集:FullGc,目标是整个Java堆和方法区

同时,针对不同的区域安排与里面存储对象的存亡特征,需要采用相匹配的垃圾收集算法(如何标记、如何清除,垃圾收集器采用的算法)

  • 标记——复制算法
  • 标记——清除算法
  • 标记——整理算法

Java虚拟机一般将Java堆划分成新生代和老年代两个区域

  • 新生代:对应的就是弱分代,刚来的,朝生夕灭,没熬过垃圾回收,每次垃圾回收都会出现大量的新生代对象死亡
  • 老年代:对应的就是强分代,熬过的垃圾回收多,每次垃圾回收都只有少量的对象死亡
  • 新生代每次存活后的对象都会晋升到老年代中存放

分代收集不仅仅只是划分区域来收集这么简单,因为对象不是孤立的,是存在引用关系的,甚至会出现跨代引用的,比如新生代引用了老年代

举个栗子

比如现在要进行一次仅限于新生代区域内的收集,也就是MinocGC,但新生代中的对象完全有可能会被老年代所引用,那么这里就要再加多一层判断,判断老年代是否引用了新生代,那么此时新生代是没有意识到老年代引用了它,新生代不仅要固定的GC Roots看是否有标记,还有额外去遍历老年代中所有对象从而确保可达性分析结果的正确性,也就是说还要去考虑老年代的情况

举个栗子

在这里插入图片描述
老年代引入了新生代,新生代进行回收时,无法通过老年代最终到达GC Roots,所以也会被回收,所以回收新生代的时候,要遍历老年代,看有没有老年代用到该新生代对象

此时,就需要为分代收集理论添加第三条原则

  • 跨代引用假说:跨代引用相对于同代引用来说仅仅占极少数

这条假说之所以成立,是因为存在互相引用关系的两个对象是应该倾向于同时生存或者同时消亡的,比如一个新生代引用了老年代,老年代会称为GC Roots,那么此时新生代不能被GC清除,那么新生代在熬过了一轮GC之后就会变成老年代,此时就不存在跨代引用了

现在分代理论就变成了三条原则

  • 弱分代:对象都是朝生夕灭的

  • 强分代:熬过越多次的垃圾回收,对象就越难消灭

  • 跨分代引用:跨代引用相对于同代引用仅仅占少数

    根据第三条原则,我们就可以不再为了少量的跨代引用去扫描整个老年代了,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(记忆集),这个结构把老年代划分成若干个小块,并且表示出老年代哪一块内存存在跨代引用的问题,那么就不需要对所有的老年代进行遍历了,只需要将包含了跨代引用关系的小块内存里的对象才会被加入到GC Roots进行扫描

    这种模式虽然会增加去维护新生代记忆集的开销,但相比于遍历老年代仍然是划算的

下面就学习一下三种清除算法

标记——清除算法

标记清除算法跟其名字一样,分为两步进行

  • 标记:标记出所有需要回收的对象
  • 清除:清除回收所有已经标记过的对象

标记清除算法是基础的算法,后面的算法都是基于标记清除算法来实现的

标记的过程前面已经分析过了,标记算法的缺点在于

  • 执行效率不稳定:假如Java堆中包含大量的对象需要进行回收,这时就要进行大量的标记清除动作,即每个对象都要进行对应的标记和清除,随着对象越来越多,标记和清除的效率就越来越低(这里效率越来越低是指做的清除标记动作越来越多,完成整体的标记和清除效率降低)
  • 内存空间碎片化:对象在被标记清除之后,会产生大量不连续的内存碎片,当内存碎片太多时,后面如果要存放大对象的时候,会导致无法找到足够的空间给大对象进行分配

标记——复制算法

针对标记——清除算法的执行效率不稳定、内存空间碎片化的问题,提出了复制算法(标记——复制算法)

  • 标记:标记出仍然存活的对象
  • 复制:将仍然存活的对象复制到另外一块内存

整体的过程如下

  • 将内存按容量划分为大小相等的两块,一块用于存放对象,另一块空置(这里讨论的是半区标记复制方法)
  • 当存放对象的区域用完了,就会将还存活着的对象转移到空置的区域,并且是通过堆顶的指针去进行连续地去放置(解决了内存空间碎片化问题)
  • 对存放对象的区域进行一次清理(解决了执行效率不稳定问题)

这种算法的优点在于对于大多数对象是可回收的情况,算法需要复制的就是少数的存活对象,而且每次都是针对整个半区进行清理,减少了清理的动作,并且复制转移后,存活对象使用的内存变回连续,避免了碎片化的问题;但缺点也很明显,对于大多数对象是存活的情况,算法需要耗费较大的时间成本去进行复制转移,并且对于程序来说,可以使用的内存只有一半了,GC次数更加频繁了

Java虚拟机一般优先采用标记——复制算法去回收新生代,不过对于新生代来说,大部分的对象都要熬不过第一轮收集(大概98%),也就是说所存活的新生代很少,那么就可以去调整一下内存的分配,不需要1:1的比例去划分新生代的内存空间

先在大概已经懂了标记——复制算法大概是怎么一回事了,下面介绍一下HotSpot虚拟机采用的什么内存分配策略的标记——复制算法

Appel式回收

HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局

Apple式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor空间,当发生垃圾搜集时,会将Eden和其中一块Survivor中仍然存活的对象一次性复制到另外一块Survival空间上,然后直接清理掉Eden和Survivor空间(不包括复制转移的那一块)

HotSpot默认Eden和Survivor的比例是8:1(有两块Survivor,加起来就为1了),即新生代的对象可以使用新生代内存空间的90%,还有10%用于复制转移(默认情况下),普通环境下大概98%的新生代都要被回收,仅有2%存活,所以留下10%已经足够了,但是这只是在一般环境下,并不是针对所有环境,同时也会出现其他情况下,存活的新生代超过了10%,那怎么办呢?

发生上述情况就要依赖于其他内存区域(大多数情况是老年代)进行分配担保了,这是Apple式提供的一个逃生门功能

当Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代

标记——整理算法

对于标记——复制算法,缺点就是需要使用一块固定大小的内存来进行复制转移存活的对象,效率会降低,如果不想浪费50%的内存空间,就需要提供担保机制,所以一般对于老年代来说不会采用标记——复制算法,因为老年代绝大多数都是存活的

针对老年代的存活特征,出现了标记——整理算法

  • 标记:标记需要清除的对象(因为需要清除的对象少),与标记清除算法一样
  • 整理:让所有存活的对象都向内存空间一端移动,对边界以外的内存进行清理,同时这也是为了解决内存不连续的问题

标记整理算法与标记复制算法最大的区别在于,标记整理算法是一个移动式的,而标记复制是非移动式的,标记整理算法是将对象内容整个移动到了边界,中间是没有缓冲的,而标记复制是将对象进行复制了之后然后改变指针转向的,相当于中间多了个缓冲的过程,而且老年代存活的对象还很多,也就是说整个移动并更新引用这些对象的地方会是一个很负重的操作,而且整个过程必须全程暂停用户应用程序才能继续进行(清除标记法也是需要停顿用户线程来标识、清理可回收对象的,只不过停顿时间比较短而已)

标记整理算法拉跨的就在于移动的过程,如果不移动,那就与标记清除方法无异了,而且还要想额外的方法来解决内存不连续的问题,比如采用“分区空闲分配链表”来解决,就是大文件不要求使用物理连续的磁盘空间,而是通过链表让碎片化的内存空间来逻辑地连接起来,但这种操作会给程序给对象分配内存添加额外的维护负担,减少了吞吐量 ;如果移动,就会出现暂停服务时间段

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值