生产服务GC调优实践及基本流程总结

2021年11月22日 阅读数:9
这篇文章主要向大家介绍生产服务GC调优实践及基本流程总结,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

Photo by Pixabay from Pexelshtml

本文做者:夜色微光 - 博客园 (cnblogs.com)
本文连接:https://www.cnblogs.com/novwind/p/15585845.htmljava

前言

对Java虚拟机进行性能调优是一个很是宽泛的话题,在实践上也是很是棘手的过程。由于它须要一种系统的优化方法和清晰的优化指望。默认的JVM参数尝试在大多数状况下提供可接受的性能;可是,根据应用程序的行为和它所处的工做负载,默认值可能不会产生理想的结果。若是Java虚拟机没有按照预期运行就可能须要对应用程序进行基准测试并进行调优,以找到一组合适的 JVM参数。spring

大多数状况下谈论的“JVM调优”都是在说“Java GC调优”,Java官方手册上关于HotSpot虚拟机下的第一个主题就是HotSpot Virtual Machine Garbage Collection Tuning Guideshell

本文记录的是从真实业务开发GC调优中总结的一个“基本步骤和流程“,结合着一些参考资料给出一个案例,以及我的对这块知识点的理解进行讲解。数据库

1. 开始优化前的准备

  • 深刻理解须要优化系统的业务逻辑
  • 深刻理解Java虚拟机相关的概念(书籍、官方文档)
  • 思考一下JVM调优的目的,真的须要调优吗?90%以上的问题都是业务代码致使的,把调优的精力放到业务逻辑的优化上是否是能够达到更好的效果?

有一些因素会影响你获得的最终调优参数:json

  • JDK的版本
  • 服务器硬件
  • 操做系统
  • 系统的负载曲线
  • 系统的业务类型,优化须要围绕的业务类型进行
  • 系统的数据集

以上全部内容构成了调优GC性能的环境。调优参数越具体,解决方案就越不通用,它适用的环境也就越少。这意味着,若是任何变量发生变化(例如,更多用户被授予请求应用程序、应用程序升级、硬件升级的权限),那么所作的任何性能调优均可能须要从新评估。后端

另外必须理解经过显式调优,实际上可能会下降性能。重要的是要持续监控应用程序,并检查基于调优的假设是否仍然有效。若是通过仔细的调试后仍达不到目标,或许应该考虑GC调优以外的更改,例如更换更适合的硬件、操做系统调优和应用程序调优。缓存

Default Arrangement of Generations 。 图不重要,看文本~服务器

2.选择优化目标

JVM优化首先须要选择目标。在下一步为GC调优准备系统时围绕着这些目标设置值。可参考的目标选择方向:oracle

  • 延迟——JVM在执行垃圾收集时引发的Stop The World(STW)。有两个主要的指标,平均GC延迟最大GC延迟。这个目标的动机一般与客户感知到的性能或响应能力有关。
  • 吞吐量——JVM可用于执行应用程序的时间百分比。可用来执行应用程序的时间越多,可用来服务请求的处理时间就越多。须要注意的是,高吞吐量和低延迟并不必定相关——高吞吐量可能伴随着较长但不频繁的暂停时间。
  • 内存成本——内存占用是JVM为执行应用程序所消耗的内存量。若是应用程序环境内存有限,将此项设为目标也能够起到下降成本的做用。

以上内容能够在Java官方手册的HotSpot调优部分找到,而一些经验性的优化原则以下:

  • 优先考虑MinorGC。在大多数应用程序中,大多数垃圾都是由最近短暂的对象分配建立的,因此优先考虑年轻代的GC。年轻代对象生命周期越短,不会由于动态年龄判断和空间分配担保等因素致使存活时间不长的对象被分配到老年代;同时老年代对象生命周期长,JVM的总体垃圾收集就越高效,这将致使更高的吞吐量。
  • 设定合适的堆区大小。给JVM的内存越多,收集频率就越低。此外,这还意味着能够适当地调全年轻代的大小,以更好地应对短时间对象的建立速度,这就减小了向老年代分配的对象数量。
  • 设定简单的目标。为了使事情变得更容易,把JVM调优的目标设定的简单一点,例如只选择其中两个性能目标进行调整,而牺牲另外一个甚至只选择一个。一般状况下,这些目标是相互竞争的,例如,为了提升吞吐量,你给堆的内存越多,平均暂停时间就可能越长;反之,若是你给堆的内存越少,从而减小了平均暂停时间,暂停频率就可能增长,下降吞吐量。一样,对于堆的大小,若是全部分代区域的大小合适则能够提供更好的延迟和吞吐量,这一般是以牺牲JVM的占用率为代价的。
    简而言之,调整GC是一种平衡的行为。一般没法仅仅经过GC的调整来实现全部的目标。

