Java 9中的GC调优基础

在通过了几回跳票以后,Java 9终于在原计划日期的整整一年以后发布了正式版。Java 9引入了不少新的特性,除了闪瞎眼的Module SystemREPL,最重要的变化我认为是默认GC(Garbage Collector)修改成新一代更复杂、更全面、性能更好的G1(Garbage-First)。JDK的维护者在GC选择上一直是比较保守的,G1从JDK 1.6时代就开始进入开发者的视野,直到今天正式成为Hotspot的默认GC,也是走了很长的路。html

本文将主要讲解GC调优须要知道的一些基础知识,会涉及到一些GC的实现细节,但不会对实现细节作很全面的阐述,若是你看完本文以后,能对GC有一个大体的认识,那本文的写做目的也就达到了。因为在此次写做过程当中,恰逢Java 9正式版发布,以前都是依赖Java 8的文档写的,若是有不正确的地方还望指正。本文将包含如下内容:java

  1. GC的做用范围
  2. GC负责的事情
  3. JVM中的4种GC
  4. G1的一些细节
  5. 使用Java 9正式版对G1进行测试
  6. 一些简单的GC调优方法

1、GC的做用范围

要谈GC的做用范围,首先要谈JVM的内存结构,JVM内存中主要有如下几个区域:堆、方法区(JVM规范中的叫法,Hotspot大体对应的是Metaspace)、栈、本地方法栈、PC等,其中GC主要做用在堆上,以下图所示:git

JVM内存结构
JVM内存结构

其中堆和方法区是全部线程共享的,其余则为线程独有,HotSpot JVM使用基于分代的垃圾回收机制,因此在堆上又分为几个不一样的区域(在G1中,各年龄代再也不是连续的一整片内存,为了描述方便,这里还使用传统的表示方法),具体以下图所示:github

JVM堆中的分区
JVM堆中的分区

2、GC负责的事情

GC的发展是随着JDK(Standard Edition)的发展一步步发展起来的,垃圾回收(Garbage Collection)能够说是JDK里最影响性能的行为了。GC作的事情,说白了就是「经过对内存进行管理,以保障在内存足够的时候,程序能够正常的使用内存」。具体而言,GC一般作的事情有如下3个:算法

1. 分配对象和对象的年龄管理

一般而言,GC须要管理「在上图中的年轻代(Young)分配对象,而后经过一系列的年龄管理,将之销毁或晋升到老年代(Tenured)中去」的过程。这个过程会伴随着若干次的Minor GC。安全

对于普通的对象而言,分配内存是一件很简单并且快速的事情。在对象还未建立时,其所占内存大小经过类的元数据就能够肯定,而Eden区域的内存能够认为是连续的,因此给对象分配内存要作的只是在上图中Eden区域中把指针移动相应的长度,并将地址返回给对象的引用便可。固然实际的过程比这个复杂,在下文中会提到。bash

不过,有时候一个对象会直接在老年代中建立,这个点也会在后边提到。服务器

2. 在老年代中进行标记

老年代的GC算法能够大体是认为是一个标记-整理(Mark-Compact,实际上是混合了标记-清理,标记-复制和标记-整理)算法,因此老年代的垃圾清理首先要作的就是在老年代对存活的对象(可达性分析,关于不一样的可达性能够参考JDK解构 - Java中的引用和动态代理的实现)进行标记,对于寻求大吞吐量的服务器应用来讲,这个过程每每须要是并发的。多线程

标记的过程发生在Major GC被触发以后,不一样的GC对于MajorGC的触发条件和标记过程的实现也不尽相同。并发

3. 在老年代中进行压缩

在上一条的基础上,将还存活的对象进行压缩(CMS和G1的行为与此有些不一样之处),压缩的过程就是将存活的对象从老年代的起点进行挨个复制,使得老年代维持在一片连续的内存中,消除内存碎片,对于内存分配速度的提高会有很大的帮助。

3、GC的种类

Hotspot会根据宿主机的硬件特性和操做系统类型,将之分为客户端型(client-class)或者服务器型(server-class),若是是服务器型主机,Java 9以前默认使用Parallel GC,Java 9中默认使用G1。对于服务器型主机的选择标准是「CPU核心数大于1,内存大于2GB」,因此如今大部分的主机均可以认为是服务器型主机。

这里讨论的全部GC都是基于分代垃圾回收算法的。

1. Serail

