聊一聊GC的内容

堆区的内存清理

Java的一大特性就是支持垃圾的自动回收,这里的垃圾就是不再使用的对象,而完成自动回收的垃圾回收器(简称GC)。Java领域中,主流的Java虚拟机采用可达性分析算法来管理内存。更加简单的引用计数法更多使用在一些脚本语言如python,因为引用计数法最大特点就是回收及时,一旦引用为0就可以清除,而可达性分析通常至少需要标记和清除(这一步也可能是其他的处理)两个阶段,一个垃圾对象至少经过两轮标记才会被清除

python的GC主流实现中,对于不可能产生循环引用的对象如数值、字符串采用引用计数,而对于集合、自定义对象等采用类似标记-清除的算法进行回收。

大部分对象存放在堆内存,而如果堆内存不足以用户申请的对象,则会触发GC进行垃圾回收,而如果最终仍然无法放下,将终止运行,并抛出OutOfMemoryError的异常。(在这里是堆内存溢出,而该异常也会出现在方法区溢出、直接内存溢出的场景)

JVM中可以通过 -Xms和-Xmx来调整堆内存的范围。其中-X代表标准,所有JVM标准下的JVM实现都遵循-X,ms就是memory size 即JVM给堆内存的最小值,随着对象的创建堆的大小肯定是不断变大(动态扩展),而mx 就是memory max 即堆的最大值。
如果想要禁止堆的动态扩展,而限制堆内存为一个固定大小,则可以将-Xms和-Xmx指定一个同样的大小

可达性分析

可达性分析是主流JVM实现用于判断对象是否存活的方法。首先需要通过根结点枚举确定根结点集合GC roots,然后从这些节点开始,根据引用关系向下搜索对象图。这个对象图就是与GC roots可达的对象。(具体标记哪个对象与具体GC实现有关)

根对象集合包括(这里可以理解为引用,或者引用直接指向的堆中对象):
【1】栈内存中,包括操作数栈、局部变量表引用的对象(具体表现为使用到的局部变量、返回值、参数)
【2】方法区引用的对象:classLoader和class对象
【3】常量池引用的对象,如常量池中的(引用的)字符串对象
【4】被同步锁synchronized持有的对象
【5】JVM内部的引用如系统类加载器、一些常驻的异常对象
【6】类所属的静态变量
如果一个指针保存了堆中对象的地址,但是自己由不存放在堆中(存于栈中),那么它就是一个root

常驻JVM内存的对象基本都是不能轻易回收的,因为它们总是被生命周期更长的对象引用(例如JVM实例本身可以看作一个进程对象),他们就可以作为可达性分析的根对象,方法区总是保留引用的两个对象classLoader和class对象、常量池中保存的字符串字面量是什么——堆中字符串对象的引用、monitor关联的对象、总是需要用到的异常对象、与类同生共死的静态变量指向的对象、栈帧中各种结果指向的对象等

根结点枚举

这里存在两个待优化或解决的问题:

【1】 如果使用可达性分析算法进行判断,则分析工作必须在一个能保证一致性的快照中进行,否则无法保证结果的准确性。因此需要stop the world,停止所有的用户线程工作,使得GC线程可以在一个一致性快照中进行垃圾回收工作。

STW原因
【1】可行性分析工作必须在一个能确保一致性的快照中进行,分析期间整个执行系统需要是静止在某一个时间点的。
【2】如果分析过程中,对象引用关系在不断变化,会使得分析结果的准确性收到影响。
STW无法被避免,是JVM在后台自动发起和自动完成的,对用户透明。垃圾回收器的升级不断优化STW的时间

不断的GC可能造成频繁的STW,这会使得程序运行显得很慢,本质上是由于内存吃紧而不断触发GC。

系统频繁变慢和通常和频繁STW有关,频繁STW是频繁GC的表现,一般是因为内存不够用了,再不GC就OOM了,这时候可以看一下是不是存在内存泄露了或者被外部DDOS攻击了。

【2】对象那么多,如果全部遍历一遍去进行根结点枚举,那么无疑是大海捞针,而且根结点枚举在垃圾回收器的大部分实现中,都是无法避免STW的,因此需要找到一种方法进行优化,以保证快速地完成根结点枚举。
主流的JVM使用的都是准确式垃圾收集(JVM可以知道内存中某个位置的数据具体是什么类型——字面量还是引用),JVM不需要查找所有GC root,而是可以使用一组数据结构,它指明了哪些位置存在对象引用。Hotspot中,使用一组称为OopMap的数据结构来快速完成GC root枚举。
一旦类加载完毕,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,并保存在oopMap中。

导致OOPMap内容变化的指令很多(程序运行期间很多指令都有可能修改引用关系),Hotspot没有为所有指令生成OOPMap,而是在安全点记录了OOPMap相关信息,只有在安全点才会对oopMap做一个统一的更新,因此只有安全点位置的oopMap一定是准确的,因此只能在安全点处理GC行为,进而STW一般也只发送在安全点

一段程序被若干个安全点切出若干程序段,而CPU执行到程序安全点时才会GC,因为HotSpot只在安全点记录了oopMap的信息,而oopMap用于实现快速枚举GC root。
源码中变量都是有类型的,但是一旦经过编译后,变量就只有在局部变量表中slot的位置,oopMap用来说明栈上某个位置存放的变量原来是一个什么类型的
换句话说,假如我们不计成本和实现复杂度,在任何位置、为每条指令的位置都记录oopMap,那么HotSpot的GC可以在任何时候进入GC,因为任何位置都是一个安全点。

安全点

程序执行过程中并不是在任意位置都能够停下来开始GC,而是到达“安全点”。安全点通常为与方法调用处、循环跳转、异常跳转位置——经常被复用的指令、执行时间较长的指令
主流JVM采用主动式中断——垃圾收集器需要中断线程时,设置一个中断标志,各个线程在运行过程时不停地主动轮询这个标志,如果为真则在最近的安全的主动挂起

