~/blog/Java垃圾回收

Java垃圾回收

发布日期
430018分钟

GC的目的是回收不被对象占用的内存空间,并且要求尽可能的高效

垃圾回收算法

垃圾回收算法就是用来判断哪些对象正在使用,哪些对象可以回收

进行垃圾回收前必须要知道那些对象是需要回收的,判断一个对象是否需要回收的算法叫做垃圾回收算法。 目前主流的垃圾回收算法分为引用计数法和可达性分析算法

引用计数法

引用计数法是在对象中添加一个引用计数器,当一个地方引用它时计数器加1,当引用失效时计数器减1,任何时刻当对象的计数器为0时代表对象是不能再被使用的。

引用计数法的一个缺点是无法处理循环引用的问题,必须配合大量的额外处理才可以。

可达性分析算法

先找到活动对象,在基于这些活动对象一层一层向下查找

可达性分析算法的思想是通过一系列GC Root的对象(GC Root对象就是活动对象)作为搜索的起点,由这些起点开始进行向下搜索,能够搜索到的对象构成一个引用链,任何时刻当一个对象不在引用链上时称为这个对象是不可达的。这个算法也是Java语言采用的垃圾回收算法。

活动对象(也就是GC Root对象)在哪里呢:

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

简而言之就是:线程正在使用的,本地方法正在使用的,寄存器中的和其他常驻内存的常量

对象的引用

在Java1.2之前,引用的定义十分狭隘:Reference类型的数据代表作内存中的一块地址。

但是有些对象我们希望当空间充足时保留他们,在空间不足时回收他们,有些对象我们希望在对象进行回收的时候能够有通知方便我们进行额外的资源释放。所以在Java1.2后对引用进行了扩充,分为:强引用Strong Reference、软引用Soft Reference、弱引用Weak Reference和虚引用Phantom Reference

强引用是传统意义上的引用,无论何时,只要引用关系存在,那么引用对象就不会被垃圾回收器回收。

软引用用来描述一些有用但是非必要的对象,当系统将要发生内存溢出时,会先对这些引用对象进行回收,当回收后仍然没有足够的内存时才会OOM异常。

弱引用是的强度比软引用要弱,引用的对象只能存活到下一次GC前,如论内存是否足够,GC时弱引用的对象都会被回收。

虚引用是最弱的一种引用,它的引用完全不会对对象的生存有任何的影响,当虚引用的对象被垃圾回收器回收时,发送一个系统通知。一般Java中使用native方法分配内存时会利用虚引用来确保对象被垃圾回收后能够释放分配的虚拟机外内存。

对象回收判断

当一个对象通过可达性分析判断为不可达后就一定会被回收吗?

Java一个对象进行垃圾回收时需要经历两次标记: 当没有一条GC Root到对象的引用链即对象不可达时,会对对象进行第一次标记,意味着对象可以被回收。这时JVM会试图执行判断是否需要执行finalize方法,如果这个对象重写了finalize方法并且对象的finalize方法没有执行过,那么会将对象放入F-Queue中执行finalize。稍后JVM会对F-Queue对象进行第二次标记。当JVM判断无需执行finalize方法或者方法执行后任然被标记的对象会真正被回收掉。

Java的finalize方法目前不推荐使用,并且已被提案在后续的Java版本中废弃,finalize的所有功能都可以使用try with resourceCleaner进行替换

方法区的回收

有些垃圾回收器会将方法区实现成永久代,那么方法区便不会进行垃圾回收吗?

首先《Java虚拟机规范》中没有要求方法区一定要有一个垃圾回收器。

由于分代收集假说,Java中的大部分对象都是朝生夕死的,在Java堆中,尤其是新生代,一次GC可以回收70-99%的内存空间可谓性价比非常的高。但是方法区中一般保存的是类信息和常量池,这些象并不那么容易死亡,由于这些对象的特殊性,对这些对象的死亡判断往往也十分的苛刻,使得垃圾回收的代价很高而回收率却很低。所以并不是所有的垃圾收集器都对方法区的内存空间进行垃圾回收的,如JDK11的ZGC就没有处理方法区。

GC Root枚举

OopMap

JVM中的栈和寄存器都是无状态的,意思就是在GC时不知道这些二进制是一块地址的引用还单纯的值

HotSpot解决方法是使用一组称为OopMap(Ordinary Object Pointer)的数据结构来达到目的:

  1. OopMap中记录着栈帧中那些地方是引用
  2. OopMap会在类加载完成时记录着对象哪某一偏移量上是什么类型的数据

这样当遍历GC Root时,就可以从OopMap中快速得到栈帧中的引用对象,同时便利对象时也能从OopMap中快速得到对象中的引用。

GC Root是JVM进行垃圾回收的起点,需要保证JVM在浏览GC Root过程中GC Root的对象不会变化,要求JVM需要暂停用户线程,即STW(Stop The World)