Typical Distribution for Lifetimes of Objects 。 图不重要,看文本~

3.业务逻辑分析

这里并不会把真实的业务系统拿出来进行分析,而是模拟相似的场景,大体的业务信息以下:

  1. 一个微服务系统(本例中是一个SpringBoot Demo,如下简称D服务)主要提供书籍实体信息的缓存和搜索业务
  2. 底层数据库为MySQL+Redis+Elasticsearch
  3. 业务场景为向其余微服务模块或者其余部分提供高速缓存及书籍信息检索
  4. 实际实现需求时采用MySQL+Redis+应用内存式多级缓存

从D服务的性质来讲并不属于用户强交互类型的后端服务,主要面向的部门内的其余微服务和其余部门的后端服务,因此对于请求的响应速度(延迟)并不属于第一目标,因此能够把主要的调优目标设定为增长吞吐量或者下降内存占用。固然因为服务自己并不算臃肿,生产资源也不是很吃紧,因此也不考虑下降内存,则最后的目标为增长吞吐量

从业务逻辑的角度分析主要是对书籍信息的增删改查,请求流量中90%以上是,增删改则大可能是服务内部对于多方数据的维护。

查询的类型分为多种,从对象的角度来讲大多数是短时间对象,例如:

  • 经过AdvanceSearch(AS)模块生成的复杂ES查询语句对象(数据量小,频率中等,存活时间极短)
  • 从多方数据源整合业务逻辑的数据响应对象(数据量中,频率极高,存活时间极短)
  • 多级缓存体系中在内存中暂存的数据对象(数据量小,频率高,存活时间中等,由于是LRU缓存)
  • 按期维护任务处理分发的数据对象(数据量大、频率低、存活时间极短)

可能会进入老年代的业务对象只有内存缓存中的对象,这一块结合业务量+过时时间常时保持在数百MB之内,这里的大小是屡次压测中Dump堆内存+MAT分析得出的结果。

统合以上全部信息能够看出这是一个大部分对象存活时间都不会太长的应用,因此初步的想法是增大年轻代的大小,让大部分对象都在年轻代被回收,减小进入老年代的概率。

下面贴出Demo的代码,为了模拟一个相似的状况可能Demo中的代码采用了一些取巧或者极端的设定,或许这也不能很好的表达本来的逻辑,但一个40行不到就能够运行的Demo总比一个复杂的项目或者开源软件容易理解

/**
 * @author fengxiao
 * @date 2021-11-09
 */
@RestController
@SpringBootApplication
public class Application {
    private final Logger logger = LoggerFactory.getLogger(Application.class);

    //1. 模拟内存缓存, 短时间对象
    private Cache<Integer, Book> shortCache;

    //2. 模拟业务流程里 中等存活时长 对象,,从而使短时间对象更有可能进入老年代
    private Cache<Integer, Book> mediumCache;

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @PostConstruct
    public void init() {
        //3. 这里本地缓存的数值设定在实际业务中并不具备参考性,只是设法让一部分对象在初始的设定下进入老年代
        shortCache = Caffeine.newBuilder().maximumSize(800).initialCapacity(800)
                .expireAfterWrite(3, TimeUnit.SECONDS)
                .removalListener((k, v, c) -> logger.info("Cache Removed,K:{}, V:{}, Cause:{}", k, v, c))
                .build();
        mediumCache = Caffeine.newBuilder().maximumSize(1000).softValues()
                .expireAfterWrite(30, TimeUnit.SECONDS)
                .build();
    }

