Java的垃圾回收机制

前言

在C++语言中, 程序员必须小心谨慎的处理每一项内存分配, 且内存使用完后必须手动释放曾经占用的内存空间。当内存释放不够完全时, 即存在分配但永不释放的内存块, 就会引起"内存泄漏"问题。

而在Java语言中, 它给了程序员一个美好的承诺: 程序员无需管理内存, 因为JVM会有GC去自动进行垃圾回收。其实不然:

  • 垃圾回收并不会按照程序员的要求, 随时进行GC。
  • 垃圾回收并不会及时的清理内存, 尽管有时程序需要额外的内存。
  • 程序员不能对垃圾回收进行控制。

基于上面的事实, 我们就有必要彻底了解JVM的自动内存管理机制, 如此才能将程序控制于鼓掌之中。本篇文章就是从垃圾回收内存分配这两个知识点, 对JVM的内存管理机制做一个基本的了解。

为什么要进行垃圾回收?

随着程序的运行,内存中存在的实例对象、变量等信息占据的内存越来越多,如果不及时进行垃圾回收,必然会带来程序性能的下降,甚至会因为可用内存不足造成一些不必要的系统异常。

哪些垃圾要进行回收?

在Java内存运行时区域的各个部分, 其中程序计数器、JVM栈、本地方法栈3个区域的生命周期是和线程同步的, 他们占用的内存会随着线程销毁而自动释放, 所以这几个区域不需要过多的考虑垃圾回收问题。

而Java堆和方法区则不一样, 一个接口中的多个实现类需要的内存可能不一样,  一个方法中的多个分支需要的内存也可能不一样, 我们只有在程序处于运行期间才能知道会创建哪些对象, 这部分内存的分配和回收是动态的, 所以需要进行GC。

什么时候进行垃圾回收?

垃圾收集器在对Java堆进行回收前, 会先去确定所有的对象实例之中哪些还"存活"着, 哪些已经"死去"(即已经不存在任何引用)。

在很多教科书中是根据引用计数算法来判断对象是否可回收的: 给对象中添加一个引用计数器, 每被引用一次,计数器加1; 引用失效时,计数器减1; 当计数器在一段时间内保持为0时,该对象就被认为是可回收的。但是, 这个算法有明显的缺陷: 当两个对象相互引用,但是二者已经没有作用时,按照常规,应该对其进行垃圾回收,但是其相互引用,又不符合垃圾回收的条件,因此无法完美处理这块内存清理。因此Sun的JVM并没有采用引用计数算法, 而是采用了可达性分析算法来进行垃圾回收。

可达性分析算法的基本思想是: 通过一系列的称为"GC Roots"的对象作为起始点, 从这些节点开始向下搜索, 搜索所走过的路径称为引用链, 当一个对象到GC Roots没有任何引用链相连时, 则证明此对象是不可用的。如下图所示, 对象object5、object6、object7虽然互相有关联, 但是它们到GC Roots是不可达的, 所以它们将会被判定为是可回收的对象。

无论是引用计数算法, 还是可达性分析算法, 它们判定对象是否存活都与"引用"有关。在JDK 1.2之后, Java对引用的概念进行了扩充,引入了强、软、弱、虚四种引用, 这4种引用强度依次逐渐减弱。关于这几种引用的概念, 读者可自行了解, 这里就不多做赘述。

另外, 即使在可达性分析算法中不可达的对象,也并非是"非死不可"的。如果类重写了finalize()方法, 且没有被虚拟机调用过, 那么虚拟机会调用一次finalize()方法, 以完成最后的工作, 在此期间, 如果对象重新与引用链上的任何一个对象建立关联,则该对象可以“重生”; 如果对象这时候还没有逃脱, 那么它就真的被回收了。

垃圾收集器在对方法区进行回收前, 会先去判定一个类是否是"无用的类", 而类需要同时满足下面3个条件才能算是"无用的类":

  • 该类的所有实例对象都已经被回收。
  • 加载该类的ClassLoader已经被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用, 无法在任何地方通过反射访问该类的方法。

如何进行垃圾回收?

在Java堆中, 内存被分为新生代旧生代, 两者比例为1:2。新生代适合那些生命周期较短、频繁创建及销毁的对象, 旧生代适合生命周期相对较长的对象和需要大量连续内存空间的大对象。

