在K8s中调整JVM提高CPU和内存利用率 - Anurag


JVM 是有史以来最古老但功能最强大的虚拟机之一。
每当一个新的 JVM 进程启动时,所有需要的类都会被ClassLoader的一个实例加载到内存中。这个过程分三个步骤进行:

  1. Bootstrap 类加载: “ Bootstrap 类加载器”将 Java 代码和基本的 Java 类(如java.lang.Object )加载到内存中。这些加载的类位于JRElibrt.jar中。
  2. 扩展类加载:ExtClassLoader 负责加载位于java.ext.dirs路径的所有 JAR 文件。在非 Maven 或非基于 Gradle 的应用程序中,开发人员手动添加 JAR,所有这些类都在此阶段加载。
  3. 应用程序类加载:AppClassLoader 加载位于应用程序类路径中的所有类。

此初始化过程基于延迟加载方案。
一旦类加载完成,所有重要的类(在进程启动时使用)都会被推送到JVM 缓存(本机代码)中——这使得它们在运行时可以更快地访问。其他类是根据每个请求加载的。
对 Java Web 应用程序发出的第一个请求通常比进程生命周期内的平均响应时间慢得多。这个初始启动阶段通常可以归因于延迟类加载和即时编译。
请记住这一点,对于低延迟应用程序,我们需要事先缓存所有类——以便在运行时访问它们时立即可用。

这个调整 JVM 的过程称为预热:
JVM 预热期间的高延迟是一个突出的问题。基于 JVM 的应用程序可提供出色的性能,但在达到最高速度之前需要一些时间来“预热”。当应用程序启动时,它通常以降低的性能开始。它可以归因于诸如实时 (JIT) 编译之类的东西,它通过收集使用配置文件信息来优化常用代码。这样做的净负面影响是,与平均水平相比,在此预热期间收到的请求将具有非常长的响应时间。在容器化、高吞吐量、频繁部署和自动缩放的环境中,这个问题可能会加剧

在本文中,我将讨论 JVM 预热问题、我们 Kubernetes 集群中的高堆内存利用率以及我们如何处理这些问题以及我们从中学到的东西。

当遭遇高延迟问题时,我们不清楚这是一个与 JVM 预热相关的问题时,解决这个问题的最简单方法是将 pod 增加到稳定状态下所需数量的 3 倍左右。这无疑解决了我们的问题,但导致了高昂的基础设施成本。在深入研究这个问题时,我们发现还有其他方法可以解决同样的问题。

JVM需要更多的CPU,在我们的案例中,在持续几分钟的初始预热阶段,它比配置的限制x要多3倍。在预热之后,JVM可以在其全部潜力下舒适地运行,即使CPU为x。 如果所需的CPU不可用,pod CPU会被节流到可用的资源,这就导致了整个问题。

有一个简单的方法来验证这一点。Kubernetes提供了一个每个pod的指标container_cpu_cfs_throttled_seconds_total,它表示--从这个pod开始,CPU被节制限制了多少秒。

由于这种节流限制,预热需要时间,在此之前,延迟增加,导致请求排队,因此导致线程在等待状态下的高状态。

Kubernetes是使用 "请求 "而不是 "限制 "来安排pod
这是我们在调试问题时遇到的一个非常重要的信息。
Kubernetes根据配置的资源请求和限制,为pod分配QoS类。

在研究了关于QoS类Kubernetes Burstable QoS后,我们问题的答案似乎很清楚了:

由于Kubernetes使用请求中指定的值来调度pod,它将找到有x个空闲CPU容量的节点来调度这个pod。但由于限制是更高的3倍,如果应用程序在任何时候都需要比x更多的CPU,并且如果该节点上有空闲的CPU容量,应用程序将不会在CPU上被扼杀。如果有的话,它最多可以使用3倍。

