TiDB整体架构与实践心得

605 阅读33分钟

Java内存模型

首先来回顾一下JVM内存模型,如下图:

以提升响应速度和吞吐量为目标性能优化的关键域就在Java堆和垃圾回收器

堆和栈的内存分配

    • Stack(栈)是JVM的内存指令区,顺序分配,内存大小定长,速度很快;

    • Heap(堆)是JVM的内存数据区,分配不定长的内存空间;

静态和非静态方法的内存分配

  • 这个隐含的参数就是对象实例在Stack中的地址指针。非静态方法必须获得

    该隐含参数,因此非静态方法在调用前,必须先new一个对象实例,获得

    Stack中的地址指针,否则JVM将无法将隐含参数传给非静态方法。

    只要class文件被ClassLoader load进入JVM的Stack,该静态方法即可被调用。当然此时静态方法是存取不到Heap中的对象属性的。

    前面提到对象实例以及动态属性都是保存在Heap中的,而Heap 必须

    通过Stack中的地址指针才能够被指令(类的方法)访问到。因此可以推断出:

    静态属性是保存在Stack中的,而不同于动态属性保存在Heap中。正因为都是

    在Stack中,而Stack中指令和数据都是定长的,因此很容易算出偏移量,也

    因此不管什么指令(类的方法),都可以访问到类的静态属性。也正因为静态

    属性被保存在Stack中,所以具有了全局属性。

      在JVM中,静态属性保存在Stack指令内存区,动态属性保存在Heap数据

    内存区。

    当一个class文件被ClassLoader load进入JVM后,方法指令保存在Stack中,

    此时Heap区没有数据。然后程序技术器开始执行指令,

    如果是静态方法,直接依次执行指令代码,当然此时指令代码是不能访问Heap

    数据区的;

    如果是非静态方法,由于隐含参数没有值,会报错。因此在非静态方法执行前,要先new对象,在Heap 中分配数据,并把Stack中的地址指针交给非静态方法,这样程序技术器依次执行指令,而指令代码此时能够访问到Heap 数据区了。

    • 非静态方法有一个隐含的传入参数,该参数是JVM给它的;

    • 静态方法无此隐含参数,因此也不需要new对象;

    • 静态属性和动态属性;

    • 方法加载过程;

JVM内存模型概念

①程序计数器(Program Counter Register)

  程序计数器是用于存储每个线程下一步将执行的JVM指令,如该方法为native的,则程序计数器中不存储任何信息。

②JVM栈(JVM Stack)

  JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈,JVM栈中存放的为当前线程中局部基本类型的变量(java中定义的八种基本类型:boolean、char、byte、short、int、long、float、double)、部分的返回结果以及Stack Frame,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址。

③堆(Heap)

  它是JVM用来存储对象实例以及数组值的区域,可以认为Java中所有通过new创建的对象的内存都在此分配,Heap中的对象的内存需要等待GC进行回收。

  (1)堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的

  (2)Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配

  (3)TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。

④方法区(Method Area)

  (1)在Sun JDK中这块区域对应的为PermanetGeneration,又称为持久代。

  (2)方法区域存放了所加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息,当开发人员在程序中通过Class对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区域,同时方法区域也是全局共享的,在一定的条件下它也会被GC,当方法区域需要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。

⑤本地方法栈(Native Method Stacks)

  JVM采用本地方法栈来支持native方法的执行,此区域用于存储每个native方法调用的状态。

⑥运行时常量池(Runtime Constant Pool)

  存放的为类中的固定的常量信息、方法和Field的引用信息等,其空间从方法区域中分配。JVM在加载类时会为每个class分配一个独立的常量池,但是运行时常量池中的字符串常量池是全局共享的。

JVM堆内存(Heap)

JVM将堆分成了二个大区新生代(Young)和老年代(Old),新生代又被进一步划分为Eden和Survivor区,而Survivor由FromSpace(S0)和ToSpace(S1)组成。Young中的98%的对象都是死朝生夕死,所以将内存分为一块较大的Eden和两块较小的

