登录后台

页面导航

本文编写于 2519 天前,最后修改于 1509 天前,其中某些信息可能已经过时。

Java 引用的种类

Java 是面向对象的变成语言,一个 Java 程序往往需要创建大量 Java 类,然后再对各 Java 类创建大量 Java 对象,再调用这些 Java 对象的属性和方法来操作它们。

程序员需要通过关键字 new 创建 Java 对象,即可视作为 Java 对象申请内存空间,JVM 会在堆内存中为每个对象分配空间;当一个 Java 对象失去引用时,JVM 的垃圾回收机制会自动清除它们,并回收它们所占用的内存空间。

Java 内存管理包括内存分配(创建 Java 对象时)和内存回收两个方面(回收 Java 对象时)。这两方面工作都是由 JVM 自动完成的,因此降低了 Java 程序员的学习难度,以致让很多初级 Java 程序员不再关心程序内存分配。但这两方面的工作也加重了 JVM 的工作,从而使 Java 程序运行较慢。

对象在内存中的状态

对于 JVM 的垃圾回收机制来说,是否回收一个对象的标准在于:是否还有引用变量引用该对象?只要有引用变量引用该对象,垃圾回收机制就不会回收它。

也就是说,当 Java 对象被创建出来之后,垃圾回收机制会实时地监控每一个对象的运行状态,包括对象的申请、引用、被引用、赋值等。当垃圾回收机制实时地监控到某个对象不再被引用变量所引用时,立即回收机制就会回收它所占用的空间。

基本上,可以把 JVM 内存中对象引用理解成一种有向图,把引用变量、对象都当成有向图的顶点,将引用关系当成图的有向边,有向边总是从引用端指向被引用的 Java 对象。因为 Java 所有对象都是由一条一条线创建出来的,因此可把线程对象当成有向图的起始顶点。

对于单线程程序而言,整个程序只有一条 main 线程,那么该图就是以 main 进程为顶点的有向图。在这个有向图中,main 顶点可达的对象都处于可达状态,垃圾回收机制不会回收它们;如果某个对象在这个有向图中处于不可达状态,那么就认为这个对象不再被引用,接下来垃圾回收机制就会主动回收它了。

以下面的程序为例:

class Node {
    Node next;
    String name;
    public Node(String name) {
        this.name = name;
    }
}
public class NodeTest {
    public static void main(String[] args) {
        Node n1 = new Node("第一个节点");
        Node n2 = new Node("第二个节点");
        Node n3 = new Node("第三个节点");
        n1.next = n2;
        n2 = null;
        n3 = n2;
    }
}

上面的程序中定义了 3 个 Node 对象,并通过合适的关系把这 3 个 Node 对象组织在一起,应该可以清楚地绘制出 3 个 Node 对象在内存中引用关系图。接下来就可以把它们在 JVM 中对应的有向图绘制出来。

title=

从上图可以看出,从 main 顶点开始,有一条路径到达“第一个 Node 对象”,因此该对象处于可达状态,垃圾回收机制不会回收它;从 main 顶点开始,有两条路径到达“第二个 Node 对象”,因此该对象也处于可达状态,垃圾回收机制也不会回收它;从 main 顶点开始,没有路径可以达到“第三个 Node 对象”,因此这个 Java 对象就变成了垃圾,接下来垃圾回收机制就会开始回收它了。

JVM 的垃圾回收机制采用有向图方式来管理内存中的对象,因此可以方便地解决循环引用的问题。例如,有 3 个对象相互引用,A 对象引用 B 对象,B 对象引用 C 对象,C 对象又引用 A 对象,它们都没有失去引用,但只要从有向图的其实顶点(也就是进程根)不可达它们,垃圾回收机制就会回收它们。采用有向图来管理内存中的对象具有高的精度,但缺点是效率较低。

当一个对象在堆内存中运行时,根据它在对应有向图中的状态,可以把它所处的状态分成如下 3 中。

  • 可达状态:当一个对象被创建后,有一个以上的引用变量引用它。在有向图中可从起始顶点导航该对象,那他就处于可达状态,程序可通过引用变量来调用该对象的属性和方法。
  • 可恢复状态:如果程序中某个对象不再有任何引用变量引用它,它将先进入可恢复状态,此时从有向图的起始顶点不能导航到该对象。在这个状态下,系统垃圾回收机制准备回收该对象所占用的内存。在回收该对象之前,系统会调用可恢复状态的对象的 finalize 方法进行资源清理,如果系统在调用 finalize 方法之前重新让一个以上引用变量引用该对象,则这个对象会再次变为可达状态;否则,该对象将进入不可达状态。
  • 不可达状态:当对象的所有关联都被切断,且系统调用所有对象的 finalize 方法时依然没有使该对象变成可达状态,那这个对象将永久性失去引用,最后变成不可达状态。只有当一个对象处于不可达状态时,系统才会真正回收该对象所占用的资源。

下面的示图显示了对象的 3 种状态的转换。

title=

下面程序简单地创建了两个字符串对象,并创建了一个引用变量依次指向两个对象。

public class StatusTranfer {
    public static void test() {
        String a = new String("疯狂Java讲义");
        a = new String("轻量级 Java EE 企业级应用实战");
    }
    public static void main(String[] args) {
        test();
    }
}

当程序执行 test 方法的 String a = new String("疯狂Java讲义"); 时,代码定义了一个 a 变量,并让该变量指向了“疯狂Java讲义”字符串。该对象执行结束后,“疯狂Java讲义”字符串对象处于可达状态。

当程序执行了 test 方法的 a = new String("轻量级 Java EE 企业级应用实战"); 代码后,代码再次定义了“轻量级 Java EE 企业应用实战”字符串对象,并让 a 变量指向该对象。此时,“疯狂Java讲义”字符串对象处于可恢复状态,而“轻量级 Java EE 企业应用实战”字符串对象处于可达状态。

一个对象可以被另一个方法局部变量所引用,可以被其它类的类变量引用,或者被其它对象的实例变量所引用。当某个对象被其它类的类变量引用shiho,只有该类被销毁后,该对象才会进入可恢复状态;当某个对象被其它对象的实例变量引用时,只有当引用该对象的对象被销毁或变成不可达状态后,该对象才会进入不可达状态。

对垃圾回收机制来说,判断一个对象是否可回收的标准就在于该对象是否被引用,因此引用也是 JVM 进行内存管理的一个重要概念。为了更好地管理对象的引用,从 JDK1.2 开始,Java 在 java.lang.ref 包下提供了 3 个类:SoftReference、PhantomReference 和 WeakReference。它们分贝代表了系统对对象的 3 种引用方式:软引用、虚引用和弱引用。归纳起来,Java 语言对对象的引用有如下 4 种:

  • 强引用
  • 软引用
  • 弱引用
  • 虚引用

下面将分贝介绍这几种引用方式。

强引用

这是 Java 程序中最常见的引用方式,程序创建一个对象,并把这个对象赋给一个引用变量,这个引用变量就是强引用。

Java 程序可通过强引用来访问实际对象,前面介绍的程序中的所有引用变量都是强引用的方式。当一个对象被一个或一个以上的强引用变量所引用时,它处于可达状态,它不可能被系统垃圾回收机制回收。

