JVM 之垃圾收集器与内存分配策略

Java 与 C++ 之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。

对象死活

垃圾收集器在对堆进行回收前,第一件事就是要确认这些对象之中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。这就是引用计数法。

但是,主流的 Java 虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。

可达性分析算法

这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为 引用链,当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。

可达性分析算法判定对象是否可回收

上图所示,对象 object 5、object 6、object 7 虽然互相有关联,但是它们到 GC Roots 是不可达的,所以它们将会判定为是可回收的对象。

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

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

引用

在 JDK 1.2 以前,Java 中的引用的定义很传统:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。

在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用 4 种,这 4 种引用强度依次逐渐减弱。

  • 强引用就是指在程序代码之中普遍存在的,类似“Object obj = new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。
  • 软引用是用来描述一些 还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中 进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 之后,提供了 SoftReference 类来实现软引用。
  • 弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 之后,提供了 WeakReference 类来实现弱引用。
  • 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 之后,提供了 PhantomReference 类来实现虚引用。

生存 Or 死亡

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

  • 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
  • 如果这个对象被判定为有必要执行 finalize() 方法,那这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalize 线程去执行它。

finalize() 方法是对象逃脱死亡命运的最后一次机会,GC 将会对 F-Queue 中的对象进行第二次小规模的标记,对象只要重新与引用链上的任何一个对象建立关联才能拯救自己。

注意:任何一个对象的 finalize() 方法都只会被系统自动调用一次。

回收方法区

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

类需要同时满足下面 3 个条件才能算是“无用的类”:

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

附上手稿

垃圾收集算法

标记-清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

它的不足有两个:一个是效率问题,标记和清除两个过程的效率都不高;另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法

为了解决效率问题,一种称为“复制”的收集算法出现了,它将可用的内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

标记-整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。

根据老年代的特点,有人提出了这种算法,标记过程还是一样,后续则让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

HotSpot 的算法实现

枚举根节点

可达性分析从 GC Roots 节点找引用链这个操作,如果逐个检查这里面的引用必然会消耗很多时间,且此分析工作必须在一个能确保一致性的快照中进行,即不可以出现分析过程中对象引用关系还在不断变化的情况。所以 GC 进行时必须停顿所有 Java 执行线程。

在 HotSpot 的实现中使用一组称为 OopMap 的数据结构来得知哪些地方存放着对象引用。

安全点

HotSpot 没有为每条指令都生成 OopMap,只是在“特定的位置”记录了这些信息,这些位置称为安全点,即程序执行时并非在所有地方都能停顿下来开始 GC,只有在到达安全点时才能暂停。

安全区域

若是程序没有分配 CPU 时间,这时候线程就无法响应 JVM 的中断请求。如线程处于 Sleep 状态或者 Blocked 状态。这种情况下就需要安全区域来解决。

垃圾收集器

Serial 收集器

Serial 收集器是最基本、发展历史最悠久的收集器。它是一个单线程的收集器,进行垃圾收集时必须暂停其他所有的工作线程,直到它收集结束。

ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本。

Parallel Scavenge 收集器

Parallel Scavenge 收集器的目的是达到一个可控制的吞吐量。吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,同样是一个单线程收集器。

Parallel Old 收集器

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

CMS 收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为 4 个步骤:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

CMS 有以下 3 个明显的缺点:

  • CMS 收集器对 CPU 资源非常敏感。
  • CMS 收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次 Full GC 的产生。
  • CMS 是基于“标记-清除”算法实现的,收集结束时会产生大量空间碎片。

G1 收集器

G1 是一款面向服务端应用的垃圾收集器。与其他 GC 收集器相比,特点如下:

  • 并行与并发
  • 分代收集
  • 空间整合
  • 可预测的停顿

G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记(Initial Marking)
  • 并发标记(Concurrent Marking)
  • 最终标记(Final Marking)
  • 筛选回收(Live Data Counting and Evacuation)

附上手稿

内存分配与回收策略

Java 技术体系中所提倡的自动内存管理最终可以归结为自动化地解决两个问题:给对象分配内存以及回收分配给对象的内存。

对象优先在 Eden 分配

大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

大对象直接进入老年代

所谓的大对象是指,需要大量连续内存空间的 Java 对象,最典型的就是很长的字符串以及数组。

长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。对象在 Survivor 区中每“熬过”一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程序(默认为 15 岁),就将会被晋升到老年代中。

动态对象年龄判定

如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入到老年代。

空间分配担保

新生代使用复制收集算法,为了内存利用率之使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况,就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代。

附上手稿


好困啊……2018-11-20 00:37:02

文章作者: DoubleFJ
文章链接: http://putop.top/2018/11/19/JVM-GC/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 DoubleFJ の Blog