一张图看懂 JVM 之垃圾回收机制

有这样一个梗,说在食堂里吃饭,吃完把餐盘端走清理的,是 C++ 程序员,吃完直接就走的,是 Java 程序员。

确实,在 Java 的世界里,似乎我们不用对垃圾回收那么的专注,很多初学者不懂 GC,也依然能写出一个能用甚至还不错的程序或系统。但其实这并不代表 Java 的 GC 就不重要。相反,它是那么的重要和复杂,以至于出了问题,那些初学者除了打开 GC 日志,看着一堆 0101 的天文,啥也做不了。

今天我们就从头到尾完整地聊一聊 Java 的垃圾回收。

导读

我们通过一张图的方式,从总体上对 JVM 的结构特别是内存结构有一个比较清晰的认识。

在这里插入图片描述

虽然在 JDK1.8+ 的版本中,JVM 内存管理结构有了一定的优化调整:主要是方法区(持久代)取消变成了直接使用元数据区的方式,但是整体上 JVM 的结构并没有大改,特别是我们最为关心的堆内存管理方式并没有在 JDK1.8+ 的版本中有什么变化。

在上面的图中,我们也大致对整个垃圾回收系统进行了标注,这里主要涉及回收策略回收算法垃圾回收器这几个部分。

形象一点表述,就是 JVM 需要知道哪些内存可以被回收,要有一套识别机制,在知道哪些内存可以回收以后具体采用什么样的回收方式,这就需要涉及一些回收算法,而具体的垃圾回收器就是根据不同内存区域的使用特点,采用相应地回收策略和算法的具体实现了。

下面我们就从这几个方面给大家介绍,JVM的垃圾回收相关的知识点。

什么是垃圾回收?

垃圾回收(Garbage Collection,GC),顾名思义就是释放垃圾占用的空间,防止内存泄露。有效的使用可以使用的内存,对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。

哪些内存需要回收?

我们知道,根据 《Java虚拟机规范》,Java 虚拟机运行时数据区分为程序计数器、虚拟机栈、本地方法栈、堆、方法区

在这里插入图片描述
程序计数器、虚拟机栈、本地方法栈这 3 个区域是线程私有的,会随线程消亡而自动回收,所以不需要过多考虑如何回收的问题

而 Java 堆区方法区则不一样,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

如何判断对象已成垃圾?

既然是垃圾收集,我们得先判断哪些对象是垃圾,然后再看看何时清理,如何清理。

常见的垃圾回收策略分为两种:

  • 一种是直接回收,即引用计数;
  • 另一种是间接回收,即追踪式回收(可达性分析)。

引用计数法

  • 引用计数法是通过在对象头中分配一个空间来保存该对象被引用的次数(Reference Count)
  • 每当有一个地方引用它,引用计数就加 1
  • 当引用失效,它的引用计数就减 1
  • 当该对象的引用计数减少为 0 的时候,就意味着这个对象再也无法被引用了,所以可以立即释放内存
    在这里插入图片描述

优点:实现简单,效率高。

缺点

  1. 需要占据额外的存储空间,如果本身的内存单元较小,则计数器占用的空间就会变得明显。
  2. 很难解决对象之间相互循环引用的问题

所谓对象之间的相互引用问题,如下面代码所示:

public class ReferenceCountingGc {
    Object instance = null;

    public static void main(String[] args) {
        ReferenceCountingGc objA = new ReferenceCountingGc();
        ReferenceCountingGc objB = new ReferenceCountingGc();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
    }
}
  • 定义 2 个对象
  • 相互引用
  • 置空各自的声明引用

除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。
在这里插入图片描述
但是它们因为互相引用对方,导致它们的引用计数器都不为 0,通过引用计数算法,也就永远无法通知 GC 收集器回收它们。

可达性分析算法

