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

#### 目录

1. 概述
2. 对象是否存活
3. 再谈引用
4. 垃圾收集算法
5. HotSpot 的算法实现
6. 垃圾收集器
7. 内存分配和回收策略

#### 概述

在前面介绍了 Java 内存运行时区域的各个部分，其中程序计数器、虚拟机栈、本地方法栈这三个区域是线程私有的，也就是随着线程而生，伴随线程而灭；栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈操作。每一个栈帧中分配多少内存基本是在类结构确定下来就已知的，因此这几个区域的内存分配和回收都具备确定性，在这几个区域内就不需要过多考虑回收的问题，因为方法结束或者线程结束时，内存自然就跟着回收了。而 Java 堆和方法区则不一样，一个接口中的多个实现类需要的内容可能不一样，一个方法中的多个分支需要的内存也可能不一样，我们只有在程序处于运行期间才能知道创建了哪些对象，这部分内存的分配和回收都是动态的，垃圾收集器所关注的也是这部分内存。

#### 对象是否存活？

垃圾收集器在堆进行回收前，需要判断对象是否不被使用了。判活有以下几种办法：

1. 引用计数法

   给对象添加一个引用计数器，每当有一个地方引用时，计数器值就加一，当引用失效时，计数器值就减一；任何时刻计数器为零的对象就是不可能再被使用的。

   引用计数法实现简单，判断效率高，但是 Java 虚拟机里面并没有选用引用计数法来管理内存，其中最主要的原因是它很难解决对象之间相互循环引用的问题。

2. 可达性分析

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

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

   - 虚拟机栈中引用的对象
   - 方法区中类静态属性引用的对象
   - 方法区中常量引用的对象
   - 本地方法栈中 JNI（即一般说是 Native 方法）引用的对象

#### 再谈引用

如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址，就称这块内存代表着一个引用。

在 JDK1.2 之后，Java 对引用的概念进行了扩充，将引用分为强引用、软引用、弱引用、虚引用这四种。

##### 强引用

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

##### 软引用

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

##### 弱引用

被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时，无论当前内存是否足够，都会回收掉只被弱引用关联的对象。在 JDK1.2 之后，提供了 WeakReference 类来实现弱引用。

##### 虚引用

一个对象是否有虚引用的存在，完全不会对其生存时间构成影响，也无法通过虚引用来获取一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在 JDK1.2 之后，提供了 PhantomReference 类来实现虚引用。

#### 垃圾收集算法

##### 标记 - 清除算法

首先标记出所有需要回收的对象，在标记完成后统一回收所有被标记的对象。它主要有两点不足：一个是效率问题，标记和清除两个过程的效率都不高；另外一个是空间问题，标记清除后会产生大量的内存碎片。

