JVM基础(5)——垃圾回收和调优

来源:互联网 发布:35互联域名证书下载 编辑:程序博客网 时间:2024/06/03 18:44

系列文章规划:

  1. JVM基础(1)——内存模型
  2. JVM基础(2)——内存管理
  3. JVM基础(3)——编译机制
  4. JVM基础(4)——类加载机制
  5. JVM基础(5)——垃圾回收和调优
  6. JVM基础(6)——G1收集器及G1日志分析
  7. JVM基础(7)——jdk常用内置工具

  • 认识GC
    • 1 GC
    • 2 GC工作
    • 4 GC算法
    • 5 GC类型
  • 监控GC
  • 调优GC
    • 1 调优目标
    • 2 调优配置
    • 3 调优过程
  • 参考文献

当应用规模达到一定量级时,GC对项目性能的影响会放大,我们需要通过GC调优实现了项目性能的提升。这不仅考验着我们对GC工作原理的理解,也考验着我们对应用特性的理解,是通往优秀程序员的必由之路。

下面,我们通过浅显易懂的文字介绍一下GC调优。

1. 认识GC

首先,我们需要认识GC。知道什么是GC,不同的GC算法,GC是如何工作的,young generation和old generation是什么,5种GC类型及其使用场景。

1.1 GC

GC(Garbage Collector)的出现和JVM的内存管理机制有关。Java并不在代码层提供内存释放的API,而是由JVM去自主清理内存,将不可达的对象清理掉,这个过程就叫GC。GC的设计是基于弱分代假设的:

  1. 大部分新对象立即不可达;
  2. 只存在少量old对象对young对象的引用。

为了保证这两个假设的有效性,HotSpot VM将Heap分为两个区:

  • Young Generation。绝大多数新对象会被分配到这里,由于大部分对象在创建后会立即不可达,所以很多对象被创建在新生代,然后消失。对象从该区消失的过程叫“minor GC”或“young GC”
  • Old Generation。从Young Generation幸存下来的对象移动到该区。对象从该区消失的过程叫“major GC”或“full GC”通常该区比Young Generation要大,发生GC的频率要比Young Generation小。

1.2 GC工作

Young Generation被分为3个区Eden、From和To。执行如下:

  1. 绝大多数New Object会存放在Eden;
  2. Eden满,或者新对象大小 > Eden所剩空间,Eden执行GC,GC后存活的对象被移动到From;
  3. 此后Eden执行GC后幸存的对象会被堆积在From;
  4. 当From饱和,From执行GC,GC幸存的对象会被移动到To,然后清空From,并将To置为From,From置为To;
  5. 在以上步骤中重复几次依然存活的对象,就会被移动到Old Generation。

Old Generation的GC事件基本上是在空间已满时发生,执行的过程根据GC类型不同而不同。

1.4 GC算法

GC可以有多种不同的实现,这里简单介绍下主要的GC算法和核心思想。

1 引用计数法

每个对象添加一个引用计数器,每被引用一次,计数器加1,失去引用,计数器减1,当计数器在一段时间内保持为0时,该对象就认为是可以被回收得了。但是,这个算法有明显的缺陷:当两个对象相互引用,但是二者已经没有作用时,按照常规,应该对其进行垃圾回收,但是其相互引用,又不符合垃圾回收的条件,因此无法完美处理这块内存清理。因此Sun的JVM并没有采用引用计数算法来进行垃圾回收。因此在java中,单纯使用引用计数法实现垃圾回收是不可行的。

2 根搜索算法

由于引用计数算法的缺陷,所以JVM一般会采用一种新的算法,叫做根搜索算法。它的处理方式就是,设立若干种根对象,当任何一个根对象到某一个对象均不可达时,则认为这个对象是可以被回收的。

JAVA中可以当做GC roots的对象有以下几种:

  • 栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中的静态成员。
  • 方法区中的常量引用的对象(全局变量)
  • 本地方法栈中JNI(一般说的Native方法)引用的对象。

注:第一和第四种都是指的方法的本地变量表,第二种表达的意思比较清晰,第三种主要指的是声明为final的常量值。

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

现代垃圾回收算法的基础。分两个阶段:标记和清除。标记阶段,通过根节点,标记所有从根节点开始的可达对象,未被标记的对象就是垃圾对象。清除节点,清除所有垃圾对象。

这个算法效率不高,而且在清理完成后会产生内存碎片,这样,如果有大对象需要连续的内存空间时,还需要进行碎片整理,所以,此算法需要改进。

4 复制算法(Copying)

将原有内存分为两块,每次只使用一块,垃圾回收时,将正使用内存中存活的对象复制到未使用的内存块中,之后,清除正使用内存块中所有对象,交换两个内存的角色,完成垃圾回收。

