【Java】Java垃圾收集

Java的一大特性就是内存的分配和回收都是自动进行的。当程序规模不大时,我们完全可以不考虑内存的使用情况。但是一旦程序的规模足够大,对性能的要求足够高时,了解Java垃圾收集(GC)的内部机制并根据具体的应用特征来调整使用的垃圾收集算法就显得十分重要了。

GC属性

  1. 吞吐量(Throughput):程序运行时间 /(程序运行时间 + 垃圾收集时间)
  2. 延迟(Latency):使程序尽可能少的因为垃圾回收而暂停的能力
  3. 足迹(Footprint):GC运行时使用的内存空间
  4. 敏捷度(Promptness):对象被标记为死亡到对象所占内存被回收所经历的时间

影响GC的因素

  1. 堆大小
  2. 堆内对象的存活率
  3. 内存分配速率
  4. 引用更新速率
  5. 对象的寿命

GC步骤

Mark

标记阶段,目的是将不可用的对象标记出来,以便进行后阶段的回收。那么,如何判断一个对象是否可用呢?这跟指向该对象的引用有很大的关系。因此,在具体研究对象可用性判定算法之前,让我们先看一看Java中不同的引用类型。

  • Java引用类型

    引用类型 GC操作
    强引用(Strong Reference) 任何时候都不能被回收
    软引用(Soft Reference) 内存空间不足时可被回收
    弱引用(Weak Reference) 将在下一次GC时被回收
    虚引用(Phantom Reference) 不影响对象寿命,仅在被回收时收到一个系统通知

    表中引用的强度由上至下依次减弱。可以看出,除了强引用,其他引用对于GC的执行并无太大的影响。因此,以下讨论中谈到的引用均指强引用

  • 引用计数算法(Reference Counting)

    该算法给每个对象添加一个引用计数器,当有引用指向对象时,计数器加1,当引用失效时,计数器减1。因此,当一个对象的引用计数变为0时,就证明该对象不可用,其所占用的内存也可以立即被释放。
    但是主流的Java虚拟机中并没有使用这一简单高效的算法来管理内存,主要原因就是它无法解决循环引用(Circular)的问题。也即,当对象A和对象B相互引用,而没有任何其他对象指向A和B时,由于A和B的引用计数均为1(不等于0),引用计数算法将无法回收这两个对象。
    同时,该算法对引用计数的频繁更新也会使得效率降低。

  • 可达性分析算法(Reachability Analysis)

    从一系列名为”GC Roots”的对象开始向下搜索,就可以形成若干条引用链。如果一个对象到”GC Roots”无任何引用链相连,该对象则被判定为可回收对象。
    可以作为GC Roots的对象包括以下几类:

    • 虚拟机栈中引用的对象
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • Native方法引用的对象

    该算法可以很好的解决循环引用的问题。同时,对于高度变化的程序来说比引用计数法效率更高。但是,在迅速发现不可用的对象方面,则没有引用计数法那么快。

Clean Up

清理阶段。将Mark阶段标记出的不可用对象清除,释放其所占用的内存空间。主要有以下几种实现方式。

  • 清除(Sweep)

    算法思想:遍历堆空间,将Mark阶段标记不可用的对象清除。
    不足: 效率不高;空间问题,多次清除之后会产生大量的内存碎片。
    适用场景:对象寿命长的内存区域。
    该算法过程如下图所示:
    【Java】Java垃圾收集_第1张图片

  • 复制(Copying)

    算法思想:将内存划分为两个区域(大小比例可调整),每次只用其中一块,当此块内存用完时,就将存活对象复制到另一块内存中,并对当前块进行内存回收。
    优点:解决了内存碎片问题;内存分配效率提高。每次复制后对象在堆中都是线性排列的,因此内存分配时只需移动堆顶指针即可。
    不足:如果对象的存活率较高,大量的复制操作会显著的降低效率;内存空间浪费,每次都只能使用堆空间的一部分,代价高昂。
    该算法过程如下图所示:
    【Java】Java垃圾收集_第2张图片

  • 整理(Compacting)

    算法思想:将标记的所有可用对象向内存一端移动,然后直接清理边界以外的内存区域即可。
    优点:类似于复制算法,解决了内存碎片问题,内存分配效率提高;消除了复制算法对内存空间的浪费。
    不足:难以做到并行。
    该算法过程如下图所示:
    【Java】Java垃圾收集_第3张图片

分代回收(Generational)

前面所述的Mark-Clean算法都是针对整个堆区域的,每一次GC运行都需要对堆中所有的对象进行遍历。因此,随着堆中对象数量的增多,GC的效率就会随之下降。于是,GC对程序运行做出如下假设:

  • 大多数对象都会在创建后不久死亡
  • 如果对象已存活一段时间,那它很可能会继续存活一段时间

基于这两个假设,GC将堆中的对象按照存活时间分为三代:Young(新生代)、Old(老年代)、Perm(永久代)。其内存划分示意图如下:
【Java】Java垃圾收集_第4张图片