![](https://i.loli.net/2019/02/23/5c70bb8f2f084.jpg)

##### 复制算法

为了解决效率问题，复制算法出现了。它将可用内存按容量划分为大小相等的两块，每次只使用其中一块，当这一块内存用完了，就将还存活的对象复制到另外一块，然后将已使用的那块内存空间一次清理掉。实现简单，运行高效。但是这种算法的代价就是将内存缩小了原来的一半。

现在虚拟机都采用复制算法来回收新声代。新声代的对象 98% 的对象都是朝生夕死的，所以并不需要按照 1:1 的比例来划分内存空间，而是将内存分为一块较大的 Eden 区和两块较小的 Survivor 空间，每次使用 Eden 区和其中一块 Survivor。当回收时，将 Eden 和 Survivor 中还存活的对象一次性的复制到另外一块 Survivor 空间上，最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1。也就是只有 10% 的内存会 ”浪费“。当然，如果 Survivor 空间不够用时，需要依赖其他内存（这里指老年代）进行分配担保。

![](https://i.loli.net/2019/02/23/5c70bbc54fb78.jpg)

##### 标记 - 整理算法

复制算法在对象存活率比较高的时候是非常低效的，更关键的是，如果不想浪费 50% 的内存空间，就要有额外的空间进行分配担保，所以老年代一般不会选用复制算法。

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

![](https://i.loli.net/2019/02/23/5c70bbaad5038.jpg)

#### HotSpot 的算法实现

##### 枚举根节点

可达性分析对执行时间的敏感还体现在 GC 停顿上，因为这项分析工作必须确保一致性。一致性的意思是指整个分析过程中整个系统看起来像被冻结在某个时间点上，不可以出现分析过程中引用关系还在不断变化的情况，这点是导致 GC 进行时必须停顿掉所有的线程。

##### 安全点

程序执行时并非在所有的地方都能停顿下来开始 GC，只有在到达安全点时才能暂停。那么如何让 GC 发生时所有的线程都跑到安全点上在停顿下来呢？有两种方案可供选择：抢先式中断和主动式中断。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应 GC 事件。

主动式中断的思想是当 GC 需要中断线程时，不直接对线程操作，仅仅是设置一个标志，各个线程执行时主动去轮训这个标志，发现中断标志时就自己中断挂机。轮训标志的地方和安全点是重合的。

##### 安全区

可以看作是被扩展的安全点。

#### 垃圾收集器

如果说垃圾收集算法是对内存回收的方法论，那么垃圾收集器就是内存回收的具体实现。如果两个收集器之间存在连线，就说明它们可以搭配使用：

![](https://i.loli.net/2019/02/23/5c70bdfb374c1.jpg)

##### Serial 收集器

单线程收集器，它在进行垃圾收集时，必须暂停其他所有的工作现场。

##### ParNew 收集器

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

##### Parallel Scavenge 收集器

是一个使用复制算法的新生代收集器，又是并行的多线程收集器。但是它的关注点和其它收集器不同，CMS 等收集器的关注点是尽可能的缩短垃圾收集时用户线程的停顿时间，而 Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量，所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值。例如虚拟机总运行了 100 分钟，其中垃圾回收花费了一分钟，那么吞吐量就是 99%。

停顿时间越短就越适合需要与用户交互的程序，良好的响应速度能提升用户体验，而高吞吐量则可以高效率的利用 CPU 时间，尽快完成程序的运算任务，主要适合在后台运算而不需要太多交互的任务。

##### Serial Old 收集器

是 Serial 收集器的老年代版本，它同样是一个单线程收集器，使用标记-整理算法。

##### Parallel Old 收集器

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

##### CMS 收集器

CMS 收集器是一种以获取最短回收停顿时间为目标的收集器。基于标记清除算法实现，并发收集，低停顿。

##### G1 收集器

G1 是一款面向服务端应用的垃圾收集器，有以下特点：

1. 并行和并发
2. 分代收集
3. 空间整合
4. 可预测的停顿

#### 内存分配策略

对于内存分配，往大方向讲，就是在堆上分配，对象主要分配在新声代的 Eden 区上，如果启动了本地线程分配缓冲区，将按线程优先在 TLAB 上分配。少数情况下也可以直接分配在老年代。

##### 对象优先在 Eden 区分配

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

- 新生代 GC（Minor GC）

  指发生在新生代的垃圾收集动作，因为 Java 对象大多都具备朝生夕死的特性，所以 Minor GC 非常频繁，一般回收速度也比较快。

- 老年代 GC（Major GC / Full GC）

  指发生在老年代的 GC，出现 Major GC，经常会伴随至少一次的 Minor GC，Major GC 的速度一般会比 Minor GC 慢十倍以上。

##### 大对象直接进入老年代

所谓的大对象是指，需要大量连续内存空间的 Java 对象，最典型的大对象就是那种很长的字符串以及数组。一般来说，超过 3M 的对象会直接在老年代进行分配。

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

既然虚拟机采用了分代收集的思想来管理内存，那么内存回收就必须能识别哪些对象应该放在新生代还是老年代。为了做到这一点，虚拟机给每个对象定义了一个对象年龄计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活，并且能被 Survivor 容纳的话，将被移动到 Survivor 空间中，并且对象年龄设为 1。对象在 Survivor 区每熬过一次 Minor GC，年龄就会增加一岁。当它的年龄增加到一定程度，默认是 15，就将会被晋升到老年代中。