作者:风敬(谢文欣)

Java 凭借着自身活跃的开源社区和完善的生态优势,在曩昔的二十几年一直是最受欢迎的编程言语之一。步入云原生年代,蓬勃发展的云原生技能开释云计算红利,推进事务进行云原生化改造,加快企业数字化转型。

可是 Java 的云原生转型之路面临着巨大的应战,Java 的运转机制和云原生特性存在着诸多矛盾。企业借助云原生技能进行深层次本钱优化,资源本钱办理被上升到史无前例的高度。公有云上资源按量收费,用户对资源用量十分敏感。在内存运用方面,根据 Java 虚拟机的履行机制使得任何 Java 程序都会有固定的基础内存开支,相比 C++/Golang 等原生言语,Java 运用占用的内存巨大,被称为“内存吞噬者”,因而 Java 运用上云更加贵重。而且运用集成到云上之后体系复杂度添加,一般用户对云上 Java 运用内存没有清晰的认识,不知道怎么为运用合理装备内存,呈现 OOM 问题时也很难排障,遇到了许多问题。

为什么堆内存未超越 Xmx 却产生了 OOM?怎样了解操作体系和JVM的内存联系?为什么程序占用的内存比 Xmx 大不少,内存都用在哪儿了?为什么线上容器内的程序内存需求更大?本文将 EDAS 用户在 Java 运用云原生化演进实践中遇到的这些问题进行了抽丝剥茧的剖析,并给出云原生 Java 运用内存的装备主张。

布景常识

K8s运用的资源装备

云原生架构以 K8s 为柱石,运用在 K8s 上布置,以容器组的形态运转。K8s 的资源模型有两个定义,资源恳求(request)和资源约束(limit),K8s 保证容器具有 request数量的资源,但不允许运用超越limit数量的资源。以如下的内存装备为例,容器至少能获得 1024Mi 的内存资源,但不允许超越 4096Mi,一旦内存运用超限,该容器将产生OOM,而后被 K8s 控制器重启。

spec:
  containers:
  - name: edas
    image: alibaba/edas
    resources:
      requests:
        memory: "1024Mi"
      limits:
        memory: "4096Mi"
    command: ["java", "-jar", "edas.jar"]

容器 OOM

关于容器的 OOM 机制,首要需求来复习一下容器的概念。当咱们谈到容器的时候,会说这是一种沙盒技能,容器作为一个沙盒,内部是相对独立的,而且是有鸿沟有巨细的。容器内独立的运转环境经过 Linux的Namespace 机制完成,对容器内 PID、Mount、UTS、IPD、Network 等 Namespace 进行了障眼法处理,使得容器内看不到宿主机 Namespace 也看不到其他容器的 Namespace;而所谓容器的鸿沟和巨细,是指要对容器运用 CPU、内存、IO 等资源进行束缚,否则单个容器占用资源过多或许导致其他容器运转缓慢或许反常。Cgroup 是 Linux 内核提供的一种能够约束单个进程或许多个进程所运用资源的机制,也是完成容器资源束缚的核心技能。容器在操作体系看来只不过是一种特殊进程,该进程对资源的运用受 Cgroup 的束缚。当进程运用的内存量超越 Cgroup 的约束量,就会被体系 OOM Killer 无情地杀死。

所以,所谓的容器 OOM,实质是运转在Linux体系上的容器进程产生了 OOM。Cgroup 并不是一种晦涩难懂的技能,Linux 将其完成为了文件体系,这很符合 Unix 全部皆文件的哲学。关于 Cgroup V1 版别,咱们能够直接在容器内的 /sys/fs/cgroup/ 目录下检查当时容器的 Cgroup 装备。

关于容器内存来说,memory.limit_in_bytes 和 memory.usage_in_bytes 是内存控制组中最重要的两个参数,前者标识了当时容器进程组可运用内存的最大值,后者是当时容器进程组实践运用的内存总和。一般来说,运用值和最大值越接近,OOM 的风险越高。

# 当时容器内存约束量
$ cat /sys/fs/cgroup/memory/memory.limit_in_bytes
4294967296
# 当时容器内存实践用量
$ cat /sys/fs/cgroup/memory/memory.usage_in_bytes
39215104

JVM OOM