如上图所示, 新生代中分为Eden区和Survivor区, 而Survivor区又分为大小相同的两部分:FromSpace 和ToSpace。其中Eden区和一个Survivor区的默认空间比例为8:1, 可以用-XX:SurvivorRatio来设置大小。大多数情况下, 对象在新生代Eden区中分配, 当Eden空间不足时, 虚拟机将发起一次Minor GC把存活的对象转移到Survivor区中。新生代采用复制算法收集内存。

旧生代中用于存放新生代中经过多次垃圾回收仍然存活的对象, 和一些需要大量连续内存空间的大对象。另外在JVM中还有一种动态对象年龄判定: 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半, 年龄大于或等于该年龄的对象就可以直接进入旧生代。旧生代采用标记-整理(压缩)算法收集内存。

垃圾收集算法

在上文中, 我们提及到复制算法和标记-整理(压缩)算法, 这两种算法就是常见的GC算法之一。

标记-清除算法(Mark-Sweep)

标记-清除是最基础的GC算法, 分为"标记"和"清除"两个阶段: 首先标记出所有需要回收的对象, 然后扫描和回收所有被标记的对象。它有两个不足: 其一, 标记和清除两个过程的效率都不高;其二, 标记清除之后会产生大量不连续的内存碎片, 空间碎片太多可能会导致以后在分配大对象时, 无法找到足够的连续内存而不得不提前触发另一次GC动作。

复制算法(Copying)

前文提到,  新生代分为1块Eden区和2块Survivor区, 其中Eden区和一个Survivor区的默认空间比例为8:1, 即另一块Survivor区是空闲的。在垃圾回收时, 将Eden区和Survivor区还存活着的对象一次性地复制到另一块Survivor空间上, 然后清理掉刚才用过的Eden和Survivor空间。当第二块Survivor空间不够用时, 就需要依赖旧生代进行分配担保。复制算法适用于新生代。

标记-整理(压缩)算法(Mark-compact)

标记过程仍然与"标记-清除"算法一样, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向一端移动, 然后直接清理掉端边界以外的内存。标记-整理算法适用于旧生代。

分代收集算法(Generational Collecting)

根据垃圾回收对象的特性, 不同阶段最优的方式是使用合适的算法用于本阶段的垃圾回收, 分代算法即是基于这种思想, 它将内存区间根据对象的特点分成几块, 根据每块内存区间的特点, 使用不同的回收算法, 以提高垃圾回收的效率。以Hot Spot 虚拟机为例, 它将Java堆分为新生代和旧生代, 这样就能根据各个年代的特点采用最适当的收集算法。

垃圾收集器分类

基于JDK 1.7 Update 14之后的HotSpot虚拟机包含的所有收集器如下图所示。 

Serial收集器

串行收集器主要有两个特点:第一,它仅仅使用单线程进行垃圾回收;第二,它独占式的垃圾回收。

在串行收集器进行垃圾回收时,Java 应用程序中的线程都需要暂停,等待垃圾回收的完成,这样给用户体验造成较差效果。虽然如此,串行收集器却是一个成熟、经过长时间生产环境考验的极为高效的收集器。新生代串行处理器使用复制算法,实现相对简单,逻辑处理特别高效,且没有线程切换的开销。在诸如单 CPU 处理器或者较小的应用内存等硬件平台不是特别优越的场合,它的性能表现可以超过并行回收器和并发回收器。在 HotSpot 虚拟机中,使用-XX:+UseSerialGC参数可以指定使用新生代串行收集器和旧生代串行收集器。当 JVM 在 Client 模式下运行时,它是默认的垃圾收集器。

ParNew收集器

并行收集器是工作在新生代的垃圾收集器,它只简单地将串行回收器多线程化。它的回收策略、算法以及参数和串行回收器一样。

并行回收器也是独占式的回收器,在收集过程中,应用程序会全部暂停。但由于并行回收器使用多线程进行垃圾回收,因此,在并发能力比较强的 CPU 上,它产生的停顿时间要短于串行回收器,而在单 CPU 或者并发能力较弱的系统中,并行回收器的效果不会比串行回收器好,由于多线程的压力,它的实际表现很可能比串行回收器差。

开启并行回收器可以使用参数-XX:+UseParNewGC,该参数设置新生代使用并行收集器,旧生代使用串行收集器。

Parallel Scavenge收集器

新生代并行回收收集器也是使用复制算法的收集器。从表面上看,它和并行收集器一样都是多线程、独占式的收集器。但是,并行回收收集器有一个重要的特点:它非常关注系统的吞吐量。