强引用是 Java 变成中广泛使用的引用变量,被强引用所引用的 Java 对象绝不会被垃圾回收机制回收,即使系统内存非常紧张;即使有些 Java 对象以后永远都不会被用到,JVM 也不会回收被强引用所引用的 Java 对象。

由于 JVM 肯定不会回收强引用所引用的 Java 对象,因此强引用是造成 Java 内存泄漏的主要原因之一。

软引用

软引用需要通过 SoftReference 类来实现,当一个对象只具有软引用时,它有可能被垃圾回收机制回收。对于只有软引用的对象而言,当系统内存空间足够时,它不会被系统回收,程序也可使用该对象;当系统内存空间不足时,系统将会回收它。

软引用通常用于对内存敏感的程序中,软引用是强引用很好的替代。对于被强引用所引用的 Java 对象而言,无论系统的内存如何紧张,即使某些 Java 对象以后再也不可能使用,垃圾回收机制依然不会回收它所占用的内存。对于软引用则不同,当系统内存空间充足时,软引用与强引用没有太大的区别;当系统内存不足时,被软引用所引用的 Java 对象可以被垃圾回收机制回收,从而避免系统内存不足的异常。

当程序需要大量创建某个类的新对象,而且有可能重新访问已创建老对象时可以充分使用软引用来解决内存紧张的问题。

例如,需要访问 1000 个 Person 对象,可以有两种方式:

  • 依次创建 1000 个 Person 对象,但只有一个 Person 引用指向最后一个 Person 对象;
  • 定义一个长度为 1000 的 Person 数组,每个数组元素引用一个 Person 对象。

对于第一种情形而言,弱点很明显:程序不允许重新访问前面创建的 Person 对象,即使这个对象所占堆空间还没有被回收。但已经失去了这个对象的引用,因此也不得不重新创建一个 Person 对象(重新分配内存),而那个已有的 Person 对象(完整的、正确的、可用的)则只能等待垃圾回收。

对于第二种情形而言,优势是可以随时重新访问前面创建的每个 Person 对象,但弱点也很大。如果系统堆内存空间紧张,而 1000 个 Person 对象都被强引用引用着,垃圾回收机制也不可能回收它们的堆内存空间,系统性能将变的非常差,甚至因此内存不足导致程序中止。

如果使用软引用则是一种较好的方法。当堆内存空间足够时,垃圾回收机制不会回收 Person 对象,可以随时重新访问一个已有的 Person 对象,这和普通的强引用没有任何区别;但当堆内存空间不足时,系统也可以回收软引用引用的 Person 对象,从而提高程序运行性能,避免垃圾回收。

例如,下面创建一个一个 SoftReference 数组,通过 SoftReference 数组来保存 100 个 Person 对象,当系统内存充足时,SoftReference 引用和强引用并没有太大的区别。

class Person {
    String name;
    int age;
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    public String toString() {
        return "Person[name=" + name + ", age=" + age +"]";
    }
}
public class SoftReferenceTest {
    public static void main(String[] args) {
        SoftReference<Person>[] people = new SoftReference[100];
        for (int i = 0;i < people.length;i++) {
            people[i] = new SoftReference<Person>(new Person("名字" + i, (i + 1) * 4 % 100));
        }
        System.out.println(people[2].get());
        System.out.println(people[4].get());
        //通知系统进行垃圾回收
        System.gc();
        System.runFinalization();
        //垃圾回收机制运行之后,SoftReference数组里的元素保持不变
        System.out.println(people[2].get());
        System.out.println(people[4].get());
    }
}

上面程序创建了一个长度为 100 的 SoftReference 数组,程序使用这个数组来保存 100 个 Person 对象,当系统内存足够时,如上面程序所示,即使系统进行垃圾回收,垃圾回收机制也不会回收这些 Person 对象所占用的内存空间。在这种情况下,SoftReference 引用的作用与普通强引用的效果完全一样。运行上面程序,看到如下所示的结果:

Person[name=名字2, age=12]
Person[name=名字4, age=20]
Person[name=名字2, age=12]
Person[name=名字4, age=20]

如果将上面 SoftReference 数组的长度改为 100000,并修改运行上面程序的命令如下:

java -Xmx5m -Xms5m SoftReferenceTest

运行上的命令,可以看到下面的结果:

null
null
null
null

从上面的结果可以看出,当使用 java -Xmx5m -Xms5m SoftReferenceTest 命令强制堆内存只有 5m,而程序创建一个长度为 100000 的数组,这样将使得系统内存紧张。在这种情况下,软引用所引用的 Java 对象将会被垃圾回收。

对伊一下强引用的程序,将程序该为如下形式:

class Person {
    String name;
    int age;
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    public String toString() {
        return "Person[name=" + name + ", age=" + age +"]";
    }
}
public class SoftReferenceTest {
    public static void main(String[] args) {
        Person[] people = new Person[100000];
        for (int i = 0;i < people.length;i++) {
            people[i] = new Person("名字" + i, (i + 1) * 4 % 100);
        }
        System.out.println(people[2]);
        System.out.println(people[4]);
        //通知系统进行垃圾回收
        System.gc();
        System.runFinalization();
        //SoftReference数组里的元素保持不变
        System.out.println(people[2]);
        System.out.println(people[4]);
    }
}

上面程序以传统方式创建了一个 Person 数组,该 Person 数组长度为 100000,即程序的堆内存将会保存 100000 个 Person 对象,这样使得程序因为系统内存不足而中止。运行上面程序,可以看到如下所示的错误:

Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
        at SoftReferenceTest.main(SoftReferenceTest.java:17)

从上面的结果可以看出,当程序使用强引用时,无论系统堆内存如何紧张,JVM 垃圾回收机制都不会回收被强制引用所引用的 Java 对象,因此最后导致程序因内存不足而中止。但如果程序把强引用改为使用软引用,就可以完全避免这种枪口,这就是软引用的优势所在。

弱引用

弱引用与软引用有点相似,区别在于弱引用所引用对象的生存期更短。弱引用通过 WeakReference 类实现,弱引用和软引用很像,但弱引用的引用级别更低。对于只有弱引用的对象而言,当垃圾回收机制运行时,不管系统内存是否足够,总会回收该对象所占用的内存。当然,并不是说当一个对象只有弱引用时,它就会立即被回收————正如那些失去引用的对象一样,必须等到系统垃圾回收机制运行时才会被回收。

下面程序示范了弱引用所引用对象也会被系统垃圾回收的过程:

public class WeakReferenceTest {
    public static void main(String[] args) {
        //创建一个字符串对象
        String str = new String("疯狂 Java 讲义");
        //创建一个弱引用,让弱引用引用到"疯狂 Java 讲义"字符串
        WeakReference<String> wr = new WeakReference<String>(str);
        //切断str引用和"疯狂 Java 讲义"字符串之间的引用
        str = null;
        //取出弱引用所引用的对象
        System.out.println(wr.get());
        //强制垃圾回收
        System.gc();
        System.runFinalization();
        //再次取出弱引用所引用的对象
        System.out.println(wr.get());
    }
}

