生产服务GC调优实践基本流程总结
阅读原文时间:2021年11月22日阅读:4

Photo by Pixabay from Pexels

本文作者:夜色微光 - 博客园 (cnblogs.com)

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

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

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

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

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

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

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

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

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

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

  • 延迟——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 。 图不重要,看文本~

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

  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;
                }
        );
    }
}

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

4.1 容器镜像选择

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

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

4.2 启动/预热工作负载

在测量特定应用程序JVM的GC性能之前,需要能够让应用程序执行工作并达到稳定状态。这是通过将load应用到应用程序来实现的。

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

Demo这里以MeterSphere来模拟压力。简单的一点也可以用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日志观察
  • 从可视化监控中观察(直观)

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

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

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

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

系统要求

可接受的Minor GC暂停时间:

0.2秒

可接受的Full GC暂停时间:

2秒

可接受的最低吞吐量:

95%

这一步可以多理解Java官方文档中的 Behavior-Based Tuning ,以及一些现成的JVM调优案例,重要的是理解思想然后结合自己的业务来确定需求。

以下为本例中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的,如果时真实的业务系统务必多花时间采样

对于本例中的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次数和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)"
          }
        }
      }
    }
  }
}

结果如图所示:

分析之后的结果其实并不重要,因为最终的决定要看调优者对业务系统以及垃圾收集的理解。在上一小节我们得到了在不同参数下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 }
}

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

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

如果结果没有达到预期,那重新来~

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

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

附录:参考资料