这个中断标志就设置在安全点以及所有创建对象和其他需要在堆上分配内存的地方(为了没有足够内存分配新对象)

但是对于处于阻塞或等待状态的线程,就无法执行中断操作,因为它们此时无法进入运行状态,这时需要引入安全空间。(如果一个线程长期sleep,那么GC将暂时无法回收该线程在堆中产生的垃圾)
安全空间可以看作安全点的拉伸,线程要么冻结在安全点的位置,要么冻结在安全区域的范围

安全区域:指的是一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的

我的理解:安全空间内,保证了oopMap的内容不会被改变,即使此时sleep的线程没有被主动挂起进入STW状态,GC也可以对该线程进行根结点枚举,相当于该线程运行到了安全点,而涉及sleep、blocked的代码都属于安全区域代码,因为它不会改变某些对象的引用关系

【1】当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,如果这段时间发生GC,那么JVM会忽略标识为“已进入安全区域”状态的线程(因为JVM知道安全区域代码段内的oopMap内容不会变)。(进入安全空间后,即使程序因为某些原因无法响应JVM的中断请求,JVM也可以开始进行根结点枚举工作)

一致性视图是指 “一致的引用关系” ,如果一段代码不会改变这个一致性视图,那么STW并GC和边GC边使得线程继续执行这段“不影响”的代码之间的效果差不多,但是如果线程想要退出这段安全区域代码,则需要“经过JVM的允许”

【2】当线程即将离开安全区域的时候,会检查JVM是否已经完成了根结点枚举,完成了则继续执行。否则需要等待接收JVM“允许离开安全空间”的通知。

因为根结点枚举是STW的,如果没有进行完毕,那么用户线程需要停在安全区间,虽然它没有被STW,但是需要逻辑上达成STW的效果。因为离开安全空间时,可以看作达到安全点(离开安全点之后无法保证oopMap的准确性),而此时的JVM可能只在对程序进行根结点枚举,如果不经过JVM通知就离开安全空间相当于为枚举工作“添乱”,使得GC使用的oopMap不准确。

总结
GC roots枚举需要保证:
【1】确保一致性快照
一致性视图:分析期间,整个系统需要是静止在某个时间点的,并且能保证所有线程都能够响应JVM的STW信号。

【2】效率,快速定位的数据结构oopMap
基于准确式垃圾收集算法,通过数据结构oopMap记录内存中哪些位置存放了哪些对象。
但是oopMap总是被频繁的改变,但是不能在每条指令后面都更新一次oopMap,于是引入安全点,对oopMap进行统一的更新。而引入安全空间则是一种双重保险,防止某些用户线程迟迟不执行到安全点的位置,而导致GC无法开展工作。

因此,在oopMap的辅助下,GC能够快速完成根结点枚举,因为进入GC开始,程序不是运行在安全点下就,就是运行在安全空间中,因此,此时的oopMap是被同步更新后的内容,GC能够拿到oopMap描述的对象内存分布的信息,就相当于拿到“一致性视图”

并发的可达性分析

对于非(GC线程与用户线程)并行的垃圾收集器,往往整个GC的过程都是STW的,因为需要整个过程基于一致性视图。其中根结点枚举中,枚举的都是GC根对象,而且能够通过oopMa优化,停顿时间相对短暂且固定。而从根节点开始,向下遍历对象图则占有很长的停顿时间,堆越大,存放的对象越多,标记占有的时间越长。
并发的可行性分析主要分为四个阶段:根结点枚举、并发标记、修正标记、最终回收

三色标记法

在遍历对象图的过程中,把所有对象按照“(引用)是否被访问过”标记为三种颜色
【1】白色:对象尚未被垃圾回收器访问过
【2】黑色:对象已经被访问过,且对象内的所有引用也都被扫描过。(表示未完成的工作状态,解决并发问题)
【3】灰色:对象已经被垃圾回收器访问过,对象内至少存在一个引用还没有被扫描过。

类比进程的引入,因为多线程环境下存在线程切换,因此存在“工作到一半/工作未完成”的任务

灰色是黑白对象直接的中间态,标记过程结束后,只会有白色和黑色的对象,其中白色对象就是需要被回收的对象——因为它不可达,GC没有办法“接触它

标记修正

因为并发标记阶段GC和用户线程一起工作。因此标记完成后必须修正标记
【1】GC标记死亡的对象可能被用户再次使用(对象消失、空指针)(大问题,在用户看来,对象离奇消失)
【2】GC标记存活的对象可能被用户不再使用(浮动垃圾、多余垃圾未被清理,需要等待下一次)(这个是小问题,等待下一次GC捎带清理就可以了)

扫描过程中插入一条或者多条从黑色对象指向白色对象的新引用,而且灰色对象对这个新插入的白色对象不存在(或者存在但是被删除了)直接或者间接的引用,那么这个新插入的对象将被GC当做不可达对象清理掉。

通俗的描述一下,某一时刻,有几个节点已经被扫描完毕,那么这几个节点都是黑色,现在用户线程创建一个对象,而且黑色节点对象存在一条指向白色节点的引用,可是黑色节点已经不会被再次扫描了,而且如果不存在某一个灰色节点对这个新插入对象的引用,那么这个对象将被视为“不可达节点”而被清除。
另一种情况就是,对象插入后本来是由灰色节点可达的,但是经过一些操作后,白色节点与任何一个灰色节点的引用都被删除了,白色对象变为不可达状态

当且仅当满足以上两种情况时(增黑白/删白灰),才会出现对象消失问题,因此破坏其中一个条件即可 (通过理论证明得到的)
解决思路:
【1】记录被新增的黑白对象之间的引用
【2】记录被删除的灰白对象之间的引用

解决方案
【1】增量更新——记录新增加引用,这也是CMS的解决方案
黑色对象一旦插入新的指向白色对象的引用时,就记录这个引用,之后再以这些引用中的黑色对象为根再次扫描一次——黑色对象一旦插入了指向白色对象的引用之后,就变成了灰色对象
【2】原始快照——记录被删除的引用,这是G1的解决方案
灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次——无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照进行扫描

