应用机器内存过高的排查&优化

应用机器内存过高的排查&优化

线上应用机器不乏遇到内存溢出或内存使用率居高不下等问题,该如何精准找到原因并进行分析优化?本文将通过一个实际案例,为大家分享内存使用率过高的排查思路及排查过程的逐步剖析。

目录

  • 1. 问题背景
  • 2. 排查思路
    • 2.1 机器内存利用率指标计算
    • 2.2 为什么内存利用率持续增长且这么高
  • 3. 排查过程
    • 3.1 glibc 64M问题
    • 3.2 线程泄漏
    • 3.3 线程数过多
    • 3.4 单一验证glibc的问题
  • 4. 参考文章
 1. 问题背景 

一句话背景: 我们的tmsx-XX应用sunfire总是频繁告警【内存利用率高】的问题。(告警阈值是90%)

应用机器内存过高的排查&优化

之前:

示例其中一台机器:

可以看到,从2023-3-16 19:00:00 到2023-03-21 11:00:00差不多4-5天的时间,机器内存利用率从发布重启之后的71.96%升高到90.30% ,达到了sunfire设置的告警阈值,升高了差不多20%。

应用机器内存过高的排查&优化

之后:

经过排查优化之后的结果:

应用机器内存过高的排查&优化
 2. 排查思路 

然后内心就有很多疑问❓❓

  • 为什么应用重启之后,内存利用率怎么也这么高?71%, 都是什么在占用呢?

  • 为什么内存利用率一直持续性在增长而且这么高呢?

  • sunfire内存利用率是怎么计算的?分子是什么?分母又是什么?

  • ……

然后接下来, 带着这些疑问,我就开始先给自己答疑解惑..  因为我觉得只有搞懂这些,知道了原因才可以对症下药。

▐ 2.1 机器内存利用率指标计算

sunfire采集方式,通过每分钟在服务器上执行一次tsar指令,获取系统指标的平均值。

集团xflush监控显示的物理内存利用率本质是RSS,即实际使用物理内存Resident Set Size,(指进程虚拟内存空间中已经映射到物理内存的那部分的大小)。

sunfire的内存利用率指的是机器内存利用率而不是jvm维度的。从top上看,这个机器内存利用率基本上就是java进程的内存利用率,下面是java进程内存包含的内容:主要分为堆内存和非堆内存。

应用机器内存过高的排查&优化

注:NMT是JDK自带的内存跟踪工具

下面主要解释了堆外内存:

1)Code Cache 

当JVM将字节码编译成汇编指令时,它将这些指令存储在一个称为Code Cache的特殊非堆数据区域中。

-XX:InitialCodeCacheSize 和 -XX:ReservedCodeCacheSize 可以调整初始值 和最大值。

code cache 包含以下3部分:

  • non-method “非方法”代码堆包含非方法代码,如编译器缓冲区和字节码解释器。此代码类型将永远保留在代码缓存中。

  • profiled “已分析的”代码堆包含经过轻微优化的剖析方法,其生命周期很短。

  • non-profiled “未分析的”代码堆包含完全优化的、未分析的方法,其生命周期可能很长。

2)MetaSpace 参数控制 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m 如果不设置参数,应用启动可能会因为 Metaspace不足而引发的动态扩容,启动时会多次Full GC。

  • Compressed class space

JVM 有个功能是 CompressedOops ,目的是为了在 64bit 机器上使用 32bit 的原始对象指针(oop,ordinary object pointer,这里直接就当成指针概念理解就可以了,不用关心啥是 ordinary)来节约成本(减少内存/带宽使用),提高性能(提高 Cache 命中率), Compressed Class Space 是MetaSpace的一部分。

-XX:CompressedClassSpaceSize:Metaspace 中的 Compressed Class Space 的最大允许内存,默认值是 1G,这部分会在JVM启动时向操作系统申请 1G 的虚拟地址映射,但不是真的就用了操作系统的 1G 内存。

  • Class metadata

存储包含Klass结构,method metadata(方法的字节码、局部变量表、异常表、参数信息等),

常量池,注解, 方法计数器(记录方法被执行的次数,用来辅助 JIT 决策)等等

  • Symbol Table

