Teaser Image

mindwind

十日画一水,五日画一石




GC(Garbage Collection 垃圾回收)的概念随着 java 的流行而被人们所熟知。 实际 GC 最早起源于20世纪60年代的 LISP 语言,是一种自动的内存管理机制。 GC 要解决的问题有 3 个:

  1. 回收什么?(what)
  2. 何时回收?(when)
  3. 如何回收?(how)

回收什么?

清理的是垃圾,回收的是内存空间。 既然 GC 是 java 的自动内存管理机制,那么先看下 java 虚拟机将所管理的内存划分为不同的区域,如图1。

如图1所示,java 虚拟机管理的内存区域分为如下几个部分:

  1. 堆(Heap)
  2. 方法区(Method Area)
  3. 虚拟机栈(VM Stack)
  4. 本地方法栈(Native Method Stack)
  5. 程序计数器(Program Counter Register)

其中堆和方法区属于所有线程共享,而其他区域属于线程隔离的区域。 下面我们以 java HotSpot 虚拟机为例分别说说每个区域的作用和构成:

堆(Heap)

堆用于存储对象实例,从内存回收的角度看,由于收集器基本都采用了分代收集算法,所以堆可以进一步细分为:

  • Eden 区
  • Survivor 0 区 (From)
  • Survivor 1 区 (To)
  • Old/Tenured 区

其中 Eden、S0、S1 组成了新生代(Young/New Generation),Old/Tenured 为老年代。

方法区(Method Area)

方法区存储虚拟机加载的类信息、常量、编译代码等数据。 HotSpot 虚拟机使用永久代(Permanent Generation)来实现方法区。

虚拟机栈(VM Stack)

虚拟机栈描述的是 java 方法执行的内存模型,每个方法在执行时创建一个栈帧(Stack Frame)。 栈帧中存储内容主要包含:

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 方法返回地址

每个方法的执行过程就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

本地方法栈(Native Method Stack)

本地方法栈与虚拟机栈类似,只不过服务于虚拟机执行 Native 方法时。 HotSpot 虚拟机的实现把虚拟机栈和本地方法栈合二为一。

程序计数器(Program Counter Register)

可以看作是线程执行的字节码的行号指示器,在虚拟机的概念模型中便于实现分支、循环、跳转、异常处理和线程切换恢复等基础功能。 每个线程都有一个独立的程序计数器。

GC 管理的内存区域主要是堆(Heap),而堆中存放的是对象实例,因此 GC 回收的就是“死亡”(不可能再被使用)的对象占用的内存空间。

何时回收?

既然说到“死亡”的对象,那不得不说下对象的生命周期。 虚拟机通过 new 指令创建了对象,大多数对象创建时在 Eden 区分配内存空间,而一些大对象若 Eden 区不能满足其空间需求时会直接在 Old/Tenured 区分配。 对象的死亡判定,主流的 GC 实现都是通过可达性分析,形象点来说就是在基于引用建立的对象图中形成了孤岛的对象就是死亡的(可回收的)。

GC 分类

  • Minor GC
  • Major GC
  • Full GC

Minor GC 是针对新生代的回收,当 Eden 区空间满了时将触发 Minor GC。 Major GC 是针对老年代的回收,当 Minor GC 发生时会拷贝对象到老年代,这个过程称为对象晋升(promotion)或老年化(tenuring)。 为了避免对象晋升时老年代空间不足,收集器总是尝试预测剩余的空间是否足够以避免对象晋升失败,当晋升失败时就会发生 Full GC。 Full GC 是针对整个堆的操作,是非常昂贵的操作。除了在对象晋升失败时发生 Full GC,当堆自动调整大小时(Heap-Resizing)也会发生,不过可以通过设置 -Xms和-Xmx为相同的值来避免 Heap-Resizing。

如何回收?

  • Minor GC 将新生代中存活的对象拷贝到 Survivor 区和 Tenured 区。
  • Major GC 针对老年代区域进行死亡对象标记、清除和内存整理。
  • Full GC 则包括了所有存活对象的晋升以及老年代的内存回收及整理。

前面泛泛而谈了3种垃圾收集方式的过程,而具体则是由垃圾收集器来实现。 截至 JDK 1.7 HotSpot 虚拟机提供的垃圾收集器如图2所示,一共有 7 种不同作用的收集器。 图中连线表明它们可以搭配使用。

Serial Collector

如其名,串行的单线程收集器,是目前虚拟机运行在 client 模式下的默认新生代收集器。

ParNew Collector

相当于 Serial 的多线程版本。

Parallel Scavenge Collector

