JVM调优(二) - 基本垃圾回收算法和垃圾收集器

基本的垃圾回收算法

引用计数(Reference Counting)

比较古老的回收算法。原理是此对象有一个引用,则增加一个计数,删除一个引用则减少一个计数。垃圾回收时,只用收集计数为0的对象。

此算法最致命的是无法处理循环引用的问题。

标记-清除(Mark-Sweep)

mark_sweep

此算法执行分两阶段。第一阶段从引用根节点开始标记所有被引用的对象,第二阶段遍历整个堆,把未标记的对象清除。

缺点是此算法需要暂停整个应用,同时,会产生内存碎片

复制(Copying)

copying

此算法把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域,把正在使用中的对象复制到另外一个区域中。次算法每次只处理正在使用中的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理,不会出现“碎片”问题。

当然,此算法的缺点也是很明显的,就是需要两倍内存空间

标记-整理(Mark-Compact)

mark_compact

此算法结合了“标记-清除”和“复制”两个算法的优点。也是分两阶段,第一阶段从根节点开始标记所有被引用对象,第二阶段遍历整个堆,把清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放。此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。

但标记-整理算法唯一的缺点就是效率不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。

分代GC

以上算法各有缺陷,最终,大神们找到了一个GC算法中的神级算法 – 分代GC,详见分代GC详述

垃圾收集器

自动垃圾收集机制

自动垃圾收集机制是查看堆内存、区分在使用的对象和未使用的对象、删除未使用的对象的一个过程。对于使用对象或者引用对象,指的是你的程序持有一个指向那个对象的引用。对于未使用的对象或者是无引用对象,则不被你程序的任何部分持有引用。所以,无引用对象使用的内存是可以被重新回收利用的。

在类C语言的编程语言中,内存的分配和回收都是手动的。而在Java中,内存的回收是由垃圾回收器自动处理的。基本的步骤可以描述如下:

步骤一:标记

第一步是标记,通过这一步骤来区分哪块内存在使用,那哪块内存未使用。

marking

引用对象用蓝色标识,未引用的对象用金色标识。在标记阶段,扫描所有的对象并判断。如果系统中所有的对象都要被扫描的,那么这一步骤可能非常耗时。

步骤二(a):正常删除

正常删除移除无引用对象,留下引用对象及指向空闲空间的指针。

normal_deletion

内存分配器持有空闲内存的引用,这些空闲内存都链接到一个List中,当需要的时候可以分配给新的对象。

步骤二(b):带压缩删除

为了进一步改善性能,除了删除未引用的对象,用户也可以压缩存活的引用对象。把引用对象移动到一起,通过这种方法可以使更快速、更方便的分配新的内存。

deletion_with_compacting

JVM垃圾收集器种类

现在我们已经知道垃圾收集器的一些基本原理。本节将会详细讲解Java可以使用的垃圾回收器,以及在命令行如何选用配置它们。配置JVM有很多可以用的命令行参数,本节选用常用的配置参数进行详细解。

与堆配置相关的命令行参数

参数 描述
-Xms JVM启动的时候设置初始堆的大小
-Xmx 设置最大堆的大小
-Xmn 设置年轻代的大小
-XX:PermSize 设置持久代的初始大小
-XX:MaxPermSize 设置持久代的最大值

串行(Serial)收集器

在Java SE 5和6中,Serial收集器是客户端环境机器的默认设置。在这种情况下,小垃圾收集和大垃圾收集都是串行进行的(使用单个的虚拟CPU)。

使用的算法:

串行收集器在年轻代使用的是拷贝算法,这个算法比较简单,在这里不做详述。而年老代和持久代使用标记-清扫-压缩(mark-sweep-compact)算法

标记阶段,收集器识别哪些对象仍然活着。清扫阶段“扫荡”整个代,识别垃圾。之后,收集器执行平移压缩(sliding compaction),将存活的对象平移到代的前端(持久代类似),相应的在尾部留下一整块连续的空闲空间。压缩后,以后的分配就可以在年老代和持久代使用空闲指针(bump-the-pointer)技术。这种压缩算法能够在堆上迅速分配内存块。

