Unity GC + C# GC + Lua GC原理

Unity垃圾回收原理

参考文章:垃圾回收 (计算机科学) - 维基百科,自由的百科全书 (wikipedia.org)

在计算机科学中,垃圾回收(英语:Garbage Collection,缩写为GC)是指一种自动的存储器管理机制。当某个程序占用的一部分内存空间不再被这个程序访问时,这个程序会借助垃圾回收算法向操作系统归还这部分内存空间。垃圾回收器可以减轻程序员的负担,也减少程序中的错误。

原理

垃圾回收器有两个基本的原理:

  1. 考虑某个对象在未来的程序执行中,将不会被访问。
  2. 回收这些对象所占用的存储器。

分类

收集器实现

引用计数收集器

主条目:引用计数

最早的也是最简单的垃圾回收实现方法,这种方法为占用物理空间的对象附加一个计数器,当有其他对象引用这个对象时计数器加一,反之引用解除时减一。这种算法会定期检查尚未被回收的对象的计数器,为零的话则回收其所占物理空间,因为此时的对象已经无法访问。这种方法无法回收循环引用的存储对象。

跟踪收集器

主条目:追踪垃圾回收

近现代的垃圾回收实现方法,这种算法会定期遍历它管理的内存空间,从若干根储存对象开始查找与之相关的存储对象,然后标记其余的没有关联的存储对象,最后回收这些没有关联的存储对象占用的内存空间。

回收算法

基于其标记和回收行为,又分为若干细致方法。

标记-清除

先暂停整个程序的全部运行线程,让回收线程以单线程进行扫描标记,并进行直接清除回收,然后回收完成后,恢复运行线程。这样会产生大量的空闲空间碎片,和使大容量对象不容易获得连续的内存空间,而造成空间浪费。

标记-压缩

和“标记-清除”相似,不同的是,回收期间同时会将保留的存储对象搬运汇集到连续的内存空间,从而整合空闲空间,避免内存碎片化。

复制

需要程序将所拥有的内存空间分成两个部分。程序运行所需的存储对象先存储在其中一个分区(定义为“分区0”)。同样暂停整个程序的全部运行线程,进行标记后,回收期间将保留的存储对象搬运汇集到另一个分区(定义为“分区1”),完成回收,程序在本次回收后将接下来产生的存储对象会存储到“分区1”。在下一次回收时,两个分区的角色对调。[3]

这种方式非常简单,但是因为只有一个“半空间”(semi-space)被用于分配对象,内存使用相较于其他算法是其两倍。这种技术也叫做“停止并复制”。Cheney算法是改进的半空间分配器。

增量回收器

需要程序将所拥有的内存空间分成若干分区。程序运行所需的存储对象会分布在这些分区中,每次只对其中一个分区进行回收操作,从而避免暂停所有正在运行的线程来进行回收,允许部分线程在不影响回收行为下保持运行,并且降低回收时间,增加程序响应速度。

分代

由于“复制”算法对于存活时间长,大容量的储存对象需要耗费更多的移动时间,和存在储存对象的存活时间的差异。需要程序将所拥有的内存空间分成若干分区,并标记为年轻代空间和年老代空间。程序运行所需的存储对象会先存放在年轻代分区,年轻代分区会较为频密进行较为激进垃圾回收行为,每次回收完成幸存的存储对象内的寿命计数器加一。当年轻代分区存储对象的寿命计数器达到一定阈值或存储对象的占用空间超过一定阈值时,则被移动到年老代空间,年老代空间会较少运行垃圾回收行为。一般情况下,还有永久代的空间,用于涉及程序整个运行生命周期的对象存储,例如运行代码、数据常量等,该空间通常不进行垃圾回收的操作。 通过分代,存活在局限域,小容量,寿命短的存储对象会被快速回收;存活在全局域,大容量,寿命长的存储对象就较少被回收行为处理干扰。

现今的GC(如Java.NET)使用分代收集(generation collection),依照对象存活时间的长短使用不同的垃圾收集算法,以达到最好的收集性能。

原文链接:Unity GC 学习总结 - 知乎 (zhihu.com)

什么是GC