保留字符串(Interned string) 的引用 与符号表引用在这里。

3)Thread stack

所有线程的线程栈分配的内存大小。在64位操作系统中,通常一个线程默认分配1M。参数-Xss

GC 算法使用的堆外空间 和 Compiler 自身操作需要的空间

4)Internal 不符合前面类别的内存,例如命令行解析器使用的内存、JVMTI、属性等。

5)Direct Memory 直接内存:

属于原生内存的一部分,但共享硬件内的底层缓冲区。 例如,网络适配器或图形显示器内的缓冲区。 这里的目标是减少相同字节在内存中被复制的次数。目前,很多 NIO 框架(如 netty)会采用 Java 的 DirectByteBuffer 类来操作原生内存.(减少数据在heap 和off-heap之间的copy) 

-XX:MaxDirectMemorySize=** 默认是 64M DirectByteBuffer 对象本身会存在heap中,它关联到堆外内存,DirectByteBuffer进入老年代后这部分内存 也可以通过 Full GC 回收。

6)Mapped Memory 通过系统调用mmap()将某个文件或者其一部分映射到内存中。此时只是逻辑地将文件映射到内存中,也就创建了相关数据结构,并没有真正进行数据复制。Java 的 MappedByteBuffer 调用load()方法会将数据加载到内存。force()会强制把数据刷到磁盘。isLoaded()判断是否已经加载。

通过 JNI 分配的 Memory 通过 Java JNI 调用 native code 分配的内存。 

注意: 通过 JVM NMT(NativeMemoryTracking) 来追踪分析堆外内存,只能 Track JVM 自身的内存分配,第三方的 Native 库内存使用无法 Track ;不能 Track JNI 里直接调用 malloc 时的内存分配,这里最典型的就是 ZipInputStream 的场景。NMT 有 5%-10% 的性能开销。

▐ 2.2、为什么内存利用率一直持续性在增长而且这么高呢?

通过相关资料的学习梳理,大概得到以下几种原因:

  • 低版本AJDK使用glibc做内存分配,可能出现内存占用不归还的情况

  • 线程数过多,不合理 

  • 文件打开了没有关闭

  • pandoraboot老版本有未释放内存的问题

 3. 排查过程 

通过梳理分析以上几种原因之后,就看看自己的应用是不是也命中其中的某一种或者某几种呢…

经过分析,tmsx-XX内存持续飙升的原因和上面的前两种有点像,具体排查过程如下。

▐ 3.1 glibc 64M问题

3.1.1 glibc内存分配基本原理

Glibc使用了ptmalloc的内存管理方式,Glibc申请内存时是从分配区申请的,分为主分配区和非主分配区,分配区都有锁,在分配内存前需要先获取锁,然后再去申请内存。

一般进程都是多线程的,当多个线程同时需要申请内存时,如果只有一个分配区,那么效率太低。

glibc为了支持多线程的内存申请释放,会在多个线程同时需要申请内存时根据cpu核数分配一定数量的分配区,将分配区分配给线程。如果线程数量较多,则会出现多个线程争用一个分配区的的情况,这里不展开。

内存申请基本原理:当用户调用malloc申请内存时,glibc会查看是否已经缓存了内存,如果有缓存则会优先使用缓存内存,返回一块符合用户请求大小的内存块。

如果没有缓存或者缓存不足则会去向操作系统申请内存(可通过brk、mmap申请内存),然后切一块内存给用户,如下图所示。

应用机器内存过高的排查&优化

内存释放基本原理:当业务模块使用完毕后调用free释放内存时,glibc会检查该内存块虚拟地址上下内存块的使用状态(fast bin除外)。若其上一块内存空闲,则与上一块内存进行合并。若下一块内存空闲,则与下一块内存进行合并。如下图所示。

应用机器内存过高的排查&优化

若下一块内存时top chunk(top chunk一直是空闲的),则看top chunk的大小是否超过一个阈值,如果超过一个阈值则将其释放给OS,如下图所示。

应用机器内存过高的排查&优化

可参考文章:https://www.51cto.com/article/666913.html

