【深入理解JVM】垃圾回收相关概念与相关算法
相关概念
System.gc()
显示触发FullGC,但不是立刻调用,并不确定调用的时间
内存溢出(OOM)
没有空闲内存,垃圾回收器也无法提供更多内存时,就会报出OOM 错误
内存泄漏(Memory Leak)
对象不会再被程序再次使用,但是GC不能回收他们的情况,就是内存泄漏
举例:
- 单例模式中持有对外部对象的引用,因为单例对象的生命周期和应用程序一样长,所以外部对象不能被回收
- 提供close()接口的资源未关闭,导致资源一直开启无法被回收
Stop The World
分析工作必须在一个能确保一致性的快照中进行,所以要进行STW
在 JVM 中,STW 指的是所有 Java 线程都被暂停执行,只有 JVM 自己的后台线程(如 GC 线程)在工作的状态。
垃圾回收的并行与并发
并行:
- 多条垃圾收集线程并行工作,但此时用户线程处于等待状态
串行:
- 单线程执行
- 如果内存不够,则程序暂停,启动JVM垃圾回收器GC,回收完在启动程序的线程
安全点与安全区域
只有在特定的位置才能听来下GC,这个点就是安全点
选择一些执行时间较长的程序作为safe point,如方法调用,循环跳转,异常跳转
在GC发生时,如何检查所有线程都到安全点停顿下来?
线程响应停顿请求并进入安全点
- 运行中线程:正在执行字节码的线程会在到达 “安全点”(如方法调用、循环跳转、异常抛出等预设位置)时,检测到全局停顿标志,随后暂停执行并进入安全点状态。
- 阻塞 / 等待线程:处于
sleep
、wait
或阻塞状态的线程,由于未执行字节码,默认已处于 “安全区域”(Safe Region),无需额外操作即可被视为已进入安全点。 - native 方法线程:若线程正在执行本地方法(非 Java 字节码),JVM 会等待其返回 Java 代码后,在下次安全点位置停顿。
检查所有线程是否已进入安全点
- 线程状态遍历:JVM 维护一个线程列表(如
Threads_list
),GC 线程会遍历列表,检查每个线程的状态标志(如_at_safepoint
)。 - 等待机制:若存在未进入安全点的线程(如仍在执行字节码的线程),GC 线程会进入等待状态,直到所有线程的
_at_safepoint
标志被置位。 - 超时处理:极少数情况下,若线程长期无法到达安全点(如死循环且无安全点),JVM 可能会强制中断(具体行为依赖 JVM 实现)。
引用
强引用(Strong Reference)
- 不会被回收
- 造成内存泄露的主要原因之一
软引用(Soft Reference)
- 内存溢出是进行回收,回收完如果内存还不够,第二次回收就会回收软引用的对象。第二次结束内存还是不够就会抛出OOM
- 用于实现内存敏感的缓存
- 可选引用队列
弱引用(Weak Reference)
- 当垃圾收集器工作时就会被回收
- 可以实现可有可无的缓存数据
- 可选引用队列
虚引用(Phantom Reference)
- 设置虚引用的对象被回收时会收到一个系统通知。
- 不会对对象生存时间构成影响,也无法通过虚引用获得对象实例
- 必须要引用队列
相关算法
垃圾标记阶段
引用计数算法
对每个对象保存一个整形的引用计数器属性,用于记录对象被引用的情况
- 需要单独的字段存储计数器,增加了存储空间的开销
- 每次赋值都要更新计数器,增加了时间开销
- 无法处理循环引用
可达性分析算法(根搜索算法,追踪性垃圾收集)
- Java使用
- 以跟对象集合为起始点,按照从上至下的方式搜索被根对象集合(GC Roots)所链接的目标对象是否可达(根对象集合就是一组必须活跃的引用)
- 搜所经过的路径是引用链
GC Roots的元素:
- 虚拟机栈中引用的对象
- 本地方法栈中引用的对象
- 方法区类静态属性引用的对象
- 方法区常量引用的对象
- 被synchronized持有的对象
- JVM内部的引用
- 还有其他对象临时加入,比如分代收集和局部回收
可达性分析算法必须在能保证一致性的快照中进行,枚举根节点时是必须停顿(STW)的
finalization机制
垃圾回收垃圾对象之前,总会调用finalize()方法,用于在对象被回收前进行资源释放
不要主动调用finalize():
- 可能导致对象复活
- 执行时间没有保障,如果没有GC,finalize()也不会执行
- 糟糕的finalize()严重影响GC性能
由于finalize()方法存在,对象一般处于三种可能的状态:
- 可触及的:从根节点开始可以到达这个对象
- 可复活的:对象所有音乐都被释放,有可能在finalize()中复活
- 不可触及的:finalize()方法被调用且没有复活
判断是否可回收,经历两次标记过程:
- 到GC Root没有引用链,进行第一次标记
- 进行筛选:
1. 如果finalize()没有被重写,或者已经被调用过,对象就会被视为不可触及的
2. 如果重写了finalize()且没有被执行,对象会被插入到F-Queue队列中,由虚拟机自动创建的低优先级的 Finalizer线程触发其finalize方法
3. 稍后进行第二次标记,如果对象与引用链上任意一个对象建立了联系,对象就会被移除”即将回收“+集合
垃圾清除阶段
标记-清除算法(Mark-Sweep)
当有效内存空间被耗尽的时候,STW期间进行两项工作:标记、清除
标记:从根节点开始遍历,标记可达的对象,在对象的Header中记录
清除:对堆内存从头到尾进行线性的遍历,回收对象Header中没有被标记的对象(把需要清除的对象的地址放在空闲列表之中)
缺点:
- 效率不高
- 进行GC时STW
- 清理出来的空间内存不连续,需要维护一个空闲列表
复制算法(Copying)
将内存空间分为两块,垃圾回收时将存活的对象复制到未使用的内存块中,之后清除当前使用的内存块中的所有对象,然后交换两个内存块的角色
- 空间换时间
- 保证空间连续性
- 是复制而不是移动,GC需要维护对象之间的引用关系。内存占用、时间开销较大
标记-压缩算法(Mark-Compact)
基于老年代回收,标记-清除算法效率低下,内存回收后还会产生内存碎片。复制算法因为老年代大部分对象都是存活对象,复制成本过高。因此需要其他算法
第一阶段与标记-清除算法一样
第二阶段将所有的存活对象压缩到内存的一段,按顺序排放
之后清除边界外所有的空间
- 效率低于复制算法,最慢
- 如果移动的对象被其他对象引用,还需要调整引用的地址
- 移动过程STW
分代收集算法
不同生命周期对象可以采取不同的收集方式,以便提高回收效率
年轻代:区域较小,对象生命周期短、存活率低,回收频繁——使用复制算法
老年代:区域较大,对象生命周期长、存活率高,回收不频繁——标记-清除 或者 标记-清除与标记-压缩混合实现
- Mark阶段开销与存活对象数量成正比
- Sweep阶段开销与管理区域大小成正比
- Compact阶段开销与存活对象的数量成正比
增量收集算法
在STW状态下,如果垃圾回收时间过长,严重影响用户体验和系统的稳定性
基本思想:
垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。反复至垃圾收集完成
缺点:
线程切换和上下文的转换会使垃圾回收的总体成本上升,造成系统吞吐量的下降
分区算法
- 将整个堆空间划分为连续的不同小区间。
- 每一个小区间独立使用,独立回收
- 每次合理地回收若干小区间