我再直白点解释:扫描完的是黑节点。而扫描不到,准备回收的是白节点。如果你在可达性分析时,悄咪咪的新建个对象,那么它就是一个白节点。如果是和灰色节点相连还不算糟糕,但是如果和黑色节点相连那基本就是等死了,因为黑色节点不会被再次扫描,因此这个新建的白节点会被错误回收。而如果灰色节点和白色节点之间的线(引用)断了,那么这时的白节点也是死路一条。

增量更新是啥?如果这个新插入的白节点和黑节点相邻,我就记录下来,然后以这个黑节点再扫描一次,达到一个黑节点变灰的效果。
原始快照是啥?记录被删除的引用,在并发扫描后,把这个被删除的引用加上再以灰色节点为根,再扫描一遍。

GC算法

分代收集

主流的JVM都是基于分代收集理论而设计的。而该理论建立在两个分代假说:绝大多数对象都是朝升息灭的,而熬过多次垃圾收集过程的对象都是难以消亡的
设计者一般至少可以将对堆内存划分为年轻代和老年代,而根据分代理论,不同程度的GC有可以划分为minorGC、majorGC、FullGC。
使用JVM数据监测工具Jstat可以拿到堆内存和垃圾回收的情况,还可以看到YGC和FGC的字段(表示从应用程序启动到采样时,不同generation的gc次数)。

一般针对不同的分代具有不同的垃圾回收器实现进行负责,而垃圾回收器针对不同的generation也会基于不同的算法进行实现。例如基于年轻代垃圾回收,使用标记复制算法实现的serial、parNew、parallel scavenger等,以及基于老年代垃圾回收,使用标记整理的parallel Old、serial Old,使用标记清除等待CMS等

理清楚这个关系:堆内存是虚拟机向操作系统申请的一块内存(操作系统就是管理底层硬件的),这块内存从操作系统看就是一块连续的虚拟内存,而JVM将它划分若干个运行时数据区段,这是Jave虚拟机之于操作系统(物理机),而应用程序运行时,GC线程也可以看作程序的一部分(作为插件插入我们的程序,帮我们管理堆内存),GC设计者至少将堆内存划分为年轻代和老年代,但是这样粒度仍然很大,因此一般将堆内存的generation划分的更细,常见的Appel模型将年轻代划分为一个Eden空间和两个survivor空间(HotSpot默认Eden与survivor为8比1 )、G1延续了分代的理念,但是仅是逻辑上的,而对堆内存进行分块,每次GC时生成回收集。
总结:分代是一种理念,如何分代或划分内存没有统一标准而只有参考模型,具体如何分代取决于GC的具体实现和某个版本的JVM选用了哪个垃圾回收器作为默认(也可以通过参数修改)

一般minor GC发生的十分频繁,只针对新生代区域的对象,回收的速度也很快。而majorGC、FullGC主要发生在老年代(不过一般也会伴随至少一次minorGC),速度一般比minorGC慢一个数量级。(newRatio默认是1:2,老年代很大,占用整个堆的2/3)

永久代

方法区和堆一样,都是被线程共享的一个区域,方法区存放的主要是一些元信息

方法区是JVM中的一个规范,JVM规范把方法区描述为堆的一个逻辑部分。具体实现取决于具体的JVM产品,这里以HotSpot为例。
java7及以前,方法区(HotSpot)的实现中,物理上仍然与堆相连,仅在逻辑上与堆独立(如通过一个指针限制访问范围)。由于方法区的垃圾回收条件是十分严格的,因此方法区存放的元信息和常量几乎不会被回收,因此称为永久代
方法区的垃圾回收主要为两部分:不被引用的常量和不再使用的类型
回收必须满足以下三个条件:
【1】该类的所有实例都被回收
【2】加载该类的类加载器已经被回收(只能是自定义类加载器)
【3】该类Class对象是不可达的,而且无法在任何地方通过反射访问该类的方法

这种实现,将方法区的内存限定在JVM从操作系统申请的内存,而且存在默认的上限(-XX:MaxPermSize),而且方法区几乎不存在垃圾回收(加载进内存的类型很难被卸载),这导致方法区的内存压力随着类不断被加载进内存而持续增大,最终导致OOM。

在JDK1.7之前运行时常量池逻辑上包含字符串常量池,这时字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代
在JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆(从一个class实例使用一个到一个VM实例共用一个),运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代
在JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

总结:方法区在JVM规范中,用于存放程序元信息,Hotspot jdk1.7属于堆内存的一部分,被看作逻辑上的非堆结构。1.8的时候使用本地内存实现,彻底变成了非堆结构。

元空间

Java7中已经有部分数据开始转移到堆内存或者本地内存了。
例如:符号引用转移到了本地内存、字符串常量池转移到了java堆中,静态变量也转移了堆中
而java8中,Hotspot移除了永久代,使用元空间去实现,而元空间属于本地内存。
其中元空间与永久代的最大区别,也是堆内存与进程内存的区别。如果将方法区局限在JVM的堆内存,那么很容易触碰到内存上限导致OOM,而使用本地内存的情况下,只要没有触发到进程可用内存的上限就不会出现问题。(相当于类型加载不再向JVM申请内存,而是直接向OS申请内存)
元空间替换了永久代,它的内存不由虚拟机器管理,而是取决于物理机器,不再受限于JVM本身申请的内存
另一方面,HotSpot也是学习J9和JRockit等虚拟机产品的优点,使用本地内存实现方法区

标记复制算法

最基础的标记复制算法就是将内存分为两块,每次只使用其中一块,然后将没有被清理的对象存放到另一块上。这种算法一个缺点是:如果存活对象太多,复制将产生极大时间开销,同时存放存活对象也意味着目标区占用的内存空间需要很多。因此,这种算法更加适合新生代的垃圾收集,因为新生代都是朝升息灭的,一个young GC后仅有少部分对象存活,然后换入另一块内存。

