详解简介:本文将分析JVM和Flink的内存模型,总结一下工作中遇到和社区交流中了解到的导致Flink内存使用超出容器限制的常见原因。由于Flink内存使用与用户代码、部署环境、各种依赖版本等因素密切相关,本文主要讨论YARN部署、OracleJDK/OpenJDK8、Flink1.10+。在生产环境中,Flink通常部署在YARN或k8s等资源管理系统上。进程会以容器化(YARN容器或docker等容器)模式运行,其资源会受到资源管理系统的严格限制。另一方面,Flink运行在JVM之上,而JVM并不是特别适合容器化环境,尤其是JVM复杂且可控性差的内存模型,很容易导致进程因过度使用资源而被kill掉,从而导致在FlinkApps中不稳定甚至无法使用。针对这个问题,Flink在1.10版本重构了内存管理模块,设计了新的内存参数。在大多数场景下,Flink的内存模型和默认值已经足够好,可以帮助用户屏蔽掉进程背后复杂的内存结构。但是,一旦出现内存问题,排查和修复问题需要更多的领域知识,这通常会让普通用户敬而远之。为此,本文将对JVM和Flink的内存模型进行分析,总结出工作中遇到和社区交流中了解到的Flink内存使用超过容器限制的常见原因。由于Flink内存使用与用户代码、部署环境、各种依赖版本等因素密切相关,本文主要讨论YARN部署、OracleJDK/OpenJDK8、Flink1.10+。另外感谢@宋新宝(Flink1.10+新内存架构主要作者)和@唐云(RocksDBStateBackend专家)在社区中的解答,让作者受益匪浅。JVM内存分区对于大多数Java用户来说,在日常开发中与JVMHeap打交道的频率远高于其他JVM内存分区,因此其他内存分区往往统称为Off-Heap内存。对于Flink来说,内存过大的问题通常来自于Off-Heap内存,因此需要对JVM内存模型有更深入的了解。根据JVM8Spec[1],JVM管理的内存分区如下:img1。除了上述Spec中规定的标准分区外,JVM8内存模型还经常为高级功能模块增加一些额外的分区。以HotSoptJVM为例,根据OracleNMT[5]标准,我们可以将JVM内存细分为以下几个区域:●堆:各线程共享的内存区域,主要存放new操作符创建的对象,以及内存由GC管理释放,可以供用户代码或JVM本身使用。●Class:类的元数据,对应Spec中的MethodArea(不包括ConstantPool),Java8中的Metaspace。●Thread:线程级内存区,对应Spec中PCRegister、Stack和NatviveStack的总和。●编译器:JIT(即时)编译器使用的内存。●代码缓存:用于存储JIT编译器生成的代码的缓存。●GC:垃圾收集器使用的内存。●Symbol:存储Symbols(如字段名、方法签名、InternedStrings)的内存,对应Spec中的ConstantPool。●ArenaChunk:JVM申请操作系统内存的临时缓存。●NMT:NMT自身使用的内存。●Internal:不符合上述分类的其他内存,包括用户代码请求的Native/Direct内存。●Unknown:无法归类的内存。理想情况下,我们可以严格控制每个分区的内存上限,保证进程的整体内存在容器限制之内。但是,过于严格的管理会带来额外的使用成本和缺乏灵活性。因此,在实践中,JVM只对暴露给用户的部分分区提供了硬性上限,而其他分区则可以作为一个整体来看待。对于JVM本身的内存消耗。具体可用于限制分区内存的JVM参数如下表所示(值得注意的是,业界对JVMNativememory并没有一个准确的定义,本文中的Nativememory指的是非Direct部分)Off-Heap内存,不同于NativeNon-Direct是可互换的)。从表中可以看出,使用Heap、Metaspace和Direct内存是比较安全的,但是非DirectNative内存的情况就比较复杂,可能是JVM本身的一些内部使用(比如提到的MemberNameTable下),也可能是用户代码引入的JNI依赖,也可能是用户代码自己通过sun.misc.Unsafe请求的Native内存。从理论上讲,用户代码或第三方lib请求的Native内存需要用户规划内存使用,而Internal的其余部分可以纳入JVM本身的内存消耗。事实上,Flink的内存模型也遵循类似的原理。FlinkTaskManager内存模型首先回顾一下Flink1.10+的TaskManager内存模型。img2。FlinkTaskManager内存模型显然,Flink框架本身不仅会包括JVM管理的Heap内存,还会申请自己管理Off-HeapNative和Direct内存。在我看来,Flink对Off-Heap内存的管理策略可以分为三种:HardLimit:HardLimit内存分区是Self-Contained,Flink会保证其使用量不会超过设定的阈值(如果没有足够memory,会抛出类似OOM的异常)。●SoftLimit:SoftLimit表示内存使用率会长时间低于阈值,但可能会短时间超过配置的阈值。●Reserved:Reserved是指Flink不限制分区内存的使用,只是在规划内存时只保留一部分空间,但不能保证实际使用不会超过限制。结合JVM的内存管理,Flink内存分区内存溢出后果的判断逻辑如下:1.如果Flink存在硬限制分区,Flink会报该分区内存不足。否则进行下一步。2、如果分区属于JVM管理的分区,当其实际值增加,JVM分区也内存不足时,JVM会报告所属JVM分区的OOM(如java.lang.OutOfMemoryError:Jave堆空间)。否则进行下一步。3.分区内存不断溢出,最终导致进程整体内存超过容器内存限制。在启用了严格资源控制的环境中,资源管理器(YARN/k8s等)会杀死进程。为了直观的展示Flink内存分区和JVM内存分区的关系,作者整理了如下内存分区映射表:img3。Flink分区和JVM分区内存限制关系按照前面的逻辑,在所有的Flink内存分区中,只有JVMOverhead不是Self-Contained并且所属的JVM分区没有hardmemorylimit参数可能会导致进程被OOM杀死。作为为各种用途预留的内存大杂烩,JVMOverhead确实容易出问题,但同时它也可以作为自下而上的隔离缓冲区,从其他方面缓解内存问题。例如,Flink内存模型在计算NativeNon-Direct内存时有一个技巧:虽然nativenon-directmemoryusage可以作为frameworkoff-heapmemory或者taskoff-heapmemory的一部分来计算,但是会导致在这种情况下,在更高的JVM直接内存限制中。虽然Task/Framework的Off-Heap分区可能包含NativeNon-Direct内存,但这部分内存严格属于JVMOverhead,不受JVM-XX:MaxDirectMemorySize参数限制的控制,但Flink仍然将其计入MaxDirectMemorySize。这部分reservedDirectmemoryquota不会被实际使用,所以可以预留给JVMOverhead,没有上限,达到为NativeNon-Directmemory预留空间的效果。OOMKilled的常见原因与上述分析一致。在实践中,常见的OOMKilled原因基本都是Native内存泄露或者过度使用。因为虚拟内存的OOMKilled很容易通过资源管理器的配置来避免,一般问题不大,所以下面只讨论物理内存的OOMKilled。RocksDBNative内存的不确定性是众所周知的。RocksDB通过JNI直接申请Native内存,不受Flink控制。因此,Flink实际上是通过设置RocksDB的内存参数来间接影响其内存使用的。但是目前Flink是通过估计的方式得到这些参数,并不是很准确的值,原因如下。首先是部分内存难以准确计算的问题。RocksDB的内存占用分为4部分[6]:●BlockCache:OSPageCache之上的一层缓存,缓存未压缩的数据块。●索引和过滤块:索引和布隆过滤器用于优化读取性能。●Memtable:类似于写缓存。●IteratorpinnedBlocks:当触发RocksDB遍历操作(如遍历RocksDBMapState的所有key)时,Iterator会在其生命周期内阻止其引用的Blocks和Memtables被释放,从而导致额外的内存使用[10].前三个区域的内存是可配置的,但Iterator锁定的资源取决于应用业务使用模式,没有硬性限制,所以Flink在计算RocksDBStateBackend内存时不考虑这部分。二是RocksDBBlockCache的一个bug8,会导致Cache的大小无法严格控制,可能会在短时间内超过设定的内存容量,相当于软限制。对于这个问题,通常我们只需要提高JVMOverhead的阈值,让Flink预留更多的内存即可,因为RocksDB的内存过度使用只是暂时的。glibcThreadArena问题的另一个常见问题是glibc著名的64MB问题,它可能会导致JVM进程的内存使用量大幅增加,最终被YARN杀死。具体来说,JVM通过glibc申请内存,为了提高内存分配效率,减少内存碎片,glibc维护了一个名为Arena的内存池,包括共享的MainArena和线程级的ThreadArena。当一个线程需要申请内存但是MainArena已经被其他线程锁定时,glibc会分配一个大约64MB(64位机)的ThreadArena供线程使用。这些ThreadArenas对JVM是透明的,但会被计入进程的总虚拟内存(VIRT)和物理内存(RSS)。默认情况下,Arenas的最大数量是cpu核心数*8。对于普通的32核服务器,最多需要16GB,这不无道理。为了控制消耗的内存总量,glibc提供了环境变量MALLOC_ARENA_MAX来限制Arena的总量。例如,Hadoop默认将此值设置为4。然而,这个参数只是一个软限制。当所有Arenas都被锁定时,glibc仍然会创建一个新的ThreadArena来分配内存[11],从而导致意外的内存使用。一般来说,这个问题出现在需要频繁创建线程的应用程序中。比如HDFSClient会为每个正在写入的文件创建一个新的DataStreamer线程,所以更容易遇到ThreadArena的问题。如果你怀疑你的Flink应用遇到了这个问题,一个比较简单的验证方法是检查进程的pmap中是否有很多连续的大小为64MB的倍数的anon段。比如下图中65536KB的蓝色段很可能是It'sArena。img4.pmap64MBarena的修复比较简单,只需将MALLOC_ARENA_MAX设置为1,即禁用ThreadArena,只使用MainArena。当然,这样做的代价是线程分配内存的效率会降低。不过值得一提的是,使用Flink的进程环境变量参数(如containerized.taskmanager.env.MALLOC_ARENA_MAX=1)覆盖默认的MALLOC_ARENA_MAX参数可能不可行,因为非白名单变量(yarn.nodemanager.env-whitelist)冲突,NodeManager会通过合并URL的方式合并原始值和新增值,最终导致MALLOC_ARENA_MAX="4:1"的结果。最后还有一个更彻底的可选方案,就是用谷歌的tcmalloc或者Facebook的jemalloc[12]替代glibc。除了没有ThreadArena问题外,内存分配性能更好,碎片更少。事实上,Flink1.12的官方镜像也将默认内存分配器从glibc更改为jemelloc[17]。JDK8NativememoryleaksOracleJdk8u152之前的版本有一个Nativememoryleakbug[13],会导致JVM的内部内存分区不断增长。具体来说,JVM会缓存字符串符号(Symbol)到方法(Method)和成员变量(Field)的映射对,以加快查找速度。每对映射称为MemberName,整个映射关系称为MemeberNameTable,由java.lang定义。invoke.MethodHandles这个类负责。在Jdk8u152之前,MemberNameTable使用的是Native内存,所以一些过时的MemberNames不会被GC自动清理,造成内存泄漏。要确认这个问题,需要通过NMT查看JVM的内存状态。比如我在一个在线的TaskManager中遇到过400多MB的MemeberNameTable。img5。JDK8MemberNameTableNativememoryleak在JDK-8013267[14]之后,MemberNameTable从Native内存移动到JavaHeap,解决了这个问题。但是JVM原生内存泄漏问题不止一个,比如C2编译器的内存泄漏问题[15],所以对于像笔者这样没有专门的JVM团队的用户来说,升级到最新版本的JDK是解决问题的最佳方法。YARN的mmap内存算法众所周知,YARN会根据/proc/${pid}下的进程信息计算出整个容器进程树的整体内存,但是其中有一个特别的地方就是mmap的共享内存。毫无疑问,mmap内存都会全部计入进程的VIRT,但是RSS的计算标准不同。根据YARN和Linuxsmaps的计算规则,内存页(Pages)按照两种标准划分:PrivatePages:只被当前进程映射的PagesSharedPages:与其他进程共享的PagesCleanPages:自映射以来thathavenotbeenmodifiedsinceDirtyPages:自被映射后被修改的页面在默认实现中,YARN根据/proc/${pid}/status计算总内存,所有的SharedPages都会被统计为RSS的进程,即使这些Pages同时被多个进程映射[16],这也会造成与实际操作系统物理内存的偏差,并可能导致Flink进程被误杀(当然,前提是用户代码使用了mmap,没有预留足够的空间)。为此,YARN提供了yarn.nodemanager.container-monitor.procfs-tree.smaps-based-rss.enabled配置选项,设置为true后,YARN会使用更准确的/proc/${pid}/smap来计算内存使用量,其中一个关键概念是PSS。简单来说,PSS的不同之处在于,在计算内存时,SharedPages被平均分配给所有使用这个Pages的进程。例如,一个进程持有1000个PrivatePages和1000个SharedPages,这些SharedPages将与另一个进程共享,那么该进程的总Page数就是1500。回到YARN的内存计算,进程RSS等于所有页面RSS映射到它。YARN默认计算一个PageRSS的公式如下:PageRSS=Private_Clean+Private_Dirty+Shared_Clean+Shared_Dirty因为一个Page要么是Private要么是Shared,要么是Clean要么是Dirty,至少有3个Item是0。开启smaps选项后,公式变为:PageRSS=Min(Shared_Dirty,PSS)+Private_Clean+Private_Dirty简单来说,新公式的结果就是去除了Shared_Clean部分双重计算的影响。虽然开启基于smaps计算的选项会让计算更加准确,但是会引入遍历Pages计算总内存的开销。不如直接获取/proc/${pid}/status的统计数据来得快。建议通过增加Flink的JVMOverhead分区的容量来解决问题。总结本文首先介绍了JVM内存模型和FlinkTaskManager内存模型,然后分析了通常由于Native内存泄漏导致的进程OOMKilled。最后列举了Native内存泄露的几种常见原因以及处理方法,包括RocksDB内存使用的不确定性。glibc的64MB问题,JDK8MemberNameTable泄漏和YARN对mmap内存的计算不准确。由于作者水平有限,不能保证所有内容都是正确的。读者如有不同意见,欢迎留言共同讨论。作者:林小波原文链接本文为阿里云原创内容,未经允许不得转载