3.1.2 glibc 64M内存问题及原因

内存站岗概念:

内存站岗指的是glibc从OS申请到内存后分配给业务模块,业务模块使用完毕后释放了内存,但是glibc没有将这些空闲内存释放给OS,也就是缓存了很多空闲内存无法归还给系统的现象。

内存站岗原因:

glibc设计时就确定其内存是用于短生命周期的,因此在设计上内存释放给OS的时机是当top chunk的大小超过一个阈值时会释放top chunk的一部分内存给OS。当top chunk不超过阈值就不会释放内存给OS。

那么问题来了,若与top chunk相邻的内存块一直在使用中,那么top chunk就永远也不会超过阈值,即便业务模块释放了大量内存,达到几十个G 或者上百个G,glibc也是无法将内存还给OS的。

对于glibc来说,其有主分配和非主分配区的概念。主分配通过sbrk来增加分配区的内存大小,而非主分配区则是通过一个或多个mmap出来的内存块用链表链接起来模拟主分配区的。为了更清晰的解释内存站岗,下面举个例子来说明主分配区的内存站岗,如下图所示。

应用机器内存过高的排查&优化

如上有(a) (c) (e) (g)内存块正在使用,故而导致了空闲内存(b) (d) (f)无法和top chunk连成一块更大的空闲内存块,glibc的阈值(64位系统默认是128K),尽管目前空闲内存有将近130M,也无法还给OS。

接下来看非主分配区的内存站岗,如下图所示,实际的非主分配区可能有很多个heap,这里假设只有4个heap。

应用机器内存过高的排查&优化

后面在走读代码时发现这是glibc原生机制,同时在查看内存布局时观察到非主分配区大量heap均为free状态。原有机制是先释放heap3,如果heap3有内存在使用,尽管heap0、heap1、heap2的内存都释放了,那也是无法释放给系统。

glibc有多个分配区,每个分配区都几百 M 空闲内存的话,则整个进程占用达到几十个G也就不奇怪了。

3.1.3 检查堆内存

首先看一下启动参数:

应用机器内存过高的排查&优化

应用设置的JVM 堆内存大小是-Xmx4g , RES 达到了6.6g

应用机器内存过高的排查&优化

然后就排查一下是否是堆内存的问题:

 dump了堆信息,但是并没有看到可疑的数据。然后登录 Arthas,使用 dashboard 命令查看当前系统的实时数据面板,例如:服务器thread信息、内存memory、GC回收等情况。

应用机器内存过高的排查&优化

发现各区域的内存占用大小,比如metaSpace、codeheap等都是正常水位。 

堆内存才使用了不到1G,从上图发现,应用程序的堆内存和非堆内存是没有用完的,并且还可以大致计算出应用程序所能达到的最大内存利用率:应用程序最大内存利用率 ≈(Max 堆内存4G + Max 非堆内存1.9G*非堆内存利用率)/ 机器内存 8G + 系统其他占用约 7%  = 65%  < 70%   这与之前从 sunfire 基础监控观察到的82%有较大的差距。所以觉得大概堆外内存发生了泄漏。

3.1.4 检查JDK版本

应用机器内存过高的排查&优化

查看我们的JDK的版本:发现我们的JDK版本是8.2.3,是比较老的AJDK8版本,说明我们是使用glibc分配内存。

通过查询jdk每个版本的相关信息,知道了JDK这个版8.4.8 fp2版本之前是采用glibc分配内存,这个版本之后采用了jemalloc作为内存分配器,解决内存归还问题。

应用机器内存过高的排查&优化

3.1.5 查看内存管理器

执行命令:ldd /opt/taobao/install/ajdk-8_4_7-b187/bin/java查看发现确实使用的是glibc在进行内存管理

应用机器内存过高的排查&优化

3.1.6 查看glibc版本

再查看我们的glibc的版本,发现我们的glibc的版本是2.17。

查阅资料发现,这个和 Linux glibc >= 2.10  之后的版本 使用了内存池有关系,由于glibc使用了内存池之后,应用通过malloc申请的堆外内存,在free 的时候,并不会还给操作系统,而是回到了glibc 维护的内存池中,供下次再使用,避免重复申请。引入了这个内存不能及时归还的问题。