总所周知,内存是程序运行时所需要的重要资源,在程序运行时往往需要内存来临时存储各种数据,但是操作系统提供给进程的堆内存(注意是堆内存,栈上的内存会随函数调用自动被回收,下文提及的都是指堆内存)是有限的,所以我们需要对这有限的资源进行管理

在代码中,我们会反复地申请内存来完成各种计算,等到确认内存不需要使用时,我们就会归还这部分内存,从而可以将其用于其他地方。GC所做的事情,就是自动确定那些不需要的内存,或者说 Garbage ,然后将其归还。这样开发者就无需关心内存的管理。

GC的实现

实现GC的策略有很多种,其中最常见一种就是 Tracing garbage collection,或者叫 Mark-Sweep,这种算法会通过一个 root Object,遍历这个该对象引用的变量,并且标记,递归这个过程,这样就确定了所有reachable的对象,剩下的对象即视为garbage。

另一种常见的策略还有引用计数(Reference counting),它是通过为每个对象维护一个引用计数,这代表当前对该对象的引用数目,当引用为0,即代表该对象为 Garage。引用技术有如下缺点

  • 循环引用问题
  • 保存计数带来的空间开销
  • 修改引用数目带来的速度开销以及原子性要求
  • 非实时(一个引用的变化可能递归得导致一系列引用修改,内存释放)

有很多算法可以一定程度解决上述问题,顺便一提,C++使用的智能指针即是基于引用计数实现的,COM对象也使用了引用计数来管理。

GC的优缺点

优点

如上文提及的,可以将程序从对内存的维护中解放出来,专心于代码逻辑。不会发生因为内存管理不当而导致的问题,例如

  • 内存泄漏
  • 访问已经释放的指针
  • 反复释放指针

缺点

那么代价是什么呢?享受 GC 带来的便利,意味你必须承受 GC 开销对性能的影响,眼睁睁地看着它费老大劲去处理一个你一眼看出来的 Garbage 。比如

Unity 中的GC

Unity的脚本后端是基于Mono的实现(当然现在多了个IL2CPP,不过也是类似的GC实现),而Mono使用的GC是所谓的Boehm–Demers–Weiser garbage collector。是Mark-Sweep 的实现,它会在需要进行GC时占用主线程,进行遍历-标记-垃圾回收的过程,然后在归还主线程控制权。这会导致帧数的突然下降,产生卡顿(不过因为该实现是非压缩式的,所以卡顿现象相对较轻,但是对内存利用率进一步下降了,会有内存碎片的问题。。囧)。所以我们需要慎重地处理对象的创建(内存请求),还有释放(使用GC管理内存是没有主动释放内存的接口的,但是我们可以通过消除对某个对象的引用来做到这一点)。此外,Unity的代码分为两部分:托管与非托管,GC影响的只有托管部分的代码使用的堆内存。而且这个托管堆占用的地址空间不会返还给操作系统,非托管内存需要手动维护

GC的优化

上文讲到了GC对性能影响的原因(占用主线程进行大量工作),而优化GC即是减小GC占用主线程时花费的CPU时间,所以优化GC优化的是CPU时间,而非内存,事实上常见的优化GC的手段之一就是占用内存

排查热点

优化的第一步就是确定性能热点,我们可以使用 Unity 自带的 Profiler 中 CPU Usage里的Garbage Collector来确定,或者粗暴一点使用 GarbageCollector.GCMode 这一接口来关掉GC,然后观察 Profiler 中 Memory里的 Total GC Allocated 来确定。不过该接口无法用于编辑器下。

常见热点与优化方式

GC优化的核心在于消除垃圾,减小GC运行时间。GC的热点一般都是写了一些会产生大量垃圾的代码。

1.字符串

using UnityEngine;
using UnityEngine.UI;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    public Text scoreBoard;
    public int score;
    
    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}

上述代码中拼接字符串会导致一些额外的中间对象产生,所以会大量创建临时的变量,可以通过使用StringBuilder来优化。此外还在Update中每帧调用,进一步恶化了问题,创建了更多的临时变量。可以通过将变量改为非局部变量来解决(这也就是上面讲的占用内存,优化GC),上述代码即可以优化成

