JVM系列之:你真的了解垃圾回收吗(一)

简介: JVM系列之:你真的了解垃圾回收吗(一)

1.jpg

本文为《深入学习 JVM 系列》第十一篇文章

文章更新履历:

20220317:补充了垃圾回收的内容,通过 finalize()复活对象增加了一个代码示例,补充完善了垃圾收集算法


Java 虚拟机的自动内存管理,将原本需要由开发人员手动回收的内存,交给垃圾回收器来自动回收。因为是自动机制,我们平时不会直接接触,但还是有必要了解与垃圾回收实现相关的问题。下文先从基础开始学习垃圾回收。


垃圾回收的目的


垃圾回收的目的是回收堆内存中不再使用的对象所占的内存,释放资源。


垃圾回收的时间


回收时间:即触发 GC 的时间,在新生代的 Eden 区满了,会触发新生代 GC(Minor GC),经过多次触发新生代 GC 存活下来的对象就会升级到老年代,升级到老年代的对象所需的内存大于老年代剩余的内存,则会触发老年代 GC(Full GC),或者小于时被 HandlePromotionFailure 参数强制 Full GC。当程序调用 System.gc()时也会触发 Full GC。


垃圾回收的内容


垃圾回收的重点就是关注堆和方法区中的内存了,堆中的回收主要是对象的回收,方法区的回收主要是废弃常量和无用的类的回收。

回收内容:方法区中无用的类和废弃常量池(运行时常量池)、堆中判定为死亡的对象。


JVM 的永久代中会发生垃圾回收么?(如何判断一个类是无用的类?)



方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。因此方法区也被人们称为永久代。


永久代的垃圾回收主要包括类型的卸载和废弃常量池(运行时常量池)的回收。当没有对象引用一个常量的时候,该常量即可以被回收。而类型的卸载更加复杂。必须满足以下三点:


  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。


  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。


虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。


如果对象的引用被置为null,垃圾收集器是否会立即释放对象占用的内存?


不会立即释放对象占用的内存。 如果对象的引用被置为 null,只是断开了当前线程栈帧中对该对象的引用关系,而垃圾收集器是运行在后台的线程,只有当用户线程运行到**安全点(safe point)或者安全区域(safe region)**才会扫描对象引用关系,扫描到对象没有被引用则会标记对象,这时候仍然不会立即释放该对象内存,因为有些对象是可恢复的(在 finalize 方法中恢复引用 )。只有确定了对象无法恢复引用的时候才会清除对象内存。


那么如何判定对象是否死亡呢?


如何判定对象是否死亡


关于判断对象是否存活有两种方式。引用计数法可达性分析


很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。上述说法是不正确的,单纯的引用计数很难解决对象之间相互循环引用的问题,如下述案例所示:


2.jpg


上述代码的结果显示内存被回收了,意味着虚拟机并没有因为这两个对象互相引用就放弃回收它们,这也从侧面说明了 Java 虚拟机并不是通过引用计数算法来判断对象是否存活的。


当前 Java 通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。


3.jpg


即使在可达性分析算法中判定为不可达的对象,也不是“ 非死不可”的,这时候它们暂时还处于“ 缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:


  1. 如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记;
  2. 随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize()方法。假如对象没有覆盖 finalize()方法,或者 finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“ 没有必要执行”。如果这个对象被判定为有必要执行 finalize()方法,执行结束后仍然没有复活的对象,则该认为该对象死亡。


这里我们通过一个案例来演示对象复活的情形:


public class CanReliveObj {
  public static CanReliveObj obj;
  @Override
  protected void finalize() throws Throwable {
    super.finalize();
    System.out.println("CanReliveObj finalize called");
    System.out.println("obj 被复活了");
    obj = this;
  }
  @Override
  public String toString() {
    return "CanReliveObj";
  }
  public static void main(String[] args) throws InterruptedException {
    obj = new CanReliveObj();
    System.out.println("第一次gc");
    obj = null;
    System.gc();
    Thread.sleep(1000);
    if(obj == null){
      System.out.println("obj为null");
    }else{
      System.out.println("obj不为null");
    }
    System.out.println("第二次gc");
    obj = null;
    System.gc();
    Thread.sleep(1000);
    if(obj == null){
      System.out.println("obj为null");
    }else{
      System.out.println("obj不为null");
    }
  }
}
复制代码


