深入理解JVM - 如何排查分区溢出问题
前言
这篇文章会接续上一篇关于分区溢出的案例实战内容再次补充几个OOM的案例,本文不再讲述新内容,以案例实战为主,希望这些案例能帮助同学们了解到更多JVM关于OOM溢出的排查套路。
概述
- Jetty的底层机制是如何造成直接内存溢出的?如何从现象看到代码设计缺陷?
- 线程假死应该如何处理?内存使用率过高会有那些原因?这里将通过一个案例讲述常见分析套路。
- 队列是如何造成JVM堆溢出的,一个简单案例介绍队列数据结构设计的重要性。
案例实战
第一个案例:Jetty的如何造成直接内存溢出的?
案发现场:
首先说明这种场景并不多见,这里也是搜集到一个不错的案例,这个项目比较特殊,使用的不是常见的Tomcat服务器而是使用的Jetty服务器,在项目上线的时候突然遇到了一个报警,此报警的内容是某一台服务的内容突然不能进行访问了。
此时毫无疑问第一时间去线上查看日志,服务器挂掉很有可能是OOM了,查看一下日志之后发现了如下的内容:
毫无疑问这是一个直接内存的溢出Direct buffer memory。上网搜索之后我们得知,直接内存也叫做堆外内存,不受JVM的堆限制,而是由本机的操作系统进行直接管理。接下里我们来看下为什么会出现直接内存的溢出。
初步排查:
这里有必要补充一下Jetty是个什么玩意:我们可以简单理解为是和Tomcat服务器一样是一个WEB容器,他由Eclipse基金会组织进行维护,Jetty也是使用JAVA代码写的,所以也可以像Tomcat一样直接部署,同时和Tomcat一样是一个JVM进程,在Jetty启动的时候同样会和Tomcat一样监听某一个端口,然后你也可以通过发送请求给Jetty的形式,完成MVC转发等一系列的操作。
既然直接内存会发生溢出,代码上面没有使用到和NIO或者Netty相关的API内容,也没有做直接内存的分配操作,那么经过反复排查最终可以认为是Jetty在捣鬼,至于他为什么要使用Direct Memory我们不需要关注,这里涉及一个JVM的设计缺陷,下面我们会分析关于直接内存理解和操作方式:
对于jetty的直接内存溢出感兴趣的可以看下这一篇文章:Jetty/Howto/Prevent Memory Leaks,下面截取了一段相关内容机翻过来了:
直接字节缓冲区 另一个与 jvm 错误相关的问题是本机内存耗尽。需要注意的症状是进程大小不断增长,但堆使用量保持相对稳定。本机内存可以被很多东西消耗,JIT 编译器是其中之一,而 nio ByteBuffers 是另一个。 Sun 错误编号 #6210541 讨论了一个仍未解决的问题,即 jvm 本身在某些情况下分配了一个从不进行垃圾回收的直接 ByteBuffer,从而有效地消耗了本机内存。 Guy Korland 的博客在这里和这里讨论了这个问题。由于 JIT 编译器是本机内存的一个消费者,因此可用内存的缺乏可能会在 JIT 中表现为 OutOfMemory 异常,例如“线程“CompilerThread0”中的异常 java.lang.OutOfMemoryError: requests xxx bytes for ChunkPool::allocate. Out交换空间?”
默认情况下,如果配置了 nio SelectChannelConnector,Jetty 将为 io 分配和管理自己的直接 ByteBuffers 池。它还通过 DefaultServlet 设置将 MappedByteBuffers 分配给内存映射静态文件。但是,如果您禁用了这些选项中的任何一个,您可能容易受到此 jvm ByteBuffer 分配问题的影响。例如,如果您使用的是 Windows,您可能已经禁用了 DefaultServlet 上静态文件缓存的内存映射缓冲区的使用,以避免文件锁定问题。
首先如果我们想在绕过JAVA的堆在堆外使用一块本机操作的系统的内存,就需要使用一个叫做 DirectByteBuffer的类,我们可以使用这个类来完成直接内存的构建,虽然这个对象在构建的时候在堆里面,但是实际上这个对象一旦构建同时会在堆外的内存(操作系统)中构建一个同样的对象跟他进行关联,这里这两块内存可以想象为一个二级指针的关系,用形象的理解就是我们拥有一张藏宝图,我们在藏宝图中标记宝藏,但是实际上的对象在宝藏当中,但是我们可以通过藏宝图就可以操作宝藏里面存放的内容,是不是很有意思。
那么你可能会想,这一块内容如何释放呢?其实也很好理解,当你不再引用DirectByteBuffer这个对象的时候,这个对象自然会变成垃圾对象,而当他变成垃圾对象被回收的时候自然也把对应的“宝藏”也一并的进行回收。
问题就出现在这里,当你引用越来越多的直接内存映射的堆对象,但是这些对象又一直没有进行释放,久而久之,直接内存也会不够用,这时候又不能进行回收,那么只能抛出OOM的异常了!
一般情况下我们可以认为JVM在瞬间分配过多直接内存的情况下可能会导致直接内存溢出,但是这个系统是这种情况么?从上面的案发现场可以看到,明显不是这么一回事,那么这个案例又是这么个情况呢?
案例分析:
我们来继续分析案例,虽然不是瞬间分配大量对象引发的错误,但是问题原因肯定还是直接内存不断的积压导致的,既然这个直接内存对应了堆中的一个映射内容,那么肯定也脱不开堆内存这一块,那么会不会是这些对象压根没有回收过。很有可能,顺着这个思路,可以发现包含了这些直接内存的映射的对象在新生代回收之后居然没有被回收掉,并且由于Survior区域刚好放不下而直接进入到了老年代,但是虽然这些对象进入了老年代,但是由于进入的对象很少,也不会触发老年代回收!十分坑爹,所以这里从结果来看,居然又是对象直接进入老年代引发的问题!
从结果来看,正是堆中映射堆外内存的对象长时间没有被回收,构建的对象同样在YGC之后进入老年代,这样的多次回收之后引用对象越来越多,但是由于老年代没有占用满也不会触发Full Gc,最终导致了堆外内存的溢出。
NIO没有考虑过回收直接内存?
当然考虑过,在java.nio.Bits
源码包下面的reserveMemory
有这么一段代码,里面居然有一个System.gc()
,这种做法虽然确实有可能触发Old GC,但是这里实在是要吐槽一句这种做法是错误并且十分不负责任的!在之前的案例中我们有一个案例介绍过由于System.gc()
的频繁调用,导致的FULL GC过于频繁的问题,所以这里一旦在JVM参数中禁止Sytem.gc()
,这段代码就完全失效了。
这里不得不怀疑写这一段代码的人在偷懒,希望后续的JDK在NIO这一块可以修复这个问题。
// These methods should be called whenever direct memory is allocated or // freed. They allow the user to control the amount of direct memory // which a process may access. All sizes are specified in bytes. static void reserveMemory(long size, int cap) { // .... System.gc(); // .... }
解决办法:
- 对症下药,既然是新生代当中的Survior区域放不下将要成为垃圾但是暂时存活的对象,那么自然需要扩大新生代的堆大小以及Survior区域的大小,保证在下一次YGC之前把这些直接内存映射的堆中对象给回收掉,确保直接内存不会不断累积。
- 虽然可以放开关于禁止**System.gc()**的参数限制,但是为了你的系统着想,还是使用第一种方法比较稳妥一些,System.gc()是一个臭名昭著的方法,至于他没有被标记未过期是因为还有很多的代码在使用这个方法,但是我们开发的时候应该完全避免使用这个方法。
第二个案例:线程假死应该如何处理
案发现场:
这个案例是一个非常普通的Tomcat以及WEB系统,但是在在某一天突然报告说服务会出现 假死的情况,相当于服务此时是不可使用的,有点类似上面的下游服务宕机的问题。但是这个案例又不一样,过了一会儿又可以访问了,也就是意味着这种假死只是在一段时间内。
初步排查:
遇到这种问题需要用上一些Linux的技巧,注意这里看日志是没有什么效果的,因为并不是OOM的问题,所以这里使用TOP命令看一下进程对于内存和CPU的使用量很有必要。
如果出现接口上述的假死问题,一般可以按照下面这两种思路进行排查:
- 这个服务可能要使用大量的内存,内存却无法释放,导致频繁的GC
- CPU的负载太高了,进程直接把内存的资源用满了,也就是我们日常使用系统过程中的CPU负荷过高页面卡死问题,最终导致你的服务线程无法得到CPU的执行权,然后进程也会被CPU给干掉,也就无法响应接口的请求了。
经过了上面的排查之后,这时候就发现CPU耗费很少很少,但是对于内存使用率却达到了50%!这肯定是存在异常的,这里补充说明一下机器这是一台4G8核的机器,JVM的可以使用4-6G左右的内存,堆的空间大概3-4G左右,所以这时候如果JVM进程使用了50%的内存,意味着JVM不仅把系统分给它的内存用了一大半。系统自己的内存也不够用了!
内存使用率高会发生什么?
这里我们来盘点一下如果系统的内存使用率过高JVM会发生什么情况?
- 内存使用率居高不下,频繁的Full Gc,Gc带来的Stop World的问题
- 内存使用率过多,JVM发生OOM
- 内存使用率过高,进程可能因为申请内存不足,导致操作系统直接杀进程。
那么上面的排查和这几个点那个关系比较大呢?
案例分析
根据上面的内容,我们来一一分析一下这些情况:
首先,第二点发生OOM的情况我们可以直接排除,因为这个案例的情况是假死,但是日志排查并没有OOM的情况,所以可以不再考虑。接着我们来看下第一点和第三点。
首先是第一点,是关于频繁Full GC和Stop World的,然而实际排查的过程中GC的耗时每次也就几百毫秒,所以虽然GC的频率高,但是没有占用非常多的时间,属于正常的情况。
最后,我们重点来看下第三点,第三点说的是申请内存不足导致杀进程的问题,这里需要提醒一点的是整个系统会有自动化检查和启动脚本,当服务宕机的时候会自动启动,很贴近第三点的问题,就是杀完进程之后,系统又自动启动之后又自动正常了。
谁占用了许多内存
这里单独再开一个小节说明一下为什么会占用这么多内存,排查过程这里不再细说,总之就是通过MAT等工具分析得知有一个对象长期占用并且没有办法进行回收,那就是 自定义类加载器,工程师自己开发了一个类加载器,但是由于没有控制好逻辑导致线程创建了大量的类加载器不断的加载,最后导致这些大量对象积压在老年代但是Full Gc发现存在引用又不能回收掉。
解决办法:
此案例问题是频繁创建类加载器导致Full Gc频繁又没法回收,所以最终的解决办法就是修改代码保证不要重复创建类加载器即可。
第三个案例:同步系统的溢出问题
案发现场:
这个案例是一个同步系统,负责从一个系统向另一个系统进行数据的同步操作,中间使用kafka中间件进行生产和消费的工作。
问题是什么呢?这个问题是一个OOM的问题,在OOM之后需要手动进行重启,结果重启之后又出现了OOM,最终Kafka的数据量越来越大,GC的频率也越来越高,最终导致系统宕机OOM。
初步排查:
JVM堆内存溢出无非就是两种情况,要么是堆内存进行Full GC之后依然有很多的存活对象,要么是一瞬间突然产生一大堆的对象在堆中无法存放导致OOM,这两者情况都是有可能的。
这里再分析下,这个案例是一段时间之后才溢出的,同时即使要处理的数据变多也并不是一旦启动就立马溢出,而是OOM的频率变高了而已。这么看来,可以确认是由于存活对象过多赖在内存里面导致处理数据加载到堆之后老年代又放不下立马就FULL GC了,然后FULL GC之后发现还是很多对象,最终老年代累积到100%,结果就只能OOM了。
案例分析:
我们使用Jstat进行案例的分析,果然发现Full Gc之后对象没有进行回收,当老年代空间为100%之后,对象无法完成分配最终只能溢出并且停止JVM进程了。
这里依然使用了MAT的工具进行分析,这个分析在专栏之前的案例进行过图解分析,这里就不在啰嗦了。
接下来我们来分析下突然泄露的根本原因,这时候需要注意下Kafka这个消息队列了,Kafka是什么东西这里不再多说,它在案例中主要的工作是不断推入两边的数据进行数据的生产消费的同步操作。
重点来了,我们都知道消费数据可以一次性消费几百条的,因此这时候就有开发人员为了方便消费,把消费的内容设置为了List,并且每一个List有几百条数据。这种设计结构在消费的时候有什么情况呢?这里做一个比喻就是传送带上的产品,本来每一道流程的负责人只需要加工传送带上的一份数据,但是使用这个结果就好比在每一份传送带的产品上挂着100多个产品要进行处理!这样消费方毫无疑问是消费不过来的,而生产方又在不断的推送数据,最终只能罢工(OOM)了。
解决办法:
这是一个典型的生成和消费速率不对等的案例,这里的解决办法就是把队列设置为阻塞队列,比如设置1024个大小,一旦队列满了,生产方就会停止生成并且阻塞监听这个队列,当队列一旦有空间再开启消费工作,这样消费就可以及时处理,也就不会造成对象积压在堆内存而没办法回收了。
总结
这一次又是三个案例,这里用三个不同情况的案例,用系统分析的角度去分析他们的细节部分,这里还是再提一点,线上OOM的情况是千奇百怪的,希望学习这些案例更多的是学习解决思路而不是去背案例,因为现实情况下既有可能是资源分配问题,又有可能是系统代码问题,深圳有可能是配置参数有问题导致系统宕机,总之问题是千奇百怪的,到目前为止也没用很万金油的解决方案,但是通过学习这些案例,我们有了一定的理论基础,将来遇到问题的时候,可以很快的回想案例并且进行避坑。
写在最后
到此关于案例实战的部分就已经完全结束了,从专栏的下一篇开始,将会稍微深入一些JVM的底层,当然这些知识还是从书上总结来的,如果觉得理解有难度建议阅读一下周大神的《深入理解JVM虚拟机第三版》。
最后推荐一下个人的微信公众号:“懒时小窝”。有什么问题可以通过公众号私信和我交流,当然评论的问题看到的也会第一时间解答。