Serail是最先的一款GC,它只使用一个线程来作全部的Minor和Major垃圾回收。它在运行时,其余全部的事情都会暂停。其工做方式十分简单,在须要GC的安全点,它会中止全部其余线程(Stop-The-World),对年轻代进行标记-复制,或对老年代进行标记-整理。

可使用JVM参数-XX:+UseSerialGC来开启此GC,当使用此参数时,年轻代和老年代将都是用Serial来作垃圾回收。在年轻代使用标记-复制算法,将Eden中存活的对象和非空的Suvivor区(From)中存活的对象复制到空的Suvivor区(To)中去,同时将一部分Suvivor中的对象晋升到老年代去。在老年代则使用标记-整理算法。

看起来Serial古老而简陋,但在宿主机资源紧张或者JVM堆很小的状况下(好比堆内存大小只有不到100M),Serial反而能够达到更好的效果,由于其余并发或并行GC都是基于多线程的,会带来额外的线程切换和线程间通讯的开销。

2. Parallel/Throughput

Parallel在Java 9以前是服务器型宿主机中JVM的默认GC,其垃圾回收的算法和Serial基本相同,不一样之处在与它使用多线程来执行。因为使用了多线程,能够享受多核CPU带来的优点,能够经过参数-XX:+UseParallelGC -XX:+UseParallelOldGC显示指定。

3. CMS

CMS和G1都属于「Mostly Concurrent Mark and Sweep Garbage Collector」,可使用-XX:+UseConcMarkSweepGC参数打开。CMS的年轻代垃圾回收使用的是Parallel New来作,其行为和Parallel中的差很少相同,他们的实现上有一些不一样的地方,好比Parallel能够自动调节年轻代中各区的大小,用的是广度优先搜索等。

老年代使用CMS,CMS的回收和Parallel也基本相似,不一样点在与,CMS使用的更复杂的可达性分析步骤,而且不是每次都作压缩的动做,这样达到的效果就是,Stop-The-World的时长会下降,JVM运行中断的时间减小,适合在对延迟敏感的场景下使用。

CMS在Java 9中已经被废弃,但了解CMS的行为对理解G1会有一些帮助,因此这里仍是会简单的叙述一下。CMS的步骤大体以下:

  1. 第一次标记
    从GC Roots开始,找到它们在老年代中第一个可达的对象,这些对象或者是直接被GC Roots引用,或者经过年轻代中的对象被GC Roots引用。这一步会Stop-The-World。

  2. 并发标记
    在第一次标记的基础上,进一步进行可达性分析,从而标记存活的对象。这一步叫「并发」标记,是由于作标记的线程是和应用的工做线程并发执行的,也就是说,这一步不会Stop-The-World。

  3. 第二次标记
    在并发标记的过程当中,因为程序仍在执行,会致使在并发标记完成后,有一些对象的可达性会发生变化,因此须要再次对他们进行标记。这一步会Stop-The-World。

  4. 清理
    回收不使用的对象,留做之后使用。

CMS的设计比较复杂,因此也带来了一些问题,好比浮动垃圾(Floating Garbage,指的是在第一步标记可达,但在第二步执行的同时已经不可达的对象),因为不作老年代压缩,致使老年代会出现较多的内存碎片。

4. G1

因为「引入了并发标记」和「不作老年代压缩」,CMS能够带来更好的响应时延表现,但同时也带来了一些问题。G1自己就是做为CMS的替代品出现的,在它的使用场景里,堆再也不是连续的被分为上文所说的各类代,整个堆会被分为一个个区域(Region),每一个区域能够是任何代。以下图所示:

使用G1的JVM某时刻的堆内存
使用G1的JVM某时刻的堆内存

其中有红色方框的为年轻代(标S的为Survivor区域,其余为Eden),其余蓝色底的区域为老年代(标H的为大对象区域,用以存储大对象)。

4、G1的一些细节

G1与以上3种GC相同,也是基于分代的垃圾回收器。它的垃圾回收步骤能够分为年轻代回收(Young-only phase,相似于Minor GC)和混合垃圾回收阶段(Space-reclamation phase)。下图是Oracle文档中对于此两个阶段的示意图:

jsgct_dt_001_grbgcltncyl.png
jsgct_dt_001_grbgcltncyl.png

G1设计目标和适用对象

