7.5.2 垃圾回收

垃圾回收器对虚拟机上运行的Java程序有着非常重要的作用,也会对程序的性能产生不同的影响。垃圾回收器在实现上要考虑的因素非常多,并不存在一个完美的算法能够适合不同Java程序的运行情况。垃圾回收器的实现算法更多的是对各种不同因素的权衡和取舍,而权衡的依据是程序本身的特性和需求。为了适合不同的程序的运行情况,HotSpot虚拟机提供了多种不同的垃圾回收算法。这些算法的具体细节虽然各不相同,但是都采用了分代回收的方式。

1.分代回收方式

分代回收是垃圾回收中的一种常见算法。这种算法的特点是把内存划分成不同的世代(generation),分别对应虚拟机中存活时间不同的对象。进行这种划分的依据是从对象存活时间得出的统计规律。在一般程序的运行过程中,大部分对象的存活时间比较短。比如,在一个方法内部创建的局部变量,方法执行完并退出之后,这些变量所引用的对象的内存就不再需要,可以被回收。只有少量对象存活的时间会比较长,还有极少数对象的存活时间与程序本身一样长。对于存活时间不同的对象,可以采用不同的回收策略。对于包含存活时间较短的对象的内存空间,其中所包含的存活对象较少,可被回收的内存区域较多,而且状态变化比较快,因此,对这个世代的内存进行回收的频率比较高,速度比较快。而对于存活时间较长的对象,回收的频率可以比较低。

在HotSpot虚拟机中,一般把内存划分成3个世代:年轻、年老和永久世代。大部分对象所需内存的分配是在年轻世代区域进行的。当垃圾回收器运行时,年轻世代中的很多对象可能已经不再存活,可以直接被回收。而有些对象可能仍然处于存活状态。某些对象可能在经过若干个垃圾回收操作之后,仍然处于存活状态。对于这些仍处于存活状态的对象,垃圾回收器会把这些对象移动到年老世代的内存区域。永久世代中包含的是Java虚拟机自身运行所需的对象。年轻世代被进一步划分成伊甸园(eden)和两个存活区(survivor space)。大部分对象的内存分配都是在伊甸园中进行的。由于伊甸园的内存空间较小,因此某些所需内存较大的对象无法直接在伊甸园中进行分配,而直接在年老世代中进行。两个存活区中总有一个是空白的。在对年轻世代进行垃圾回收时,先把伊甸园中的存活对象复制到当前空白的存活区中,接着对另外一个非空白存活区中的存活对象进行处理。如果对象的存活时间较短,那么同样将其复制到空白的存活区中;如果对象存活时间已经较长,那么将其复制到年老世代区域。在复制到空白的存活区的过程中,如果发现该存活区已满,就把这些存活对象直接复制到年老世代区域。经过这两次复制之后,就可以把伊甸园和非空白存活区中的内容直接全部清空。因为这两个区域中的对象要么不再存活,要么已经被复制到了其他内存区域中。在完成垃圾回收之后,下次的内存分配可以继续从空白的伊甸园开始进行,两个存活区的作用也发生了交换。

对于年老和永久世代内存区域,通常采用另外一种回收算法。这种算法分3个具体的步骤,其名称也来源于这3个步骤,称为标记-清除-压缩(mark-sweep-compact)算法。第一个步骤的作用是扫描整个内存区域,把当前仍然存活的对象标记出来;第二个步骤的作用则是清理内存区域,清除垃圾;第三个步骤的作用是压缩整个内存区域,把存活对象所占的内存都移动到内存区域的起始位置,使内存中可用区域是连续的。经过压缩之后,在年老和永久世代中进行内存分配就变得很容易,只需从可用区域的开头位置进行分配即可。

2.解决永久世代内存不足

垃圾回收器在每次回收操作时所处理的内存世代区域并不相同。一次较小的回收操作只会对年轻世代进行回收处理,一次较大的回收操作会处理年老世代,而完全的回收操作会对整个内存区域进行处理。这些回收操作的运行频率也并不相同。在垃圾回收器的运行过程中,最常回收的是年轻世代的内存区域;对于年老世代的内存区域的回收操作要少得多;而对于永久世代来说,回收的操作就更少。永久世代中存放的一般是虚拟机运行所需的元数据,包括加载的Java类等。如果程序中加载的类比较多,可能会造成永久世代的空间不够,而出现OutOfMemoryError错误。错误的提示信息一般是“java.lang.OutOfMemoryError:PermGen space”。如果出现这样的错误,可以通过启动参数“-XX:MaxPermSize”为永久世代指定一个更大的内存容量。不过需要注意的是,虚拟机对字符串的内部化处理,有可能会造成永久世代的内存不足。