上面程序创建了一个“疯狂 Java 讲义”字符串对象,并让 str 变量引用它,执行 WeakReference<String> wr = new WeakReference<String>(str); 代码时,系统创建了一个弱引用对象,并让该对象和 str 引用同一个对象。当程序执行到 str = null; 代码时,切断了 str 和“疯狂 Java 讲义”字符串对象之间的引用关系。此时“疯狂 Java 讲义”字符串对象只有一个弱引用对象引用它,程序依然可以通过这个弱引用对象来访问该字符串常量,程序第一次执行 System.out.println(wr.get()); 时,依然可以输出“疯狂 Java 讲义”。接下来程序强制垃圾回收,如果系统垃圾回收机制启动,只有弱引用的对象就会被清理掉。当第二次执行 System.out.println(wr.get()); 时,通常就会看到输出 null,这表名该对象已经被清理了。

上面程序创建“疯狂 Java 讲义”字符串对象时,不要使用 String str = "疯狂 Java 讲义";,这样将看不到运行效果,因为采用 String str = "疯狂 Java 讲义"; 代码定义字符串时,系统会缓存这个字符串直接量(会使用强引用来引用它),系统不会回收被缓存的字符串常量。

弱引用具有很大的不确定性,因为每次垃圾回收机制执行时都会回收弱引用所引用的对象,而垃圾回收机制的运行又不受程序员的控制,因此程序获取弱引用所引用的 Java 对象时必须小心空指针异常————通过弱引用所获取的 Java 对象可能是 null。

由于垃圾回收的不确定性,当程序希望从弱引用中取出被引用对象时,可能这个被引用对象已经被释放了。如果程序需要使用那个被引用的对象,则必须重新创建该对象。这个过程可以采用两种风格的代码完成,下面代码显示了一种风格:

//取出弱引用所引用的对象
obj = wr.get();
//如果取出的对象为null
if (obj == null) {
    //重新创建一个新的对象,再次使用弱引用引用该对象
    wr = new WeakReference(recreateIt());
    //取出弱引用所引用的对象,将其赋给obj变量
    obj = wr.get();
}
...//操作obj对象
//再次切断obj和对象质检的关联
obj = null;

下面代码显示了另一种取出被引用代码的代码风格:

//取出弱引用所引用的对象
obj = wr.get();
//如果取出的对象为null
if (obj == null) {
    //重新创建一个新的对象,将其使用强引用来引用它
    obj = recreateIt();
    //取出弱引用所引用的对象,将其赋给obj变量
    wr = new WeakReference(obj);
}
...//操作obj对象
//再次切断obj和对象质检的关联
obj = null;

上面两段代码均采用伪码,其中 recreateIt 方法用于生成一个 obj 对象。这两段代码都是先判断 obj 对象是否已被回收,如果已经被回收则重新创建该对象。如果弱引用所引用的对象已经被垃圾回收释放了,则重新创建该对象。但第一段代码有一定的问题:当 if 块执行完成后,obj 还是有可能为 null。因为垃圾回收的不确定性,假设系统在执行 wr = new WeakReference(recreateIt());obj = wr.get(); 代码之间进行垃圾回收,则会再次将 wr 所引用的对象回收掉,从而导致 obj 依然为 null。第二段代码则不会存在这个文笔塔,当 if 块执行结束后,obj 一定不是 null。

与 WeakReference 功能类似的还有 WeakHashMap。其实程序很少会考虑直接使用单个的 WeakReference 来引用某个 Java 对象,因此这种时候系统内存往往不会特别紧张。当程序中有大量的 Java 对象需要使用弱引用来引用时,可以考虑使用 WeakReference 来保存它们,示例如下:

import java.util.WeakHashMap;

class CrazyKey {
    String name;
    public CrazyKey(String name) {
        this.name = name;
    }
    //重写hashcode()方法
    public int hashCode() {
        return name.hashCode();
    }
    //重写equals方法
    public boolean equals(Object obj) {
        if (obj == this) {
            return true;
        }
        if (obj != null && obj.getClass() == CrazyKey.class) {
            return name.equals(((CrazyKey)obj).name);
        }
        return false;
    }
}
public class WeakHashMapTest {
    public static void main(String[] args) throws InterruptedException {
        WeakHashMap<CrazyKey, String> map = new WeakHashMap<>();
        //循环放入10个key-value对
        for (int i = 0;i < 10;i++) {
            map.put(new CrazyKey(i + 1 + ""), "value" + (i + 11));
        }
        //垃圾回收之前,WeakHashMap与普通的HashMap并无区别
        System.out.println(map);
        System.out.println(map.get(new CrazyKey("2")));
        //通知垃圾回收
        System.gc();
        //暂停当前线程50ms,让垃圾回收后台线程获得执行
        Thread.sleep(50);
        //垃圾回收后,WeakHashMap里所有key-value对全部清空
        System.out.println(map);
        System.out.println(map.get(new CrazyKey("2")));
    }
}

上面程序两次调用了 System.out.println(map);System.out.println(map.get(new CrazyKey("2")));,分别用于查看 WeakHashMap 里所有key-value 对和获取指定 Key 对象的 value。运行上面的程序,将看到如下结果:

{CrazyKey@38=value18, CrazyKey@39=value19, CrazyKey@34=value14, CrazyKey@35=value15,
CrazyKey@36=value16, CrazyKey@37=value17, CrazyKey@61f=value20, CrazyKey@31=value11,
CrazyKey@32=value12, CrazyKey@33=value13}
value12
{}
null

从上面的结果可以看出:在垃圾回收机制运行之前,WeakHashMap 的功能与普通 HashMap 并没有太大的区别,它们的功能完全相似。但一旦垃圾回收机制被执行,WeakHashMap 中所有 key-value 对都会被清空,除非某些 key 还有强引用在引用它们。

虚引用

软引用和弱引用可以单独使用,但虚引用不能单独使用,单独使用虚引用没有太大的意义。虚引用的主要作用就是跟踪对象被垃圾回收的状态,程序可以通过检查与虚引用关联的引用队列中是否已经包含指定的虚引用,从而了解虚引用所引用对象是否即将被回收。

引用队列由 java.lang.ref.ReferenceQueue 类表示,它用于保存被回收后对象的引用。当把软引用、弱引用和引用队列联合使用时,系统回收被引用的对象之后,将会把回收对象对应的引用添加到关联的引用队列中,这使得可以在对象被回收之前采取行动。

虚引用通过 PhantomReference 类实现,它完全类似与没有引用。虚引用对对象本身没有太大影响,对象甚至感觉不到虚引用的存在。如果一个对象只有一个虚引用,那它和没有引用的效果大致相同。虚引用主要用于跟踪对象被垃圾回收的状态,虚引用不能单独使用,虚引用必须和引用队列(ReferenceQueue)联合使用。
下面程序与上面程序基本相似,只是使用了虚引用来引用字符串对象,虚引用无法获取它引用的对象。下面程序还将虚引用和引用队列结合使用,可以看到,被虚引用所引用的对象被垃圾回收后,虚引用将被添加到引用队列中。

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceTest {
    public static void main(String[] args) {
        String str = new String("疯狂 Java 讲义");
        //创建一个引用队列
        ReferenceQueue<String> rq = new ReferenceQueue<>();
        //创建一个虚引用,让次虚引用引用到"疯狂 Java 讲义"字符串
        PhantomReference<String> pr = new PhantomReference<String>(str, rq);
        //切断str引用和"疯狂 Java 讲义"字符串之间的引用
        str = null;
        //试图取出虚引用所引用的对象
        //程序并不能通过虚引用访问被引用的对象,所以此处为null
        System.out.println(pr.get());
        //强制垃圾回收
        System.gc();
        System.runFinalization();
        //取出引用队列中最先进入队列中引用与pr进行比较
        System.out.println(rq.poll() == pr);
    }
}