提到 OOM,Java 开发者更熟悉的是 JVM OOM,当 JVM 由于没有满足的内存来为对象分配空间而且废物回收器也已经没有空间可回收时,将会抛出 java.lang.OutOfMemoryError。依照 JVM 标准,除了程序计数器不会抛出 OOM 外,其他各个内存区域都或许会抛出 OOM。最常见的 JVM OOM 状况有几种:

  • java.lang.OutOfMemoryError:Java heap space 堆内存溢出。当堆内存 (Heap Space) 没有满足空间寄存新创立的对象时,就会抛出该过错。一般由于内存走漏或许堆的巨细设置不当引起。关于内存走漏,需求经过内存监控软件查找程序中的走漏代码,而堆巨细能够经过-Xms,-Xmx等参数修改。

  • java.lang.OutOfMemoryError:PermGen space / Metaspace 永久代/元空间溢出。永久代存储对象包括class信息和常量,JDK 1.8 运用 Metaspace 替换了永久代(Permanent Generation)。通常由于加载的 class 数目太多或体积太大,导致抛出该过错。能够经过修改 -XX:MaxPermSize 或许 -XX:MaxMetaspaceSize 发动参数, 调大永久代/元空间巨细。

  • java.lang.OutOfMemoryError:Unable to create new native thread 无法创立新线程。每个 Java 线程都需求占用必定的内存空间, 当 JVM 向底层操作体系恳求创立一个新的 native 线程时, 假如没有满足的资源分配就会报此类过错。或许原因是 native 内存不足、线程走漏导致线程数超越操作体系最大线程数 ulimit 约束或是线程数超越 kernel.pid_max。需求根据状况进行资源升配、约束线程池巨细、削减线程栈巨细等操作。

为什么堆内存未超越 Xmx 却产生了 OOM?

信任很多人都遇到过这一场景,在 K8s 布置的 Java 运用常常重启,检查容器退出状况为exit code 137 reason: OOM Killed 各方信息都指向明显的 OOM,可是 JVM 监控数据显示堆内存用量并未超越最大堆内存约束Xmx,而且装备了 OOM 主动 heapdump 参数之后,产生 OOM 时却没有产生 dump 文件。

根据上面的布景常识介绍,容器内的 Java 运用或许会产生两种类型的 OOM 反常,一种是 JVM OOM,一种是容器 OOM。JVM 的 OOM 是 JVM 内存区域空间不足导致的过错,JVM 主动抛出过错并退出进程,经过观测数据能够看到内存用量超限,而且 JVM 会留下相应的过错记录。而容器的 OOM 是体系行为,整个容器进程组运用的内存超越 Cgroup 约束,被体系 OOM Killer 杀死,在体系日志和 K8s 事情中会留下相关记录。

总的来说,Java程序内存运用同时遭到来自 JVM 和 Cgroup 的约束,其间 Java 堆内存受限于 Xmx 参数,超限后产生 JVM OOM;整个进程内存受限于容器内存limit值,超限后产生容器 OOM。需求结合观测数据、JVM 过错记录、体系日志和 K8s 事情对 OOM 进行区分、排障,并按需进行装备调整。

怎样了解操作体系和 JVM 的内存联系?

上文提到 Java 容器 OOM 实质是 Java 进程运用的内存超越 Cgroup 约束,被操作体系的 OOM Killer 杀死。那在操作体系的视角里,怎么看待 Java 进程的内存?操作体系和 JVM 都有各自的内存模型,二者是怎么映射的?关于探究 Java 进程的 OOM 问题,了解 JVM 和操作体系之间的内存联系非常重要。

以最常用的 OpenJDK 为例,JVM 本质上是运转在操作体系上的一个 C++ 进程,因而其内存模型也有 Linux 进程的一般特点。Linux 进程的虚拟地址空间分为内核空间和用户空间,用户空间又细分为很多个段,此处选取几个和本文评论相关度高的几个段,描述 JVM 内存与进程内存的映射联系。

解读 Java 云原生实践中的内存问题

  • 代码段。一般指程序代码在内存中的映射,这儿特别指出是 JVM 自身的代码,而不是Java代码。

  • 数据段。在程序运转初已经对变量进行初始化的数据,此处是 JVM 自身的数据。

  • 堆空间。运转时堆是 Java 进程和一般进程差异最大的一个内存段。Linux 进程内存模型里的堆是为进程在运转时动态分配的对象提供内存空间,而几乎所有JVM内存模型里的东西,都是 JVM 这个进程在运转时新建出来的对象。而 JVM 内存模型中的 Java 堆,只不过是 JVM 在其进程堆空间上树立的一段逻辑空间。

  • 栈空间。寄存进程的运转栈,此处并不是 JVM 内存模型中的线程栈,而是操作体系运转 JVM 本身需求留存的一些运转数据。

