1、JVM 的垃圾回收机制
对于程序计数器、虚拟机栈、本地方法栈这三部分区域而言,其生命周期与相关线程有关,随线程而生,随线程而灭。并且这三个区域的内存分配与回收具有确定性,因为当方法结束或者线程结束时,内存就自然跟着线程回收了。
因此有关内存分配和回收关注的为堆区和方法区这两个区域。
垃圾回收,就是回收内存。而 JVM 中的内存又分为好几块:
1、程序计数器,由于程序计数器占用的内存空间小,不需要“垃圾回收”。
2、栈区,局部变量都是在代码块执行结束后自动销毁,这和“垃圾回收”没有关系。
3、元数据区 / 方法区,一般都是涉及到“类加载”,很少涉及到“类卸载”,少量使用到“垃圾回收”。
4、堆区,“垃圾回收”的主要战场。
2、识别垃圾
想要回收垃圾,首先要识别出垃圾。在 Java 中,使用对象一定需要通过引用的方法来使用(除了匿名对象,执行完匿名对象那行代码后,对应的对象就会被当作垃圾),如果一个对象没有任何引用指向它,就视为是无法被代码使用的,即可以当作垃圾。
但是,如果代码复杂一些,这些引用的生命周期各不相同,此时识别垃圾的判定过程就会复杂一些。为了解决这个问题,引入了两种解决方法:
2.1、引用计数
给每个对象安排一个额外的空间,空间里要保存当前这个对象有几个引用。这种思想虽然并没有在 JVM 中使用,但是广泛应用于其他主流语言的垃圾回收机制中(Python、php)。
引用计数机制,是一个简单有效的机制,但是存在两个关键的问题:
- 问题一:消耗额外的内存空间。
- 问题二:引用计数可能会产生“循环引用”的问题,此时引用计数就无法正常工作。所谓“循环引用”是指类似于:一个类中的一个成员变量指向另一个类的引用,另一个类中的成员变量同样又指向这个类的引用,当将原先引用类的变量置空,此时引用计数依然不为0。
上述代码出现了问题,此时两个对象的引用计数都不是0,不能被 GC 回收掉,但是这两个对象又无法使用,出现了类似“死锁”的情况,这也是 Java 不用引用计数的原因之一。
2.2、可达性分析
本质上是用“时间”换取“空间”,相比引用计数需要消耗更多的额外空间,但总体来说是可控的,不会产生类似“循环引用”的问题。
在写代码的过程中会定义很多变量,就可以从这些变量作为起点入手,尝试进行“遍历”,所谓遍历就是沿着变量中持有的引用类型的成员,再进一步的往下进行访问。
JVM 中存在专门的扫描线程,会不停的尝试对代码中已有的变量进行遍历,尽可能多的访问到对象。
使用上图举例,root 指向这棵二叉树的根节点,如果代码中出现了 root.right.right = null,此时二叉树与 f 节点断开,无法再使用 root 出发进行遍历操作访问 f 了,此时就说 f 节点为 “不可达” ,即可称为是垃圾;同样的代码中出现 root.right = null,此时二叉树与 c 节点断开,此时无法使用 root 遍历到达 c 节点,自然也无法到达 f 节点,此时就说 c 和 f 都是垃圾。
因此,所有能被访问到的对象,自然就不是垃圾了,反之剩下遍历了一圈也访问不到的对象,就是垃圾。
3、垃圾回收算法
把标记为垃圾的对象的内存空间进行释放,主要的释放方式有三种。
3.1、标记-清除
把标记为垃圾的对象直接释放掉(最朴素的做法),但是会产生内存碎片。如果存在很多内存碎片,很可能导致申请内存空间失败的情况。
因此一般不会使用这种方案,因为“内存碎片问题”是比较致命的。
3.2、复制算法
核心是不直接释放内存,而是把不是垃圾的对象,复制到内存的另外一半中,然后再整体将左则空间整体释放掉。
复制算法确实能够规避内存碎片问题,但是也有缺点:
- 因为需要留有空间进行复制操作,总的可用内存变少了。
- 如果每次要复制的对象比较多,此时复制的开销就会很大。(只有当该轮 GC 过程中,有大量对象被释放,少数对象存活,此时才适合使用“复制算法”)
3.3、标记-整理
类似于顺序表删除中间元素的操作(向前搬运),通过这个过程也能够有效解决内存碎片问题,也没有复制算法需要浪费很多空间,但是搬运内存的开销非常大。
由于这三种释放方式都存在各自的问题,因此 JVM 并没有直接使用上述方式,而是结合上述思想取长补短,做出了综合性方案,称为“分代回收”。
4、分代回收
引入了“对象的年龄”这样的概念,JVM 中有专门的线程负责周期性扫描/释放,一个对象如果被线程扫描一次,并且“可达性分析”证明可达(不是垃圾),此时将该对象的年龄属性 + 1(初始年龄为0)。JVM 中根据年龄的差异,将整个堆内存分成两个大的部分,“新时代”和“老年代”。而新生代区中又分为“伊甸区”、“生存区s0”和“生存区s1”。
当代码中 new 出一些对象,这些对象会先被创建在伊甸区,而在伊甸区中的对象,绝大部分会死去,只有少量存活,因此此处的垃圾回收算法使用“复制算法”。
然后经过一轮 GC 扫描后,会有少数存活的对象通过“复制算法”拷贝到生存区(有两个生存区),后续的 GC 扫描线程会对伊甸区和生存区都进行扫描,伊甸区中少数存活的对象同样会进入生存区,而生存区中仍然存活的对象会拷贝到另外一个生存区。
当对象的年龄达到一定阈值(一般情况默认 15 次)并且依然存活时,此时 JVM 就会认为该对象的生命周期大概率很长,就将这个对象从生成区拷贝到老年代区。
而在老年代的对象,GC 扫描的频率会大大降低。在老年代中结束的对象会按照 JVM的“标记整理”或者“标记清除”方法释放内存。