转载

由浅入深了解GC原理

GCGarbage Collection )很大程度上帮助 Java 程序员解决了内存释放的问题,有了 GC ,就不需要再手动的去控制内存的释放。

在阅读之前需要了解的相关概念:

Java 堆内存分为新生代和老年代,新生代中又分为 1Eden 区域 和 2Survivor 区域。

一、什么是GC(Garbage Collection)

GC 垃圾收集, Java 提供的 GC 可以自动监测对象是否超过作用域从而达到自动回收内存的目的。

每个程序员都遇到过内存溢出的情况,程序运行时,内存空间是有限的,那么如何及时的把不再使用的对象清除将内存释放出来,这就是GC要做的事。

需要 GC 的内存区域

JVM 中,程序计数器、虚拟机栈、本地方法栈都是随线程而生随线程而灭,栈帧随着方法的进入和退出做入栈和出栈操作,实现了自动的内存清理,因此,我们的内存垃圾回收主要集中于 JAVA 堆和方法区中,在程序运行期间,这部分内存的分配和使用都是动态的。

注意:

对于 Java8HotSpots 取消了永久代,那么是不是也就没有方法区了呢?当然不是,方法区是一个规范,规范没变,它就一直在。那么取代永久代的就是元空间。它可永久代有什么不同的?存储位置不同,永久代物理是是堆的一部分,和新生代,老年代地址是连续的,而元空间属于本地内存;存储内容不同,元空间存储类的元信息,静态变量和常量池等并入堆中。相当于永久代的数据被分到了堆和元空间中。

GC 的对象

当一个对象到 GC Roots 不可达时,在下一个垃圾回收周期中尝试回收该对象,如果对象重写了 finalize() ,并在这个方法中成功自救(将自身赋予某个引用),那么这个对象不会被回收。但如果这个对象没有重写 finalize() 方法或已执行过这个方法,该对象将会被回收。

需要进行回收的对象就是已经没有存活的对象,判断一个对象是否存活常用的有两种办法:引用计数算法和可达性分析算法。

  • 引用计数算法:

每个对象有一个引用计数属性,新增一个引用时计数加 1 ,引用释放时计数减 1 ,计数为 0 时可以回收。此方法简单,无法解决对象相互循环引用的问题。

  • 可达性分析算法(Reachability Analysis):

GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,不可达对象。

在Java语言中, GC Roots 包括:

JNI

什么时候触发 GC

  • 程序调用 System.gc 时,但不是必然执行
  • 系统自身来决定 GC 触发的时机(根据 Eden 区和 From Space 区的内存大小来决定。当内存大小不足时,则会启动 GC 线程并停止应用线程)

GC 又分为 Minor GCFull GC (也称为 Major GC )

Minor GC 触发条件:当 Eden 区满时,触发 Minor GC

Full GC 触发条件:

  • 调用 System.gc 时,系统建议执行 Full GC ,但是不必然执行
  • 老年代空间不足
  • 方法去空间不足
  • 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
  • Eden 区、 From Space 区向 To Space 区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

GC 做了什么事

主要做了清理对象,整理内存的工作。 Java 堆分为新生代和老年代,采用了不同的回收方式。

GC常用算法

GC 常用算法有: 标记-清除算法,标记-压缩算法,复制算法,分代收集算法

目前主流的 JVMHotSpot )采用的是分代收集算法。

标记-清除算法( Mark-Sweep )

首先标记出所有需要回收的对象,标记完成后回收所有被标记的对象。不足主要体现在效率和空间,从效率的角度讲,标记和清除效率都不高;从空间的角度讲,标记清除后会产生大量不连续的内存碎片, 内存碎片太多可能会导致需要分配较大对象时,无法找到足够的连续内存而提前触发一次垃圾收集动作。