执行结果为:


第一次gc
CanReliveObj finalize called
obj 被复活了
obj不为null
第二次gc
obj为null
复制代码


可以看到,第一次 GC 后,obj 对象被复活了。虽然系统中 obj 的引用已经被清除了,但是在 finalize 方法中,对象的 this 引用被传入到方法内部,如果引用外泄,对象就会复活。当然 finalize 方法只会被调用一次,所以第二次 GC 时 obj 对象就无法被复活了。


一般而言,GC Roots 包括(但不限于)如下几种:


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


除了这些固定的 GC Roots 集合以外, 根据用户所选用的垃圾收集器以及当前回收的内存区域不同, 还可以有其他对象“临时性”地加入, 共同构成完整 GC Roots 集合。

虽然可达性分析的算法本身很简明,但是在实践中还是有不少其他问题需要解决的。


比如说,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。误报并没有什么伤害,Java 虚拟机至多损失了部分垃圾回收的机会。漏报则比较麻烦,因为垃圾回收器可能回收事实上仍被引用的对象内存。一旦从原引用访问已经被回收了的对象,则很有可能会直接导致 Java 虚拟机崩溃。

还比如说如何快速找到 GC Roots。


枚举GC Roots


固定可作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性) 与执行上下文(例如栈帧中的本地变量表) 中, 尽管目标明确, 但查找过程要做到高效并非一件容易的事情, 现在Java应用越做越庞大, 光是方法区的大小就常有数百上千兆, 里面的类、 常量等更是不计其数, 若要逐个检查以这里为起源的引用肯定得消耗不少时间。


现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发 ,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上, 不会出现分析过程中, 根节点集合的对象引用关系还在不断变化的情况, 若这点不能满足的话, 分析结果准确性也就无法保证。


目前 HotSpot 虚拟机使用的都是准确式垃圾收集(HotSpot 基于准确式内存管理,准确式内存管理是指虚拟机可以知道内存中某个位置的数据具体是什么类型) , 所以当用户线程停顿下来之后, 其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置, 虚拟机应当是有办法直接得到哪些地方存放着对象引用的。


在 HotSpot 的解决方案里, 是使用一组称为 OopMap 的数据结构来达到这个目的。 一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来, 在即时编译过程中, 也会在特定的位置记录下栈里和寄存器里哪些位置是引用。 这样收集器在扫描时就可以快速通过 OopMap 找到 GC Roots


安全点SafePoint


每个被 JIT 编译过后的方法也会在一些特定的位置记录下 OopMap,记录了执行到该方法的某条指令的时候,栈上和寄存器里哪些位置是引用。这样 GC 在扫描栈的时候就会查询这些 OopMap 就知道哪里是引用了。这些特定的位置主要在:


1、循环的末尾

2、方法临返回前 / 调用方法的call指令后

3、可能抛异常的位置


这种位置被称为**“安全点”(safepoint)**。


可以看出,HotSpot 采用 OopMap 的数据结构其实是一种空间换时间的方法,但并没有为每条指令(的位置)都生成 OopMap,那将会需要大量的额外存储空间, 导致空间成本消耗增大。


安全点的选择标准:是否具有让程序长时间执行的特征为标准


安全点的选定既不能太少,让 GC 等待时间太长,也不能太多,过分增大运行时的负荷。


只有到达安全点的时候才会停止当前正在执行的程序(Stop the world),去进行 GC。


这里就涉及到一个新的概念——Stop-the-world。


Stop-the-world


Stop-the-world,即停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。


对于安全点, 另外一个需要考虑的问题是, 如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程) 都跑到最近的安全点, 然后停顿下来。