YOUNG 新生代

由图可见,新生代又可划分为三个区域:Eden,Survivor0,Survivor1。其中,Eden区最大,新对象的内存分配都在此区域进行。两个Survivor区域一个为From区,一个为To区,每次只使用其中的一个。
新生代的垃圾回收采用的是复制算法。第一次GC时,Eden区的存活对象会被复制到S0区。此后每次进行GC时,Eden区和From区的存活对象都会被复制到To区。如果一个对象在经历了几次垃圾回收后仍然存活,那么它就会被复制到Old Generation(老年代),此过程称为Promotion

OLD 老年代

老年代的对象是由新生代对象经过Promotion而来,基于前面列出的假设:“如果对象已存活一段时间,那它很可能会继续存活一段时间”,该区域的对象存活率普遍较高,因此一般采用Mark-Sweep或Mark-Compact算法。

PERM 永久代

永久代并不用来存储从老年代经过Promotion而来的对象,它存储的是元数据,包括已被虚拟机加载的类信息、常量、静态变量、方法等。该区域通常不会发生垃圾回收。

安全点/安全区域

在程序执行时,并非任何时候都可以停下来进行垃圾回收,只有到达某些特定的点时才能暂停,这些点称为安全点(Safepoint)。安全点的设定既不能太少以致于让GC等待时间过长,也不能太频繁导致运行时负荷增大。一般在方法调用、循环跳转、异常跳转处会产生安全点。
那么,如何在GC发生时让所有的用户线程都“跑”到最近的安全点上停下来呢,有以下两种方案:

  1. 抢先式中断:在GC发生时即中断所有用户线程,若有的线程中断的地方不是安全点,则恢复该线程,让它跑到安全点上再暂停。(几乎不用)
  2. 主动式中断:GC在其要开始运行前设置一个标志。而每个用户线程在运行过程中都会去主动的轮询这个标志,如果标志为真则主动中断挂起。由于轮询标志的地方和安全点重合,因此线程暂停的地方一定是安全的。

但是,以上实现方案有一种情况无法解决,那就是用户线程不运行的时候,也即处于sleep或blocked状态的时候。由于此时线程无法轮询中断标志,也就不能保证GC开始时它一定处于安全状态。此时就需要引入安全区域(Safe Region)的概念了,它是指在一段代码片段中,对象之间的引用关系不会发生变化。安全区域可以看做是扩展了的安全点。
当用户线程执行到Safe Region时,首先会标志自己进入了安全区域。那么,就算GC要开始时该线程处于blocked状态,GC也可以放心的执行垃圾回收动作了。而当线程要离开Safe Region时,要先检查GC是否已经完成。如果完成了,线程就可以继续执行,否则需等待直到收到可以安全离开Safe Region的信号为止。

垃圾收集器

不同的虚拟机中通常有不止一种的垃圾收集器,它们实现了不同的垃圾收集算法。以下列举在Sun HotSpot虚拟机中包含的垃圾收集器。

YOUNG 新生代

  • Serial(-XX:+UseSerialGC)

    单线程收集器,采用复制算法。GC运行时会暂停所有的用户线程(STW,Stop The World)。是虚拟机运行在Client模式下的默认新生代收集器。

  • ParNew(-XX:+UseParNewGC)

    Serial收集器的多线程版本,除此之外与Serial收集器几乎完全相同。是许多运行在Server模式下的虚拟机中首选的新生代收集器。原因之一是它是唯一能与CMS配合使用的新生代收集器。
    可使用-XX:ParallelGCThreads参数指定垃圾收集的线程数。

  • Parallel Scavenge(-XX:+UseParallelGC)

    与ParNew一样,是使用复制算法的多线程收集器。但是不同于ParNew对缩短垃圾收集时用户线程停顿时间的关注,Parallel Scavenge更多的是关注提高程序的吞吐量,因此常被称为“吞吐量优先”收集器。适用于在后台运算而没有太多交互的任务。