从堆栈和静态存储区出发,遍历所有的引用,进而找出所有存活的对象,如果活着,就标记。只有全部标记完毕的时候,清理动作才开始。在清理的时候,没有标记的对象将会被释放,不会发生任何动作。但是剩下的堆空间是不连续的,垃圾回收器要是希望得到连续空间的话,就得重新整理剩下的对象。

优点:标记—清除算法中每个活着的对象的引用只需要找到一个即可,找到一个就可以判断它为活的。此外,更重要的是,这个算法并不移动对象的位置。

缺点:它的缺点就是效率比较低(递归与全堆对象遍历)。每个活着的对象都要在标记阶段遍历一遍;所有对象都要在清除阶段扫描一遍,因此算法复杂度较高。没有移动对象,导致可能出现很多碎片空间无法利用的情况。

由浅入深了解GC原理

标记-压缩算法(标记-整理)( Mark-Compact )

过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是将所有存活的对象整理一下,放到另一处空间,然后把剩下的所有对象全部清除。这样就达到了标记-整理的目的。

优点:该算法不会像标记-清除算法那样产生大量的碎片空间。

缺点:如果存活的对象过多,整理阶段将会执行较多复制操作,导致算法效率降低。

由浅入深了解GC原理

复制( Copying )算法

将可用内存分为两块,每次只用其中一块,当一块内存用完了,就将还存活的对象复制到另外一块上,然后再把已经使用过的内存空间一次性清理掉,循环下去。这样每次只需对整个半区进行内存回收,内存分配时也不需要考虑内存碎片等复杂情况,只需要移动指针,按照顺序分配即可。

优点:实现简单;不产生内存碎片

缺点:内存缩小为原来的一半,代价太高

现在商用虚拟机都采用这种算法来回收新生代,不过 1:1 的比例非常不科学,因此新生代的内存被划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。每次回收时,将 EdenSurvivor 中还存活着的对象一次性复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。 HotSpot 虚拟机默认 Eden 区和 Survivor 区的比例为 8:1 ,意思是每次新生代中可用内存空间为整个新生代容量的 90% 。当然,我们无法保证每次回收都少于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖老年代进行分配担保( Handle Promotion )。

由浅入深了解GC原理

分代收集( Generational Collection )算法

分代收集算法根据对象的生存周期,将堆分为新生代( Young )和老年代( Tenur )。在新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用 复制算法 。老年代里的对象存活率较高,没有额外的空间进行分配担保,所以可以使用 标记-整理 或者 标记-清除

新生代( Young )分为 Eden 区, From 区与 To 区:

由浅入深了解GC原理

当系统创建一个对象的时候,总是在 Eden 区操作,当这个区满了,那么就会触发一次 YoungGC ,也就是年轻代的垃圾回收。一般来说这时候并不是所有的对象都没用了,所以就会把还能用的对象复制到 From 区:

由浅入深了解GC原理

这样整个 Eden 区就被清理干净了,可以继续创建新的对象,当 Eden 区再次被用完,就再触发一次 YoungGC ,然后注意,这个时候跟刚才稍稍有点区别。这次触发 YoungGC 后,会将 Eden 区与 From 区还在被使用的对象复制到 To 区:

由浅入深了解GC原理

再下一次 YoungGC 的时候,则是将 Eden 区与 To 区中的还在被使用的对象复制到 From 区:

由浅入深了解GC原理

经过若干次 YoungGC 后,有些对象在 FromTo 之间来回游荡,这时候 From 区与 To 区亮出了底线(阈值),这些家伙要是还没有被回收,就会被复制到老年代:

由浅入深了解GC原理

老年代经过这么几次折腾,也就扛不住了(空间被用完),那就来次集体大扫除( Full GC ),也就是全量回收。如果 Full GC 使用太频繁的话,无疑会对系统性能产生很大的影响。所以要合理设置年轻代与老年代的大小,尽量减少 Full GC 的操作。

垃圾收集器

收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现

Serial 收集器

