深刻理解Java中的Garbage Collection

前提

最近因为系统业务量比较大,从生产的GC日志(结合Pinpoint)来看,须要对部分系统进行GC调优。可是鉴于以往不是专门作这一块,可是一直都有零散的积累,这里作一个相对全面的总结。本文只针对HotSpot VM也就是Oracle Hotspot VM或者OpenJDK Hotspot VM,版本为Java8,其余VM不必定适用。java

什么是GC(Garbage Collection)

Garbage Collection能够翻译为“垃圾收集” -- 通常主观上会认为作法是:找到垃圾,而后把垃圾扔掉。在VM中,GC的实现过程偏偏相反,GC的目的是为了追踪全部正在使用的对象,而且将剩余的对象标记为垃圾,随后标记为垃圾的对象会被清除,回收这些垃圾对象占据的内存,从而实现内存的自动管理。算法

分代假说(Generational Hypothesis)

名称 具体内容
弱分代假说(Weak Generational Hypothesis) 大多数对象在年轻时死亡
强分代假说(Strong Generational Hypothesis) 越老的对象越不容易死亡

弱分代假说已经在各类不一样类型的编程范式或者编程语言中获得证明,而强分代假说目前提供的证据并不充足,观点还存在争论。编程

分代垃圾回收器的主要设计目的是减小回收过程的停顿时间,同时提高空间吞吐量。若是采用复制算法对年轻代对象进行回收,那么指望的停顿时间很大程度取决于次级回收(Minor Collection)以后存活的对象总量,而这一数值又取决于年轻代的总体空间。数组

若是年轻代的总体空间过小,虽然一次回收的过程比较快,可是因为两次回收之间的间隔过短,年轻代对象有可能没有足够的时间“到达死亡”,于是致使回收的内存很少,有可能引起下面的状况:缓存

  • 年轻代的对象回收过于频繁而且存活下来须要复制的对象数量变多,增大垃圾回收器停顿线程和扫描其栈上数据的开销。
  • 将较大比例的年轻代对象提高到老年代会致使老年代被快速填充,会影响整个堆的垃圾回收速率。
  • 许多证据代表,对新生代对象的修改会比老年代对象的修改更加频繁,若是过早将年轻代对象晋升到老年代,那么大量的更新操做(mutation)会给赋值器的写屏障带来比较大的压力。
  • 对象的晋升会使得程序的工做集合变得稀疏。

分代垃圾回收器的设计师对上面几个方面进行平衡的一门艺术:数据结构

  1. 要尽可能加快次级回收的速度。
  2. 要尽可能减小次级回收的成本。
  3. 要减小回收成本更高的主回收(Major Collection)。
  4. 要适当减小赋值器的内存管理开销。

基于弱分代假说,JVM中把堆内存分为年轻代(Young Generation)和老年代(Old Generation),而老年代有些时候也称为Tenured多线程

j-v-m-g-c-s-2.png

JVM对不一样分代提供了不一样的垃圾回收算法。实际上,不一样分代之间的对象有可能相互引用,这些被引用的对象在分代垃圾回收的时候也会被视为GC Roots(见下一节分析)。弱分代假说有可能在特定场景中对某些应用是不适用的;而GC算法针对年轻代或者老年代的对象进行了优化,对于具有“中等”预期寿命的对象,JVM的垃圾回收表现是相对劣势的。并发

对象判活算法

JVM中是经过可达性算法(Reachability Analysis)来断定对象是否存活的。这个算法的基本思路就是:经过一些列的称为GC Roots(GC根集合)的活跃引用为起始点,从这些集合节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,说明该对象是不可达的。jvm

j-v-m-g-c-s-1.png

GC Roots具体是指什么?这一点能够从HotSpot VMParallel Scavenge源码实现总结出来,参考jdk9分支的psTasks.hpppsTasks.cpp编程语言

