深入理解 JVM(2)——GC 算法与内存分配策略
in Java 进击 with 0 comment

深入理解 JVM(2)——GC 算法与内存分配策略

in Java 进击 with 0 comment

深入理解 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 到这个对象不可达)时,则证明此对象时不可用的。用下图来加以说明:

![][1]

[1]: https://pic.yupoo.com/crowhawk/5d0246eb/0635cbe8.png)

上图中,对象 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 虚拟机上实现这些算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。

枚举根节点

从可达性分析中从 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 过于频繁。

对于 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 虚拟机如何去发起内存回收的问题,也解答了文章开头提出的三个问题中的前两个——“哪些内存需要回收”和“何时回收”,同时对于第三个问题——“如何回收”,在原理层面作出了解答。在下一篇文章中,笔者将通过介绍几种具体的垃圾收集器,来更深入地回答第三个问题。

参考:

From: https://crowhawk.github.io/2017/08/10/jvm_2/