可达性分析算法(Reachability Analysis)的基本思路:

  • 首先要确定一系列根对象(GC Roots)
  • 并从根对象为起点根据对象之间的引用关系搜索出一条引用链(Reference Chain)
  • 当一个对象到 GC Roots 没有任何引用链相连时,我们就称之为对象引用不可达,则证明这个对象是不可用的,就可以暂时判定这个对象为可回收对象。

有一个比喻十分恰当:可达性分析算法就好比是在清洗葡萄串,我们可以从一根枝提起一大串葡萄,他们就像一串引用链,而没有和引用链相连的对象就像是散落在池子里的葡萄,可以回收。

在这里插入图片描述

在图中虽然 Object 6Object 7 之间互相有关联,但是它们到 GC Roots 是不可达的,所以将会被判定为可回收对象。

通过可达性算法,成功解决了引用计数所无法解决的问题「循环依赖」,只要你无法与 GC Roots 建立直接或间接的连接,系统就会判定你为可回收对象。那这样就引申出了另一个问题,哪些属于 GC Roots

在 Java 语言中里面,可作为 GC Roots 的对象包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象(正在运行的方法使用到的变量、参数等)
  • 方法区中类静态属性引用的对象(static 关键字声明的字段)
  • 方法区中常量引用的对象,(也就是 final 关键字声明的字段)
  • 本地方法栈中引用的对象(native 方法)
  • 所有被同步锁(synchronized 关键字)持有的对象
  • Java虚拟机内部的引用(系统内部的东西当然能作为根了)

四种引用方式

无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与「引用」有关。

JDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。

JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种,引用强度逐渐减弱。

强引用

正常情况下我们平时基本上我们只用到强引用类型,例如 Object obj = new Object();

无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。且当内存空间不足抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠回收具有强引用的对象,来解决内存不足的问题。

软引用

软引用是种相对强引用弱化一些的引用,用来描述一些还有用,但非必须的对象。

软引用是通过 SoftReference 类实现的。

Object obj = new Object();
SoftReference softObj = new SoftReference(obj);
obj = null;

被软引用关联着的对象,在即将 OOM 之前,垃圾回收器会把这些软引用指向的对象加入回收范围,以获得更多的内存空间。如果这次回收还没有足够的内存,才会抛出内存溢出异常。

弱引用

弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

弱引用是通过 WeakReference 类实现的。

Object obj = new Object();
WeakReference<Object> weakObj = new WeakReference<Object>(obj);
obj = null;

弱引用与软引用的区别:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

ThreadLocal 中的 key 就用到了弱引用。

虚引用

虚引用,也称幻象引用。是通过 PhantomReference 类实现的。它是最弱的一种引用关系,定义完成之后,无法通过虚引用来取得一个对象实例。

无法通过虚引用访问对象的任何属性或者函数。那就要问了要它有什么用

虚引用仅仅只是提供了一种确保对象被 finalize 以后来做某些事情的机制。

比如说这个对象被回收之后发一个系统通知啊啥的。虚引用是必须配合 ReferenceQueue 使用的,当垃圾回收时,如果存在虚引用,就会在回收对象内存前,把这个虚引用加入与之关联的引用队列中。

引用小结

  • 强引用是 Java 的默认引用形式,使用时不需要显示定义,是我们平时最常使用到的引用方式。不管系统资源有多紧张,Java GC 都不会主动回收具有强引用的对象。
  • 弱引用和软引用一般在引用对象为非必需对象的时候使用。它们的区别是被弱引用关联的对象在垃圾回收时总是会被回收,被软引用关联的对象只有在内存不足时才会被回收。
  • 虚引用的 get() 方法获取的永远是 null,无法获取对象实例。Java GC 会把虚引用的对象放到引用队列里面。可用来在对象被回收时做额外的一些资源清理或事物回滚等处理。

重点来了,下面我们开始介绍几个重要的垃圾回收算法。

垃圾收集算法

  • 标记-清除
  • 标记-复制
  • 标记-整理
  • 分代收集

标记-清除算法