因为系统无法通过虚引用来获得被引用的对象,索引执行 System.out.println(pr.get()); 时,程序将输出 null(即使此时并未强制进行垃圾回收)。当程序强制垃圾回收后,只有虚引用引用的字符串将被垃圾回收,当被引用的对象被回收后,对应引用将被添加到关联的引用队列中,因为将在执行 System.out.println(rq.poll() == pr); 代码后看到 true。

使用这些引用类可以避免在程序执行期间将对象留在内存中。如果以软引用、弱引用或虚引用的方法引用对象,垃圾回收器就能够随意地释放对象。如果希望尽可能减少程序在其生命周期中所占用的内存大小,这些引用类就很有好处。

最后需要指出的是:要使用这些特殊的引用类,就不能保留对对象的强引用。如果保留了对对象的强引用,就会浪费这些类提供的任何好处。

Java 的内存泄漏

Java 向程序员许下一个美好的承诺:Java 程序员无需关心内存释放的问题,JVM 的垃圾回收机制会自动回收无用对象所占用的内存空间。这个承诺给很多 Java 初级程序员一个错觉:Java 程序不会有内存泄漏。但实际上,如果使用不当,Java 程序一样会有内存泄漏的问题存在。

为了搞清楚 Java 程序是否有内存泄漏存在,首先了解以下什么是内存泄漏:程序运行过程中会不断地分配内存空间,那些不再使用的内存空间应该立即及时回收它们,从而保证系统可以再次使用这些内存,如果存在无用的内存没有被回收起来,那就是内存泄漏。

对于 C++ 程序而言,对象所占的内存空间都必须由程序员来显式回收,如果程序员忘记了回收它们,那它们所占用的内存空间就会产生内存泄漏;对于 Java 程序来说,所有不可达的对象都由垃圾回收机制负责回收,因此程序员不需要考虑这部分的内存泄漏。如果程序中有一些 Java 对象,它们处于可达状态,但程序以后永远都不会再访问它们,那它们所占用的空间也会产生内存泄漏。

title=

上面的图片展示了 Java 和 C++ 内存泄漏的示意图。

相对来说,C++ 的内存泄漏更危险一些。对于上图中最外层的方框中的对象,它们处于不可达状态,因此程序根本不可能访问它们,程序即使想释放它们所占用的空间也无能为例了;但 JVM 的垃圾回收机制会自动回收这个方框中的对象所占用的内存空间,因为垃圾回收机制会实时监控每个对象的运行状态。

对于上图中间那个方框中的对象,它依然处于可达状态,程序以后永远也不会访问它们,按照正常情况,它们所占用的内存空间也应该被回收。对于 C++ 程序而言,程序员可在核实的时机释放它们所占用的内存空间;但对于 Java 程序而言,只要它们一直处于可达状态,垃圾回收机制就不会回收它们————即使它们对于程序来说已经变成了垃圾(程序再也不需要它们了),而对于垃圾回收机制来说,它们还不是垃圾(处于可达状态),因此不能回收。

回顾以下 ArrayList 中 remove(int index) 方法的源代码,程序如下:

    public E remove(int index) {
        //检查index索引是否越界
        rangeCheck(index);
        //使修改次数加1
        modCount++;
        //获取被删除元素
        E oldValue = elementData(index);
        int numMoved = size - index - 1;
        //整体搬家
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        //将ArrayList的size减1
        //并将最后一个数组元素赋为null,让垃圾回收机制回收最后一个元素
        elementData[--size] = null;
        return oldValue;
    }

上面程序中的 elementData[--size] = null; 就是为了避免垃圾回收机制而书写的代码,如果没有这行代码,这个方法就会产生内存泄漏————删除一个对象,但该对象所占用的内存空间却不会释放。

加入试图删除 ArrayList 的最后一个元素,假设该 ArrayList 底层的数组长度为 8,该 ArrayList 里装有 4 个元素,即该 ArrayList 的长度为4。该 ArrayList 在内存中的分配如下图左侧所示:

title=

当程序删除 ArrayList 最后一个元素,也就是上图中指向 "d" 的那个集合元素,当程序删除最后一个元素时,也就是要删除的元素所在的 index = size - 1,此时 ArrayList 无需整体搬家,程序只需要将 ArrayList 的 size 减 1。但如果不执行 elementData[--size] = null; 这行代码,ArrayList 在内存中的分配如上图右侧所示。

从上图右侧所示可以看出,如果没有 elementData[--size] = null; 这行代码,上图右侧所示数组的第 4 个元素将一直引用内存中原来的对象,那么这个对象将一直处于可达状态。但对于 ArrayList 而言,它的 size 等于 3,也就是说该 ArrayList 认为自己只有 3 个元素,因此它永远也不会去访问底层数组的第 4 个元素。对于程序员来说,这个对象已经便成了垃圾;但对于垃圾回收机制来说,这个数组依然处于可达状态,因此不会回收它,这就产生了内存泄漏。

下面的程序采用基于数组的方式实现了一个 Stack,他家可以找找这个程序中的内存些泄漏。

public class Stack {
    //存放栈内元素的数组
    private Object[] elementData;
    //记录栈内元素的个数
    private int size = 0;
    private int capacityIncrement;
    //以指定初始化容量创建一个Stack
    public Stack(int initialCapacity) {
        elementData = new Object[initialCapacity];
    }
    public Stack(int initialCapacity, int capacityIncrement) {
        this(initialCapacity);
        this.capacityIncrement = capacityIncrement;
    }
    //向“栈”顶压入一个元素
    public void push(Object object) {
        ensureCapacity();
        elementData[size++] = object;
    }
    public Object pop() {
        if (size == 0) {
            throw new RuntimeException("空栈异常");
        }
        return elementData[--size];
    }
    public int size() {
        return size;
    }
    //保证底层数组能容纳栈内所有元素
    private void ensureCapacity() {
        //增加堆栈的容量
        if (elementData.length == size) {
            Object[] oldElements = elementData;
            int newLength = 0;
            //已经设置capacityIncrement
            if (capacityIncrement > 0) {
                newLength = elementData.length + capacityIncrement;
            } else {
                //将长度扩充为原来的1.5倍
                newLength = (int)(elementData.length * 1.5);
            }
            elementData = new Object[newLength];
            //将元数组的元素复制到新数组中
            System.arraycopy(oldElements, 0, elementData, 0, size);
        }
    }
    public static void main(String[] args) {
        Stack stack = new Stack(10);
        //向栈顶压入10个元素
        for (int i = 0;i < 10;i++) {
            stack.push("元素" + i);
        }
        //依次弹出10个元素
        for (int i = 0;i < 10;i++) {
            System.out.println(stack.pop());
        }
    }
}

