3.1|3.1 - 3.3 垃圾收集器与内存分配策略
如何确定对象已经无效
- 引用
- JDK1.2之前,reference类型仅仅代表数据中存储的数值代表的是另一块内存的地址。
- JDK1.2之后,reference类分为强引用、软引用、弱引用和虚引用(Phantom Reference)。
- 强引用:传统定义的引用,例如
Object obj = new Object();
这种引用赋值。
- 只要强引用关系还存在,垃圾回收器就不会回收被引用的对象。
- 软引用:指一些还有用,但是非必须的对象,JDK1.2后可以用SoftReference类来实现软引用。
常用于小内存设备的 Cache 中,可以先清理掉缓存以保证不发生OOM,在后续合适的时机再将它们重新加载到 Cache 中。也常用于内存敏感的场景中。
- 当系统要发生内存溢出前,会将只被软引用的对象加入回收范围进行二次回收。如果任然没有足够的内存,才会抛出内存溢出异常。
import java.lang.ref.SoftReference; public class Test { public static void main(String[] args) { SoftReference sr = new SoftReference(new String("softReference")); // 如果 sr 已经被回收,那么 sr.get() 返回 null,否则返回其引用 System.out.println(sr.get()); } }
- 弱引用:同指还有用,但是非必须的对象,但是比软引用弱,JDK1.2后可以有WeakRefernece来实现弱引用。
软引用一般用于保存对象的引用而不影响其 GC 过程。常用于 Debug 和 内存监视软件之中。
JDK的Proxy将动态生成的Class实例暂存于一个由Weakrefrence构成的Map中作为Cache。
- 其和软引用的区别是:只被弱引用的对象在发生垃圾回收时就会被回收,而只被软引用的对象要等到内存不足时才会被回收。
- 虚引用(幽灵引用、幻影引用):最弱的引用关系,不会影响被引用对象的生存时间,也无法通过虚引用获得一个对象实例,JDK1.2后可以用PhantomReference来实现虚引用。
创建虚引用时需要和一个队列关联,在虚引用所引用的对象执行 finalize() 方法后该对象被放到队列中,可以判断队列中是否有该对象判断其是否执行了回收。
- 设置虚引用关联的唯一目的是在对象被回收时收到一个系统通知。
- 强引用:传统定义的引用,例如
- JDK1.2之前,reference类型仅仅代表数据中存储的数值代表的是另一块内存的地址。
- 引用计数法 (reference counting)
在对象中添加一个计数器,每次当一个地方引用到此对象时则计数器+1,当引用失效时则计数器-1。
计数器为0时则代表对象已经无效。
- 优点:原理简单,判断快速。
- 缺点:需要额外的处理才能保证正确工作。
public class ReferenceCountingGc { public Object instance = null; private static final int _1MB = 1024 * 1024; // Byte数组仅用来占据空间 private Byte[] bigSize = new Byte[2 * _1MB]; public static void main(String[] args) { ReferenceCountingGc objA = new ReferenceCountingGc(); ReferenceCountingGc objB = new ReferenceCountingGc(); objA.instance = objB.instance; objB.instance = objA.instance; // 原本的两个引用已经无效,但是原指的对象的引用计数并没有清0 objA = null; objB = null; //显示地告诉虚拟机要进行垃圾回收了,并不一定会执行回收动作。 System.gc(); } }
- 优点:原理简单,判断快速。
- 【3.1|3.1 - 3.3 垃圾收集器与内存分配策略】可达性分析算法 (Reachability Analysis)
设置一系列"GC Roots"作为起始节点集,从这些节点根据引用关系向下搜索,搜索路径又称为引用链。
如果某个对象无法与GC Roots相连(直接或者间接),那么这个对象被视为无效。
文章图片
- 可以被固定作为GC Roots的对象:
- 虚拟机栈(帧栈中的本地变量表)中引用的对象
public class Test { public static void main(String[] args) { // 本地变量表中的对象引用 Object obj = new Object(); // obj设为null后,原来new的对象无法到达 obj = null; } }
- 方法区中静态属性引用、常量引用的对象
public class Test { // 静态属性引用 public static Test s; public static void main(String[] args) { Test a = new Test(); a.s = new Test(); a = null; } }
- 本地方法中 JNI (java native interface)引用的对象
JNI简单地说就是java调用其他语言编写的程序。Java会将这些方法装入一个新的帧栈然后放入本地方法栈中,调用只是简单的动态连接而已。JVM虚拟机和本地方法之间交换数据的接口是JNI。
- Java虚拟机内部的引用
- 被同步锁 (synchronized关键字) 持有的对象
- 反应Java虚拟机内部情况的JMXBeans、JVMTI中注册的回调、本地代码缓存等
- 虚拟机栈(帧栈中的本地变量表)中引用的对象
- 根据垃圾回收器和当前回收区域的不同,还有其他对象可以“临时性”地加入GC Roots (如发生跨代引用时老年代中的对象)
- 可以被固定作为GC Roots的对象:
- 可达性分析算法回收对象的时间:
可达性分析算法判断不可达的对象,并不会被立即回收,最多会进行两次标记过程后被回收。
- 第一次标记:
- 可达性分析算法在发现一个对象没有与GC Roots的引用链时,该对象被第一次标记。随后进行一次筛选,筛选的标准是此对象是否需要执行 finalize() 方法。
如果对象没有实现 finalize() 方法或者 finalize() 方法已经被虚拟机调用,那么被判断为无须执行 finalize() 方法 —— 直接进行垃圾回收。
如果被判断为需要执行 finalize() 方法,那么这个对象被放在 F-Queue队列中。虚拟机会在稍后为这个F-Queue自动建立一个低优先级的线程,在这个线程中执行它们的 finalize() 方法。
- finalzie() 的三种调用情况:
- 显示地调用 finalize() 方法
- 在系统退出时为每个对象执行一次 finalize() 方法
- 发生 GC 时
- 这个执行只是尽力而为,它并不能保证运行 finalize() 方法。因为某些 finalize() 方法可能会导致死循环,而队列中的其他对象则一直处于等待状态。
- finalzie() 的三种调用情况:
- 可达性分析算法在发现一个对象没有与GC Roots的引用链时,该对象被第一次标记。随后进行一次筛选,筛选的标准是此对象是否需要执行 finalize() 方法。
- 第二次标记:
- 在经过一段时间后,垃圾收集器对F-Queue进行第二次小规模标记。如果对象在 finalize() 中与 GC Roots中的引用链相连,那么其会被移除”即将回收“的集合。否则对象将最终被回收。
- 对象自我拯救的演示代码:
/* finalize() 方法已经被抛弃,其存在的目的时帮助c++程序员从析构函数转到Java来。 但是 finalize() 方法的开销很大,不如使用try-finally快,已经被抛弃了。 */ public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK = null; public void isAlive() { System.out.println("yes, i am alive :)"); }@Override protected void finalize() throws Throwable { super.finalize(); System.out.println("finalize method executed"); // 执行 finalize() 方法后拯救自己 FinalizeEscapeGC.SAVE_HOOK = this; }public static void main(String[] args) throws Throwable { SAVE_HOOK = new FinalizeEscapeGC(); // 第一次拯救成功了 SAVE_HOOK = null; System.gc(); // finalize() 方法优先级较低,等待0.5s以执行 finalize() 方法 Thread.sleep(500); if(SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("no, i am dead :("); }// 下面的代码和上面的一摸一样 // 但是执行结果却是 no, i am dead :( SAVE_HOOK = null; System.gc(); Thread.sleep(500); if(SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("no, i am dead :("); } } }/* 运行结果: finalize method executed yes, i am alive :) no, i am dead :( */
- 第一次回收时,对象及时的逃脱了。
- 第二次回收时,因为对象已经执行过 finalize() 方法,所以不再执行 finalize() 方法,对象被回收。
- 第一次标记:
- 回收方法区
方法区垃圾回收是可选择实现的,如HotSpot虚拟机中的元空间或永久代就没有垃圾回收行为。
因为方法区的回收性价比较低 —— 堆中进行一次垃圾回收可以回收 70% 到 99% 的内存。
- 方法区的内存回收主要有两部分:
- 废弃的常量:和堆中的对象回收一样,当该常量不可达时被回收。
- 不再使用的类型:
- 当满足下述三个条件时,JVM被允许对无用类进行回收:
- 该类所有的实例(该类、派生子类)都已经被回收。
- 加载该类的类加载器已经被回收。(这个条件一般难以达到)
classLoader负责将 .class 文件加载到JVM中,并生成 java.lang.Class 的一个实例。
- 该类对应的 java.lang.Class 对象也是不可达。
- 该类所有的实例(该类、派生子类)都已经被回收。
- 当满足这三个条件是,JVM仅仅是被允许回收,而不一定会回收。
- 当满足下述三个条件时,JVM被允许对无用类进行回收:
- 废弃的常量:和堆中的对象回收一样,当该常量不可达时被回收。
- 方法区的内存回收主要有两部分:
部分收集 Partial GC : 目标不是完整收集整个Java堆的垃圾收集。
整堆收集 Full GC : 目标是整个Java堆和方法去的垃圾收集。
- 新生代收集 Minor GC / Young GC : 目标时新生代的垃圾收集。
- 老年代收集 Major GC / Old GC : 目标是老年代的垃圾收集。目前只有 CMS收集器会单独收集老年代。
- 混合收集 Mixed GC : 指目标是收集整个新生代和部分老年代的垃圾收集。目前只有G1收集器有这种行为。
- 分代收集理论:
当前商业虚拟机的垃圾收集器,大多都遵循了“分代收集” (Generational Collection) 的理论进行设计。
- 分代收集实质上是一套符合大多数程序运行实际情况的经验法则,而非理论。其建立在两个法则之上:
- 弱分代假说 (Weak Generational Hypothesis) —— 绝大多数对象都是朝生夕灭的。
- 强分代假说 (Strong Generational Hypothesis) —— 熬过多次垃圾收集收集过程的对象就越难以灭亡。
设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象根据其年龄 (年龄就是对象熬过垃圾回收的次数) 分配到不同的区域之中储存。Java堆划分为不同的区域之后,垃圾收集器每次可以只回收其中一部分区域。
- JVM设计者至少会将Java堆划分为 新生代(Young Generation) 和 老年代(Old Generation)。新生代和老年代的内存占比为 1 : 2。
新生代:每次都有大量对象”死去“,少量存活的对象被逐步晋升到老年代中存储。
- 简单划分内存区域所带来的问题:
- 对象不是孤立的,对象之间会存在跨代引用。如果需要发动一次局限在新生代内部的收集 (Minor GC),但是新生代中的对象被老年代所引用,为了找出该区域中的存活对象不得不遍历整个老年代。反过来也是如此。
- 由简单划分内存区域所带来的问题而添加了第三条法则:
- 跨代引用学说 (Intergenerational Reference Hypothesis) : 跨代引用相对于同代引用来说仅占少数。
- 很容易理解,存在互相引用关系的两个对象一般是倾向于同时生存活着同时灭亡的。如果某个新生代存在跨代引用,那么它很快会被移动到老年代中:
- 为了避免为了少量跨代引用去遍历整个老年代,只需要在新生代上建立一个全局的数据结构 (记忆集,Remebered Set) —— 记忆集中将老年代划分成若干小块,表示出老年代中哪一块会存在跨代引用,在发生Minor GC时将记录区域中的对象加入GC Roots中进行扫描。
- 跨代引用学说 (Intergenerational Reference Hypothesis) : 跨代引用相对于同代引用来说仅占少数。
- 分代收集实质上是一套符合大多数程序运行实际情况的经验法则,而非理论。其建立在两个法则之上:
- 标记-清除算法:标记出需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。(也可以标记不需要收集的对象)
- 最基础的垃圾收集算法,有两个主要的缺点:
- 执行效率不稳定,标记和清楚过程随着对象数量增长而降低。
- 内存空间的碎片化问题,标记清楚后会留下大量不连续的空间碎片,在分配大内存空间时无法找到足够的连续空间而触发内存回收。
- 最基础的垃圾收集算法,有两个主要的缺点:
- 标记-复制算法:(主要应用于新生代)
- 半区复制 (Semispace Copying) 垃圾收集算法:将内存区域划分为相等的两部分,每次只使用其中一块,进行垃圾回收时将不需要回收的对象移到另一半内存空间中,然后将原空间全部回收。
- 优点:由弱生代假说可得,大部分对象不需要复制而只需要回收。同时这种发发不需要考虑内存碎片化的问题。
- 缺点:每次只有一半的内存可见可用,且复制开销较大。
- Apple 式回收:
- 新生代:分为一块较大的Eden区域和两块较小的Survivor区域,每次分配内存只使用其中的Eden区域和一块Survivor区域,将其中任然存活的对象复制到另一块Survivor区域中,然后整个回收Eden区和原来的一块Survivor区域。
文章图片
- Eden区域和 Survivor区域的内存占比是 8 : 1 : 1。如果另一块Survivor区域放不下 Minor GC后任存活的对象时,需要借助其他区域 (老年区) 进行存储。
- 在堆中 new 了一个大家伙时,会发生以下几个步骤:
- 在 Eden 区域中尝试申请内存
- 内存不足时在 Eden 区域进行一次 Minor GC
- 如果还是放不下,在 Old 区域尝试申请内存
- 如果 Old 区内存也不够,则进行一次 Major GC
- 如果还是不够,抛出OOM
文章图片
- 在堆中 new 了一个大家伙时,会发生以下几个步骤:
- Eden区域和 Survivor区域的内存占比是 8 : 1 : 1。如果另一块Survivor区域放不下 Minor GC后任存活的对象时,需要借助其他区域 (老年区) 进行存储。
- 新生代:分为一块较大的Eden区域和两块较小的Survivor区域,每次分配内存只使用其中的Eden区域和一块Survivor区域,将其中任然存活的对象复制到另一块Survivor区域中,然后整个回收Eden区和原来的一块Survivor区域。
- 半区复制 (Semispace Copying) 垃圾收集算法:将内存区域划分为相等的两部分,每次只使用其中一块,进行垃圾回收时将不需要回收的对象移到另一半内存空间中,然后将原空间全部回收。
- 标记-整理算法 :(主要应用与老年代)
- 朴素版方法:在进行垃圾收集时,将存活的对象向内存空间的一端移动,然后清理掉边界外的内存。
- 优点:可以保证整个程序的吞吐量,避免了内存空间的碎片化
- 缺点:每次移动存活对象的开销较高,可能会增加延迟
- “融合”版方法:在大多数时间执行 标记-清除算法,在内存碎片化达到阈值时进行 标记-整理算法。
HotSpot虚拟机中关注吞吐量的 Parallel Old收集器是标记-整理算法的。
而关注延迟的CMS则采用了“融合”版方法。
- 朴素版方法:在进行垃圾收集时,将存活的对象向内存空间的一端移动,然后清理掉边界外的内存。
推荐阅读
- 龙蜥社区一周动态 | 3.14-3.18
- python|Python数据结构与算法(3.3)——队列
- 算法题——字符串3.19
- 22.3.18|22.3.18 新随笔
- 模拟赛|2022.3.13模拟赛总结
- G1垃圾回收器在并发场景调优
- Re:《Unity|Re:《Unity Shader入门精要》13.3全局雾效--如何从深度纹理重构世界坐标
- 随手记-2022.03.17
- 算法题-字符串3.17
- 环保减排绿色工业(数字孪生垃圾焚烧发电站)