由于Java中的String类的对象是不可变的,为了提高字符串比较时的性能,Java提供了一种字符串内部化(intern)的机制。这种机制的实现方式是在虚拟机中缓存String类的对象,当需要使用包含相同字符串的String类对象的时候,可以直接使用缓存中的对象。这样只需要简单地使用“==”操作符就可以比较两个String对象是否相等,而不需要使用更加耗时的equals方法。虚拟机会对Java源代码中的字符串字面量进行内部化处理,同时也可以使用String类的intern方法来得到一个缓存的String类的对象。这种内部化机制在Java 7中被用到了数值型的基本类型上,具体的细节可以参见第6章。

不过需要注意的一点是,不同虚拟机在实现字符串内部化机制时有很大不同。在一些虚拟机实现中,所缓存的String对象是保存在永久世代中的。如果使用了太多的内部化String对象,对象又都处于被引用的状态,就会导致永久世代的内存不足,出现OutOfMemoryError错误。永久世代的内存容量一般都比较小,比较容易出现内存不足的问题。在代码清单7-23中,通过循环来不断地生成包含随机内容的字符串,并调用String类的intern方法来缓存这些字符串。使用List接口的作用是保持对创建出来的String对象的引用,防止被垃圾回收器处理。

代码清单7-23 由于字符串内部化机制造成内存不足的示例


public class StringIntern{

private List<String>list=new ArrayList<String>();

public void useInternString(){

Random random=new Random();

for(int i=0;i<200;i++){

char[]data=new char[128*1024];

for(int j=0;j<data.length;j++){

data[j]=(char)random.nextInt(32768);

}

list.add((new String(data)).intern());

}

}

}


上面的代码在不同的虚拟机上的运行结果不同。在OpenJDK的Java 7虚拟机上不会出现OutOfMemoryError错误。通过检查垃圾回收器的输出信息可以发现,大部分的内存占用发生在年老世代中,而不是永久世代中。在JDK 6更新21的虚拟机中运行时,会出现由于永久世代内存不足而造成的OutOfMemoryError错误。因此,如果程序中使用了大量的字符串字面量或是String类的intern方法,有可能会产生兼容性的问题。程序在某个虚拟机上可以正确运行,换到另外一个虚拟机上可能会出现OutOfMemoryError错误。如果字符串是由用户或其他程序提供的,那么一定不要调用这些字符串对象的intern方法,因为有可能会使程序被恶意攻击,比如一个Web应用接受用户提供的字符串作为输入。在通过servlet请求获取表示参数值的字符串对象之后,不应该调用该字符串对象的intern方法。如果调用了intern方法,攻击者可以通过发送一些内容很长的字符串的方式进行攻击,当虚拟机尝试缓存这个对象时,可能会因为OutOfMemoryError错误而退出。

另外一个会造成永久世代内存不足的原因是加载的Java类过多。虚拟机中当前加载的类的元数据是保存在永久世代中的。如果加载的类过多,会导致永久世代内存不足而引发OutOfMemoryError错误。代码清单7-24给出了一个示例,通过ASM工具创建出一个简单的Java类的字节代码,将其保存在一个字节数组中。由于LoadClass类继承自Java标准库中的java.lang.ClassLoader类,可以直接调用defineClass方法从包含字节代码的数组中得到表示Java类的Class类的对象。在调用了defineClass方法之后,Java类的元数据被保存在永久世代中。运行之后会发现,当加载的类的数量达到一定值时,虚拟机会抛出OutOfMemoryError错误来说明永久世代区域内存不足。

代码清单7-24 由于加载的Java类过多造成内存不足的示例