    @GetMapping("/book")
    public Book book() {
        //4. 模拟业务处理流程中其余模块短时间对象生成的消耗,能够调整这个参数来对老年代无干扰的增长YoungGC的频率
//        byte[] bytes = new byte[16 * 1024];
        return shortCache.get(new Random().nextInt(5000), (id) -> {
                    //5. 模拟正常的LRU缓存场景消耗
                    Book book = new Book().setId(id).setArchives(new byte[256 * 1024]);
                    //6. 模拟业务系统中对老年代的正常消耗
                    mediumCache.put(id, new Book().setArchives(new byte[128 * 1024]));
                    return book;
                }
        );
    }
}

4.准备调优环境

一旦肯定了方向,就须要为GC调优准备环境。这一步的结果将是你所选择目标的价值。总之,目标和价值将成为要调优的环境的系统需求。

4.1 容器镜像选择

其实就是选择JDK的版本,不一样版本的JDK可选择的收集器组合不尽相同,而根据业务类型选择收集器也是相当重要的。

由于在平常工做中使用的JDK版本仍是1.8,结合上面谈到的吞吐量目标天然就选择了PS/PO(也是JDK1.8服务端默认收集器),关于收集器的特性和组合在这里不作展开,有须要自行查阅书籍和文档。

4.2 启动/预热工做负载

在测量特定应用程序JVM的GC性能以前,须要可以让应用程序执行工做并达到稳定状态。这是经过将load应用到应用程序来实现的。

建议将负载建模为但愿调优GC的稳定状态负载,即反映应用程序在生产环境中使用时的使用模式和使用量的负载。这里能够拉上测试部门的同事一块儿完成,经过压测、流量回放等手段让目标程序接近于真实生产中的状态,同时须要注意的是相似的硬件、操做系统版本、加载配置文件等等。负载环境、配额能够经过容器化很方便的实现。

Demo这里以JMeter来模拟压力。简单的一点也能够用脚本或者直接录制真实的流量进行回放。

4.3 开启GC日志/监控

这一点相信大部分生产上的系统都是默认开启的,若是是JDK9如下则:

JAVA_OPT="${JAVA_OPT} -Xloggc:${BASE_DIR}/logs/server_gc.log -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M"

JDK9则整合在了xlog参数中:

-Xlog:gc*:file=${BASE_DIR}/logs/server_gc.log:time,tags:filecount=10,filesize=102400"

而后就须要理解GC日志的各类字段信息含义了,这里建议多观察本身系统中的GC日志,理解日志含义。

至于监控的话能够接入相似Prometheus+Grafana这样的监控系统,监控+报警,谁也不想每天盯着GC日志看。因此此处为了方便展现结果临时搭建了一个Prometheus+Grafana监控,Elasticsearch+Kibana用来分析GC日志。用最小的配置启动Grafana:

version: '3.1'

services:
  prometheus:
    image: prom/prometheus
    volumes:
      - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - 9090:9090
    container_name: prometheus
  grafana:
    image: grafana/grafana
    depends_on:
      - prometheus
    ports:
      - 3000:3000
    container_name: grafana

对Prometheus和Grafana作一些基本的配置后随意导入一个JVM相关的监控面板便可:

4.4 肯定内存足迹

内存足迹是指程序在运行时使用或引用的主内存的数量(Wiki传送门:Memory footprint,如下之内存占用这个通俗的理解做为简称)

为了优化JVM分代的大小,须要很好地了解稳定状态活动数据集的大小,能够经过如下两种方式得到相关信息:

  • 从GC日志观察
  • 从可视化监控中观察(直观)

5. 肯定系统需求

回到性能目标,咱们讨论了VM GC调优的三个性能目标。如今须要肯定这些目标的值,它们表示你正在调优GC性能的环境的系统需求。须要肯定的一些系统要求是:

  • 可接受的平均Minor GC暂停时间
  • 可接受的平均GC暂停时间
  • 最大可容忍的Full GC暂停时间
  • 可用时间百分比表示的可接受的最小吞吐量