有一种更加优化的模型,将新生代分为一个Eden区和两个survivor区。(serial、parNew等新生代GC均采用此内存布局),默认比例8:1,每次新生代可用的内存空间占整个新生代的90%(一个Eden和其中一个survivor)。如果一个survivor容纳不下当前轮次的存活对象,则需要依赖老年代进行分配担保(大对象直接放入老年代)

eden区的对象都是朝升息灭的,每对Eden区和第一块survivor进行回收后,存活的对象复制进入第二个survivor,然后对Eden和第一块survivor全部清空,然后在将第二块survivor的对象复制进第一块。(也可能下一次扫描Eden和第二块survivor,然后复制存活对象到第一块survivor,看具体实现)下一次GC再次重复这个过程。因此其中一个survivor相当于起到了一个交换缓冲区的作用
每一次复制,存活的对象对应对象头的age会加一,默认情况下达到15就可以晋升到老年代了。

复制算法的最大特点就是不产生内存碎片,新生代内存相对规整,可以使用碰撞指针的方式分配内存。
缺点的话就是复制耗时,而且一般存在至少10%的空间没有被利用

标记清除算法

主要分为标记和清除两步:从根结点开始遍历对象图,标记出所有可达的对象(这里如何标记取决于具体实现),然后回收所有不可达对象。
不需要额外空间,但是会产生内存碎片——JVM需要维护空闲列表去分配对象的内存。(把需要清除的对象的内存地址放入空闲列表,则视为逻辑上的清除)

不适合新生代,因为新生代对象大多朝升息灭,要标记的对象太多,而且会在新生代产生大量碎片,为之后的对象创建制造困扰。

CMS就是标记清除算法的。

标记整理算法

标记后不是清理对象,而是将存活对象移向内存的一端,然后清除端边界外的对象
这种对象移动操作必须暂停用户程序(STW)
该算法没有内存碎片,但是移动对象的成本很多,而且移动对象必须STW,将造成停顿时间上升,但是程序吞吐量更好,parallel scavenger就是基于标记整理算法的

老年代一般是将标记清除和标记整理混合使用的,例如,CMS虽然是基于标记清除算法,但是如果内存碎片过多导致无法分配对象,则会使用基于标记整理的serial old垃圾回收器进行一次基于标记整理的GC。

跨代引用问题

我们将堆内存按照generation划分,但是每一个generation都不是独立的,新生代的对象可能引用老年代对象,反过来一样。我们不关心新生代对老年代的跨代引用,因为majorGC总是伴随着minorGC,但是我们在乎老年代对象指向新生代对象的引用
如果每次扫描一个新生代对象,还需要专门扫描一遍老年代,那么效率就太低了。
基于假说:跨代引用相对于同代引用,只占用极少部分,我们不应该为了少量的跨代引用去扫描整个老年代,只需要在新生代建立一个全局的数据结构——记忆集

记忆集与卡表

记忆集相当于一个映射结构,它将老年代内存块划分为若刚个小块,每一个单位与一个小块产生映射,相当于bitMap。
记忆集可以记录哪些内存块存在引用了新生代对象的老年代对象,我们在minorGC的时候不需要遍历老年代,只需要遍历年轻代+记忆集即可

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。(可以简单理解为一个对象引用数组Object[])
如果不考虑成本,那么这个记忆集就可以看作老年代指向年轻代对象的指针集合。

而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。
卡精度:每个记录精确到一块内存区域,该区域内有对象,其中对象存在跨带指针。
卡表就是基于“卡精度”实现记忆集。

卡表是记忆集的一种实现,可以看作一个字节数组,每一个元素对应着其标识的内存区域中一块特定大小的内存块——这个内存块中可以存在多个老年代对象,因此一个卡页包含多个指针,如果任意一个指针是跨带指针,那么就将卡页标记为dirty,GC时扫描该卡页中的对象。
再通俗点,就把卡表看作一个布尔数组,每个单位映射到一块内存,如果b[2]=true,我YGC的时候就连带这扫描2映射到的那块老年代内存区。

卡表就是对一块块内存地址的映射,每一块称为一个卡页。只要一个卡页内的对象存在一个或者多个跨带引用指针,则将该位置的卡表数组元素置为脏。GC的时候将GC root连同脏卡表映射的地址一起扫描。

卡页变脏的动作原则上发生在引用类型字段赋值的那一刻,实现上,维护卡表状态的动作被放到了引用类型字段赋值的时候。(只要对引用类型进行更新都会产生额外的开销,不管是否是跨代引用)
JVM注入的一小段代码,用于记录指针变化 Object.field = <reference>(putfield)
当更新(引用类型)指针的时候,标记(卡表中的卡)card为dirty,将card存于dirty card queue(当队列中达到一定数量时才考虑真正去同步更新Rset)。
HotSpot是通过写屏障技术维护卡表状态的,写屏障相当于对“应用类型字段赋值”这个动作包了一层代码,赋值的前后都在写屏障的覆盖范围了

因为java是基于多线程的,如果立即更新RS,那么可能存在竞争关系,因为写屏障(更新引用变量)的操作发送在线程中,十分频繁。

回答三个问题

【1】何时触发GC
对于主流和常见的垃圾回收器来说,堆内存的Eden区无法容纳待创建的对象,触发minorGC。如果某一个新生代的对象需要晋升到老年代或者一个大对象准备放入老年代,而且老年代没有足够内存空间,则触发majorGC。(频繁YGC而导致不断有对象晋升,会触发FGC)
根据一些垃圾回收器的特点,还会有一些其他的触发时机,例如CMS扫描到堆内存使用率超过某一个阈值,会产生一个concurrent mode failure并提前触发major/full GC。(如果考虑G1,那就能说更多了,下面再说)
【2】对什么对象执行垃圾回收
从GC roots开始扫描,最终被判定为不可达的对象,且经过第一次标记没有被finalize方法复活的对象
【3】GC做了什么
针对不同分代不同的垃圾回收器具有不同的行为,可以举例其中一个垃圾回收器进行详细说明,例如年轻代的基于标记复制算法的serial垃圾回收器、老年代的基于标记清除的CMS垃圾回收器。