查看版本:ldd –version

应用机器内存过高的排查&优化

3.1.7 查看内存映射详情

通过上面一系列的验证,那大概率是有问题的,继续验证 。 

然后随机选了一台内存利用率相对高的机器。

  • 使用top命令看实际执行情况:

发现使用内存比较大的确实是java进程,我们可以看到:

Java进程的RES物理内存占用6.6G,系统物理内存是8G,占用6.6G,则6.6/8 = 82.5% ,和xflush显示的基本上差不多。但是虚拟内存占用43.1G,这个有点大。

应用机器内存过高的排查&优化
  • 使用pmap查看进程的内存映射详情

命令:sudo pmap -x [pid] > mem.txt

(其中按照RSS排序下):pmap -x 1294 | sort -n -k3。  选取截图如下:

应用机器内存过高的排查&优化

应用机器内存过高的排查&优化

发现确实有很多的64M(圈红的)的虚拟内存块。65400KB/1024 = 64M,但是实际占用RSS(圈红那列的右边一列)是0。觉得,这不就是 linux glibc 中经典的 64M 内存问题么。

大概算了下,堆+非堆+N多64M=应用程序 RSS 的内存,这些 64M 的内存并没有体现在 sunfire 的非堆内存利用率监控中,所以猜测就是这些内存提高了应用的内存利用率。

应用机器内存过高的排查&优化

从截图可以看到总的RSS:6690224KB   6690224KB/1024/1024 = 6.3G,和top命令看到的RSS差不多。

3.1.8 网上实验证实

可参考:https://juejin.cn/post/6854573220733911048

网上有同学做过实验验证内存不及时归还的问题:

实验示例:

程序中先 malloc 了一块 500M 的内存(备注:这里不是一次性malloc,细节可看原文),然后再 malloc 了 1B 的内存(实际上比 1B 要大一点,不过不影响说明),接下来 free 掉那 500M 的内存。

在 free 之前的内存占用如下所示。511976/1024 = 500M

应用机器内存过高的排查&优化

在调用 free 以后,使用 top 查看 RES 的结果如下。

应用机器内存过高的排查&优化

可以看到实际上 glibc 并没有把内存归还给系统。而是放到了它自己的 unsorted bin 中,使用 gdb 的 arenainfo 工具可以看得很清楚。

应用机器内存过高的排查&优化

0x1efe9200 用十进制表示是  520,000,000,正是我们刚刚释放的 500M 左右的内存。

如果把代码中的第二次 malloc 注释掉,glibc 是可以立刻释放内存的。

应用机器内存过高的排查&优化

3.1.9 线下实验证实

经过上面一系列的梳理、理论以及网上的实验验证,大概率可以确认自己这个应用存在这个问题。

然后就开始实践一波了,看看效果了…

修改JDK的版本。ajdk-8.16.20 使用 jemalloc 作为内存分配器。可使用  jemalloc 作为内存分配器的JDK。

步骤1:修改 docker 文件,升级 JDK

应用机器内存过高的排查&优化

步骤2:查看 glibc 是否替换成功:ldd /opt/taobao/java/bin/

应用机器内存过高的排查&优化

步骤3: 解决后的效果:可以观察升级 jdk前后的64M内存块的对比。

可以看出,发布之后,不存在64M的虚拟内存块了。

步骤4:看实际结果

可以看出,发布修复之后,内存利用率降低了15%个点,并且非常的平稳,不再像之前那样高低起伏那么明显。

应用机器内存过高的排查&优化

▐ 3.2 线程泄漏

3.2.1 线程泄漏知识

线程泄漏是指 JVM 里面的线程越来越多, 而这些新创建的线程在初期被使用之后, 再也不被使用了, 然而也没有被销毁. 通常是由于错误的代码导致的这类问题.

一般通过监控 Java 应用的线程数量的相关指标, 都能发现这种问题. 如果没有很好的对这些指标的监控措施, 或者没有设置报警信息, 可能要到等到线程耗尽操作系统内存导致OOM才能暴露出来.