一般,若是关注的是Latency(延迟)或Footprint(内存占用)目标,那么可能倾向于较小的堆内存值,并使用较小的吞吐量值设置暂停时间的最大容限;相反,若是您关注的是吞吐量,那么您可能会但愿在没有最大容差的状况下使用更大的暂停时间值和更大的吞吐量值。

这部分最好结合本身当前负责业务系统的现状进行评估和拟定,在一个基准值上进行调优。以D服务的原始吞吐量举例(实际的业务系统中各类复杂的状况都要细致考虑):

系统要求
可接受的Minor GC暂停时间: 0.2秒
可接受的Full GC暂停时间: 2秒
可接受的最低吞吐量: 95%

这一步能够多理解Java官方文档中的 Behavior-Based Tuning ,以及一些现成的JVM调优案例,重要的是理解思想而后结合本身的业务来肯定需求。

6. 设定样本参数开始压测

如下为本例中D服务的基准运行参数

java -Xms4g -Xmx4g
-XX:-OmitStackTraceInFastThrow
-XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=logs/java_heapdump.hprof 
-Xloggc:logs/server_gc.log 
-verbose:gc 
-XX:+PrintGCDetails 
-XX:+PrintGCDateStamps 
-XX:+PrintGCTimeStamps 
-XX:+UseGCLogFileRotation 
-XX:NumberOfGCLogFiles=10 
-XX:GCLogFileSize=100M 
-jar spring-lite-1.0.0.jar

此时使用jmap命令分析堆区状况以下,程序是跑在虚拟机里的,由于调试参数的时候发现有些状况下会频繁GC因此只分配了4个逻辑处理器:

Debugger attached successfully.
Server compiler detected.
JVM version is 25.40-b25