G1的设计目标是让大型的JVM能够动态的控制GC的行为以知足用户配置的性能目标。G1会在平衡吞吐和响应时延的基础上,尽量的知足用户的需求。它适用的JVM每每有如下特征:

  1. 堆的大小可能达到数十G(或者更大),同时存活的对象数量也不少。
  2. 对象的分配和年龄增加的行为随着程序的运行不断的变化
  3. 堆上很容易造成碎片
  4. 要求较少的Stop-The-World暂停时间,一般小于数百毫秒
对G1的行为进行测试

若是想要看垃圾回收的具体执行过程,可使用虚拟机参数-Xlog:gc*=debug或者-Xlog:gc*=info,前一个会打印更多的细节。注意传统的VM参数-XX:+PrintGCDetails在Java9中已经废弃,会有Warning信息。可使用如下代码中的程序去测试:

static int TOTAL_SIZE = 1024 * 5;
static Object[] floatingObjs= new Object[TOTAL_SIZE];
static LinkedList<Object> immortalObjs = new LinkedList<Object>();
//释放浮动垃圾
synchronized static void renewFloatingObjs() {
    System.err.println("存活对象满========================================");
    if (floatingSize + 5 >= TOTAL_SIZE) {
        floatingObjs= new Object[TOTAL_SIZE];
        floatingSize = 0;
    }
}
//添加浮动垃圾
synchronized static void addObjToFloating(Object obj) {
    if (floatingSize++ < TOTAL_SIZE) {
        floatingObjs[floatingSize] = obj;
        if (immortalSize++ < TOTAL_SIZE) {
            immortalObjs.add(obj);
        } else {
            immortalObjs.remove(new Random().nextInt(TOTAL_SIZE));
            immortalObjs.add(obj);
        }
    }
}

public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
        new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                Byte[] garbage = new Byte[1024 * (1 + new Random().nextInt(20))];
                if (new Random().nextInt(20) < 2) {
                    if (floatingSize + 5 >= TOTAL_SIZE) {
                        renewFloatingObjs();
                    }
                    addObjToFloating(garbage);
                }
            }
        }).start();
    }
}复制代码

在这段代码中,模拟了常规程序的使用状况。不断的生成新的大小不等的对象,这些对象中会有大约10%的机会进入浮动垃圾floatingObjs,浮动垃圾会被按期清除。同时会有一部分的对象进入immortalObjs,这些对象被释放的机会更少,它们大几率将成为老年代的常住用户。

从上边的测试能够获得以下GC日志1,这是一次完整的年轻代GC,从中能够看到,默认的区域大小为1M,同时将开始一次Full GC,其格式大体为[<虚拟机运行的时长>][<日志级别>][<标签>] GC(<GC的标识>) <其余信息>

//日志1
[0.014s][info][gc,heap] Heap region size: 1M
//一次完整的年轻代垃圾回收,伴随着一次暂停
[12.059s][info ][gc,start             ] GC(18) Pause Young (G1 Evacuation Pause)                            
[12.059s][info ][gc,task              ] GC(18) Using 8 workers of 8 for evacuation                            
[12.078s][info ][gc,phases            ] GC(18)   Pre Evacuate Collection Set: 0.0ms                            
[12.078s][info ][gc,phases            ] GC(18)   Evacuate Collection Set: 18.6ms                            
[12.079s][info ][gc,phases            ] GC(18)   Post Evacuate Collection Set: 0.3ms                            
[12.079s][info ][gc,phases            ] GC(18)   Other: 0.3ms                            
[12.079s][info ][gc,heap              ] GC(18) Eden regions: 342->0(315)                            
[12.079s][info ][gc,heap              ] GC(18) Survivor regions: 38->35(48)                            
[12.079s][info ][gc,heap              ] GC(18) Old regions: 425->463                            
[12.079s][info ][gc,heap              ] GC(18) Humongous regions: 0->0                            
[12.078s][debug][gc,ergo,ihop         ] GC(18) Request concurrent cycle initiation (occupancy higher than threshold) occupancy: 485490688B allocation request: 0B threshold: 472331059B (45.00) source: end of GC
[12.078s][debug][gc,ihop              ] GC(18) Basic information (value update), threshold: 472331059B (45.00), target occupancy: 1049624576B, current occupancy: 521069456B, recent allocation size: 20640B, recent allocation duration: 817.38ms, recent old gen allocation rate: 25251.50B/s, recent marking phase length: 0.00ms
[12.078s][debug][gc,ihop              ] GC(18) Adaptive IHOP information (value update), threshold: 472331059B (47.37), internal target occupancy: 997143347B, occupancy: 521069456B, additional buffer size: 367001600B, predicted old gen allocation rate: 318128.08B/s, predicted marking phase length: 0.00ms, prediction active: false
[12.078s][debug][gc,ergo,refine       ] GC(18) Updated Refinement Zones: green: 15, yellow: 45, red: 75
[12.079s][info ][gc,heap              ] GC(18) Eden regions: 342->0(315)
[12.079s][info ][gc,heap              ] GC(18) Survivor regions: 38->35(48)
[12.079s][info ][gc,heap              ] GC(18) Old regions: 425->463
[12.079s][info ][gc,heap              ] GC(18) Humongous regions: 0->0
[12.079s][info ][gc,metaspace         ] GC(18) Metaspace: 5172K->5172K(1056768K)
[12.079s][debug][gc,heap              ] GC(18) Heap after GC invocations=19 (full 0):        
[12.079s][info ][gc                   ] GC(18) Pause Young (G1 Evacuation Pause) 803M->496M(1001M) 19.391ms                                
[12.079s][info ][gc,cpu               ] GC(18) User=0.05s Sys=0.00s Real=0.02s            复制代码
年轻代回收(Young-only)