public class LoadClass extends ClassLoader{

public void loadManyClasses(){

int num=50000;

String classNamePrefix="ManyClass";

for(int i=0;i<num;i++){

String className=classNamePrefix+i;

createAndLoadClass(className);

}

}

private void createAndLoadClass(String className){

ClassWriter cw=new ClassWriter(ClassWriter.COMPUTE_FRAMES);

cw.visit(V1_7,ACC_PUBLIC|ACC_SUPER, className, null,"java/lang/Object",null);

cw.visitEnd();

byte[]classData=cw.toByteArray();

this.defineClass(className, classData,0,classData.length);

}

}


3.选择垃圾回收算法

以上介绍的是分代回收的方式,HotSpot虚拟机提供的垃圾回收算法都使用了这种方式,不过在其他方面仍然有很多不同。在为程序选择合适的垃圾回收算法时,需要综合考虑多个因素。

第一个需要考虑的因素是使用串行或并行的回收方式。如果虚拟机运行的硬件平台只有一个CPU,那么垃圾回收工作只能由这个CPU按照串行的方式来完成。如果存在多个CPU的情况,那么可以利用这些CPU并行地进行回收工作。

第二个需要考虑的因素是对回收过程中发现的存活对象的处理方式。第一种做法是进行压缩,即把存活对象移动到内存区域的一端,使内存中的空闲区域变成连续的。这样的好处是分配内存时的速度会非常快,只需要从空闲区域的端点开始计算,判断是否有足够的空闲区域满足分配请求也会很容易。不足之处是需要花费额外的时间来进行存活对象的移动操作。第二种做法是不进行压缩,即存活对象仍然被保留在原始位置。这样的好处是垃圾回收操作可以很快完成。不足之处是分配内存的过程会比较慢,这是因为内存中的可用空间不是连续的,需要逐个查找可用内存块来寻找满足当前分配请求的空闲块。第三种做法是进行复制,即把存活对象复制到另外一个区域之中。这样做的好处是在复制操作完成之后,之前的内存区域就变成了全部可用的内存空间,内存分配速度会很快。不足之处是需要花费额外的时间来进行复制操作。总的来说,这三种方式都要求在垃圾回收操作所花费的时间及后续的内存分配操作所花费的时间这两者之间进行权衡。

理解这些不同的因素及如何在这些因素之间进行取舍,对大多数用户和开发人员来说是一件很困难的事情,因此,对虚拟机的默认垃圾回收机制的选择就显得尤为重要。绝大多数程序在运行时都不会对默认的垃圾回收机制进行修改。

HotSpot虚拟机会根据硬件平台的能力来选择最适合的垃圾回收方式。硬件平台根据其性能被划分成服务器级别和非服务器级别两类。服务器级别指的是有两个或两个以上的CPU以及2 GB以上物理内存的硬件平台。对于服务器级别的机器,默认使用并行回收方式,堆内存大小的初始值和最大值分别是物理内存的1/64和1/4;对于非服务器级别的机器,默认使用串行回收方式,堆内存大小的初始值和默认值分别是4 MB和64 MB。

如果虚拟机默认的垃圾回收的相关设置不能满足要求,可以通过修改启动参数的方式进行调整。可以使用的启动参数比较多,不过对于一般用户来说,最简单的启动参数是指明垃圾回收机制所要达到的目标。这种目标驱动的方式对于用户来说更加直接。垃圾回收机制的目标包括降低回收时造成的程序的停顿时间、提高吞吐量和降低程序的内存占用量等。第一个目标是降低由于垃圾回收造成的程序的停顿时间。在进行垃圾回收时,一个不可避免的问题是当前运行程序的停顿。过长的停顿时间会对程序造成比较大的影响。某些对实时性要求很高的程序更加不允许出现较长时间的停顿。如果对停顿时间有严格的要求,那么可以通过虚拟机的启动参数“-XX:MaxGCPauseMillis”来指定最长的停顿时间。虚拟机会调整垃圾回收器的其他参数来保证这个目标得以满足。第二个目标是提高程序运行的吞吐量。吞吐量指的是虚拟机花费在垃圾回收操作上的时间和程序实际运行时间的比例。虚拟机应该保证把尽可能多的时间花费在程序运行上,同时保证正常的垃圾回收操作不受影响。通过启动参数“-XX:GCTimeRatio”可以指定垃圾回收时间所占的比例,如使用“-XX:GCTimeRatio=49”就指明垃圾回收时间所占的比例是1/(1+49)=2%。最后一个目标是降低虚拟机占用的内存总量。当前面两个目标满足时,垃圾回收器会降低堆内存的大小,直到出现了前面两个目标不满足的情况。这样的作用是在满足前两个目标的前提下,尽可能地减少程序所占用的内存。

