标记阶段和清除阶段相关垃圾回收算法

垃圾回收:顾名思义就是将已经分配除去的,但却不再使用的内存回收回来,以便能够再次分配。在 Java 虚拟机的语境下,垃圾指的是死亡的对象占据的堆空间。那么如何辨别一个对象是否可回收?

标记阶段

引用计数法和可达性分析

引用计数法 (reference counting),它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为 0,则说明该对象已经死亡,可以回收

具体实现:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器 +1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器 -1。也就是说,我们需要获取所有引用更新操作,并且相应地增减目标对象地引用计数器。

除了需要额外空间来存储计数器,以及繁琐地更新操作,引用技术法还有一个重大漏洞,即无法处理循环引用对象。

eg:假设对象 a 和 b 相互引用,除此之外没有其他引用指向 a 或 b。此时 a 和 b 实际上已经死了,但由于他们的引用计数器皆不为 0,所以从引用计数法来看两个对象还活着。因此这些循环引用对象所占据的空间将不可回收,从而造成了内存泄漏。
在这里插入图片描述

当前 Java 虚拟机的主流垃圾回收器采取的是可达性分析算法

可达性分析算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(live set) ,然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程称之为标记(mark)。最终,未被探索到的对象便是死亡的,可以回收。

什么是 GC Roots 呢?可以暂时理解为由堆外指向堆内的引用,一般可以包括下面几种:

  • Java 虚拟机栈 栈帧中的局部变量
  • 已加载类的静态变量
  • JNI handles
  • 已启动且未停止的 Java 线程

可达性分析可以解决引用计数法不能解决的循环引用问题。即便对象 a 和 b 相互引用,只要从 GC Roots 触发无法到达 a 或 b,那么可达性分析便不会将他们加入存活对象合集中。

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

Stop-the-world 以及安全点

传统的垃圾回收算法采用的是一种简单粗暴的方式: Stop-the-world ,停止其他非垃圾回收线程的工作,知道完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间 (GC pause)。Java 虚拟机中的 Stop-the-world 是通过安全点 (safepoint) 机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。

安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下, Java 虚拟机的堆栈不会发生变化。这样,垃圾回收器便能够 “安全” 地执行可达性分析。

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

由于本地代码需要通过 JNI 的API 来完成上述三个操作,因此 Java 虚拟机仅需在 API 的入口处进行安全点检测,测试是否有其他线程请求停留在安全点里,便可以在必要的适合挂起当前线程。

除了执行 JNI 本地代码外, Java 线程还有其他几种状态:解释执行字节码,执行即时编译器生成的机器码和线程阻塞。阻塞的线程由于处于 Java 虚拟机线程调度器掌控之下,因此属于安全点。

其他几种状态则是运行状态,需要虚拟机保证在可预见的时间内进入安全点。否则,垃圾回收线程可能长期处于等待所有线程进入安全点的状态,从而变相地提高了垃圾回收地暂停时间。

对于解释执行来说,字节码与字节码之间皆可作为安全点。 Java 虚拟机采取地是当有安全点请求时,执行一条字节码便进行一次安全点检测。

执行即使编译器生成地机器码比较复杂。由于这些代码直接运行在底层硬件上,不受 Java 虚拟机掌控,因此在生成机器码时,即使编译器需要插入安全点检测,以避免机器码长时间没有安全点检测地情况。HotSpot 虚拟机地做大便是在生成代码地方法出口以及非计数循环地循环回边 (back-edge) 处插入安全点检测。

那么为什么不在每一条机器码或每一个机器码基本块处插入安全检测?原因主要有两个。

第一,安全点检测本事也是有开销。不过 HotSpot 虚拟机已经将机器码中安全点检测简化为一个内存访问地内存所在地页设置为不可读,并且定义一个 segfault 处理器,来截获因访问该不可读内存而触发 segfault 地线程,并将他们挂起。

第二,即时编译器生成地机器码打乱了原本栈帧上地对象分布状况。在进入安全点时,机器码还需提供一些额外地信息,来表明哪些寄存器,或者当前栈帧上地哪些内存空间存放着指向对象地引用,以便垃圾回收器呢刚刚枚举 GC Roots。

由于这些信息需要不少空间来存储,因此即时编译器会尽量避免过多的安全点检测。除了垃圾回收之外,Java 虚拟机其他一些对堆栈内容的一致性有要求的操作也会用到安全点这一机制。我会在涉及的时侯再进行具体的讲解。

清除阶段

垃圾回收地三种方式

当标记完所有的存活对象时,便可以进行死亡对象的回收工作了。主流的基础回收方式可分为三种。

第一种是清除 (sweep),即把死亡对象所占据地内存标记为空闲内存,并记录在一个空闲列表之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。

清除这种回收方式的原理比较简单,但是有两个缺点。一是会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。另一个是分配效率低的问题。针对一块连续的内存空间,可以通过指针加法来做分配。而对于空闲列表, Java 虚拟机则需要这个访问列表中的项,来查找能够放入新建对象的空闲内存。

第二种是压缩(compact), 即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。
在这里插入图片描述

第三种是复制 (copy), 即把内存区域分成两等份,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。也就是 from 指针是要指向存活的对象的。复制这种回收方式通用能够解决内存碎片化问题,但是缺点很明显就是堆空间效率低下。

小结:以上是垃圾回收的基础知识

Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象。它从一系列 GC Roots 触发,边标记边探索所有被引用的对象。

为了防止在标记过程中堆栈的状态发生变化, Java 虚拟机采取安全点几种来实现 STW 操作,在那听其他非垃圾回收线程。

回收死亡对象的内存共有三种方式,分别为:会造成内存碎片的清除、性能开销较大的压缩、堆使用效率较低的复制。

猜你喜欢

转载自blog.csdn.net/qq_40488936/article/details/106535330