存活对象少、垃圾对象多的前提下,复制算法效率高。又由于对象是在垃圾回收过程中统一被复制到新内存空间中,可保证回收后的内存空间无碎片。优点很明显,但缺点是内存会折半,单纯的复制算法也让人很难接受。

5 标记-压缩算法(Mark-Compact)

和标记-清除算法前半段一样,标记阶段,通过根节点,标记所有从根节点开始的可达对象,未被标记的对象就是垃圾对象。然后将所有存活对象压缩到内存的一端,使得内存连续,之后清理边界外所有内存空间。

避免了内存碎片,又不需要两块相同大小的内存,性价比较高。

6 增量算法(Incremental Colllecting)

对于大部分垃圾回收算法,垃圾回收时应用将stop-the-world。stop-the-world状态下,应用所有线程挂起,暂停一切正常工作,等待垃圾会回收完成。垃圾回收的时间长,应用被挂起的时间就长,严重影响用户体验和系统性能。

增量算法,每次圾收集线程只收集一小片区域的内存空间,接着切换到应用线程,以此反复,知道垃圾收集完成。

减少了系统停顿时间,但频繁的线程切换和上下文转换,会使垃圾收集的总成本上升,系统吞吐量下降。

7 分代算法(Generational Collecting)

上面的算法都有自己的优缺点,分代就是根据对象特点将内存划分成几块,根据每块内存区域的特点,选择合适的算法回收,以提高垃圾回收效率。

Young Generation的对象特点是朝生夕灭,大约90%的新对象会很快被回收,因此适合使用复制算法。

Old Generation的对象特点是存活时间长,存活率几乎达到100%,不适合复制算法,可选择标记-压缩算法。

1.5 GC类型

JDK 8有5种GC类型:

1 串行收集器(Serial GC)

串行收集器是单线程、独占式的垃圾回收。串行收集器运行时,应用所有线程停止工作,进入等待,这种现象为“Stop the world”。

serial gc

在新生代,串行收集器使用复制算法,实现简单,处理逻辑高效,无线程切换开销。硬件不是特别优越的场合,性能表现好。

在老年代,串行收集器使用标记-清除-压缩算法,一般老年代垃圾回收比较耗时,造成的停顿时间更长。

不建议使用串行收集器。因为该收集器只使用单核,会降低应用性能。

2 并行收集器(Parallel GC)

并行收集器是多线程、独占式的垃圾回收。关注吞吐量,使用在新生代,实现复制算法。

parallel gc

原理是将串行收集器多线程化。在并发能力强的CPU上效果比串行收集器好。若多线程压力大,则并行收集器可能还没有串行收集器好。

并行收集器使用在对吞吐量有要求的应用上。

3 老年代并行压缩收集器(Parallel Compacting GC)

和并行收集器类似,区别在于该收集器使用在老年代,实现标记-压缩算法。GC时经历标记-整理-压缩的过程,和标记-清除-压缩算法过程略有区别。

4 并发标记清除收集器(Concurrent Mark & Sweep GC ,简称”CMS”)

CMS是并发、非独占式的垃圾回收。关注系统停顿时间。使用标记-清除算法。

cms gc

主要工作步骤为:

  • 初始标记。独占系统资源。标记出需要回收的对象。只标记出GC ROOTS(如classloader)能直接关联到的对象,速度快。
  • 并发标记。非独占式。标记出需要回收的对象。进行GC ROOTS根搜索算法,判定对象是否存活
  • 重新标记。独占系统资源。标记出需要回收的对象。为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。
  • 并发清除。非独占式。
  • 并发重置。非独占式。垃圾回收完成后,重新初始化CMS数据结构和数据,为下一次垃圾回收做准备。

CMS常使用在响应时间敏感的应用上。需要注意的是,CMS比其他GC类型使用更多的内存和CPU。CMS是基于“标记-清除”算法实现的收集器,使用“标记-清除”算法收集后,会产生大量碎片。默认不进行压缩,需要手动设置。

5 G1收集器(Garbage First (G1) GC)

G1基于标记-压缩算法。

不同于其他的分代回收算法、G1将堆空间划分成了互相独立的区块。每块区域既有可能属于O区、也有可能是Y区,且每类区域空间可以是不连续的(对比CMS的O区和Y区都必须是连续的)。