// psTasks.hpp
class ScavengeRootsTask : public GCTask {
 public:
  enum RootType {
    universe              = 1,
    jni_handles           = 2,
    threads               = 3,
    object_synchronizer   = 4,
    flat_profiler         = 5,
    system_dictionary     = 6,
    class_loader_data     = 7,
    management            = 8,
    jvmti                 = 9,
    code_cache            = 10
  };
 private:
  RootType _root_type;
 public:
  ScavengeRootsTask(RootType value) : _root_type(value) {}

  char* name() { return (char *)"scavenge-roots-task"; }

  virtual void do_it(GCTaskManager* manager, uint which);
};

// psTasks.cpp
void ScavengeRootsTask::do_it(GCTaskManager* manager, uint which) {
  assert(ParallelScavengeHeap::heap()->is_gc_active(), "called outside gc");

  PSPromotionManager* pm = PSPromotionManager::gc_thread_promotion_manager(which);
  PSScavengeRootsClosure roots_closure(pm);
  PSPromoteRootsClosure  roots_to_old_closure(pm);

  switch (_root_type) {
    case universe:
      Universe::oops_do(&roots_closure);
      break;

    case jni_handles:
      JNIHandles::oops_do(&roots_closure);
      break;

    case threads:
    {
      ResourceMark rm;
      Threads::oops_do(&roots_closure, NULL);
    }
    break;

    case object_synchronizer:
      ObjectSynchronizer::oops_do(&roots_closure);
      break;

    case flat_profiler:
      FlatProfiler::oops_do(&roots_closure);
      break;

    case system_dictionary:
      SystemDictionary::oops_do(&roots_closure);
      break;

    case class_loader_data:
    {
      PSScavengeKlassClosure klass_closure(pm);
      ClassLoaderDataGraph::oops_do(&roots_closure, &klass_closure, false);
    }
    break;

    case management:
      Management::oops_do(&roots_closure);
      break;

    case jvmti:
      JvmtiExport::oops_do(&roots_closure);
      break;


    case code_cache:
      {
        MarkingCodeBlobClosure each_scavengable_code_blob(&roots_to_old_closure, CodeBlobToOopClosure::FixRelocations);
        CodeCache::scavenge_root_nmethods_do(&each_scavengable_code_blob);
        AOTLoader::oops_do(&roots_closure);
      }
      break;

    default:
      fatal("Unknown root type");
  }

  // Do the real work
  pm->drain_stacks(false);
}

因为HotSpot VM的源码里面注释比较少,因此只能参考一些资料和源码方法的具体实现猜想GC Roots的具体组成:

  • Universe::oops_do:VM的一些静态数据结构里指向GC堆里的对象的活跃引用等等。
  • JNIHandles::oops_do:全部的JNI handle,包括全部的global handle和local handle。
  • Threads::oops_do:全部线程的虚拟机栈,具体应该是全部Java线程当前活跃的栈帧里指向GC堆里的对象的引用,或者换句话说,当前全部正在被调用的方法的引用类型的参数/局部变量/临时值。
  • ObjectSynchronizer::oops_do:全部被对象同步器关联的对象,看源码应该是ObjectMonitor中处于Block状态的对象,从Java代码层面应该是经过synchronized关键字加锁或者等待加锁的对象。
  • FlatProfiler::oops_do:全部线程的中的ThreadProfiler
  • SystemDictionary::oops_doSystem Dictionary,也就是系统字典,是记录了指向Klass,KEY是一个Entry,由KalssNameClassloader组成,实际上,YGC不会处理System Dictionary,可是会扫描System Dictionary,某些GC可能触发类卸载功能,能够这样理解:System Dictionary包含了全部的类加载器。
  • ClassLoaderDataGraph::oops_do:全部已加载的类或者已加载的系统类。
  • Management::oops_doMBean所持有的对象。
  • JvmtiExport::oops_doJVMTI导出的对象,断点或者对象分配事件收集器相关的对象。
  • CodeCache::scavenge_root_nmethods_do:代码缓存(Code Cache)。
  • AOTLoader::oops_do:AOT加载器相关,包括了AOT相关代码缓存。

还有其余有可能的引用:

StringTable::oops_do:全部驻留的字符串(StringTable中的)。

JVM中的内存池