3.2.2 线程泄漏示例

想着是不是线程池创建不规范导致泄漏问题,一查还真的查到了一处!

应用机器内存过高的排查&优化

不规范主要有几处:

1)线程池局部使用,没有shutdown

局部使用线程池,同时设置核心线程不为0,且设置allowCoreThreadTimeOut=false(空闲后不回收核心线程池)会导致什么问题?(想都不用想,核心线程池得不到回收,自然会导致OOM)

使用局部线程池,最后一定要shutdown,否则可能导致不回收核心线程的内存泄漏

2)使用Executors直接创建线程池

阿里的Java开发手册中这样说:“线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式

应用机器内存过高的排查&优化

多线程创建两种方式:

1.通过 Executors 工厂方法创建

2.通过 new ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue) 自定义创建。

相对而言,更建议用第二个创建线程池,Executors 创建的线程池内部很多地方用到了无界任务队列,在高并发场景下,无界任务队列会接收过多的任务对象,严重情况下会导致 JVM 崩溃,一些大厂也是禁止使用 Executors 工厂方法去创建线程池。newFixedThreadPool 和 newSingleThreadExecutor 的主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至 OOM;newCachedThreadPool和 newScheduledThreadPool 的主要问题是线程数最大数是 Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至 OOM。

(3)应用里面直接在方法里面直接创建线程池

首先,我们明确:局部变量new出来的线程池,执行这段代码的程序的每一个线程都会去创建一个局部的线程池。暂且不说每一个线程都去创建线程池是出于什么神奇的目的,首当其冲的线程池的复用的性质就被打破了。创建出来的线程池都得不到复用,那么还有什么必要花费大精力创建线程池?

所以线程池局部使用本身就是不推荐的使用方式!

但是,这个接口没看到有流量调用,所以即使不规范,但是不调用,问题不大!所以这个问题就排除了。

▐ 3.3 线程数过多

3.3.1 找到多余的核心线程

tmsx-XX线程数大,是早就知道的问题。先看下都是什么线程:

pmap -x [pid] 显示 PID对应进程的内存信息

应用机器内存过高的排查&优化

tmsx-XX应用的常驻线程数是1700,其中统计上面的文件,有1600多个是1016KB也就是1M的内存分配,这1600多就是JVM线程。

但是:

  • 为什么线程数这么多?

  • 都是什么线程?

抛给自己两个疑问。对比一个内存水位正常的应用解答一下

tmsx-XXA和tmsx-XX线程数对比,

tmsx-XXA:1300    tmsx-XX:1700   

对比下来practice比order比较多的是消息消费的线程 和 ISS的线程:ConsumeMessageThread_* 和iss-client-executeTask-thread-*

其中tmsx-XX 的 ISS线程数量 :256+8+2 = 266个, 消息消费线程:350个

tmsx-XXA的消息消费的线程212个

  • 350-212 = 138 (practice多出来的消息消费的线程)

  • 138(消息消费)+ 266(ISS) = 404 ,差不多补齐了两个应用线程数量的差距

应用机器内存过高的排查&优化

虽然确实线程相对确实是多了400左右,但是怎么和自己的机器内存增长率相关联呢!虽然我们知道创建线程肯定会占用内存,但是线程都是占用的都是什么内存呢?以及有什么样的关联呢? 

然后就去观察了jvm线程数和内存利用率趋势图。

通过趋势对比,发现整体趋势一致!印证肯定是有关系的,而且和WAITING状态的线程关系比较密切。

因为RUNNABLE和TIMED_WATING两张图差不多,不同的是WAITING的数量有点差别。

应用机器内存过高的排查&优化

对应的内存利用率的图如下:整体是内存利用率和WAITING的线程整体是呈正比的趋势。

应用机器内存过高的排查&优化

3.3.2 jvm线程占内存知识

虽然在 Java 语言中创建线程看上去就像创建一个对象一样简单,只需要new Thread()就可以了,但实际上创建线程远不是创建一个对象那么简单。创建对象,仅仅是在 JVM 的堆里分配一块内存而已;而创建一个线程,却需要调用操作系统内核的 API,然后操作系统要为线程分配一系列的资源,这个成本就很高了,所以线程是一个重量级的对象,应该避免频繁创建和销毁。