OLD 老年代

  • Serial Old

    Serial收集器的老年代版本,单线程收集器,使用Mark-Compact算法。
    主要用于Client模式下的虚拟机。但在Server模式下也有两大用途。

    • 在JDK1.5及之前版本中与Parallel Scavenge搭配使用
    • 作为CMS收集器的后备预案,在发生Concurrent Mode Failure时使用
  • Parallel Old(-XX:+UseParallelOldGC)

    Parallel Scavenge的老年代版本,使用多线程和Mark-Compact算法,JDK1.6开始提供。
    下图展示了Serial和Parallel收集器的工作模式。
    【Java】Java垃圾收集_第5张图片

  • CMS(-XX:+UseConcMarkSweepGC)

    Concurrent Mark Sweep,其工作过程可分为以下四个步骤:

    • 初始标记(Initial Mark):STW方式。标记GC Roots直接引用的对象,时间很短。
    • 并发标记(Concurrent Mark):进行GC Roots Tracing,标记出所有可用对象。
    • 重新标记(Remark):对并发标记期间因程序继续运行而变化的引用进行修正,停顿时间比初始标记长,但远比并发标记短。
    • 并发清除(Concurrent Sweep):清除不可用对象,释放内存。

    该过程示意图如下所示:
    【Java】Java垃圾收集_第6张图片

    由图可见,CMS执行过程中大部分阶段都是与用户线程并行进行的,因此用户线程暂停时间会大大减少。但是由于CMS在进行清理时,用户线程也在运行,也即此时仍然会有新的垃圾产生。这些垃圾称为“浮动垃圾”(Floating Garbage)。由于“浮动垃圾”产生于CMS标记阶段之后,它们只能等到下一次GC时才可被回收。所以,CMS并不能等到老年代几乎要满了才开始垃圾收集动作,它必须预留足够的空间给用户线程在垃圾收集过程中使用。如果预留的空间预估不准的话,就有可能出现以下两种情况:

    1. Concurrent Mode Failure:在CMS运行期间预留的内存空间不够用户线程使用,这将触发一次Full GC,即启动后备预案(Serial Old收集器)来重新进行老年代的垃圾收集。这可能会导致数分钟的用户线程停顿。
    2. Promotion Failure:由于CMS采用的是Mark-Sweep算法,因此在执行了几次GC之后老年代会存在大量的内存碎片。如果从新生代经过Promotion而来的对象过大,就很有可能找不到足够的空间来分配。这也会提前触发一次Full GC。

    可使用-XX:+CMSInitiatingOccupancyFraction参数来指定在老年代空间被使用多少后触发垃圾收集,默认为68%。

G1(Garbage First,-XX:+UseG1GC)

之所以把G1单独列出来,是因为它在内存年代划分上不同于上面介绍的所有收集器。G1把内存分为很多个大小相等的独立区域(Region),新生代和老年代不再是相互隔离的,而是都由若干个非连续的Region组成。除此之外,G1收集器还有以下几个特点:

  • 并行与并发:可大大缩短STW的时间
  • 分代收集:G1可以不需要其他收集器的配合而独立管理整个GC堆,而且它能够使用不同的方式去处理不同年代的对象
  • 空间整合:G1整体上采用Mark-Compact算法,消除了内存碎片
  • 可预测的停顿:通过跟踪各个Region中垃圾堆积的价值大小(可回收的空间大小以及回收所需时间的经验值),G1维护了一个优先列表,每次根据允许的收集时间,优先对价值最大的Region进行回收。

当然,由于跨Region引用的存在,垃圾收集并不能真的以Region为单位进行。对于这种情况,G1通过为每一个Region维护一个Remember Set(RSet)来避免进行全堆扫描。RSet中记录了其他Region中的对象指向本Region对象的引用信息。
忽略RSet的维护操作,G1的执行过程主要分为以下四步,其与CMS的执行过程很相似:

  1. 初始标记(Initial Mark):STW方式。标记GC Roots直接引用的对象,并修改TAMS的值,使下一阶段用户线程并发运行时能够在正确可用的Region中创建新对象。时间很短。
  2. 并发标记(Concurrent Mark):进行GC Roots Tracing,标记出所有可用对象。
  3. 最终标记(Final Mark):将并发标记期间因程序继续运行而变化的引用合并到RSet中。
  4. 筛选回收(Live Data Counting and Evacuation):对各个Region按照回收价值和成本进行排序,然后根据用户所期望的GC停顿时间来制定回收计划。

其执行过程如下图所示:
【Java】Java垃圾收集_第7张图片
【Java】Java垃圾收集_第8张图片

GC监控

在实际应用中,常常需要根据不同的应用特征调整垃圾收集器的配置方案。在调整过程中,不免需要监控各种收集器的运行过程来进行性能的比较。JDK自带了一个Visual VM工具来可视化GC的执行过程。笔者最近为了跟踪服务器上不同垃圾收集器实现的性能,分析了较多的GC日志。不同收集器生成的日志格式可能不尽相同,但都有一定的共性。下面列出的是在实际应用中使用ParNew+CMS和使用G1时产生的日志,从中可以很清楚的看到CMS和G1的执行阶段以及GC运行时用户线程暂停的时间,有兴趣的朋友可以研究一下(可以右键‘在新标签页中打开图片’查看清晰大图)。

ParNew+CMS日志
【Java】Java垃圾收集_第9张图片

G1日志
【Java】Java垃圾收集_第10张图片

使用的日志相关参数如下

-verbose:gc
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintGCDateStamps
-XX:+PrintHeapAtGC

其他注意点

  1. 避免显式调用System.gc()或Runtime.getRuntime().gc()。这两个方法只是给虚拟机一个建议,是否执行垃圾回收还是由虚拟机来决定。
  2. 不要在finalize()方法中释放资源。
  3. 不要尝试在finalize()方法中逃脱垃圾回收。

你可能感兴趣的:(JAVA+JVM)