串行收集器是最古老,最稳定以及效率高的收集器,但是可能会产生较长的停顿,只使用一个线程去回收。

启用命令: -XX:+UseSerialGC

Parallel 收集器

并行GC的好处是提升垃圾回收的性能,减少串行回收带来的问题,也有停顿,但可以并行回收,一边标记对象一边执行线程,整体上提升了回收的性能。

启用命令:

-XX:+UseParallelGC

  • 使用 Parallel 收集器 + 老年代串行

-XX:+UseParallelOldGC

  • 使用 Parallel 收集器 + 老年代并行

由浅入深了解GC原理

CMS 收集器

CMS 收集器是以获取最短回收停顿时间为目标的收集器,基于”标记-清除”( Mark-Sweep )算法实现,整个过程分为四个步骤:

  • 初始标记 ( Stop the World 事件 CPU 停顿很短) ,仅标记 GC Roots 能直接关联到的对象,速度快;
  • 并发标记 (收集垃圾跟用户线程一起执行) ,初始标记和重新标记仍需要 Stop the World ,并发标记过程就是进行 GC Roots Tracing 的过程;
  • 重新标记 ( Stop the World 事件 CPU 停顿,比初始标记稍长,远比并发标记短),修正并发标记期因用户程序继续运作而导致标记产生变动的那部分对象的标记记录,这个阶段停顿时间比初始标记阶段稍长些,比并发标记时间短;
  • 并发清理-清除算法。

整个过程中最耗时的并发标记和并发清除过程,收集器线程都可与用户线程一起工作,总体上来说, CMS 收集器的内存回收过程是与用户线程一起并发执行的。

CMS 收集器优点:并发收集,低停顿

CMS 收集器缺点:

  • CMS 收集器对 CPU 资源非常敏感
  • CMS 处理器无法处理浮动垃圾
  • CMS 基于“标记--清除”算法实现,会产生大量空间碎片,会在大对象分配时提前触发 Full GC 。为解决这个问题, CMS 提供了一个开关参数,用于在 CMS 要进行 Full GC 时开启内存碎片的合并整理过程,内存整理的过程无法并发,停顿时间变长;

CMS 也提供了整理碎片的参数:

-XX:+ UseCMSCompactAtFullCollection Full GC 后,进行一次整理

  • 整理过程是独占的,会引起停顿时间变长

-XX:+CMSFullGCsBeforeCompaction

  • 设置进行几次 Full GC 后,进行一次碎片整理

-XX:ParallelCMSThreads

  • 设定 CMS 的线程数量(一般情况约等于可用CPU数量)

CMS 的提出是想改善 GC 的停顿时间,在 GC 过程中的确做到了减少 GC 时间,但是同样导致产生大量内存碎片,又需要消耗大量时间去整理碎片,从本质上并没有改善时间。  

G1 ( Garbage First )收集器

G1 是一款面向服务端应用的垃圾收集器。与 CMS 收集器相比 G1 收集器有以下特点:

  • 空间整合: G1 收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次 GC
  • 可预测停顿:这是 G1 的另一大优势,降低停顿时间是 G1CMS 的共同关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 N 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒,这几乎已经是实时 JavaRTSJ )的垃圾收集器的特征了。
  • 并行于并发:充分使用多个 CPU 来缩短 Stop the World 停顿时间。
  • 分代收集:采用不同方式处理新创建的对象和已存活一段时间,熬过多次 GC 的旧对象,以获取更好的收集效果。

使用 G1 收集器时, Java 堆的内存布局与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域( Region ),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,它们都是一部分(可以不连续) Region 的集合。

G1 运作步骤:

  • 初始标记( Initial-Mark )( Stop the World 事件 CPU 停顿只处理垃圾);

这个阶段是停顿的( Stop the World Event ),并且会触发一次普通 Mintor GC

对应 GC log : GC pause ( young ) ( inital-mark )

  • Root Region Scanning;