标记-清除算法是最为基础的一种收集算法,总的来说分为两步:

  • 标记:标记所有需要回收的对象,也就是在做垃圾的判定(标记的过程就是上面介绍过的根节点可达算法)
  • 清除:标记完后统一回收所有被标记对象占用的内存空间

回收前状态:
在这里插入图片描述

回收后状态:

在这里插入图片描述
这种收集算法的优点是简单直接,不会影响 JVM 进程的正常运行。

但是会带来两个明显的问题:

  • 空间问题:标记清除后会产生大量不连续的碎片,不利于后续连续内存的分配
  • 另一个是执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。

标记-复制算法

复制算法(Copying)是在标记清除算法上演化而来,解决标记-清除算法的内存碎片问题。这种算法的思路是将可用的内存空间按容量划分为大小相等的两块,每次只使用其中一块。

算法描述:

  • 将可用内存分为容量大小相等的两块,每次只使用其中一块;
  • 在垃圾回收时将正在使用的内存中的存活对象,复制到未被使用的内存块中,然后再清除正在使用的内存块中的所有对象
  • 最后再交换两个内存的角色,最后完成垃圾回收。

回收前状态:
在这里插入图片描述
回收后状态:

在这里插入图片描述

优点:

  • 不会产生不连续的内存碎片
  • 提高效率:
    • 回收:每次都是对整个半区进行回收
    • 分配:分配时也不用考虑内存碎片问题,只要移动堆顶指针,按顺序分配内存即可

缺点:

  • 需要双倍空间
  • 如果存活对象数量比较大,复制性能会变得很差

如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。

目前此种算法主要用于新生代回收

因为新生代的中 98% 的对象都是很快就需要被回收的对象,所以并不需要 1:1 的比例来划分内存空间,在新生代中 JVM 是按照 8:1:1 的比例(文顶图中有标注)来将整个新生代内存划分为一块较大的 Eden 区和两块较小的 Survivor 区(S0、S1)。

每次使用 Eden 区和其中一个 Survivor 区,当发生回收时将 Eden 区和 Survivor 区中还存活的对象一次性复制到另一块 Survivor 区上,最后清理掉 Eden 区和刚才使用过的 Survivor 区。

理想情况下,每次新生代中的可用空间是整个新生代容量的 90%(80%+10%),只会有 10% 的内存会被浪费。

实际情况中,如果另外一个 10% 的 Survivor 区无法装下所有还存活的对象时,就会将这些对象直接放入老年代空间中 (这块在后面的分代回收算法会说到,这里先了解下)

标记-整理算法

如果在对象存活率较高的情况下,仍然采用复制算法的话,因为要进行较多的复制操作,效率就会变得很低,而且如果不想浪费 50% 的内存空间的话,就还需要额外的空间进行分配担保,以应对存活对象超额的情况。

显然老年代不能采用复制算法

根据老年代的特点,标记-清除-压缩(简称标记-整理)算法应运而生。

算法描述

  • 标记-整理算法的标记过程仍然与「标记-清除」算法一样
  • 只是后续的步骤不再是直接清除可以回收的对象,而是将所有存活的对象都向一端移动后,再直接清理掉端边界以外的内存。

回收前状态:
在这里插入图片描述

回收后状态:

在这里插入图片描述

标记整理算法一方面在标记-清除算法上做了升级,解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。看起来很美好,但从上图可以看到,它对内存变动更频繁,需要整理所有存活对象的引用地址,在效率上比复制算法要差很多。

标记整理算法的问题:

  1. 在整理存活对象时,因为对象位置点变动,还需要该调整虚拟机栈中的引用地址
  2. 在整理存活对象时,需要全程暂停用户线程,这种情况叫做 STW 停顿(Stop The World)
  3. 效率上比复制算法要差很多

Stop The World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。

分代收集算法

分代收集算法(Generational Collection)严格来说并不是一种思想或理论,而是融合上述 3 种基础的算法思想,而产生的针对不同情况所采用不同算法的一套组合拳。