Survivor0、Survivor1,JVM默认分配是8:1:1,每次调用Eden和其中的Survivor0(FromSpace),当发生回收的时候,将Eden和Survivor0(FromSpace)存活的对象复制到Survivor1(ToSpace),然后直接清理掉Eden和Survivor0的空间。

堆模型如下图:

新生代的GC(Minor GC):新生代通常存活时间较短基于Copying算法进行回收,所谓Copying算法就是扫描出存活的对象,并复制到一块新的完全未使用的空间中,对应于新生代,就是在Eden和FromSpace或ToSpace之间copy。新生代采用空闲指针的方式来控制GC触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发GC。当连续分配对象时,对象会逐渐从Eden到Survivor,最后到老年代。

老年代的GC(Major GC/Full GC):老年代与新生代不同,老年代对象存活的时间比较长、比较稳定,因此采用标记(Mark)算法来进行回收,所谓标记就是扫描出存活的对象,然后再进行回收未被标记的对象,回收后对用空出的空间要么进行合并、要么标记出来便于下次进行分配,总之目的就是要减少内存碎片带来的效率损耗。

垃圾回收算法

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

  这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。

标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

②.Copying(复制)算法

  为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。

这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。 很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。新生代GC算法采用的是这种算法

③Mark-Compact(标记-整理)算法

  为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。

JVM中老年代GC就是使用的这种算法,由于老年代的特点是每次回收都只回收少量对象。

新生代GC :串行GC(SerialGC)、并行回收GC(ParallelScavenge)和并行GC(ParNew)

串行GC:在整个扫描和复制过程采用单线程的方式来进行,适用于单CPU、新生代空间较小及对暂停时间要求不是非常高的应用上,是client级别默认的GC方式,可以通过-XX:+UseSerialGC来强制指定。

并行回收GC:在整个扫描和复制过程采用多线程的方式来进行,适用于多CPU、对暂停时间要求较短的应用上,是server级别默认采用的GC方式,可用-XX:+UseParallelGC来强制指定,用-XX:ParallelGCThreads=4来指定线程数。

并行GC:与老年代的并发GC配合使用。

老年代GC:串行GC(Serial MSC)、并行GC(Parallel MSC)和并发GC(CMS)。

串行GC(Serial MSC):client模式下的默认GC方式,可通过-XX:+UseSerialGC强制指定。每次进行全部回收,进行Compact,非常耗费时间。

并行GC(Parallel MSC):吞吐量大,但是GC的时候响应很慢:server模式下的默认GC方式,也可用-XX:+UseParallelGC=强制指定。可以在选项后加等号来制定并行的线程数。

并发GC(CMS):响应比并行gc快很多,但是牺牲了一定的吞吐量。

CMS垃圾回收算法(Concurrent Mark-Sweep)

应用场景

    • CMS满足对响应时间的重要性需求 大于对吞吐量的要求;

    • 应用中存在比较多的长生命周期的对象的应用;

    • CMS用于年老代的回收,目标是尽量减少应用的暂停时间,减少full gc发生的几率,利用和应用程序线程并发的垃圾回收线程来标记清除年老代。

收集阶段

    • 初始标记 (Initial Mark)

      (Stop the World Event,所有应用线程暂停)

      从root对象开始标记存活的对象。

      暂停时间一般持续时间较短。

    • 并发标记 (Concurrent Marking)

      和Java应用程序线程并发运行;

      遍历老年代的对象图,标记出活着的对象。

      扫描从被标记的对象开始,直到遍历完从root可达的所有对象.

    • 再次标记(Remark)

      (Stop the World Event, 所有应用线程暂停)

      查找在并发标记阶段漏过的对象,这些对象是在并发收集器完成对象跟踪之后由应用线程更新的.

    • 并发清理(Concurrent Sweep)

      回收在标记阶段(marking phases)确定为不可达的对象.

      垃圾对象占用的空间添加到一个空闲列表(free list),供以后的分配使用。死对象的合并可能在此时发生. 请注意,存活的对象并没有被移动.

    • 重置(Resetting) 清理数据结构,为下一个并发收集做准备.

触发场景

与其他老年代的垃圾回收器相比,CMS在老年代空间占满之前就应该开始。

