今天来说说 Java 垃圾回收,高频面试问题。
提纲附上,话不多说,直接干货
1、什么是垃圾回收?垃圾回收(Garbage Collection,GC):就是释放垃圾占用的空间,防止内存泄露。对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收。
2、垃圾在哪儿?上图可以看到程序计数器、虚拟机栈、本地方法栈都是伴随着线程而生死,这些区域不需要进行 GC。
而方法区/元空间在 1.8 之后就直接放到本地内存了,假设总内存 2G,JVM 被分配内存 100M, 理论上元空间可以分配 2G-100M = 1.9G,空间还是足够的,所以这块区域也不用管。
所以就只剩下堆了,java 对象实例和数组都是在堆上分配的,所以垃圾回收器重点照顾堆。
3、怎么发现它?在发生 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、为什么定义这些引用?
个人理解,其实就是给对象加一种中间态,让一个对象不只有引用和非引用两种情况,还可以描述一些“食之无味弃之可惜”的对象。比如说:当内存空间足时,则能保存在内存中,如果内存空间在进行垃圾回收之后还不够时,才对这些对象进行回收。
4、生存还是死亡?要真正宣告一个对象死亡,至少要经历两次标记过程和一次筛选。
一张图带你看明白:
5、垃圾收集算法1、标记清除算法
分为两个阶段“标记”和“清除”,标记出所有要回收的对象,然后统一进行清除。
缺点:
- 在对象变多的情况下,标记和清除效率都不高
- 会产生空间碎片
2、复制算法
就是将堆分成两块完全相同的区域,对象只在其中一块区域内分配,然后标记出那些是存活的对象,按顺序整体移到另外一个空间,然后回收掉之前那个区域的所有对象。
缺点:
- 虽然能够解决空间碎片的问题,但是空间少了一半。也太多了吧!!
3、标记整理算法
这种算法是,先找到存活的对象,然后将它们向空间的一端移动,最后回收掉边界以外的垃圾对象。
4、分代收集
其实就是整合了上面三种算法,扬长避短。
之所以叫分代,是因为根据对象存活周期的不同将整个 Java 堆切割成为三个部分:
-
Young(年轻代)
- Eden(伊利园):新生对象
- Survivor(幸存者):垃圾回收后还活着的对象
- Tenured(老年代):对象多次回收都没有被清理,会移到老年代
- Perm(永久代):存放加载的类别还有方法对象,java8 之后移除了永久代,替换为元空间(Metaspace)
在新生代中,每次垃圾收集都有大量的对象死去,只有少量的存活,那就选用 复制算法 ,因为复制成本很小,只需要复制少量存活对象。
老年代中,存活对象较多,没有额外的空间担保,就得使用 标记清除 或者 标记整理 。
6、垃圾收集器在说垃圾回收器之前需要了解几个概念:
1、几个概念
吞吐量
CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值。
比如说虚拟机总运行了 100 分钟,用户代码时间 99 分钟,垃圾回收时间 1 分钟,那么吞吐量就是 99%。
STW
全称 Stop-The-World,即在 GC 期间,只有垃圾回收器线程在工作,其他工作线程则被挂起。
为什么需要 STW 呢?
在 java 程序中引用关系是不断会变化的,那么就会有很多种情况来导致垃圾标识出错。
想想一下如果一个对象 A 当前是个垃圾,GC 把它标记为垃圾,但是在清除前又有其他引用指向了 A,那么此刻又不是垃圾了。
那么,如果没有 STW 的话,就要去无限维护这种关系来去采集正确的信息,显然是不可取的。
安全点
从线程角度看,安全点可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,说明虚拟机当前的状态是安全的。
比如:方法调用、循环跳转、异常跳转等这些地方才会产生安全点。
如果有需要,可以在这个位置暂停,比如发生 GC 时,需要暂停所有活动线程,但是线程在这个时刻,还没有执行到一个安全点,所以该线程应该继续执行,到达下一个安全点的时候暂停,等待 GC 结束。
串行、并行
串行:是指垃圾回收线程在进行垃圾回收工作,此时用户线程处于等待状态。
并行:是指用户线程和多条垃圾回收线程分别在不同 CPU 上同时工作。
2、回收器
下面是一张很经典的图,展示了 7 种不同分代的收集器,如果两个收集器之间存在连线,说明可以搭配使用。
Serial
Serial 收集器是一个单线程收集器,在进行垃圾回收器的时候,必须暂停其他工作线程,也就是发生 STW。在 GC 期间,应用是不可用的。
特点:1、采用复制算法 2、单线程收集器 3、效率会比较慢,但是因为是单线程,所以消耗内存小
ParNew
ParNew 是 Serial 的多线程版本,也是工作在新生代,能与 CMS 配合使用。
在多 CPU 的情况下,由于 ParNew 的多线程回收特性,毫无疑问垃圾收集会更快,也能有效地减少 STW 的时间,提升应用的响应速度。
特点:1、采用复制算法 2、多线程收集器 3、效率高,能大大减少 STW 时间。
Parallel Scavenge
Parallel Scavenge 收集器也是一个使用复制算法,多线程,工作于新生代的垃圾收集器,看起来功能和 ParNew 收集器基本一样。
但是它有啥特别之处呢?关注点不同
- ParNew 垃圾收集器关注的是尽可能缩短垃圾收集时用户线程的停顿时间,更适合用到与用户交互的程序,因为停顿时间越短,用户体验肯定就好呀!!
- Parallel Scavenge 目标是达到一个可控制的吞吐量,所以更适合做后台运算等不需要太多用户交互的任务。
Parallel Scavenge 收集器提供了两个参数来控制吞吐量,
- -XX:MaxGCPauseMillis:控制最大垃圾收集时间
- -XX:GCTimeRati:直接设置吞吐量大小
特点:1、采用复制算法 2、多线程收集器 3、吞吐量优先
Serial Old
Serial 收集器是工作于新生代的单线程收集器,与之相对地,Serial Old 是工作于老年代的单线程收集器。
作用:
- 在 Client 模式下与 Serial 回收器配合使用
- Server 模式下,则它还有两大用途:一种是在 JDK 1.5 及之前的版本中与 Parallel Scavenge 配合使用,另一种是作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用
它与 Serial 收集器配合使用示意图如下:
特点:1、标记-整理算法 2、单线程 3、老年代工作
Parallel Old
Parallel Old 是一个多线程的垃圾回收器,采用标记整理算法,负责老年代的垃圾回收工作,可以与 Parallel Scavenge 垃圾回收器一起搭配工作。真正的实现吞吐量优先
示意图如下:
特点:1、标记-整理算法 2、多线程 3、老年代工作
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 代表伊甸区
- S 代表 Survivor 区
- H 代表的是 Humongous 区
- O 代表 Old 区
G1 的工作流程图:
- 初始标记:标记出来 GC Roots 能直接关联到的对象,修改 TAMS 的值以便于并发回收时新对象分配
- 并发标记:根据刚刚关联的对像扫描整个对象引用图,和用户线程并发执行,记录 SATB(原始快照) 在并发时有引用的值
- 最终标记:处理第二步遗留下来的少量 SATB(原始快照) 记录,会发生 STW
- 筛选回收:维护之前提到的优先级列表,根据优先级列表、用户设置的最大暂停时间来回收 Region
特点:
- 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,可以通过并发的方式让 Java 程序继续执行,进一步缩短 STW 的时间。
- 分代收集:分代概念在 G1 中依然得以保留,它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象来获得更好的收集效果。
- 空间整合:G1 从整体上看是基于标记-整理算法实现的,从局部(两个 Region 之间)上看是基于复制算法实现的,G1 运行期间不会产生内存空间碎片。
- 可预测停顿:G1 比 CMS 厉害在能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
上文说的一直都是回收内存的内容,那么怎么给对象分配内存呢?
堆空间的结构:
Eden 区
研究表明,有将近 98%的对象是朝生夕死,所以针对这一现状,大多数情况下,对象会在新生代 Eden 区中进行分配。
当 Eden 区没有足够空间进行分配时,虚拟机会发起一次 Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快。
通过 Minor GC 之后,Eden 会被清空,Eden 区中绝大部分对象会被回收,而那些无需回收的存活对象,将会进到 Survivor 的 From 区(若 From 区不够,则直接进入 Old 区)。
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 区 ,以此反复。
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 空间的一半,年龄大于等于该年龄的对象就可以直接进去老年区。
空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。
如果条件成立的话,Minor GC 是可以确保安全的。
如果不成立,则虚拟机会查看 HandlePromotionFailure 设置是否担保失败,如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。
如果大于,尝试进行一次 Minor GC。
如果小于或者 HandlePromotionFailure 不允许,则进行一次 Full GC。