经典垃圾回收器

垃圾回收器的使用更多是看场景,下面的垃圾回收器更像是一段的历史,要从发展和迭代的眼光看待

serial

串行垃圾回收器是最基本的垃圾回收器,使用复制算法。是一个单线程的收集器,并且在进行垃圾回收的同时,必须暂停其他所有的工作线程,直到垃圾回收结束。
它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial垃圾收集器依然是java虚拟机运行在Client模式下默认的新生代垃圾收集器

垃圾回收中的“并行”和“串行”:并行指的是多条垃圾收集线程并行工作,而用户线程处于暂停状态。串行则指的是单条垃圾收集线程。
并发一般指的是用户线程与垃圾收集线程同时执行,如CMS/G1

通过**-XX:UseSerialGC**参数,可以使用两个新老代串行回收器的组合(垃圾回收器启用一般都是-XX:Use 垃圾回收器名字 GC)

类比redis,serial不需要上下文切换,而此时用户线程又是STW,相当于GC 线程独占了CPU,效率也是比较高的。

serial Old

使用标记整理算法。
这个收集器也主要是运行在Client默认的java虚拟机默认的年老代垃圾收集器

两个Serial类型收集器配合使用,GC执行的全程都是STW

parallel scavenger

也是使用复制算法新生代垃圾回收器,也是一个多线程垃圾收集器,它重点关注的是程序达到一个可控制的吞吐量。(Thoughput,CPU 用于运行用户代码的时间/CPU 总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间))(即最大化用户代码的执行效率)

高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。 自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别。
parNew仅是serial的一个多GC线程并行回收版本,而parallel scavenger问世的目的就是为了达到可控吞吐量

parallel scavenger问世时处于一个尴尬的地位,因为当时能够与他搭配的老年代收集器只有serial old,由于serial old更适用于客户端,而服务器应用性能不佳,使得parallel scavenger未能在整体上达到吞吐量优先的目标。
CMS无法与parallel scavenger配合工作
而parallel old的问世更像是为了与parallel scavenger进行搭配。

parallel old

Parallel Old收集器是Parallel Scavenge的年老代版本,使用多线程的标记-整理算法,在JDK1.6 才开始提供。(在此之前老年代只能使用serial old与parallel scavenger搭配使用)
而serial old性能低于parallel scavenger使得吞吐量优先的效果不好

parNew

可以看做serial收集器的多GC线程版本(其中new是新生代的意思)。(主要是为了与CMS配合,除了serial,只有parNew能够配合CMS收集器)
ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。
ParNew虽然是除了多线程外和Serial收集器几乎完全一样,但是ParNew垃圾收集器是很多java 虚拟机运行在Server模式下新生代的默认垃圾收集器

开启parNew:-XX:UsePARNewGC 指定parNew回收年轻代,不影响老年代
-XX:parallelGCThreads限制线程数量,默认开启和CPU数量相同的线程数

parNew不一定总优于serial,需要具体到某个场景。单个CPU的环境下,单线程GC可以避免频繁的任务切换导致的开销。而且处理serial Old

parNew仅能搭配CMS

CMS是一个老年代回收器,因此它需要和一个年轻代回收器进行配合,但是parallel scavenge和它无法配合,因此开发了一个新的年轻代并行回收器parNew

CMS

CMS是第一款具有真正意义的并发收集器,可以使用GC线程与用户线程并行工作,是一款老年代垃圾收集器。其主要目的是获取最短垃圾回收停顿时间。最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验
在这里插入图片描述
(注意,每一个步骤的GC线程都开始于一个安全点)
【1】初始标记:只是标记一下GC Roots能直接关联的对象(根结点枚举的过程),速度很快(仅是直接关联的对象,不向下探索),仍然需要暂停所有的工作线程(STW)
【2】并发标记从【1】标记出的对象开始遍历整个对象图的过程。进行GC Roots跟踪的过程,和用户线程一起工作,不需要暂停工作线程。
【3】重新标记:(STW,时间也短,比【1】长一点)为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程(比初始标记长一点,比并发标记短)(例如通过增量更新的方式修复对象图)
【4】并发清除(清理掉以上标记阶段中,被判定已经死亡的对象,释放内存空间)清除GC Roots不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看 CMS收集器的内存回收和用户线程是一起并发地执行(并发清除,不涉及移动对象,不会打扰用户线程占用的内存空间)

年轻代垃圾回收器一般是整体STW的,因为年轻代要回收的对象十分多,而老年代的对象都是经过多次YGC后晋升上去的对象,而且老年代一般是年轻代的两倍大小,一般可以通过并发标记的方式缩短停顿时间。

CMS垃圾回收器可以减少垃圾停顿的时间,缺点也很明显——频繁的进行垃圾回收,吞吐量下降;标记清除带来的缺点:存在内存碎片(基于mark sweep)【应该使用空闲链表分配内存,因为可分配内存不够规整

与CMS搭配使用的主要是parNew,因此minorGC的时候会STW并以多个GC线程的方式进行垃圾回收,而Full GC的时候则使用多个GC线程对老年代空间进行扫描,并进行并发标记清除(CMS)

CMS应该确保应用程序有足够内存可用,因此不能“快满了”才开始回收,而是当堆内存使用率达到某一阈值的时候,便开始进行回收
如果CMS运行期间预留的内存无法满足程序需要,则会出现concurrent mode failure失败,这时JVM将会启动后备预案——临时启用serial old重新进行老年代垃圾回收,防止用户程序OOM。由concurrent mode failure导致一次 Full GC产生。另一方面,CMS会产生内存碎片,并发清除后可能使得用户线程可分配空间不足,(普遍基于复制算法的年轻代GC)无法为大对象分配内存时可能会提前触发Full GC。