CMS收集会在老年代的空闲时间少于某一个阈值的时候被触发(这个阈值可以是动态统计出来的,也可以是固定设置的),而实际的回收周期可能要延迟到下一次年轻代的回收。为什么要这样,前面已经有解释了。在某些极端恶劣的情况下,对象会直接在老年代中进行分配,并且CMS回收周期开始的时候,eden区尚有非常多的对象。这个时候初始标记阶段会有多于10-100倍的时间消耗。这个通常是因为要分配非常大的对象。几兆的数组等。为了尽量避免长时间的暂停,我们需要合理的配置

启动CMS设置参数:

-XX:+UseConcMarkSweepGC

配置固定的CMS启动阈值:

  1. -XX:+UseCMSInitiatingOccupancyOnly
  2. -XX:MCSInitiatingOccupancyFraction=70

如果CMS不能够在老年代清理出足够的空间,会导致异常,使得JVM临时启动Serial Old垃圾回收方式进行回收。这个会造成长时间的stop-the-world暂停。全量的GC的原因可能有两个

    • CMS垃圾回收的速度跟不上了

    • 老年代中有大量的内存碎片

一个导致CMS需要进行全量GC的原因是永久代中的垃圾。默认情况下,CMS是不回收永久代中的垃圾的。如果在你的应用中使用了多个类加载器,或者反射机制,那么就需要对永久代进行回收。采用参数-XX:+CMSClassUnloadingEnabled会打开永久代的垃圾回收。

通过使用以下的选项,可以使得CMS充分利用多核:

  1. -XX:+CMSConcurrentMTEnabled 在并发阶段,可以利用多核
  2. -XX:+ConcGCThreads 指定线程数量
  3. -XX:+ParallelGCThreads 指定在stop-the-world过程中,垃圾回收的线程数,默认是cpu的个数
  4. -XX:+UseParNewGC 年轻代采用并行的垃圾回收器

CMS的缺点

  • CMS占用CPU资源,4个CPU以上才能更好发挥CMS优势

    CMS并发阶段,它不会导致用户线程停顿,但会因为占用了一部分线程(或CPU资源)而导致应用程序变慢,总吞吐量会降低。

    CMS默认启动的回收线程数是(CPU数量+3)/ 4,也就是当CPU在4个以上时,并发回收时垃圾收集线程最多占用不超过25%的CPU资源。但是当CPU不足4个时(譬如2个),那么CMS对用户程序的影响就可能变得很大,如果CPU负载本来就比较大的时候,还分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,这也很让人受不了。

    为了解决这种情况,虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep / i-CMS)的CMS收集器变种,所做的事情和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思想一样,就是在并发标记和并发清理的时候让GC线程、用户线程交替运行,尽量减少GC线程的独占资源的时间,这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得少一些,速度下降也就没有那么明显,但是目前版本中,i-CMS已经被声明为“deprecated”。

  • 产生浮动垃圾

    CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC。

    原因:

    CMS并发清理阶段,同时用户线程还在运行着,伴随程序的运行自然还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理掉它们,只好留待下一次GC时再将其清理掉。

    这一部分垃圾就称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,即还需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。

    在默认设置下,CMS收集器在老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数以获取更好的性能。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量“Concurrent Mode Failure”失败,性能反而降低。

  • 产生大量的空间碎片

CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会产生大量空间碎片。空间碎片过多时,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。

为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数,用于在“享受”完Full GC服务之后额外免费附送一个碎片整理过程,内存整理的过程是无法并发的。

空间碎片问题没有了,但停顿时间不得不变长了。虚拟机设计者们还提供了另外一个参数-XX: CMSFullGCsBeforeCompaction,这个参数用于设置在执行多少次不压缩的Full GC后,跟着来一次带压缩的。

G1垃圾回收算法(Garbage First)

G1回收算法诞生原因

在G1出现之前,经典的垃圾收集器主要有三种类型:串行收集器、并行收集器和并发标记清除收集器,这三种收集器分别可以是满足Java应用三种不同的需求:内存占用及并发开销最小化、应用吞吐量最大化和应用GC暂停时间最小化,但是,上述三种垃圾收集器都有几个共同的问题:

    • 所有针对老年代的操作必须扫描整个老年代空间;

    • 新生代和老年代是独立的连续的内存块,必须先决定年轻代和老年代在虚拟

      地址空间的位置。