如上所述,堆空间作为 Linux 进程内存布局和 JVM 内存布局都有的概念,是最容易混杂也是差别最大的一个概念。Java 堆相较于 Linux 进程的堆,规模更小,是 JVM 在其进程堆空间上树立的一段逻辑空间,而进程堆空间还包括支撑 JVM 虚拟机运转的内存数据,例如 Java 线程仓库、代码缓存、GC 和编译器数据等。

为什么程序占用的内存比 Xmx 大不少,内存都用在哪了?

在 Java 开发者看来,Java 代码运转中拓荒的对象都放在 Java 堆中,所以很多人会将 Java 堆内存等同于 Java 进程内存,将 Java 堆内存约束参数Xmx当作进程内存约束参数运用,而且把容器内存约束也设置为 Xmx 一样巨细,然后悲催地发现容器被 OOM 了。

实质上除了我们所熟悉的堆内存(Heap),JVM 还有所谓的非堆内存(Non-Heap),除掉 JVM 办理的内存,还有绕过 JVM 直接拓荒的本地内存。Java 进程的内存占用状况能够简略地总结为下图:

解读 Java 云原生实践中的内存问题

JDK8 引入了 Native Memory Tracking (NMT)特性,能够追寻 JVM 的内部内存运用。默许状况下,NMT 是封闭状况,运用 JVM 参数敞开:-XX:NativeMemoryTracking=[off | summary | detail]

$ java -Xms300m -Xmx300m -XX:+UseG1GC -XX:NativeMemoryTracking=summary -jar app.jar

此处约束最大堆内存为 300M,运用 G1 作为 GC 算法,敞开 NMT 追寻进程的内存运用状况。

注意:启用 NMT 会导致 5% -10% 的功能开支。

敞开 NMT 后,能够运用 jcmd 指令打印 JVM 内存的占用状况。此处仅检查内存摘要信息,设置单位为 MB。

$ jcmd <pid> VM.native_memory summary scale=MB

JVM 总内存

Native Memory Tracking:
Total: reserved=1764MB, committed=534MB

NMT 陈述显示进程当时保存内存为 1764MB,已提交内存为 534MB,远远高于最大堆内存 300M。保存指为进程拓荒一段连续的虚拟地址内存,能够了解为进程或许运用的内存量;提交指将虚拟地址与物理内存进行映射,能够了解为进程当时占用的内存量。

需求特别说明的是,NMT 所统计的内存与操作体体系计的内存有所差异,Linux 在分配内存时遵从 lazy allocation 机制,只有在进程真正拜访内存页时才将其换入物理内存中,所以运用 top 指令看到的进程物理内存占用量与 NMT 陈述中看到的有差别。此处只用 NMT 说明 JVM 视角下内存的占用状况。

Java Heap

Java Heap (reserved=300MB, committed=300MB)
    (mmap: reserved=300MB, committed=300MB)

Java 堆内存如设置的一样,实践拓荒了 300M 的内存空间。

Metaspace

