彻底搞懂JVM垃圾回收
哈喽,大家好🎉,我是世杰。
本文我为大家介绍面试官经常考察的「JVM垃圾回收那些事」
面试连环call
- 如何判断对象是否应被回收?
- finalize方法的实现机制是什么?
- 如何判断类应该被回收?
- 垃圾回收算法都有什么? 优缺点是什么?
- Minor GC, Major GC, Full GC 分别代表什么?
- JVM都有哪些内存分配策略?
1. 对象回收判断
1.1 引用计数算法
「定义」
一个对象被创建之后,系统会给这个对象初始化一个引用计数器,当这个对象被引用了,则计数器 +1,而当该引用失效后,计数器便 -1,直到计数器为 0,意味着该对象不再被使用了,则可以将其进行回收了。
「存在问题」
两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。
1.2 可达性分析算法
「定义」
以 GC Roots 作为起始点进行搜索,一步步遍历找到和这个根对象具有引用关系的对象,然后再从这些对象开始继续寻找,能够到达到的对象都是存活的,不可达的对象可被回收。
在 Java 中 GC Roots 一般包含以下内容:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中的常量引用的对象
1.3 finalize()
对象可以被回收,就代表一定会被回收吗?
即使在可达性分析法中不可达的对象,也并不是立刻回收。宣告一个对象死亡,至少要经历两次标记
「第一次标记」
如果对象进行可达性分析算法之后发现未与 GC Roots 引用链相连,那它将会第一次标记并且进行一次筛选。当对象没有覆盖 finalize () 方法、或者 finalize () 方法已经被 JVM 执行过,则判定为可回收对象。如果对象有必要执行 finalize () 方法,则被放入 F-Queue 队列中。稍后在 JVM 自动建立、低优先级的 Finalizer 线程(可能多个线程)中触发这个方法.
「第二次标记」
GC 对 F-Queue 队列中的对象进行二次标记。如果对象在 finalize () 方法中重新与引用链上的任何一个对象建立了关联,那么二次标记时则会将它移出 “即将回收” 集合。如果此时对象还没成功逃脱,那么只能被回收了。
注意 ⚠️:JDK9 版本及后续版本中各个类中的 finalize
方法会被逐渐弃用移除。忘掉它的存在吧!
2. 类回收判断
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。
JVM方法区存储的是类信息,因此主要针对方法区进行类回收,那么如何判断一个类是无用的类的呢?
类需要同时满足下面 3 个条件才能算是 “无用的类”:
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的
ClassLoader
已经被回收。 - 该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用就会必然被回收。
3. 垃圾回收算法
3.1 标记-清除
将存活的对象进行标记,然后清理掉未被标记的对象。
这是一个非常基本的GC算法,它是现代GC算法的思想基础,分为标记和清除两个阶段:先把所有活动的对象标记(可达性分析法)出来,然后把没有被标记的对象统一清除掉。
「不足」
- 标记和清除过程效率都不高;
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存。
3.2 标记-整理
让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
标记—整理算法适用于存活对象较多的场合,它的标记阶段和标记-清除算法中的一样。整理阶段是将所有存活的对象压缩到内存的一端,之后清理边界外所有的空间。它的效率也不高。
3.3 复制
将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
它比标记-清除算法要高效,但不适用于存活对象较多的内存,因为复制的时候会有较多的时间消耗。它的致命缺点是会有一半的内存浪费。
现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。
3.4 分代收集
现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。
- 新生代使用: 复制算法
- 老年代使用: 标记-清除 或者 标记-整理 算法
『新生代(Young Generation)』
所有新生成的对象首先都是放在新生代的。新生代的目标就是尽可能快速的收集掉那些生命周期短的对象。
- 新生代内存分配一块较大的 Eden 空间和两块较小的 Survivor 空间(默认 Eden 和 Survivor 的大小比例是 8:1)。每次使用 Eden 和其中一块 Survivor 空间(当Eden内存满了就会把对象分配到Survivor区)
- 回收时将
Eden
和Survivor
空间中存活的对象一次性复制到另一块 Survivor 空间上。最后清理掉 Eden 和使用过的 Survivor 空间。Survivor 区总有一个是空的。 - 如果另一块
Survivor
空间没有足够内存来存放上一次新生代收集下来的存活对象,那么这些对象则直接进入老年代。 - 新生代发生的GC也叫做
Minor GC
,MinorGC发生频率比较高(不一定等Eden区满了才触发)。
『老年代(Old Generation)』
- 在新生代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到老年代中。因此,可以认为老年代中存放的都是一些生命周期较长的对象。
- 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发
Major
或者Full GC
,发生频率比较低,老年代对象存活时间比较长,存活率标记高
4. Minor GC、Major GC、Full GC
-
Minor GC/Young GC:只是新生代的垃圾收集
-
Major GC/Old GC:只是老年代的垃圾收集
- 目前,只有 CMS GC 会有单独收集老年代的行为
- 很多时候 Major GC 会和 Full GC 混合使用,需要具体分辨是老年代回收还是整堆回收
-
Mixed GC:收集整个新生代以及部分老年代的垃圾收集
- 目前只有
G1 GC
会有这种行为
- 目前只有
-
Full GC:收集整个 Java 堆和方法区的垃圾
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:
-
调用 System.gc()
-
老年代空间不足
-
空间分配担保失败
-
JDK 1.7 及以前的永久代空间不足
-
Concurrent Mode Failure
注意 ⚠️:Concurrent Mode Failure 是 CMS 特有行为,执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
5. 内存分配策略
JVM为对象分配内存时,会有一些分配策略保证更好的使用和回收对象
5.1 对象优先在 Eden 分配
- 大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。
5.2 大对象直接进入老年代
-
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
-
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
-
-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。
5.3 长期存活的对象进入老年代
-
为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。
-
-XX:MaxTenuringThreshold 用来定义年龄的阈值。
5.4 动态对象年龄判定
- 虚拟机并不是永远地要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。
5.5 空间分配担保
-
在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
-
空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。
-
如果空间分配担保失败则会进行
Full GC
#面试##Java#参考文章