G1是一种服务端应用使用的垃圾收集器,目标是用在多核、大内存的机器上,它在大多数情况下可以实现指定的GC暂停时间,同时还能保持较高的吞吐量。

适用场景

G1适用以下几种场景的应用

    • 可以像CMS收集器一样,允许垃圾收集线程和应用线程并行执行,即需要

      额外的CPU资源;

    • 压缩空闲空间不会延长GC的暂停时间;

    • 需要更易预测的GC暂停时间;

    • 不需要实现很高的吞吐量

堆内存模型变动

分区(Region)

G1采取了不同的策略来解决并行、串行和CMS收集器的碎片、暂停时间不可控制等问题——G1将整个堆分成相同大小的分区(Region)

每个分区都可能是年轻代也可能是老年代,但是在同一时刻只能属于某个代。

年轻代、幸存区、老年代这些概念还存在,成为逻辑上的概念,这样方便复用之前分代框架的逻辑。在物理上不需要连续,则带来了额外的好处——有的分区内垃圾对象特别多,有的分区内垃圾对象很少,G1会优先回收垃圾对象特别多的分区,这样可以花费较少的时间来回收这些分区的垃圾,这也就是G1名字的由来,即首先收集垃圾最多的分区。

新生代其实并不是适用于这种算法的,依然是在新生代满了的时候,对整个新生代进行回收——整个新生代中的对象,要么被回收、要么晋升,至于新生代也采取分区机制的原因,则是因为这样跟老年代的策略统一,方便调整代的大小。

G1还是一种带压缩的收集器,在回收老年代的分区时,是将存活的对象从一个分区拷贝到另一个可用分区,这个拷贝的过程就实现了局部的压缩。每个分区的大小从1M到32M不等,但是都是2的冥次方。

收集集合(CSet)

一组可被回收的分区的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个堆空间的1%大小。

记忆集合(RSet)

RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。RSet的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可。

如下图所示,Region1和Region3中的对象都引用了Region2中的对象,因此在Region2的RSet中记录了这两个引用。

G1 GC是在points-out的card table之上再加了一层结构来构成points-into RSet:每个region会记录下到底哪些别的region有指向自己的指针,而这些指针分别在哪些card的范围内。 这个RSet其实是一个hash table,key是别的region的起始地址,value是一个集合,里面的元素是card table的index。 举例来说,如果region A的RSet里有一项的key是region B,value里有index为1234的card,它的意思就是region B的一个card里有引用指向region A。所以对region A来说,该RSet记录的是points-into的关系;而card table仍然记录了points-out的关系。

G1的过程

G1收集器的收集活动主要有四种操作:

    • 新生代垃圾收集

    • 后台收集、并发周期

    • 混合式垃圾收集

    • 必要时候的Full GC

新生代垃圾收集

  • Eden区耗尽的时候就会触发新生代收集,新生代垃圾收集会对整个新生代进行回收

  • 新生代垃圾收集期间,整个应用STW

  • 新生代垃圾收集是由多线程并发执行的

  • 新生代收集结束后依然存活的对象,会被拷贝到一个新的Survivor分区,或者是老年代。

后台收集,并发周期

G1设计了一个标记阈值,它描述的是总体Java堆大小的百分比,默认值是45,这个值可以通过命令-XX:InitiatingHeapOccupancyPercent(IHOP)来调整,一旦达到这个阈值就回触发一次并发收集周期。注意:这里的百分比是针对整个堆大小的百分比,而CMS中的CMSInitiatingOccupancyFraction命令选型是针对老年代的百分比。

  • 新生代的空间占用情况发生了变化——在并发收集周期中,至少有一次(很可能是多次)新生代垃圾收集;

  • 注意到一些分区被标记为X,这些分区属于老年代,它们就是标记周期找出的包含最多垃圾的分区(注意:它们内部仍然保留着数据);

  • 老年代的空间占用在标记周期结束后变得更多,这是因为在标记周期期间,新生代的垃圾收集会晋升对象到老年代,而且标记周期中并不会是否老年代的任何对象。

