众所周知,程序在运行过程中总是需要申请内存空间,内存空间又不是无限的,为了保持内存空间的持续使用,我们就需要知道如何识别出哪些是垃圾,识别出来后又需要怎么处理这些垃圾?这里就需要垃圾回收机制,也被称为 GC ( 下文通过 GC 代指垃圾回收)
Java 进程在启动的时候会向操作系统申请内存空间,并划分为堆,栈,程序计数器,方法区
知道了 GC 主要发生在堆上了, 应该怎么识别一个对象是垃圾? 在我们看来, 如果一个变量全部空间都不需要用到了, 那自然是垃圾, 而这个对象空间还有一部分需要用, 那肯定不能识别为垃圾, 所以, 垃圾回收机制的基本单位是对象, 而接下来完全用不到的对象就是 GC 的回收目标
这篇主要介绍两个方法:可达性分析 和 引用计数
首先是引用计数法,这也是 Python 中使用的垃圾回收机制
我们来定义一下何为 垃圾对象,如果说我们没有某个对象的引用,我们就用不到这个对象了,既然用不到这个对象,那视为垃圾,没有问题吧
所以我们可以记录引用是某个对象(我们记为 A)的次数, 如果这个引用是局部变量, 那么出了作用域, 这个对象A的引用次数就减一, 而如果这个引用是成员变量, 那么当这个引用对应的对象被销毁了, 对象A的引用次数才减一, 当对象A的引用次数为 0 的时候, 也就达成了上述情况, 这个对象就可以视为垃圾
举个例子🌰, 我们创建了一个 Student 对象, 用 a 接收这个对象引用, 再创建一个引用 b 再指向这个 Student 对象, 那么此时引用次数就为 2, 但是如果让 a = b = null;
那么这个 Student 也就没有引用指向它了, 就可以被视为 GC 的回收对象
public class Main {
public static void main(String[] args) {
Student a = new Student();
Student b = a;
}
}
class Student {
int v;
}
如下图
接下来讲一下循环引用问题, 如下代码,Test 类中有个 Test 类型的引用变量,然后创建两个实例,让a,b分别指向它们(假设 a 引用指向的地址为 0x100, b 引用指向的地址为 0x200)
class Test {
Test ptr;
}
public class Main {
public static void main(String[] args) {
Test a = new Test(); // 假设引用为 0x100
Test b = new Test(); // 假设引用为 0x200
}
}
好!执行完这个代码后,引用数的情况是这样的, 两个对象各有一个引用,所以对象的引用次数都为 1,好理解
然后再执行下述代码
a.ptr = b;
b.ptr = a;
这时候的情况是这样的,两个对象各增加了一个引用,所以引用计数也各自加一,于是两个对象的引用计数总共就为 2
接着我们直接销毁引用a, b,让 a = b = null,此时由于引用数量减少,两个对象引用次数都减一
于是如下图,最终这两个对象都存着彼此的引用,但是实际上这两个对象的地址已经无法获取了,但是由于引用次数不为 0,所以无法视为垃圾,只能占着空位置却用不了,这就是引用计数法中的重大缺陷
这也是 JVM 在使用的算法
以一系列「起点」出发,能够直接或间接访问到的对象标记为「可达」, 否则标记为“不可达”,而被标记为“不可达”的对象即会被认为是垃圾。而这些起点就被定义为 GCRoot 。换个说法,只有能够和 GCRoot 直接或间接访问的对象才会被认为是存活对象。
那么 GCRoot 是怎么被定义的 ?
GCRoot 包括但不局限于以下几种
那么现在能够识别出垃圾了还不够,清理垃圾也很讲究,一起康康垃圾是怎么清理的
首先介绍一种最容易想到的算法
这个算法简单暴力好理解,就是哪里是垃圾,就释放哪里的内存
缺点就是会出现垃圾碎片的问题,如上图,由于我们在申请内存空间的时候,往往需要连续的内存空间,而这种一小片又不连续的空间,实际上很难利用起来,所以我们需要进化一下算法,起码能解决垃圾碎片的问题
主要思想:先将内存空间一分为二,一半用来使用,一半是空着的,当我们需要清理垃圾的时候,就将不是垃圾的对象全复制到空着的那一半空间,刚刚那一半全部释放
这个算法较为高效,并且解决了上面的问题,但是缺点显而易见,空间利用率太低,一半空间放着不用
于是我们就又想创造出一种能够解决上述弊端的算法
主要思想:直接释放垃圾内存,随后让存活的对象都往一个方向移动(搬运)
这其实和 ArrayList 中的删除元素很像,一个删除,后面的元素都往前挪
如下图
这个算法既解决了垃圾碎片问题,又解决了空间利用率低的问题,但是时间消耗也比较大。
虽然 复制算法 和 标记-整理 各有各的缺点,但是如若将这两者应用在不同的场景中够扬长避短——于是分代回收算法出现
首先,我们将内存区域分成两片,一边叫新生代,另一边叫老年代
并且引入一个属性:年龄
我们将年龄定义为:经历过多少次 GC,且还存活的轮次,例如,经历 3 次 GC,还健在的对象年龄为 3
新生代再分为伊甸区和两个幸存区,然后每次新建立的对象都会放在伊甸区,并且根据经验规律,大部分对象连一轮 GC 都撑不过,所以每次 GC 后,伊甸区只会剩下小部分对象
然后将这部分对象通过复制算法移动到其中一个幸存区中,再释放伊甸区的对象(注意:幸存区有两个,可以方便复制算法在这两个区域运行)
移动到幸存区后,还会经历多次 GC,每次 GC 运行后,幸存的对象又会通过复制算法移动到另一个幸存区,再将之前的幸存区释放
随后,将幸存区中满足年龄要求的对象会通过复制算法移动到老年代
能够到达老年区也就足以说明这些对象在短时间内还死不了,所以老年代中会进行频率更低的 GC,如果老年代中发现了垃圾,就会通过标记-整理算法清除。(如果对象空间很大,则会被直接移动到老年代中)
至此,我们来总结一下
虽然复制算法需要一定的空闲空间,但是由于经验规律,刚创建完的对象大部分在第一轮就会死去,所以幸存区的空间小点就能够满足需求,于是我们将这些对象通过复制算法移动到幸存区,然后在这两片幸存区中,每次 GC 都会将幸存对象通过复制算法移动到另一个幸存区,当对象年龄够大且存活时,再移动到老年代。在老年代中,GC 仍以较低频率运转,如果老年代中出现垃圾,就使用标记-整理算法进行处理