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保证容器有请求的资源量,但不允许超过限制量的资源使用。以下面的内存配置为例。容器至少可以获得1024Mi的内存资源,但不允许超过4096Mi。一旦内存使用超过限制,容器就会OOM,然后由K8scontroller重启。规格:容器:-名称:edas图像:alibaba/edas资源:请求:内存:“1024Mi”限制:内存:“4096Mi”命令:[“java”,“-jar”,“edas.jar”]容器OOMfor对于容器的OOM机制,我们首先要回顾一下容器的概念。当我们谈到容器时,我们会说这是一种沙箱技术。容器作为沙箱,内部相对独立,有边界,有大小。通过LinuxNamespace机制实现容器内的独立运行环境,对容器内的PID、Mount、UTS、IPD、Network等Namespace进行盲化,使宿主机的Namespace和其他容器的Namespace在容器中不可见容器;所谓容器的边界和大小,是指容器对CPU、内存、IO等资源的使用约束。否则,单个容器占用过多资源可能会导致其他容器运行缓慢或异常。Cgroup是Linux内核提供的一种限制单个进程或多个进程使用资源的机制,也是实现容器资源约束的核心技术。从操作系统的角度来看,容器无非是一个特殊的进程,它对资源的使用受到一个Cgroup的约束。当进程使用的内存量超过Cgroup的限制时,就会被系统OOMKiller无情的杀死。所以,所谓容器OOM,本质上就是运行在Linux系统上的容器进程发生了OOM。Cgroup并不是什么晦涩难懂的技术,Linux将其实现为文件系统,符合Unix万物皆文件的理念。对于CgroupV1版本,我们可以直接在容器中的/sys/fs/cgroup/目录下查看当前容器的Cgroup配置。对于容器内存,memory.limit_in_bytes和memory.usage_in_bytes是内存控制组中最重要的两个参数。前者标识了当前容器进程组可用的最大内存,后者是当前容器进程组实际使用的内存总和。一般来说,使用的值越接近最大值,OOM的风险就越高。#当前容器内存限制$cat/sys/fs/cgroup/memory/memory.limit_in_bytes4294967296#当前容器内存实际使用情况$cat/sys/fs/cgroup/memory/memory.usage_in_bytes39215104JVMOOM说到OOM,Java开发者都是比较熟悉最重要的是JVMOOM。当JVM没有足够的内存为对象分配空间,垃圾回收器没有空间回收时,会抛出java.lang.OutOfMemoryError。根据JVM规范,除了程序计数器不会抛出OOM外,其他所有内存区域都可能抛出OOM。最常见的JVMOOM情况有几种:java.lang.OutOfMemoryError:Javaheapspaceheapmemoryoverflow。当堆内存(HeapSpace)没有足够的空间来存放新创建的对象时会抛出这个错误。通常是由内存泄漏或堆大小设置不当引起的。对于内存泄漏,需要使用内存监控软件查找程序中泄漏的代码,可以通过-Xms、-Xmx等参数修改堆大小。java.lang.OutOfMemoryError:PermGen空间/MetaspacePermGen/Metaspace溢出。永久代存储对象包括类信息和常量。JDK1.8使用元空间来替代永久代(PermanentGeneration)。抛出这个错误通常是因为加载的类数量太多或者size太大。您可以通过修改-XX:MaxPermSize或-XX:MaxMetaspaceSize启动参数来增加永久代/元空间大小。java.lang.OutOfMemoryError:Unabletocreatenewnativethread无法创建新线程。每个Java线程都需要占用一定的内存空间。当JVM请求底层操作系统创建一个新的native线程时,如果没有分配足够的资源就会报这样的错误。可能的原因是native内存不足,threadleak导致线程数超过操作系统最大线程数的ulimit限制,或者线程数超过kernel.pid_max。需要根据情况进行资源升级、限制线程池大小、减小线程栈大小等操作。为什么堆内存不超过Xmx就出现OOM?这种场景相信很多人都遇到过。部署在K8s上的Java应用经常重启,容器退出状态为exitcode137reason:OOMKilled。所有信息都指向明显的OOM。但是JVM监控数据显示,堆内存使用率并没有超出最大堆内存限制Xmx,并且配置了OOM自动heapdump参数后,OOM时没有生成dump文件。根据上面的背景知识,容器中的Java应用可能会出现两种OOM异常,一种是JVMOOM,一种是容器OOM。JVM的OOM是JVM内存区空间不足导致的错误。JVM主动抛出错误并退出进程。通过观察数据可以看出内存使用超过了限制,JVM会留下相应的错误记录。容器的OOM是一种系统行为。整个容器进程组使用内存超过Cgroup限制,被系统OOMKiller杀死。相关记录会留在系统日志和K8s事件中。一般情况下,Java程序的内存使用受到JVM和Cgroup的限制。Java堆内存受Xmx参数限制,超过限制后发生JVMOOM;整个进程内存受容器内存限制值限制,超过限制后发生。容器OOM。需要结合观察数据、JVM错误记录、系统日志、K8s事件来区分和排查OOM,并根据需要进行配置调整。如何理解操作系统和JVM的内存关系?上面说了,Java容器OOM的本质是Java进程使用内存超过Cgroup限制,被操作系统的OOMKiller杀死。那么站在操作系统的角度,如何看待Java进程的内存呢?操作系统和JVM都有自己的内存模型,它们是如何映射的?理解JVM和操作系统的内存关系对于探索Java进程的OOM问题很重要。以最常用的OpenJDK为例,JVM本质上是一个运行在操作系统上的C++进程,因此其内存模型也具有Linux进程的一般特征。Linux进程的虚拟地址空间分为内核空间和用户空间,而用户空间又被细分为很多段。这里选取几个与本文讨论高度相关的片段来描述JVM内存与进程内存的映射关系。代码片段。一般是指程序代码在内存中的映射。这里特别指出,是JVM本身的代码,不是Java代码。数据段。程序运行开始时初始化过变量的数据,这里是JVM本身的数据。堆空间。运行时堆是Java进程和普通进程之间最不同的内存段。Linux进程内存模型中的堆为进程在运行时动态分配的对象提供内存空间,而JVM内存模型中几乎所有的东西都是JVM进程在运行时新创建的对象。JVM内存模型中的Java堆无非是JVM在其进程堆空间上建立的逻辑空间。堆栈空间。存储进程的运行栈。这不是JVM内存模型中的线程栈,而是操作系统运行JVM本身时需要保存的一些运行数据。上文提到,堆空间作为Linux进程内存布局和JVM内存布局的一个概念,是最容易混淆、差异最大的概念。与Linux进程的堆相比,Java堆的范围更小。是JVM在其进程堆空间上建立的逻辑空间,进程堆空间还包括支持JVM虚拟机运行的内存数据,如Java线程栈、代码缓存、GC和编译器数据等。为什么程序占用的内存比Xmx大很多,内存用在什么地方?从Java开发者的角度来看,Java代码运行过程中创建的对象是放在Java堆中的,所以很多人会把Java堆内存等同于Java进程内存,并使用Java堆内存限制参数Xmx作为进程内存限制参数。并且把容器内存限制设置成和Xmx一样大小,然后悲催的发现容器OOM了。本质上,除了大家熟悉的堆内存(Heap),JVM还有所谓的非堆内存(Non-Heap),它去掉了JVM管理的内存,绕过JVM直接开发本地内存。Java进程的内存使用情况可以简单概括为下图:JDK8引入了NativeMemoryTracking(NMT)特性,可以跟踪JVM内部内存的使用情况。默认情况下,NMT是关闭的,使用JVM参数开启:-XX:NativeMemoryTracking=[off|总结|detail]$java-Xms300m-Xmx300m-XX:+UseG1GC-XX:NativeMemoryTracking=summary-jarapp.jar这里限制最大堆内存为300M,使用G1作为GC算法,开启NMT跟踪内存使用情况过程。注意:启用NMT会产生5%-10%的性能开销。启用NMT后,您可以使用jcmd命令打印JVM内存使用情况。这里只能查看内存汇总信息,设置单位为MB。$jcmd