using UnityEngine;
using UnityEngine.UI;
using System.Collections;
using System.Text;

public class ExampleScript : MonoBehaviour
{
    public Text scoreBoard;
    public StringBuilder scoreText;
    public int score;
    public int oldScore;

    void Update()
    {
        if (score != oldScore)
        {
            scoreText.Clear();
            scoreText.Append("Score: ");
            scoreText.Append(score.ToString());
            scoreBoard.text = scoreText.ToString();
            oldScore = score;
        }
    }
}

StringBuilder   

[C#] StringBuilder简介及使用方法_c# stringbuilder length方法-CSDN博客

C#中,StringBuilder弥补了string在赋值时开辟新空间不足之处。

StringBuilder类型变量会初始化一段长度,供后续对该变量进行增加。当然也可以手动定义其长度

StringBuilder builder = new StringBuilder(10);

其缺点是需要较为精确估算出StringBuilder类型变量的长度,否则若在使用中实际builder长度超出了定义的长度,会自动开辟一段新的StringBuilder空间,并将原先的数据赋值给新的空间,旧的地址就变成了垃圾。

StringBuilder builder = new StringBuilder(10);
for (int i = 1; i < 10; i++) 
{
    builder.Append(i); // 0123456789
}

整个操作都是在一处内存地址,提高了内存利用率。在Unity实际开发时也有很大的用处

实例方法

单词反转 Hello world => world Hello

private static StringBuilder t3(string str)
{
    string[] str2 = str.Split(' ');
    StringBuilder builder = new StringBuilder(str.Length);
    for (int i = str2.Length - 1; i >= 0; i--)
    {
        builder.Append(str2[i]);
        if (i != 0)
            builder.Append(" ");
    }
    return builder;
}
 
// 调用
Console.WriteLine(t3("Hello world")); // world Hello

2.闭包

闭包的使用也需要慎重,因为闭包除了函数指针还会将捕获的变量一起包装起来创建到堆上,相当于 new 了个对象,性能敏感部分代码还是要慎重使用。可以通过将匿名函数改为成员函数,捕获变量改为成员变量一定程度上缓解,不过还是会有影响。

3.装箱

还有要小心装箱,这也会隐式地导致对象的创建。从而产生意想不到的垃圾。用枚举值当字典的key的时,各种字典操作会调用 Object.getHashCode 获取哈希值 ,该方法会导致装箱。Unity5.5版本以前 foreach 会导致装箱,这之后的版本修复了这个问题,但是 foreach相比起直接使用下标遍历还是要慢(因为有一些额外的方法调用),不过这就和GC没啥关系了。

4.返回数组的Unity API

应该是为了防止意外修改内部值,Unity API返回数组对象时返回的是一份拷贝。类似下面的代码

for(int i = 0; i < mesh.vertices.Length; i++)

{

    float x, y, z;

    x = mesh.vertices[i].x;

    y = mesh.vertices[i].y;

    z = mesh.vertices[i].z;

    // ...

    DoSomething(x, y, z);   

}

会导致4次数组拷贝,可以通过cache返回的数组(返回引用解决)来解决。

5.空数组

空数组(长度为0的数组)的创建事实上也会导致堆内存的分配。所以应该将其提前创建出来并复用。

上述问题的原因都是类似的,即大量地创建了短暂使用的对象(垃圾),基本上都可以通过将会反复使用的对象创建为非局部变量来解决(或者更进一步,使用所谓对象池的技术,基本原理是一样的)。有些地方就只能通过避免会造成垃圾产生的接口来解决。总之优化GC,核心在于消灭垃圾

特别的技巧

1.关闭GC

可以把需要的内存先全部创建完,然后关掉GC,不过感觉这种方式应用场景太有限。

2.主动定时GC

游戏的卡顿来自与不稳定的帧数变化(稳定的低帧数和不稳定的高帧数前者可以带来更平滑的体验),所以可以按一定间隔主动地调用 System.GC.Collect 进行GC,这样就不会有剧烈的毛刺产生,当然这个间隔不能太小,否则就和不主动调用区别不大,但也不能太小,否则会对帧数造成明显影响,具体数值的确定还是很难的。

3.主动扩大托管堆

Mono的GC会尽量避免内存的扩展,所以说它对判断 需要进行GC 了的阈值比较低,可能已分配内存达到当前GC管理内存的70%~80%就会进行GC了,如果GC的持有内存足够大的话,就会减少GC的触发,可以通过类似下面的代码

using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void Start() {
        var tmp = new System.Object[1024];
        
        // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
        for (int i = 0; i < 1024; i++)
            tmp[i] = new byte[1024];
        
        // release reference
        tmp = null;
    }
}