这里有两种方案可供选择: 抢先式中断(Preemptive Suspension) 和主动式中断(Voluntary Suspension) ,


  • 抢先式中断不需要线程执行相关的代码主动去配合,在 GC 的时候,首先让所有的线程全部中断,如果发现有的线程没有到达安全点,就恢复线程,直到它跑到安全点上。 现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应 GC 事件。


  • 主动式中断的思想是当垃圾收集需要中断线程的时候, 不直接对线程操作, 仅仅简单地设置一个标志位, 各个线程执行过程时会不停地主动去轮询这个标志, 一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。


对 Java 线程中的 JNI 方法,它们既不是由 JVM 里的解释器执行的,也不是由 JVM 的JIT编译器生成的,所以会缺少 OopMap 信息。那么GC 碰到这样的栈帧该如何维持准确性呢?


HotSpot 的解决方法是:所有经过 JNI 调用边界(调用JNI方法传入的参数、从JNI方法传回的返回值)的引用都必须用“句柄” (handle)包装起来。JNI 需要调用 Java API 的时候也必须自己用句柄包装指针。在这种实现中,JNI 方法里写的“object”实际上不是直接指向对象的指针,而是先指向一个句柄,通过句柄才能间接访问到对象。这样在扫描到 JNI 方法的时候就不需要扫描它的栈帧了——只要扫描句柄表就可以得到所有从 JNI 方法能访问到的 GC堆里的对象。

但这也就意味着调用JNI方法会有句柄的包装/拆包装的开销,是导致 JNI 方法的调用比较慢的原因之一。


举个例子,当 Java 程序通过 JNI 执行本地代码时,如果这段代码不访问 Java 对象、调用 Java 方法或者返回至原 Java 方法,那么 Java 虚拟机的堆栈不会发生改变,也就代表着这段本地代码可以作为同一个安全点。只要不离开这个安全点,Java 虚拟机便能够在垃圾回收的同时,继续运行这段本地代码。


安全点机制保证了程序执行时, 在不太长的时间内就会遇到可进入垃圾收集过程的安全点。 但是, 程序“不执行”的时候呢? 所谓的程序不执行就是没有分配处理器时间, 典型的场景便是用户线程处于Sleep状态或者Blocked状态, 这时候线程无法响应虚拟机的中断请求, 不能再走到安全的地方去中断挂起自己, 虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。 对于这种情况, 就必须引入安全区域(Safe Region) 来解决。


安全区域SafeRegion


Safepoint 机制保证程序执行时,短时间内就会遇到可进入 GC 的 Safepoint,但是也有一些特例,比如说 JNI 方法、sleep、block等,这些时候 JVM 无法掌控执行能力,也就无法响应 GC 事件。


安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化, 因此, 在这个区域中任意地方开始垃圾收集都是安全的。 我们也可以把安全区域看作被扩展拉伸了的安全点。


当用户线程执行到 SafeRegion 里面的代码时, 首先会标识自己已经进入了 SafeRegion, 在此期间虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。 当线程要离开 SafeRegion 时, 它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段) , 如果完成了, 那线程就当作没事发生过,继续执行; 否则它就必须一直等待,直到收到可以离开安全区域的信号为止。



