Java堆外内存管理和OOM-Killer

我曾遇到过一个问题,在Docker中限制容器以1G内存运行Spring项目,将堆空间调整为800MB,项目启动失败,检查日志发现没有输出,检查系统日志时才发现触发了OOMKiller。原因可能是堆外空间不足,将堆空间调整为600MB,项目正常启动。

由于这个问题涉及到的知识比较多,同时资料也相对比较零散,这里将查到的相关知识汇总在一起方便查看

HotSpot堆外内存结构

堆外内存占用主要由几部分构成(参考周志明《深入理解Java虚拟机》第三版):

  1. 虚拟机和垃圾回收器占用
  2. 元空间占用(Class元数据,主要是Class文件的运行时数据结构和动态生成的类型)
  3. DirectByteBuffer分配的直接内存和Unsafe.allocateMemory()分配的直接内存
  4. JNI(本地方法调用)过程中分配的内存

参考JDK8 HotSpot参数

参数说明
-XX:MaxMetaspaceSize=size允许JVM分配的最大元空间内存
-XX:MaxDirectMemorySize=size允许JVM分配的最大直接内存(包括元空间?)

在比较大的Spring项目中,启动时会动态生成大量代理类,填充元空间,导致直接内存激增,如果可用的直接内存不足,有几种可能的后果:

  1. 设置了最大元空间,触发了垃圾回收,幸运地(事实上不太可能)将足够多的已经无用的类卸载了,腾出足够的空间
  2. 设置了最大元空间,触发了垃圾回收后还是空间不足,抛出OutOfMemoryException: metaspace,Spring启动可能就失败了
  3. 没有设置最大元空间,触发了容器或者操作系统内存限制,由于是刚启动的进程,直接被OOM-Killed,没有Java日志记录异常。

容器OOM管理

Docker与宿主机共享内核,使用内核提供的cgroup模型管理容器的CPU和内存资源(关于cgroup(v1)介绍cgroup内存管理)。

(注意cgroup有v1和v2两个版本,Docker 20.10 之后才支持v2,以下按v1讨论)

在启动Docker时,先启动Docker的Daemon进程,在创建容器时,通过调用fork()函数创建一个新的进程作为容器的init进程(容器内PID=1)。docker把同一个容器的进程都分配到自己cgroup的sub-group中,设置这些cgroup的CPU和最大占用内存,并监控这些sub-group的OOM异常。

每个cgroup都可设置memory.limit_in_bytesmemory.oom_control 两个参数,用于控制cgroup的最大可用内存,和在发生OOM Exception的时候是否启动OOM-Killer杀死cgroup中的进程(本段参考了这篇博客)。cgroup提供的行为在docker中有相应的容器参数可以控制:

OptionDescription
-m or --memory=The maximum amount of memory the container can use. If you set this option, the minimum allowed value is 6m (6 megabytes). That is, you must set the value to at least 6 megabytes.
--oom-kill-disableBy default, if an out-of-memory (OOM) error occurs, the kernel kills processes in a container. To change this behavior, use the --oom-kill-disable option. Only disable the OOM killer on containers where you have also set the -m/--memory option. If the -m flag is not set, the host can run out of memory and the kernel may need to kill the host system’s processes to free memory.

容器默认行为是启用OOM-Killer的,在超出最大可用内存时就会杀死容器中的一个进程。

当没有指定最大可用内存的时候,容器最终可能会耗尽宿主机所有内存,此时如果操作系统需要执行某些内核操作,而没有足够内存时,就会强制触发OOM-Killer(由于操作系统必须正确工作,这种场景下的OOM-Killed无法避免)。Docker Daemon 被默认设定为高于其他容器进程的优先级,以避免Daemon先于容器被杀死(参考Docker关于内存管理的文档)。但是宿主机的其他进程(其他cgroup中的进程)在此过程中也可能被任意杀死,这种情况的OOM-Killed后果基本不可控,因此应当限制容器的最大可用内存。

内核OOM管理

C语言中的void *malloc(size_t size)函数,该函数通常宣称在内存不足的时候会通过返回NULL提示程序分配失败,但是经过深入调查,发现事实上malloc除了嵌入式系统,基本上非常难返回NULL,而是直接触发OOM-Killer。关于Linux内核的这个行为,在这篇文章中有讨论(这里的x-order allocation,指的是2^x个内存页的内存分配,0-order就是1页)。

实际上,Linux内核malloc分配内存不足(包括物理内存+虚拟内存)时,(合理猜测)会经历这样一个过程(参考了这篇回答,和这份文档的2.5 Reclaim):

  1. 回收cgroup中不使用的page尝试腾出足够的空间
  2. 根据vm.overcommit_memory的设置(参考),在cgroup超出可用内存的合理范围内,将其他进程已分配但未访问的内存分配给请求内存的进程
  3. 尽最大努力反复重试分配,如果发现最近几秒内发生多次分配失败,且最近没有进程被OOM-Killer杀死(参考文章中的Determining OOM Status),如果cgroup未禁用OOM-Killer则唤起OOM-Killer
  4. OOM-Killer针对cgroup及sub-group内进程,根据进程评分寻找受害者杀死

此外,从上面第2点看出,cgroup超出可用内存时仍可能分配成功,这是一种称为Memory Overcommit的技术,允许进程提交多于系统拥有的内存(包括物理内存和虚拟内存)。这是因为大多数进程并不会实际访问其申请的所有内存,因而可以将这些内存继续分配给其他进程使用(有点像货币超发而总是有部分存款不流通,因而通胀不会很明显)。当每个进程真正访问到的内存超过了实际能提供的内存时,就会触发OOM-Killer。只有当关闭了Overvcommit时,才能让malloc在内存不足时返回NULL。而Docker中关闭Overcommit的办法可能就是配置-m--oom-kill-disable

通过以下指令检查容器是否由于超过了Docker设置的最大可用内存而被杀死(参考回答):

docker container inspect --format '{{.State.OOMKilled}}' $container_name

通过检查日志/var/log/messages/var/log/syslog看是否被内核杀死

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值