程序运行过程中会回收 survivor 区(存活到老年代),这一过程必须在 young GC 之前完成。

  • 并发标记( Concurrent Marking )(与用户线程并发执行);

在整个堆中进行并发标记(和应用程序并发执行),此过程可能被 young GC 中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那个这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。

  • 最终标记( Stop the World 事件 CPU 停顿处理垃圾);

此阶段是用来收集 并发标记阶段 产生新的垃圾(并发阶段和应用程序一同运行); G1 中采用了比 CMS 更快的初始快照算法: snapshot-at-the-beginning ( SATB )。

  • 筛选回收( Stop the World 事件根据用户期望的 GC 停顿时间回收);

多线程清除失活对象,会有 Stop the World 事件。 G1 将回收区域的存活对象拷贝到新区域,清除 Remember Sets ,并发清空回收区域并把它返回到空闲区域链表中。

finalize()方法

finalize 的作用

  • finalize()Objectprotected 方法,子类可以覆盖该方法以实现资源清理工作, GC 在回收对象之前调用该方法;
  • finalize()C++ 中的析构函数不是对应的。 C++ 中的析构函数调用的时机是确定的(对象离开作用域或 delete 掉),但 Java 中的 finalize 的调用具有不确定性;
  • 不建议用 finalize 方法完成“非内存资源”的清理工作,但建议用于:

① 清理本地对象(通过 JNI 创建的对象);

② 作为确保某些非内存资源(如 Socket 、文件等)释放的一个补充:在 finalize 方法中显式调用其他资源释放方法。

finalize 的问题

  • 一些与 finalize 相关的方法,由于一些致命的缺陷,已经被废弃了,如 System.runFinalizersOnExit() 方法、 Runtime.runFinalizersOnExit() 方法;
  • System.gc()System.runFinalization() 方法增加了 finalize 方法执行的机会,但不可盲目依赖它们;
  • Java 语言规范并不保证 finalize 方法会被及时地执行、而且根本不会保证它们会被执行;
  • finalize 方法可能会带来性能问题。因为 JVM 通常在单独的低优先级线程中完成 finalize 的执行;
  • 对象再生问题: finalize 方法中,可将待回收对象赋值给 GC Roots 可达的对象引用,从而达到对象再生的目的;
  • finalize 方法至多由 GC 执行一次(用户当然可以手动调用对象的 finalize 方法,但并不影响 GCfinalize 的行为)。

finalize 的执行过程(生命周期)

当对象变成( GC Roots )不可达时, GC 会判断该对象是否覆盖了 finalize 方法,若未覆盖,则直接将其回收。否则,若对象未执行过 finalize 方法,将其放入 F-Queue 队列,由一低优先级线程执行该队列中对象的 finalize 方法。执行 finalize 方法完毕后, GC 会再次判断该对象是否可达,若不可达,则进行回收,否则,对象“复活”。

具体的 finalize 流程:

对象可由两种状态,涉及到两类状态空间,一是终结状态空间 F = {unfinalized, finalizable, finalized} ;二是可达状态空间 R = {reachable, finalizer-reachable, unreachable} 。各状态含义如下:

  • unfinalized : 新建对象会先进入此状态, GC 并未准备执行其 finalize 方法,因为该对象是可达的。
  • finalizable : 表示 GC 可对该对象执行 finalize 方法, GC 已检测到该对象不可达。正如前面所述, GC 通过 F-Queue 队列和一专用线程完成 finalize 的执行。
  • finalized : 表示 GC 已经对该对象执行过 finalize 方法。
  • reachable : 表示 GC Roots 引用可达。
  • finalizer-reachable ( f-reachable ):表示不是 reachable ,但可通过某个 finalizable 对象可达。
  • unreachable :对象不可通过上面两种途径可达。

状态变迁图:

由浅入深了解GC原理