目录
相关文章
|
6月前
|
Arthas 存储 算法
深入理解JVM,包含字节码文件,内存结构,垃圾回收,类的声明周期,类加载器
JVM全称是Java Virtual Machine-Java虚拟机JVM作用:本质上是一个运行在计算机上的程序,职责是运行Java字节码文件,编译为机器码交由计算机运行类的生命周期概述:类的生命周期描述了一个类加载,使用,卸载的整个过类的生命周期阶段:类的声明周期主要分为五个阶段:加载->连接->初始化->使用->卸载,其中连接中分为三个小阶段验证->准备->解析类加载器的定义:JVM提供类加载器给Java程序去获取类和接口字节码数据类加载器的作用:类加载器接受字节码文件。
593 55
|
11月前
|
监控 算法 Java
Java虚拟机(JVM)垃圾回收机制深度剖析与优化策略####
本文作为一篇技术性文章,深入探讨了Java虚拟机(JVM)中垃圾回收的工作原理,详细分析了标记-清除、复制算法、标记-压缩及分代收集等主流垃圾回收算法的特点和适用场景。通过实际案例,展示了不同GC(Garbage Collector)算法在应用中的表现差异,并针对大型应用提出了一系列优化策略,包括选择合适的GC算法、调整堆内存大小、并行与并发GC调优等,旨在帮助开发者更好地理解和优化Java应用的性能。 ####
288 27
|
12月前
|
监控 算法 Java
Java虚拟机(JVM)的垃圾回收机制深度解析####
本文深入探讨了Java虚拟机(JVM)的垃圾回收机制,旨在揭示其背后的工作原理与优化策略。我们将从垃圾回收的基本概念入手,逐步剖析标记-清除、复制算法、标记-整理等主流垃圾回收算法的原理与实现细节。通过对比不同算法的优缺点及适用场景,为开发者提供优化Java应用性能与内存管理的实践指南。 ####
|
6月前
|
缓存 算法 Java
JVM深入原理(八)(一):垃圾回收
弱引用-作用:JVM中使用WeakReference对象来实现软引用,一般在ThreadLocal中,当进行垃圾回收时,被弱引用对象引用的对象就直接被回收.软引用-作用:JVM中使用SoftReference对象来实现软引用,一般在缓存中使用,当程序内存不足时,被引用的对象就会被回收.强引用-作用:可达性算法描述的根对象引用普通对象的引用,指的就是强引用,只要有这层关系存在,被引用的对象就会不被垃圾回收。引用计数法-缺点:如果两个对象循环引用,而又没有其他的对象来引用它们,这样就造成垃圾堆积。
181 0
|
6月前
|
算法 Java 对象存储
JVM深入原理(八)(二):垃圾回收
Java垃圾回收过程会通过单独的GC线程来完成,但是不管使用哪一种GC算法,都会有部分阶段需要停止所有的用户线程。这个过程被称之为StopTheWorld简称STW,如果STW时间过长则会影响用户的使用。一般来说,堆内存越大,最大STW就越长,想减少最大STW,就会减少吞吐量,不同的GC算法适用于不同的场景。分代回收算法将整个堆中的区域划分为新生代和老年代。--超过新生代大小的大对象会直接晋升到老年代。
148 0
|
8月前
|
缓存 监控 算法
JVM简介—2.垃圾回收器和内存分配策略
本文介绍了Java垃圾回收机制的多个方面,包括垃圾回收概述、对象存活判断、引用类型介绍、垃圾收集算法、垃圾收集器设计、具体垃圾回收器详情、Stop The World现象、内存分配与回收策略、新生代配置演示、内存泄漏和溢出问题以及JDK提供的相关工具。
JVM简介—2.垃圾回收器和内存分配策略
|
12月前
|
机器学习/深度学习 监控 算法
Java虚拟机(JVM)的垃圾回收机制深度剖析####
本文深入探讨Java虚拟机(JVM)的垃圾回收机制,揭示其工作原理、常见算法、性能调优策略及未来趋势。通过实例解析,为开发者提供优化Java应用性能的思路与方法。 ####
268 28
|
11月前
|
算法 网络协议 Java
【JVM】——GC垃圾回收机制(图解通俗易懂)
GC垃圾回收,标识出垃圾(计数机制、可达性分析)内存释放机制(标记清除、复制算法、标记整理、分代回收)
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
674 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
11月前
|
存储 监控 算法
Java虚拟机(JVM)垃圾回收机制深度解析与优化策略####
本文旨在深入探讨Java虚拟机(JVM)的垃圾回收机制,揭示其工作原理、常见算法及参数调优方法。通过剖析垃圾回收的生命周期、内存区域划分以及GC日志分析,为开发者提供一套实用的JVM垃圾回收优化指南,助力提升Java应用的性能与稳定性。 ####