JVM 根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

  • 比如在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
  • 老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择标记-清除或标记-整理算法进行垃圾收集。

问题来了,那内存区域到底被分为哪几块,每一块又有什么特别适合什么算法呢?

分代收集理论

为什么要分代?

我们首先必须知道,将 JVM 堆中区域分成新生代和年老代并不是 Java 虚拟机规范所规定的。规范中只是阐述了堆这么个区域,将堆中区域进行分代是不同垃圾收集器的行为,而不是JVM的规范,当然大多数垃圾收集器确实对堆进行了分代回收的策略。

那为什么要这么做呢?

这是基于这样一个事实:不同的对象的生命周期是不一样的。

在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如 Http 请求中的 Session对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

试想,在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,同时,因为每次回收都需要遍历所有存活对象,但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是它们依旧存在。

因此,分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。

新生代中的对象存活时间短,只需要在新生代区域中频繁进行 GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,还可以根据不同年代的特点采用合适的垃圾收集算法。分代收集大大提升了收集效率,这些都是内存分代带来的好处。

内存分代划分

Java 堆主要分为 2 个区域:新生代与老年代,其中新生代又分 Eden 区和 Survivor 区,其中 Survivor 区又分 From 和 To 两个区。

内存分代示意图如下:

在这里插入图片描述

Eden 区

IBM 公司的专业研究表明,有将近 98% 的对象是朝生夕死,所以针对这一现状,大多数情况下,对象会在新生代 Eden 区中进行分配,当 Eden 区没有足够空间进行分配时,虚拟机会发起一次 YGC

  • YGC 对应于新生代,第一次 YGC 只回收 Eden 区域,回收后大多数的对象会被回收,活着的对象通过复制算法进入 Survivor 0(后续用S0和S1代替)。

  • 再次 YGC 后,Eden + S0 中活着的对象进入 S1

  • 再次 YGC,Eden + S1 中活着的对象进入到 S0

  • 依次循环。看到这里我相信你已经明白了为什么要设置两个 Survivor 区域了。

  • YGC(Young GC)/ MinorGC: 针对新生代进行的垃圾回收,新生代空间不足会触发

  • Major GC:清理老年代

  • Full GC:清理整个堆空间,包括年轻代和永久代甚至是方法区

Survivor 区

Survivor 区相当于是 Eden 区和 Old 区的一个缓冲,类似于我们交通灯中的黄灯。

Eden 区的旁边是两个存活区(Survivor Spaces),称为 from 空间和 to 空间。需要着重强调的的是,任意时刻总有一个存活区是空的(empty)。

空的那个存活区用于在下一次年轻代 GC 时存放收集的对象。年轻代中所有的存活对象(包括 Eden 区和非空的那个 from 存活区)都会被复制到 to 存活区。GC 过程完成后, to 区有对象,而 from 区里没有对象。两者的角色进行正好切换,from 变成 toto 变成 from

为啥需要 Survivor 区?

不就是新生代到老年代么,直接 Eden 到 Old 不好了吗,为啥要这么复杂。

想想如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次 Minor GC 没有消灭,但其实也并不会蹦跶多久,或许第二次,第三次就需要被清除。这时候移入老年区,很明显不是一个明智的决定。

所以,Survivor 的存在意义就是减少被送到老年代的对象,进而减少 Major GC 的发生。Survivor 的预筛选保证,只有经历 15 次 Minor GC 还能在新生代中存活的对象,才会被提升到老年代。

对象提升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

如果存活区空间不够存放年轻代中的存活对象,提升也可能更早地进行。

老年代(Old Gen)

老年代的 GC 实现要复杂得多。老年代内存空间通常会更大,里面的对象是垃圾的概率也更小。

老年代 GC 发生的频率比年轻代小很多。同时,因为预期老年代中的对象大部分是存活的,所以不再使用标记和复制(Mark and Copy)算法。而是采用移动对象的方式来实现最小化内存碎片。