上面程序实现了一个简单的 Stack,并为这个 Stack 实现了 push()、pop() 两个方法,其中 pop() 方法可能产生内存泄漏。为了说明这个 Stack 导致的内存泄漏,程序 main 方法创建了一个 Stack 对象,先向该 Stack 压入 10 个元素。注意,此时底层 elementData 数组的长度为 10,每个数组元素都引用一个字符串。

接下来,程序 10 次调用 pop() 弹出栈顶元素。注意 pop() 方法产生的内存泄漏,它只做了两件事情:

  • 修饰 Stack 的 size 属性,也就是记录栈内元素减 1;
  • 返回 elementData 数组中索引为 size-1 的元素。

也就是说,没调用 pop() 方法一次,Stack会记录该栈的尺寸减 1,但并未清楚 elementData 数组最后一个元素的引用,这样就会产生内存泄漏。类似地,也应该按照 ArrayList 类的源代码来改写此处的 pop() 方法的源代码,如下所示:

    public Object pop() {
        if (size == 0) {
            throw new RuntimeException("空栈异常");
        }
        Object ele = elementData[--size];
        //清除最后一个数组元素的引用,避免内存泄漏
        elementData[size] = null;
        return ele;
    }

垃圾回收机制

垃圾回收机制主要完成下面两件事情:

  • 跟踪并监控每个 Java 对象,当某个对象处于不可达状态时,回收该对象所占用的内存;
  • 清理内存分配、回收过程中产生的内存碎片。

垃圾回收机制需要完成这两方面的工作,而这两方面的工作量都不算太小,因此垃圾回收算法就成为限制 Java 程序运行效率的重要因素。实现高效 JVM 的一个重要方法名就是提供高效垃圾回收机制。高效的垃圾回收机制既能保证垃圾回收的快速运行,避免内存分配和回收成为应用程序的性能瓶颈,又不能导致应用程序产生停顿。

垃圾回收的基本算法

前面已经介绍了,JVM 垃圾回收机制判断某个对象是否可以回收的唯一标准是:是否还有其它引用指向该对象?如果存在引用指向该对象,垃圾回收机制就不会回收该对象;否则,垃圾回收机制就会尝试回收它。

实际上,垃圾回收机制不可能实时检测每个 Java 对象的状态,因此当一个对象失去引用后,它不也不会被立即回收,只有等垃圾回收运行时才会被回收。

对于一个垃圾回收器的设计算法来说,大致有如下可供的选择:

  • 串行回收(Serial)和并行回收(Parallel):串行回收就是不管系统有多少个 CPU,始终只用一个 CPU 来执行垃圾回收操作;而并行回收就是把整个回收工作拆分成多部分,每个部分由一个 CPU 负责,从而让多个 CPU 并行回收。并行回收的执行效率很高,但复杂度增加,另外也有一些其它的副作用,比如内存碎片会增加。
  • 并发执行(Concurrent)和引用程序停止(Stop-the-world):Stop-the-world 的垃圾回收方式在执行垃圾回收的同时会导致应用程序的暂停。并发执行的垃圾回收虽然不会导致应用程序的暂停,但由于并发执行垃圾回收需要解决和应用程序的执行冲突(应用程序可能会在垃圾回收的过程中修改对象),因此并发操作垃圾回收的系统开销比 Stop-the-world 更高,而且执行时也需要等多的堆内存。
  • 压缩(Compacting)和不压缩(Non-compacting)和复制(Copying):为了减少内存碎片,支持压缩的垃圾回收器会把所有的活对象搬迁到一起,然后将之前占用的内存全部回收。不压缩式的垃圾回收器只是回收内存,这样回收回来的内存不可能是连续的,因此将会有较多的内存碎片。较之压缩式的垃圾税后,不压缩的垃圾回收器回收内存快,而分配内存时就会更慢,而且无法解决内存碎片的问题。复制式的垃圾回收器将所有可达对象复制到另一块相同的内存中,这种方式的优点是垃圾回收过程中不会产生内存碎片,但确定也很明显,需要复制数据和额外的内存。

上面介绍的复制、不压缩、压缩都是垃圾回收器回收已有内存空间的方式,关于这 3 种回收方式的详述如下:

  • 复制:将堆内存分成两个相同的空间,从根(类似于前面介绍的有向图的起始顶点)开始访问每一个关联的可达对象,将空间 A 的可达对象全部复制到空间 B,然后一次性回收整个空间 A。

对于复制算法而言,因为只需访问所有可达对象,将所有可达对象复制之后就回收整个空间,完全不用理会那些不可达对象,所以遍历空间的成本较小,但需要巨大的复制成本和较多的内存。

  • 标记清除(mark-sweep):也就是不压缩的回收方式。垃圾回收器先从根开始访问所有可达对象,将它们标记为可达状态,然后再遍历一次整个内存区域,把所有没有标记为可达的对象进行回收处理。

标记清除(mark-sweep)无需进行大规模的复制操作,而且内存利用率高。但这种蒜贩需要两次遍历堆空间,遍历的成本较大,因此造成引用程序暂停的时间随堆空间大小线性增大。而且垃圾回收器回收回来的内存往往是不连续的,因此整理后堆内存里的碎片很多。

  • 标记压缩(marl-sweep-compact):这是压缩方式,这种方式充分利用上述两种算法的有点,垃圾回收器先从根开始访问所有可达对象,将它们标记为可达状态。接下来垃圾回收器会将这些活动对象搬迁在一起,这个过程也被称为内存压缩,然后垃圾回收机制再次回收那些不可达对象所占用的内存空间,这样就避免了回收产生的内存碎片。

从上面的介绍可以看出,不论采用哪种机制实现垃圾回收,不论采用哪种内存回收方式,具体实现起来总是利弊参半的。因此,实际实现垃圾回收时总会综合使用多种设计方式,也就是针对不同的情况采用不同的垃圾回收实现。

现行的垃圾回收器用分代的方式来采用不同的回收设计。分代的基本思路是根据对象生存时间的长短,把堆内存分成 3 个代:

  • Young(年轻代);
  • Old(老年代);
  • Permanent(永久代)。

垃圾回收器会根据不同代的特点采用不同的回收算法,从而充分利用各种回收算法的优点。

堆内存的分代回收

分代回收的一个依据就是对象生存时间的长短,然后再根据不同代采取不同的垃圾回收策略。采用这种“分代回收”的策略基于如下两点事实:

  • 绝大多数的对象不会被长时间引用,这些对象在其 Young 期间就会被回收;
  • 很老的对象(生存时间很长)和很新的对象(生存时间很短)之间很少存在相互引用的情况。

上面这两点事实不仅在 Java 语言中如此,其它面向对象的变成语言也大致遵循这两个事实。

根据上面两点事实,对于 Young 代的对象而言,大部分对象都会很快进入不可达状态,只有少量的对象能熬到垃圾回收执行时,而垃圾回收器只需要保留 Young 代中处于可达状态的对象,如果采用复制算法只需要少量的复制成本,因此大部分垃圾回收器对 Young 代都采用复制算法。

Young 代

对 Young 代采用材质算法只需遍历那些处于可达状态的对象,而且这些对象的数量较少,可复制成本也不大,因此可以充分发挥复制算法的有点。

