(伪)2022 NJUSE VJVM Lab3.2 垃圾回收

ℹ️ 本篇 Blog 的主要内容是我对南京大学 2022 软工大作业 VJVM 的继续完善, 用 Java 实现 jvm.

前两个 Lab 代码基于学长 Github@amnore框架代码, 具体的实验指引见 https://amnore.github.io/VJVM/, 答案的视频解说见 https://space.bilibili.com/507030405, 以下内容假设你已经完成 Lab1&2.

从 Lab3 开始的代码和测试用例为我自己的补充.

前言

Lab3.1 中我们完成了对象的创建, 让我们的 jvm 能支持 java 中基本面向对象的特性.

由于 jvm 接管了内存管理, 程序员使用 java 语言编写程序时不再需要像使用 C 语言编写程序时那样手动使用 mallocfree 释放和分配内存.

但我们的 jvm 目前只完成了内存分配的部分, 处于一种管杀不管埋的中间态, 现在我们的 jvm 需要补全内存释放的部分.

C 语言中的内存泄漏

以下面这段 C 语言代码为例.

1
2
3
4
5
6
7
8
9
10
void func() {
char* buffer = (char*) malloc(SIZE);
// Do something else with buffer
}

int main() {
func();
// Do something else
return 0;
}

我们在 func 函数中使用 malloc 向系统申请了一段 SIZE 大小的内存, 但在函数返回前既没有使用 free 释放内存, 也没有将指针 buffer 以返回值的方式传递给调用者. 我们知道 mallocfree 理应是成对出现的, free 接受通过 malloc 获得的指针, 释放掉指针指向的内存. 可是在上面的例子中, 随着 func 函数执行结束, 栈帧弹出, func 中的局部变量 buffer 随栈帧一同被销毁, 我们不能再访问到 buffer, 也就不再能将其传递给 free 函数用于释放内存. 也就相当于 SIZE 大小的内存的空间就不能被释放了, 这种现象被称为”内存泄漏”.

当然并不是说执行了这个程序之后你的电脑的内存就永远报废了, 当程序结束时, 操作系统会将该程序占用的所有内存全部回收, 其中也包括了之前泄漏的内存. (所以你写 OJ 的时候不 free 也没啥大问题x.) 就算最坏的情况下, 操作系统发生了内存泄漏, 由于 RAM 易失性的特征, 重启电脑也能解决问题.

所以内存泄漏常见的后果就是, 程序长时间运行时, 内存占用不断增加, 最后系统可用内存被耗尽导致程序出错或崩溃.

垃圾回收(GC)

在编写 java 程序时我们只需要通过 new 关键字创建对象, 并不需要(也不能)手动释放对象占用的内存空间.

这时候 jvm 就需要判断这个变量是否可能”有用”. 观察上面 C 语言的例子. 在 func 函数内程序员可以读写 buffer, 离开 funcbuffer 指针及其指向的内存就不再可能被访问, 这种情况下这段内存就是不可达的.

jvm 对内存是否”有用”的判断标准就是可达性. 用户可能访问到的变量都是可达的, 这些内存不应该被释放. 相反, 用户不再能访问到的不可达的内存都应该被系统回收.

可达性分析

如何在 jvm 中判断一个对象是否可达呢? 从字面意义上讲, 我们只需要判断一个对象是否有可能被访问到即可.

具体的判断过程可以表示为如下过程.

  1. 定义一些 GC Roots. 将这些 GC Roots 视为可达的.

    • 虚拟机栈 (栈帧中的本地变量表) 中引用的对象
    • 方法区中的常量引用的对象
    • 方法区中的类静态属性引用的对象
    • 本地方法栈中 JNI (Native 方法) 的引用对象 (目前暂无本地方法栈)
    • 活跃线程 (已启动且未停止的 Java 线程) (目前只有一个线程)
  2. 从现有的可达对象出发, 将可达对象包含的对象视为可达的.

  3. 重复第二步, 直至不再能找到新的可达对象, 即找到了所有的可达对象, 堆中剩余对象即为不可达对象, 可以被回收.

p.s. 这里我们所有可达的对象都是强引用对象, 即使发生 OOM 也不会被回收. 除了强引用外, java 中还有其他三种引用 - 软引用, 弱引用, 虚引用.

Java 语言中的内存泄漏

通常情况下, 我们不会怀疑 jvm 等计算机界”基础设施”的可靠性, 毕竟它们已经经历了无数的检查与考验.

