Java语言的成功很大一部分也得益于它的垃圾回收机制,这与C++等语言的内存回收方式发生了巨大的改变。了解过C++语言的应该要很清楚当我们有不用的对象时,我们必须进行手动的释放内存。但是写Java的程序员通常都不用理会那些不再使用的对象,在Java虚拟机中有一个充当『保姆』角色的垃圾回收器,由它来负责为我们打扫产生的垃圾。

前面文章介绍过虚拟机中的内存模型,内存回收当然也是基于这模型来进行。我们清楚知道不同的内存划分区域具有不同的职责。如:程序计数器、Java栈、本地方法栈三个区域都是与线程相关的,随线程而生、随线程而灭。而栈中的栈帧一开始所需要的内存空间在类结构确定的时候就知道大小了,随着方法执行的结束,对应的栈帧也就自然跟着回收了。这几个区域的内存分配和回收都是相对确定的,不需要而外来进行回收。垃圾回收器所关心的是那些动态分配的内存,如在Java堆和方法区上的内存。

JVM

对象是否死亡

前面提到垃圾回收器会回收掉不再使用的对象,那么究竟怎么判定一个对象不再使用了?

引用计数法

显然的,最简单的思路就是给每个对象添加一个引用计数器,当有对象引用它时计数器加1,当引用失效时,计数器减1,如果回收时发现对象的引用计数器的值为0,那么表示该对象不再被使用了。但是在Java中并没有采用这种方式来判定一个对象是否不再使用。为什么呢?想象一下对象之间相互引用的情景。

1
2
3
4
5
6
7
8
9
10
11
public class RefCount{
public RefCount dep = null;
public static void main(String[] args){
RefCount a = new RefCount();
RefCount b = new RefCount();
a.dep = b;
b.dep = a;
a = null;
b = null;
}
}

上面的代码a和b相互依赖的对方,在回收时,无论是哪一个都需要等另一个先回收,计数器的值才能变为零,但是这显然出问题了,两者都不会被回收。

根搜索算法

为了防止出现相互引用的问题,Java中采用的是根搜索算法。根(”GC Roots”)指的是选取满足一定条件的对象作为搜索的起点。从起点开始根据引用情况进行搜索,搜索所走的路径也称为引用链。这样搜索后,可以发现有部分对象是从选中的任何起点都搜索不到,换句话将对象看成图的顶点,也就是从起点出发不可达的其他对象顶点,这些对象是不再使用的。根并不是只有一种,存在多种根,一个对象可以属于多种根,根的种类如下:

  • Class:被系统类加载器加载的Class,这些类是不会被卸载的,它们可以以静态属性的方式持有其他对象的引用。用户自定义类加载器加载的类,除非以其他方式成为根,否则不是根。
  • Thread:活着的线程。
  • Stack Local:Java方法中的本地变量,对应栈帧中本地变量表中的引用的对象。
  • JNI Local:JNI方法中的本地变量或参数
  • JNI Global: 全局JNI引用
  • Monitor Used:用于同步的监控对象
  • Held By JVM:用于JVM特殊目的由GC保留的对象,如系统类加载器、一些重要的异常处理类等。

内存泄露

很不幸的是,即使使用了根搜索算法,Java内存中还是有可能存在不再被使用的对象,但是依然存在内存中没有被回收,这就是所说的内存泄露。为什么会出现这种情况?肯定是这些对象通过根搜索算法能够找到,但是这些对象其实是不再使用了,这是写Java程序时可能写出的Bug,即程序自身的逻辑问题。内存泄露一般有以下两种情况:

  1. 在堆中分配的内存未被释放,将访问该内存的指针重新赋值。
1
2
3
4
5
6
7
8
public static void main(String[] args){
List<Object> list = new ArrayList();
Object obj = new Object();
for (int i = 0; i < 50; i++) {
list.add(obj);
obj = null;
}
}

上面的代码不断生成新的对象放到List中,但是之后又将对象置为空。当发生垃圾回收的时候,对象是不能被回收的,因为通过根搜索算法,会发现list对象中存在obj对象的引用,虽然obj对象已经不再用了。

  1. 对象已经不需要使用了,但是依然保存在内存中,通常表现为一个内存对象的生命周期超出了程序所需要它的时间长度,也称为『对象游离』。
1
2
3
4
5
6
7
8
9
10
11
public class StringMatch {
public String str1;
public String str2;
public boolean match(String s1, String s2) {
str1 = s1;
str2 = s2;
return str1.equals(str2);
}
}

上面的代码在比较两个字符串是否相等,其实不需要定义全局变量str1和str2,当方法执行结束后,便不再使用。定义为全局变量使得字符串的生存周期超出了它在程序中所需的时间长度,导致出现第二种情况的内存泄露。
这种情况其实能够避免,在声明对象引用时,先明确对象的有效作用域。函数内有效的内存对象应声明为局部变量,类实例生命周期相同的变量声明为实例变量等。

Java的引用方式

通常情况,我们会认为Java中可能就存在被引用和没被引用两种情况。事实告诉我们,Java中存在的多种引用方式。为什么引入多种引用方式?在内存空间还足够的时候,我们希望某类对象能保存在内存中;当内存在垃圾回收后还紧张的时候,可以放弃这些对象,有点类似缓存。Java将引用分为强引用、软引用、弱引用、虚引用四种,这四种引用强度依次减弱。

  • 强引用:在代码中普遍存在,通过new关键字实例化的对象的引用都属于强引用,只要强引用存在,垃圾收集器就不会回收掉被引用的对象。

  • 软引用:可以通过SoftReference类来实现软引用。用来描述一些还有用,但并非必需的对象。当系统要发生内存溢出异常之前,会把这些对象列为回收范围之内,并进行第二次回收。

  • 弱引用:可以通过WeakReference类来实现弱引用。它的引用强度比软引用更弱一些,弱引用关联的对象只能生存到下一次垃圾回收发生之前。无论内存是否足够,都会回收掉只被弱引用关联的对象。

  • 虚引用:可以通过PhantomReference类来实现虚引用。是一种最弱的引用关系,设置虚引用的目的只是希望在对象被回收时收到一个系统通知,虚引用对对象的生存时间不会有任何影响,无法通过虚引用获得一个对象实例。

对象宣告死亡

当我们采用根搜索算法来查询不可达的对象后,这些对象并非马上就被宣告死亡。要宣告一个对象死亡需要至少经历两次标记过程:如果对象在进行根搜索算法后,发现不可达,那么它会被第一次标记,并且进行筛选,筛选的条件是对此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者该方法已经被虚拟机调用过,则不需要执行finalize()方法,这也可以看出对象的finalize()只会被虚拟机执行一次。如果认为有必要执行该方法,对象会被放入一个名为F-Queue的队列中。如果在finalize()方法执行后,对象能够重新使得GC Roots可达,那么它就会被移除『即将回收』的集合,存活下来,否则对象就将宣告死亡。

总结

本文主要介绍了垃圾回收机制是如何判定Java对象是否需要回收,通过对象引用关系,根据根搜索算法来进行对象是否死亡的判定。同时介绍可能出现的内存泄露问题,这在编写代码时需要额外注意,否则随着程序的运行,到后面可能出现内存溢出问题。后面将会介绍Java中的垃圾收集算法以及Java虚拟机是采用什么策略进行垃圾回收的。