来强行扩大GC管理内存的大小。不过实际开发中还有贴图之类的内存大户,留给GC的可以内存实在不多,盲目请求过大的内存可能会被操作系统无情干掉,要慎重。而且因为托管堆占用的地址空间并不会归还,所以请求太大的托管堆会导致内存的浪费。。这种做法算是空间换时间。

增量式GC(incremental garbage collection)

上文提到的 Unity GC实现是非分代式的,也就是是说,要么不做,要做就一次性作完。unity 在 2018 的版本推出了所谓增量式GC的功能,还是基于 Boehm–Demers–Weiser garbage collector 的实现,但是不再是非分代式的,这能带来特别的技巧♂中第二点同样的好处,即均衡负载到多帧,消除毛刺。可以缓解卡顿。因为GC的执行分配到每帧了,所以单帧GC的执行时间会受到垂直同步 还有 unity 的 Application.targetFrameRate 的影响。

增量式GC目前还是抢先体验版本,因为它事实上还是存在一些问题,它的基本实现原理还是标记-清扫,但是在两次增量式GC之间,对象的引用可能会发生变化,导致前一次GC的标记失效,需要重新进行遍历标记,最糟的情况会退化为普通的非分代GC(其实更糟,因为前面的工作全白费了)。比如这样的代码

void Update()
    {
        if (Time.frameCount % 2 == 0)
        {
            s = "A";
        }
        else
        {
            s = "B";
        }
    }

"A" 和 "B" 在垃圾与不是垃圾之间反复横跳(字符串常量的引用可能不太一样,但这里是为了表达对象引用情况反复变化的意思)。而且增量式GC还需要额外的开销来确定对象的引用是否变化,这开销也不可忽视,实际项目看对毛刺的容忍程度来确定要不要使用增量式GC,而且要好好地做Profiler,很容易一不小心就负优化了。

贝姆垃圾收集器  原文链接:贝姆垃圾收集器 - 维基百科,自由的百科全书 (wikipedia.org)

Boehm-Demers-Weiser garbage collector,也就是著名的Boehm GC,是计算机应用在C/C++语言上的一个保守的垃圾回收器

原文链接:Unity 垃圾回收GC的原理? - 知乎 (zhihu.com)

1、BoehmGC中的内存分配

作为一个重量级的基础组件库,BoehmGC的使用方法非常简单,只需要把系统函数malloc替换为GC_malloc即可,之后你就完全不用管何时free的问题。在小型项目里,你甚至可以直接

#define malloc(n) GC_malloc(n)

然后再不用管free,BoehmGC自会帮你打理好一切。

既然全盘接管了内存分配,那就必须做到以下两点,才能称得上是合格的分配器

1). 分配的效率要高

2). 尽量避免内存浪费,避免碎片化等

那BoehmGC是怎么做的呢?

2、BoehmGC的内存分配架构

在整个内存分配链的最底部,BoehmGC通过平台相关接口来向操作系统申请内存(可能是malloc, sbrk, 或者mmap等)。为了提高效率会根据配置,每次批量申请4K的倍数大小,除了用户能使用的内存之外,还有BoehmGC内部维护的数据结构(通过GC_scratch_alloc分配)。

分配器的核心是一个分级的结构,BoehmGC把每次申请根据内存大小归类成小内存对象(Small Object)和大内存对象(Large Object),这点和STL的分配器也比较相似。归类的依据具体来说就是,

1)不超过PageSize/2,也就是2048字节的对象为小内存对象

2)大于PageSize/2的对象为大内存对象

//heap block定义了一个页,大小为4K的倍数
struct hblk {
    char hb_body[HBLKSIZE];
};

