当前位置: 首页 > 后端技术 > Java

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

时间:2023-04-02 02:07:25 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保证容器有请求的资源量,但不允许超过限制量的资源使用。以下面的内存配置为例。容器至少可以获得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。$jcmdVM.native_memorysummaryscale=MBJVMtotalmemoryNativeMemoryTracking:Total:reserved=1764MB,committed=534MBNMT报告显示进程当前reserved内存为1764MB,committed内存为534MB,即远高于最大堆内存300M。预留是指为进程开辟一块连续的虚拟地址内存,可以理解为进程可能使用的内存量;commit是指将虚拟地址映射到物理内存,可以理解为进程当前占用的内存量。需要注意的是,NMT统计的内存和操作系统统计的内存是不一样的。Linux在分配内存时遵循惰性分配机制,只有在进程真正访问内存页时才将其换入物理内存,所以使用top命令看到的进程物理内存占用与NMT报告中看到的不一样。这里只用NMT来说明JVM视角下的内存使用情况。JavaHeapJavaHeap(reserved=300MB,committed=300MB)(mmap:reserved=300MB,committed=300MB)Java堆内存和设置的一样,实际开辟了300M的内存空间。MetaspaceClass(reserved=1078MB,committed=61MB)(classes#11183)(malloc=2MB#19375)(mmap:reserved=1076MB,committed=60MB)加载的类存储在Metaspace中,其中11183个类被加载到metaspace中,保留近1G,承诺61M。加载的类越多,使用的元空间就越多。元空间大小受-XX:MaxMetaspaceSize(默认无限制)和-XX:CompressedClassSpaceSize(默认1G)限制。ThreadThread(reserved=60MB,committed=60MB)(thread#61)(stack:reserved=60MB,committed=60MB)JVM线程栈也需要占用一定的空间。这里61个线程占用60M空间,每个线程栈默认1M左右。堆栈大小由-Xss参数控制。CodeCacheCode(reserved=250MB,committed=36MB)(malloc=6MB#9546)(mmap:reserved=244MB,committed=30MB)代码缓存区主要用于保存JIT即时编译器和Native方法编译后的代码.目前,缓存36M的代码。代码缓存区的容量可以通过-XX:ReservedCodeCacheSize参数设置。GCGC(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算法可能会造成性能下降,需要平衡性能和内存性能进行选择。SymbolSymbol(reserved=15MB,committed=15MB)(malloc=11MB#113566)(arena=3MB#1)JVM的Symbol包括符号表和字符串表,这里占用15M。Non-JVMmemoryNMT只能统计JVM内部的内存,有些内存不是JVM管理的。除了JVM管理的内存,程序还可以显式申请堆外内存ByteBuffer.allocateDirect,它受限于-XX:MaxDirectMemorySize参数(默认等于-Xmx)。System.loadLibrary加载的JNI模块也可以申请堆外内存,不受JVM的控制。综上所述,其实并没有一个模型能够准确预估一个Java进程的内存使用情况,只能尽可能的综合考虑各种因素。有些内存区域可以通过JVM参数进行限制,比如代码缓存、元空间等,但有些内存区域不受JVM控制,而是与具体的应用程序代码相关。总内存=Heap+CodeCache+Metaspace+Threadstacks+Symbol+GC+Directbuffers+JNI+...为什么在线容器需要比本地测试更多的内存?经常有用户反馈,为什么同样的代码在线上容器运行总是比本地运行更耗内存,甚至出现OOM。可能的情况如下:没有使用container-aware的JVM版本在一般的物理机或者虚拟机上,当没有设置-Xmx参数时,JVM会从一个普通的位置启动(比如在/proc目录下)Linux))找到它可以使用的最大内存量,然后使用宿主机最大内存的1/4作为JVM默认的最大堆内存量。早期的JVM版本并没有适配容器。在容器中运行时,JVM最大堆还是按照宿主机内存的1/4设置。但是一般集群节点的宿主机内存要比本地开发机大很多。Java进程的堆空间更大,自然消耗更多的内存。同时,容器受到Cgroup资源的限制。当容器进程组的内存使用量超过Cgroup限制时,就会OOM。为此,8u191之后的OpenJDK引入了UseContainerSupport参数,默认开启,让容器中的JVM感知容器内存限制,按照Cgroup内存限制的1/4设置最大堆内存。在线业务消耗更多内存。对外提供服务的业务往往会带来更主动的内存分配动作,例如创建新对象、启动执行线程等。这些操作都需要开辟内存空间,因此线上业务往往会消耗较多的内存。.而且流量高峰越多,消耗的内存就越多。因此,为了保证服务质量,需要根据自身的业务流量来扩展应用的内存配置。建议使用容器感知的JDK版本来配置云原生Java应用程序的内存。使用CgroupV1的集群,需要升级到8u191+、Java9、Java10及更高版本;对于使用CgroupV2的集群,需要升级到8u372+或Java15及更高版本。使用NativeMemoryTracking(NMT)了解应用程序的JVM内存使用情况。NMT可以跟踪JVM的内存使用情况。在测试阶段,可以通过NMT了解程序的JVM使用内存的大概分布情况,作为内存容量配置的参考。JVM参数-XX:NativeMemoryTracking用于启用NMT。开启NMT后,可以使用jcmd命令打印JVM内存使用情况。根据Java程序内存使用情况设置容器内存限制。容器Cgroup内存限制值来自为容器设置的内存限制值。当容器进程使用的内存量超过限制时,就会发生容器OOM。为了让程序在正常运行或业务波动时OOM,容器内存限制应该设置为Java进程使用内存的20%到30%。如果不知道第一次运行的程序的实际内存使用量,可以先设置一个较大的限制,让程序运行一段时间,然后根据观察到的内存量调整容器内存限制进程内存。OOM时自动转储内存快照,并为转储文件配置持久化存储,如使用PVC挂载到hostPath、OSS或NAS,尽可能保留现场数据,以支持后续故障排除。原文链接本文为阿里云原创内容,未经许可不得转载。