对于纯粹的年轻代回收,其算法很简单,与Parallel和CMS的年轻代十分相似,这是一个多线程并行执行的过程,一样须要Stop-The-World(对应上边日志中的Pause Young),停下来全部的工做线程,而后将Eden上存活的对象拷贝到Suvivor区域,这里会将不少个对象从多个不一样的区域拷贝到少数的几个区域内,因此这一步在G1中叫作疏散(Evacuation),同时把Suvivor上触及年龄阈值的对象晋升到老年代区域。

老年代回收(concurrent cycle)

G1的老年代回收是在老年代空间触及一个阈值(Initiating Heap Occupancy Percent)以后,这个回收伴随着年轻代的回收工做,但与上边所说的回收有些不一样。

  1. 年轻代回收:伴随着年轻代的回收工做,同时会执行并发标记和一部分清理的工做,这样能够共用年轻代垃圾回收的Stop-The-World。

    1. 第一次标记:对应一次Pause Initial Mark
      和CMS的步骤相似,首先进行第一次标记。但实现方法上有很大的区别,G1首先对当前堆上的对象状况进行一个虚拟快照(Snapshot-At-The-Beginning),而后根据这个快照对老年代的对象和区域进行标记,并执行以后的垃圾回收。以后像CMS同样会有并发标记的过程。
      这样会产生一个问题,在此次回收结束以后,会有些对象在并发标记的过程当中,它的可达性已经变化,致使已经不可达的对象仍然没有被回收。可是这样能带来更好的响应时间。

    2. 从新标记:对应一次Pause Remark
      在这个阶段,G1首先完成上一步开始的标记工做,以后会对特殊引用的对象进行处理(具体能够参考JDK解构 - Java中的引用和动态代理的实现),还有对Metaspace区域进行垃圾回收。这一步会进行Stop-The-World。

    3. 清理:对应一次Pause Cleanup
      这一步主要作的是收集当前堆中的内存区域信息,对空的区域进行回收,为接下来的空间回收作一些准备工做,清理结束以后,一般会伴随着一次年轻代回收,若是判断不须要进行空间回收,则会进入下一个年轻代回收的工做。这一步会进行Stop-The-World。
    1. 混合垃圾回收:对应一次或屡次Pause Mixed
      主要作的是对老年代的区域内存进行疏散(Evacuation),也包含对年轻代的区域回收工做。同时这一步也会动态地调整IHOP

从对G1的GC日志的分析,能够看到G1的垃圾回收行为是基于一个可预测的模型:GC会不断的主动触发垃圾回收,在这个过程当中不断地进行信息统计和系统GC参数的设置,而后将上边这些步骤安排在这些垃圾回收过程当中。

大对象的分配

正常状况下,一个对象会在年轻代的Eden中建立,而后经过垃圾回收和年龄管理以后,晋升到老年代。但对于某些比较大的对象,可能会直接分配到老年代去。

对于G1,对象大多数状况都会在Eden上分配,若是JVM判断一个对象为大对象(其阈值能够经过-XX:G1HeapRegionSize来设置),则会直接分配如老年代的大对象区域中。