CMS垃圾收集器特有的错误,CMS的垃圾清理和应用线程运行是并行进行的,如果在并行清理的过程中老年代的空间不足以容纳应用新产生的垃圾(也就是老年代正在清理,用户线程产生垃圾触发GC,而从年轻代晋升了新的对象,或者用户程序直接分配大对象年轻代放不下导致直接在老年代生成,这时候老年代也放不下),则会抛出“concurrent mode failure”

一旦CMS退化为serial old,则停顿时间将会大大增加(退化为了STW)。产生该现象可能的原因:
【1】空间碎片太多,用户程序运行时总是在触发minor GC
【2】CMS触发太晚了
【3】用户产生垃圾的速度超过了CMS清理的速度

CMS收集器也无法处理浮动垃圾,并发标记阶段如果产生了新的垃圾对象,CMS无法及时对新产生的垃圾对象进行标记,只能留到下一次并发GC。(CMS可以通过增量更新避免对象被误删除,但是不能处理浮动垃圾)

CMS对CPU资源十分敏感,并发阶段将会占用一部分操作系统线程资源(参与CPU争抢),导致应用程序变慢(需要等待获得CPU资源),总吞吐量下降
CMS默认启动的线程数是 (ParallelGCThreads + 3) / 4ParallelGCThreads是年轻代并行收集器的线程数,可以当做是 CPU 最大支持的线程数。当CPU资源比较紧张时,受到CMS收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕

-XX:+use concMarkSweep GC 手动指定使用CMS执行内存回收任务,同时useParNewGC会自动打开——parNew(年轻代)+CMS(老年代)+serial old(老年代备选)
-XX:CMSFullGCsBeforeCompaction:设置在执行多少次Full GC后对内存空间进行压缩整理
-XX:ParallelCMSThreads:设置CMS的线程数量

总结

GC算法的古典时代:
单线程:年轻代:Serial 老年代:serialOld
多线程:年轻代:parallel scavenge 老年代:parallel old
中古时代:
CMS

其中相对固定的搭配:
serial——serial Old (serial 与 CMS的组合jdk8已经被废弃)
parallel scavenge——parallel old、serial old
parNew——CMS (parNew与serial Old组合jdk8已经被废弃)
其中CMS失效时,会临时切换为serial old

如果你想要最小化地使用内存和并行开销,请选Serial GC;
如果你想要最大化应用程序的吞吐量,请选Parallel GC;
如果你想要最小化GC的中断或停顿时间,请选CMS GC。

G1

现代的堆越来越大,不适合全堆扫描。而G1是一个面向全堆的垃圾回收器,在JDK9正式替换了parallel scavenger/parallel old的组合,成为服务器模式下的默认垃圾回收器。

G1使用:
【1】开启G1垃圾回收器
【2】设置堆的最大内存(-Xmx:)
【3】设置最大停顿时间(-XX:MaxGCPauseMillis)

G1可以看作同时具有parallel scavenger和CMS的特点,官方目标:在(停顿时间)延迟可控的情况下,尽可能获得高的吞吐量
相对于parallel scavenger,G1可以建立停顿时间模型,做到软实时,同时尽可能获得高的吞吐量。相对于CMS,G1可以避免内存碎片,同时兼顾吞吐量。

G1不再将回收目标限定在某个区域,而是将内存切块,将回收的部分组成回收集合collection set,衡量的标准不再是属于哪一个分代,而是哪一块(region)的垃圾数量最多,回收收益最大,这就是G1的mixed GC模式。
G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域region,并且跟踪这些区域 的垃圾收集进度,同时在后台维护一个优先级列表每次根据所允许的收集时间,优先回收垃圾最多的区域

和前面的几款垃圾回收器不同,G1它是面向全堆的,不管是年轻代还是老年代都可以管理,因此回收集可以同时包含不同的分代

G1会跟踪各个region中垃圾的“价值”大小,价值即为回收获得的空间大小以及回收所需时间的经验值每次根据用户设定的最大停顿时间,优先收集价值最大的那些region

区域划分优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率

总结:
G1建立了可预测的停顿时间模型(软实时),可以让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超过N毫秒
由于分区的原因,G1只对**选取分区(回收集)**进行内存回收,缩小了回收的范围,可以对全局停顿情况进行更好的把控

region

在这里插入图片描述
G1的分区不再是Eden+survivor+tenured了。而是将内存划分为一个个的region。内存的回收是以region作为基本单位的。Region之间是复制算法,但整体上实际可以看作是标记-压缩算法。G1的垃圾回收算法可以避免产生内存碎片,因此G1下的堆内存比较规整,JVM通过指针碰撞分配空间,有利于程序的长时间运行,不会因为分配大对象时,找不到内存空间而提前触发GC,适合大内存堆。同时,G1的缺点也很明显**,垃圾回收时需要管理每个的region信息,系统变量占用内存很多,而且G1垃圾收集产生的内存占用、程序运行时的额外执行负载也很高**,因此适合大内存的堆,那种负载信息和总内存大小相比不算事的那种。

可以通过操作系统分页内存管理和连续内存管理去对比G1和之前的那种内存分配管理

一个region可能属于Eden、survivor或者old内存区域,但是一个region只可能属于一个角色。G1收集器还增加了一种新的内存区域humongous,主要用于存放大对象。(如果超过1.5个region大小就放入H区)

为什么设计humongous?
对于一个短期存在的大对象,如果直接分配到老年代就会对垃圾回收器造成负面影响。如果一个H区装不下(找不到连续区的话)那么G1会寻找连续的H区来存储,有时不得不启动full GC。

每个region的大小,通过**-XX:G1HeapRegionSize**设置,默认最多2048个region。

每次回收不需要回收整个区域,而是选择一个区域集合collection set(region集合,不像之前一整代回收,而是将若干个region组合回收,这些region都是G1分析得出的有价值的region)