JVM把内存池划分为多个区域,下面分别介绍每一个区域的组成和基本功能,方便下面介绍GC算法的时候去理解垃圾收集如何在不一样的内存池空间中发挥其职责。

j-v-m-g-c-s-3.png

  • 年轻代(Young Generation):包括EdenSurvivor Spaces,而Survivor Spaces又等分为Survivor 0Survivor 1,有时候也称为fromto两个区。
  • 老年代(Old Generation):通常称为Tenured
  • 元空间:称为Metaspace,在Java8中VM已经移除了永久代Permanent Generation

Eden

伊甸园是地上的乐园,根据《圣经·旧约·创世纪》记载,神·耶和华照本身的形像造了人类的祖先男人亚当,再用亚当的一个肋骨创造了女人夏娃,并安置第一对男女住在伊甸园中。

Eden,也就是伊甸园,是一块普通的在建立对象的时候进行对象分配的内存区域。而Eden进一步划分为驻留在Eden空间中的一个或者多个Thread Local Allocation Buffer(线程本地分配缓冲区,简称TLAB)TLAB是线程独占的。JVM容许线程在建立大多数对象的时候直接在相应的TLAB中进行分配,这样能够避免多线程之间进行同步带来的性能开销。

当没法在TLAB中进行对象分配的时候(通常是缓冲区没有足够的空间),那么对象分配操做将会在Eden中共享的空间(Common Area)中进行。若是整个Eden都没有足够的空间,则会触发YGC(Young Generation Garbage Collection),以释放更多的Eden中的空间。触发YGC后依然没有足够的内存,那么对象就会在老年代中分配(通常这种状况称为分配担保(Handle Promotion),是有前置条件的)。

当垃圾回收器收集Eden的时候,会遍历全部相对于GC Roots可达的对象,而且标记它们是对象,这一阶段称为标记阶段。

这里还有一点须要注意的是:堆中的对象有可能跨代连接,也就是有可能年轻代中的对象被老年代中的对象持有(注:老年代中的对象被年轻代中的对象持有这种状况在YGC中不须要考虑),这个时候若是不遍历老年代的对象,那么就没法经过可达性算法分析这种被被老年代中的对象持有的年轻代对象是否可达。JVM中采用了Card Marking卡片标记)的方式解决了这个问题,这里不对卡片标记的细节实现进行展开。

j-v-m-g-c-s-4.png

标记阶段完成后,Eden中全部存活的对象会被复制到幸存者空间(Survivor Spaces) 的其中一块空间。复制阶段完成后,整个Eden被认为是空的,能够从新用于分配更多其余的对象。这里采用的GC算法称为标记-复制(Mark and Copy) 算法:标记存活的对象,而后复制它们到幸存者空间(Survivor Spaces) 的其中一块空间,注意这里是复制,不是移动

关于Eden就介绍这么多,其中TLABCard Marking是JVM中的相对底层实现,大概知道便可。

Survivor Spaces

Survivor Spaces也就是幸存者空间,幸存者空间最经常使用的名称是fromto。最重要的一点是:幸存者空间中的两个区域总有一个区域是空的。

下一次YGC触发以后,空闲的那一块幸存者空间才会入驻对象。年轻代的全部存活的对象(包括Eden和非空的from幸存者区域中的存活对象),都会被复制到to幸存者区域,这个过程完成以后,to幸存者区域会存放着活跃的对象,而from幸存者区域会被清空。接下来,from幸存者区域和to幸存者区域的角色会交换,也就是下一轮YGC触发以后存活的对象会复制到from幸存者区域,而to幸存者区域会被清空,如此循环往复。

j-v-m-g-c-s-5.png

上面提到的存活对象的复制过程在两个幸存者空间之间屡次往复以后,某些存活的对象“年龄足够大”(通过屡次复制还存活下来),则这些“年纪大的”对象就会晋升到老年代中,这些对象会从幸存者空间移动到老年代空间中,而后它们就驻留在老年代中,直到自身变为不可达。