老年代空间的清理算法通常是建立在不同的基础上的。原则上,会执行以下这些步骤:

  • 通过标志位(marked bit),标记所有通过 GC roots 可达的对象;
  • 删除所有不可达对象;
  • 整理老年代空间中的内容,方法是将所有的存活对象复制,从老年代空间开始的地方依次存放。

通过上面的描述可知,老年代 GC 必须明确地进行整理,以避免内存碎片过多。

堆内存常见的分配策略

  • 对象优先在 Eden 区分配
  • 大对象直接进入老年代
  • 长期存活的对象将进入老年代
对象优先在 Eden 区分配

目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC

下面我们来进行实际测试一下。

public class GCTest {
    public static void main(String[] args) {
        byte[] allocation1, allocation2;

        allocation1 = new byte[30900*1024];

        //allocation2 = new byte[900000*1024];
    }
}

idea 添加参数:

-XX:+PrintGCDetails

在这里插入图片描述
运行结果:

Heap
 PSYoungGen      total 75776K, used 37408K [0x000000076bb00000, 0x0000000770f80000, 0x00000007c0000000)
  eden space 65024K, 57% used [0x000000076bb00000,0x000000076df88318,0x000000076fa80000)
  from space 10752K, 0% used [0x0000000770500000,0x0000000770500000,0x0000000770f80000)
  to   space 10752K, 0% used [0x000000076fa80000,0x000000076fa80000,0x0000000770500000)
 ParOldGen       total 173568K, used 0K [0x00000006c3000000, 0x00000006cd980000, 0x000000076bb00000)
  object space 173568K, 0% used [0x00000006c3000000,0x00000006c3000000,0x00000006cd980000)
 Metaspace       used 3319K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 362K, capacity 388K, committed 512K, reserved 1048576K

根据上面的结果,我们可以看出 eden 区内存被分配 57%。假如我们再为 allocation2 分配内存会出现什么情况呢?

Heap
 PSYoungGen      total 75776K, used 37403K [0x000000076bb00000, 0x0000000770f80000, 0x00000007c0000000)
  eden space 65024K, 57% used [0x000000076bb00000,0x000000076df86f60,0x000000076fa80000)
  from space 10752K, 0% used [0x0000000770500000,0x0000000770500000,0x0000000770f80000)
  to   space 10752K, 0% used [0x000000076fa80000,0x000000076fa80000,0x0000000770500000)
 ParOldGen       total 1073664K, used 900000K [0x00000006c3000000, 0x0000000704880000, 0x000000076bb00000)
  object space 1073664K, 83% used [0x00000006c3000000,0x00000006f9ee8010,0x0000000704880000)
 Metaspace       used 3323K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 362K, capacity 388K, committed 512K, reserved 1048576K

简单解释一下为什么会出现这种情况:因为给 allocation2 分配内存的时候 eden 区内存几乎已经被分配完了,我们刚刚讲了当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。GC 期间虚拟机又发现 allocation1 无法存入 Survior 空间,所以只好通过分配担保机制把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 allocation1,所以不会出现 Full GC。

执行 Minor GC后,后面分配的对象如果能够存在 eden 区的话,还是会在 eden 区分配内存。

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。

直接在老年代分配内存,主要为了避免在新生代区频繁的 GC 时发生大量的内存复制。

长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别那些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。

对象在 Survivor 中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。

对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

总结

本文我们主要介绍了垃圾回收的基本原理,垃圾回收器相关的,我们在后面的文章继续分析。

我整理了以下的问题,可以在文中找到答案:

  1. 哪些内存需要回收?
  2. 判断对象已成垃圾的方法
  3. 介绍一下四种引用方式
  4. 垃圾收集算法以及各自的优缺点
  5. 分代收集为什么要分代?
  6. 内存分代划分以及各自适合的垃圾回收算法

如果你还想看更多优质原创文章,欢迎关注我的公众号「ShawnBlog」。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值