这与我们的问题陈述非常吻合。在热身阶段,当JVM需要更多的CPU时,它可以通过突发获得。一旦JVM被优化,它就可以在请求范围内全速前进。这使得我们可以使用集群中的备用容量(如果我们没有备用容量,我们仍然会面临这个问题,但是8/10的时候,我们往往有那个额外的容量,这是需要限制的)来解决预热问题,而不需要任何额外的成本。

在减少CPU资源后,是时候研究我们系统的高内存使用率了。
当我们在优化我们的成本时,我们发现为我们的服务分配了非常高的内存资源,因为堆的大小会不断增加,如果资源不高,Pod就会在内存需求没有得到满足时陷入CrashLoop。

无效的垃圾回收被认为是我们服务中堆大小高的主要原因,因为堆内存曾经逐渐增加,直到主要的GC,导致高内存使用,最终需要提供高资源以保持服务稳定。

垃圾收集
垃圾收集(又称GC)是Java最重要的功能之一。垃圾收集是Java中用来取消分配未使用的内存的机制,也就是清除未使用的对象所消耗的空间。为了清除未使用的内存,垃圾收集器跟踪所有仍在使用的对象,并将其余对象标记为垃圾。基本上,垃圾收集器使用标记和扫除算法来清除未使用的内存。这些是以下类型。

1.串行垃圾收集器
串行垃圾收集器通过保持所有的应用程序线程来工作。它是为单线程环境设计的。它只使用一个单线程进行垃圾收集。它的工作方式是在进行垃圾收集时冻结所有的应用线程,这可能不适合服务器环境。它最适合于简单的命令行程序。

打开-XX:+UseSerialGC JVM参数以使用串行垃圾收集器。

2.并行垃圾收集器
并行垃圾收集器也被称为吞吐量收集器。它是JVM的默认垃圾收集器。与串行垃圾收集器不同,它使用多个线程进行垃圾收集。与串行垃圾收集器类似,它在执行垃圾收集时也冻结了所有的应用程序线程。

3.CMS垃圾收集器
并发标记扫除(CMS)垃圾收集器使用多个线程来扫描堆内存,以标记要驱逐的实例,然后扫除标记的实例。CMS垃圾收集器只在以下两种情况下保留所有的应用线程。

  • 同时在保有的生成空间中对引用的对象进行标记。
  • 如果在做垃圾收集时,并行的堆内存有变化。

与并行垃圾收集器相比,CMS收集器使用更多的CPU来保证更好的应用吞吐量。如果我们可以分配更多的CPU以获得更好的性能,那么CMS垃圾收集器是比并行收集器更优先的选择。

打开XX:+USeParNewGC JVM参数以使用CMS垃圾收集器。

4.G1垃圾收集器
G1垃圾收集器用于大型堆内存区域。它将堆内存分成若干区域,并在这些区域内进行并行收集。G1在回收内存后,也会对空闲的堆空间进行压缩,而CMS垃圾收集器在停止世界(STW)的情况下对内存进行压缩。G1垃圾收集器会根据最多垃圾的情况来确定区域的优先级。

打开-XX:+UseG1GC JVM参数以使用G1垃圾收集器。

我们从Java 8默认的GC(Parallel GC)切换到G1 GC,它是为了有效地处理高的Heap大小。这导致了整体的堆大小不变,因此我们可以减少内存资源,并在服务中保持同样的稳定性,其结果是惊人的。

主要经验

  • 如果节点没有我们指定的所需限制,我们仍然可能面临CPU节流,因为pods是根据请求而不是限制来安排的。
  • 我们不应该过分依赖稳定状态的限制,而应该保持我们的请求足够高,以满足应用程序的稳定状态要求。
  • 在使用G1 GC的同时,我们也应该尝试使用重复数据删除,因为大多数网络应用都大量使用字符串,所以我认为其优势会非常明显。字符串重复数据删除是一项Java功能,可以帮助你节省Java应用程序中重复的字符串对象所占用的内存。要使用它,我们需要在JAVA_OPTS中加入以下内容: - XX:+UseStringDeduplication