John Miiler 是ebay团队的高级后端工程师,负责各类项目,包括结帐和支付系统。做为公司摆脱单一业务的努力的一部分,他的团队正试图将业务逻辑一块一块地提取到单独的微服务中。他分享了他的团队如何解决在提取图像处理微服务时遇到的内存使用问题。html
最近提取的microservice是一种图像处理服务,它对图像进行大小调整、裁剪、从新编码和执行其余处理操做。这个服务是一个在Docker容器中使用springboot构建的Java应用程序,并部署到AWS托管的Kubernetes集群中。在实现该服务时,咱们偶然发现了一个巨大的问题:该服务存在内存使用问题。本文将讨论咱们识别和解决这些问题的方法。我将从对通常记忆问题的简要介绍开始,而后深刻研究解决这个问题的过程。java
我觉得是内存泄露。可是在使用内存分析器(MAT)时,当我比较一个快照和另外一个快照的内存使用状况时,个人“惊喜”时刻到来了,我意识到问题在于springboot产生的线程数linux
有许多类型的错误直接或间接地影响应用程序。本文主要讨论其中的两个问题: OOM (内存不足)错误和内存泄漏。调查这类错误多是一项艰巨的任务,咱们将详细介绍在咱们开发的服务中修复此类错误所采起的步骤。web
OOM错误表明第一类内存问题。它能够归结为一个试图在堆上分配内存的应用程序。可是,因为各类缘由,操做系统或虚拟机(对于JVM应用程序)没法知足该请求,所以,应用程序的进程会当即中止。spring
使识别和修复变得很是困难的是,它能够在任什么时候候从代码中的任何位置发生。所以,仅仅查看一些日志来肯定触发它的代码行一般是不够的。一些最多见的缘由是:数据库
有各类各样的开源和开源工具,用于检查进程的内存使用状况以及它是如何演变的。咱们将在后面的部分讨论这些工具。后端
首先让咱们了解什么是内存泄漏。内存泄漏是一种资源泄漏类型,当程序释放丢弃的内存时发生故障,致使性能受损或失败。当一个对象存储在内存中,但运行的代码没法访问时,也可能发生这种状况。springboot
这听起来很抽象,但在现实生活中,内存泄漏到底是什么样子呢?让咱们看一个用垃圾回收(GC)语言编写的应用程序内存泄漏的典型示例。微服务
该图显示了旧的Gen内存(老年代对象)的内存模式。绿线显示分配的内存,紫色线显示GC对Old Gen memory执行扫描阶段后的实际内存使用量,垂直红线显示GC步骤先后内存使用量的差别。工具
正如您在本例中所看到的,每一个垃圾收集步骤都会略微减小内存使用量,但整体而言,分配的空间会随着时间的推移而增加。此模式表示并不是全部分配的内存均可以释放。
内存泄漏有多种缘由。咱们将在这里讨论最多见的。第一个也是最容易被忽视的缘由是 静态变量的滥用 。在Java应用程序中,只要全部者类加载到Java虚拟机(JVM)中,静态字段就存在于内存中。若是类自己是静态的,那么将在整个程序执行过程当中加载该类,所以类和静态字段都不会被垃圾回收。
这个问题的实际解决办法出人意料地简单。咱们选择将默认线程池从200个线程覆盖到16个线程。
未关闭的流和链接是内存泄漏的另外一个缘由。通常来讲,操做系统只容许有限数量的打开的文件流,所以,若是应用程序忘记关闭这些文件流,在一段时间后,最终将没法打开新文件。
一样,容许的开放链接的数量也受到限制。若是一我的链接到一个数据库但没有关闭它,在打开必定数量的这样的链接以后,它将达到全局限制。在此以后,应用程序将没法再与数据库通讯,由于它没法打开新的链接。
最后,内存泄漏的最后一个主要缘由是未释放的本机对象。若是本机库自己有漏洞,那么使用本机库 JNI 的Java应用程序很容易遇到内存泄漏。这些类型的泄漏一般是最难调试的,由于大多数时候,您不必定拥有本机库的代码,而且一般将其用做黑盒。
关于本机库内存泄漏的另外一个方面是,JVM垃圾收集器甚至不知道本机库分配的堆内存。所以,人们只能使用其余工具来解决此类泄漏问题。
好吧,理论够了。让咱们看一个真实的场景:
现状
如简介部分所述,咱们一直致力于图像处理服务。如下是开发初始阶段内存使用模式的外观:
在这张图中,Y轴上的数字表示内存的GiBs。红线表示JVM可用于堆内存(1GiB)的绝对最大值。深灰色线表示一段时间内的平均实际堆使用量,灰色虚线表示随时间推移的最小和最大实际堆使用量。开头和结尾处的峰值表示应用程序被从新部署的时间,所以在本例中能够忽略它们。
该图显示了一个明显的趋势,由于它从大约须要的300MB堆开始,而后在短短几天内增加到超过800MiB,而在Docker容器中运行的应用程序将因为OOM而被杀死。
为了更好地说明这种状况,让咱们也看看在同一时间段内应用程序的其余指标。
看看这个图,内存泄漏的惟一迹象是堆使用率和GC旧gen大小随着时间的推移而增加。当堆空间使用量达到1GiB时,运行Docker容器的Kubernetes pod就要被杀死了。每个其余指标看起来都很稳定:线程数一直保持在略低于40的水平,加载的类的数量也很稳定,非堆的使用也很稳定。
这些图表中惟一缺乏的变量是垃圾收集时间。它与堆上分配的内存成比例增长。这意味着响应时间愈来愈慢,应用程序运行的时间越长。
咱们试图解决这个问题的第一步是确保全部的流和链接都关闭了。有些角落的案子咱们一开始没有涉及。然而,一切都没有改变。咱们观察到的行为和之前彻底同样。这意味着咱们必须更深刻地挖掘。
下一步是查看本机内存的使用状况,并确保最终释放全部分配的内存。咱们用来为服务作重载的 OpenCV 库不是java库,而是本地C++库。它提供了一个能够在应用程序中使用的Java本机接口。
由于咱们知道OpenCV有可能泄漏Java本机内存,因此咱们确保全部OpenCV Mat对象都被释放,并在返回响应以前显式地调用GC。仍然没有明确的泄漏指示器,内存使用模式也没有任何变化。
到目前为止尚未明确的指示,是时候用专用工具进一步分析内存使用状况了。首先,咱们研究了内存分析器工具中的内存转储。
第一个转储是在应用程序启动后生成的,只有几个请求。第二个转储是在应用程序达到1GiB堆使用率以前生成的。咱们分析了在这两种状况下分配的内容和可能引发问题的内容。乍一看没有什么不寻常的事。
而后咱们决定比较堆上最须要的内存。令咱们惊讶的是,堆上存储了至关多的请求和响应对象。这是“bingo”时刻。
深刻研究这个内存转储,咱们发现堆上存储了44个响应对象,比初始转储中的响应对象要高得多。这44个响应对象实际上都存储了本身的 launchDurlClassLoader ,由于它位于一个单独的线程中。每一个对象的保留内存大小都超过 3MiB 。
咱们容许应用程序为咱们的用例使用不少的线程。默认状况下,springboot应用程序使用大小为200的线程池来处理web请求。这对于咱们的服务来讲太大了,由于每一个请求/响应都须要几MB的内存来保存原始/调整大小的图像。由于线程只是按需建立的,因此应用程序开始时的堆使用量很小,但随着每一个新请求的增长,使用量愈来愈高。
这个问题的实际解决办法出人意料地简单。咱们选择将默认线程池从200个线程减小到16个线程。这就完全解决了咱们的内存问题。如今堆终于稳定了,所以GC也更快了。
在调查和排除此问题的过程当中,咱们使用了几个被证实是必不可少的工具:
咱们手头上的第一个工具是针对JVM度量的DataDog APM仪表板,它很是容易使用,容许咱们得到上面的图形和仪表板。
咱们用来分析堆使用率和本机内存使用状况的另外一个工具是jemalloc库的使用状况来分析对malloc的调用。为了可以使用jemalloc,须要使用apt get install libjemalloc dev进行安装,而后在运行时将其注入Java应用程序:
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so MALLOC_CONF=prof:true,lg_prof_interval:30,lg_prof_sample:17 java [arguments omitted for simplicity ...] -jar imageprocessing.jar
总而言之,我觉得是内存泄漏。可是最后发现问题出在springboot生成的线程数上。
原文连接:http://javakk.com/982.html
若是以为本文对你有帮助,能够点赞关注支持一下