Java虚拟机——垃圾收集器

928 阅读19分钟

       Java特性之一是不需要显式地管理对象的生命周期:我们可以在需要时创建对象,对象不再被使用时,会由 JVM 在后台自动进行回收。简单来说,垃圾收集由两步构成:査找不再使用的对象,以及释放这些对象所管理的内存。

垃圾收集算法

分代收集理论

         在Java虚拟机里,Java堆划分为新生代 (Young Generation)和老年代(Old Generation)两个区域 。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

采用这种设计有两个性能上的优势:

  • 其一,由于新生代仅是堆的一部分,与处理整个堆相比,处理新生代的速度更快。而这意味着应用线程停顿的时间会更短。
  • 其二,对象分配于Eden空间。垃圾收集时,新生代空间被清空,Eden空间中的对象要么被移走,要么被回收;由于所有的对象都被移走,相当于新生代空间在垃圾收集时自动地进行了一次压缩整理。

       垃圾收集器每次只回收其中某一个或者某些部分的区域 ——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。

  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。

  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。

  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。通常导致应用程序线程长时间的停顿

标记-清除算法

       首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。主要缺点有两个:

  • 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
  • 第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法

       将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。

        缺点:标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。而且浪费50%的空间。在老年代中所有对象都100%存活的极端情况,在老年代里一般不选用这种算法。

标记-整理算法

        其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

缺点:老年代这种每次回收都有大量对象存活区域,移动存活对象并更新 所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行(Stop The World)

经典垃圾收集器

在HotSpot虚拟机里面实现了七种作用于不同分代的收集器。

       如果两个收集器之间存在连线,就说明它们可以搭配使用 ,图中收集器所处的区域,则表示它是属于新生代收集器抑或是老年代收集器。

       明确一个观点:虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来,虽然垃圾收集器的技术在不断进步,但直到现在还没有 最好的收集器出现,更加不存在“万能”的收集器,所以我们选择的只是对具体应用最合适的收集器。

Serial收集器

         Serial收集器是最基础、历史最悠久的收集器,是一个单线程工作的收集器,使用 Serial收集器,无论是进行 Minor gc 还是 Full GC ,清理堆空间时,所有的应用线程都会被暂停。进行Full GC时,它还会对老年代空间的对象进行压缩整理。通过 -XX:+UseSerialgGC 标志可以启用 Serial收集器。

         对于单核处理器或处理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。 Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

ParNew收集器

        ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一致。

       ParNew 收集器在单核心处理器的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程(Hyper-Threading)技术实现的伪双核处理器环境中都不能百分之百保证超越Serial收集器。是JDK 7之前的遗留系统中首选的新生代收集器。

Parallel Scavenge收集器

       Parallel Scavenge收集器也是一款新生代收集器,基于标记——复制算法实现,能够并行收集的多线程收集器和 ParNew 非常相似。

       Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。如果虚拟机完成某个任务,用户代码加上垃圾收集总共耗费了100分钟,其中垃圾收集花掉1分 钟,那吞吐量就是99%。

       Parallel Scavenge 收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的 -XX:MaxGCPauseMillis 参数和直接设置吞吐量大小的**-XX:GCTimeRatio** 参数。

  • -XX:MaxGCPauseMillis 参数允许的值是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过用户设定值。垃圾收集停顿时间缩短是以牺牲吞吐量和新生代空间为代价换取的: 系统把新生代调得小一些,收集 300MB 新生代肯定比收集500MB快,但这也直接导致垃圾收集发生得更频繁,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。缺省情况下,不设定该参数。
  • -XX:GCTimeRatio  参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5% (即1/(1+19)),默认值为99,即允许最大1%(即1/(1+99))的垃圾收集时间。

         还有一个参数 -XX:+UseAdaptiveSizePolicy 虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为垃圾收集的自适应的调节策略(GC Ergonomics)。自适应调节策略也是收集器区别于ParNew收集器的一个重要特性。

Serial Old收集器

        Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。

Parallel Old收集器

       Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。

在JDK8里面默认垃圾收集器是 UseParallelGC 即 Parallel Scavenge + Parallel Old 。使用 java -XX:+PrintCommandLineFlags  -version 命令可以查看

java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=266359616 -XX:MaxHeapSize=4261753856 
-XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers 
-XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation 
-XX:+UseParallelGC
java version "1.8.0_66"
Java(TM) SE Runtime Environment (build 1.8.0_66-b17)
Java HotSpot(TM) 64-Bit Server VM (build 25.66-b17, mixed mode)

CMS收集器(Concurrent Mark Sweep)

       CMS 收集器设计的初衷是为了消除 Parallel 收集器和 Serial 收集器 Full gc 周期中的长时间停顿。CMS收集器在 Minor gc 时会暂停所有的应用线程,并以多线程的方式进行垃圾回收。 CMS收集器基于标记-清除算法实现的,整个过程分为四个步骤, 整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一 起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

初始标记(CMS initial mark)

       初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;仍然需要“Stop The World”。

并发标记(CMS concurrent mark)

       并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。

重新标记(CMS remark)

       重新标记阶段则是为了修正并发标记期间,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。

并发清除(CMS concurrent sweep)

       并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

        CMS收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的 B/S 系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求。

Garbage First收集器(G1)

        GI垃圾收集器的设计初衷是为了尽量缩短处理超大堆(大于4GB)时产生的停顿。JDK 9发布之日,G1 宣告取代 Parallel Scavenge 加 Parallel Old 组合,成为服务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用(Deprecate)的收集器。

       虽然G1也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异:G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的 Region采用不同的策略去处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。通过标志  -XX:+UseG1GC (默认关闭)  开启G1垃圾收集 。

G1收集器的运作过程大致可划分为以下四个步骤:

初始标记(Initial Marking)

        仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要 停顿线程,但耗时很短,而且是借用进行Minor GC 的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。

并发标记(Concurrent Marking)

         从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆 里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理SATB记录下的在并发时有引用变动的对象。

         在并发周期中,至少有一次(很可能是多次)新生代垃圾收集。因此,在将Eden室间中的分区标记为完全释放之前,新的Eden分区已经开始分配了。这些X分区属于老年代(它们依然还保持着数据),它们就是标记周期( marking cycle)找出的包含最多垃圾的分区。在标记周期中,新生代的垃圾收集会晋升对象到老年代。

       标记周期中实际不会释放老年代中的任何对象:它仅仅锁定了那些垃圾最多的分区。 这些分区中的垃圾数据会在之后的周期中被回收释放。 

最终标记(Final Marking)

     对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。G1到这个点为止真正做的事情是定位出哪些老的分区可回收垃圾最多(即标记为X的分区)。

筛选回收(Live Data Counting and Evacuation)

        现在,G1会执行一系列的混合式垃圾回收( mixed GC)。这些垃圾回收被称作“混合式” 是因为它们不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的分区。

        同新生代垃圾收集通常的行为一样,G1收集器已经清空了Eden空间,同时调整了Survivor 空间的大小。此外,标记的两个分区也已经被回收。这些分区在之前的扫描中已经证实包含大量垃圾对象,因此绝大部分已经被释放。

内存分配与回收策略

内存分配

对象优先在Eden分配

        大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起 一次Minor GC。

大对象直接进入老年代

        大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。

       在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销。

        HotSpot虚拟机提供了 -XX:PretenureSizeThreshold(只对 Serial 和ParNew收集器有效) 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在 Eden 区及两个 Survivor 区之间来回复制,产生大量的内存复制操作。

长期存活的对象将进入老年代

        虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次 Minor GC后仍然存活,并且能被 Survivor 容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX: MaxTenuringThreshold 设置。

         虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到要求的年龄。

空间分配担保

         在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 - XX:HandlePromotionFailure 参数的设置值是否允许担保失败;如果允 许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者设置不允许冒险,那这时就要改为进行一次Full GC。

回收策略

新生代收集器

        HotSpot虚拟机的 Serial、ParNew 等新生代收集器均采用了半区复制分代策略设计新生代的内存布局。把新生代分为一块较大的Eden 空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor空间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是8∶1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会 被“浪费”的。

通过JAVA VisualVM 可以观察到内存区域

老年代收集器

        老年代垃圾收集会回收新生代中所有的对象(包括 Survivor空间中的对象)。只有那些有活跃引用的对象,或者已经经过压缩整理的对象(它们占据了老年代的开始部分)会在老年代中继续保持,其余的对象都会被回收。

虚拟机及垃圾收集器日志

每一种收集器的日志形式都是由它们自身的实现所决定的,换而言之,每个收集器的日志格式都可以不一样。

查看GC基本信息:-XX:+PrintGC

查看GC详细信息:-XX:+PrintGCDetails

查看GC前后的堆、方法区可用容量变化:-XX:+PrintHeapAtGC

查看GC过程中用户线程并发时间以及停顿的时间:

-XX:+Print- GCApplicationConcurrentTime

-XX:+PrintGCApplicationStoppedTime

通过 -Xloggc:D:/gc.log 生成gc.log文件

第一行:
2020-10-14T16:04:30.395+0800: 0.160: [GC (System.gc()) [PSYoungGen:9382K->1032K(76288K)]
9382K->1040K(251392K),0.0203927 secs] [Times: user=0.00 sys=0.00, real=0.02 secs]

第二行:
2020-10-14T16:04:30.416+0800: 0.161: [Full GC (System.gc()) [PSYoungGen: 1032K->0K(76288K)] 
[ParOldGen: 8K->879K(175104K)] 1040K->879K(251392K), 
[Metaspace: 3208K->3208K(1056768K)], 0.0041625 secs] 
[Times: user=0.00 sys=0.00, real=0.00 secs]

2020-10-14T16:04:30.395+0800: 0.160: 当前时间戳[GC (System.gc()) 通过System.gc()触发
GC[PSYoungGen(新生代收集):
 9382K(GC前该内存区域已使用容量)->1032K(GC后该内存区域已使用容量)(76288K(该内存区域总容量))]
 9382K(GC前Java堆已使用容量)->1040K(GC后Java堆已使用容量)(251392K(Java堆总容量)),
 0.0203927 secs(GC所占用的时间)] 
[Times: user=0.00 sys=0.00, real=0.02 secs] 
user:代表用户态消耗的CPU时间 sys:系统消耗的CPU时  real:实际时间

2020-10-14T16:04:30.416+0800: 0.161: [Full GC(整堆收集) (System.gc())
[PSYoungGen: 1032K->0K(76288K)] 同上
[ParOldGen(老年代收集): 8K->879K(175104K)] 1040K->879K(251392K), 
[Metaspace: 3208K->3208K(1056768K)], 0.0041625 secs] 
[Times: user=0.00 sys=0.00, real=0.00 secs]

[GC (System.gc()) /[Full GC (System.gc()) :GC类型以及GC触发方式 。

“[PSYoungGen”、“[ParOldGen”、“[Metaspace”表示GC发生的区域,名称也是由收集器决定的 。

9382K->1032K(76288K):GC前该内存区域已使用容量-> GC后该内存区域已使用容量(该内存区域总容量)

9382K->1040K(251392K):GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容 量)

大家也可以通过 GCViewer 工具去分析GC日志,通过分析GC日志去执行相对的JVM调优。

虚拟机性能监控工具

基础工具总结

参考

Java性能权威指南
深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)