背景

我们的springboot项目是通过k8s部署的,我们使用了G1垃圾回收器作为jvm的垃圾回收器,同时也配置了如下的各种应对jvm oom崩溃时的参数;在服务上线的一周左右时间,我们发现这个服务出现了oom重启,于是开启了我漫长的问题追踪过程;

-XX:+UseG1GC -XX:MaxGCPauseMillis=50 
-XX:InitiatingHeapOccupancyPercent=45 
-XX:+ParallelRefProcEnabled
-XX:+DisableExplicitGC 
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/app/logs 
-XX:+PrintAdaptiveSizePolicy 
-XX:+PrintTenuringDistribution 
-verbose:gc 
-XX:+PrintGCDetails 
-XX:+PrintGCApplicationStoppedTime
-XX:+PrintGCApplicationConcurrentTime 
-XX:+PrintGCDateStamps 
-Xloggc:/app/logs/gc.log

1、从websocket开始

由于我们的业务使用了websocket,并且通过websocket传输给springboot大对象(MB级别),于是乎怀疑是不是前端操作太频繁,导致堆内存溢出了,但难搞的是服务oom的时候没有内存dump日志,所以只能实时观察了

1.1、上工具

网络上很多可以查看jvm虚拟机的工具,包括想arthas、visualvm等等,或者你也可以自己使用jmap、jstat等等命令来观察,具体命令可以问一下chatgpt;我个人偏爱jprofiler这款工具(简单直观、功能强大),于是开始搭建jprofiler,本地先安装好jprofiler(自行破解),然后需要在pod镜像里边加上相关agent和so文件外加启动命令,才能远程查看;agent和so文件下载地址如下(注意和你安装的版本保持一致,比如你安装的jprofiler是12.0.4,那么你应该下载12.0.4版本的agent和so文件):https://www.ej-technologies.com/download/jprofiler/files
从这个地址里边找到linux系统的包,下载下来;把里边的红框的两个文件,拷贝到你的java项目的对应目录下,如下图;

image.png
image.png

然后编写Dockerfile的时候把这个目录加到镜像里面去,如下
ADD bin/jprofiler /app/jprofiler

在java启动脚本上加上

-agentpath:/path/to/libjprofilerti.so=port=8849,nowait

再在service里边配置nodePort,target指向这个8849,外部暴露一个nodePort就搞定了;
打开你的jprofiler,链接你的nodePort ip和端口

image.png
1.2、开始操作

我在前端创建了一个特别大的json,然后短时间内保存多次,果不其然,发现堆内存很快就oom了,然后pod被kill掉了(我们的pod配了8G的-Xmx),还没来得及dump就被k8s干掉了;问题找到,前端在存储json的时候把图片搞成了base64,换成上传地址后json变得很小,问题解决,皆大欢喜
你以为这就完了?完了我就不写这篇博客了
问题解决后,又是一次上线,又是一周左右时间,突然又oom了,我擦;再次排查,发现没有特大对象了啊!

2、是堆外内存吗?

由于使用了socketIO,底层是netty,那肯定会用到直接内存啊,于是打开jprofiler-MBeans,发现direct使用了不到300MB,而且会随着时间被回收;mapper里面都是0,没有消耗;

image.png

由于不放心这个jprofiler(后来验证人家没问题),我在代码里也开了一个接口来查询直接内存,结果也是一样;
然后我还是觉得这俩是不是都有问题,然后我就又重新调整了一下jvm参数

-XX:MaxDirectMemorySize=256M

同时把pod的limit调整成16G,这样有一半空间可以放直接内存(以防我这个参数也不好使)
又观察了2周,没有oom,进pod里边瞅瞅吧,我草,top命令执行后,Java进程的Res已经快打满16GB了,怎么个事儿?远程查看一下,发现nio的direct还是很小啊?内存花哪儿了?

3、是metaspace吗?

于是又研究了一下metaspace(忽略这里边的数字,当时看比这个大多了)

image.png

发现这个玩意儿也正常,就是max值设置了-1,这说明最大值可以无限大,得,再加俩参数;

-XX:MetaspaceSize=512M 
-XX:MaxMetaspaceSize=512M

时间又过去一周,我们瞅瞅吧,看看怎么个事儿?
还是一个效果,我擦了,我看堆内存都正常、直接内存也正常、metaspace也正常;没头绪了

4、难道是线程栈?

线程里一共有300多个线程,每个512K,就算1MB也才300多MB,不对啊

5、jvm还用了别的内存吗?

经过一番搜索,说是这个命令可以查看jvm所有的内存使用

jcmd 12 VM.native_memory summary