对于其余的内存区域连续的GC,下面是从StackOverflow上搬运过来的对象在堆上的分配过程:

  1. 使用 thread local allocation buffer (TLAB), 若是空间足够,则分配成功。
    从名称即可知,TLAB是线程独占的,因此线程安全,且速度很是快。若是一个TLAB满了,线程会被分配一个新的TLAB。

  2. 若是TLAB 空间不够此次分配对象,但其中还有不少空间可用,则不使用TLAB,直接在Eden中分配对象。
    直接在Eden上分配对象要去抢占Eden中的指针操做,其代价较使用TLAB要大一些。

  3. 若是Eden的对象分配失败,出发Minor GC。

  4. 若是Minor GC完成后还不够,则直接分配到老年代。

一些简单的GC调优方法

1. 使用不一样的索引对象

引用的类型会直接影响其所引用对象的GC行为,当要作一些内存敏感的应用时,能够参考使用合适的引用类型。具体能够参考JDK解构 - Java中的引用和动态代理的实现

2. 使用Parallel

从上文中可知,Java 8默认的GC是Parallel,它也叫Throughput,因此它的目的是尽量的增长系统的吞吐量。在Parallel里,能够经过参数调节最大中止时间(-XX:MaxGCPauseMillis,默认无设置)和吞吐量(-XX:GCTimeRatio,默认值是99,即最大使用1%的时间来作垃圾回收)来调优GC的行为。其中设置最大中止时间可能会致使GC调节各年龄代分区的尺寸(经过增量来实现)。

3. 使用G1

从Java 9开始G1变成了默认的GC,G1中有一些细节的概念在上文中没有叙述,这里先介绍一下:

  1. Remembered Sets(Rsets):对于每一个区域,都有一个集合记录这个区域中全部的引用。
    1. G1 refinement:G1中须要有一系列的线程不断地维护Rsets。
    2. Collection Sets(Csets):在垃圾回收中须要被回收的区域,这些区域中的可达对象(活着的对象)会被疏散。这些区域多是任何年龄代。
    3. 写屏障(Write Barriers):对于每一次赋值操做,G1都会有两个写屏障,写以前(Pre-Write)一个,写以后(Post-Write)一个。Pre-write主要与SATB相关,Post-write主要与Rsets相关
    4. Dirty Card Queue:写屏障会将写的记录放入这个队列,会有线程将这里的对象不断的刷入Rsets。
    5. Green/Yellow/Red Zone:三个会影响处理Dirty Card Queue线程数的阈值。根据Dirty Card Queue中元素的个数,能够来设置一些GC行为(能够认为是逻辑上将Dirty Card Queue分隔成多个区域)。Green表示超过此阈值则开始新建线程来处理这个队列,Yellow表示超过此阈值,强制启动这些线程,Red表示超过此阈值则会让写操做的线程本身来执行G1 refinement。

G1提供了丰富的基于不一样目的的可调优的参数,列表以下:

参数 描述
-XX:+G1UseAdaptiveConcRefinement, 调节G1 refinement所使用的资源。
-XX:G1ConcRefinementGreenZone=, 调节G1 refinement所使用的资源。
-XX:G1ConcRefinementYellowZone=, 调节G1 refinement所使用的资源。
-XX:G1ConcRefinementRedZone=, 调节G1 refinement所使用的资源。
-XX:G1ConcRefinementThreads= 调节G1 refinement所使用的资源。
-XX:G1RSetUpdatingPauseTimePercent=10 调节G1 refinement所须要的时间在整个垃圾回收时间的比例,G1会根据这个时间动态地调节第一行的各个参数。
-XX:+ReduceInitialCardMarks 批量执行对象的生成,以减小初始标记的时间
-XX:-ParallelRefProcEnabled 使用多线程处理上文中所说的在从新标记阶段对引用的处理
-XX:G1SummarizeRSetStatsPeriod= 设置n次垃圾回收后,打印Rsets的总结性报告。
-XX:GCTimeRatio= 设置GC吞吐量。GC总共应该使用的时间是1 / (1 + n),这个参数会影响不一样年龄代尺寸的增加。
-XX:G1HeapRegionSize 设置区域的大小

主要参考文档:

  1. Getting Started with the G1 Garbage Collector
  2. Garbage-First Garbage Collector Tuning
  3. Evaluating and improving remembered sets in the HotSpot G1 garbage collector
  4. G1GC Internals
  5. GC Algorithms: Basics
  6. Java中几种常量池的区分、、
相关文章
相关标签/搜索