状态变迁说明:

  • 新建对象首先处于 [reachable, unfinalized] 状态( A );
  • 随着程序的运行,一些引用关系会消失,导致状态变迁,从 reachable 状态变迁到 f-reachable ( B, C, D )或 unreachable ( E, F )状态;
  • JVM 检测到处于 unfinalized 状态的对象变成 f-reachableunreachableJVM 会将其标记为 finalizable 状态( G,H )。若对象原处于 [unreachable, unfinalized] 状态,则同时将其标记为 f-reachable ( H );
  • 在某个时刻, JVM 取出某个 finalizable 对象,将其标记为 finalized 并在某个线程中执行其 finalize 方法。由于是在活动线程中引用了该对象,该对象将变迁到( reachable, finalized )状态( KJ )。该动作将影响某些其他对象从 f-reachable 状态重新回到 reachable 状态( L, M, N );
  • 处于 finalizable 状态的对象不能同时是 unreahable 的,由上一点可知,将对象 finalizable 对象标记为 finalized 时会由某个线程执行该对象的 finalize 方法,致使其变成 reachable 。这也是图中只有八个状态点的原因;
  • 程序员手动调用 finalize 方法并不会影响到上述内部标记的变化,因此 JVM 只会至多调用 finalize 一次,即使该对象“复活”也是如此。程序员手动调用多少次不影响 JVM 的行为;
  • JVM 检测到 finalized 状态的对象变成 unreachable ,回收其内存( I );
  • 若对象并未覆盖 finalize 方法, JVM 会进行优化,直接回收对象( O )。

注: System.runFinalizersOnExit() 等方法可以使对象即使处于 reachable 状态, JVM 仍对其执行 finalize 方法。

总结

GC 垃圾收集, Java 提供的 GC 可以自动监测对象是否超过作用域从而达到自动回收内存的目的。

判断一个对象是否存活常用的有两种办法:引用计数算法和可达性分析算法。

GC 常用算法有: 标记-清除算法,标记-压缩算法,复制算法,分代收集算法

不管选择哪种 GC 算法, Stop the World 都是不可避免的。 Stop the World 意味着从应用中停下来并进入到 GC 执行过程中去。一旦 Stop the World 发生,除了 GC 所需的线程外,其他线程都将停止工作,中断了的线程直到 GC 任务结束才继续它们的任务。 GC 调优通常就是为了改善 Stop the World 的时间。

关于程序设计的几点建议:

  • 尽早释放无用对象的引用。大多数程序员在使用临时变量的时候,都是让引用变量在退出活动域( scope )后,自动设置为 null .我们在使用这种方式时候,必须特别注意一些复杂的对象图,例如数组,队列,树,图等,这些对象之间有相互引用关系较为复杂。对于这类对象, GC 回收它们一般效率较低。如果程序允许,尽早将不用的引用对象赋为 null ,这样可以加速 GC 的工作。
  • 尽量少用 finalize 函数。 finalize 函数是 Java 提供给程序员一个释放对象或资源的机会。但是,它会加大 GC 的工作量,因此尽量少采用 finalize 方式回收资源。
  • 如果需要使用经常使用的图片,可以使用 soft 应用类型。它可以尽可能将图片保存在内存中,供程序调用,而不引起 OutOfMemoryException
  • 注意集合数据类型,包括数组,树,图,链表等数据结构,这些数据结构对 GC 来说,回收更为复杂。另外,注意一些全局的变量,以及一些静态变量。这些变量往往容易引起悬挂对象( dangling reference ),造成内存浪费。
  • 当程序有一定的等待时间,程序员可以手动执行 System.gc() ,通知 GC 运行,但是 Java 语言规范并不保证 GC 一定会执行。使用增量式 GC 可以缩短 Java 程序的暂停时间。

本文由博客一文多发平台 OpenWrite 发布!

更多内容请点击我的博客 沐晨

原文  https://segmentfault.com/a/1190000021456958
正文到此结束
Loading...