包含以下阶段(其中有些阶段是属于Young GC的):

  • 初始并行阶段(Initial Marking Phase)。属于Young GC范畴,是stop-the-world活动。对持有老年代对象引用的Survivor区(Root区)进行标记。
  • Root区扫描(Root Region Scanning)。扫描Survivor区中的老年代对象引用,该阶段发生在应用运行时,必须在Young GC前完成。
  • 并行标记(Concurrent Marking)。找出整个堆中存活的对象,对于空区标记为“X”。该阶段发生在应用运行时,同时该阶段活动会被Young GC打断。
  • 重标记(Remark)。清除空区,重计算所有区的存活状态(liveness),是stop-the-world活动。
  • 清除(Cleanup)。选择出存活状态低的区进行收集。计算存活对象和空区,是stop-the-world活动。更新记录表,是stop-the-world活动。重置空区,将其加入空闲列表,是并行活动
  • 复制(Copying)。该阶段是stop-the-world活动,负责将存活对象复制到新的未使用的区。可以发生在年轻代区,日志记录为[GC pause (young)]。也可以同时发生在年轻代区和老年代区,日志记录为[GC Pause (mixed)]。

虽然在清理这些区块时G1仍然需要暂停应用线程、但可以用相对较少的时间优先回收包含垃圾最多区块。这也是为什么G1命名为Garbage First的原因:第一时间处理垃圾最多的区块。

在以下场景下G1更适合:

  • 服务端多核CPU、JVM内存占用较大的应用(至少大于4G)
  • 应用在运行过程中会产生大量内存碎片、需要经常压缩空间
  • 想要更可控、可预期的GC停顿周期;防止高并发下应用雪崩现象

2 监控GC

了解了GC原理的相关知识后,我们有足够的信心去判断GC是否合理。下一步我们需要知道JVM实时进行GC的状态,以供我们进行判断。我们需要知道如何监控GC,监控哪些指标,有哪些工具可以使用。

目前有很多种监控GC的方法,但GC日志是由JVM产生,只有这唯一的一份,因此不同监控方法间唯一的区别在于如何显示GC操作信息。所以掌握一些核心监控方法即可,针对不同的场景选择合适的监控方法。

监控GC的方法主要分两种:

  • CUI。jstat、verbosegc
  • GUI。jconsole、jvisualvm、Visual GC。

jstat

具体使用参考 jstat –help。使用格式如下:

jstat <option> <pid>

gc结果含义参考其他文章。

-verbosegc

-verbosegc需要在启动时设置为java参数。下列参数可以和-verbosegc配合使用:

  • -XX:+PrintGCDetails
  • -XX:+PrintGCTimeStamps
  • -XX:+PrintHeapAtGC
  • -XX:+PrintGCDateStamps (from JDK 6 update 4)
  • -Xloggc:LOG_FILE_PATH(LOG_FILE_PATH为日志文件路径)

gc发生时,gc日志格式为:

[GC [<collector>: <starting occupancy1> -> <ending occupancy1>, <pause time1> secs] <starting occupancy3> -> <ending occupancy3>, <pause time3> secs]
Collector Name of Collector Used for minor gc starting occupancy1 The size of young area before GC ending occupancy1 The size of young area after GC pause time1 The time when the Java application stopped starting occupancy3 The total size of heap area before GC ending occupancy3 The total size of heap area after GC pause time3 The time when the Java application stopped

3 调优GC

了解了GC原理,获取了GC状态信息,下一步就是具体调优了。我们需要明确调优目标,知道有哪些调优配置参数,以及合理的调优过程。

3.1 调优目标

一个实际运行的java项目需要有以下特征:

  • 通过-Xms和-Xmx选项指定了内存大小
  • 使用了-server选项
  • 系统未产生太多超时日志

如果不具有以上特征,这样的java项目需要进行GC调优。

GC调优的目标是:降低移动到老年代的对象数量,缩短Full GC执行时间(stop-the-world持续的时间)。具体调优措施如下(按照重要性由高到低排序):

  1. 减少对象产生的数量;
  2. 选择合适的GC收集器;
  3. 调整新生代空间大小
  4. 调整老年代空间大小

1.减少对象产生的数量

GC产生的原因是heap内对象过多超过一定的大小导致。控制住对象的数量、大小,就控制住了GC的源头,从而保证了GC的性能。比如尽量少使用String,换用StringBuilder或StringBuffer。但更多时候我们不得不使用一些对象,我们只能换用其他措施。

2. 选择合适的GC收集器

GC收集器时回收对象的工具和场所,每种GC收集器都有其使用的场景,其性能表现也各不相同,选择合适的GC收集器,针对GC收集器进行后续优化。

3. 调整新生代空间大小

在Oracle JVM中除了JDK 7及最高版本中引入的G1 GC外,其他的GC都是基于分代回收的。也就是对象会在Eden区中创建,然后不断在Survivor中来回移动。之后如果该对象依然存活,就会被移到老年代中。有些对象,因为占用空间太大以致于在Eden区中创建后就直接移动到了老年代。老年代的GC较新生代会耗时更长,因此减少移动到老年代的对象数量可以降低full GC的频率。