如果默认的垃圾回收算法和目标驱动的配置方式都不能满足需求,可以通过启动参数直接指定所要使用的垃圾回收算法。这种配置方式要求对垃圾回收算法有比较深入的了解,一般只建议有经验的开发人员使用。

第一种可用的垃圾回收方式是串行回收,这也是非服务器级别的硬件平台上的默认回收方式。可以通过启动参数“-XX:+UseSerialGC”来显式指定。

第二种可用的垃圾回收方式是并行回收,这也是服务器级别的硬件平台上的默认回收方式。可以通过启动参数“-XX:+UseParallelGC”来显式地指定。并行回收方式相对于串行回收方式的改进体现在对年轻世代进行回收时会利用多个CPU来并行完成,可以减少垃圾回收操作造成的停顿时间和整体的回收操作所占用的时间,提高吞吐量。而对于年老世代,并行回收的方式与串行回收的方式是一样的。

由于并行回收的方式对于年老世代并没有采用并行的做法,因此性能会受到一定的影响。并行压缩回收方式改进了这一点,对于年老世代的回收也采用并行的处理方式,可以通过启动参数“-XX:+UseParallelOldGC”来使用这种回收方式。这种回收方式会把年老世代的内存区域划分成若干个固定大小的子区域。对每个子区域以并行的方式同时进行存活对象的标记工作。由于标记工作是并行进行的,执行的效率会比较高。在完成标记工作之后,下一步是找出这些子区域中需要进行压缩操作的部分。这是因为某些子区域中存活对象所占的比例可能比较大,不需要进行压缩。不需要压缩的子区域一般都出现在年老世代的某一端。找到了需要进行压缩的子区域之后,就可以通过复制和移动存活对象等操作来压缩子区域,使存活对象密度较高的子区域都出现在年老世代内存区域的某一端,方便后续的内存分配。

如果希望程序运行时因垃圾回收操作而造成的停顿时间比较短,那么可以使用并发标记清除的回收方式。这种回收方式对年轻世代的做法和并行回收方式一样,而对于年老世代的处理方式则比较复杂,分成几个具体的阶段。第一个阶段是初始的标记阶段。这个阶段会先暂停程序的运行,再标记出从程序的代码中直接可达的存活对象。完成这个阶段之后,程序可以继续运行。与此同时,垃圾回收器会从上一阶段标记出的存活对象出发,递归地标记可达的其他存活对象。这个标记过程与程序的运行并发进行。完成这个并发的标记阶段之后,下一个阶段会再次暂停程序的运行。这是因为在上一个标记阶段,由于程序仍然在运行,对象的存活状态可能已经发生了变化。这一个阶段会再次暂停程序,并对这些发生变化的对象重新进行标记。重新标记完成之后,就可以继续程序的运行,同时并发地对标记过程中发现的垃圾进行回收处理。这种回收方式所造成的程序停顿时间比较短,但是会要求比较大的堆内存。另外,这种回收方式不会进行内存区域的压缩操作,使得后续的内存分配操作比较耗时。通过启动参数“-XX:+UseConcMarkSweepGC”可以指定使用这种回收方式。

Java 7中添加了一个新的垃圾回收方式,即垃圾优先方式(garbage first),简称为G1。G1所采用的回收方式不同于之前已有的其他回收算法。G1也采用了把内存区域划分成不同世代的做法,对年轻世代进行更加频繁的回收操作。但是G1并没有把年轻世代和年老世代所占用的内存区域从物理上分隔开来,而是把整个堆内存划分成大小相同的子区域,每个子区域的内容可以属于年轻世代或年老世代。垃圾回收的过程是先找出需要进行回收的子区域和接收待回收子区域中存活对象的其他子区域。回收的过程是并行进行的,把待回收子区域中的存活对象移动到用来接收的其他子区域中,再把原始的子区域清空。回收操作比较多地发生在年轻世代的子区域中,对于年老世代的子区域也会顺带进行处理。启用G1回收器需要使用参数“-XX:+UnlockExperimentalVMOptions-XX:+UseG1GC”来完成。G1的主要目标是替换前面介绍的并发标记清除回收方式。