我特么配完不好使,真尴尬,最后经过一番搜索,在stackoverflow里边搜到了答案,得把-XX:NativeMemoryTracking=summary 这个玩意直接写到java后面,注意是紧紧挨着,不能写到边;
在pod里执行,只关注里边的committed就行了,里边显示了堆、class、现成、code、GC、编译器等等jvm使用的内存;发现总和+直接内存也没有top中的res大

jcmd 12 VM.native_memory summary scale=MB
12:

Native Memory Tracking:

Total: reserved=6184MB, committed=5064MB
-                 Java Heap (reserved=4096MB, committed=4096MB)
                            (mmap: reserved=4096MB, committed=4096MB) 
 
-                     Class (reserved=1252MB, committed=254MB)
                            (classes #36872)
                            (malloc=14MB #227004) 
                            (mmap: reserved=1238MB, committed=240MB) 
 
-                    Thread (reserved=159MB, committed=159MB)
                            (thread #302)
                            (stack: reserved=157MB, committed=157MB)
                            (malloc=1MB #1802) 
 
-                      Code (reserved=266MB, committed=152MB)
                            (malloc=22MB #25451) 
                            (mmap: reserved=244MB, committed=130MB) 
 
-                        GC (reserved=212MB, committed=212MB)
                            (malloc=28MB #75928) 
                            (mmap: reserved=184MB, committed=184MB) 
 
-                  Compiler (reserved=1MB, committed=1MB)
                            (malloc=1MB #3160) 
 
-                  Internal (reserved=133MB, committed=133MB)
                            (malloc=133MB #121188) 
 
-                    Symbol (reserved=43MB, committed=43MB)
                            (malloc=39MB #429206) 
                            (arena=4MB #1)
 
-    Native Memory Tracking (reserved=14MB, committed=14MB)
                            (tracking overhead=14MB)
 
-                   Unknown (reserved=8MB, committed=0MB)
                            (mmap: reserved=8MB, committed=0MB)
1.  Java Heap:
    •   reserved=4096MB, committed=4096MB
    •   这是 JVM 堆内存,表示分配给 Java 对象的内存。
    2.  Class:
    •   reserved=1252MB, committed=254MB
    •   这是元空间(Metaspace)的内存使用情况,元空间用于存储类的元数据。
    3.  Thread:
    •   reserved=159MB, committed=159MB
    •   这是线程栈的内存使用情况。
    4.  Code:
    •   reserved=266MB, committed=152MB
    •   这是代码缓存,用于存储 JIT 编译的代码。
    5.  GC:
    •   reserved=212MB, committed=212MB
    •   这是垃圾回收相关的内存使用情况。
    6.  Compiler:
    •   reserved=1MB, committed=1MB
    •   这是编译器相关的内存使用情况。
    7.  Internal:
    •   reserved=133MB, committed=133MB
    •   这是 JVM 内部使用的内存。
    8.  Symbol:
    •   reserved=43MB, committed=43MB
    •   这是符号表相关的内存。
    9.  Native Memory Tracking:
    •   reserved=14MB, committed=14MB
    •   这是 NMT 本身的内存开销。
    10. Unknown:
    •   reserved=8MB, committed=0MB
    •   未知的内存使用。

听说docker使用的不是top的res,然后我又查看了/sys/fs/cgroup/memory 里边的memory.stat,唉,一个吊样;
查到这,把jvm使用的所有内存都翻了个遍,发现也没有RES使用的多,继续搜吧

6、有些博客写的挺好的

第一篇另一篇
比如这两篇,我按照博客里边的说明,使用pmap -x java进程id | sort -nrk3|less,查看了一下java进程使用的内存,发现总内存确实和RES一样大,博客里边说有很多64MB大小的内存块,和ARENA有关,但是我们的pmap里边,不是64MB大小的内存块,有很多20多MB大小的,感觉也不太像,也执行了里边的命令查看了内存块,发现的内容也没什么营养,于是就放弃了;

7、按照博客说的,上async-profiler

https://github.com/async-profiler/async-profiler
自己打了个基础镜像,具体过程我就不写了,大家自己动手操作吧,给我生成了一堆火焰图,发现有的类确实使用很频繁,比如iometers相关,后来发现是我们Prometheus埋点做的,每次都调用,然后发现我们使用的安全监控的一个javaagent,也频繁分配内存,反复确认后不是这些问题,又走到了死胡同;
期间还尝试了Facebook的jemalloc,看了内存分配链路,也是没分析出啥问题;

8、国外的博客写的更细致

https://blog.malt.engineering/java-in-k8s-how-weve-reduced-memory-usage-without-changing-any-code-cbef5d740ad
这里边给我详细解释了下Arena这个空间,于是结合chatgpt我又深入研究了一下;
原来:
java底层会使用mmap和brk等方法来分配内存

image.png

这个ARENA就在堆里面;Arena里边由一堆chunk组成,而在Arena顶部的chunk空闲的时候才可能被回收,在中间的chunk 如果空闲就形成了”气泡“,我们的服务pod里边查询cpu数量有32个,Arena的数量默认是cpu核数的8倍,也就是256个,如果频繁调用Arena分配内存,比如上传文件、调用直接内存等,会在很多的Arena里边形成气泡,这些气泡就是内存碎片,同样占用内存,这时候如果是小数据量还可以复用,但如果一直是大的那种文件上传,可能刚传了10MB的-刚空闲-小数据占了1KB-再传10MB发现没10MB的空闲-再申请10MB,出现这种循环的话内存就一直持续上涨;
Arena 1
  ├── Chunk 1
  ├── Chunk 2
  └── Top Chunk (Chunk 3)

Arena 2
  ├── Chunk 1
  ├── Chunk 2
  └── Top Chunk (Chunk 3)
...
Arena 256
  ├── Chunk 1
  ├── Chunk 2
  └── Top Chunk (Chunk 3)

9、解决办法

  • 最简单的办法就是在pod启动的时候增加环境变量,你可以加到你的Dockerfile里边:
    ENV MALLOC_ARENA_MAX=1,或者其他方式也行,把ARENA的个数变小,但仍然可能出现内存”气泡“,并且会导致多个线程争用同一个ARENA,进而导致性能下降;
  • 另一种方法就是使用Facebook的jemalloc或者google的tcmalloc,我使用的是jemalloc
    安装方法如下(是个dockerfile,你也可以自己打一个基础镜像);
FROM 基础镜像
# 安装必要的依赖
RUN yum update -y && \
    yum install -y wget gcc make tar bzip2 glibc-locale-source glibc-langpack-en ghostscript java-1.8.0-openjdk-devel.x86_64
# 下载并安装jemalloc
RUN wget https://github.com/jemalloc/jemalloc/releases/download/5.2.1/jemalloc-5.2.1.tar.bz2 && \
    tar -xjf jemalloc-5.2.1.tar.bz2 && \
    cd jemalloc-5.2.1 && \
    ./configure && \
    make && \
    make install && \
    cd .. && \
    rm -rf jemalloc-5.2.1 jemalloc-5.2.1.tar.bz2 && \
    localedef -i en_US -f UTF-8 en_US.UTF-8
# 设置环境变量
ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so
ENV LD_LIBRARY_PATH=/usr/local/lib
# 其他待编写的

10、为什么jemalloc就能解决内存气泡?

jemalloc的内存划分:

jemalloc
├── Arena 1
│   ├── Small Chunk 1 (2 MB)
│   │   ├── Run 1 (for Bin 1)
│   │   │   ├── Region 1 (8B)
│   │   │   ├── Region 2 (8B)
│   │   │   └── ...
│   │   ├── Run 2 (for Bin 2)
│   │   │   ├── Region 1 (16B)
│   │   │   ├── Region 2 (16B)
│   │   │   └── ...
│   │   └── ...
│   ├── Small Chunk 2 (2 MB)
│   │   ├── Run 1 (for Bin 1)
│   │   ├── ...
│   │   └── ...
│   ├── Large Chunk (10 MB)
│   └── ...
├── Arena 2
│   ├── Small Chunk 1
│   │   ├── Run 1
│   │   ├── Run 2
│   │   └── ...
│   ├── Small Chunk 2
│   │   ├── Run 1
│   │   ├── Run 2
│   │   └── ...
│   ├── Large Chunk
│   └── ...
└── ...
  1. 线程选择 Arena:
    • 每个线程被分配一个 arena,减少锁争用。新的内存分配请求会在该 arena 中进行。
  2. 查找适当的 Bin:
    • 根据请求的内存大小,jemalloc 会在 arena 中找到对应的 bin。bin 管理特定大小的内存块(由 size class 决定)。
  3. 分配内存块:
    • jemalloc 从 bin 中分配一个内存块给请求者。如果 bin 中没有足够的空闲块,jemalloc 会从 chunk 中分配新的内存块给 bin。
  4. 扩展 Chunk:
    • 如果当前 chunk 无法满足 bin 的需求,jemalloc 会从操作系统请求新的 chunk(通常是通过 mmap 系统调用)。
  5. 释放内存:
    • 当 Java 进程释放内存时,jemalloc 会将内存块返回到相应的 bin 中。如果 chunk 中的所有内存块都被释放,jemalloc 可能会将 chunk 返回给操作系统。
    6、大对象的分配:
    • 大对象一般直接从操作系统申请chunk,而不是bin分配;释放后会通过unmap返还给操作系统
Logo

K8S/Kubernetes社区为您提供最前沿的新闻资讯和知识内容

更多推荐