Young 代由 1 个 Eden 区和 2 个 Survivor 区构成。绝大多数对象先分配到 Eden 区中(有一些大的对象可能会直接被分配到 Old 代中),Survivor 区中的对象都至少在 Young 代中经历过一次垃圾回收,所以这些对象在被转移到 Old 代之前会保留在 Survivor 空间中。同一时间 2 个 Survivor 空间中有一个用来保存对象,而另一个是空的,用来在下次垃圾回收时保存 Young 代中的对象。每次复制即使将 Eden 和 第 1 个 Survivor 的可达对象复制到第 2 个 Survivor 区,然后清空 Eden 与第 1 个 Survivor 区。

Eden 和 Survivor 的比例通过 -XX:SurvivorRation 附加选项来设定,默认为 32。如果 Survivor 太大会产生浪费,太小则会使一些 Young 代的对象提前进入 Old 代。

Old 代

如果 Young 代中对象经过数次垃圾回收依然还没有被回收掉,即这个对象经过足够长的时间还处于可达状态,垃圾回收机制就会将这个对象转移到 Old 代。

Old 代的大部分对象都是“久经考验”的“老人”了,因此它们没那么容易死。而且随着时间的流逝,Old 代的对象会越来越多,因此 Old 代的空间要比 Young 代空间更大。出于这两点考虑,Old 代的垃圾回收具有如下两个特征:

  • Old 代垃圾回收的执行频率无需太高,因为很少有对象会死掉;
  • 每次对 Old 代执行垃圾回收需要更长的时间来完成。

基于以上考虑,垃圾回收器通常会使用标记压缩算法。这种算法可以避免复制 Old 代的大量对象,而且由于 Old 代的对象不会很快死亡,回收过程不会大量地产生内存碎片,因此相对比较划算。

Permanent 代

Permanent 代主要用于装载 Class、方法等信息,默认为 64M,垃圾回收机制通常不会回收 Permanent 代中的对象。对于那些需要加载很多类的服务器程序,往往需要加大 Permanent 代内存,否则可能因为内存不足而导致程序终止。

对于像 Hibernate、Spring 这类喜欢 AOP 动态生成类的框架,往往会大量的动态代理类,因此需要更多的 Permanent 代内存,相信读者在调试、运行 Hibernate、Spring 程序时应该见过 java.lang.OutOfMemoryError: PermGen space 的错误,这就是由 Permanent 代内存耗尽所导致的错误。

当 Young 代的内存将要用完的时候,垃圾回收机制会对 Young 代进行垃圾回收,垃圾回收机制会采用较高的频率对 Young 代进行扫描和回收。因为这种回收的系统开销比较小,因此也被称为次要回收(minor collection)。当 Old 代的内存将要用完时,垃圾回收机制会进行全回收,也就是对 Young 代和 Old 代都要进行回收,此时回收成本就大得多了,因此也称为主要回收(major collection)。

通常来说,Young 代的内存会先被回收,而且会使用专门的回收算法(复制算法)来回收 Young 代的内存;对于 Old 代的回收频率则要低得多,因此也会采用专门的回收算法。如果需要进行内存压缩,每个代都独立地进行压缩。

与垃圾回收的附加选项

下面两个选项用于设置 Java 虚拟机内存大小:

  • -Xmx:设置 Java 虚拟机内存的最大容量,如 java -Xmx256m XxxClass
  • -Xms:设置 Java 虚拟机内存的初始容量,如 java -Xms128m XxxClass

下面的选项都是关于 Java 垃圾回收机制的附加选项:

  • -XX:MinHeapFreeRatio = 40:设置 Java 堆内存最小的空闲百分比,默认值为 40,如 java -XX:MinHeapFreeRatio = 40 XxxClass
  • -XX:MaxHeapFreeRatio = 70:设置 Java 堆内存最大的空闲百分比,默认值为 70,如 java -XX:MaxHeapFreeRatio = 70 XxxClass
  • -XX:NewRatio = 2:设置 Young/Old 内存的比例,如 java -XX:NewRatio = 1 XxxClass
  • -XX:newSize = size:设置 Young 代内存的默认容量,如 java -XX:newSize = 64m XxxClass
  • -XX:SurvivorRatio = 8:设置 Young 代中 eden/survivor 的比例,如 java -XX:SurvivorRatio = 8 XxxClass
  • -XX:MaxNewSize = size:设置 Young 代内存的最大容量,如 java -XX:MaxNewSize = 128m XxxClass
