Java 虚拟机垃圾收集机制简介

本文介绍Java虚拟机垃圾回收机制。以下内容总结来自于《深入理解Java虚拟机:JVM高级特性与最佳实践(第二版)》

垃圾收集机制

垃圾收集需要考虑三件事:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

回收对象

垃圾收集主要是针对Java堆和方法区进行

程序计数器、虚拟机栈和本地方法栈这三个区域只存在于线程的生命周期内,因此不需要对这三个区域进行垃圾回收
Java堆和方法区是线程共享的。在程序运行期间,一个接口的多个实现类所需内存可能大小不一样;一个方法中多个分支所需要的内存可能不一样;程序运行时会动态创建对象。这部分内存的分配和回收都是动态的

在对Java堆的对象进行回收之前,需要确定哪些对象还“存活”,哪些对象已经“死去”,需要被回收掉。判断一个对象是否可被回收的方法有:

  • 引用计数法
    为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收

    在两个对象出现循环引用的情况下,此时两个对象的引用计数器永远都不为 0,导致无法对它们进行回收。正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法

  • 可达性分析算法
    以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。Java 虚拟机使用该算法来判断对象是否可被回收

GC Roots 对象包括以下几种:

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

Java 虚拟机垃圾收集机制简介_第1张图片
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关

Java 提供了四种强度不同的引用类型

  • 强引用
    被强引用的对象不会被回收

使用 new 一个新对象的方式来创建强引用

  • 软引用
    被软引用关联的对象只有在内存不够的情况下才会被回收。如果正常进行垃圾回收后内存不足,会将这些对象进行回收;如果回收后还没有足够的内存,才会抛出内存溢出异常

使用 SoftReference 类来创建软引用

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联
  • 弱引用
    被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收只被弱引用关联的对象

使用 WeakReference 类来创建弱引用

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;
  • 虚引用
    也称为幽灵引用或者幻影引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象
  • 为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知
  • 使用 PhantomReference 来创建虚引用
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;

回收时机

方法区(或者HotSpot虚拟机中的永久代)主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。方法区回收主要是对常量池的回收和对类的卸载

  • 回收废弃常量
    判定一个常量是否是“废弃常量”的方法与Java堆中的对象类似:没有任何其他对象引用该常量。此时如果发生内存回收,必要时该常量会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用与此类似
  • 回收无用类
    判定一个类是否是“无用类”,需要满足以下3个条件,并且满足了条件也不一定会被卸载:
    • 该类所有的实例都已经被回收,即Java堆中不存在该类的任何实例
    • 加载该类的ClassLoader已经被回收
    • 该类对应的java.lang.Class对象没有被任何地方引用,无法在任何地方通过反射访问该类的方法

为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能

  • finalize()
    类似 C++ 的析构函数,用于关闭外部资源。但是 try-finally 等方式可以做得更好,并且该方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用

    当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能在该方法中让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize() 方法自救,后面回收时不会再调用该方法

回收算法

标记 - 清除算法

最基础的收集算法,分为“标记”和“清除”两个阶段

  • 标记阶段
    标记出所有需要回收的对象。程序会检查每个对象是否为可回收对象,如果是,则程序会在对象头部打上标记
  • 清除阶段
    进行对象回收并取消标志位。并且判断回收后的分块与前一个空闲分块是否连续,若连续,会合并这两个分块。回收对象就是把对象作为分块,连接到被称为 “空闲链表” 的单向链表,之后进行分配时只需要遍历这个空闲链表,就可以找到分块

在分配时,程序会搜索空闲链表寻找空间大于等于新对象大小 size 的块 block。如果它找到的块等于 size,会直接返回这个分块;如果找到的块大于 size,会将块分割成大小为 size 与 (block - size) 的两部分,返回大小为 size 的分块,并把大小为 (block - size) 的块返回给空闲链表

缺点

  • 效率问题:标记和清除过程效率都不高
  • 空间问题:会产生大量不连续的内存碎片,空间碎片太多可能导致分配大对象时无法找到足够的连续内存,导致不得不提前触发另一次垃圾收集
    Java 虚拟机垃圾收集机制简介_第2张图片

复制算法

将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理

复制算法可以解决“标记-清除”算法的效率问题

优点

  • 实现简单
  • 运行高效

每次都是对整个半区进行内存回收,分配内存时不用考虑内存碎片问题,只需要移动堆顶指针,按顺序分配内存即可

缺点

  • 只使用了内存的一半
    Java 虚拟机垃圾收集机制简介_第3张图片
    现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor

HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象

标记 - 整理算法

复制算法在对象存活率较高时要进行较多的复制操作,效率会变低;如果不想浪费50%的空间,需要额外的空间进行分配担保,以应对被使用的内存中所有对象100%存活的极端情况。老年代一般不能选用这种算法

”标记 - 整理“算法的“标记”过程与“标记-清除”算法一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

优点

  • 不会产生内存碎片

缺点

  • 需要移动大量对象,处理效率比较低

Java 虚拟机垃圾收集机制简介_第4张图片

