likes
comments
collection
share

超级详细的Java垃圾回收机制解析(上)

作者站长头像
站长
· 阅读数 1

前言

前面文章说到JVM的一大好处就是它相当于一个托管平台,来处理一些比较复杂的逻辑,比如内存管理,JVM的自动内存管理将本来需要由开发人员手动回收的内存,交由JVM的垃圾回收器来自动回收,所以本篇文章就来探讨一下这个垃圾回收机制的知识。

正文

垃圾回收,顾名思义就是将已经分配出去的、却不再使用的内存给回收回来,以便能够再次被分配。在JVM中,垃圾就是指的是死亡对象所占用的堆空间,而这里的关键点是如何判别这个对象已经是死亡的

引用计数法

我们先来说一种古老的鉴别方法:引用计数法。它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数,一旦某个对象的引用计数器为0,则说明该对象已经死亡,需要被回收。

这个设计看起来没问题,但是实现起来却很麻烦,比如有一个引用,被赋值为某一个对象,那么该对象的引用计数器加1,但是这个引用如果指向了另一个对象,那么该对象的引用计数器减一,也就是要截获所有的引用更新操作,这将是一个巨大工作量。

同时还需要来保存计数器也需要额外空间,不过最大问题是引用计数法有个巨大漏洞,即无法处理循环引用的对象。

也就是假设对象a和b互相引用,除此之外没有其他引用指向a和b,在这种情况下,a和b其实已经是死亡状态了,但是由于引用计数器没有为0,所以无法被当成垃圾。

可达性分析

目前JVM的主流垃圾回收采取的算法是可达性算法,这个算法思路是:将一系列GC Roots作为初始的存活对象合集(live set),然后从该集和出发,探索所有能够被该集和引用到的对象,并且将其加入到该集和中,这个过程称为标记(mark),最终未探索到的对象便是垃圾。

那什么是GC Roots呢,我们暂时可以理解为由堆外指向堆内的引用,一般而言GC Roots包括但不局限于下面几种:

  • Java方法栈帧中的局部变量;(Java方法栈中数据)
  • 已加载类的静态变量;(方法区中数据)
  • JNI handles;(本地方法栈中数据)
  • 已启动且未停止的Java线程。

可达性分析可以解决引用计数法不能解决的循环引用问题。

虽然可达性分析思路比较简单,但是还有不少问题需要解决。比如在多线程环境下,刚开始看到一个对象,可达性分析是不可到达的,需要被回收,这时垃圾回收线程开始工作中其他线程有个引用指向了该对象,但是没有同步,垃圾回收机制还是会将该对象回收,很有可能就导致JVM崩溃。

Stop-the-World以及安全点

那如何解决上面所说的多线程问题呢 在JVM中传统的垃圾回收算法采用了一种简单粗暴的方式:Stop-the-World,即停止其他非垃圾回收线程的工作,直到垃圾回收完成,这也就是所谓的暂停时间(GC pause)

JVM中的Stop-the-World是通过安全点(safepoint)机制来实现的,即当JVM收到Stop-the-World请求,它便会等待所有的线程都到到安全点,才允许垃圾回收线程进行独占工作。

那为什么要这个安全点呢,安全点的目的不是为了让其他线程停下,而是找到一个稳定的状态,在这个状态下,JVM中的堆栈不会发生变化,这样垃圾回收器才能够正确的执行可达性分析

举个例子,当Java程序通过JNI执行本地代码时,如果这段代码不访问Java对象、调用Java方法或者返回至Java方法,那么JVM的堆栈就不会发生改变,也就代表这段本地代码在执行时也是一个安全点,即在垃圾回收的同时,可以继续执行这段本地代码。

除了执行JNI本地代码外,Java线程还有其他几种状态:解释执行字节码、执行即时编译器生成的机器码和线程阻塞。其中阻塞的线程处于JVM程序调度器的掌握中,肯定属于安全点,其他几种状态是运行状态,所以需要JVM在可预见的时间进入安全点,否则长期处于等待所有线程进入安全点状态将提高垃圾回收的暂停时间。

对于解释执行来说,字节码与字节码之间皆可以作为安全点,因为每执行一条字节码指令,堆栈变化就已经操作完成,所以当有安全点请求时,执行一条字节码便可进入安全点状态。

而执行即时编译器生成的机器码则比较复杂,由于这些代码直接运行在底层硬件上,不受JVM掌控,因此在生成机器码时,即时编译器需要插入安全点检测,以避免机器码长时间没有安全点检测的情况。目前HotSpot的JVM做法是在生成代码的方法出口处插入安全点检测。

那为什么不在每一条机器码或者每一个机器码代码块插入安全点检测呢,原因有2个:

  • 第一是安全点检测是有一定的开销,虽然JVM已经将机器码中安全点的检测简化为一个内存访问操作,即有安全点请求情况下,JVM会将安全点检测访问的内存所在的页设置为不可读,并且设置一个segfault处理器,来截获因访问该不可读内存而触发segfault的线程,并将他们挂起。

  • 第二是即时编译器生成的机器码会打乱原来栈帧上的对象分布,在进入安全点时,机器码还需要提供额外的一些信息来表明哪些寄存器或者当前栈帧上的哪些内存空间存放着指向对象的引用,以便垃圾回收器能够枚举GC Roots(因为栈帧中的局部变量是GC Roots)。

由于这些信息需要不少空间来存储,所以尽量避免过多的安全点检测。

垃圾回收的三种方式

说完了如何识别垃圾以及识别垃圾所导致的线程同步问题,那就来看看如何进行回收。当标记完所有存活的对象,其他对象就是死亡的对象,我们可以进行回收,目前主流的回收方式分为三种。

标记清除

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

超级详细的Java垃圾回收机制解析(上)

这种方法足够简单,但是缺点很明显:一是会造成内存碎片,由于JVM堆中的对象必须是连续分布的,因此可能出现总空闲空间够,但没法分配的情况。二是分配效率较低,如果是一块连续的内存空间,可以使用指针加法(pointer bumping)来做分配,但是对于空闲列表,JVM则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。

标记压缩

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

超级详细的Java垃圾回收机制解析(上)

标记复制

第三种是复制(copy),先把内存区域分为俩等分,分别用2个指针from和to来维护,并且只用from指针指向的内存区域来分配内存。当垃圾回收发生时,便把存活的对象复制到to指针指向的内存区域,并且交换from指针和to指针。标记复制这种回收方式同样能解决内存碎片化的问题,但是缺点也明显,就是堆空间使用效率低下。

超级详细的Java垃圾回收机制解析(上)

上面3种方法有优点也有缺点,现代的垃圾回收器会综合上述回收方式,下篇文章我们仔细来说一下JVM中垃圾回收算法的具体实现。

总结

这篇文章我们知道如何标记一个对象不是垃圾,以及为了防止在标记过程中堆栈发生变化,JVM采用安全点机制来实现Stop-the-World的操作,同时在后面介绍了回收死亡对象的3种方式:会造成内存碎片的清除、性能开销大的压缩和堆使用效率较低的复制。

下篇文章我们解析如何来综合它们的优点,同时规避它们的缺点,来实现一个比较好的垃圾回收算法。