《深入理解JVM(2)——GC算法与内存分配策略》
说起垃圾收集(Garbage Collection, GC),想必大家都不陌生,它是JVM实现里非常重要的一环,JVM成熟的内存动态分配与回收技术使Java(当然还有其他运行在JVM上的语言,如Scala等)程序员在提升开发效率上获得了惊人的便利。理解GC,对于理解JVM和Java语言有着非常重要的作用。并且当我们需要排查各种内存溢出、内存泄漏问题时,当垃圾收集称为系统达到更高并发量的瓶颈时,只有深入理解GC和内存分配,才能对这些“自动化”的技术实施必要的监控和调节。
在Java的运行时数据区中,程序计数器、虚拟机栈、本地方法栈三个区域都是线程私有的,随线程而生,随线程而灭,在方法结束或线程结束时,内存自然就跟着回收了,不需要过多考虑回收的问题。而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾回收器关注的是这部分内存,后续讨论的“内存”分配回收也是指这一块,尤其需要注意。
GC主要回答了以下三个问题:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
这三个问题的具体解决方案,也就是本文接下来要讲解的内容。
对象存活判定算法
在堆里存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,首要的就是确定这些对象中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。
引用计数算法
引用计数算法是在JVM中被摒弃的一种对象存活判定算法,不过它也有一些知名的应用场景(如Python、FlashPlayer),因此在这里也简单介绍一下。
用引用计数器判断对象是否存活的过程是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1;任何时刻计数器为0的对象就是不可能再被使用的。
引用计数算法的实现简单,判定效率也很高,大部分情况下是一个不错的算法。它没有被JVM采用的原因是它很难解决对象之间循环引用的问题。例如以下例子:
/** * testGC()方法执行后,objA和objB会不会被GC呢? */ public class ReferenceCountingGC { public Object instance = null; private static final int _1MB = 1024 * 1024; /** * 这个成员属性的唯一意义就是占点内存,以便在能在GC日志中看清楚是否有回收过 */ private byte[] bigSize = new byte[2 * _1MB]; public static void testGC() { ReferenceCountingGC objA = new ReferenceCountingGC(); ReferenceCountingGC objB = new ReferenceCountingGC(); objA.instance = objB; objB.instance = objA; objA = null; objB = null; // 假设在这行发生GC,objA和objB是否能被回收? System.gc(); } }
在上面这段代码中,对象objA 和对象objB都有字段instance,赋值令objA.instance = objB;
、objB.instance = objA;
,除此之外,这两个对象再无引用。如果JVM采用引用计数算法来管理内存,这两个对象不可能再被访问,但是他们互相引用着对方,导致它们引用计数不为0,所以引用计数器无法通知GC收集器回收它们。
而事实上执行这段代码,objA和objB是可以被回收的,下面一节将介绍JVM实际使用的存活判定算法。
可达性分析算法
在主流商用程序语言的实现中,都是通过可达性分析(tracing GC)来判定对象是否存活的。此算法的基本思路是:通过一系列的称为“GC Roots”的对象作为起点,从这些节点向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是GC Roots 到这个对象不可达)时,则证明此对象时不可用的。用下图来加以说明:
上图中,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
可以看到,GC Roots在对象图之外,是特别定义的“起点”,不可能被对象图内的对象所引用。
准确地说,GC Roots其实不是一组对象,而通常是一组特别管理的指向引用类型对象的指针,这些指针是tracing GC的trace的起点。它们不是对象图里的对象,对象也不可能引用到这些“外部”的指针,这也是tracing GC算法不会出现循环引用问题的基本保证。因此也容易得出,只有引用类型的变量才被认为是Roots,值类型的变量永远不被认为是Roots。只有深刻理解引用类型和值类型的内存分配和管理的不同,才能知道为什么root只能是引用类型。
在Java中,可作为GC Roots的对象包括以下几种:
- 虚拟机栈(栈帧中的局部变量表,Local Variable Table)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(即一般说的Native方法)引用的对象。
看到这里你可能要问,选择这些对象的依据是什么呢?
可以概括得出,可作为GC Roots的节点主要在全局性的引用与执行上下文中。要明确的是,tracing gc必须以当前存活的对象集为Roots,因此必须选取确定存活的引用类型对象。GC管理的区域是Java堆,虚拟机栈、方法区和本地方法栈不被GC所管理,因此选用这些区域内引用的对象作为GC Roots,是不会被GC所回收的。其中虚拟机栈和本地方法栈都是线程私有的内存区域,只要线程没有终止,就能确保它们中引用的对象的存活。而方法区中类静态属性引用的对象是显然存活的。常量引用的对象在当前可能存活,因此,也可能是GC roots的一部分。
两次标记与 finalize()方法
即使在可达性分析算法中不可达的对象,也不是一定会死亡的,它们暂时都处于“缓刑”阶段,要真正宣告一个对象“死亡”,至少要经历两次标记过程:
如果对象在进行可达性分析后发现没有与 GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finaliza()
方法。当对象没有覆盖finaliza()
方法,或者finaliza()
方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为有必要执行finaliza()
方法,那么此对象将会放置在一个叫做 F-Queue 的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发此方法,但并不承诺会等待它运行结束,原因是:如果一个对象在finaliza()
方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能导致F-Queue 队列中的其它对象永久处于等待,甚至导致整个内存回收系统崩溃。
finaliza()
方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue 队列中的对象进行第二次小规模的标记。如果对象想在finaliza()
方法中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,例如把自己(this关键字)赋值给某个类变量或者对象的成员变量,这样在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,基本上它就真的被回收了。
值得注意的是,如果代码中有两段一模一样的代码段,执行结果却是一次逃脱成功,一次失败。这是因为任何一个对象的finalize()
方法都只会被系统调用一次,如果对象面临下一次回收,它的finalize()
方法不会再被执行,因此第二次逃脱行动失败。
需要说明的是,使用finalize()
方法来“拯救”对象是不值得提倡的,因为它不是C/C++中的析构函数,而是Java刚诞生时为了使C/C++程序员更容易接受它所做的一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。finalize()
能做的工作,使用try-finally
或者其它方法都更适合、及时,所以笔者建议大家可以忘掉此方法存在。
回收方法区
很多人认为方法区没有垃圾回收,Java虚拟机规范中确实说过不要求,而且在方法区中进行垃圾收集的“性价比”较低:在堆中,尤其是新生代,常规应用进行一次垃圾收集可以回收70%~95%的空间,而方法区的效率远低于此。在JDK 1.8中,JVM摒弃了永久代,用元空间来作为方法区的实现,下面介绍的将是元空间的垃圾回收。
元空间的内存管理由元空间虚拟机来完成。先前,对于类的元数据我们需要不同的垃圾回收器进行处理,现在只需要执行元空间虚拟机的C++代码即可完成。在元空间中,类和其元数据的生命周期和其对应的类加载器是相同的。话句话说,只要类加载器存活,其加载的类的元数据也是存活的,因而不会被回收掉。
我们从行文到现在提到的元空间稍微有点不严谨。准确的来说,每一个类加载器的存储区域都称作一个元空间,所有的元空间合在一起就是我们一直说的元空间。当一个类加载器被垃圾回收器标记为不再存活,其对应的元空间会被回收。在元空间的回收过程中没有重定位和压缩等操作。但是元空间内的元数据会进行扫描来确定Java引用。
垃圾收集算法
本节将介绍几种垃圾收集算法的思想及其发展过程,具体的实现将在稍后介绍。
标记-清除(Mark-Sweep)算法
标记-清除(Mark-Sweep)算法是最基础的垃圾收集算法,后续的收集算法都是基于它的思路并对其不足进行改进而得到的。顾名思义,算法分成“标记”、“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,标记过程在前一节讲述对象标记判定时已经讲过了。
标记-清除算法的不足主要有以下两点:
- 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不触发另一次垃圾收集动作。
- 效率问题,因为内存碎片的存在,操作会变得更加费时,因为查找下一个可用空闲块已不再是一个简单操作。
标记-清除算法的执行过程如下图所示:
复制(Copying)算法
为了解决标记-清除算法的效率问题,一种称为“复制”(Copying)的收集算法出现了,思想为:它将可用内存按容量分成大小相等的两块,每次只使用其中的一块。当这一块内存用完,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
这样做使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,代价可能过高了。复制算法的执行过程如下图所示:
Minor GC与复制算法
现在的商业虚拟机都使用复制算法来回收新生代。新生代的GC又叫“Minor GC”,IBM公司的专门研究表明:新生代中的对象98%是“朝生夕死”的,所以Minor GC非常频繁,一般回收速度也比较快,同时“朝生夕死”的特性也使得Minor GC使用复制算法时不需要按照1:1的比例来划分新生代内存空间。
Minor GC过程
事实上,新生代将内存分为一块较大的Eden空间和两块较小的Survivor空间(From Survivor和To Survivor),每次Minor GC都使用Eden和From Survivor,当回收时,将Eden和From Survivor中还存活着的对象都一次性地复制到另外一块To Survivor空间上,最后清理掉Eden和刚使用的Survivor空间。一次Minor GC结束的时候,Eden空间和From Survivor空间都是空的,而To Survivor空间里面存储着存活的对象。在下次MinorGC的时候,两个Survivor空间交换他们的标签,现在是空的“From” Survivor标记成为“To”,“To” Survivor标记为“From”。因此,在MinorGC结束的时候,Eden空间是空的,两个Survivor空间中的一个是空的,而另一个存储着存活的对象。
HotSpot虚拟机默认的Eden : Survivor的比例是8 : 1,由于一共有两块Survivor,所以每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的容量会被“浪费”。
分配担保
上文说的98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖老年代内存进行分配担保(Handle Promotion)。如果另外一块Survivor上没有足够空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代。
标记-整理(Mark-Compact)算法
复制算法在对象存活率较高时要进行较多的复制操作,效率将会变低。更关键的是:如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用复制算法。
根据老年代的特点,标记-整理(Mark-Compact)算法被提出来,主要思想为:此算法的标记过程与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。具体示意图如下所示:
分代收集(Generational Collection)算法
当前商业虚拟机的垃圾收集都采用分代收集(Generational Collection)算法,此算法相较于前几种没有什么新的特征,主要思想为:根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法:
新生代 在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
老年代 在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清除”或“标记-整理”算法来进行回收。
HotSpot的算法实现
前面两大节主要从理论上介绍了对象存活判定算法和垃圾收集算法,而在HotSpot虚拟机上实现这些算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。
枚举根节点
从可达性分析中从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的局部变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。
GC停顿(”Stop The World”)
另外,可达性分析工作必须在一个能确保一致性的快照中进行——这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,这是保证分析结果准确性的基础。这点是导致GC进行时必须停顿所有Java执行线程(Sun将这件事情称为“Stop The World”)的其中一个重要原因,即使是在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
准确式GC与OopMap
由于目前的主流Java虚拟机使用的都是准确式GC(即使用准确式内存管理,虚拟机可用知道内存中某个位置的数据具体是什么类型),所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。
安全点(Safepoint)——进行GC时程序停顿的位置
在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实的问题随之而来:可能导致引用关系变化,或者说OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC的空间成本将会变得很高。
为此,HotSpot选择不为每条指令都生成OopMap,而是只在“特定的位置”记录这些信息,这些位置便被称为安全点(Safepoint)。也就是说,程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。
对于Sefepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:
- 抢先式中断(Preemptive Suspension) 抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
- 主动式中断(Voluntary Suspension): 主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
安全区域(Safe Region)
Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候(如线程处于Sleep状态或Blocked状态),这时线程无法响应JVM的中断请求,“走到”安全的地方去中断挂起,这时候就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。
内存分配策略
Java的自动内存管理最终可以归结为自动化地解决了两个问题:
- 给对象分配内存
- 回收分配给对象的内存
对象的内存分配通常是在堆上分配(除此以外还有可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是固定的,实际取决于垃圾收集器的具体组合以及虚拟机中与内存相关的参数的设置。至于内存回收策略,在上文已经描述得很详尽了。
下面以使用Serial/Serial Old收集器(将在下一篇文章中讲解)为例,介绍内存分配的策略。
对象优先在Eden区分配
大多数情况下,对象在新生代的Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是很长的字符串以及数组。大对象对虚拟机的内存分配来说是一个坏消息(尤其是遇到朝生夕灭的“短命大对象”,写程序时应避免),经常出现大对象容易导致内存还有不少空间时就提前触发GC以获取足够的连续空间来安置它们。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(新生代采用复制算法回收内存)。
长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。
Full GC的触发条件
对于Minor GC,其触发条件非常简单,当Eden区空间满时,就将触发一次Minor GC。而Full GC则相对复杂,因此本节我们主要介绍Full GC的触发条件。
调用System.gc()
此方法的调用是建议JVM进行Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加Full GC的频率,也即增加了间歇性停顿的次数。因此强烈建议能不使用此方法就不要使用,让虚拟机自己去管理它的内存,可通过-XX:+ DisableExplicitGC来禁止RMI调用System.gc()。
老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等,当执行Full GC后空间仍然不足,则抛出如下错误: Java.lang.OutOfMemoryError: Java heap space
为避免以上两种状况引起的Full GC,调优时应尽量做到让对象在Minor GC阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。
空间分配担保失败
前文介绍过,使用复制算法的Minor GC需要老年代的内存空间作担保,如果出现了HandlePromotionFailure担保失败,则会触发Full GC。
JDK 1.7及以前的永久代空间不足
在JDK 1.7及以前,HotSpot虚拟机中的方法区是用永久代实现的,永久代中存放的为一些class的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation可能会被占满,在未配置为采用CMS GC的情况下也会执行Full GC。如果经过Full GC仍然回收不了,那么JVM会抛出如下错误信息: java.lang.OutOfMemoryError: PermGen space
为避免PermGen占满造成Full GC现象,可采用的方法为增大PermGen空间或转为使用CMS GC。
在JDK 1.8中用元空间替换了永久代作为方法区的实现,元空间是本地内存,因此减少了一种Full GC触发的可能性。
Concurrent Mode Failure
执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足(有时候“空间不足”是CMS GC时当前的浮动垃圾过多导致暂时性的空间不足触发Full GC),便会报Concurrent Mode Failure
错误,并触发Full GC。
小结
本文简要地介绍了HotSpot虚拟机如何去发起内存回收的问题,也解答了文章开头提出的三个问题中的前两个——“哪些内存需要回收”和“何时回收”,同时对于第三个问题——“如何回收”,在原理层面作出了解答。在下一篇文章中,笔者将通过介绍几种具体的垃圾收集器,来更深入地回答第三个问题。