1. 絮絮叨叨
- 一般说GC,Garbage Collection,就是指垃圾收集或垃圾回收
- 回收的对象:程序运行中,不再使用的内存
- 这是一个很宽泛的概念,可能指整个垃圾回收的过程,也可能特指垃圾回收这个动作
- 垃圾收集器,Garbage Collector ,就是垃圾回收时使用的收集器,是对垃圾收集算法的具体实现
1.1 GC是把双刃剑
-
本科学习C语言、C++时,任课老师总是爱唠叨这样一句话:申请(allocate)了内存,使用完以后一定要记得释放(free)内存,不然很容易内存泄漏
-
在C++中,内存申请和释放的典型代码示例如下:
// 动态分配,数组长度为 m int *array=new int [m]; //释放内存 delete [] array;
-
然后学习Java时,任课老师自豪的说:Java和你们之前学习的C语言或C++都不同,它可以跨平台执行、可以自动进行GC。因此,Java使用起来更加简单,无需担心内存泄漏的问题
-
其实吧,一门编程语言是否支持GC,就像一座围城:城外的人想进去,城里的人想出来
- 城外的人,需要手动进行GC,增加了编程和运维的工作量
- 城里的人,无需手动进行GC。但自动化不是100%的智能化,总有内存泄漏的情况发生,且很难排查
-
可以说,GC就是一把双刃剑
-
我们既要充分利用Java自动GC带来的便利,又要了解Java GC的细节,方便出现内存泄漏或内存溢出时,进行问题的排查
- 1960年,诞生于MIT的Lisp是第一门真正使用内存动态分配计数和垃圾收集技术的语言
1.2 GC的几件大事
- 想要学习或掌握GC的理论知识,本人觉得首先得掌握GC的几件大事
- where:JVM的几大内存区域,哪些内存区域(运行时数据区)需要回收?
- what:回收内存中的哪些数据?
- when:什么时候进行回收?(Under what circumstances?)
- how:如何进行回收?
2. where & what ?
2.1 where:Java堆和方法区
- JVM中的几大内存区域,程序计数器、VM栈、本地方法栈属于线程私有,随线程而生、随线程而灭
- 上述三个内存区域,当方法或线程结束时,占用的内存自然就回收了。因此,无需考虑GC的问题
- Java堆和方法区属于线程共享的内存区域,其内存的分配和回收都是动态,需要依靠GC进行内存回收
2.2 what ?
- 根据之前的学习
- 对象实例(几乎所有的)和数组都在堆上分配内存,其实数组也是一种Java对象
- JDK 1.7之前,方法区使用永久代实现,包含了类信息、常量、静态变量和codeCache
- JDK 1.7时,字符串常量池和静态变量移动到了Java堆
- JDK 1.8开始,元空间替换了永久代
- 因此,笼统的说,GC的目标应该是Java堆中的对象,方法区中的常量和类
3. when:何时被回收?
- 注意:
- 这里的何时被回收,是指Java堆和方法区中的GC目标,在什么情况下可以被回收?
Under what circumstances can instance be gc?
- 并非指什么时候触发JVM的GC机制,导致非活动的GC目标被回收
- 这里的何时被回收,是指Java堆和方法区中的GC目标,在什么情况下可以被回收?
3.1 回收Java堆
- Java堆中,不再被使用的对象应该被回收
- 垃圾收集器在进行GC前,首先需要判断哪些对象还活着,哪些对象已经死去
3.1.1 判断方法一:引用计数法
- Reference Count(引用计数法)的原理:
- 给对象添加一个引用计数器,有一个地方引用它,计数器的值加1;当引用失效时,计数器的值减1
- 这是教科书级别的、判断对象是否能被回收的方法,最大的问题:无法解决对象间循环引用的问题
循环引用的代码示例
-
下面的代码中,对象A引用了对象B,对象B引用了对象A
-
虽然断开了对象A和B的外部强引用,但内部隐藏的循环引用,使其无法被垃圾回收
public class Student { private Student partner; public static void main(String[] args) { Student studentA = new Student(); Student studentB = new Student(); // 更新学生对象的partner studentA.setPartner(studentB); studentB.setPartner(studentA); // 断开外部强引用 studentA = null; studentB = null; } public void setPartner(Student partner) { this.partner = partner; } }
-
示意图如下,引用名和图中的引用名存在差异,能看懂就行😂
JVM不使用引用计数法
- 很多出名的编程语言或脚本,如Python、FlashPlayer等使用引用计数法进行内存管理
- 但是,主流的JVM并未使用引用计数法来判断对象是否可以被回收
3.1.2 判断方法二:可达性分析算法
- Java、C#,甚至古老的Lisp,它们的主流实现都是通过可达性分析算法来判断对象是否可以被回收
- 可达性分析算法原理:
- 通过一系列被称作GC Roots的对象作为起始点,开始向下搜索
- 如果存在从GC Roots到当前对象的一条路径(引用链,Reference Chain),则说明对象是可达的
- 那些不可达的对象,则被判定为是可回收的对象
- 下图中,绿色云表示GC Roots,蓝色的圆圈表示可达的对象,灰色的圆圈表示不可达的对象
- 相对引用计数算法,最右边的几个对象,虽然形成了循环引用,即使不存在GC Roots也能被顺利回收
Java中的GC Roots
- VM栈中(局部变量表)引用的对象
- 本地方法栈中JNI(Java Native Interface)引用的对象
- 方法区常量引用的对象
- 方法区静态变量引用的对象
3.1.3 不可达对象一定会被回收吗?
- 还记的那些年一起看过的面试八股文,经常要求说final、finally、finalize的区别
- 自己就只记得finalize是所有Java对象都有的一个方法,在垃圾回收时会被调用
- 甚至,一度把finalize方法当做了C++的析构函数,认为可以在finalize方法中进行资源的回收
奇怪的现象:finalize()方法已经执行了,为何对象还能访问?
-
下面的代码,新建对象赋值给静态变量,断开静态变量与对象的强引用。
-
GC时,会调用对象的finalize()方法并被垃圾回收
-
之后,再判断静态变量时,该静态变量应该为
null
public class FinalizeTest { private static FinalizeTest HOOK = null; public void isAlive() { System.out.println(this + "is alive!"); } @Override protected void finalize() throws Throwable { super.finalize(); // 再次加入引用链 HOOK = this; System.out.println("finalize() method executed"); } public static void main(String[] args) throws InterruptedException { // 新建对象,加入引用链 HOOK = new FinalizeTest(); HOOK.isAlive(); // 断开引用链,主动触发gc,休眠一定时间后,看对象是否存活 HOOK = null; System.gc(); TimeUnit.SECONDS.sleep(1); if (HOOK == null) { System.out.println("HOOK指向的对象已经被回收"); } else { HOOK.isAlive(); } } }
-
执行结果,出乎意料:静态变量竟然还指向之前的对象,且该对象未被垃圾回收
-
原因: finalize()方法中,再次将该对象赋值给静态变量,使其重新加入了引用链,从而未被垃圾回收
新问题:是否能不断自救,使得对象永远不被GC?
-
感觉这是一个bug啊,我要是是一个恶搞的程序员,我就在finalize()方法里面复活对象,最后内存泄漏 😂
-
将main方法的最后添加如下代码:期待恶作剧的发生
// 再次断开引用链,主动触发gc HOOK = null; System.gc(); TimeUnit.SECONDS.sleep(1); if (HOOK == null) { System.out.println("HOOK指向的对象已经被回收"); } else { HOOK.isAlive(); }
-
执行结果如下:竟然没有再次执行finalize方法,也就导致对象没有自救成功
-
原因: 一个对象的finalize()方法只能被JVM调用一次,自救也只能自救一次
finalize方法的执行过程
-
对象在被垃圾回收前,需要进行对象是否存活的标记
-
第一次标记,发现对象已经不可达,会进行一次筛选:
- 根据对象是否重写finalize()方法以及对象的finalize()方法是否已经被调用过,来决定是否执行对象的finalize()方法
- 如果未重写finalize()方法或finalize()方法已经被调用过,则认为没必要执行finalize()方法,可以直接进行垃圾回收。(这也是为什么第二次gc时,对象没有成功自救的原因)
-
如果需要执行finalize()方法,则对象会被放入一个叫做F-Queue的队列中
-
一个由JVM自己创建的、低优先级的Finalizer线程会消费F-Queue队列中的对象:触发对象的finalize()方法,但不等待finalize()方法执行结束
-
稍后,会对F-Queue队列中的对象进行第二次标记:如果发现对象又关联上了引用链,则可以逃脱此次GC;否则,将被垃圾回收
-
逃脱GC的方法: 在finalize()方法中,将对象重新加入引用链
总结
- 对象的finalize()方法只会被JVM自动调用一次,对象逃脱GC并自救的机会也只有一次
- finalize()方法的运行代价高、不确定性大,无法保证对象的调用顺序
- 同时,finalize()方法执行时抛出的异常会被JVM忽略,不利于错误的排查
- 因此,不建议主动调用对象的finalize()方法,或在对象的finalize()方法中进行资源回收等操作
3.2 方法区的回收
- 方法区的垃圾回收,主要是常量的回收和类的卸载
- 常量的回收与Java堆中对象的回收非常类似,不再赘述
- 类的卸载是一件需要慎重考虑的事情,要求同时满足下面三个条件才能才能被视为无用的类
- 类的所有实例已经被回收(Java堆中不存在该类的实例)
- 加载该类的ClassLoader已经被回收
- 该类对应的Class对象未在任何地方被使用(这样就不存在反射访问类的情况)
类卸载的必要性
- 大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi(动态组件)等频繁自定义ClassLoader的场景
- 都需要JVM具备类卸载的能力,以避免方法区内存溢出
4. how?(如何进行垃圾回收)
4.1 对象内存分配
-
根据之前对单例模式的学习,了解到
Singleleton instance = new Singleleton()
需要三步memory = allocate(); //1:分配对象的内存空间 ctorInstance(memory); //2:初始化对象 instance = memory; //3:设置instance指向刚分配的内存地址
-
第一步就需要为对象在堆中内存,那么JVM是如何为对象分配的内存的呢?
4.1.1 指针碰撞
- 如果Java堆中的内存是规整,可以设置一个指针作为已使用内存和空闲内存的分界
- 新的内存分配申请来了以后,只需要将指针向空闲内存移动一段距离即可,这段距离就是对象所需的内存大小
- 这样的内存分配方式,叫做指针碰撞(Bump the Pointer或Pointer Bumping)
- 其实,叫做指针移动好像更容易理解 😂
4.1.2 空闲列表
- 如果Java堆中的内存不是规整的,空闲内存东一块、西一块的(内存碎片)
- 要想使用上空闲内存,首先得有一个表去记录这些空闲的内存。
- 内存分配申请来了以后,遍历列表,找到一块具有足够空间的空闲内存换分给对象实例
- 同时,根据实际使用情况更新列表:
- 可能这块空闲内存全部被使用,可以从表中移除;
- 可能这块空闲内存还有剩余,则需要更新表中可用内存的大小
- 通过空闲列表管理并分配内存碎片的方式,被叫做空闲列表(Free List)
4.1.3 如何保证内存分配的线程安全问题
- 无论是空闲列表还是指针碰撞,都涉及线程安全的问题
- 以指针碰撞为例,并发情况下,为对象A分配内存时,指针还未来得及移动,对象B的内存分配申请来了
- 这时,会在原来的指针位置,开始为对象B分配内存
如何解决线程安全问题?
- 方法一: 使用CAS + 失败重试的方式(自旋),保证内存分配操作的原子性
- 方法二: 每个线程都有一块属于自己的内存空间,叫做TLAB(Thread Local Allocation Buffer)
- 对象的内存分配在当前线程的TLAB中进行,不存在线程安全的问题
- 只有当为线程分配新的TLAB时,才需要进行同步锁定
4.2 垃圾收集算法
- 对象的内存分配,与垃圾收集算法息息相关
- 因为,垃圾收集后,可能会出现内存碎片,也可能出现一块完整区域
4.2.1 标记 — 清除
- 标记 — 清除算法(Mark And Sweep),是最基础的垃圾收集算法
- 分为标记和清除两个阶段:
- 通过可达性分析算法,标记出所有不可达对象,也就是可回收的对象
- 统一回收标记出的不可达对象,这些对象占用的内存空间将被认为是空闲内存
- 标记 — 清除算法之所以说是最基础的垃圾收集算法,主要有两个原因:
- 一是效率问题:标记和清除两个过程的效率都不高
- 二是空间问题:标记清除后将产生大量不连续的内存碎片。
- 如果内存碎片较多,当需要为程序中的较大对象分配内存时,总的空闲内存明显是足够的
- 但因为无法找到一块足够大的内存碎片,而不得不提前触发垃圾回收
- 甚至,在经历系列复杂的过程后,仍然无法找到一块足够大的内存,从而导致内存分配失败并抛出OOM异常
- 使用标记 — 清除算法回收的内存空间,适合使用空闲列表的方式进行内存分配
4.2.2 标记 — 整理
- 针对标记 — 清除算法存在的问题,不难想到一个更好的方法:将存活的对象移动到内存某一端,然后清理边界以外的内存空间
- 上述算法,叫做标记 — 整理算法(Mark and Compact)
- 对于整理的理解:整合 + 清理
- 整合存活对象:将存活对象移动到内存的一端
- 清理存活对象之外的内存(边界以外的内存)
- 标记 — 整理算法的优缺点
- 优点:相对标记 — 清除算法,解决了内存碎片的问题
- 缺点:同标记 — 清除算法一样,标记和整理两个过程的效率不高
- 使用标记 — 整理算法回收的内存空间,适合使用指针碰撞的方式进行内存分配
4.2.3 复制
- 标记 — 整理算法之所以效率不高,是因为标记和整理两个过程不能同时进行
- 如果能边标记、边整理,效率将会提高很多
- 复制算法,又称标记 —复制算法,Copying或Mark and Copy,应运而生
- 复制算法将内存一分为二,每次只使用其中的一块内存(为了方便描述,被使用的内存叫做active内存,未使用的内存叫做unactive内存)
- 当active内存耗尽时,将存活的对象复制到另一块unactive内存,然后清理active内存
- 最后,unactive内存成为新的active内存,对外提供内存分配服务
- 复制算法的优缺点
- 优点:标记和复制可以同时进行,效率较高
- 缺点:只使用了内存空间的一半,内存利用非常低
- 使用复制算法回收的内存空间,适合使用指针碰撞的方式进行内存分配
4.2.4 分代收集(对前三种算法的合理使用)
- 当前的垃圾收集都采用分代收集算法(Generational Collection),根据对象生命周期的不同,将内存区域划分成几块
- 根据每个块中对象的存活特点,采用前三种算法中最适合的收集算法,以提高GC的性能
- Hot Spot虚拟机,根据对象生命周期的特点,将Java堆分为新生代(
Young Generation
)和老年代(Old Generation
或Tenured) - 在JDK 1.7及以前的JDK版本中,基于永久代(
Permanent Generation
)的方法区实现,也是为了使用分代收集算法
4.2.4.1 新生代的收集算法
- 新生代中,每次垃圾回收只有少量对象存活,可以使用回收效率较高的复制算法
- 复制算法具有一个致命缺点:只有50%内存利用率的问题
- 根据新生代中98%的对象都是 “朝生夕死” 的特点,将新生代分成一个较大Eden区、两个较小的Survivor区
- 每次使用Eden区和其中一块Survivor区(S0)
- 垃圾回收时,将Eden区和S0中存活的对象,全部复制到另一块Survivor区(S1)
- 然后,回收Eden和S0中的内存空间
- 最后,Eden区和S1成为可使用的内存空间,之前的S0成为保留的内存空间
- Eden区和Survivor区的默认大小比例为8 : 1,也就是说每次只有10%的内存被 “浪费”
新的问题:如果最后存活的对象超过10%,怎么办?
- 由于无法保证每次存活的对象不超过10%,很容易导致保留的Survivor区不够用
- 这时,需要依赖其他内存(也就是老年代)进行分配担保(Handle Promotion)
- 所谓分担保,就跟银行贷款一样,在信誉良好的情况下,银行愿意贷款给我们。
- 但是,从风险管控的角度,银行还需要有一个贷款担保人。
- 在我们因为某些情况无法偿还贷款时,从担保人的账户扣钱
- 垃圾回收时,会Eden区和S0区中存活的对象复制到S1。如果S1的空间不够,存活的对象将直接通过分配担保机制进入老年代
4.2.4.2 老年代的收集算法
- 老年代中对象存活率较高,也没有额外的空间进行分配担保,因此使用标记 — 清除或标记 — 整理算法
- 除CMS收集器使用标记 — 清除算法外,其他的老年代垃圾收集器,都是标记 — 整理算法
参考链接:
- 结合空闲列表和指针碰撞讲解GC算法:深入理解 JVM 垃圾回收机制及其实现原理
4.3 Hot Spot如何发起GC?
4.3.1 OopMap帮助枚举GC Roots
- 在学习可达性分析算法时,我们了解到VM栈、本地方法栈中引用的对象,方法区中的静态变量、常量引用的对象,都是GC Roots
- 不管使用哪种GC算法,初始时,都需要标记对象是否可达,即需要通过GC Roots追踪整个引用链
- 以VM栈为例,需要对栈进行全局扫描以发现栈帧中的引用(Reference类型的数据)
- 全局扫描效率低下: 栈帧中只有一部分是Reference类型的数据(简称引用),其他类型的数据是无需关注的
OopMap:以空间换时间,实现GC Roots的快速枚举
- 很多经典的算法或系统实现,为了避免全局扫描,都会使用类似索引的数据结构,以空间换时间
- 受此启发,也可以为栈帧中的引用建立一个映射表,记录引用的位置
- 这样的映射表,叫做OopMap
- Oop是普通对象指针(Ordinary Object Pointer),也就是Java中对象的引用
- Map表示地址映射,即引用在栈帧中的位置
- 通过OopMap可以快速定位栈帧中的引用,然后通过引用中记录的对象内存地址,快速找到GC Roots
总结:
- OopMap是Hot Spot准确式GC的实现,可以避免全局扫描执行上下文和全局引用
执行上下文:VM栈和本地方法栈;全局引用:方法区中常量和静态变量
- OopMap作为记录引用地址映射的数据结构,以空间换时间,可以帮助Hot Spot快速且准确地完成GC Roots的枚举
4.3.2 安全点
GC停顿(STW)
- 通过GC Roots追踪对象是否可达时,如果程序仍旧在运行,对象的引用关系也会不断地发生变化,这样分析出来的结果准确性就无法得到保证
- 因此,在标记对象是否可达时,必须停顿所有的Java执行线程,即Stop the World,STW
- 众多的垃圾收集器中,即使是号称几乎不停顿的CMS收集器,在初始标记和重新标记时,也需要STW
安全点 —— 何时停顿?
- 安全点(Safe Point),程序可以停顿的位置
- 程序不可能在任何地方都能停顿下来进入GC,只有在到达安全点时才能停顿
何时更新OopMap?
- OopMap中,记录了引用的地址
- 问题来了:很多指令的执行都可能使引用的地址发生变化,如果每执行一条指令,就更新一次OopMap,这样成本将会非常高
- 因此,理想的情况是:只在特殊的位置更新OopMap
- 这个特殊的位置就是安全点:在程序停顿下来、进入GC前,更新OopMap就可以帮助JVM快速且准确地枚举GC Roots
如何设置安全点?
- 安全点设置得太少,则程序长时间无法进入GC,发生OOM的概率将会增大
- 安全点设置得过多,程序运行压力将变大
- 安全点的选择标准:是否具有让程序长时间执行的特征
- 方法调用、循环跳转、异常跳转等功能的指令,将使得指令序列复用
- 因此,这些指令具有长时间执行的特征,可以在这样的指令处设置安全点
GC发生时,如何让所有线程都运行至安全点处停顿?
- 方法一:抢占式中断(Preemptive Suspension)
- GC发生时,把所有的线程都中断
- 如果线程中断的地方不是安全点,则恢复线程,让它运行至安全点再停顿
- 注意: 几乎没有VM使用抢占式中断来暂停线程以响应GC事件
- 方法二:主动式中断(Voluntary Suspension)
- 当GC需要中断线程时,不直接对线程进行操作,而是设置中断标志
- 当线程运行到需要轮询中断标志的地方时,主动轮询中断标志;如果发现中断标志为true,则将自己中断挂起
- 轮询中断标志的地方:安全点、创建对象需要分配内存的地方
总结:
- 安全点是程序可以停顿的位置,GC发生时,线程需要在安全点停顿
- 同时,安全点是可以更新OopMap的位置
- 安全点的设置不能太多,也不能太少,选择标准:是否具有让程序长时间执行的特征
- GC时,可以通过抢占式中断或主动式中断,保证程序中所有线程都在安全点停顿
4.3.3 安全域
- 安全点的选择机制保证程序时,经过不太长的时间就会遇到可以进入GC的安全点
- 如果线程处于Sleep或Blocked状态,因为没有分配CPU时间片,无法响应JVM的中断请求,也无法执行到安全点去挂起自身
使用安全域解决上述问题
- 安全域(Safe Region):是一段代码片段,在这段代码片段中,引用关系不会发生变化。
- 因此,在安全域的任意位置开始GC都是安全的
- 线程执行到安全域的代码时,首先标记自己进入了安全域
- 线程退出安全域时,需要检查系统是否已经完成了GC Roots的枚举(或者是整个GC过程)
- 如果已经完成,则可以退出安全域,继续执行后续代码;否则,需要等待直到收到可以退出的信号为止
参考链接:
- 对OopMap、安全点和安全域的全面讲解:Java虚拟机如何快速找到GC Roots?又是如何中断线程?
- 比较认同的理解:我对OopMap,安全点,安全区域的理解
后续内容还有很多,可以继续查看博客:JVM的垃圾回收(二)