1)线程堆栈结构

  • JVM创建线程即分配线程堆栈, 执行过程中存储方法调用栈,线程内存消耗主要是线程堆栈。

  • 堆栈数据结构为Stack,元素为stack frame,  包含基本数据类型、局部变量、对象指针和返回值等。

  • 调用方法即创建并push一个stack frame到stack, 方法递归调用中不断push stack frame到堆栈,调用完成从堆栈中pop stack frame。

应用机器内存过高的排查&优化

2)线程堆栈分配区间

  • 在虚拟机Java方法栈区中分配,属于off-heap memory, 线程堆栈内存使用不影响JVM堆内存。

  • 线程堆栈方法中引用对象属于JVM堆内存

应用机器内存过高的排查&优化

3)JVM启动参数 -Xss

  • JVM启动参数 -Xss 设置线程堆栈分配空间大小,默认JDK8 -Xss为1MB。

    • JVM创建线程即分配 -Xss 指定线程堆栈空间大小。

  • JVM -Xss 设置太小会栈溢出StackOverFlowError,设置太大OutOfMemoryError

    • -Xss 值设置太小,线程栈空间被耗尽,无足够资源分配给新栈帧,会出现java.lang.StackOverFlowError

    • -Xss 值设置太大,同等虚拟内存下可创建线程数变小,极端情况下java.lang.OutOfMemoryError: Unable to create new native thread

结论:

  • JVM创建线程即分配 -Xss 指定大小线程堆栈虚拟内存, 实际占用物理内存由方法调用栈深度决定。

  • JVM -Xss 仅设置线程堆栈分配内存上限, 不能通过调小 -Xss 达成优化JVM线程物理内存消耗目的。

  • NMT观察线程堆栈大小为虚拟内存空间,不是线程实际物理内存消耗。

  • 关联线程堆栈地址与进程虚拟地址块(jstackmem.py),统计虚拟地址块实现物理内存消耗,确定堆栈物理内存消耗。

  • 分析多个应用业务线程堆栈物理内存,集中在100KB-200KB,远小于 -Xss JDK8默认值1MB。

3.3.3 减少核心线程数

  • 比如ISS,因为ISS基本上不用了,ISS配置的线程池是256,这个ISS本身就是要下线的东西,所以创建这么大的核心线程数是浪费的。

然后就把ISS的核心线程数改为了50。

  • 还有其它的浪费线程的线程池,比如线程数的大头,消息消费线程, 一般metaq的默认核心线程数是20,很多的消息消费其实是用不了20的线程池的,大部分10都已经足够,但是还是需要具体topic具体分析。

应用机器内存过高的排查&优化

3.3.4 实践效果

应用机器内存过高的排查&优化

▐ 3.4 单一验证glibc的问题

因为tmsx-XX下图的改变是因为我改动了两个变量的影响,更改jdk版本和减小核心线程数。

应用机器内存过高的排查&优化

为了验证glibc确实有问题,本人使用tmsx-XXA应用做了单一变量验证,下图是只修改了JDK的版本,将内存分配器由glibc更改为jemalloc,通过单一变量也验证了glibc的问题。

应用机器内存过高的排查&优化
 4. 参考文章 

1. https://liam.page/2020/07/17/memory-stat-in-TOP/

2. https://anguslean.github.io/2021/03/11/Engineering/一次线上内存报警的排查/

3. https://mp.weixin.qq.com/s/IvIE_G8nIAwyUjq7EdExPg#at

4. https://juejin.cn/post/6854573220733911048

5. https://zhuanlan.zhihu.com/p/451209598

END
应用机器内存过高的排查&优化



阅读更多好文



本篇文章来源于微信公众号:货拉拉技术

本文来自投稿,不代表TakinTalks稳定性技术交流平台立场,如若转载,请联系原作者。

(0)
上一篇 2024年3月21日 下午2:05
下一篇 2024年4月16日 下午8:02

相关推荐

发表评论

邮箱地址不会被公开。