共两种GC: young GC和mixed GC。(尽量避免fullGC)
G1可以在最后的回收阶段,粗略计算每个region的垃圾比例,优先回收垃圾多的region
将堆内存划分为若干个region,判断每个region的垃圾比例,优先回收垃圾多的region(或更有回收价值的region)。(garbage first)

G1仍然使用分代的思想,但是不要求分代是物理上连续的,也不再坚持将内存划分为固定比例。将堆空间分为若干个区域(region),这些区域包含了逻辑上的年轻代和老年代

应用场景:面向服务端、面向具有大内存、多处理器的机器。

G1的跨代引用问题

一个region中的对象难以避免被其他region中的对象引用,G1同样也是基于记忆集避免全region扫描。其中每个region都有一个对应的记忆集

每次reference类型数据写操作时,都会产生一个write barrier暂时中断操作,然后检查将要写入的引用指向的对象是否和该reference类型数据在不同的region(检查是否跨region修改引用,同一个region就没必要记录了,因为本身就会扫描)。如果不同,通过卡表把相关引用信息记录到引用指向对象(被跨代引用对象)的所在region对应的remember set中。当进行垃圾收集时,在GC根结点的枚举范围包括记忆集,就可以保证不进行全局扫描,也不会漏扫描。
在这里插入图片描述
在进入GC的时候,region2的记忆集已经记录了两块内存,这两块内存中的某些对象具有指向region对象的跨代指针。进行根结点枚举时除了需要枚举region中的对象,还需要将脏卡页指向的内存作为扫描的内容

G1垃圾回收

G1的垃圾回收主要为以下三个环节
【1】年轻代GC
【2】老年代并发标记过程(一般会伴随年轻代GC)
【3】混合回收(标记和回收是两个独立的过程)
在一定情况下还会触发单线程、**独占式(STW)**的full GC,这是一种强力回收的保护机制。G1垃圾回收的目的是尽可能的避免full GC

G1是面向全堆的,所以它同时具有年轻代GC和老年代GC,同时还能够在一定条件下触发mixed GC或者full GC。

其中年轻代GC和其他年轻代垃圾回收产品的过程很像,是多个GC线程和全程STW的。
全局并发标记主要为mixed GC服务,属于老年代并发标记过程(老年代并发标记结束后,马上开始混合回收过程)。它标记出来老年代region垃圾数量,使得mixed GC可以创建适合的回收集,在可预测的停顿时间下,优先回收“回收价值”大的垃圾。
因此G1的老年代回收器不需要整个老年代被回收,一次只需要回收一小部分老年代的region就可以了。mixedGC,老年代和年轻代region是一起被回收的(不是基于generation而是基于回收集)。

young GC

应用程序分配内存,当年轻代的Eden区用尽(Eden对应region块没有空闲内存)时开始年轻代回收过程。G1的年轻代收集阶段是一个并行的独占式收集器——年轻代GC,用户线程是STW的,多个GC线程并行清理。(和之前几款年轻代垃圾收集器的流程很像如parNew)

(默认新生代占比region60%时,触发年轻代GC,标记复制算法,STW)
JVM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden耗尽时,G1会启动一次年轻代垃圾回收过程。(只有Eden满才可以触发)

在年轻代回收期,会暂停所有的应用程序线程,启动多线程GC进行年轻代回收。然后从年轻代区间移动存活对象到survivor区间或者老年代区间,也有可能是两个区间都会涉及。

G1创建回收集(collection set),回收集是指需要被回收的内存分段的集合

年轻代回收只回收Eden区和survivor区

回收完Eden区和survivor区,Eden剩余存活的对象会复制到新的survivor区,survivor区达到一定的阈值可以晋升为old区对象
【1】达到年龄阈值 【2】动态年龄判断,survivor区年龄1、2、3对应的对象存活率加起来达到50%了,那么年龄3以上的对象直接晋升如老年代。
一般超过region大小一半的对象都使用humongous存放,甚至使用多个存放。该区域的回收会在新生、老年代回收时捎带回收

YGC 细节补充:
【1】首先对Eden和survivor的region块构建回收集(一般YGC构建回收集包含所有的eden和survivorregion)
【2】将根结点连通记忆集记录的内存作为扫描存活对象的入口
【3】将脏页队列的待更新卡页全部更新到卡表(记忆集)中
【4】扫描记忆集,识别老年代对象指向的年轻代对象,将这些对象看作可达对象
【5】基于复制算法,将存活对象拷贝到一块空闲的 survivor region,并且清空原region的内容(survivor内存不够则放入humongous,达到年龄阈值则晋升)
【6】以上处理的是强引用,接下来接着处理其他类型的引用,最终回收集的region被清空,YGC完毕

老年代并发标记

当堆内存的使用达到一定值(默认45%)时,开始老年代并发标记过程
过程和CMS有一定相似之处,都是一定程度上和用户线程并发执行的。

