扒一扒 JVM 的垃圾回收机制

本文转载自:https://www.cnblogs.com/1024Community/p/honery.html,如有侵权请联系删除。

相信和小编一样的程序猿们在日常工作或面试当中经常会遇到 JVM 的垃圾回收问题,有没有在夜深人静的时候详细捋一捋 JVM 垃圾回收机制中的知识点呢?没时间捋也没关系,因为小编接下来会给你捋一捋。

技术背景

按照套路是要先装装 X,谈谈 JVM 垃圾回收的前世今生的。说起垃圾回收(GC),大部分人都把这项技术当做 Java 语言的伴生产物。事实上,GC 的历史比 Java 久远,早在 1960 年 Lisp 这门语言中就使用了内存动态分配和垃圾回收技术。设计和优化 C++ 这门语言的专家们要长点心啦~~

哪些内存需要回收

猿们都知道 JVM 的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈 3 个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而 Java 堆区和方法区则不一样、不一样(怎么不一样说的朗朗上口),这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,哪些暂时还不能回收,这就要用到判断对象是否存活的算法!(面试官肯定没少问你吧)

引用计数算法

算法分析

引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,就将该对象实例分配给一个变量,该变量计数设置为 1。当任何其它变量被赋值为这个对象的引用时,计数加 1(a = b,则 b 引用的对象实例的计数器 +1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减 1。任何引用计数器为 0 的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减 1。

优缺点

优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。

缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为 0。

可达性分析算法

可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点 GC ROOT 开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。

img

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

  • 虚拟机栈中引用的对象(栈帧中的本地变量表);
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中 JNI(Native 方法)引用的对象。

Java 中的引用你了解多少

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在 Java 语言中,将引用又分为强引用、软引用、弱引用、虚引用 4 种,这四种引用强度依次逐渐减弱。

  • 强引用

    在程序代码中普遍存在的,类似 Object obj = new Object() 这类引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

  • 软引用

    用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

  • 弱引用

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

  • 虚引用

    也叫幽灵引用或幻影引用(名字真会取,很魔幻的样子),是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。它的作用是能在这个对象被收集器回收时收到一个系统通知。

    不要被概念吓到,也别担心,还没跑题,再深入,可就不好说了。小编罗列这四个概念的目的是为了说明,无论引用计数算法还是可达性分析算法都是基于强引用而言的。

对象死亡(被回收)前的最后一次挣扎

即使在可达性分析算法中不可达的对象,也并非是“非死不可”,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。

第一次标记:如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记;

第二次标记 :第一次标记后接着会进行一次筛选,筛选的条件是此对象是否有必要执行finalize() 方法。在 finalize() 方法中没有重新与引用链建立关联关系的,将被进行第二次标记。

第二次标记成功的对象将真的会被回收,如果对象在 finalize() 方法中重新与引用链建立了关联关系,那么将会逃离本次回收,继续存活。猿们还跟的上吧,嘿嘿。

方法区如何判断是否需要回收

猿们,方法区存储内容是否需要回收的判断可就不一样咯。方法区主要回收的内容有:废弃常量和无用的类。对于废弃常量也可通过引用的可达性来判断,但是对于无用的类则需要同时满足下面 3 个条件:

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例;
  • 加载该类的 ClassLoader 已经被回收;
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

常用的垃圾收集算法

标记 - 清除算法

标记 - 清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如下图所示。标记 - 清除算法不需要进行对象的移动,只需对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记 - 清除算法直接回收不存活的对象,因此会造成内存碎片。

img

复制算法

复制算法的提出是为了克服句柄的开销和解决内存碎片的问题。它开始时把堆分成 一个对象 面和多个空闲面, 程序从对象面为对象分配空间,当对象满了,基于 copying 算法的垃圾 收集就从根集合(GC Roots)中扫描活动对象,并将每个 活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。

img

标记 - 整理算法

标记 - 整理算法采用标记 - 清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记 - 整理算法是在标记 - 清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。具体流程见下图:

img

分代收集算法

分代收集算法是目前大部分 JVM 的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

img

年轻代(Young Generation)的回收算法

  1. 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。
  2. 新生代内存按照 8:1:1 的比例分为一个 eden 区和两个 survivor(survivor0,survivor1)区。一个 Eden 区,两个 Survivor 区(一般而言)。大部分对象在 Eden 区中生成。回收时先将 eden 区存活对象复制到一个 survivor0 区,然后清空 eden 区,当这个 survivor0 区也存放满了时,则将 eden 区和 survivor0 区存活对象复制到另一个 survivor1 区,然后清空 eden 和这个 survivor0 区,此时 survivor0 区是空的,然后将 survivor0 区和 survivor1 区交换,即保持 survivor1 区为空, 如此往复。
  3. 当 survivor1 区不足以存放 eden 和 survivor0 的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次 Full GC,也就是新生代、老年代都进行回收。
  4. 新生代发生的 GC 也叫做 Minor GC,MinorGC 发生频率比较高(不一定等 Eden 区满了才触发)。

年老代(Old Generation)的回收算法

  1. 在年轻代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
  2. 内存比新生代也大很多(大概比例是 1:2),当老年代内存满时触发 Major GC 即 Full GC,Full GC 发生频率比较低,老年代对象存活时间比较长,存活率标记高。

持久代(Permanent Generation)的回收算法

用于存放静态文件,如 Java 类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些 class,例如 Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代也称方法区。

常见的垃圾收集器

img

  • Serial 收集器(复制算法)

    新生代单线程收集器,标记和清理都是单线程,优点是简单高效。是 client 级别默认的 GC 方式,可以通过 -XX:+UseSerialGC 来强制指定。

  • Serial Old 收集器(标记 - 整理算法)

    老年代单线程收集器,Serial 收集器的老年代版本。

  • ParNew 收集器(停止 - 复制算法)

    新生代收集器,可以认为是 Serial 收集器的多线程版本, 在多核 CPU 环境下有着比 Serial 更好的表现。

  • Parallel Scavenge 收集器(停止 - 复制算法)

    并行收集器,追求高吞吐量,高效利用 CPU。吞吐量一般为 99%, 吞吐量 = 用户线程时间 /(用户线程时间 +GC 线程时间)。适合后台应用等对交互相应要求不高的场景。是 server 级别默认采用的 GC 方式,可用 -XX:+UseParallelGC 来强制指定,用 -XX:ParallelGCThreads=4 来指定线程数。

  • Parallel Old 收集器(停止 - 复制算法)

    Parallel Scavenge 收集器的老年代版本,并行收集器,吞吐量优先。

  • CMS(Concurrent Mark Sweep)收集器(标记 - 清理算法)

    高并发、低停顿,追求最短 GC 回收停顿时间,cpu 占用比较高,响应时间快,停顿时间短,多核 cpu 追求高响应时间的选择。

GC 是什么时候触发的

由于对象进行了分代处理,因此垃圾回收区域、时间也不一样。GC 有两种类型:Scavenge GC 和 Full GC。

Scavenge GC

一般情况下,当新对象生成,并且在 Eden 申请空间失败时,就会触发 Scavenge GC,对 Eden 区域进行 GC,清除非存活对象,并且把尚且存活的对象移动到 Survivor 区。然后整理 Survivor 的两个区。这种方式的 GC 是对年轻代的 Eden 区进行,不会影响到年老代。因为大部分对象都是从 Eden 区开始的,同时 Eden 区不会分配的很大,所以 Eden 区的 GC 会频繁进行。因而,一般在这里需要使用速度快、效率高的算法,使 Eden 去能尽快空闲出来。

Full GC

对整个堆进行整理,包括 Young、Tenured 和 Perm。Full GC 因为需要对整个堆进行回收,所以比 Scavenge GC 要慢,因此应该尽可能减少 Full GC 的次数。在对 JVM 调优的过程中,很大一部分工作就是对于 Full GC 的调节。有如下原因可能导致 Full GC:

  1. 年老代(Tenured)被写满;
  2. 持久代(Perm)被写满;
  3. System.gc()被显示调用;
  4. 上一次 GC 之后 Heap 的各域分配策略动态变化;

结束语

内容的完整度和深度在一篇博文里面真的很难全部考虑,本文做了很大尝试,最后还是得投降。对于各个垃圾收集器的区别、运行过程中各内存区域参数的设置、GC 日志的查看等内容后续再补上吧。文章概念很多,也借用了一些书籍和博文的经典总结,算是一个知识点整理后的输出吧,希望对大家有所裨益。