若是对象在Eden中出生而且通过了第一次YGC以后依然存活,而且可以被Survivor Spaces容纳的话,对象将会被复制到Survivor Spaces而且对象年龄被设定为1。对象在Survivor Spaces中每经历一次YGC以后还能存活下来,则对象年龄就会增长1,当它的年龄增长到晋升老年代的年龄阈值,那么它就会晋升到老年代也就是被移动到老年代中。晋升老年代的年龄阈值的JVM参数是-XX:MaxTenuringThreshold=n

VM参数 功能 可选值 默认值
-XX:MaxTenuringThreshold=n Survivor Spaces存活对象晋升老年代的年龄阈值 1<= n <= 15 15

值得注意的是:JVM中设置-XX:MaxTenuringThreshold的默认值为最大可选值,也就是15。

JVM还具有动态对象年龄判断的功能,JVM并非永远地要求存活对象的年龄必须达到MaxTenuringThreshold才能晋升到老年代,若是在Survivor Spaces中相同年龄的全部对象的大小总和大于Survivor Spaces的一半,那么年龄大于或者等于该年龄的对象能够直接晋升到老年代,不须要等待对象年龄到达MaxTenuringThreshold,例如:

类型 占比 年龄 动做(MaxTenuringThreshold=15)
ObjectType-1 60% 5 下一次YGC若是存活直接晋升到老年代
ObjectType-2 1% 6 下一次YGC若是存活直接晋升到老年代
ObjectType-3 10% 4 下一次YGC若是存活对象年龄增长1

能够简单总结一下对象进入老年代的几种状况:

  • 屡次YGC对象存活下来而且年龄到达设定的-XX:MaxTenuringThreshold=n致使对象晋升。
  • 由于动态对象年龄判断致使对象晋升。
  • 大对象直接进入老年代,这里大对象一般指须要大量连续内存的Java对象,最多见的就是大型的数组对象或者长度很大的字符串,由于年轻代彻底有可能装不下这类大对象。
  • 年轻代空间不足的时候,老年代会进行空间分配担保,这种状况下对象也是直接在老年代分配。

Tenured

老年代(Old Generation)更多时候被称为Tenured,它的内存空间的实现通常会更加复杂。老年代空间通常要比年轻大大得多,它里面承载的对象通常不会是“内存垃圾”,侧面也说明老年代中的对象的回收率通常比较低。

老年代发生GC的频率通常状况下会比年轻代低,而且老年代中的大多数对象都被指望为存活的对象(也就是对象经历GC以后存活率比较高),所以标记和复制算法并不适用于老年代。老年代的GC算法通常是移动对象以最小化内存碎片。老年代的GC算法通常规则以下:

  • 经过GC Roots遍历和标记全部可达的对象。
  • 删除全部相对于GC Roots不可达的对象。
  • 经过把存活的对象连续地复制到老年代内存空间的开头(也就是起始地址的一端)以压缩老年代内存空间的内容,这个过程主要包括显式的内存压缩从而避免过多的内存碎片。

j-v-m-g-c-s-6.png

Metaspace

在Java8以前JVM内存池中还定义了一块空间叫永久代(Permanent Generation),这块内存空间主要用于存放元数据例如Class信息等等,它还存放其余数据内容,例如驻留的字符串(字符串常量池)。实际上永久代曾经给Java开发者带来了不少麻烦,由于大多数状况下很难预测永久代须要设定多大的空间,由于开发者也很难预测元数据或者字符串常量池的具体大小,一旦分配的元数据等内容出现了失败就会遇到java.lang.OutOfMemoryError: Permgen space异常。排除内存溢出致使的java.lang.OutOfMemoryError异常,若是是正常状况下致使的异常,惟一的解决手段就是经过VM参数-XX:MaxPermSize=XXXXm增大永久代的内存,不过这样也是治标不治本。

由于元数据等内容是难以预测的,Java8中已经移除了永久代,新增了一块内存区域Metaspace(元空间),不少其余杂项(例如字符串常量池)都移动了Java堆中。Class定义信息等元数据目前是直接加载到元空间中。元空间是一片分配在机器本地内存(native memory)的内存区,它和承载Java对象的堆内存是隔离的。默认状况下,元空间的大小仅仅受限于机器本地内存能够分配给Java程序的极限值,这样基本能够避免由于添加新的类致使java.lang.OutOfMemoryError: Permgen space异常发生的场景。