新生代并行回收收集器可以使用以下参数启用:

  • -XX:+UseParallelGC:新生代使用并行回收收集器,旧生代使用串行收集器。
  • -XX:+UseParallelOldGC:新生代和旧生代都使用并行回收收集器。

另外, 并行回收收集器与并行收集器另一个不同之处在于,它支持一种自适应的 GC 调节策略,使用-XX:+UseAdaptiveSizePolicy可以打开自适应 GC 策略。在这种模式下,新生代的大小、eden 和 survivor 的比例、晋升旧生代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。在手工调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量 (GCTimeRatio) 和停顿时间 (MaxGCPauseMills),让虚拟机自己完成调优工作。

Serial Old收集器

旧生代串行收集器使用的是标记-压缩算法。和新生代串行收集器一样,它也是一个串行的、独占式的垃圾回收器。由于旧生代垃圾回收通常会使用比新生代垃圾回收更长的时间,因此,在堆空间较大的应用程序中,一旦旧生代串行收集器启动,应用程序很可能会因此停顿几秒甚至更长时间。虽然如此,旧生代串行回收器可以和多种新生代回收器配合使用,同时它也可以作为 CMS 回收器的备用回收器。若要启用旧生代串行回收器,可以尝试使用参数-XX:+UseSerialGC指定新生代、旧生代都使用串行回收器。

Parallel Old收集器

旧生代的并行回收收集器也是一种多线程并行的收集器。和新生代并行回收收集器一样,它也是一种关注吞吐量的收集器。旧生代并行回收收集器使用标记-压缩算法,JDK1.6 之后开始启用。

使用-XX:+UseParallelOldGC可以在新生代和旧生代都使用并行回收收集器,这是一对非常关注吞吐量的垃圾收集器组合,在对吞吐量敏感的系统中,可以考虑使用。参数-XX:ParallelGCThreads也可以用于设置垃圾回收时的线程数量。

CMS (Concurrent Mark Sweep)收集器

与并行回收收集器不同,CMS 收集器主要关注于系统停顿时间。CMS 是 Concurrent Mark Sweep 的缩写,意为并发标记清除,从名称上可以得知,它使用的是标记-清除算法,同时它又是一个使用多线程并发回收的垃圾收集器。

CMS 工作时,主要步骤有:初始标记、并发标记、重新标记、并发清除和并发重置。其中初始标记和重新标记是独占系统资源的,而并发标记、并发清除和并发重置是可以和用户线程一起执行的。因此,从整体上来说,CMS 收集不是独占式的,它可以在应用程序运行过程中进行垃圾回收。

根据标记-清除算法,初始标记、并发标记和重新标记都是为了标记出需要回收的对象。并发清理则是在标记完成后,正式回收垃圾对象;并发重置是指在垃圾回收完成后,重新初始化 CMS 数据结构和数据,为下一次垃圾回收做好准备。并发标记、并发清理和并发重置都是可以和应用程序线程一起执行的。

CMS 收集器在其主要的工作阶段虽然没有暴力地彻底暂停应用程序线程,但是由于它和应用程序线程并发执行,相互抢占 CPU,所以在 CMS 执行期内对应用程序吞吐量造成一定影响。CMS 默认启动的线程数是 (ParallelGCThreads+3)/4),ParallelGCThreads 是新生代并行收集器的线程数,也可以通过-XX:ParallelCMSThreads 参数手工设定 CMS 的线程数量。当 CPU 资源比较紧张时,受到 CMS 收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

由于 CMS 收集器不是独占式的回收器,在 CMS 回收过程中,应用程序仍然在不停地工作。在应用程序工作过程中,又会不断地产生垃圾。这些新生成的垃圾在当前 CMS 回收过程中是无法清除的。同时,因为应用程序没有中断,所以在 CMS 回收过程中,还应该确保应用程序有足够的内存可用。因此,CMS 收集器不会等待堆内存饱和时才进行垃圾回收,而是当前堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在 CMS 工作过程中依然有足够的空间支持应用程序运行。

这个回收阈值可以使用-XX:CMSInitiatingOccupancyFraction 来指定,默认是 68。即当旧生代的空间使用率达到 68%时,会执行一次 CMS 回收。如果应用程序的内存使用率增长很快,在 CMS 的执行过程中,已经出现了内存不足的情况,此时,CMS 回收将会失败,JVM 将启动旧生代串行收集器进行垃圾回收。如果这样,应用程序将完全中断,直到垃圾收集完成,这时,应用程序的停顿时间可能很长。因此,根据应用程序的特点,可以对-XX:CMSInitiatingOccupancyFraction 进行调优。如果内存增长缓慢,则可以设置一个稍大的值,大的阈值可以有效降低 CMS 的触发频率,减少旧生代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个阈值,以避免频繁触发旧生代串行收集器。

