初识垃圾回收
翻译原文 => plumbr Java GC handbook
乍一看,垃圾回收所做的事情应当恰如其名——查找并清除垃圾。事实上却恰恰相反。垃圾回收是用来跟踪所有仍在使用的对象,然后将剩余的对象标记为垃圾。牢记了这点之后,我们再来更加深入地了解下这个被称为“垃圾回收”的自动化内存回收在Java虚拟机中到底是如何实现的。
在介绍细节之前,我们从介绍垃圾回收的基本特性,核心概念和实现方法等这些基础知识开始。
注意:这些内容是基于Oracle的Hotspot和OpenJDK的实现来介绍的,在其他的其他的运行时或者JVM版本,比如JRockit或者IBM J9上,本文所描述有些方面会完全不适用
手动管理内存
在介绍现代版的垃圾回收之前,我们先来简单地回顾下需要手动地显式分配及释放内存的那些日子。如果你忘了去释放内存,那么这块内存就无法重用了。这块内存被占有了却没被使用。这种场景被称之为内存泄露。
下面是用C写的一个手动管理内存的简单例子:
int send_request() {
size_t n = read_size();
int *elements = malloc(n * sizeof(int));
if(read_elements(n, elements) < n) {
// elements not freed!
return -1;
}
// …
free(elements)
return 0;
}
有过C语言经验的人可以深刻的体会到,你很容易就会忘了释放内存。内存泄露曾经是个非常普遍的问题。你只能通过不断地修复自己的代码来与它们进行抗争。因此,需要有一种更优雅的方式来自动释放无用内存,从而消除人为错误的可能性。这种自动化过程被称为垃圾回收(简称GC)。
智能指针
自动垃圾回收早期的一种实现便是通过析构器。例如,我们在C++里面可以通过使用vector来做同样的事情,当vector对象离开作用域时,vector的析构器会被自动调用以回收内存。
int send_request() {
size_t n = read_size();
vector elements = vector(n);
if(read_elements(elements.size(), &elements[0]) < n) {
return -1;
}
return 0;
}
但是在更复杂的情况下,特别是在多线程之间共享对象时,仅仅依赖析构器是无法实现自动内存回收的。这个时候,引用计数技术作为最简单的垃圾回收器应运而生。对于每个对象,你知晓它被引用了几次,当计数器归零时,这个对象就可以被安全地回收掉了。C++的共享指针就是一个非常著名的例子:
int send_request() {
size_t n = read_size();
auto elements = make_shared>();
// read elements
store_in_cache(elements);
// process elements further
return 0;
}
现在,为了避免在下次该函数被调用时,重新读取元素,我们可能希望把这些元素缓存起来。在这种情况下,当该vector对象离开作用域时是不能销毁该vector对象的。因此我们可以使用共享指针,它会记录这个对象被引用的次数。如果你将它传递给别人则计数加一,当它离开了作用域后便会减一。一旦这个计数为零,共享指针会自动地删除底层对应的vector。
自动内存管理
在上面的C++代码中,我们还得显式地声明我们需要使用内存管理。那如果所有的对象都采用这个机制会怎样呢?那简直就太方便了,这样开发人员便无需考虑清理内存的事情了。运行时会自动知晓哪些内存不再使用了,然后释放掉它。也就是说,它自动地回收了这些垃圾。第一代的垃圾回收器是1959年Lisp引入的,这项技术迄今为止一直在不断演进。
引用计数
刚才我们用C++的共享指针所演示的想法可以应用到所有的对象上来。许多语言比如说Perl, Python以及PHP,采用的都是这种方式。这个通过一张图可以很容易说明:
绿色的云所指向的对象表示仍然被程序使用。从技术层面上来说,这有点像是正在执行的某个方法里面的局部变量,亦或是静态变量之类的。不同编程语言的情况可能会不一样,因此这并不是我们关注的重点。
蓝色的圆圈代表的是内存中的活着的对象,可以看到有多少对象引用了它们。灰色圆圈的对象是已经没有任何人引用的了。因此,它们属于垃圾对象,可以被垃圾回收器清理掉。
看起来还不错对吧?没错,不过这里存在着一个重大的缺陷。很容易会出现一些孤立的环,它们中的对象都不在任何域内,但彼此却互相引用导致引用数不为0。下面便是一个例子:
看到了吧,红色部分其实就是应用程序不再使用的垃圾对象。由于引用计数的缺陷,因此会存在内存泄露。
有几种方法可以解决这一问题,比如说使用特殊的“弱”引用,或者使用一个单独的算法回收循环引用。之前提到的Perl,Python以及PHP等语言,都是使用类似的方法来回收循环引用的,不过这已经超出本文讲述的范围了。我们准备详细介绍下JVM所采用的方法。
标记删除
首先,JVM对于对象可达性的定义要明确一些。它可不像前面那样用绿色的云便含糊了事的,而是有着非常明确及具体的垃圾回收根对象(Garbage Collection Roots)的定义:
- 局部变量
- 活动线程
- 静态字段
- JNI引用
- 其它(后面将会讨论到)
JVM通过标记删除的算法来记录所有可达(存活)对象,同时确保不可达对象的那些内存能够被重用。这包含两个步骤:
- 标记是指遍历所有可达对象,然后在本地内存中记录这些对象的信息
- 删除会确保不可达对象的内存地址可以在下一次内存分配中使用。
JVM中的不同GC算法,比如说Parallel Scavenge,Parallel Mark+Copy, CMS都是这一算法的不同实现,只是各阶段略有不同而已,从概念上来讲仍然是对应着上面所说的那两个步骤。
这种实现最重要的就是不会再出现泄露的对象环了:
缺点就是应用程序的线程需要被暂停才能完成回收,如果引用一直在变的话你是无法进行计数的。这个应用程序被暂停以便JVM可以进行整理活动的情况又被称为Stop The World pause(STW)。这种暂停被触发的可能性有很多,不过垃圾回收应该是最常见的一种。