JVM 的垃圾回收机制笔记整理
垃圾收集(Garbage Collection, GC)
为什么要了解垃圾回收?
- 排查内存溢出、内存泄漏的问题
- 垃圾收集会成为系统达到更高并发量的瓶颈
什么时候回收?
几乎所有的对象实例都存放在 Java 堆中,所以要判断对象是否“存活”,回收已经“死亡”的对象(即不可能在被任何途径使用的对象)。
判断对象是否需要被回收
1. 引用计数器算法:
原理:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。
优点:实现简单,判定效率高。
缺点:不能解决对象之间相互循环引用的问题。
2. 可达性分析算法:
原理:通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始乡下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,证明该对象不可用。
可作为 GC Roots 的对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI (即一般说的 Native 方法)引用的对象
引用的分类
-
强引用(Strong Reference)
类似
Object obj = new Object();
这种的通过new
关键字创建的引用,只要强引用存在,该对象就不会被回收。 -
软引用(Soft Reference)
对于软引用对象,在系统将要发生内存溢出异常之前,JVM 才会回收。
Object obj = new Object(); SoftReference<Object> sf = new SoftReference<>(obj);
-
弱引用(Weak Reference)
被弱引用的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。
通过 WeakReference 类实现。
-
虚引用(Phantom Reference)
一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。
为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。
通过 PhantomReference 类实现。
哪些内存需要回收?
-
不需要回收:程序计数器,虚拟机栈,本地方法栈
它们是线程私有的,它们的生命周期和线程一致,线程结束后内存紧接着也就回收了。
-
需要回收:Java堆,方法区
它们是线程共享的,而且这部分区域内存的分配是动态的,只有在程序处于运行期间才能知道会创建哪些对象,垃圾收集器所关注的就是这部分内存。
方法区的回收
因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。
主要是对常量池的回收和对类的卸载。
为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。
类卸载需要满足的三个条件(都满足也不一定会被卸载):
- 该类所有的实例都已被回收,此时堆中不存在该类的任何实例。
- 加载该类的 ClassLoader 已被回收。
- 该类对应的 Class 对象没有在任何地方被引用,也就是无法在任何地方通过反射访问该类方法。
如何回收?
垃圾收集算法
-
标记 - 清除算法(Mark-Sweep)
该算法分为“标记”,“清除”两个阶段:首先标记出所需要回收的对象,在标记完成后统一回收所有被标记的对象。(最基础的收集算法)
不足:
- 效率不高:标记和清除两个过程的效率都不高。
- 空间问题:标记清除后会产生大量不连续的内存碎片,导致无法给大对象分配内存。
-
复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完后,就将还存活的对象复制到另一块上面,然后在把已使用的内存空间一次清理掉。
-
优点:每次对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶的指针,按顺序分配内存即可,实现简单,运行高效。
-
不足:内存缩小为原来的一半,代价高。
商业虚拟机一般采用这种复制算法来回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活的对象全部复制到另一块 Survivor 上, 最后清理 Eden 和使用过的那一块 Survivor。
-
-
标记 - 整理(Mark-Compact)
标记过程和“标记-清除”算法一致,整理过程是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
- 优点:不会产生内存碎片。
- 不足:需要移动大量对象,处理效率低。
-
分代收集
现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。
- 新生代:复制。
- 老年代:标记 - 清除 或着 标记 - 整理。
垃圾收集器
- 串行:垃圾收集器与用户程序交替执行(执行垃圾收集时需要停顿用户程序)。
- 并行:垃圾收集器与用户程序同时执行。
以上是 HotSpot 虚拟机(JDK 1.7)中的垃圾收集器,连线表示可以搭配使用。
-
Serial 收集器
“serial” 翻译为串行,也就是说 Serial 收集器是以串行的方式运行。
单线程收集器,只会使用一个线程进行垃圾收集。
优点:简单高效,在单个 CPU 环境下,Serial 收集器没有线程交互的开销,因此拥有最高的单线程收集效率。
是 Client 模式下默认的新生代收集器。
-
ParNew 收集器
是 Serial 收集器的多线程版本。
是 Server 模式下默认的新生代收集器。
除 Serial 收集器外,只有它能与 CMS 收集器配合使用。
-
Parallel Scavenge 收集器
多线程收集器,新生代收集器,使用复制算法的收集器。
该收集器的目标是达到一个可控制的吞吐量。吞吐量是指 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值。
GC 停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾收集变得频繁,导致停顿时间缩短,吞吐量下降。
-
Serial Old 收集器
是 Serial 收集器的老年代版本,单线程收集器,使用标记-整理算法。
主要意义在于给 Client 模式下的虚拟机使用。
在 Server 模式下,有两大用途:① 在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用;② 作为 CMS 收集器的后背预案。
-
Parallel Old 收集器
是 Parallel Scavenge 收集器的老年代版本。
多线程收集器,使用标记-整理算法。
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
-
CMS 收集器(Concurrent Mark Sweep)
使用标记-清除算法,并发收集,低停顿。
运作流程:
- 初始标记:仅仅标记以下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
- 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
- 重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,需要停顿。
- 并发清除:不需要停顿。
不足:
- 吞吐量低:停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不高。
- 无法处理浮动垃圾,可能出现 “Concurrent Mode Failure”。如果预留的内存不够存放浮动垃圾,就会出现 ”Concurrnt Mode Failure“,这时虚拟机将临时启用 Serial Old 来代替 CMS。
- 标记-清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
-
G1 收集器(Garbage-First)
是面向服务端应用的垃圾收集器。
Java 堆被分为新生代和老年代,其它收集器的收集范围是整个新生代或者老年代。
而 G1 收集器重新定义了堆空间,将整个 Java 堆划分为多个大小相等的独立区域(Region)。每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
区域划分带来的好处就是带来了停顿时间可预测的收集模型:用户可以指定收集操作在多长时间内完成。
运作流程:
- 初始标记
- 并发标记
- 最终标记:为了修正证在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录。需要停顿线程,可以并行执行。
- 筛选回收:首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。此阶段可以做到与用户程序一起并发执行。
特点:
-
并行与并发:缩短了用户程序停顿时间。
-
分代收集
-
空间整合:整体来看是基于“标记-整理”算法实现,局部来看是基于“复制”算法实现。这意味着 G1 运行期间不会产生内存碎片,利于程序长时间运行。
-
可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集器上的时间不超过 N 毫秒。
内存分配与回收策略
Minor GC 和 Full GC
- 新生代 GC (Minor GC):指发生在新生代的 GC,因为 Java 对象大多具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也快。
- 老年代 GC (Major GC / Full GC):指发生在老年代的 GC,老年代对象存活时间长,所以老年代 GC 很少执行,一般老年代的 GC 会伴随着新生代的 GC。
内存分配与回收策略
-
对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,进行 Minor GC。
-
大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是很长的字符串或者数组。
经常出现大对象容易导致在内存足够的情况下触发垃圾回收。
-
长期存活的对象进入老年代
为对象定义一个年龄计数器,对象在 Eden 出生并且经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。此后该对象每经过一次 Minor GC 后仍存活,年龄加 1,当年龄增加到一定程度时,进入老年代。
-XX:MaxTenuringThreshold
:设置对象晋升老年代的阈值。 -
动态对象年龄判定
虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代。
如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
-
空间分配担保
在进行 Minor GC 之前,虚拟机先检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果条件成立,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。
Minor GC 触发条件:
当 Eden 空间满时,就会触发 Minor GC。
Full GC 触发条件:
- 调用
System.gc();
,只是建议虚拟机执行,不一定真执行。 - 老年代空间不足,大对象进入老年代,长期存活的对象进入老年代。
- 空间分配担保失败
- JDK 1.7 及以前的永久代空间不足
- Concurrent Mode Failure
小结
收集器 | 串行、并行or并发 | 新生代/老年代 | 收集算法 | 目标 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制 | 响应速度优先 | 单CPU环境下的Client模式 |
Serial Old | 串行 | 老年代 | 标记-整理 | 响应速度优先 | 单CPU环境下的Client模式、CMS的后备预案 |
ParNew | 并行 | 新生代 | 复制 | 响应速度优先 | 多CPU环境时在Server模式下与CMS配合 |
Parallel Scavenge | 并行 | 新生代 | 复制 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
Parallel Old | 并行 | 老年代 | 标记-整理 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
CMS | 并发 | 老年代 | 标记-清除 | 响应速度优先 | 集中在互联网站或B/S系统服务端上的Java应用 |
G1 | 并发 | both | 标记-整理+复制算法 | 响应速度优先 | 面向服务端应用,将来替换CMS |
参考资料:
《深入理解 Java 虚拟机》