Class (reserved=1078MB, committed=61MB)
      (classes #11183)
      (malloc=2MB #19375) 
      (mmap: reserved=1076MB, committed=60MB)

加载的类被存储在 Metaspace,此处元空间加载了 11183 个类,保存了近 1G,提交了 61M。

加载的类越多,运用的元空间就越多。元空间巨细受限于-XX:MaxMetaspaceSize(默许无约束)和 -XX:CompressedClassSpaceSize(默许 1G)。

Thread

Thread (reserved=60MB, committed=60MB)
       (thread #61)
       (stack: reserved=60MB, committed=60MB)

JVM 线程仓库也需求占有必定空间。此处 61 个线程占用了 60M 空间,每个线程仓库默许约为 1M。仓库巨细由 -Xss 参数控制。

Code Cache

Code (reserved=250MB, committed=36MB)
     (malloc=6MB #9546) 
     (mmap: reserved=244MB, committed=30MB)

代码缓存区首要用来保存 JIT 即时编译器编译后的代码和 Native 办法,目前缓存了 36M 的代码。代码缓存区能够经过 -XX:ReservedCodeCacheSize 参数进行容量设置。

GC

GC (reserved=47MB, committed=47MB)
   (malloc=4MB #11696) 
   (mmap: reserved=43MB, committed=43MB)

GC 废物收集器也需求一些内存空间支撑 GC 操作,GC 占用的空间与具体选用的 GC 算法有关,此处的 GC 算法运用了 47M。在其他装备相同的状况下,换用 SerialGC:


GC (reserved=1MB, committed=1MB)
   (mmap: reserved=1MB, committed=1MB)

能够看到 SerialGC 算法仅运用 1M 内存。这是由于 SerialGC 是一种简略的串行算法,涉及数据结构简略,计算数据量小,所以内存占用也小。可是简略的 GC 算法或许会带来功能的下降,需求平衡功能和内存表现进行挑选。

Symbol

Symbol (reserved=15MB, committed=15MB)
       (malloc=11MB #113566) 
       (arena=3MB #1)

JVM 的 Symbol 包括符号表和字符串表,此处占用 15M。

非 JVM 内存

NMT 只能统计 JVM 内部的内存状况,还有一部分内存不由JVM办理。除了 JVM 托管的内存之外,程序也能够显式地恳求堆外内存 ByteBuffer.allocateDirect,这部分内存受限于 -XX:MaxDirectMemorySize 参数(默许等于-Xmx)。

System.loadLibrary 所加载的 JNI 模块也能够不受 JVM 控制地请求堆外内存。综上,其实并没有一个能准确估量 Java 进程内存用量的模型,只能够尽或许多地考虑到各种因素。其间有一些内存区域能经过 JVM 参数进行容量约束,例如代码缓存、元空间等,但有些内存区域不受 JVM 控制,而与具体运用的代码有关。

Total memory = Heap + Code Cache + Metaspace + Thread stacks +
               Symbol + GC + Direct buffers + JNI + ...

为什么线上容器比本地测验内存需求更大?

常常有用户反应,为什么相同的一份代码,在线上容器里跑总是要比本地跑更耗内存,甚至呈现 OOM。或许的状况的状况有如下几种:

没有运用容器感知的 JVM 版别

在一般的物理机或虚拟机上,当未设置 -Xmx 参数时,JVM 会从常见位置(例如,Linux 中的 /proc目录下)查找其能够运用的最大内存量,然后依照主机最大内存的 1/4 作为默许的 JVM 最大堆内存量。而早期的 JVM 版别并未对容器进行适配,当运转在容器中时,仍然依照主机内存的 1/4 设置 JVM最 大堆,而一般集群节点的主机内存比本地开发机大得多,容器内的 Java 进程堆空间开得大,自然更耗内存。同时在容器中又遭到 Cgroup 资源约束,当容器进程组内存运用量超越 Cgroup 约束时,便会被 OOM。为此,8u191 之后的 OpenJDK 引入了默许敞开的 UseContainerSupport 参数,使得容器内的 JVM 能感知容器内存约束,依照 Cgroup 内存约束量的 1/4 设置最大堆内存量。

线上事务消耗更多内存

对外提供服务的事务往往会带来更活跃的内存分配动作,比方创立新的对象、敞开履行线程,这些操作都需求拓荒内存空间,所以线上事务往往消耗更多内存。而且越是流量高峰期,消耗的内存会更多。所以为了保证服务质量,需求根据自身事务流量,对运用内存装备进行相应扩容。

云原生 Java 运用内存的装备主张

  1. 运用容器感知的 JDK 版别。关于运用 Cgroup V1 的集群,需求升级至 8u191+、Java 9、Java 10 以及更高版别;关于运用 Cgroup V2 的集群,需求升级至 8u372+ 或 Java 15 及更高版别。
  2. 运用 NativeMemoryTracking(NMT) 了解运用的 JVM 内存用量。NMT 能够追寻 JVM 的内存运用状况,在测验阶段能够运用 NMT 了解程序JVM运用内存的大致散布状况,作为内存容量装备的参阅根据。JVM 参数 -XX:NativeMemoryTracking 用于启用 NMT,敞开 NMT 后,能够运用 jcmd 指令打印 JVM 内存的占用状况。
  3. 根据 Java 程序内存运用量设置容器内存 limit。容器 Cgroup 内存约束值来源于对容器设置的内存 limit 值,当容器进程运用的内存量超越 limit,就会产生容器 OOM。为了程序在正常运转或事务波动时产生 OOM,应该依照 Java 进程运用的内存量上浮 20%~30% 设置容器内存 limit。假如初次运转的程序,并不了解其实践内存运用量,能够先设置一个较大的 limit 让程序运转一段时间,依照观测到的进程内存量对容器内存 limit 进行调整。
  4. OOM 时主动 dump 内存快照,并为 dump 文件装备耐久化存储,比方运用 PVC 挂载到 hostPath、OSS 或 NAS,尽或许保存现场数据,支撑后续的毛病排查。