背景
我们的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项目的对应目录下,如下图;
然后编写Dockerfile的时候把这个目录加到镜像里面去,如下
ADD bin/jprofiler /app/jprofiler
在java启动脚本上加上
-agentpath:/path/to/libjprofilerti.so=port=8849,nowait
再在service里边配置nodePort,target指向这个8849,外部暴露一个nodePort就搞定了;
打开你的jprofiler,链接你的nodePort ip和端口
1.2、开始操作
我在前端创建了一个特别大的json,然后短时间内保存多次,果不其然,发现堆内存很快就oom了,然后pod被kill掉了(我们的pod配了8G的-Xmx),还没来得及dump就被k8s干掉了;问题找到,前端在存储json的时候把图片搞成了base64,换成上传地址后json变得很小,问题解决,皆大欢喜
你以为这就完了?完了我就不写这篇博客了
问题解决后,又是一次上线,又是一周左右时间,突然又oom了,我擦;再次排查,发现没有特大对象了啊!
2、是堆外内存吗?
由于使用了socketIO,底层是netty,那肯定会用到直接内存啊,于是打开jprofiler-MBeans,发现direct使用了不到300MB,而且会随着时间被回收;mapper里面都是0,没有消耗;
由于不放心这个jprofiler(后来验证人家没问题),我在代码里也开了一个接口来查询直接内存,结果也是一样;
然后我还是觉得这俩是不是都有问题,然后我就又重新调整了一下jvm参数
-XX:MaxDirectMemorySize=256M
同时把pod的limit调整成16G,这样有一半空间可以放直接内存(以防我这个参数也不好使)
又观察了2周,没有oom,进pod里边瞅瞅吧,我草,top命令执行后,Java进程的Res已经快打满16GB了,怎么个事儿?远程查看一下,发现nio的direct还是很小啊?内存花哪儿了?
3、是metaspace吗?
于是又研究了一下metaspace(忽略这里边的数字,当时看比这个大多了)
发现这个玩意儿也正常,就是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等方法来分配内存
这个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
│ └── ...
└── ...
- 线程选择 Arena:
• 每个线程被分配一个 arena,减少锁争用。新的内存分配请求会在该 arena 中进行。 - 查找适当的 Bin:
• 根据请求的内存大小,jemalloc 会在 arena 中找到对应的 bin。bin 管理特定大小的内存块(由 size class 决定)。 - 分配内存块:
• jemalloc 从 bin 中分配一个内存块给请求者。如果 bin 中没有足够的空闲块,jemalloc 会从 chunk 中分配新的内存块给 bin。 - 扩展 Chunk:
• 如果当前 chunk 无法满足 bin 的需求,jemalloc 会从操作系统请求新的 chunk(通常是通过 mmap 系统调用)。 - 释放内存:
• 当 Java 进程释放内存时,jemalloc 会将内存块返回到相应的 bin 中。如果 chunk 中的所有内存块都被释放,jemalloc 可能会将 chunk 返回给操作系统。
6、大对象的分配:
• 大对象一般直接从操作系统申请chunk,而不是bin分配;释放后会通过unmap返还给操作系统
所有评论(0)