在有大量JVM运行在同一个机器上(在某些情况下,JVM的个数比可以用的处理器的个数多)的应用环境下,串行垃圾收集器也被广泛使用。在这种环境下,要进行垃圾回收的JVM最好使用一个处理器,虽然这样会使垃圾回收的时间变得更长,但可以降低与其他JVM的冲突。这时,使用串行垃圾回收器能够获得很好的权衡。最后,如果在较小的内存和较少的CPU核心上对硬件进行稍加扩充,将能获得更好的性能。

命令行参数:

1
-XX:+UseSerialGC

并行(Parallel)收集器

并行垃圾收集器在年轻代使用多线程进行垃圾回收。默认情况下,在N个CPU的主机上,并行垃圾收集器使用N个垃圾收集器线程进行垃圾回收。垃圾收集器线程的个数可以在命令行进行设置:-XX:ParallelGCThreads=<期望的数值>

使用的算法:

年轻代:与Serial垃圾收集器年轻代相同的拷贝算法,只不过是该算法的并行版本,使用多个CPU并行的运行,减少了垃圾收集的开销,因此增加了吞吐量。

年老代:与Serial垃圾收集器老年代相同的标记-清扫-压缩(mark-sweepcompact)算法,只不过是该算法的并行版本。

命令行参数:

1
-XX:+UseParallelGC

使用这个命令行参数,就会将年轻代设置为多线程的收集器,老年代使用单线程的收集器。该选项还会在老年代进行单线程的压缩工作。

1
-XX:+UseParallelOldGC

使用该参数,年轻代和老年代都会使用多线程的收集器,同时,也使用多线程的压缩收集器。HotSpot仅仅在老年代进行整理,在年轻代是一个复制收集器,因此没必要进行整理。

压缩描述的是这样一种行为:移动对象使得个对象之间没有空闲位置。再一次垃圾收集的清理之后,存活对象在内存中的存储位置之间可能存在空闲区。整理移动对象,使得对象的存储都是顺序的,彼此之间没有空闲区。垃圾收集器可能也是一个不带压缩的收集器。所以,并行收集器和并行压缩收集器之间的区别就是后者在垃圾收集清理操作之后,对内存空间进行一次整理。

并发标记清理(CMS)收集器

并发标记清理收集器(CMS,又叫作并发低暂停收集器)在老年代进行收集。由于垃圾收集能使用应用线程的并发进行大多数的垃圾收集工作,所以它降低了应用程序的暂停时间。

正常说来,并发低暂停的收集器对存活对象不进行复制和压缩的工作。这种情况下,垃圾收集器没有移动任何存活对象。如果因此而带来了内存的碎片问题,那就为其分配一个更大的堆。

注意:CMS收集器在年轻代使用和并行收集器一样的算法。

命令行参数:

如果要使用CMS收集器,使用:

1
-XX:+UseConcMarkSweepGC

同时,可以设置并发的线程数目:

1
-XX:ParallelCMSThreads=<n>

G1垃圾收集器

在Java 7中可以使用G1垃圾回收器,它设计的初衷是用于长期取代CMS收集器。G1垃圾收集器是一个并行、并发,同时也是基于增量整理的低暂停垃圾收集器。与前面所描述的垃圾收集器相比,从布局方面与它们有很大的不同,本文不对该部分做详细的说明。

命令行参数:

1
-XX:+UseG1GC

Java 8各种垃圾收集器比较

实验

OpenJDK 8 有多种 GC(Garbage Collector)算法,如 Parallel GC、CMS 和 G1。哪一个才是最快的呢?如果在 Java 9 中将 Java 8 默认的 GC 从 Parallel GC 改为 G1 (目前只是建议)将会怎么样呢?让我们对此进行基准测试。

为了方便查看,我已经对每种 GC 与 Java 8 默认 GC(Parallel GC)进行了比较。

GC_performance

结果非常清楚:默认(Parallel GC)是最快的。

结论

在 Java 8 中,对 OptaPlanner 用例来说,默认 GC(Parallel GC)通常情况是最好的选择。