对于Large Object,向上取整到4K的倍数大小,直接以整数个hblk的形式给出。

而Small Object则会先申请一个hblk出来,而后在这块内存上进一步细分为Small Objects,形成free-list。

3、BoehmGC的内存管理策略

为了尽量减少碎片化和加速分配,BoehmGC在设计上就做了一些限制,充分体现了“物以类聚”的思想。

首先,GC管理的对象有一个最小的“粒度”,即Granule。

32位上这个值是8字节,64位则是16字节。

在64位环境下,即使用户申请的内存是10个字节,也会被向上调整到16字节。

一个在用的hblk如果不是属于一个large object,那就是容纳了若干个等大小的small object。

对于有一定内存分配器实现经验的开发者来说,以上两点应该都比较熟悉了,不过BoehmGC把这种“物以类聚”的设计贯彻落实得更加彻底。

对于大内存对象(large object),按照对应的hblk数,把他们归类到若干个freelist中。具体的做法可以参考GC_hblk_fl_from_blocks和GC_allochblk_nth。

当大内存对象被垃圾回收的时候,会尝试把相邻的hblk合并,减少内存碎片。

对于小内存对象的大小分档,也不是完全按照Granule的等差数列来决定。有些临近的大小会被优化合并掉,比如系统当前有很多1024字节的闲置块,但申请1008字节的小内存对象仍然可能miss。此时用1024字节的块可能是更好的选择,适当的合并临近的block size可以优化内存分配效率。这块的做法可以参考GC_init_size_map和GC_extend_size_map。

三、和lua GC有什么区别?

不知道有没有同学想过,lua中的GC用到是什么算法。

Lua 5.3 的垃圾回收机制采用的是标记-清除算法(mark-and-sweep),它会对所有经过 Lua 管理的内存进行垃圾回收,但不会回收非 Lua 管理的内存,例如使用 malloc 或者 new 分配的内存。该算法的优点是实现简单,效率高,但缺点是可能会产生内存碎片

而Boehm GC(Garbage Collector)是一个通用的垃圾回收库,可以用于 C 和 C++ 语言中的动态分配的内存

四、和Java 、C# GC的区别?

Java和 C# GC 都是精准式GC,而Boehm GC 是保守式的。

下面是一个比较官方的回答

“保守式垃圾回收是一种通过近似方式识别和回收垃圾对象的方式。在保守式垃圾回收中,垃圾回收器并不直接访问对象的内部结构和引用关系,而是通过扫描内存中的数据块,识别出可能是指向对象的指针,并将其标记为活动对象。然后,垃圾回收器将从活动对象出发,递归地遍历和标记其他可达的对象,并回收那些未被标记为活动对象的内存。保守式垃圾回收不需要额外的内存开销来维护对象之间的引用关系,但可能会存在一定的误判,即将某些实际上是垃圾的对象错误地标记为活动对象。”

用大白话讲就是Boehm GC无法区分指针和非指针,这就可能由于误判导致有些已经可以释放的内存无法释放。

而Java和 C# GC是可以的,但是需要付出一定的代价。

什么是装箱拆箱

Unity 客户端开发面经之C#——装箱、拆箱 - 知乎 (zhihu.com)

装箱:值类型转换为引用类型。使用值类型给引用类型赋值,就叫做装箱。

int value = 42;
object obj = value; // 装箱操作,将值类型装箱成引用类型

拆箱:引用类型转换为值类型。使用引用类型给值类型赋值,就叫做拆箱。

object obj = 42;
int value = (int)obj; // 拆箱操作,将引用类型拆箱成值类型

值类型:所有的数值类型(int、float、char、float等),struct,enum、bool

引用类型:class、interface、string、delegate,数组

在C#中,值类型在栈区分配内存,引用类型在堆区分配内存。

(35 封私信 / 81 条消息) wagailinzu - 知乎 (zhihu.com)

Q:堆和栈的区别是什么?

A:1、申请的方式不同。栈由系统自动分配,堆由程序申请开辟

2、申请的大小不同。栈获得的空间比较小,堆获得的内存比较大。

3、申请的效率不同。栈由系统自动分配,速度比较快,堆的速度一般比较慢。