G1的并发标记周期包括多个阶段

  • 初始标记(initial-mark),在这个阶段,应用会经历STW,通常初始标记阶段会跟一次新生代收集一起进行,换句话说——既然这两个阶段都需要暂停应用,G1 GC就重用了新生代收集来完成初始标记的工作。在新生代垃圾收集中进行初始标记的工作,会让停顿时间稍微长一点,并且会增加CPU的开销。初始标记做的工作是设置两个TAMS变量(NTAMS和PTAMS)的值,所有在TAMS之上的对象在这个并发周期内会被识别为隐式存活对象;

  • 根分区扫描(root-region-scan),这个过程不需要暂停应用,在初始标记或新生代收集中被拷贝到survivor分区的对象,都需要被看做是根,这个阶段G1开始扫描survivor分区,所有被survivor分区所引用的对象都会被扫描到并将被标记。survivor分区就是根分区,正因为这个,该阶段不能发生新生代收集,如果扫描根分区时,新生代的空间恰好用尽,新生代垃圾收集必须等待根分区扫描结束才能完成。如果在日志中发现根分区扫描和新生代收集的日志交替出现,就说明当前应用需要调优。

  • 并发标记阶段(concurrent-mark),并发标记阶段是多线程的,我们可以通过-XX:ConcGCThreads来设置并发线程数,默认情况下,G1垃圾收集器会将这个线程总数设置为并行垃圾线程数(-XX:ParallelGCThreads)的四分之一;并发标记会利用trace算法找到所有活着的对象,并记录在一个bitmap中,因为在TAMS之上的对象都被视为隐式存活,因此我们只需要遍历那些在TAMS之下的;记录在标记的时候发生的引用改变,SATB的思路是在开始的时候设置一个快照,然后假定这个快照不改变,根据这个快照去进行trace,这时候如果某个对象的引用发生变化,就需要通过pre-write barrier logs将该对象的旧的值记录在一个SATB缓冲区中,如果这个缓冲区满了,就把它加到一个全局的列表中——G1会有并发标记的线程定期去处理这个全局列表。

  • 重新标记阶段(remarking),重新标记阶段是最后一个标记阶段,需要暂停整个应用,G1垃圾收集器会处理掉剩下的SATB日志缓冲区和所有更新的引用,同时G1垃圾收集器还会找出所有未被标记的存活对象。这个阶段还会负责引用处理等工作。

  • 清理阶段(cleanup),清理阶段真正回收的内存很小,截止到这个阶段,G1垃圾收集器主要是标记处哪些老年代分区可以回收,将老年代按照它们的存活度(liveness)从小到大排列。这个过程还会做几个事情:识别出所有空闲的分区、RSet梳理、将不用的类从metaspace中卸载、回收巨型对象等等。识别出每个分区里存活的对象有个好处是在遇到一个完全空闲的分区时,它的RSet可以立即被清理,同时这个分区可以立刻被回收并释放到空闲队列中,而不需要再放入CSet等待混合收集阶段回收;梳理RSet有助于发现无用的引用。

混合式垃圾收集
混合收集只会回收一部分老年代分区,下图是第一次混合收集前后的堆情况对比。

混合收集会执行多次,一直运行到(几乎)所有标记点老年代分区都被回收,在这之后就会恢复到常规的新生代垃圾收集周期。当整个堆的使用率超过指定的百分比时,G1 GC会启动新一轮的并发标记周期。在混合收集周期中,对于要回收的分区,会将该分区中存活的数据拷贝到另一个分区,这也是为什么G1收集器最终出现碎片化的频率比CMS收集器小得多的原因——以这种方式回收对象,实际上伴随着针对当前分区的压缩。

巨型对象管理

在G1中,如果一个对象的大小超过分区大小的一半,该对象就被定义为巨型对象(Humongous Object)。巨型对象创建时直接分配到老年代分区,如果一个对象的大小超过一个分区的大小,那么会直接在老年代分配两个连续的分区来存放该巨型对象。巨型分区一定是连续的,分配之后也不会被移动。