减少对象转移到老年代可能会被误解为把对象驻留在新生代,然而这是不可能的,我们只能调整新生代的空间大小,让对象尽可能的在新生代回收掉。

4. 调整老年代空间大小

Heap主要由新生代和老年代。调整新生代大小的同时也在调整老年代大小。

Full GC的单次执行与Minor GC相比,耗时有较明显的增加。如果执行Full GC占用太长时间(例如超过1秒),在对外服务的连接中就可能会出现超时。

  • 通过缩小老年代空间的方式来降低Full GC执行时间,可能会面临OutOfMemoryError或者带来更频繁的Full GC。
  • 通过增加老年代空间来减少Full GC执行次数,单次Full GC耗时将会增加。

因此,需要为老年代空间设置适当的大小。

3.2 调优配置

内存分配

分类 选项 说明 堆空间 -Xms JVM启动时占据,JVM会将所用内存尽可能限制在-Xms内,触及-Xms时会引发Full GC。 -Xmx 堆空间最大值 新生代空间 -XX:NewRatio 新生代与老年代的比例 -XX:NewSize 新生代大小 -XX:SurvivorRatio Eden区与Survivor区的比例 -XX:TargetSurvivorRatio survivor可使用率,当survivor空间使用率达到这个值时,将对象送入老年代

GC收集器选择

分类 选项 说明 串行GC -XX:+UseSerialGC 新生代、老年代均使用串行收集器 并行GC -XX:+UseParallelGC 新生代使用并行收集器,老年代使用并行压缩收集器 -XX:+UseParallelGC -XX:+UseParallelOldGC 新生代使用并行收集器,老年代使用并行压缩收集器 -XX:+UseParallelGC -XX:-UseParallelOldGC 新生代使用并行收集器,老年代使用串行收集器 -XX:ParallelGCThreads=< value > 指定并行收集器工作时的线程数 并行压缩GC 参考并行GC CMS GC -XX:+UseConcMarkSweepGC 新生代使用并行收集器,老年代使用“CMS+串行”收集器 -XX:+CMSParallelRemarkEnabled 启用并行重标记 -XX:CMSInitiatingOccupancyFraction=< value > 堆内存使用率回收阈值,默认68% -XX:+UseCMSInitiatingOccupancyOnly 只在达到阈值时进行CMS回收 -XX:+UseCMSCompactAtFullCollection CMS回收后进行一次内存压缩 G1 -XX:+UseG1GC G1收集器

3.3 调优过程

具体过程为:

  1. 监控GC状态;
  2. 分析监控结果,确定是否需要调优;
  3. 选择GC类型;
  4. 设置内存大小;
  5. 分析调优结果(至少24小时);
  6. 如果调优结果理想,将调优配置扩展到该应用其他服务器上。否则,继续调优并分析。

1.监控GC状态

$ jstat -gcutil 21719 1sS0    S1    E    O    P    YGC    YGCT    FGC    FGCT GCT48.66 0.00 48.10 49.70 77.45 3428 172.623 3 59.050 231.67348.66 0.00 48.10 49.70 77.45 3428 172.623 3 59.050 231.673

2. 分析监控结果

YGCT/YGC=0.05s,执行一次young gc,平均需要50ms,可接受。

FGCT/FGC=19.68s,执行一次full gc,平均需要19.68s,需要调优。

当然,不能只看平均执行时间,还需要看执行次数等指标。

3. 选择GC类型

通常,CMS GC要比其他GC更快。但发生并发模式错误(CONCURRENT MODE FAILURE)时,CMS GC要比Parallel GC慢。

一般来说,除G1外的GC只适用于10G范围的gc,内存超过10G,这些GC性能会下降很快,建议使用G1。

4. 设置内存大小

以一次full gc后剩余的内存为基础,向上增加单位内存(如500M)。若full gc后剩余300M,则设置内存大小300M(默认使用)+500M(老年代最小值)+200M(空余浮动)。

5. 分析调优结果

主要关注:

  • Full GC 执行时间
  • Minor GC 执行时间
  • Full GC 执行间隔
  • Minor GC 执行间隔
  • Entire Full GC 执行时间
  • Entire Minor GC 执行时间
  • Entire GC 执行时间
  • Full GC 执行时间
  • Minor GC 执行时间

4 参考文献

  1. Garbage Collection
  2. how-to-monitor-java-garbage-collection
  3. JVM调优:选择合适的GC collector

推荐阅读文献

  1. JVM 自动内存管理:对象判定和回收算法(视频)
  2. Java 技术,IBM 风格: 垃圾收集策略,第 1 部分(为什么要有不同的 GC 策略?)
  3. JVM 垃圾回收器工作原理及使用实例介绍(GC算法、垃圾收集器、GC参数)
  4. JVM内存回收理论与实现(对象存活的判定)
1 1