但这并不意味着我们可以把所有内存管理工作全部丢给 jvm, 而不用担心内存泄漏问题.

java 中的内存泄漏会发生在那些可达但无用的对象上.

用以下代码为例.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Stack<T> {  // 手搓一个栈
private Object[] elements;
private int top;
public Stack(int size) { // 构造器
elements = new Object[size];
top = 0;
}
public void push(T t) { // 压栈
elements[top++] = t;
}
public T pop() { // 弹栈
return (T) elements[--top];
}
}

看起来是一段十分正确的代码, 但实际上会产生内存泄漏问题.

假设我们先 push 了 100 个 String, 后 pop 了 99 个 String, 我们的设想是 Stack 中只需要保留一个 String 即可, 被弹出的 99 个 String 都是无用的. (事实上不使用反射等手段也无法访问到.)

但实际上只要我们持有 Stack 对象的引用的话, 其内部的 elements 也是可达的, 那 elements 中的 100 个 String 都是可达的, 就不能被 jvm 回收, 导致内存泄漏.

正确的做法是我们应该将已经弹出的对象手动置为 null, 来告知 jvm 这些对象已经不再需要, 可以被回收了.

java 中内存泄漏常常发生在类似的 “长生命周期对象持有短生命周期对象的引用“ 的情况下.

实现

jvm 手册没有对垃圾回收的实现做出具体要求.

1
Heap storage for objects is reclaimed by an automatic storage management system (known as a garbage collector); objects are never explicitly deallocated. The Java Virtual Machine assumes no particular type of automatic storage management system, and the storage management technique may be chosen according to the implementor's system requirements. 

从上面的分析看来实现也并不十分复杂.

但实际上, jvm 作为现代计算机体系中重要的基础设施, 它已经被多次优化迭代了, 现在我们使用的 jvm 的垃圾回收算法十分的复杂.

如果要详细讲最新的 jvm 的垃圾回收算法, 可能再写十个 Lab 也写不完. 所以对于目前常见的一些垃圾回收算法的介绍请见此处的文章.


对于我们使用 java 编写的 jvm 来说, 其实我们可以偷懒地将垃圾回收的工作交给上层 jvm 完成. (通过上面的分析, 你应该已经想到怎么做了.)

但为了起到学习和演示效果, 我在 Lab3.1 中的代码没有采用这种方法, 我将对象中 Fields 的保存在了 JHeap 中的 HashMap 中, JHeap 的生命周期几乎贯穿整个 jvm 的运行过程. 我们需要手动删除 JHeap 中保存的不需要的对象数据以释放内存(让上层 jvm 能够回收这部分内存).

我们将在 Lab3.2 中尝试实现最简单的标记-清除算法, 在我们的 jvm 运行时根据情况(比如内存占用达到一定数量)启动垃圾回收, 每次垃圾回收就分析所有对象的可达性, 然后从堆中删除不可达的对象.

其实在垃圾回收时我们还应该调用这个对象的 finalize 方法, 但由于这种与 jvm 实现强相关的做法已经被发现会导致不少问题, 一个对象甚至可以在 finalize 中让自己重新变得可达从而”躲避” GC, 所以这种做法已经在 jvm9 中被弃用, 我们的 jvm 暂时也忽略对 finalize 的调用. (理论上良好编码的程序不会受到这一变更的影响.)

Tips: 在编写代码和 debug 时你可能不小心会写出 bug, 回收仍然可达的对象. 为了 debug 方便, 你可以先不要真正的删除它, 而是给它添加一个”已删除”的标记, 在每次从 Reference 中读取对象的时候判断该对象是否是”已删除”的, 如果是则抛出异常 WrongGCException, 使用 IDE 的断点功能你可以更好的找到为什么对象被错误回收了. Debug 过程完成后, 你可以将其改回直接删除对象以释放内存的模式.

完成垃圾回收后, 你可以尝试运行原本的用例. 你可以在测试时调高 GC 的频率/打印更多调试日志以测试你写的垃圾收集器的表现情况, 你也可以自己增加测试用例对你编写的垃圾收集器进行压力测试.

如果你发现了 Lab3.2 中的错误, 欢迎联系我.


打赏支持
“请我吃糖果~o(*^ω^*)o”
(伪)2022 NJUSE VJVM Lab3.2 垃圾回收
https://blog.lyc8503.site/post/njuse-jvm-lab3.2/
作者
lyc8503
发布于
2022年7月11日
许可协议