using thread-local object allocation.
Parallel GC with 4 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 4294967296 (4096.0MB)
   NewSize                  = 1431306240 (1365.0MB)
   MaxNewSize               = 1431306240 (1365.0MB)
   OldSize                  = 2863661056 (2731.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 1073741824 (1024.0MB)
   used     = 257701336 (245.76314544677734MB)
   free     = 816040488 (778.2368545532227MB)
   24.00030717253685% used
From Space:
   capacity = 178782208 (170.5MB)
   used     = 0 (0.0MB)
   free     = 178782208 (170.5MB)
   0.0% used
To Space:
   capacity = 178782208 (170.5MB)
   used     = 0 (0.0MB)
   free     = 178782208 (170.5MB)
   0.0% used
PS Old Generation
   capacity = 2863661056 (2731.0MB)
   used     = 12164632 (11.601097106933594MB)
   free     = 2851496424 (2719.3989028930664MB)
   0.42479301014037324% used

14856 interned Strings occupying 1288376 bytes.

Jmeter请求设置以下,由于Demo的流程里没有任何业务逻辑因此即便单线程都有较高的吞吐量,但要注意测试环境配置的区别,例如我在IDE中直接使用Jmeter单线程吞吐量就能够达到1800/s,而到了一个配置较低的虚拟机中须要多个线程才能达到类似的吞吐量级,因此能够加几个线程同时用吞吐量定时器控制请求速率:

每次调整参数反复的进行请求,每次半个小时。这是对于Demo的,若是是真实的业务系统务必多花时间采样

7.分析GC日志

对于本例中的Demo取了四种参数下的GC日志结果,Grafana概览以下:

最终产生了四个GC日志样本,文件名里的内存大小是指年轻代的大小,对于生产服务能够从多个角度采集更多参数样本:

7.1 导入Elasticsearch

接下来咱们将GC日志导入到Elasticsearch中,并来到Kibana查看(注意这里的Elasticsearch和Kibana必需是7.14版本以上的,不然后续的操做会由于版本缘由没法完成):

此时索引中的GC仍是原始格式,就像是在Linux中直接打开同样,因此咱们须要经过Grok Pattern将日志拆分红各个关键字段。实际开发场景能够用Logstash解析并传输,这里只是作个演示,因此经过IngestPipeline + Reindex 快速处理索引数据。

成功后索引以下所示,咱们拥有了能够制做可视化面板的结构化GC数据(Grok Pattern这里就不分享了,主要是我花了半个小时写出来的表达式彷佛还不能完美匹配一些特殊的GC日志行= =。也是建议你们本身编写匹配表达式,这样能够细致的消化GC日志的结构)

7.2 制做可视化面板

如今经过聚合找到每一个日志索引对应的开始时间以及结束时间

GET throughput-gc-log-converted*/_search
{
  "size": 0,
  "aggs": {
    "index": {
      "terms": { "field": "_index", "size": 10 },
      "aggs": {
        "start_time": {
          "min": { "field": "timestamp" }
        },
        "stop_time": {
          "max": { "field": "timestamp" }
        }
      }
    }
  }
}
// 响应以下:
"aggregations" : {
    "index" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {
          "key" : "throughput-gc-log-converted-eden1.3g",
          "doc_count" : 1165,
          "start_time" : { "value" : 1.637476135722E12, "value_as_string" : "2021-11-21T06:28:55.722Z" },
          "stop_time" : { "value" : 1.637478206592E12, "value_as_string" : "2021-11-21T07:03:26.592Z" }
        },
        {
          "key" : "throughput-gc-log-converted-eden1.6g",
          "doc_count" : 898,
          "start_time" : { "value" : 1.637480290228E12, "value_as_string" : "2021-11-21T07:38:10.228Z" },
          "stop_time" : { "value" : 1.637482100695E12, "value_as_string" : "2021-11-21T08:08:20.695Z" }
        },
        {
          "key" : "throughput-gc-log-converted-eden2.0g",
          "doc_count" : 669,
          "start_time" : { "value" : 1.63747831467E12, "value_as_string" : "2021-11-21T07:05:14.670Z" },
          "stop_time" : { "value" : 1.637480148437E12, "value_as_string" : "2021-11-21T07:35:48.437Z" }
        },
        {
          "key" : "throughput-gc-log-converted-eden2.5g",
          "doc_count" : 505,
          "start_time" : { "value" : 1.637482323999E12, "value_as_string" : "2021-11-21T08:12:03.999Z" },
          "stop_time" : { "value" : 1.637484145358E12, "value_as_string" : "2021-11-21T08:42:25.358Z" }
        }
      ]
    }
  }

肯定了时间范围以后进入Dashboard页面,建立可视化面板以下。之因此须要精确的时间范围,就是须要精确的偏移时间以对比不一样状况下GC的表现,如今咱们能够观察不一样设置下的GC次数和耗时,另外和同事分享或者向领导汇报调优结果时有现成的图表总比一堆数据好用吧~

7.3 得到性能指标

使用聚合语句来计算GC各项指标:

GET throughput-gc-log-converted*/_search
{
  "size": 0,
  "aggs": {
    "index": {
      "terms": {
        "field": "_index",
        "size": 10
      },
      "aggs": {
        "gc_type_count": {
          "terms": { "field": "gc_type.keyword" },
          "aggs": {
            "cost": {
              "sum": { "field": "clock_time" }
            }
          }
        },
        "total_cost": {
          "sum": { "field": "clock_time" }
        },
        "throughput_calc": {
          "bucket_script": {
            "buckets_path": { "total_cost": "total_cost" },
            "script": "1 -(params.total_cost/1800)"
          }
        }
      }
    }
  }
}

结果如图所示:

8.肯定结果参数

分析以后的结果其实并不重要,由于最终的决定要看调优者对业务系统以及垃圾收集的理解。在上一小节咱们获得了在不一样参数下D服务的吞吐量表现,最终是否要选那个看起来数值最好的参数还要结合其余状况。

本例中,最好的吞吐量结果 98.2%是在年轻代2.5G的设置下获得的,那为何不尝试着再加大年轻代的大小呢?由于没有意义,真实的业务系统不太可能出现年轻代和老年代比例超过3:1的。即便真的有,这样的虚拟机参数彷佛也有点偏激了,业务需求永远不会变更吗?