由于巨型对象的存在,G1的堆中的分区就分成了三种类型:新生代分区、老年代分区和巨型分区,如下图所示:

如果一个巨型对象跨越两个分区,开始的那个分区被称为“开始巨型”,后面的分区被称为“连续巨型”,这样最后一个分区的一部分空间是被浪费掉的,如果有很多巨型对象都刚好比分区大小多一点,就会造成很多空间的浪费,从而导致堆的碎片化。如果你发现有很多由于巨型对象分配引起的连续的并发周期,并且堆已经碎片化(明明空间够,但是触发了FULL GC),可以考虑调整-XX:G1HeapRegionSize参数,减少或消除巨型对象的分配。

关于巨型对象的回收:在JDK8u40之前,巨型对象的回收只能在并发收集周期的清除阶段或FULL GC过程中过程中被回收,在JDK8u40(包括这个版本)之后,一旦没有任何其他对象引用巨型对象,那么巨型对象也可以在年轻代收集中被回收。

G1执行过程中异常情况

1.并发标记周期开始后的FULL GC

G1启动了标记周期,但是在并发标记完成之前,就发生了Full GC

  • 51.408: [GC concurrent-mark-start]65.473: [Full GC 4095M->1395M(4096M), 6.1963770 secs] [Times: user=7.87 sys=0.00, real=6.20 secs]71.669: [GC concurrent-mark-abort]

GC concurrent-mark-start开始之后就发生了FULL GC,这说明针对老年代分区的回收速度比较慢,或者说对象过快得从新生代晋升到老年代,或者说是有很多大对象直接在老年代分配。针对上述原因,我们可能需要做的调整有:调大整个堆的大小、更快得触发并发回收周期、让更多的回收线程参与到垃圾收集的动作中

2.混合收集模式中的FULL GC

在GC日志中观察到,在一次混合收集之后跟着一条FULL GC,这意味着混合收集的速度太慢,在老年代释放出足够多的分区之前,应用程序就来请求比当前剩余可分配空间大的内存。针对这种情况我们可以做的调整:增加每次混合收集收集掉的老年代分区个数;增加并发标记的线程数;提高混合收集发生的频率。

3.疏散失败(转移失败)

在新生代垃圾收集快结束时,找不到可用的分区接收存活下来的对象

60.238: [GC pause (young) (to-space overflow), 0.41546900 secs]

这意味着整个堆的碎片化已经非常严重了,我们可以从以下几个方面调整:

(1)增加整个堆的大小——通过增加-XX:G1ReservePercent选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量;

(2)通过减少 -XX:InitiatingHeapOccupancyPercent提前启动标记周期;

(3)可以通过增加-XX:ConcGCThreads选项的值来增加并发标记线程的数目;

4.巨型对象分配失败

G1调优实践