VM参数 功能 可选值 默认值
XX:MetaspaceSize=Xm Metaspace扩容时触发FullGC的初始化阈值 - -
XX:MaxMetaspaceSize=Ym Metaspace的内存上限 - 接近于无穷大

经常使用内存池相关的VM参数

  • -Xmx-Xms
VM参数 功能 可选值 默认值
-Xmx 设置最大堆内存大小 有下限控制,视VM版本 -
-Xms 设置最小堆内存大小 有下限控制,视VM版本 -

j-v-m-g-c-s-7.png


  • -Xmn-XX:NewRatio-XX:SurvivorRatio
VM参数 功能 可选值 默认值
-Xmn 设置年轻代内存大小 - -
-XX:NewRatio= 设置老年代和年轻代的内存大小比值,设置为4表示年轻代占堆内存的1/5 - 4
-XX:SurvivorRatio= 设置Eden和幸存者区域的内存大小比值,设置为8表示from:to:Eden=1:1:8 - 8

j-v-m-g-c-s-8.png

GC类型

参考R大(RednaxelaFX)的知乎回答,其实在HotSpot VM的GC分类只有两大种:

  • Partial GC:也就是部分GC,不收集整个GC堆。
    • Young GC:只收集young gen的GC。
    • Old GC:只收集old gen的GC,目前只有CMS的concurrent collection是这个模式。
    • Mixed GC:收集整个young gen以及部分old gen的GC,目前只有G1有这个模式。
  • Full GC:收集整个堆,包括young gen、old gen、perm gen(若是存在的话)等全部部分的模式。

由于HotSpot VM发展多年,外界对GC的名词解读已经混乱,因此才出现了Minor GCMajor GCFull GC

Minor GC

Minor GC,也就是Minor Garbage Collection,直译为次级垃圾回收,它的定义相对清晰:发生在年轻代的垃圾回收就叫作Minor GC。Minor Garbage Collection处理过程当中会发生:

  1. 当JVM没法为新的对象分配内存空间的时候,始终会触发Minor GC,常见的状况如Eden的内存已经满了,而且对象分配的发生率越高,Minor GC发生的频率越高。
  2. Minor GC期间,老年代中的对象会被忽略。老年代中的对象引用的年轻代的对象会被认为是GC Roots的一部分,在标记阶段会简单忽略年轻代对象中引用的老年代对象。
  3. Minor GC会致使Stop The World,表现为暂停应用线程。大多数状况下,Eden中的大多数对象均可以视为垃圾而且这些垃圾不会被复制到幸存者空间,这个时候Minor GC的停顿时间会十分短暂,甚至能够忽略不计。相反,若是Eden中有大量存活对象须要复制到幸存者空间,那么Minor GC的停顿时间会显著增长。

Major GC和Full GC

Major GC(Major Garbage Collection,能够直译为主垃圾收集)和Full GC目前是两个没有正式定义的术语,具体来讲就是:JVM规范中或者垃圾收集研究论文中都没有明肯定义Major GC或者Full GC。不过按照民间或者约定俗成,二者区别以下:

  • Major GC:对老年代进行垃圾收集。
  • Full GC:对整个堆进行垃圾收集 -- 包括年轻代和老年代。

实际上,GC过程是十分复杂的,并且不少Major GC都是由Minor GC触发的,因此要严格分割Major GC或者Minor GC几乎是不可能的。另外一方面,如今垃圾收集算法像G1收集算法提供部分垃圾回收功能,侧面说明并不能单纯按照收集什么区域来划分GC的类型

上面的一些理论或者资料指明:与其讨论或者担忧GC究竟是Major GC或者是Minor GC,不如花更多精力去关注GC过程是否会致使应用的线程停顿或者GC过程是否可以和应用线程并发执行

经常使用的GC算法

下面分析一下目前Hotspot VM中比较常见的GC算法,由于G1算法相对复杂,这里暂时没有能力分析。

GC算法的目的