4、储存的内容不同。(这个内容比较多看下文。)

5、底层不同,栈是连续的空间,堆是不连续的空间。

说到内存管理,还得从C++说起。

先看一段C++代码

class MyClass
{
public: int a;

      MyClass()
      {
          a = 1;
       }
};

int main()
{
    MyClass obj;
    obj.a = 1;
    return 0;
}

Q:obj这个对象是储存在堆区还是在栈区?

A:栈区。

误区:我一开始以为类是引用类型,所以应该是储存在堆区才是(这个是C#的特性,C++没有这个特性,所以C#的对象都需要使用new来实例化)。但是实际上只有使用了new(C++)或malloc(c)这个两个来进行动态内存分配,才会储存在堆区。
 

所以我就尝试了一下用new来创建一个对象

class MyClass
{
public: int a;

        MyClass()
       {
          a = 1;
        }
};

int main()
{
    MyClass obj= new MyClass();
    obj.a = 1;
    return 0;
}

Q:以上代码能正常运行吗?如果不能,问题出现在哪?

A:不能,new一个类对象,返回的是一个地址,所以obj必须是一个指针类型来接收。当使用new关键字来进行动态内存分配,类对象会被分配到堆区,栈区会储存该类对象的地址,此时这个类对象不会随着函数的生命周期结束自动释放,只能由程序员手动释放,所以在最后还应该释放该对象,否则会造成内存泄漏。

正确代码如下

class MyClass
{
public: int a;

        MyClass()
       {
          a = 1;
       }
};

int main()
{
    MyClass *obj = new MyClass();//堆区储存该对象,栈区储存该对象地址
    obj->a = 1;
    delete obj;//手动释放,否则会引起内存泄漏。
    return 0;
}

C#的内存管理就有没有C++怎么复杂了,C# 的堆被称为“托管堆”(Managed Heap),而与之对比,C++ 的堆一般指的是“自由存储区”(Free Store)。(自由存储区其实就是指堆区,C++用自由存储区比较准确,有争论堆区和自由存储区是否等价,这里不详细说,有兴趣的可以自己去看看)

托管堆和自由存储区之间存在一些重要的区别,这些区别主要源自于 C# 和 C++ 这两种编程语言的设计哲学和内存管理模型的不同。

C# 的托管堆:

  1. 自动内存管理: C# 使用垃圾回收(Garbage Collection)来自动管理内存。这意味着程序员不需要手动分配和释放内存,垃圾回收器会周期性地检查不再被引用的对象,并回收它们的内存。
  2. 安全性: 托管堆在分配和释放内存时考虑了类型安全,避免了许多内存错误(如越界、野指针等)。
  3. 生命周期: 对象的生命周期在托管堆上是可管理的,当不再被引用时,它们会被垃圾回收器回收。
  4. 多线程: 垃圾回收机制允许多线程环境下的安全内存管理,但也会涉及到一些性能开销。

C++ 的自由存储区:

  1. 手动内存管理: 在 C++ 中,开发人员需要手动使用 newdelete 或者 mallocfree 来分配和释放内存,这需要更多的注意和谨慎,避免内存泄漏和悬挂指针等问题。
  2. 不同的内存管理方式: C++ 的自由存储区允许更灵活的内存管理,但也容易引发许多内存相关的问题,如悬挂指针、内存泄漏、内存碎片等。
  3. 生命周期: C++ 中的对象的生命周期由程序员手动管理,容易引发资源管理错误。
  4. 多线程: C++ 的自由存储区需要开发人员自己考虑多线程环境下的并发问题。


Unity 垃圾回收

垃圾回收什么时候发生?

三件事可以造成垃圾回收的发生:

  • 请求堆内存分配的时候,剩余内存不能满足时,垃圾回收器会运行。
  • 垃圾回收器不时的自动运行(不同的平台频率不同)。
  • 垃圾回收可以手动运行。

垃圾回收可能是一个频繁的操作。请求对内存分配,内存不足时候会触发垃圾回收器,这意味频繁的堆内存申请和释放可能导致频繁的垃圾回收。



 

Lua GC

以后补充

  • 20
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值