当设置 Young 代的内存超过 -Xmx 设置的大小时,Young 设置的内存大小将不会起作用,JVM 会自动将 Young 代内存设置为与 `Xmx 设置的大小相等。
  • -XX:PermSize = size:设置 Permanent 代内存的默认容量,如 java -XX:PermSize = 128m XxxClass
  • -XX:MaxPermSize = size:设置 Permanent 代内存的最大容量,如 java -XX:MaxPermSize = 128m XxxClass

此处只是介绍了垃圾回收相关的常用选项,关于 Java 垃圾回收的常用选项请参看 Oracle 官方站点页面的介绍。http://www.oracle.com/technetwork/java/javase/tech/vmoptions-jsp-140102.html

常见的垃圾回收器

下面介绍一些常见的垃圾回收器。

串行回收器(Serial Collector)

串行回收器通过运行 Java 程序时使用 -XX:+UseSerialGc 附加选项启用。

串行回收器对 Young 代和 Old 代的回收都是串行的(只使用一个 CPU),而且垃圾回收执行期间会使得引用程序产生暂停。具体策略为,Young 代采用串行复制的算法,Old 代采用串行标记压缩算法。

系统将 Eden 中的活动对象直接复制到初始为空的 Survivor 区中(也就是 To 区),如果有些对象占用空间特别大,垃圾回收器会直接将其复制到 Old 代中。

对于 Form Survivor 区中的活动对象(该对象至少经历过一次垃圾回收),到底是复制到 To Survivor 区中,还是复制到 Old 代中,则取决于这个对象的生存时间:如果这个对象的生存时间较长,它将被复制到 Old 代中;否则,将被复制到 To Survivor 区中。

完成上面的复制之后,Eden 和 Form Survivor 区中剩下的对象都是不可达的对象,系统直接回收 Eden 区的所有内存,而原来空的 To Survivor 区则保存了活动对象。在下一次回收时,原本的 From Survivor 区域将变为 To Survivor 区,原本的 To Survivor区域将变为 From Survivor 区。

串行回收器对 Old 代的回收采用串行、标记压缩算法(marik-sweep-compact),这个算法有 3 个阶段:mark(标识可达对象)、sweep(清除)、compact(压缩)。在 mark 阶段,回收器会识别出哪些对象是可达的,在 sweep 阶段会将回收不可达对象所占用的内存。在 compact 阶段回收器执行 sliding compaction,把活动对象往 Old 代的前端启动,而在尾端保留一块连续的空间,以便下次为新对象分配内存空间。

并行回收器

并行回收器通过运行 Java 程序时使用 -XX:+UseParallelGC 附加选项启用,它可以充分利用计算机的多个 CPU 来提高垃圾回收吞吐量。

并行回收器对于 Young 代采用与串行回收器基本相似的回收算法,只是增加了多 CPU 并行的能力,即同时启动多线程并行来执行垃圾回收。线程默认为 CPU 的个数,当计算机 CPU 很多时,可用 -XX:ParallelGcThreads = size 来较少并行线程的数目。

并行回收器对于 Old 代采用与串行回收器完全相同的回收算法,不管计算机有几个 CPU,并行回收器依然采用单线程、标记整理的方式进行回收。

对于并行回收器而言,只有多 CPU 并行的机器才能发挥其优势。

并行压缩回收器(Parallel Compacting Collector)

并行压缩回收器实在 J2SE5.0 update 6 开始引入的,它和并行回收器最大的不同是对 Old 代的回收使用了不同的算法,并行压缩回收器最终会取代并行回收器。并行压缩回收器通过运行 Java 程序时使用 -XX:+UseParallelOldGc 附加选项启用,一样可通过 -XX:ParallelGcThreads = size 来设置并行线程的数目。

并行压缩回收器对于 Young 代采用与并行回收器完全相同的回收算法。

并行压缩回收器的改变主要提现在对 Old 代的回收上。系统首先将 Old 代划分为几个固定大小的区域。在 mark 阶段,多个垃圾回收线程会并行标记 Old 代的可达对象。当某个对象被标记为可达对象时,还会更新该对象所在区域的大小以及该对象的位置信息。

接下来是 summary 阶段。在 summary 阶段直接操作 Old 代的区域,而不是单个的对象。由于每次垃圾回收的压缩都会在 Old 代左边部分储存大量可达对象,对这样的高密度可达对象的区域进行压缩往往很不划算。所以 summary 阶段会从最左边的区域开始检查每个区域的密度,当检测到某个区域中能回收的空间达到了某个数值的时候(也就是可达对象的密度较小时),垃圾回收器会判定该区域以及该区域右边的所有区域都应该进行回收,而该区域左边的区域都会被标识为密集区域,垃圾回收器既不会把新对象移动到这些密集区域中去,也不会对这些密集区域进行压缩。该区域和其右边的所有区域都会被进行压缩并回收空间。summary 阶段目前还是串行操作,虽然并行是可以实现的,但重要性不如对 mark 和压缩阶段的并行重要。

最后是 compact 阶段。回收器利用 summary 阶段生成的数据识别出有哪些区域是需要装填的,多个垃圾回收线程可以并行地将复制到这些区域中。经过这个过程后,Old 代的一段会密集地存在大量活动对象,另一端则存在大块的空闲块。

并发标识——清理(Mark-Sweep)回收器(CMS)

并发标志——清理回收器通过运行 Java 程序时使用 -XX:+UseConcMarkSweepGc 附加选项启用。

CMS 回收器对 Young 代的回收方式和并行回收器的回收方式完全相同。由于对 Young 的回收依然采用复制算法,因此垃圾回收时依然会导致程序暂停,除非依靠多 CPU 并行来提高垃圾回收的速度。

通常来说,建议适当加大 Young 代的内存。如果 Young 代内存够大就不用频繁地进行垃圾回收,而且增加垃圾回收,而且增加垃圾回收的时间间隔后可以让更多的 Young 代对象自己死掉,从而避免复制。但将 Young 代内存舍得过大也有一个坏处:当垃圾回收器回收 Young 代内存时,复制成本会显著上升(复制算法必须等 Young 满了之后才开始回收),所以回收时会让系统暂停时间显著加长。

CMS 对 Old 代的回收多数是并发操作,而不是并行操作。垃圾回收开始的时候需要一个短暂的暂停,称之为初始标识(initial mark)。这个阶段仅仅标识出那些被直接引用的可达对象。接下来进入了并发标识阶段(concurrent marking phase),垃圾回收器会一句在初始标识中发现的可达对象来寻找其它可达对象。由于在并发标识阶段应用程序也会同时在运行,无法保证所有的可达对象都被标识出来,因此应用程序会再次很短地的暂停以下,多线程并行地重新标记之前可能因为并发而漏掉的对象,这个阶段也被称为再标记(remark)阶段。

完成了再标记以后,所有的可达对象都已经被标识出来了,接下来就可以运行并发清理操作了。

CMS 回收器的最大改进在于对 Old 代的回收,它只需 2 个短暂的暂停,而其它过程都是与应用程序并发执行的,因此对实时性要求较高的程序更合适。

对于串行、标记压缩的回收器而言,它可以等到 Old 代满了之后再开始回收,反正垃圾回收总会让应用程序暂停。但 CMS 回收器要与应用程序并发运行,如果 Old 满了才开始回收,那应用程序将无内存可用,所以系统默认在 Old 代 68% 满的时候就开始回收。如果系统内存设的比较大,而且程序分配内存速度不是特别块时,可以通过 -XX:CMSInitiatingOccupancyFraction = ratio 适当增大这个比例。

而且 CMS 不会进行内存压缩,也就是说不可达对象占用的内存被回收以后,垃圾回收器不会移动可达对象占用的内存。

由于 Old 代的可用空间不是连续的,因此 CMS 垃圾回收器必须保存一份可用空间的列表。当需要分配对象的时候,垃圾回收器就要通过这份列表找到能容纳新对象的空间,这就会使得分配内存时的效率下降,从而影响了 Young 代对象移到 Old 代的效率。

对于 CMS 回收器而言,当垃圾回收器执行并发标识时,应用程序在运行的同时也在分配对象,因此 Old 代也同时在增长。而且,虽然可达对象在标识阶段会被识别出来,但有些标识阶段成为垃圾的对象并不能立即被回收,只有等下次垃圾回收时才能会回收。因此 CMS 回收器较之前面的几种回收器需要更大的堆内存。

对于 Permanent 代内存,CMS 可通过运行 Java 程序时使用 -XX:CMSClassUnloadingEnabled -XX:+CMSPermGenSweepingEnabled 附加选项强制回收 Permanent 代内存。

内存管理的小技巧

尽可能多地掌握 Java 的内存回收、垃圾回收机制是为了更好地管理 Java 虚拟机的内存,这样才能提高 Java 程序的运行性能。根据前面介绍的内存回收机制,下面给出 内存管理的几个小技巧。

尽量使用直接量

当需要使用字符串,还有 Byte、Short、Integer、Long、Float、Double、Boolean、Character 包装类实例时,程序不应该采用 new 的方式来创建对象,而应该直接采用直接量来创建它们。

例如,程序需要“hello”字符串,应该采用如下代码:
String str = "hello";
上面这种方式会创建一个“hello”字符串,而且 JVM 的字符串缓存池还会缓存这个字符串。

但如果程序使用如下代码:
String str = new String("hello");
此时程序同样创建了一个缓存在字符串缓存池中的“hello”字符串。除此之外 str 所引用的 String 对象底层还包含一个 char[] 数组,这个 char[] 数组里依次存放了 h、e、l、l、o 等字符。

使用 StringBuilder 和 StringBuffer 进行字符串连接

String、StringBuilder、StringBuffer 都可以代表字符串,其中 String 代表字符序列不可变的字符串,而 StringBuilder 和 StringBuffer 都代表字符串序列可变的字符串。

如果程序使用多个 String 对象进行字符串连接运算,在运行时将生成大量临时字符串,这些字符串会保存在内存中从而导致程序性能下降。

尽早释放无用对象的引用

大部分时候,方法局部引用变量所引用的对象会随着方法结束而变成垃圾,因为局部变量的生存期限很短,当方法运行结束之时,该方法内的局部变量就结束了生命期限。因此,大部分时候无需将局部、引用变量显式设为 null。

例如下面的 info() 方法:

public void info() {
    Object obj = new Object();
    System.out.println(obj.toString());
    System.out.println(obj.hashCOde());
    obj = null;
}

上面程序中 info() 方法定义了一个 obj 变量,随着 info() 方法执行完毕,程序中 obj 引用变量的作用域就结束了,原来 obj 所引用的对象就会变成垃圾。因此上面程序中的 obj = null 代码是没有必要的。

但是换一种情况来看,如果上面程序中 info() 方法改为如下形式:

public void info() {
    Object obj = new Object();
    System.out.println(obj.toString());
    System.out.println(obj.hashCOde());
    obj = null;
    //执行耗时、耗内存操作
    //或者调用耗时、耗内存的方法
}

对于上面程序中所示的 info() 方法,如果在 obj = null 代码之后还需执行耗时、耗内存的操作,或者还需调用耗时、耗内存的方法,那程序中的 obj = null 代码就是很有必要的:可以尽早释放对 Object 对象的引用。可能的情况是:当程序执行 obj = null 代码之后的耗时、耗内存操作时, obj 之前所引用的 Object 对象可能被垃圾回收。

尽量少用静态变量

从理论上来说,Java 对象核实被回收由垃圾回收机制决定,对程序员来说是不确定的。由于垃圾回收机制判断一个对象是否垃圾的唯一标准就是该对象是否有引用变量引用它,因此推荐尽早释放对象的引用。

最坏的情况是某个对象被 staic 变量所引用,那么垃圾回收机制通常是不会回收这个对象所占的内存。示例如下:

class Person {
    static Object obj = new Object();
}

对于上面的 Object 对象而言,只要 obj 变量还引用到它,它就不会被垃圾回收机制所回收。obj 变量是 Person 类的静态变量,因此它的生命周期与 Person 类同步。在 Person 类不被卸载的情况下,Person 类对应的 Class 对象会常驻内存,知道程序运行结束。因此 obj 所引用的 Object 对象一旦被创建,也会常驻内存,知道程序运行结束。

根据前面介绍的分代回收机制,JVM 会将程序中 Person 类的信息存入 Permanent 代。也就是说 Person 类、obj 引用变量都将存在 Permanent 代里,这将导致 obj 对象一直有效,从而使得 obj 所引用的 Object 得不到回收。

避免在经常调用的方法、循环中创建 Java 对象

经常调用的方法和循环有一个共同特征,这些代码会多次重复调用。示例如下:

public class Test {
    public static void main(String[] args) {
        for (int i = 0;i < 10;i++) {
            Object obj = new Object();
            //执行其它操作...
        }
    }
}

上面代码在循环中创建了 10 个 Object 对象,虽然上面程序中的 obj 变量都是代码块的局部变量,当循环执行结束的时候这些局部变量都会失效,但由于这段循环导致 Object 对象会被创建 10 次,因此系统需要不断地为这 10 个对象分配内存空间,执行初始化操作。这 10 个对象的生存时间并不长,接下来系统又需要回收它们所占用的内存空间,在这种不断分配、回收操作中,程序的性能受到巨大的影响。

缓存经常使用的对象

如果有些对象需要被经常使用,可以考虑把这些对象用缓存池保存起来,这样当下次需要时就可以直接拿出来这些对象来用。典型的缓存就是数据库连接池,数据库连接池里缓存了大量数据库连接,每次程序需要访问数据库时,都可直接取出数据库连接。

除此之外,如果系统中还有一些常用的基础信息,比如信息化信息里包含的员工信息、物料信息等,也考虑对它们进行缓存。实现缓存时通常有两种方式:

  • 使用 HashMap 进行缓存;
  • 直接使用某些开源的缓存项目。

如果直接使用 HashMap 进行缓存,程序员需要手动控制 HashMap 容器里 key-value 对不至于太多,因为当 key-value 太多时将导致 HashMap 占用过大的内存,从而导致系统性能下降。

缓存设计本身就是一种以牺牲系统空间来缓存运行时间的技术,不管是哪种缓存实现,都会使用容器来保存已用过的对象,方便下次使用。而这个保存对象的容器将占据一块不算太小的内存,如果控制该容器占用的内存不至于过大,而该容器又能保留大部分已用过的对象,这就是缓存设计的关键。

除了使用 HashMap 进行缓存之外,还可以使用一些开源的缓存项目来解决这个问题。这些缓存项目都会主动分配一个一定大小的缓存容器,再按照一定的算法来淘汰容器中不需要继续缓存的对象。这样一方面可以通过缓存已用过的对象来提高系统的运行效率,另一方面又可以控制缓存容器的无限制扩大,从而减少系统的内存占用。对于这种开源的缓存实现有很多选择,如 OSCache、Ehcache 等,它们大都实现了 FIFO、MRU 等常见的缓存算法。

尽量不要使用 finalize 方法

前面介绍垃圾回收机制时已经提到,在一个对象失去引用之后,垃圾回收器准备回收该对象之前,垃圾回收机制会先调用该对象的 finalize() 方法来执行资源清理。处于这种考虑,可能有些开发者会考虑使用 finalize() 方法来进行资源清理。

实际上,将资源清理放在 finalize() 方法中完成是非常拙劣的选择。根据前面介绍的垃圾回收算法,垃圾回收机制的工作量已经够大了,尤其是回收 Young 代内存时,大都会引起应用程序暂停,是的用户难以忍受。

在垃圾回收器本身已经严重制约应用程序性能的情况下,如果再选择使用 finalize() 方法进行资源清理,无疑是一种火上浇油的行为,这将导致垃圾回收器的负担更大,导致程序运行效率更差。

考虑使用 SoftReference

当程序需要创建长度很大的数组时,可以考虑使用 SoftReference 来包装数组元素,而不是直接让数组元素引用对象。

SoftReference 是个很好的选择:当内存足够时,它的功能等同于普通引用;当内存不够时,它会牺牲自己,释放软引用所引用的对象。

在前面又一个例子,程序创建了一个长度为 100000 的 Person 数组,如果直接使用强引用的数组,这个 Person 数组将会导致程序内存溢出;如果改为创建长度为 100000 的软引用数组,程序将可以正常运行————当系统内存紧张时,系统会自动释放软引用所引用的对象,这样能保证程序的继续运行。

使用软引用来引用对象时不要忘记了软引用的不确定性。程序通过软引用所获取的对象有可能为 null————当系统内存紧张时,SoftReference 所引用的 Java 对象被释放。由于通过 SoftReference 获取的对象可能为 null,因此引用程序取出 SoftReference 所引用的 Java 对象之后,应该显式判断该对象是否为 null;当该对象为 null时,应重建这个对象。

本文转载自:《疯狂Java 突破程序员基本功的16课》第四章 Java 内存回收