【JVM】详解 垃圾回收

目录

如何判断对象是否存活?

引用计数算法

可达性分析算法

引用

[强引用(Strong Reference)](#强引用(Strong Reference))

[软引用(Soft Reference)](#软引用(Soft Reference))

[弱引用(Weak Reference)](#弱引用(Weak Reference))

[虚引用(Phantom Reference)](#虚引用(Phantom Reference))

finalize()

垃圾回收的区域

堆内存(Heap)

[方法区(Method Area)](#方法区(Method Area))

垃圾回收算法

标记-清除算法

标记-复制算法

Appel式回收

标记-整理算法

Hotspot的算法细节实现

根节点枚举

安全点

安全区域

记忆集与卡表

写屏障

并发的可达性分析


如何判断对象是否存活?

引用计数算法

基本原理:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一。

但单纯的引用计数算法无法解决循环引用的问题,必须配合大量额外的工作才能正常运行。因此Java并不使用引用计数算法。

可达性分析算法

Java是使用可达性分析算法来判断存活对象的。

基本原理 :通过一系列称为"GC Roots "的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为**"引用链"(ReferenceChain)**。

如果某个对象不可达GC Roots(即所有的引用链中都没链接这个对象),那么就证明这个对象没有被任何对象引用,是可以被回收的。

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
  • 根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象"临时性"地加入。

引用

强引用(Strong Reference)

  • 不会被回收
  • 造成内存泄露的主要原因之一

软引用(Soft Reference)

  • 内存溢出是进行回收,回收完如果内存还不够,第二次回收就会回收软引用的对象。第二次结束内存还是不够就会抛出OOM
  • 用于实现内存敏感的缓存
  • 可选引用队列

弱引用(Weak Reference)

  • 当垃圾收集器工作时就会被回收
  • 可以实现可有可无的缓存数据
  • 可选引用队列

虚引用(Phantom Reference)

  • 设置虚引用的对象被回收时会收到一个系统通知。
  • 不会对对象生存时间构成影响,也无法通过虚引用获得对象实例
  • 必须要引用队列

finalize()

垃圾回收垃圾对象之前,总会调用finalize()方法,用于在对象被回收前进行资源释放

不要主动调用finalize()

  • 可能导致对象复活
  • 执行时间没有保障,如果没有GC,finalize()也不会执行
  • 糟糕的finalize()严重影响GC性能

由于finalize()方法存在,对象一般处于三种可能的状态:

  • 可触及的:从根节点开始可以到达这个对象
  • 可复活的无序列表:对象所有音乐都被释放,有可能在finalize()中复活
  • 不可触及的:finalize()方法被调用且没有复活

判断是否可回收,经历两次标记过程:

  1. 到GC Root没有引用链,进行第一次标记

  2. 进行筛选:

  3. 如果finalize()没有被重写,或者已经被调用过,对象就会被视为不可触及的

  4. 如果重写了finalize()且没有被执行,对象会被插入到F-Queue队列中,由虚拟机自动创建的低优先级的 Finalizer线程 触发其finalize方法

  5. 稍后进行第二次标记,如果对象与引用链上任意一个对象建立了联系,对象就会被移除"即将回收"集合

垃圾回收的区域

堆内存(Heap)

堆是垃圾回收的核心区域,几乎所有对象都在这里分配内存,也是 GC 的主要目标。堆内存进一步分为:

新生代(Young Generation):用于存放新创建的对象,生命周期较短,回收频繁。又细分为:

  • Eden 区:新对象优先在 Eden 区分配(大对象可能直接进入老年代)。
  • Survivor 区:分为 From Survivor 和 To Survivor 两个区域,用于存放 Eden 区存活下来的对象,通过多次 GC 筛选后,存活较久的对象会进入老年代。

老年代(Old Generation/Tenured Generation):存放生命周期较长的对象(如经过多次新生代 GC 后仍存活的对象),回收频率较低,回收速度较慢。

分代收集时,引入了**记忆集(Remembered Set)**来解决跨域回收的问题,下文将会对记忆集进行详细讲解。

方法区(Method Area)

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。

如何判断常量不再被使用:

与Java堆中对 对象是否可以被回收的判断相似。

如何判断类不再被使用:

  • 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

垃圾回收算法

标记-清除算法

首先标记出来所有需要被回收的对象,标记完成后再统一回收。也可以反过来。标记和回收阶段都是STW的。

缺点

  • 执行效率不稳定,如果堆中有大量对象需要被回收,那么标记和回收的阶段会浪费大量时间,效率低。
  • 产生空间碎片,会产生大量不连续的空闲空间。当大对象想要分配时,如果没用足够大的空间,又会再次触发垃圾回收。

标记-复制算法

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。复制算法也是STW

缺点:

  • 虽然解决了空间碎片化的问题,但可利用的空间只有原本空间的一半
  • 老年代一般不能直接选用这种算法,因此可利用的空间太小

Appel式回收

由于新生代 "朝生夕灭" 的特点,因此并不需要按照1∶1的比例来划分新生代的内存空间。

Appel式回收 把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。

HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%)​

标记-整理算法

标记过程和标记-清除算法是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存 。两阶段操作也是STW

标记-清除算法和标记-整理对比

  • 移动则内存回收时会更复杂,不移动则内存分配时会更复杂。
  • 从延迟来看,标记-清除算法不需要移动对象,STW的时间更短,延迟更低。
  • 从吞吐量来看,标记-整理算法通过移动对象清理了空间碎片。但标记-清除算法因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。

Hotspot的算法细节实现

根节点枚举

虽然 GC Roots 的范围(全局性引用和执行上下文)是明确的,但在实际垃圾回收时,高效地找到这些 GC Roots 并不容易,尤其是在大型 Java 应用中,这个过程可能非常耗时。

那么如何高效定位GC Roots呢?

OopMap(Ordinary Object Pointer Map) 是垃圾回收(GC)机制中的一个关键数据结构,主要作用是记录堆中对象引用(OOP)在栈、寄存器等内存区域中的位置,帮助 GC 高效识别存活对象,避免全量扫描带来的性能开销。

安全点

在Java程序运行的过程,导致OopMap变化的指令非常多,如果每次一发出指令都要生成一个新的OopMap,那么空间的损耗是极大的。

因此需要**安全点(Safepoint)**的存在:只有程序运行到安全点才开始生成OopMap。也正因为此,Java程序无法随时随地就开始垃圾回收,必须到达安全点,有了对应的OopMap才能进行垃圾回收。

如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点?

抢先式中断 :当垃圾收集发生时,中断所有线程。如果有线程不在安全点,则恢复这个线程并使它运行到安全点。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。

主动式中断 :不直接对线程操作,而是设置一个全局的标记位。当线程执行到安全点时就主动轮询标记位,如果为真线程就主动挂起。轮询标志的地方和安全点是重合的。

如何选择安全点?

安全点位置的选取基本上是以"是否具有让程序长时间执行的特征"为标准进行选定的。

  • 长时间执行"的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
  • 所有创建对象和其他需要在Java堆上分配内存的地方也是安全点。这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

安全区域

阻塞的线程无法运行,又怎么才能到达安全点并识别标识的呢?对于这种情况,就必须引入**安全区域(Safe Region)**来解决。

在安全区域范围内的代码,引用关系是不会变化的。 因此在这个区域进行垃圾回收是安全的。阻塞的线程因为不会执行,所有引用关系是一定不会变的,因此就属于安全区域。然后虚拟机在垃圾回收的过程中就不关心处于安全区域的线程了。

当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段)​,如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

记忆集与卡表

为了解决跨域垃圾回收问题,JVM引入了记忆集(Remembered Set),记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

**卡表(Card Table)**就是记忆集的具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。

卡表最简单的形式可以只是一个字节数组,字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作"卡页"(Card Page)。只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。

写屏障

但卡表又该怎么维护呢?卡表是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?

如果是解释执行的代码,虚拟机负责字节码的处理,有充足的空间维护卡表。但如果是即时编译 的代码,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。

在HotSpot虚拟机里是通过写屏障(Write Barrier) 技术维护卡表状态的。可以将写屏障看为"引用类型对象赋值"的切面AOP 。在赋值前的部分的写屏障叫作写前屏障(Pre-WriteBarrier) ,在赋值后的则叫作写后屏障(Post-Write Barrier)

并发的可达性分析

当用户线程与垃圾回收器是并行工作的时候,就会产生问题。一种是把原本消亡的对象错误标记为存活,另一种是把原本存活的对象错误标记为已消亡。

解决方案

  • 增量更新 :当黑色对象插入新的指向白色对象的引用关系时就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。可以看作是写后屏障
  • 原始快照 :当灰色对象要删除指向白色对象的引用关系时就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。可以看作是写前屏障
相关推荐
重生之我要当java大帝4 小时前
java微服务-尚医通-管理平台前端搭建-医院设置管理-4
java·开发语言·前端
以己之4 小时前
详解TCP(详细版)
java·网络·tcp/ip
LiuYaoheng4 小时前
【Android】布局优化:include、merge、ViewStub的使用及注意事项
android·java
代码欢乐豆4 小时前
编译原理机测客观题(7)优化和代码生成练习题
数据结构·算法·编译原理
RealmElysia4 小时前
CoAlbum 引入ES
java·elasticsearch
せいしゅん青春之我4 小时前
[JavaEE初阶]网络协议-状态码
java·网络协议·http
shepherd1114 小时前
JDK源码深潜(一):从源码看透DelayQueue实现
java·后端·代码规范
天天摸鱼的java工程师4 小时前
SpringBoot + OAuth2 + Redis + MongoDB:八年 Java 开发教你做 “安全不泄露、权限不越界” 的 SaaS 多租户平台
java·后端
鹿里噜哩4 小时前
Nacos跨Group及Namespace发现服务
java·spring cloud