案例发生现场
有一天突然收到线上的一个报警:某台机器部署的一个服务突然之间就不可以访问了。
此时第一反应当然是立马登录上机器去看一下日志,因为服务挂掉,很可能是OOM导致的崩溃,当然也可能是其他原因导致的问题。
这个时候在机器的日志中发现了如下的一些信息:
nio handle failed java.lang.OutOfMemoryError: Direct buffer memory
at org.eclipse.jetty.io.nio.xxxx
at org.eclipse.jetty.io.nio.xxxx
at org.eclipse.jetty.io.nio.xxxx
过多的日志信息给省略掉了,因为都是非常杂乱的一些信息,也没太大意义,大家关注比较核心的一些信息就可以
上述日志中,最主要的就是告诉我们有OOM异常,但是是哪个区域导致的呢?
居然是我们没见过的一块区域:Direct buffer memory ,而且在下面我们还看到一大堆的jetty相关的方法调用栈
到此为止,仅仅看到这些日志,我们基本就可以分析出来这次OOM发生的原因了。
初步分析事故发生的原因
先给大家解释一个东西:Direct buffer memory
这个东西其实就是堆外内存,顾名思义,他是JVM堆内存之外的一块内存空间,这块内存空间不是JVM管理的,因此之前我们也没怎么多讲这块内容,但是你的Java代码确实是可以在JVM堆之外使用一些内存空间的。
这些空间就叫做Direct buffer memory,如果按英文字面理解,他的意思是直接内存,其实你要这么叫也没问题,但是从字面可以看出来,这块内存并不是JVM管理的,而是“直接”被操作系统管理。
正因为这样,所以其英文名叫做Direct buffer memory,就是直接内存的意思。但是如果我们叫他直接内存,又显得非常的奇怪,所以通常更好的称呼就是“堆外内存”。
另外再给大家解释一个东西:Jetty。这个是什么?
其实你大致可以理解为跟Tomcat一样的东西,就是Web容器
Jetty本身也是Java写的,我们如果写好了一个系统,可以打包后放入Jetty,启动Jetty即可。
Jetty启动之后,本身就是一个JVM进程,他会监听一个端口号,比如说9090
然后你就向Jetty监听的9090端口发送请求,Jetty会把请求转交给你用的Spring MVC之类的框架,Spring MVC之类的框架再去调用写好的Controller之类的代码。
我们先看下面一个图,简单看看Jetty作为一个JVM进程运行我们写好的系统的一个流程:
首先我们可以明确一点,这次OOM是Jetty这个Web服务器在使用堆外内存的时候导致的
也就是说,基本可以推测出来,Jetty服务器可能在不停的使用堆外内存,然后堆外内存空间不足了,没法使用更多的堆外内存了,此时就会抛出内存溢出的异常。
至于为什么Jetty要不停的使用堆外内存空间,大家就暂时先别管那么多了,那涉及到Jetty作为一个Web服务器的底层源码细节。
我们只要知道他肯定会不停的去使用堆外内存,然后用着用着堆外内存不够了,就内存溢出了。
我们接着看下面的图,下面的图里,就体现出来了Jetty不停使用堆外内存的场景。
关于解决OOM问题的底层技术修为的一点建议
讲到这里,我们肯定会有一点很疑惑了:Jetty既然是用Java写的,那么他是如何通过Java代码去在JVM堆之外申请一块堆外内存来使用的?然后这个堆外内存空间又是如何释放掉的呢?
这个东西其实涉及到Java的NIO的底层技术细节,如果大家之前对NIO没什么了解的话,突然看到这个异常,估计是没法继续分析下去了。
因此这里也给大家额外插一句话,其实JVM的性能优化相对还是较为容易一些的,而且基本上整个套路之前也已经给大家都说的很清楚了。
但是如果是解决OOM问题,那么除了一些特别弱智和简单的,比如有人在代码里不停的创建对象最后导致内存溢出这种。其他的很多生产环境下的OOM问题,都是有点技术难度的。
大家如果把整个专栏最后两周的内容都看完了,就会有一个很深的感触,那就是1000个工程师可能会遇到1000种不同的OOM问题。
可能排查的思路是类似的,或者解决问题的思路是类似的,但是如果你要解决各种OOM问题,是需要对各种技术都有一定的了解,换句话说,需要有较为扎实的技术内功修为。
比如昨天的那个案例,就需要你对Tomcat的一些工作原理有一定的了解,你才能分析清楚那个案例。
同样,今天的这个案例,就要求你对Java NIO技术的工作原理有一定的了解才能分析清楚。
这些底层技术积累将会在你的线上系统出现问题的时候,迅速帮助你分析和解决问题。
堆外内存是如何申请的,又是如何释放的?
接着我们继续来看看,这个堆外内存是如何申请和释放的?
简单来说,如果在Java代码里要申请使用一块堆外内存空间,是使用DirectByteBuffer这个类,你可以通过这个类构建一个DirectByteBuffer的对象,这个对象本身是在JVM堆内存里的。
但是你在构建这个对象的同时,就会在堆外内存中划出来一块内存空间跟这个对象关联起来,我们看看下面的图,你就对他们俩的关系很清楚了。
因此在分配堆外内存的时候大致就是这个思路,那么堆外内存是如何释放的呢?
很简单,当你的DirectByteBuffer对象没人引用了,成了垃圾对象之后,自然会在某一次young gc或者是full gc的时候把DirectByteBuffer对象回收掉。
只要回收一个DirectByteBuffer对象,就会自然释放掉他关联的那块堆外内存,我们看看下面的图就知道了。
为什么会出现堆外内存溢出的情况?
那么大家现在应该很清楚了,一般什么情况下会出现堆外内存的溢出?
很简单,如果你创建了很多的DirectByteBuffer对象,占用了大量的堆外内存,然后这些DirectByteBuffer对象还没有GC线程来回收掉,那么就不会释放堆外内存!
久而久之,当堆外内存都被大量的DirectByteBuffer对象关联使用了,如果你再要使用更多的堆外内存,那么就会报内存溢出了!
那么,什么情况下会出现大量的DirectByteBuffer对象一直存活着,导致大量的堆外内存无法释放呢?
有一种可能,就是系统承载的是超高并发,复杂压力很高,瞬时大量请求过来,创建了过多的DirectByteBuffer占用了大量的堆外内存,此时再继续想要使用堆外内存,就会内存溢出了!
但是这个系统是这种情况吗?
明显不是!因为这个系统的负载其实没有想象中的那么高,不会有瞬时大量的请求过来。
真正的堆外内存溢出原因分析
这个时候你就得思路活跃起来了,我们完全可以去用jstat等工具观察一下线上系统的实际运行情况,同时根据日志看看一些请求的处理耗时,综合性的分析一下。
当时我们通过jstat工具分析jvm运行情况,同时分析了过往的gc日志,另外还看了一下系统各个接口的调用耗时之后,分析出了如下的思路。
首先看了一下接口的调用耗时,这个系统并发量不高,但是他每个请求处理较为耗时,平均在每个请求需要一秒多的时间去处理。
然后我们通过jstat发现,随着系统不停的被调用会一直创建各种对象,包括Jetty本身会不停的创建DirectByteBuffer对象去申请堆外内存空间,接着直到年轻代的Eden区满了,就会触发young gc,如下图所示。
但是往往在进行垃圾回收的一瞬间,可能有的请求还没处理完毕,此时就会有不少DirectByteBuffer对象处于存活状态,不能被回收掉,当然之前不少DirectByteBuffer对象对应的请求可能处理完毕了,他们就可以被回收了。
此时肯定会有一些DirectByteBuffer对象以及一些其他的对象是处于存活状态的,那么就需要转入Survivor区域中。
但是大家注意了,这个系统当时在上线的时候,内存分配的极为不合理,在当时而言,大概就给了年轻代一两百MB的空间,老年代反而给了七八百MB的空间,进而导致年轻代中的Survivor区域只有10MB左右的空间。
因此往往在young gc过后,一些存活下来的对象(包括了一些DirectByteBuffer在内)会超过10MB,没法放入Survivor中,就会直接进入老年代,我们看下图就表现出了这个过程。
因此上述的过程就这么反复的执行,必然会慢慢的导致一些DirectByteBuffer对象慢慢的进入老年代中,老年代中的DirectByteBuffer对象会越来越多,而且这些DirectByteBuffer都是关联了很多堆外内存的,如下图所示。
这些老年代里的DirectByteBuffer其实很多都是可以回收的状态了,但是因为老年代一直没塞满,所以没触发full gc,也就自然不会回收老年代里的这些DirectByteBuffer了!当然老年代里这些没有被回收的DirectByteBuffer就一直关联占据了大量的堆外内存空间了!
直到最后,当你要继续使用堆外内存的时候,结果所有堆外内存都被老年代里大量的DirectByteBuffer给占用了,虽然他们可以被回收,但是无奈因为始终没有触发老年代的full gc,所以堆外内存也始终无法被回收掉。
最后就会导致内存溢出问题的发生!
难道Java NIO就没考虑过这个问题吗?
所以这里我们先不说如何解决这个问题,先说一点,难道Java NIO就从没考虑过会有上述问题的产生过吗?
当然不是了,Java NIO是考虑到的!他知道可能很多DirectByteBuffer对象也许没人用了,但是因为没有触发gc就导致他们一直占据着堆外内存。
所以在 Java NIO的源码中会做如下处理 ,他每次分配新的堆外内存的时候,都会调用System.gc()去提醒JVM去主动执行以下gc去回收掉一些垃圾没人引用的DirectByteBuffer对象,释放堆外内存空间。
只要能触发垃圾回收去回收掉一些没人引用的DirectByteBuffer,就会释放一些堆外内存,自然就可以分配更多的对象到堆外内存去了。
但是我们又在JVM中设置了如下参数:
-XX:+DisableExplicitGC
导致这个System.gc()是不生效的,因此就会导致上述的情况。
最终对问题的优化
其实项目问题有两个,一个是内存设置不合理,导致DirectByteBuffer对象一直慢慢进入老年代,导致堆外内存一直释放不掉
另外一个是设置了-XX:+DisableExplicitGC导致Java NIO没法主动提醒去回收掉一些垃圾DIrectByteBuffer对象,同样导致堆外内存无法释放。
因此最终对这个项目做的事情就是:
一个是合理分配内存,给年轻代更多内存,让Survivor区域有更大的空间
另外一个就是放开-XX:+DisableExplicitGC这个限制,让System.gc()生效。
做完优化之后,DirectByteBuffer一般就不会不断进入老年代了。只要他停留在年轻代,随着young gc就会正常回收释放堆外内存了。
另外一个,只要你放开-XX:+DisableExplicitGC的限制,Java NIO发现堆外内存不足了,自然会通过System.gc()提醒JVM去主动垃圾回收,可以回收掉一些DirectByteBuffer释放一些堆外内存。