系列文章规划:
当应用规模达到一定量级时,GC对项目性能的影响会放大,我们需要通过GC调优实现了项目性能的提升。这不仅考验着我们对GC工作原理的理解,也考验着我们对应用特性的理解,是通往优秀程序员的必由之路。
下面,我们通过浅显易懂的文字介绍一下GC调优。
首先,我们需要认识GC。知道什么是GC,不同的GC算法,GC是如何工作的,young generation和old generation是什么,5种GC类型及其使用场景。
GC(Garbage Collector)的出现和JVM的内存管理机制有关。Java并不在代码层提供内存释放的API,而是由JVM去自主清理内存,将不可达的对象清理掉,这个过程就叫GC。GC的设计是基于弱分代假设的:
为了保证这两个假设的有效性,HotSpot VM将Heap分为两个区:
Young Generation被分为3个区Eden、From和To。执行如下:
Old Generation的GC事件基本上是在空间已满时发生,执行的过程根据GC类型不同而不同。
GC可以有多种不同的实现,这里简单介绍下主要的GC算法和核心思想。
1 引用计数法
每个对象添加一个引用计数器,每被引用一次,计数器加1,失去引用,计数器减1,当计数器在一段时间内保持为0时,该对象就认为是可以被回收得了。但是,这个算法有明显的缺陷:当两个对象相互引用,但是二者已经没有作用时,按照常规,应该对其进行垃圾回收,但是其相互引用,又不符合垃圾回收的条件,因此无法完美处理这块内存清理。因此Sun的JVM并没有采用引用计数算法来进行垃圾回收。因此在java中,单纯使用引用计数法实现垃圾回收是不可行的。
2 根搜索算法
由于引用计数算法的缺陷,所以JVM一般会采用一种新的算法,叫做根搜索算法。它的处理方式就是,设立若干种根对象,当任何一个根对象到某一个对象均不可达时,则认为这个对象是可以被回收的。
JAVA中可以当做GC roots的对象有以下几种:
注:第一和第四种都是指的方法的本地变量表,第二种表达的意思比较清晰,第三种主要指的是声明为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%,不适合复制算法,可选择标记-压缩算法。
JDK 8有5种GC类型:
1 串行收集器(Serial GC)
串行收集器是单线程、独占式的垃圾回收。串行收集器运行时,应用所有线程停止工作,进入等待,这种现象为“Stop the world”。
在新生代,串行收集器使用复制算法,实现简单,处理逻辑高效,无线程切换开销。硬件不是特别优越的场合,性能表现好。
在老年代,串行收集器使用标记-清除-压缩算法,一般老年代垃圾回收比较耗时,造成的停顿时间更长。
不建议使用串行收集器。因为该收集器只使用单核,会降低应用性能。
2 并行收集器(Parallel GC)
并行收集器是多线程、独占式的垃圾回收。关注吞吐量,使用在新生代,实现复制算法。
原理是将串行收集器多线程化。在并发能力强的CPU上效果比串行收集器好。若多线程压力大,则并行收集器可能还没有串行收集器好。
并行收集器使用在对吞吐量有要求的应用上。
3 老年代并行压缩收集器(Parallel Compacting GC)
和并行收集器类似,区别在于该收集器使用在老年代,实现标记-压缩算法。GC时经历标记-整理-压缩的过程,和标记-清除-压缩算法过程略有区别。
4 并发标记清除收集器(Concurrent Mark & Sweep GC ,简称”CMS”)
CMS是并发、非独占式的垃圾回收。关注系统停顿时间。使用标记-清除算法。
主要工作步骤为:
CMS常使用在响应时间敏感的应用上。需要注意的是,CMS比其他GC类型使用更多的内存和CPU。CMS是基于“标记-清除”算法实现的收集器,使用“标记-清除”算法收集后,会产生大量碎片。默认不进行压缩,需要手动设置。
5 G1收集器(Garbage First (G1) GC)
G1基于标记-压缩算法。
不同于其他的分代回收算法、G1将堆空间划分成了互相独立的区块。每块区域既有可能属于O区、也有可能是Y区,且每类区域空间可以是不连续的(对比CMS的O区和Y区都必须是连续的)。
包含以下阶段(其中有些阶段是属于Young GC的):
虽然在清理这些区块时G1仍然需要暂停应用线程、但可以用相对较少的时间优先回收包含垃圾最多区块。这也是为什么G1命名为Garbage First的原因:第一时间处理垃圾最多的区块。
在以下场景下G1更适合:
了解了GC原理的相关知识后,我们有足够的信心去判断GC是否合理。下一步我们需要知道JVM实时进行GC的状态,以供我们进行判断。我们需要知道如何监控GC,监控哪些指标,有哪些工具可以使用。
目前有很多种监控GC的方法,但GC日志是由JVM产生,只有这唯一的一份,因此不同监控方法间唯一的区别在于如何显示GC操作信息。所以掌握一些核心监控方法即可,针对不同的场景选择合适的监控方法。
监控GC的方法主要分两种:
jstat
具体使用参考 jstat –help。使用格式如下:
jstat
gc结果含义参考其他文章。
-verbosegc
-verbosegc需要在启动时设置为java参数。下列参数可以和-verbosegc配合使用:
gc发生时,gc日志格式为:
[GC [: -> , secs] -> , 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 |
了解了GC原理,获取了GC状态信息,下一步就是具体调优了。我们需要明确调优目标,知道有哪些调优配置参数,以及合理的调优过程。
一个实际运行的java项目需要有以下特征:
如果不具有以上特征,这样的java项目需要进行GC调优。
GC调优的目标是:降低移动到老年代的对象数量,缩短Full GC执行时间(stop-the-world持续的时间)。具体调优措施如下(按照重要性由高到低排序):
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秒),在对外服务的连接中就可能会出现超时。
因此,需要为老年代空间设置适当的大小。
内存分配
分类 | 选项 | 说明 |
---|---|---|
堆空间 | -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收集器 |
具体过程为:
1.监控GC状态
$ jstat -gcutil 21719 1s
S0 S1 E O P YGC YGCT FGC FGCT GCT
48.66 0.00 48.10 49.70 77.45 3428 172.623 3 59.050 231.673
48.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. 分析调优结果
主要关注:
推荐阅读文献