JVM垃圾收集机制

139 阅读12分钟

前言

  • 当Java程序运行时,程序计数器、虚拟机栈和本地方法栈这3个区域会随着线程结束而消失,栈帧分配的内存大小在类结构确定时已知。因此这几个区域的内存分配和回收具有确定性。当方法或线程结束时,内存也就回收了。
  • 但是堆和方法区就不一样了,只有在运行时,才知道创建了多少对象,接口的多个实现类需要的内存也不一样,因此这部分的内存分配和回收是动态的。垃圾收集关注的正是这部分的内容。
  • 垃圾收集器在堆内存进行回收之前,必须知道哪些对象可以回收,哪些对象还处于存活状态。

那么如何判断对象是否是存活状态呢

  • 使用可达性分析算法。基本思路是通过一系列"GC Roots"的根对象为起点,根据引用关系往下找,能达到的就是存活,反之则不是。下图中,object6、object7、object8之间相互关联,但是由于不可达到GC Roots,因此判定为可回收的对象。 image.png
  • 可达性分析算法需要确定两点:1、什么对象可被视为GC Roots。2、什么是可达到引用。
  • 什么对象可被视为GC Roots
  1. 在虚拟机栈中引用的对象。如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量。
  2. 在方法区中类静态属性引用的对象。如java类的引用类型静态变量
  3. 在方法区中常量引用的对象。如字符串常量池里的引用。
  4. 在本地方法栈中native引用的对象。
  5. java虚拟机内部的引用。如基本数据类型对应的Class对象,一些常驻的异常对象,系统类加载器。
  6. 所有被同步锁(synchronized关键字)持有的对象。
  7. 反映java虚拟机内部情况的JMXBea、JVMTI中注册的回调、本地代码缓存等。
  • 引用

引用分为强引用、软引用、弱引用、虚引用。

  • 强引用:最传统的“引用”。类似“Object obj = new Object()”。
  • 软引用:还有用,非必须的对象。用SoftReference类实现。
  • 弱引用:比软引用的强度更弱一些。只能存活到下一次垃圾收集发生为止。用WeakReference类实现。
  • 虚引用:最弱的引用关系。一个对象是否有虚引用,完全不会对生存时间有影响。也不能通过虚引用来获取一个对象实例。设置虚引用唯一目的只是为了在这个对象被回收时能收到一个系统通知。用PhantomReference类来实现。
  • “非死不可”

在不可到达的对象被回收之前,至少经历两次标记。在可达性分析时,没有到达GC Roots会被标记一次。在之后筛选finalize()方法时,第二次标记,如果执行finalize()方法,并且在其中重新和引用链上的对象建立了关联,那么该对象可以逃脱回收。finalize()方法只会被系统自动调用一次

  • 回收方法区

一般来讲,方法区是不进行回收,一方面是没有能完整实现方法区类卸载的收集器存在。另一方面方法区的收集性价比较低。方法区主要回收的目标是常量和类。判断常量是否可以回收比较简单,和堆中的垃圾回收类似。但是判断类是否可以回收就比较苛刻了。需要同时满足3个条件:

  1. 该类的实例已经被回收。2. 加载该类的类加载器被回收。3. 该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。 而且不是没用就必然会被回收

垃圾收集算法

  • 在讲垃圾收集器之前,要先了解垃圾收集算法。垃圾收集算法是垃圾收集器工作的理论基础。

分代收集理论

当前商业上在使用的垃圾收集器,大多数都遵循了“分代收集”理论进行设计。其实分代收集名义上是理论,实际上是经验法则,建立在两个分代假说上:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的。
  2. 强分代假说:经历过越多次垃圾收集的对象就越难被收集。
  3. 跨代引用假说:跨代引用相较于同代引用来说仅占少数。
  • 1、2个分代假说是多款垃圾收集器一致的设计原则:收集器将堆划分成不同的区域,然后将回收的对象依据年龄分配到不同的区域存储,年龄是根据经历过垃圾收集次数来决定的。
  • 通过这样分配后,总是会被收集的对象所在的区域(新生代),只要标记那些少量存活下来的对象,就可以很快回收大量空间。而那些存活力强的对象区域(老年代),用较低的收集频率即可。
  • 因为有不同的划分区域,所以有“Minor GC” “Major GC” “Full GC”这些回收类型。
  • 其实,分代收集并不是只要分一下区域那么容易。至少可以发现的一个问题是,两个分代之间的对象会存在跨代引用。这时可以用3来推出一个结论,存在相互引用关系的两个对象,是倾向于同时生存和消灭的。试想,一个年轻代的对象和老年代对象存在引用关系,那么新生代的这个对象在经历过多次垃圾收集后,会被划分到老年代中,这时也就没有跨代引用了。
  • 在新生代中,有一块专门用来标识出老年代的哪一块内存会存在跨代引用的数据结构(记忆集),这样在Minor GC时,就不必扫描整个老年代了。虽然维护这个记忆集需要消耗一定的资源,但整体上来讲还是划算的。 image.png

标记清除算法详解

  1. 把存活的标记;
  2. 清除没标记的;
  • 优点:高效、速度快
  • 缺点:当存活的数量多时,效率降低,还会产生很多不连续的空间碎片 image.png

