gitbook/Java性能调优实战/docs/107396.md

151 lines
12 KiB
Markdown
Raw Permalink Normal View History

2022-09-03 22:05:03 +08:00
# 23 | 如何优化垃圾回收机制?
你好,我是刘超。
我们知道在Java开发中开发人员是无需过度关注对象的回收与释放的JVM的垃圾回收机制可以减轻不少工作量。但完全交由JVM回收对象也会增加回收性能的不确定性。在一些特殊的业务场景下不合适的垃圾回收算法以及策略都有可能导致系统性能下降。
面对不同的业务场景垃圾回收的调优策略也不一样。例如在对内存要求苛刻的情况下需要提高对象的回收效率在CPU使用率高的情况下需要降低高并发时垃圾回收的频率。可以说垃圾回收的调优是一项必备技能。
这讲我们就把这项技能的学习进行拆分看看回收后面简称GC的算法有哪些体现GC算法好坏的指标有哪些又如何根据自己的业务场景对GC策略进行调优
## 垃圾回收机制
掌握GC算法之前我们需要先弄清楚3个问题。第一回收发生在哪里第二对象在什么时候可以被回收第三如何回收这些对象
### 1\. 回收发生在哪里?
JVM的内存区域中程序计数器、虚拟机栈和本地方法栈这3个区域是线程私有的随着线程的创建而创建销毁而销毁栈中的栈帧随着方法的进入和退出进行入栈和出栈操作每个栈帧中分配多少内存基本是在类结构确定下来的时候就已知的因此这三个区域的内存分配和回收都具有确定性。
那么垃圾回收的重点就是关注堆和方法区中的内存了,堆中的回收主要是对象的回收,方法区的回收主要是废弃常量和无用的类的回收。
### 2\. 对象在什么时候可以被回收?
那JVM又是怎样判断一个对象是可以被回收的呢一般一个对象不再被引用就代表该对象可以被回收。目前有以下两种算法可以判断该对象是否可以被回收。
**引用计数算法:**这种算法是通过一个对象的引用计数器来判断该对象是否被引用了。每当对象被引用引用计数器就会加1每当引用失效计数器就会减1。当对象的引用计数器的值为0时就说明该对象不再被引用可以被回收了。这里强调一点虽然引用计数算法的实现简单判断效率也很高但它存在着对象之间相互循环引用的问题。
**可达性分析算法:**GC Roots 是该算法的基础GC Roots是所有对象的根对象在JVM加载时会创建一些普通对象引用正常对象。这些对象作为正常对象的起始点在垃圾回收时会从这些GC Roots开始向下搜索当一个对象到 GC Roots 没有任何引用链相连时就证明此对象是不可用的。目前HotSpot虚拟机采用的就是这种算法。
以上两种算法都是通过引用来判断对象是否可以被回收。在 JDK 1.2 之后Java 对引用的概念进行了扩充,将引用分为了以下四种:
![](https://static001.geekbang.org/resource/image/5c/0a/5c671c5ae73cbb8bc14b38d9e871530a.jpg)
### 3\. 如何回收这些对象?
了解完Java程序中对象的回收条件那么垃圾回收线程又是如何回收这些对象的呢JVM垃圾回收遵循以下两个特性。
**自动性:**Java提供了一个系统级的线程来跟踪每一块分配出去的内存空间当JVM处于空闲循环时垃圾收集器线程会自动检查每一块分配出去的内存空间然后自动回收每一块空闲的内存块。
**不可预期性:**一旦一个对象没有被引用了,该对象是否立刻被回收呢?答案是不可预期的。我们很难确定一个没有被引用的对象是不是会被立刻回收掉,因为有可能当程序结束后,这个对象仍在内存中。
垃圾回收线程在JVM中是自动执行的Java程序无法强制执行。我们唯一能做的就是通过调用System.gc方法来"建议"执行垃圾收集器,但是否可执行,什么时候执行?仍然不可预期。
## GC算法
JVM提供了不同的回收算法来实现这一套回收机制通常垃圾收集器的回收算法可以分为以下几种
![](https://static001.geekbang.org/resource/image/3f/b9/3f4316c41d4ffb27e5a36db5f2641db9.jpg)
如果说收集算法是内存回收的方法论那么垃圾收集器就是内存回收的具体实现JDK1.7 update14 之后Hotspot虚拟机所有的回收器整理如下以下为服务端垃圾收集器
![](https://static001.geekbang.org/resource/image/28/74/2824581e7c94a3a94b2b0abb1d348974.jpg)
其实在JVM规范中并没有明确GC的运作方式各个厂商可以采用不同的方式实现垃圾收集器。我们可以通过JVM工具查询当前JVM使用的垃圾收集器类型首先通过ps命令查询出进程ID再通过jmap -heap ID查询出JVM的配置信息其中就包括垃圾收集器的设置类型。
![](https://static001.geekbang.org/resource/image/95/97/953dc139ff9035b41d06d4a400395e97.png)
## GC性能衡量指标
一个垃圾收集器在不同场景下表现出的性能也不一样,那么如何评价一个垃圾收集器的性能好坏呢?我们可以借助一些指标。
**吞吐量:**这里的吞吐量是指应用程序所花费的时间和系统总运行时间的比值。我们可以按照这个公式来计算GC的吞吐量系统总运行时间=应用程序耗时+GC耗时。如果系统运行了100分钟GC耗时1分钟则系统吞吐量为99%。GC的吞吐量一般不能低于95%。
**停顿时间:**指垃圾收集器正在运行时,应用程序的暂停时间。对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。
**垃圾回收频率:**多久发生一次指垃圾回收呢?通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间。所以我们只要适当地增大堆内存空间,保证正常的垃圾回收频率即可。
## 查看&分析GC日志
已知了性能衡量指标现在我们需要通过工具查询GC相关日志统计各项指标的信息。首先我们需要通过JVM参数预先设置GC日志通常有以下几种JVM参数设置
```
-XX:+PrintGC 输出GC日志
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 输出GC的时间戳以基准时间的形式
-XX:+PrintGCDateStamps 输出GC的时间戳以日期的形式如 2013-05-04T21:53:59.234+0800
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:../logs/gc.log 日志文件的输出路径
```
这里使用如下参数来打印日志:
```
-XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:./gclogs
```
打印后的日志为:
![](https://static001.geekbang.org/resource/image/58/58/58d74b6e3e68edf9b595287686b42b58.png)
上图是运行很短时间的GC日志如果是长时间的GC日志我们很难通过文本形式去查看整体的GC性能。此时我们可以通过[GCViewer](https://sourceforge.net/projects/gcviewer/)工具打开日志文件图形化界面查看整体的GC性能如下图所示
![](https://static001.geekbang.org/resource/image/69/79/69db951663299d342aad572d911b0279.jpeg)![](https://static001.geekbang.org/resource/image/f9/37/f95d5db87d20068085b6d67cd6822d37.png)
通过工具我们可以看到吞吐量、停顿时间以及GC的频率从而可以非常直观地了解到GC的性能情况。
这里我再推荐一个比较好用的GC日志分析工具[GCeasy](https://www.gceasy.io/index.jsp)是一款非常直观的GC日志分析工具我们可以将日志文件压缩之后上传到GCeasy官网即可看到非常清楚的GC日志分析结果
![](https://static001.geekbang.org/resource/image/ab/22/ab3119a73f313d20a4aa0cee02e84022.jpeg)![](https://static001.geekbang.org/resource/image/ef/27/ef85b02537b9c970d55d3bbd5a3e3427.jpeg)![](https://static001.geekbang.org/resource/image/71/95/71e8f8922bc7045a7b52e5a6dff82595.jpeg)![](https://static001.geekbang.org/resource/image/83/be/834d779d27afbee8c70219c1628f0bbe.jpeg)![](https://static001.geekbang.org/resource/image/83/ba/830069547013fdcbbb74c1e9b75a77ba.jpeg)![](https://static001.geekbang.org/resource/image/63/8e/638ede71247b855b50e04a25564f268e.jpeg)
## GC调优策略
找出问题后就可以进行调优了下面介绍几种常用的GC调优策略。
### 1\. 降低Minor GC频率
通常情况下由于新生代空间较小Eden区很快被填满就会导致频繁Minor GC因此我们可以通过增大新生代空间来降低Minor GC的频率。
可能你会有这样的疑问扩容Eden区虽然可以减少Minor GC的次数但不会增加单次Minor GC的时间吗如果单次Minor GC的时间增加那也很难达到我们期待的优化效果呀。
我们知道单次Minor GC时间是由两部分组成T1扫描新生代和T2复制存活对象。假设一个对象在Eden区的存活时间为500msMinor GC的时间间隔是300ms那么正常情况下Minor GC的时间为 T1+T2。
当我们增大新生代空间Minor GC的时间间隔可能会扩大到600ms此时一个存活500ms的对象就会在Eden区中被回收掉此时就不存在复制存活对象了所以再发生Minor GC的时间为两次扫描新生代即2T1。
可见扩容后Minor GC时增加了T1但省去了T2的时间。通常在虚拟机中复制对象的成本要远高于扫描成本。
如果在堆内存中存在较多的长期存活的对象此时增加年轻代空间反而会增加Minor GC的时间。如果堆中的短期对象很多那么扩容新生代单次Minor GC时间不会显著增加。因此单次Minor GC时间更多取决于GC后存活对象的数量而非Eden区的大小。
### 2\. 降低Full GC的频率
通常情况下由于堆内存空间不足或老年代对象太多会触发Full GC频繁的Full GC会带来上下文切换增加系统的性能开销。我们可以使用哪些方法来降低Full GC的频率呢
**减少创建大对象:**在平常的业务场景中我们习惯一次性从数据库中查询出一个大对象用于web端显示。例如我之前碰到过一个一次性查询出60个字段的业务操作这种大对象如果超过年轻代最大对象阈值会被直接创建在老年代即使被创建在了年轻代由于年轻代的内存空间有限通过Minor GC之后也会进入到老年代。这种大对象很容易产生较多的Full GC。
我们可以将这种大对象拆解出来,首次只查询一些比较重要的字段,如果还需要其它字段辅助查看,再通过第二次查询显示剩余的字段。
**增大堆内存空间:**在堆内存不足的情况下增大堆内存空间且设置初始化堆内存为最大堆内存也可以降低Full GC的频率。
### 选择合适的GC回收器
假设我们有这样一个需求要求每次操作的响应时间必须在500ms以内。这个时候我们一般会选择响应速度较快的GC回收器CMSConcurrent Mark Sweep回收器和G1回收器都是不错的选择。
而当我们的需求对系统吞吐量有要求时就可以选择Parallel Scavenge回收器来提高系统的吞吐量。
## 总结
今天的内容比较多,最后再强调几个重点。
垃圾收集器的种类很多我们可以将其分成两种类型一种是响应速度快一种是吞吐量高。通常情况下CMS和G1回收器的响应速度快Parallel Scavenge回收器的吞吐量高。
在JDK1.8环境下默认使用的是Parallel Scavenge年轻代+Serial Old老年代垃圾收集器你可以通过文中介绍的查询JVM的GC默认配置方法进行查看。
通常情况JVM是默认垃圾回收优化的在没有性能衡量标准的前提下尽量避免修改GC的一些性能配置参数。如果一定要改那就必须基于大量的测试结果或线上的具体性能来进行调整。
## 思考题
以上我们讲到了CMS和G1回收器你知道G1是如何实现更好的GC性能的吗
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。