安全点

OopMap看起来已经解决了GC Root的遍历问题,然而JVM生成OopMap会带来性能开销,如果每时每刻都生成OopMap的话就会白白损失性能--因为并不是每一刻都会进行GC。要避免的话也很简单:减少OopMap生成频率,只有当可能会进行GC的时候再生成OopMap。这里生成OopMap的地方就是安全点,GC时线程必须处于安全点上。

之所以GC发生时机只能是安全点位置--因为只有这个位置才会生成OopMap,其他地方要么压根没有OopMap,要么生成OopMap后又执行了指令导致OopMap的引用可能发生变化。

注:这里的安全点不是指的内存中的位置,而是指指令执行序列的某一位置

安全区域

上述无论是OopMap还是安全点都是针对活跃的线程而言的,要是GC时这个线程已经Block了无法生成OopMap岂不是无法GC了?

未避免这种情况,引入了安全区域概念:安全区域是指某一段指令序列中,引用关系不会发生变化。

换而言之当线程被Block后,无论是线程栈还是线程相关的寄存器的值都不会变动(这里指的是虚拟内存之类),这个区域中任意地方开始垃圾收集都是安全的,因为不会对现有引用关系产生影响。

这里可能会有疑惑:Block的线程没有OopMap那么它栈中的对象如何枚举啊?
其实Block的线程已经失去了CPU执行时间,它的执行上下文已经被保存到了虚拟内存中,也就意味着现存的活跃线程栈中必然有引用指向Block的线程,对于Block线程使用的对象一定时可达的,而不可达的对象也说明是可以回收的

处于安全点的线程唤起的条件比需当GC Root枚举完成,否则会一直处于Block/Sleeping状态,因为GC Root枚举是STW的。

记忆集和卡表

只要我们没有进行全局垃圾回收而进行部分垃圾回收时,往往就无法判断对象是否在其他区域被引用,这时候就需要将被外部对象的引用记录下来,这种数据结构就是记忆集。

但若是每一个引用都记录一下的话空间占用率直接爆炸,而且我们只关心对象有没有被引用而不是被谁引用,所以我们将内存区域划分成固定大小的区域,只记录那块区域是否存在对当前收集区域对象引用,这种区域被称为卡页,而记录区域对象引用关系的数据机构被称为卡表。

卡表是一个字节数组,其中的每一个元素都对应着内存中的一个卡页,只要卡页中存在跨代引用的数据就在卡表上将卡页标记为脏,这样GC时扫面脏页就可以知道哪些区域存在跨代引用,把这些区域丢进GC Root中就可以扫面全部跨代引用的对象了。

并发可达性

目前主流垃圾收集器都是采用可达性算法进行对象的存活性判断的。

当我们标记GC Root时需要STW,由于GC Root占比很少并且有一系列优化手段(OopMap等),这种暂停时间很短并且不随内存大小线性增加。

但是当我们沿着GC Root向下查找时,查找时间就会和内存成正比了,如果我们依然要STW的话,那着停顿时间会非常感人。而如果放开用户线程的话,必然会产生脏引用导致引用链失效。

那我们如何实现既不暂停用户线程,又能准确标记对象呢?答案就是三色标记法。

三色标记法

我们将对象按照是否被访问过分为三种颜色:

  • 白色:该对象从未被垃圾收集器访问过。GC开始时,所有对象都是白色的。
  • 黑色:该对象已经被收集器访问过,且该对象的所有引用页都被访问过。GC结束时,所以可达对象都应该时黑色的。
  • 灰色:该对象被扫描过,但是对象中的引用并没有扫描完。

在GC标记结束时,我们只需要找到白色的对象将其删除就可以完成垃圾回收操作。

但是在并发场景下,可能会出现并发修改导致标记不正确: todo 图

Wilson通过了我也不知道的方法反正就是证明了当同时满足下面两个条件时并发修改会出现问题:

  • 条件一:赋值器插入了一条或多条从黑色对象到白色对象的新引用
  • 条件二:赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

所以我们可以通过破坏上边任一个条件来解决并发问题

  • 增量更新(Incremental Update):破坏条件一
  • 原始快照(Snapshot At The Beginning,SATB): 破坏条件二

增量跟新:当黑色对象插入新的白色对象引用时,会将这个黑色对象连同新增引用记录下来,等标记结束后重新扫描一边。可以理解为黑色对象新增引用后会变为灰色。

原始快照:当灰色对象删除保色对象引用时,会讲这个灰色对象连同删除引用记录下来,当标记结束时,重新扫描一边。可以理解为无论是否删除,都会按照刚开始扫瞄时的引用进行搜索。

CMS垃圾收集器使用增量更新, G1垃圾收集器使用的是原始快照的方式

自此,垃圾收集的理论基础已经讲完了,下一篇会说说常见的垃圾收集器和工作流程。