GC算法的目的主要有两个:

  1. 找出全部存活的对象,对它们进行标记。
  2. 移除全部无用的对象。

寻找存活的对象主要是基于GC Roots的可达性算法,关于标记阶段有几点注意事项:

  1. 标记阶段全部应用线程将会停顿(也就是Stop The World),应用线程暂时停顿保存其信息在还原点中(Safepoint)。
  2. 标记阶段的持续时间并不取决于堆中的对象总数或者是堆的大小,而是取决于存活对象的总数,所以增长堆的大小并不会显著影响标记阶段的持续时间。

标记阶段完成后的下一个阶段就是移除全部无用的对象,按照处理方式分为三种常见的算法:

  • Sweep -- 清理,也就是Mark and Sweep,标记-清理。
  • Compact -- 压缩,也就是Mark-Sweep-Compact,标记-清理-压缩。
  • Copy -- 复制,也就是Mark and Copy,标记-复制。

Mark-Sweep算法

Mark-Sweep算法,也就是标记-清理算法,是一种间接回收算法(Indirect Collection),它并不是直接检测垃圾对象自己,而是先肯定全部存活的对象,而后反过来判断其余对象是垃圾对象。主要包括标记和清理两个阶段,它是最简单和最基础的收集算法,主要包括两个阶段:

  • 第一阶段为追踪(trace)阶段:收集器从GC Roots开始遍历全部可达对象,而且对这些存活的对象进行标记(mark)。
  • 第二阶段为清理(sweep)阶段:收集器把全部未标记的对象进行清理和回收。

j-v-m-g-c-s-9.png

Mark-Sweep-Compact算法

内存碎片化是非移动式收集算法没法解决的一个问题之一:尽管堆中有可用空间,可是内存管理器却没法找到一块连续内存块来知足较大对象的分配需求,或者花费较长时间才能找到合适的空闲内存空间。

Mark-Sweep-Compact算法,也就是标记-清理-压缩算法,也是一种间接回收算法(Indirect Collection),它主要包括三个阶段:

  • 标记阶段:收集器从GC Roots开始遍历全部可达对象,而且对这些存活的对象进行标记。
  • 清理阶段:收集器把全部未标记的对象进行清理和回收。
  • 压缩阶段:收集器把全部存活的对象移动到堆内存的起始端,而后清理掉端边界以外的内存空间。

j-v-m-g-c-s-10.png

对堆内存进行压缩整理能够有效地下降内存外部碎片化(External Fragmentation)问题,这个是标记-清理-压缩算法的一个优点。

Mark-Copy算法

Mark-Copy算法,也就是标记-复制算法,和标记-清理-压缩算法十分类似,重要的区别在于:标记-复制算法在标记和清理完成以后,全部存活的对象会被复制到一个不一样的内存区域 -- 幸存者空间。主要包括三个阶段:

  • 标记阶段:收集器从GC Roots开始遍历全部可达对象,而且对这些存活的对象进行标记。
  • 清理阶段:收集器把全部未标记的对象进行清理和回收 --- 实际上这一步多是不存在的,由于存活对象指针被复制以后,原来指针所在的位置已经能够从新分配新的对象,能够不进行清理
  • 复制阶段:把全部存活的对象复制到Survivor Spaces中的某一块空间中。

j-v-m-g-c-s-11.png

标记-复制算法能够避免内存碎片化的问题,可是它的代价比较大,由于用的是半区复制回收,区域可用内存为原来的一半。

小结

JVM和GC是Java开发者必须掌握的内容,包含的知识其实仍是挺多的,本文也只是简单介绍了一些基本概念:

  • 分代假说。
  • Minor GC、Major GC和Full GC。
  • 内存池组成。
  • 经常使用的GC算法。

后面会分析一下GC收集器搭配和GC日志查看、JVM提供的工具等等。

参考资料:

  • 《深刻理解Java虚拟机-2nd》
  • 《The Garbage Collection Handbook》
  • 知乎-RednaxelaFX部分回答
  • Java Garbage Collection handbook
  • OpenJDK HotSpot VM部分源码

原文连接

相关文章
相关标签/搜索