细节;
【1】初始标记.(STW,CMS也存在这个过程,时间很短,标记根节点可以直接达到的对象
该阶段一般都是和minor GC同步进行的,因此和minor GC同时等待相同的停顿时间
minor GC的STW和并发标记过程的初始标记STW相当于一起进行了
【2】根区域扫描
G1扫描survivor区对象直接引用的老年代region中的对象,并标记被引用的对象(minor GC之前完成,因为minor GC会使用复制算法对survivor进行GC)
【3】并发标记。
和应用程序并发执行,对整个堆进行并发标记,这个过程可能被minor GC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收
并发标记的过程中,还会计算每个区域的对象活性(区域中存活对象的比例)
【4】再次标记/最终标记(STW回收 ,修正并发标记的结果,但是比CMS的增量更新快)采用了STAB原始快照算法。清空SATB缓冲区,跟踪未被访问的存活对象,并执行引用处理
【5】独占清理(STW)
该阶段不会进行实际上的垃圾收集,会计算各个region的回收价值和成本,并进行排序识别可以混合回收的区域根据用户期望的停顿时间制定回收计划。(为了达到预测时间,只会回收部分region)
【6】并发清理阶段:识别并清理完全空闲的区域

mixed GC

当越来越多对象晋升到老年代old region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾回收器——mixed GC(通常在老年代并发标记后紧接着执行mixed GC)。该算法会回收整个young region和部分 old region,这里是mixed GC而不是full GC
根据停顿时间的目标,优先选择垃圾最多的old region进行

混合回收细节:
【1】并发标记结束后,老年region中百分比为垃圾的内存分段被回收了,还有一部分老年代region只有部分是垃圾。默认情况下这些老年代的内存分段分为8次被回收。(该老年代region被分为8个内存分段)
【2】混合回收的回收集,包括八分之一的老年代内存小段、Eden region和survivor region。混合回收和年轻代回收的算法完全一致只是包含了一部分老年代内存分段
【3】对老年代内存分段的8次回收,G1优先收集垃圾多的小段,并有一个阈值会决定内存分段是否被回收
【4】混合回收并不一定要进行8次。有一个阈值**-XX:G1HeapWastePercent**,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。

个人理解:引入mixed GC的目的更多是为了避免full GC,因为G1是面向整个堆的,同时支持年轻代GC和老年代GC,而G1的年轻代GC和老年代GC总是根据内存使用量阈值触发的,而如果内存仍然不够使用,就需要执行一次大型的GC去试图拿到更多内存,而mixed GC可以看作更轻量的full GC,是支持并行的、渐进式回收的,而full GC则是STW。

Full GC

G1设计的初衷就是避免full GC
导致G1 full GC原因和CMS的concurrent mode failure类似。
回收阶段没有足够的to-space来存放晋升的对象并发处理过程完成之前空间已经耗尽(垃圾产生速度大于回收速度,内存耗尽)。如:堆内存太小,G1复制存活对象的时候没有空的内存分段可用,会回退到类似serial/serial old,这种情况下可以通过增大内存解决。

FullGC时,G1会停止执行应用程序,使用单线程的内存回收算法进行垃圾回收

G1聊天

G1是面向全堆的垃圾回收器,是官方推荐并且JDK9默认的垃圾回收器,它的主要特点就是保证软实时,可以指定一个期望的停顿时间。G1保留了分代的思想,但是将整个内存都划分为大小相同的若干个region,这个思想类似操作系统的内存分页管理的思想。并且每次不是选取某个区进行GC,而是生成一个由region组成的回收集。G1还会跟踪每个region的回收价值,为他们在后台维护一个优先级列表,每次优先回收价值最大的垃圾,这是G1实现软实时的基础,也是garbage first名字的由来。
region之间存在跨度引用问题,需要为每个region维护一个卡表,这个卡表指向存在指向当前region指针所在的内存区域。
G1是面向全堆的,可以分为三个阶段:年轻代GC、老年代并发标记清除以及mixed GC,在某些情况下还会引发full GC。G1设计的初衷就是避免full GC,因为full GC阶段会退化为单线程GC,停顿时间大大增加。
其中年轻代GC类似于其他年轻代GC产品,是STW独占式回收的,每个region都是基于标记复制算法,整体上看是标记整理算法,当eden区的region耗尽后就会触发YGC,G1会创建回收集,并将eden和survivor region中有价值的垃圾作为回收集的一部分。回收完Eden区和survivor区,Eden剩余存活的对象会复制到新的survivor区,survivor区达到一定的阈值可以晋升为old区对象。
老年代并发标记过程为mixed GC服务,算法类似CMS,可以分为初始标记、并发标记、修正标记和并发清理。当有不断地年轻代向老年代晋升就会触发mixed GC,mixed GC的内容是年轻代与部分老年代,而且根据暂停目标,优先选择垃圾最多的老年代region进行。

常见GC参数

-XX:G1 heap region size 默认2048 ,region总数,堆内存/总数就是单个region大小

如果一个对象的对象大于region的一半,就会被视为大对象并放入humongous区。某些场景下,可以适度调大region size,减少大对象的判断,提高普通region空间的利用率。

-XX:G1 new size percent 上限默认5%
-XX:G1 max new size percent 下限默认60% 默认情况下Eden占60%。

-XX:Parallel GC threads STW阶段工作的并行线程数量

-XX: initializating heap occupancy percent 触发全局并发标记的老年代占比,默认45%
-XX:max tenuring threshold 晋升年龄阈值,调节该值,可以控制大对象的晋升数量
-XX: pretenure size threshold: 设置大对象直接进入老年代的阈值
-xx:G1 heap waste percent 触发混合GC的堆垃圾占比

-XX:+ Use conc mark sweep gc 开启CMS(use GC名字 GC)
-XX: + trace class loading 监控类加载
-XX:newRatio 新老比例
-XX:survivorRatio:survivor区和Eden比例
-XX: + head dump on Out of memory error 出现OOM时导出堆信息到文件中

-XX:+UseG1GC 启用G1
-XX:MaxGCPauseMillis 设置最大垃圾回收停顿时间

-XX:printGC
-verbose:gc
打印GC日志

反正常问的注意一下:启用某个垃圾收集器、开启GC日志、G1相关的、然后就是一些相对通用的如:新老比例、E和S的比例、晋升阈值、一些指定上下限的值…(还有一些常用的GC参数就是堆、栈相关的参数了)

一定需要知道的

堆上限 -Xmx 即 memory max
堆下限 -Xms 即 memory size
打印类加载信息 -XX:+TraceClassLoading
并行GC线程数量 -XX:parallel GC Thread
eden和survivor的比例: -XX:survivor ratio 如果是8代表eden占8份,两个survivor各占一份,survivor占1/10
新老比例:-XX:new ratio : 4代表,老占4,新占1。(老年代比较大)
晋升老年代年龄:-XX:max tenuring threshold
打印GC日志:-XX:+printGC(details) 或者 -verbose:gc
G1 region大小: -XX:G1 heap region size
G1 预期停顿时间 :-XX:Max GC Pause Millis

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值