标记-清除算法将会造成大量内存碎片,离散的可用空间无法分配较大的对象。在这种情况下,即使堆内存仍然有较大的剩余空间,也可能会被迫进行一次垃圾回收,以换取一块可用的连续内存,这种现象对系统性能是相当不利的,为了解决这个问题,CMS 收集器还提供了几个用于内存压缩整理的算法。

-XX:+UseCMSCompactAtFullCollection 参数可以使 CMS 在垃圾收集完成后,进行一次内存碎片整理。内存碎片的整理并不是并发进行的。-XX:CMSFullGCsBeforeCompaction 参数可以用于设定进行多少次 CMS 回收后,进行一次内存压缩。

G1收集器

G1 收集器的目标是作为一款服务器的垃圾收集器,因此,它在吞吐量和停顿控制上,预期要优于 CMS 收集器。

与 CMS 收集器相比,G1 收集器是基于标记-压缩算法的。因此,它不会产生空间碎片,也没有必要在收集完成后,进行一次独占式的碎片整理工作。G1 收集器还可以进行非常精确的停顿控制。它可以让开发人员指定当停顿时长为 M 时,垃圾回收时间不超过 N。使用参数-XX:+UnlockExperimentalVMOptions –XX:+UseG1GC 来启用 G1 回收器,设置 G1 回收器的目标停顿时间:-XX:MaxGCPauseMills=20,-XX:GCPauseIntervalMills=200

上述几种垃圾收集器, 从不同角度分析, 可以将其分为不同的类型:

  • 按线程数分,可以分为串行垃圾回收器和并行垃圾回收器。串行垃圾回收器一次只使用一个线程进行垃圾回收;并行垃圾回收器一次将开启多个线程同时进行垃圾回收。在并行能力较强的 CPU 上,使用并行垃圾回收器可以缩短 GC 的停顿时间。
  • 按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器。并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间;独占式垃圾回收器 (Stop the world) 一旦运行,就停止应用程序中的其他所有线程,直到垃圾回收过程完全结束。
  • 按碎片处理方式可分为压缩式垃圾回收器和非压缩式垃圾回收器。压缩式垃圾回收器会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片;非压缩式的垃圾回收器不进行这步操作。
  • 按工作的内存区间,又可分为新生代垃圾回收器和旧生代垃圾回收器。

而要评价一个垃圾收集器的好坏, 可以用以下指标:

  • 吞吐量:指在应用程序的生命周期内,应用程序所花费的时间和系统总运行时间的比值。系统总运行时间=应用程序耗时+GC 耗时。如果系统运行了 100min,GC 耗时 1min,那么系统的吞吐量就是 (100-1)/100=99%。
  • 垃圾回收器负载:和吞吐量相反,垃圾回收器负载指垃圾回收器耗时与系统运行总时间的比值。
  • 停顿时间:指垃圾回收器正在运行时,应用程序的暂停时间。对于独占回收器而言,停顿时间可能会比较长。使用并发的回收器时,由于垃圾回收器和应用程序交替运行,程序的停顿时间会变短,但是,由于其效率很可能不如独占垃圾回收器,故系统的吞吐量可能会较低。
  • 垃圾回收频率:指垃圾回收器多长时间会运行一次。一般来说,对于固定的应用而言,垃圾回收器的频率应该是越低越好。通常增大堆空间可以有效降低垃圾回收发生的频率,但是可能会增加回收产生的停顿时间。
  • 反应时间:指当一个对象被称为垃圾后多长时间内,它所占据的内存空间会被释放。
  • 堆分配:不同的垃圾回收器对堆内存的分配方式可能是不同的。一个良好的垃圾收集器应该有一个合理的堆内存区间划分。

小结

在本篇文章中, 我们主要从为什么、哪些、什么时候、以及如何进行垃圾回收等4个方面对Java的垃圾回收机制做一个基本的认识, 另外也了解了GC的4种算法, 和垃圾收集器的分类概述及评价指标。

参考资料

《成神之路-基础篇》JVM——垃圾回收

猜你喜欢

转载自www.cnblogs.com/qingshanli/p/9265954.html