最后如何改变参数仍是得结合着一些成熟稳定的调优准则和经验,例如你能够在各类GC优化的博客里看到诸如:

  • 最大堆大小应该在老年代平均值的3 - 4倍之间。
  • 观察内存足迹,老年代不该少于老年代平均值的1.5倍。
  • 年轻代应该大于整个堆的10%。
  • 在调整JVM的大小时,不要超过可用的物理内存量。
  • 。。。。。

话题回到本例中的Demo,若是看到一个生产服务这样的综合结果,我不会显式的设置-Xmn到任何值,而是会设置例如-XX:NewRatio=1这样的相对比值(本文参考最近一次真实的生产服务调优,真实的调优结果就是NewRatio和一些其余值)。

若是正在看文章的你仔细的观察了Demo 的代码并分析其对象消耗就会发现,笔者花费很多时间调制了一些参数让这个Demo在较低年轻代分配下会产生年轻代对象刚进入老年代就进入濒死状态了,从而致使高频的GC;而当年轻代达到2.3G+的阈值时,缓存对象和超时时间达到了一个微妙的平衡,缓存对象根本不可能进入老年代,不会产生Full GC。如如下聚合结果所示(这个SpringBoot程序启动基础产生了2次Full GC):

{
    "key": "throughput-gc-log-converted-eden1.3g",
    "doc_count": 1165,
    "total_cost": { "value": 75.87 },
    "gc_type_count": {
        "doc_count_error_upper_bound": 0,
        "sum_other_doc_count": 0,
        "buckets": [
            {
                "key": "GC",
                "doc_count": 1113,
                "cost": { "value": 69.86 }
            },
            {
                "key": "Full GC",
                "doc_count": 52,
                "cost": { "value": 6.01 }
            }
        ]
    },
    "throughput_calc": { "value": 0.95785 }
},
{
    "key": "throughput-gc-log-converted-eden2.0g",
    "doc_count": 669,
    "total_cost": { "value": 45.75 },
    "gc_type_count": {
        "doc_count_error_upper_bound": 0,
        "sum_other_doc_count": 0,
        "buckets": [
            {
                "key": "GC",
                "doc_count": 650,
                "cost": { "value": 43.65 }
            },
            {
                "key": "Full GC",
                "doc_count": 19,
                "cost": { "value": 2.1 }
            }
        ]
    },
    "throughput_calc": { "value": 0.9745833333333334 }
},
{
    "key": "throughput-gc-log-converted-eden2.5g",
    "doc_count": 505,
    "total_cost": { "value": 30.66 },
    "gc_type_count": {
        "doc_count_error_upper_bound": 0,
        "sum_other_doc_count": 0,
        "buckets": [
            {
                "key": "GC",
                "doc_count": 503,
                "cost": { "value": 30.57 }
            },
            {
                "key": "Full GC",
                "doc_count": 2,
                "cost": { "value": 0.09 }
            }
        ]
    },
    "throughput_calc": { "value": 0.9829666666666667 }
}

而在实际快速迭代的生产服务中不多出现这样的巧合,即便真的出现了也不要自做聪明的取巧设置。由于在错综复杂的业务系统之上,任何一个简单的业务需求改动都能轻松的破坏精心设置的参数。固然若是你开发的系统是按期发布的中间件产品或相似的软件那无所谓。

9. 测量参数变化后的情况

这一步的耗时一般比调优的过程更长。在实际的肯定了调优方向和参数后,制定结果预期、和测试部门的同事进行对接,各类方面的测试所有走起来,从系统顶层收集指标变化,顺利的话花费数周的时间或许参数配置就能够被合并到配置仓库主干分支了。

若是结果没有达到预期,那从新来~

10. 总结

最近杂项事情不少,匆匆忙忙整理的一篇随笔,结构和措辞可能没有通过细致整理,还请谅解 =_=

本文整理的是关于GC调优方面理解的一个基本流程以及思路,我的水平有限若是有描述错误或者更好的思想还请不吝赐教 X_X。

附录:参考资料