乐趣区

关于java:解读-Java-云原生实践中的内存问题

作者:风敬(谢文欣)

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 内存与过程内存的映射关系。

  • 代码段。个别指程序代码在内存中的映射,这里特地指出是 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 过程的内存占用状况能够简略地总结为下图:

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,尽可能保留现场数据,撑持后续的故障排查。
退出移动版