一. 什么是垃圾回收
垃圾回收(Garbage Collection,GC):就是释放垃圾占用的空间,防止内存泄露。对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。
二. 垃圾在哪儿
上图可以看到程序计数器、虚拟机栈、本地方法栈都是伴随着线程而生死,这些区域不需要进行 GC。
而方法区/元空间在 1.8 之后就直接放到本地内存了,假设总内存 2G,JVM 被分配内存 100M, 理论上元空间可以分配 2G-100M = 1.9G,空间还是足够的,所以这块区域也不用管。
所以就只剩下堆了,java 对象实例和数组都是在堆上分配的,所以垃圾回收器重点照顾堆。
三. 怎么发现它
在发生 GC 的时候,Jvm 是怎么判断堆中的对象实例是不是垃圾呢?
这里有两种方式:
1. 引用计数法
就是给对象中添加一个引用计数器,每当有一个地方引用它时,计数器的值就加 1,每当有一个引用失效时,计数器的值就减 1。任何时刻只要对象的计数器值为 0,那么就可以被判定为垃圾对象。
这种方式,效率挺高,但是 Jvm 并没有使用引用计数算法。那是因为在某种场合下存在问题
比如下面的代码,会出现循环引用的问题:
public class Test {
Test test;
public Test(String name) {}
public static void main(String[] args) {
Test a = new Test("A");
Test b = new Test("B");
a.test = b;
b.test = a;
a = null;
b = null;
}
}
即使你把 a 和 b 的引用都置为 null 了,计数器也不是 0,而是 1,因为它们指向的对象又互相指向了对方,所以无法回收这两个对象。
2. 可达性分析法
这才是 jvm 默认使用的寻找垃圾算法。
它的原理是通过一些列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜素所走过的路叫做称为引用链“Reference Chain”,当一个对象到 GC Roots 没有任何引用链时,就说这个对象是不可达的。
从上图可以看到,即使 Object5 和 Object6 之间相互引用,但是没有 GC Roots 和它们关联,所以可以解决循环引用的问题。
小知识点:
(1)哪些可以作为 GC ROOTS 根呢?
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
(2)不得不说的四种引用
-
强引用:就是在程序中普遍存在的,类似“Object a=new Object”这类的引用。只要强引用关系还存在,垃圾回收器就不会回收掉被引用的对象。
-
软引用:用来描述一些还有用但是并非必须的对象。直到内存空间不够时(抛出 OutOfMemoryError 之前),才会被垃圾回收,通过 SoftReference 来实现。
-
弱引用:比软引用还弱,也是用来描述非必须的对象的,当垃圾回收器开始工作时,无论内存是否足够用,弱引用的关联的对象都会被回收 WeakReference。
-
虚引用:它是最弱的一种引用关系,它的唯一作用是用来作为一种通知。采用 PhantomRenference 实现。
(3)为什么定义这些引用?
个人理解,其实就是给对象加一种中间态,让一个对象不只有引用和非引用两种情况,还可以描述一些“食之无味弃之可惜”的对象。比如说:当内存空间足时,则能保存在内存中,如果内存空间在进行垃圾回收之后还不够时,才对这些对象进行回收。
四. 生存还是死亡?
要真正宣告一个对象死亡,至少要经历两次标记过程和一次筛选。
一张图带你看明白:
五. 垃圾收集算法
1. 标记清除算法
分为两个阶段“标记”和“清除”,标记出所有要回收的对象,然后统一进行清除。
缺点:
- 在对象变多的情况下,标记和清除效率都不高
- 会产生空间碎片
2. 复制算法
就是将堆分成两块完全相同的区域,对象只在其中一块区域内分配,然后标记出那些是存活的对象,按顺序整体移到另外一个空间,然后回收掉之前那个区域的所有对象。
缺点:
- 虽然能够解决空间碎片的问题,但是空间少了一半。优化后即为后续讲到的分代收集算法。
3. 标记整理算法
这种算法是,先找到存活的对象,然后将它们向空间的一端移动,最后回收掉边界以外的垃圾对象。
4. 分代收集
其实就是整合了上面三种算法,扬长避短。
之所以叫分代,是因为根据对象存活周期的不同将整个 Java 堆切割成为三个部分:
(1) Young(年轻代)
-
Eden(伊利园):新生对象
-
Survivor(幸存者):垃圾回收后还活着的对象
(2) Tenured(老年代):对象多次回收都没有被清理,会移到老年代
(3)Perm(永久代):存放加载的类别还有方法对象,java8 之后移除了永久代,替换为元空间(Metaspace)
在新生代中,每次垃圾收集都有大量的对象死去,只有少量的存活,那就选用复制算法 ,因为复制成本很小,只需要复制少量存活对象。
老年代中,存活对象较多,没有额外的空间担保,就得使用标记清除或者标记整理。
六. HotSpot的算法细节实现
1. 根节点枚举
迄今为止,所有收集器在根节点枚举这一步都必须暂停用户线程,因为整个枚举期间必须保证根节点集合的对象引用关系的确定性,否则分析结果准确性无非保证。
为了提升性能,HotSpot使用OopMap
数据结构来提前记录对象引用的位置信息,这样收集器在扫描时就可以直接得知对象引用信息,无需检查所有执行上下文和全局的引用位置。
2. 安全点、安全区域
从线程角度看,安全点可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的。比如:方法调用、循环跳转、异常跳转等这些地方才会产生安全点。
如果有需要,可以在这个位置暂停,比如发生 GC 时,需要暂停所有活动线程,但是线程在这个时刻,还没有执行到一个安全点,所以该线程应该继续执行,到达下一个安全点的时候暂停,等待 GC 结束。
但是当用户线程处于sleep
或者blocked
状态时,无法自行进入安全点,此种情况必须引入安全区域来解决。在安全区域中,线程执行与否不会影响对象引用的状态。线程进入安全区域会给自己加标记,告诉虚拟机可以进行GC;线程准备离开安全区域前会询问虚拟机GC是否完成。
3. 记忆集与卡表
记忆集主要用来解决跨代引用问题,避免把整个老年代加进GC Roots
扫描范围。
跨代引用问题:假设现在要进行一次MinorGC
,除了需要遍历固定的GC Roots
外,还需要遍历老年代中的所有对象判定是否存在跨代引用,而这会大大降低回收性能。
记忆集就是把老年代划分为若干小块,标示出老年代哪一块内存存在跨代引用,此后发生Minor GC
时,只需将包含跨代引用的小块内存中的对象加入到GC Roots
中进行扫描即可。
卡表是记忆集的一种实现方式,HotSpot虚拟机使用一个字节数组来实现卡表:
CARD_TABLE [this address >> 9] = 0
字节数组CARD_TABLE
的每个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称为卡页。结构大体如下所示:
一个卡页的内存中通常包含多个对象,只要卡页内有一个(或多个)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称这个元素变脏(Dirty),没有则标识为0。在GC时,筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots
中一并扫描。
4. 写屏障
记忆集用来缩减GC Roots
扫描范围,而写屏障用来解决卡表元素如何维护的问题。
问题1:卡表元素何时变脏?
其他分代区域中对象引用了本区域对象时,对应的卡表元素就应该变脏,原则上发生在引用类型字段赋值的那一刻。
问题2:如何在对象赋值的那一刻更新卡表元素?
在HotSpot虚拟机里是通过写屏障来维护卡表状态。写屏障可以看作在虚拟机层面对“应用类型字段赋值”操作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的操作。
在赋值前的写屏障叫做写前屏障,在赋值后的则叫做写后屏障。HotSpot虚拟机中除了G1收集器之外,其他收集器都只用到写后屏障。下面代码是更新卡表状态的简化逻辑:
void oop_field_store(oop* field, oop new_value) {
//应用字段赋值操作
*field = new_value;
//写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
5. 并发的可达性分析
根节点枚举必须暂停用户线程,但其带来的停顿时间相对短暂且固定(不随堆容量而增长),而从根节点往下遍历对象图这一标记阶段的停顿时间会与堆容量成正比关系,因此后续出现的收集器都重点在此处进行并发优化。
并发标记容易造成并发问题,我们通过三色标记来进行原因推导,按照“是否访问过”这个条件标记成以下三种颜色:
- 白色:表示对象尚未被垃圾收集器访问过。在可达性分析开始阶段,所有对象都是白色,若在分析结束阶段,仍然是白色的对象,即代表不可达。
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。
- 灰色:表示对象已经被垃圾收集器访问过,但还存在未被扫描过的引用。
并发标记过程中容易出现两种情况:
- 浮动垃圾:即将原本消亡的对象误标记为存活,此种情况可以容忍,CMS收集器就会出现此种问题。
- 对象消失:即将原本存活的对象误标记为消亡,这会导致程序错误。
对象消失的示意图如下,图片取自《深入理解Java虚拟机第三版》:
当且仅当以下两个条件同时满足时,会产生“对象消失”问题:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
因此要解决并发标记的对象消失问题,只要破坏上述两个条件的任意一个即可,因此就衍生了两种解决方案:
- 增量更新:破坏第一个条件,当黑色对象插入新的指向白色对象的引用关系时,将新插入的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中黑色对象为根,重新扫描一次。
- 原始快照(
SATB
):破坏第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。
上述两种方案的记录操作都是通过写屏障来实现的,在HotSpot虚拟机中,CMS采用增量更新进行并发标记,而G1是采用原始快照来实现并发标记的。
七. 垃圾收集器
在说垃圾回收器之前需要了解几个概念:
1. 几个概念
(1)吞吐量
CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值。
比如说虚拟机总运行了 100 分钟,用户代码时间 99 分钟,垃圾回收时间 1 分钟,那么吞吐量就是 99%。
(2)STW
全称Stop-The-World
,即在 GC 期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。
为什么需要 STW 呢?
在 java 程序中引用关系是不断会变化的,那么就会有很多种情况来导致垃圾标识出错。
想想一下如果一个对象 A 当前是个垃圾,GC 把它标记为垃圾,但是在清除前又有其他引用指向了 A,那么此刻又不是垃圾了。
那么,如果没有 STW 的话,就要去无限维护这种关系来去采集正确的信息,显然是不可取的。
(3)GC种类
部分收集(Partial GC
):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
- 新生代收集(
Minor GC/Young GC
):指目标只是新生代的垃圾收集。 - 老年代收集(
Major GC/Old GC
):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。Major GC
在不同资料上常有不同所指,需按上下文区分是老年代收集还是整堆收集。 - 混合收集(
Mixed GC
):指目标是收集整个新生代以及部分老年代的垃圾收集。例如G1收集器就是此种类型。
整堆收集(Full GC
):收集整个Java堆和方法区的垃圾收集。
2. 垃圾收集器
下面是一张很经典的图,展示了 7 种不同分代的收集器,如果两个收集器之间存在连线,说明可以搭配使用。
Serial收集器
Serial 收集器是一个单线程收集器,在进行垃圾回收器的时候,必须暂停其他工作线程,也就是发生 STW。在 GC 期间,应用是不可用的。
特点:
- 采用复制算法
- 单线程收集器
- 效率会比较慢,但是因为是单线程,所以消耗内存小
ParNew收集器
ParNew 是 Serial 的多线程版本,也是工作在新生代,能与 CMS 配合使用。
在多 CPU 的情况下,由于 ParNew 的多线程回收特性,毫无疑问垃圾收集会更快,也能有效地减少 STW 的时间,提升应用的响应速度。
特点:
- 采用复制算法
- 多线程收集器
- 效率高,能大大减少 STW 时间。
Parallel Scavenge收集器
Parallel Scavenge 收集器也是一个使用复制算法,多线程,工作于新生代的垃圾收集器,看起来功能和 ParNew 收集器基本一样。
但是它有啥特别之处呢?关注点不同
ParNew 垃圾收集器关注的是尽可能缩短垃圾收集时用户线程的停顿时间,更适合用到与用户交互的程序,因为停顿时间越短,用户体验肯定就好呀!!
Parallel Scavenge 目标是达到一个可控制的吞吐量,所以更适合做后台运算等不需要太多用户交互的任务。
Parallel Scavenge 收集器提供了两个参数来控制吞吐量,
-
-XX:MaxGCPauseMillis:控制最大垃圾收集时间
-
-XX:GCTimeRati:直接设置吞吐量大小
特点:
- 采用复制算法
- 多线程收集器
- 吞吐量优先
Serial Old收集器
Serial 收集器是工作于新生代的单线程收集器,与之相对地,Serial Old 是工作于老年代的单线程收集器。
作用:
- 在 Client 模式下与 Serial 回收器配合使用
- Server 模式下,则它还有两大用途:一种是在 JDK 1.5 及之前的版本中与 Parallel Scavenge 配合使用,另一种是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用
它与 Serial 收集器配合使用示意图如下:
特点:
- 标记-整理算法
- 单线程收集器
- 老年代工作
Parallel Old收集器
Parallel Old 是一个多线程的垃圾回收器,采用标记整理算法,负责老年代的垃圾回收工作。可以与 Parallel Scavenge 垃圾回收器一起搭配工作,真正的实现吞吐量优先。
示意图如下:
特点:
- 标记-整理算法
- 多线程收集器
- 老年代工作
CMS收集器
CMS 可以说是一款具有"跨时代"意义的垃圾回收器,如果应用很重视服务的响应速度,希望给用户最好的体验,则 CMS 收集器是非常合适的,它是以获取最短回收停顿时间为目标的收集器!
CMS 虽然工作在老年代,和之前收集器不同的是,使用的标记清除算法。
示意图如下:
垃圾回收的 4 个步骤:
-
初始标记:标记出来和 GC Roots 直接关联的对象,整个速度是非常快的,会发生 STW,确保标记的准确性。
-
并发标记:并发标记这个阶段会直接根据第一步关联的对象找到所有的引用关系,耗时较长,但是这个阶段会与用户线程并发运行,不会有很大的影响。
-
重新标记:这个阶段是为了解决第二步并发标记所导致的标错情况。并发阶段会和用户线程并行,有可能会出现判断错误的情况,这个阶段就是对上一个阶段的修正。
-
并发清除:最后一个阶段,将之前确认为垃圾的对象进行回收,会和用户线程一起并发执行。
特点:并发收集、低停顿
缺点:
-
影响用户线程的执行效率:CMS 默认启动的回收线程数是(处理器核心数 + 3)/ 4 ,由于是和用户线程一起并发清理,那么势必会影响到用户线程的执行速度
-
会产生浮动垃圾:CMS 的第 4 个阶段并发清除是和用户线程一起的,会产生新的垃圾,就叫浮动垃圾
-
会产生碎片化的空间:标记清除的缺点
G1收集器
全称:Garbage-First
G1 回收的目标不再是整个新生代或者是老年代。G1 可以回收堆内存的任何空间来进行,不再是根据年代来区分,而是那块空间垃圾多就去回收,通过 Mixed GC 的方式去进行回收。
先看下堆空间的划分:
G1 垃圾回收器把堆划分成大小相同的 Region,每个 Region 都会扮演一个角色,分别为 H、S、E、O。
- E 代表Eden区
- S 代表 Survivor 区
- H 代表的是 Humongous 区(存储大对象区域)
- O 代表 Old 区
G1 的工作流程图:
-
初始标记:标记GC Roots能直接关联到的对象,并且修改TAMS指针的值,让并发标记阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿用户线程,但耗时很短。
-
并发标记:从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户线程并发执行。当对象图扫描完成以后,还要重新处理STAB记录下的在并发时有引用变动的对象。
-
最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的STAB记录。
-
筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里涉及到存活对象的移动,必须暂停用户线程,由多条收集器线程并行完成。
G1收集器的完整流程可参考:搞懂G1垃圾收集器
特点:
-
并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,可以通过并发的方式让 Java 程序继续执行,进一步缩短 STW 的时间。
-
分代收集:分代概念在 G1 中依然得以保留,它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象来获得更好的收集效果。
-
空间整合:G1 从整体上看是基于标记-整理算法实现的,从局部(两个 Region 之间)上看是基于复制算法实现的,G1 运行期间不会产生内存空间碎片。
-
可预测停顿:G1 比 CMS 厉害在能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
八. 内存分配与回收策略
上文说的一直都是回收内存的内容,那么怎么给对象分配内存呢?
堆空间的结构:
1. Eden区
研究表明,有将近 98%的对象是朝生夕死,所以针对这一现状,大多数情况下,对象会在新生代 Eden 区中进行分配。
当 Eden 区没有足够空间进行分配时,虚拟机会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。
通过 Minor GC 之后,Eden 会被清空,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区(若 From 区不够,则直接进入 Old 区)。
2. Survivor区
Survivor 区相当于是 Eden 区和 Old 区的一个缓冲,Survivor 又分为 2 个区,一个是 From 区,一个是 To 区。每次执行 Minor GC,会将 Eden 区和 From 存活的对象放到 Survivor 的 To 区(如果 To 区不够,则直接进入 Old 区)。
问题 1: 为什么需要 Survivor?
如果没有 Survivor 区,Eden 区每进行一次 Minor GC,存活的对象就会被送到老年代,老年代很快就会被填满。而有很多对象虽然一次 Minor GC 没有消灭,但其实或许第二次,第三次就需要被清除。
这时候移入老年区,很明显不是一个明智的决定。
所以,Survivor 的存在意义就是减少被送到老年代的对象,进而减少老年代 GC 的发生。Survivor 的预筛选保证,只有经历 15 次 Minor GC 还能在新生代中存活的对象,才会被送到老年代。
问题 2:为什么需要 From 和 To 两个呢?
这种机制最大的好处就是可以解决内存碎片化,整个过程中,永远有一个 Survivor 区是空的,另一个非空的 Survivor 区是无碎片的。
假设只有一个 Survivor 区。
Minor GC 执行后,Eden 区被清空了,存活的对象放到了 Survivor 区,而之前 Survivor 区中的对象,可能也有一些是需要被清除的。
那么问题来了,这时候我们怎么清除它们?
在这种场景下,我们只能标记清除,而我们知道标记清除最大的问题就是内存碎片,在新生代这种经常会消亡的区域,采用标记清除必然会让内存产生严重的碎片化。
因为 Survivor 有 2 个区域,所以每次 Minor GC,会将之前 Eden 区和 From 区中的存活对象复制到 To 区域。第二次 Minor GC 时,To 区 到 From 区 ,以此反复。
3. Old区
老年代占据着 2/3 的堆内存空间,只有在 Major GC 的时候才会进行清理,每次 GC 都会触发Stop-The-World
。内存越大,STW 的时间也越长,所以内存也不仅仅是越大就越好。
由于复制算法在对象存活率较高的老年代会进行很多次的复制操作,效率很低,所以在这里老年代采用的是标记整理算法。
下面三种情况也会直接进入老年代:
-
大对象
大对象指需要大量连续内存空间的对象,这部分对象不管是不是“朝生夕死”,都会直接进到老年代。这样做主要是为了避免在 Eden 区及 2 个 Survivor 区之间发生大量的内存复制。当你的系统有非常多“朝生夕死”的大对象时,需要注意。 -
长期存活对象
虚拟机给每个对象定义了一个对象年龄 Age 计数器。正常情况下对象会不断的在 Survivor 的 From 区与 To 区之间移动,对象在 Survivor 区中每经历一次 Minor GC,年龄就增加 1 岁。当年龄增加到 15 岁时,这时候就会被转移到老年代。 -
动态对象年龄
虚拟机并不重视要求对象年龄必须到 15 岁,才会放入老年区,如果 Survivor 空间中相同年龄所有对象大小的总合大于 Survivor 空间的一半,年龄大于等于该年龄的对象就可以直接进去老年区。
4. 空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。
如果条件成立的话,Minor GC 是可以确保安全的。
如果不成立,则虚拟机会查看 HandlePromotionFailure 设置是否担保失败,如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。
如果大于,尝试进行一次 Minor GC。
如果小于或者 HandlePromotionFailure 不允许,则进行一次 Full GC。
九. 资料参考
《深入理解Java虚拟机》