与 ParNew 很像,但它的关注点在达到一个可控制的吞吐量(Throughput),这里吞吐量的定义是 CPU 用于运行用户代码的时间与 CPU总消耗时间的比值。 因此 Parallel Scavenge 收集器也经常称为吞吐优先收集器,它还有个特点是自适应调节策略。 虚拟机会根据当前系统的运行情况收集监控信息,动态调整 Eden与Survivor区比例、晋升老年代对象年龄等参数,以提供最合适的停顿时间或最大的吞吐量。

Serial Old Collector

相当于 Serial 收集器的老年代版本。

Parallel Old Collector

相当于 Parallel Scavenge 收集器的老年代版本。

Concurrent Mark Sweep (CMS) Collector

前述的收集器在执行时都会停止所有的用户线程执行(Stop-The-World) CMS 收集器的关注点则是尽可能地缩短垃圾收集时用户线程的停顿时间,让垃圾收集和用户线程并行执行,从而减少应用停顿时间,提升用户体验。 当然在获得低停顿的好处时是付出了吞吐量的代价,通常与 Parallel 系收集器相比吞吐率下降 10%-40%。

CMS 收集器的处理整个过程有如下步骤:

  1. 初始标记:找到 GC Roots。
  2. 并发标记:标记所有从 GC Roots 可达的对象。
  3. 并发预清理:检查对象引用更新和在并发标记阶段晋升到老年代的对象并进行标记。
  4. 重新标记:标记预清理阶段更新的对象引用。
  5. 并发清理:回收死亡对象的内存。
  6. 并发重置:重置数据结构为下次运行作准备。

其执行示意如图3所示

其中步骤1(初始标记)和步骤4( 重新标记)仍然需要 Stop The World,只是相对来说时间较短。

低停顿是 CMS 收集器是的优点,但它也并不完美,它有 3 个明显缺点:

  1. 由于和用户线程并发执行,所以存在 CPU 争抢的问题。
  2. 无法回收浮动垃圾。
  3. CMS 仅进行了标记、清除而未进行整理,容易产生大量内存空间碎片。

CMS 默认启动的回收线程是 (CPU数量 + 3) / 4,也就是 CPU 在 4 个以上时并发回收线程使用的 CPU 资源不少于 25%。 在并发清理时新产生的垃圾称为浮动垃圾(Floating Garbage),本次无法收集,当浮动垃圾过多导致预留的内存无法满足程序需要时触发, 就可能出现 Concurrent Mode Failure 导致启用 Serial Old 收集器作为后备进行 Full GC。

Garbage First (G1) Collector

一种新的收集器,在 jdk7u4 开始正式支持,它有如下特点:

多分区的堆组织方式

G1 也是分代收集器,但其组织堆的方式与其他收集器完全不同。它根据不同的用途将堆分为大量(~2000)固定大小的区域(region)。 相同用途的堆也并不连续,G1 依然保留了新生代和老年代的概念,但新生代和老年代不再是物理上隔离的了,它们都是一部分 region 的集合,如图4所示。

如果一个对象大小超过了普通区域大小的50%,那么它会被分配到一个大区域(humongous)里面。

优先的收集方式

G1 的收集方式追求低停顿,并且建立可预测的停顿时间模型(在 M 毫秒的时间片段内,GC 的时间不得超过 N 毫秒,N < M)。 G1 通过有计划的避免在整个堆中进行全区域扫描进行垃圾收集,它通过跟踪各个 region 中垃圾的价值大小(回收获得的空间及回收所花费的时间的经验值), 在后台维护一个优先级列表,每次根据允许的收集时间,优先回收价值最大的 region,这也正式 Garbage-First 名称的由来。 而对 region 的收集采用的是 Stop-The-World 的方式,增量的将存活的对象复制到一个空 region 里面,这种方式不会产生内存碎片问题。

最后我们引用 Java Garbage Collection Distilled 一文中的关于 GC 的折衷权衡点来总结下。

俗话说:“从来没有不劳而获,当我们得到某些事物的时候,通常不得不放弃另外一些事物”。 当谈论垃圾收集的时候,我们主要考虑三个收集器的指标:

  1. 吞吐量:花费在 GC 上的时间占整个应用程序工作的比例。
  2. 延迟:因为垃圾回收,而引起的响应暂停的时间。
  3. 内存:我系统使用内存来存储状态,在管理的时候它们常常需要复制和移动。

上述三个指标,吞吐量越大越好,延迟越低越好,内存复制和移动产生的碎片越少越好。 但可惜这三个目标很难同时满足,很多时候我们都是根据应用类型在其中做出权衡取舍。