G1的调优目标主要是在避免FULL GC和疏散失败的前提下,尽量实现较短的停顿时间和较高的吞吐量

  • 不要自己显式设置新生代的大小(用Xmn-XX:NewRatio参数),如果显式设置

    新生代的大小,会导致目标时间这个参数失效。

  • 由于G1收集器自身已经有一套预测和调整机制了,因此我们首先的选择是相信它,即调整-XX:MaxGCPauseMillis=N参数,这也符合G1的目的——让GC调优尽量简单,这里有个取舍:如果减小这个参数的值,就意味着会调小新生代的大小,也会导致新生代GC发生得更频繁,同时,还会导致混合收集周期中回收的老年代分区减少,从而增加FULL GC的风险。这个时间设置得越短,应用的吞吐量也会受到影响。

  • 针对混合垃圾收集的调优。如果调整这期望的最大暂停时间这个参数还是无法解决问题,即在日志中仍然可以看到FULL GC的现象,那么就需要自己手动做一些调整,可以做的调整包括:

    • 调整G1垃圾收集的后台线程数,通过设置-XX:ConcGCThreads=n这个参数,可以增加后台标记线程的数量;

    • 调整G1垃圾收集器并发周期的频率,如果让G1更早得启动垃圾收集,可以通过

      设置-XX:InitiatingHeapOccupancyPercent这个参数来实现这个目标,如果将这个参数调小,G1就会更早得触发并发垃圾收集周期。这个值需要谨慎设置:如果这个参数设置得太高,会导致FULL GC出现得频繁;如果这个值设置得过小,又会导致G1频繁得进行并发收集,白白浪费CPU资源。通过GC日志可以通过一个点来判断GC是否正常——在一轮并发周期结束后,需要确保堆剩下的空间小于InitiatingHeapOccupancyPercent的值。

    • 调整G1垃圾收集器的混合收集的工作量,即在一次混合垃圾收集中尽量多处理一些分区,可以从另外一方面提高混合垃圾收集的频率。在一次混合收集中可以回收多少分区,取决于三个因素:(1)有多少个分区被认定为垃圾分区,-``XX:G1MixedGCLiveThresholdPercent=n这个参数表示如果一个分区中的存活对象比例超过n,就不会被挑选为垃圾分区,因此可以通过这个参数控制每次混合收集的分区个数,这个参数的值越大,某个分区越容易被当做是垃圾分区;(2)G1在一个并发周期中,最多经历几次混合收集周期,这个可以通过-``XX:G1MixedGCCountTarget=n设置,默认是8,如果减小这个值,可以增加每次混合收集的分区数,但是可能会导致停顿时间过长;(3)期望的GC停顿的最大值,由MaxGCPauseMillis参数确定,默认值是200ms,在混合收集周期内的停顿时间是向上规整的,如果实际运行时间比这个参数小,那么G1就能收集更多的分区。

关键参数项

  • -XX:+UseG1GC,告诉JVM使用G1垃圾收集器

  • -XX:MaxGCPauseMillis=200,设置GC暂停时间的目标最大值,这是个柔性的目标,JVM会尽力达到这个目标

  • -XX:INitiatingHeapOccupancyPercent=45,如果整个堆的使用率超过这个值,G1会触发一次并发周期。记住这里针对的是整个堆空间的比例,而不是某个分代的比例

参数列表

参数名

含义

默认值

-XX:+UseG1GC

使用G1收集器

JDK1.8中还需要显式指定

-XX:MaxGCPauseMillis=n

设置一个期望的最大GC暂停时间,这是一个柔性的目标,JVM会尽力去达到这个目标

200

-XX:InitiatingHeapOccupancyPercent=n

当整个堆的空间使用百分比超过这个值时,就会触发一次并发收集周期,记住是整个堆

45

-XX:NewRatio=n

新生代和老年代的比例

2

-XX:SurvivorRatio=n

Eden空间和Survivor空间的比例

8

-XX:MaxTenuringThreshold=n

对象在新生代中经历的最多的新生代收集,或者说最大的岁数

G1中是15

-XX:ParallelGCThreads=n

设置垃圾收集器的并行阶段的垃圾收集线程数

不同的平台有不同的值

-XX:ConcGCThreads=n

设置垃圾收集器并发执行GC的线程数

n一般是ParallelGCThreads的四分之一

-XX:G1ReservePercent=n

设置作为空闲空间的预留内存百分比,以降低目标空间溢出(疏散失败)的风险。默认值是 10%。增加或减少这个值,请确保对总的 Java 堆调整相同的量

10

-XX:G1HeapRegionSize=n

分区的大小

堆内存大小的1/2000,单位是MB,值是2的幂,范围是1MB到32MB之间

-XX:G1HeapWastePercent=n

设置您愿意浪费的堆百分比。如果可回收百分比小于堆废物百分比,JavaHotSpotVM不会启动混合垃圾回收周期(注意,这个参数可以用于调整混合收集的频率)。

JDK1.8是5

-XX:G1MixedGCCountTarget=8

设置并发周期后需要执行多少次混合收集,如果混合收集中STW的时间过长,可以考虑增大这个参数。(注意:这个可以用来调整每次混合收集中回收掉老年代分区的多少,即调节混合收集的停顿时间)

8

-XX:G1MixedGCLiveThresholdPercent=n

一个分区是否会被放入mix GC的CSet的阈值。对于一个分区来说,它的存活对象率如果超过这个比例,则改分区不会被列入mixed gc的CSet中

JDK1.6和1.7是65,JDK1.8是85