分代收集

现在的商业虚拟机采用“分代收集”算法,根据对象存活周期将内存划分为几块,不同块采用适当的收集算法

一般将堆分为新生代和老年代

  • 新生代:每次垃圾收集时只有少量对象存活,复制成本低,使用复制算法
  • 老年代:对象存活率高,没有额外空间进行分配担保,使用“标记 - 清除”算法 或者“标记 - 整理”算法

垃圾收集器

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

HotSpot虚拟机包含的所有垃圾收集器如下图所示
Java 虚拟机垃圾收集机制简介_第5张图片
以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用

单线程与多线程

  • 单线程指的是垃圾收集器只使用一个线程完成垃圾收集工作
  • 多线程指的是垃圾收集器使用多个线程完成垃圾收集工作

串行与并行

  • 串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序
  • 并行指的是垃圾收集器和用户程序同时执行
  • 除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行

Serial 收集器

最基本、发展历史最悠久的收集器。Serial 翻译为串行,也就是说它以串行的方式执行。是单线程的收集器,只会使用一个线程进行垃圾收集工作

它的"单线程"的意义不仅仅说明它只使用一个CPU或一条收集线程去完成垃圾收集工作,而且在进行垃圾收集时,必须暂停其他所有的工作线程(包括正在执行的用户程序),直到它收集结束
Java 虚拟机垃圾收集机制简介_第6张图片

优点

  • 简单高效。在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率

Serial收集器是 Client 模式下的默认新生代收集器,因为在该场景下内存一般来说不会很大。它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的

ParNew 收集器

ParNew 收集器是 Serial 收集器的多线程版本,除了使用多条线程进行垃圾回收之外,其余与Serial收集器完全一样

ParNew 收集器的工作过程如下图所示
Java 虚拟机垃圾收集机制简介_第7张图片

ParNew 收集器是 Server 模式下首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了 Serial 收集器之外,目前只有它能与 CMS 收集器配合使用

Parallel Scavenge 收集器

Parallel Scavenge 收集器是一个新生代收集器,使用复制算法,与 ParNew 一样是多线程收集器。

其它收集器的目标是尽可能缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量,因此它被称为“吞吐量优先”收集器

吞吐量是指 CPU 用于运行用户程序的时间与CPU总消耗时间的比值。即吞吐量= 运行用户程序时间 / (运行用户程序时间 + 垃圾收集时间)

  • 虚拟机总共运行了100分钟,其中垃圾收集花费1分钟,吞吐量即是99%
  • GC停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务
  • 缩短GC停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降

可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量

Serial Old 收集器

Serial Old 收集器是 Serial 收集器的老年代版本,是一个单线程收集器,也是给 Client 模式下的虚拟机使用

Serial Old收集器的工作过程如图所示
Java 虚拟机垃圾收集机制简介_第8张图片

如果用在 Server 模式下,有两大用途

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用
  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用

Parallel Old 收集器

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

Parallel Old 收集器的工作过程如下图所示
Java 虚拟机垃圾收集机制简介_第9张图片

在注重吞吐量以及 CPU 资源敏感的场合,可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,Mark Sweep 指的是标记 - 清除算法

CMS收集器运行过程分为以下四步:

  • 初始标记:仅标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿
  • 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿
  • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿
  • 并发清除:不需要停顿
    在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿

工作过程如下图所示
Java 虚拟机垃圾收集机制简介_第10张图片
优点

  • 并发收集
  • 低停顿

缺点

  • 对CPU资源非常敏感
  • 吞吐量低。低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高
  • 无法处理浮动垃圾。可能出现 Concurrent Mode Failure而导致另一次Full GC
    • 浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾
    • 这部分垃圾只能到下一次 GC 时才能进行回收
    • 由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收
    • 如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS
  • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC

#7. G1 收集器

G1(Garbage-First)收集器是最前沿的成果之一,是一款面向服务端应用的垃圾收集器。在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器

与其他GC收集器相比,有如下特点:

  • 支持并行与并发
  • 支持分代收集
    • 其他收集器的收集范围都是整个新生代或者老年代
    • G1 将整个Java堆划分为多个大小相等的独立区域(Region),新生代和老年代不是物理隔离的
    • 可以直接对新生代和老年代一起回收
  • 空间整合
    • 整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的
    • 不会产生内存空间碎片,收集后能提供规整的可用内存
  • 可预测的停顿
    • 建立可预测的停顿时间模型
    • 能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒
  • Region 将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收
  • 这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能
  • 通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region
  • 每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region
  • 通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记:仅标记GC Roots能直接关联到的对象,并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,需要停顿线程,但耗时很短
  • 并发标记:从GC Root开始对堆中对象进行可达性分析,找出存活对象,耗时较长,但与用户程序并发执行
  • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面
    • 最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。需要停顿线程,但是可并行执行
  • 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划
    • 此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率

G1收集器的工作过程如下图所示
Java 虚拟机垃圾收集机制简介_第11张图片

你可能感兴趣的:(java,java,java虚拟机,垃圾收集)