引言
如何结合使用 JVM Heap 堆和 Kubernetes 内存的 requests 和 limits 并远离麻烦。
在容器环境中运行 Java 应用程序需要了解两者 —— JVM 内存机制和 Kubernetes 内存管理。这两个环境一起工作会产生一个稳定的应用程序,但是,错误配置最多可能导致基础设施超支,最坏情况下可能会导致应用程序不稳定或崩溃。我们将首先仔细研究 JVM 内存的工作原理,然后我们将转向 Kubernetes,最后,我们将把这两个概念放在一起。
JVM 内存模型简介
JVM 内存管理是一种高度复杂的机制,多年来通过连续发布不断改进,是 JVM 平台的优势之一。对于本文,我们将只介绍对本主题有用的基础知识。在较高的层次上,JVM 内存由两个空间组成 —— Heap 和 Metaspace。
JVM 内存模型
非 Heap 内存
JVM 使用许多内存区域。最值得注意的是 Metaspace。Metaspace 有几个功能。它主要用作方法区,其中存储应用程序的类结构和方法定义,包括标准库。内存池和常量池用于不可变对象,例如字符串,以及类常量。堆栈区域是用于线程执行的后进先出结构,存储原语和对传递给函数的对象的引用。根据 JVM 实现和版本,此空间用途的一些细节可能会有所不同。
我喜欢将 Metaspace 空间视为一个管理区域。这个空间的大小可以从几 MB 到几百 MB 不等,具体取决于代码库及其依赖项的大小,并且在应用程序的整个生命周期中几乎保持不变。默认情况下,此空间未绑定并会根据应用程序需要进行扩展。
Metaspace 是在 Java 8 中引入的,取代了 Permanent Generation,后者存在垃圾回收问题。
其他一些值得一提的非堆内存区域是代码缓存、线程、垃圾回收。更多关于非堆内存参考这里。
Heap 堆内存
如果 Metaspace 是管理空间,那么 Heap 就是操作空间。这里存放着所有的实例对象,并且垃圾回收机制在这里最为活跃。该内存的大小因应用程序而异,取决于工作负载的大小 —— 应用程序需要满足单个请求和流量特征所需的内存。大型应用程序通常具有以GB为单位的堆大小。
我们将使用一个示例应用程序用于探索内存机制。源代码在此处。
这个演示应用程序模拟了一个真实世界的场景,在该场景中,为传入请求提供服务的系统会在堆上累积对象,并在请求完成后成为垃圾回收的候选对象。该程序的核心是一个无限循环,通过将大型对象添加到列表并定期清除列表来创建堆上的大型对象。
val list = mutableListOf<ByteArray>() generateSequence(0) { it + 1 }.forEach { if (it % (HEAP_TO_FILL / INCREMENTS_IN_MB) == 0) list.clear() list.add(ByteArray(INCREMENTS_IN_MB * BYTES_TO_MB)) }
以下是应用程序的输出。在预设间隔(本例中为350MB堆大小)内,状态会被清除。重要的是要理解,清除状态并不会清空堆 - 这是垃圾收集器内部实现的决定何时将对象从内存中驱逐出去。让我们使用几个堆设置来运行此应用程序,以查看它们对JVM行为的影响。
首先,我们将使用 4 GB 的最大堆大小(由 -Xmx 标志控制)。以下是应用程序的输出。在预设间隔(本例中为350MB堆大小)内,状态会被清除。重要的是要理解,清除状态并不会清空堆 - 这是垃圾收集器内部实现的决定何时将对象从内存中驱逐出去。让我们使用几个堆设置来运行此应用程序,以查看它们对JVM行为的影响。
首先,我们将使用 4 GB 的最大堆大小(由 -Xmx 标志控制)。
~ java -jar -Xmx4G app/build/libs/app.jar INFO Used Free Total INFO 14.00 MB 36.00 MB 50.00 MB INFO 66.00 MB 16.00 MB 82.00 MB INFO 118.00 MB 436.00 MB 554.00 MB INFO 171.00 MB 383.00 MB 554.00 MB INFO 223.00 MB 331.00 MB 554.00 MB INFO 274.00 MB 280.00 MB 554.00 MB INFO 326.00 MB 228.00 MB 554.00 MB INFO State cleared at ~ 350 MB. INFO Used Free Total INFO 378.00 MB 176.00 MB 554.00 MB INFO 430.00 MB 208.00 MB 638.00 MB INFO 482.00 MB 156.00 MB 638.00 MB INFO 534.00 MB 104.00 MB 638.00 MB INFO 586.00 MB 52.00 MB 638.00 MB INFO 638.00 MB 16.00 MB 654.00 MB INFO 690.00 MB 16.00 MB 706.00 MB INFO State cleared at ~ 350 MB. INFO Used Free Total INFO 742.00 MB 16.00 MB 758.00 MB INFO 794.00 MB 16.00 MB 810.00 MB INFO 846.00 MB 16.00 MB 862.00 MB INFO 899.00 MB 15.00 MB 914.00 MB INFO 951.00 MB 15.00 MB 966.00 MB INFO 1003.00 MB 15.00 MB 1018.00 MB INFO 1055.00 MB 15.00 MB 1070.00 MB ... ...
有趣的是,尽管状态已被清除并准备好进行垃圾回收,但可以看到使用的内存(第一列)仍在增长。为什么会这样呢?由于堆有足够的空间可以扩展,JVM 延迟了通常需要大量 CPU 资源的垃圾回收,并优化为服务主线程。让我们看看不同堆大小如何影响此行为。
~ java -jar -Xmx380M app/build/libs/app.jar INFO Used Free Total INFO 19.00 MB 357.00 MB 376.00 MB INFO 70.00 MB 306.00 MB 376.00 MB INFO 121.00 MB 255.00 MB 376.00 MB INFO 172.00 MB 204.00 MB 376.00 MB INFO 208.00 MB 168.00 MB 376.00 MB INFO 259.00 MB 117.00 MB 376.00 MB INFO 310.00 MB 66.00 MB 376.00 MB INFO State cleared at ~ 350 MB. INFO Used Free Total INFO 55.00 MB 321.00 MB 376.00 MB INFO 106.00 MB 270.00 MB 376.00 MB INFO 157.00 MB 219.00 MB 376.00 MB INFO 208.00 MB 168.00 MB 376.00 MB INFO 259.00 MB 117.00 MB 376.00 MB INFO 310.00 MB 66.00 MB 376.00 MB INFO 361.00 MB 15.00 MB 376.00 MB INFO State cleared at ~ 350 MB. INFO Used Free Total INFO 55.00 MB 321.00 MB 376.00 MB INFO 106.00 MB 270.00 MB 376.00 MB INFO 157.00 MB 219.00 MB 376.00 MB INFO 208.00 MB 168.00 MB 376.00 MB INFO 259.00 MB 117.00 MB 376.00 MB INFO 310.00 MB 66.00 MB 376.00 MB INFO 361.00 MB 15.00 MB 376.00 MB INFO State cleared at ~ 350 MB. INFO Used Free Total INFO 55.00 MB 321.00 MB 376.00 MB INFO 106.00 MB 270.00 MB 376.00 MB INFO 157.00 MB 219.00 MB 376.00 MB INFO 208.00 MB 168.00 MB 376.00 MB ... ...
在这种情况下,我们分配了刚好足够的堆大小(380 MB)来处理请求。我们可以看到,在这些限制条件下,GC立即启动以避免可怕的内存不足错误。这是 JVM 的承诺 - 它将始终在由于内存不足而失败之前尝试进行垃圾回收。为了完整起见,让我们看一下它的实际效果:
~ java -jar -Xmx150M app/build/libs/app.jar INFO Used Free Total INFO 19.00 MB 133.00 MB 152.00 MB INFO 70.00 MB 82.00 MB 152.00 MB INFO 106.00 MB 46.00 MB 152.00 MB Exception in thread "main" ... ... Caused by: java.lang.OutOfMemoryError: Java heap space at com.dansiwiec.HeapDestroyerKt.blowHeap(HeapDestroyer.kt:28) at com.dansiwiec.HeapDestroyerKt.main(HeapDestroyer.kt:18) ... 8 more
对于 150 MB 的最大堆大小,进程无法处理 350MB 的工作负载,并且在堆被填满时失败,但在垃圾收集器尝试挽救这种情况之前不会失败。
Java Out Of Memory
我们也来看看 Metaspace 的大小。为此,我们将使用 jstat
(为简洁起见省略了输出)
~ jstat -gc 35118 MU 4731.0
输出表明 Metaspace 利用率约为 5 MB。记住 Metaspace 负责存储类定义,作为实验,让我们将流行的 Spring Boot 框架添加到我们的应用程序中。
~ jstat -gc 34643 MU 28198.6
Metaspace 跃升至近 30 MB,因为类加载器占用的空间要大得多。对于较大的应用程序,此空间占用超过 100 MB 的情况并不罕见。接下来让我们进入 Kubernetes 领域。
Kubernetes 内存管理
Kubernetes 内存控制在操作系统级别运行,与管理分配给它的内存的 JVM 形成对比。K8s 内存管理机制的目标是确保工作负载被调度到资源充足的节点上,并将它们保持在一定的限制范围内。
Kubernetes Cluster 示例
在定义工作负载时,用户有两个参数可以操作 — requests
和 limits
。这些是在容器级别定义的,但是,为了简单起见,我们将根据 pod 参数来考虑它,这些参数只是容器设置的总和。
当请求 pod 时,kube-scheduler(控制平面的一个组件)查看资源请求并选择一个具有足够资源的节点来容纳 pod。一旦调度,允许 pod 超过其内存requests
(只要节点有空闲内存)但禁止超过其limits
。
Kubelet(节点上的容器运行时)监视 pod 的内存利用率,如果超过内存限制,它将重新启动 pod 或在节点资源不足时将其完全从节点中逐出(有关更多详细信息,请参阅有关此主题的官方文档。这会导致臭名昭著的 OOMKilled(内存不足)的 pod 状态。
当 pod 保持在其限制范围内,但超出了节点的可用内存时,会出现一个有趣的场景。这是可能的,因为调度程序会查看 pod 的请求(而不是限制)以将其调度到节点上。在这种情况下,kubelet 会执行一个称为节点压力驱逐的过程。简而言之,这意味着 pod 正在终止,以便回收节点上的资源。根据节点上的资源状况有多糟糕,驱逐可能是软的(允许 pod 优雅地终止)或硬的。此场景如下图所示。
Pod 驱逐场景
关于驱逐的内部运作,肯定还有很多东西需要了解。有关此复杂过程的更多信息,请点击此处。对于这个故事,我们就此打住,现在看看这两种机制 —— JVM 内存管理和 Kubernetes 是如何协同工作的。