标记复制算法详解

  1. 把存活的标记;
  2. 把存活的对象整理一下,放到一个连续的区域中;
  • 优点:清理后内存连续,速度也较快
  • 缺点:需要留出一半的空间用来整理,所以一般在新生代中使用,每次分配只使用eden和其中一块survivor,另一块survivor用来放复制的存活对象。HotSpot虚拟机默认的eden:survivor1:survivor2=8:1:1。 image.png
  • 内存分配担保:如果一次Minor GC之后存活的对象大于survivor空间时,这些对象就会通过分配担保机制进入到老年代。

标记整理算法详解

  1. 把存活的标记;
  2. 把存活对象挪到没标记的对象所在空间;
  3. 清楚多余空间
  • 优点:清理后内存连续,空间利用率高
  • 缺点:需要在内存中移动,会有STW产生,效率较低。在移动过程中出现多种问题,让内容分配变得复杂。 image.png

算法细节

根节点枚举

  • 在枚举根节点时,所有垃圾收集器都是需要停顿的。
  • 虚拟机在线程停止后,并不需要完整的检查所有的引用和执行上下文,而是使用一组名为OopMap的数据结构来直接得到哪些地方存放着对象引用。
  • 类一旦加载完成,HotSpot会把对象内什么偏移量上是什么类型的数据计算出来,在即使编译的过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样,在垃圾收集器扫描时很快就能知道这些信息,并不需要去找实际的GC Roots。

安全点

  • 当垃圾收集器让线程停顿下来是,不是所有程序都是立刻停止的,程序会继续执行至安全点后停下,当所有程序都停止时,才会开始收集。
  • OopMap不是每条指令都会有的,只有在特定位置才有记录信息,这些位置就是“安全点”。安全点选择的原则是“是否具有让程序长时间执行的特征”——指令复用,例如方法调用、循环跳转、异常跳转等。
  • 执行到安全点的两种方案(使用第2种):

1、抢先式:先停止所有线程,找到没到安全点的线程,恢复执行到安全点后中断。
2、主动式:设立一个标记0,所有线程监控这个标记,当发现标记变为1时,就运行到安全点后中断。

安全区域

  • 当程序执行时,可以运行到安全点来中断。可是,如果程序没执行呢?比如线程处于Sleep状态和Blocked状态时。因此产生了安全区域的概念。
  • 当线程运行到安全区域时,会标记自己已经进入了安全区域,垃圾收集时,就会无视安全区域的线程。当线程离开安全区域时,要检查根节点枚举是否完成,若没有完成,则线程继续等待直至收到可以离开安全区域的信号。

记忆集与卡表

  • 在前面的分代收集理论中已经提过记忆集,事实上不仅仅是老年代和新生代之间才有跨代引用,只要涉及到部分区域收集的垃圾收集器,都会有跨代引用。记忆集是记录非收集区域指向收集区域的指针集合的数据结构
  • 卡表可以看作是记忆集的一种实现方式。如下图所示,左边是卡表,右边是卡页。卡页大小一般是2的N次幂的字节数。一个卡页内有多个对象。只要卡页内有一个及以上的对象存在跨代指针,那其对应的卡表数组元素的值标识为1,没有则为0。在垃圾收集时,只要找出卡表中标识为1的元素,就能找出卡页中包含的跨代指针,把它们加入到GC Roots中一起扫描。 image.png

写屏障

  • 写屏障是维护更新卡表的一个操作。在引用对象赋值时,在赋值前会有一个写前屏障,赋值结束后有一个写后屏障。

三色标记法

  • 在根节点枚举这个操作中,由于GC Roots的数量相对整个堆中的全部对象数量来说还是极少数的,并且在优化技术的帮助下,这个步骤带来的停顿是短暂且稳定的。但是,从GC Roots往下扫描的过程中,随着堆容量变大,存储的对象越多,在标记对象时花费的时间也就越长。而标记几乎是所有垃圾收集器都有的步骤,如果能减少这个操作时间的话,那收益也是系统性的。
  • 想要解决上述问题,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历。下面就用三色标记来帮助理解。把遍历过程中遇到的对象,按照“是否访问过”的条件标记成三种颜色:
  1. 白色:对象尚未被收集器访问过。在可达性分析之前,所有对象显然都是白色的。当分析完成时,如果对象还是白色的,那么就表示不可达到。
  2. 黑色:对象已被收集器访问过,并且这个对象的所有引用都已扫描过。黑色对象是安全的,如果有其他对象引用指向了黑色对象,那么不用再扫描。黑色对象不可能直接指向某个白色对象。
  3. 灰色:对象已被收集器访问过,但这个对象至少存在一个引用还没被扫描过。

对象消失

  • 在标记推进的过程中,如果用户把引用关系进行了修改,如果下图: image.png
  • 最下面这个对象就会消失,这是很危险的。当且仅当满足以下两个条件时,会产生这个问题(原本应该是黑色的对象被误认为白色):
  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用
  2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
  • 所以,只要破坏上面两个条件中的其中一个,就能解决“对象消失”的问题。
  • 增量更新:破坏第一个。黑色对象插入新白色对象引用后,它变回灰色对象。
  • 原始快照:破坏第二个。灰色对象在删除白色对象时,将这个要删除的引用记录下来,在并发扫描结束后,再将记录过的引用关系中的灰色对象为根,重新扫描一次。
  • 对引用关系记录的插入和删除,都是通过写屏障实现的。CMS是基于增量更新做并发标记的,G1是利用原始快照。

空间担保分配

  • 在Minor GC之前,老年代作为担保方,需要查看是否有足够的空间来接收Minor GC后晋升的对象。具体逻辑如下图: image.png