JVM之垃圾回收过程
本文主要学习自《深入理解Java虚拟机》,内容顺序大致遵循书本顺序,加上自身对于其中算法的理解学习。今天小泉主要跟大家一起学习一下垃圾回收的整个流程。
前言
垃圾回收(Garbage Collection),是一种自动内存管理机制。
按照惯例,下面放出垃圾回收的维基百科定义。
维基百科
在计算机科学中,垃圾回收(英语:Garbage Collection,缩写为GC)是指一种自动的存储器管理机制。
当某个程序占用的一部分内存空间不再被这个程序访问时,这个程序会借助垃圾回收算法向操作系统归还这部分内存空间。垃圾回收器可以减轻程序员的负担,也减少程序中的错误。
GC比Java诞生的更早,最早起源于Lisp语言,1960年MIT的大佬们就开始研究如何能够自动管理内存和垃圾回收技术了。经过这么多年的发展,JVM中的GC机制已经十分健壮与成熟了。
判定垃圾回收的对象
回收垃圾之前,首先要找到需要被当作垃圾而回收的对象。
JVM分为五个区域——程序计数器、虚拟机栈、本地方法栈、堆、方法区。我们知道程序计数器与栈均是线程私有的,其生命周期取决于线程的生命周期。因此不需要考虑其中回收的问题。
而堆和方法区属于线程共享部分,是对象实例、变量存放的地方,是能够被动态分配的。因此根据前文定义以及此处的分析,我们可以知道,需要被当作垃圾被回收的是堆和方法区中无法再次被访问的内存区域。
堆的回收
那么知道了垃圾回收的对象的定义,那么应当如何判断当前对象是否无法再次被访问即对象“死亡”呢?
引用计数算法(Reference Counting)
引用计数算法相对来说比较简单,对象实例化后该实例对象引用计数初始值为1,后续根据该实例对象的引用与释放来进行增1、减1操作,当这一实例对象的计数器归零,则判定该对象“死亡”。
引用计数算法原来相对简单,判定效率相对来说也就高一些,许多地方都采用了引用计数算法,如微软的COM(Component Object Model)技术、Python语言、Object-C语言都采用了引用计数算法的内存管理方式。
当然引用计数算法也有着自己的弊端,比如对于对象之间的相互引用。举个书中给出的小栗子:
public clss ReferenceCountingGC {
public Object instance = null;
private int size = 10;//无实际意义
public statice void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
//相互引用
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}
对象实例化计数器初始值为1,被引用+1,最后引用释放-1,最后两个对象的计数器值为1,对象未死亡,可在其他地方这两个对象再无使用,也就是该内存不能够被回收。
可达性分析算法(Reachability Analysis)
可达性分析算法是目前C#和Java主用的判定算法。其算法流程如下:
- 选定根对象(GC Roots)作为起始节点集
- 从起始节点开始,根据对象引用关系进行向下继续搜索节点,形成引用链
从数据结构与算法的角度重构这一过程:
以实例对象为节点,以对象的引用关系为以边形成的无向图,从无向图中选取特定的节点作为特定节点,选出与这些特定节点没有任何关联(直接或者间接可以访问到)的节点。
其实就是图论中无向图的遍历,如果只是从算法角度去考虑,这里可以考虑采用构建邻接矩阵或者邻接链表并使用DFS或者BFS的方式去处理,这个会在以后数据结构与算法系列中推出。
如图,从GC Roots开始搜索到的节点均是存活对象,而未搜索到的节点则会被判定为“死亡”对象。
知道了算法流程,还需要知道一个前提,我们如何选取特定节点也就是根对象?
在Java中,书中给出几种GC Roots的固定来源:
- 虚拟机栈中引用的对象——临时变量、局部变量等
- 方法区中类静态属性引用的对象——静态变量等
- 方法区中常量引用的对象——字符串常量池等
- 本地方法栈JNI(Native方法)引用的对象
- JVM内部引用——基础数据类型、系统类加载器、常见异常对象(OOM等)等
- 同步锁(sychronized关键字)持有的对象
- 反映Java虚拟机内情况的JMXBean、本地代码缓存等等
除了固定GC Roots集合,还有一些“临时性”对象会被加入到该集合中,共同构成GC Roots集合。这一部分在以后再继续讲解吧。
那么是不是只要与GC Roots集合无关联即不可达对象就会被回收呢?
不可达就要被回收了吗?
不可达还不意味着一定就会被回收。JVM在经过可达性分析算法判定某一对象与GC Roots无关后,会对这一对象做一个标记,后续会进行二次判定,而二次判定的内容是:是否有必要实行对象的finalize(),如果对象没有覆盖finalize()或者finalize()方法已经被虚拟机调用了,那么即可认定为“没有必要执行”。
如果二次判定为“有必要执行”,那么对象会被放置在F-Queue
的队列中,之后会有一条由JVM自动建立且低调度优先级的Finalizer线程去执行队列中对象的finalize(),执行机位触发方法而不等待方法执行结束。
因此,不可达对象在此时还有着拯救自己的方法,那就是重写finalize()函数,但注意finalize()函数也只会进入一次,也就是不可达对象仅有一次拯救自己的办法。
方法区的回收
对于方法区的回收,在前文介绍JVM内存区域已经提及到了。
针对回收的对象,方法区回收主要分两类,一类是常量池中的废弃常量,一类是不再使用的类型。
废弃常量
废弃常量的判定和堆中对象的判定十分相近,不再赘述,可参考对回收中的引用计数。
不再使用的类型
同样,针对“不再使用”,需要规定其范围。
- 该类型(包括派生子类)所有实例已经被回收
- 该类型的类加载器已经被回收(难以达成)
- 其java.lang.Class对象没有被引用
达到上述三个要求的类型才能够允许被当垃圾被回收,然而这里需要注意的是,此处也只是“允许”而非必然。
垃圾收集
分代收集理论
在确定了垃圾回收的对象,那么还需要有着回收垃圾的管理者,也就是垃圾收集器,目前大多数垃圾收集器遵循着分代收集理论,该理论基于两个假说提出:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Geneartional Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
基于以上两个假说,我们的垃圾收集器会将Java堆划分为多个不同区域,同时将回收对象依据对象熬过垃圾收集的次数划分到各个区域,回收垃圾则在回收时会对某一个或者某几个区域进行回收。
虽然不同的垃圾收集器划分的区域不同,但区域大致会划分为新生代(Young Generaion)和老年代(Old Generation)两个区域。正是由于这样的划分也导致了一个问题——两个区域的对象有着引用关系(称之为跨代引用),这样就对某个区域的回收造成了影响(必须再去查询区域中的存活对象)。
为此引入了第三点假说:
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
有了第三点假说,我们就可以认为当发生新生代和老年代的跨代引用,老年代的难以消亡特点会使得引用的新生代对象得以存活并随着垃圾收集次数的增加而晋升到老年代。
同时第三点假说告诉我们跨代引用的部分会很少很少,因此我们只需要建立一种数据结构用来标记住老年代中进行了跨代引用的内存区域,将其加入到GC Roots中进行新生代存活对象的判定。
垃圾收集种类
根据垃圾收集的区域不同,主要分为以下几种垃圾回收的方式:
- Partial GC:局部垃圾收集,指的是目标不是整个Java堆扽的垃圾收集
- Minor GC :新生代收集,目标仅针对新生代
- Major GC :老年代收集,目标仅针对老年代
- Mixed GC :混合收集,目标是收集新生代以及部分老年代
- Full GC :整堆收集,目标是整个Java堆和方法区
垃圾收集器
垃圾收集器这里先不做介绍,等深入学习后再跟大家分享。
垃圾收集算法
根据判定回收垃圾对象的方式,垃圾回收的算法也分为两种,一种称为“引用计数式垃圾回收”(ReferenceCounting GC),另一种称为“追踪式垃圾回收”(Tracing GC)。由于JVM采用的是可达性分析算法进行“垃圾”的判定,因此一下主要介绍后一种收集算法。
标记-清除算法(Mark-Sweep)
标记清除算法是垃圾收集算法的基础。它的思路相对来说是最简单的,按字面意思理解,先“标记”,再“清除”:
-
标记
首先按照可达性分析算法所述,先判定可达性再进行二次判定后标记为可回收
-
清除
将标记为可回收的区域清除回收
标记-清除的过程很简单,因此也有着许多缺点,主要可归为两点:
-
效率较低
标记和清除的过程主要在于扫描对象:先扫描所有对象进行标记,再扫描判断标记进而清除,因此当可回收对象的数量增多时其效率就会降低。
-
碎片化内存
内存碎片化是可想而知的,可回收的对象不一定会保存在连续一段的内存空间,因此不经任何处理直接清除,会造成诸多的碎片化空间,类比于操作系统的内存管理操作。
标记-复制算法(Mark-Copy)
标记-复制算法是在标记-清除算法基础上进行了优化。
1969年Fenichel在标记-清除算法的基础上提出一种“半区复制”的垃圾收集算法,其算法流程(标记过程已经省略,标记-整理算法同样省略该过程)如下:
- 内存区域一分为二,一次使用一半区域,另一半作为保留区域。
- 当一半区域使用完之后,先将已使用区域中可达(存活)对象分配到保留区域。
- 再回收已使用完全的区域中可回收内存区域,然后使用原先的保留区域。
这样的标记-复制算法对于需要回收大量内存的情形十分友好,并且进行内存复制操作之后不需要考虑内存碎片化问题。
但是相应的也有着其缺点。
- 在内存复制搬移过程中产生了较大的内存复制开销,并且随着存活对象的增加,效率也会降低。
- 内存实际可用的区域变为了原先区域的二分之一,浪费内存空间
对于新生代的垃圾收集,这种方式倒是一种有效的思路,只不过比例不再需要是1:1,IBM针对分代收集理论的第一个假说,新生代中98%的对象熬不过第一轮收集。
而在此基础上,1989年Andrew Appel提出了一种更加优化的算法。
Appel式回收
将新生代分为三部分:80%的Eden空间、10%的Survivor(称为from)空间、10%的Survivor(称为to)空间。
可供分配使用的内存为80%的Eden空间和10%的名为from的Survivor空间,当可供分配的使用完之后,JVM将进行垃圾回收,其步骤如下:
- 将Eden与from空间的可达对象复制到to空间
- 清除回收Eden与from空间内所有可回收对象
- 原先的from空间变为了下一次的to空间,原先的to空间就变为了下一次的from空间。
如果存活对象超过这10%的Survivor空间又该怎么办呢?别急,还有老年代兜着底,拿出一部分用来放置多出的存活对象。
标记-整理算法(Mark-Compact)
标记-复制算法对于新生代的垃圾回收相对来说十分有效,但对于老年代来说,这种算法不太适宜,因为老年代需要考虑极端情况即内存全被占用,因此1974年Edwaed LueDers提出了另外有针对性的算法即标记-整理算法。
其算法流程如下:
- 将所有标记的可达对象移动到内存区域的一端,形成可达对象边界区
- 清理除边界之外的所有对象
与标记-清除算法相比,标记-整理算法其实只是多了一个移动可达对象的操作,这样做的好处是内存分配时不用考虑碎片问题,而这样做的坏处是内存回收会变得更加复杂。
总结
学习完之后,我们知道,JVM中的垃圾回收主要针对的是Java堆,而本文也主要针对Java堆的垃圾收集进行分析:垃圾回收的对象判定(引用计数算法 && 可达性分析以及二次判定)、划分不同区域并针对不同区域提出的垃圾收集算法。这一过程还是比较复杂的,还是需要大家多多学习呀~
针对垃圾收集算法,总结了如下一张小表格给大家,希望能够帮助大家快速了解下三种算法的优缺点。
垃圾收集算法 | 优点 | 缺点 |
---|---|---|
标记-清除算法 | 算法设计简单 | 效率较低,回收后容易内存碎片化 |
标记-复制算法 | 针对新生代相对高效,不用考虑内存碎片化问题 | 浪费部分内存空间,且有内存复制的开销 |
标记-整理算法 | 针对老年代相对高效,不用考虑内存碎片化问题 | 设计较为复杂,且有内存移动的开销 |
垃圾回收的过程就是JVM回收内存的过程,这对于以后排查内存问题(如内存泄漏、内存溢出等等)有着重大的帮助,希望大家看完之后,能有所收获。
如有兴趣,可以关注我的公众号,每周和你一起修炼~