分析Tomcat 类加载机制触发的 Too many open files 问题

宋顺,携程框架研发部技术专家。 2016年初加入携程,主要负责中间件产品的相关研发工作。 毕业于复旦大学软件工程系, 曾就职于大众点评,担任后台系统技术负责人。

说起 Too many open files 这个报错,想必大家一定不陌生。在 Linux 系统下,如果程序打开文件句柄数(包括网络连接、本地文件等)超出系统设置,就会抛出这个错误。

不过最近发现 Tomcat 的类加载机制在某些情况下也会触发这个问题。今天就来分享下问题的排查过程、问题产生的原因以及后续优化的一些措施。

在正式分享之前,先简单介绍下背景。

Apollo配置中心是携程框架研发部(笔者供职部门)推出的配置管理平台,提供了配置中心化管理、配置修改后实时推送等功能。

有一个 Java Web 应用接入了 Apollo 配置中心,所以用户在配置中心修改完配置后,配置中心就会实时地把最新的配置推送给该应用。

一、故障现象

某天,开发在生产环境照常通过配置中心修改了应用配置后,发现应用开始大量报错。

查看日志,发现原来是 redis 无法获取到连接了,所以导致接口大量报错。

Caused by: redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
         at redis.clients.util.Pool.getResource(Pool.java:50)
         at redis.clients.jedis.JedisPool.getResource(JedisPool.java:99)
         at credis.java.client.entity.RedisServer.getJedisClient(RedisServer.java:219)
         at credis.java.client.util.ServerHelper.execute(ServerHelper.java:34)
         ... 40 more
Caused by: redis.clients.jedis.exceptions.JedisConnectionException: java.net.SocketException: Too many open files
         at redis.clients.jedis.Connection.connect(Connection.java:164)
         at redis.clients.jedis.BinaryClient.connect(BinaryClient.java:82)
         at redis.clients.jedis.BinaryJedis.connect(BinaryJedis.java:1641)
         at redis.clients.jedis.JedisFactory.makeObject(JedisFactory.java:85)
         at org.apache.commons.pool2.impl.GenericObjectPool.create(GenericObjectPool.java:868)
         at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:435)
         at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:363)
         at redis.clients.util.Pool.getResource(Pool.java:48)
         ... 43 more
Caused by: java.net.SocketException: Too many open files
         at java.net.Socket.createImpl(Socket.java:447)
         at java.net.Socket.getImpl(Socket.java:510)
         at java.net.Socket.setReuseAddress(Socket.java:1396)
         at redis.clients.jedis.Connection.connect(Connection.java:148)
         ... 50 more
由于该应用是基础服务,有很多上层应用依赖它,所以导致了一系列连锁反应。情急之下,只能把所有机器上的 Tomcat 都重启了一遍,故障恢复。

二、初步分析

由于故障发生的时间和配置中心修改配置十分吻合,所以后来立马联系我们来一起帮忙排查问题(配置中心是我们维护的)。不过我们得到通知时,故障已经恢复,应用也已经重启,所以没了现场。只好依赖于日志和 CAT(实时应用监控平台)来尝试找到一些线索。

从 CAT 监控上看,该应用集群共 20 台机器,不过在配置客户端获取到最新配置,准备通知应用该次配置的变化详情时,只有 5 台通知成功, 15 台通知失败。

其中 15 台通知失败机器的 JVM 似乎有些问题,报了无法加载类的错误( NoClassDefFoundError ),错误信息被 catch 住并记录到了 CAT 。

5 台成功的信息如下:

 

15 台失败的如下:

报错详情如下:

java.lang.NoClassDefFoundError: com/ctrip/framework/apollo/model/ConfigChange
    ...
Caused by: java.lang.ClassNotFoundException: com.ctrip.framework.apollo.model.ConfigChange
    at org.apache.catalina.loader.WebappClassLoader.loadClass(WebappClassLoader.java:1718)
    at org.apache.catalina.loader.WebappClassLoader.loadClass(WebappClassLoader.java:1569)
    ... 16 more
配置客户端在配置更新后,会计算配置的变化并通知到应用。配置的变化会通过 ConfigChange 对象存储。

由于是该应用启动后第一次配置变化,所以 ConfigChange 类是第一次使用到,基于 JVM 的懒加载机制,这时会触发一次类加载过程。

这里就有一个疑问来了,为啥 JVM 会无法加载类?这个类 com.ctrip.framework.apollo.model.ConfigChange 和配置客户端其它的类是打在同一个 jar 包里的,不应该出现 NoClassDefFoundError 的情况。

而且,碰巧的是,后续 redis 报无法连接错误的也正是这 15 台报了 NoClassDefFoundError 的机器。

联想到前面的报错 Too many open files , 会不会也是由于文件句柄数不够,所以导致 JVM 无法从文件系统读取 jar 包,从而导致 NoClassDefFoundError ?

三、故障原因

关于该应用出现的问题,种种迹象表明那个时段应该是进程句柄数不够引起的,例如无法从本地加载文件,无法建立 redis 连接,无法发起网络请求等等。

前一阵我们的一个应用也出现了这个问题,当时发现老机器的 Max Open Files 设置是 65536 ,但是一批新机器上的 Max OpenFiles 都被误设置为 4096 了。

虽然后来运维帮忙统一修复了这个设置问题,但是需要重启才会生效。所以目前生产环境还有相当一部分机器的 Max Open Files 是 4096 。

所以,我们登陆到其中一台出问题的机器上去查看是否存在这个问题。但是出问题的应用已经重启,无法获取到应用当时的情况。不过好在该机器上还部署了另外的应用, pid 为 16112 。通过查看 /proc/16112/limits 文件,发现里面的 Max Open Files 是 4096 。

所以有理由相信应用出问题的时候,它的 Max Open Files 也是 4096 ,一旦当时的句柄数达到 4096 的话,就会导致后续所有的 IO 都出现问题。

所以故障原因算是找到了,是由于 Max Open Files 的设置太小,一旦进程打开的文件句柄数达到 4096 ,后续的所有请求(文件 IO ,网络 IO )都会失败。

由于该应用依赖了 redis ,所以一旦一段时间内无法连接 redis ,就会导致请求大量超时,造成请求堆积,进入恶性循环。(好在 SOA 框架有熔断和限流机制,所以问题影响只持续了几分钟)

四、疑团重重

故障原因算是找到了,各方似乎对这个答案还算满意。不过还是有一个疑问一直在心头萦绕,为啥故障发生时间这么凑巧,就发生在用户通过配置中心发布配置后?

为啥在配置发布前,系统打开的文件句柄还小于 4096 ,在配置发布后就超过了?

难道配置客户端在配置发布后会大量打开文件句柄?

4.1、代码分析

通过对配置客户端的代码分析,在配置中心推送配置后,客户端做了以下几件事情:

1.     之前断开的 http long polling 会重新连接

2.     会有一个异步 task 去服务器获取最新配置

3.     获取到最新配置后会写入本地文件

4.     写入本地文件成功后,会计算 diff 并通知到应用

从上面的步骤可以看出,第 1 步会重新建立之前断开的连接,所以不算新增,第 2 步和第 3 步会短暂的各新增一个文件句柄(一次网络请求和一次本地 IO ),不过执行完后都会释放掉。

4.2、尝试重现

代码看了几遍也没看出问题,于是尝试重现问题,所以在本地起了一个 demo 应用(命令行程序,非 web ),尝试操作配置发布来重现,同时通过 bash 脚本实时记录打开文件信息,以便后续分析。

for i in {1..1000}
do
  lsof -p 91742 > /tmp/20161101/$i.log
  sleep 0.01
done
然而本地多次测试后都没有发现文件句柄数增加的情况,虽然洗清了配置客户端的嫌疑,但是问题的答案却似乎依然在风中飘着,该如何解释观测到的种种现象呢?

五、柳暗花明

尝试自己重现问题无果后,只剩下最后一招了 - 通过应用的程序直接重现问题。

为了不影响应用,我把应用的 war 包连同使用的 Tomcat 在测试环境又独立部署了一份。不想竟然很快就发现了导致问题的原因。

原来 Tomcat 对 webapp 有一套自己的 WebappClassLoader ,它在启动的过程中会打开应用依赖的 jar 包来加载 class 信息,但是过一段时间就会把打开的 jar 包全部关闭从而释放资源。

然而如果后面需要加载某个新的 class 的时候,会把之前所有的 jar 包全部重新打开一遍,然后再从中找到对应的 jar 来加载。加载完后过一段时间会再一次全部释放掉。

所以应用依赖的 jar 包越多,同时打开的文件句柄数也会越多。

同时,我们在 Tomcat 的源码中也找到了上述的逻辑。

之前的重现实验最大的问题就是没有完全复现应用出问题时的场景,如果当时就直接测试了 Tomcat ,问题原因就能更早的发现。

5.1、重现环境分析

5.1.1 Tomcat 刚启动完

刚启动完,进程打开的句柄数是 443 。

lsof -p 31188 | wc -l
5.1.2 Tomcat 启动完过了几分钟左右

启动完过了几分钟后,再次查看,发现只剩 192 个了。仔细比较了一下其中的差异,发现 WEB-INF/lib 下的 jar 包句柄全释放了。

lsof -p 31188 | wc -l
lsof -p 31188 | grep "WEB-INF/lib" | wc -l
5.1.3 配置发布后 2 秒左右

然后通过配置中心做了一次配置发布,再次查看,发现一下子又涨到 422 了。其中的差异恰好就是 WEB-INF/lib 下的 jar 包句柄数。从下面的命令可以看到, WEB-INF/lib 下的 jar 包文件句柄数有 228 个之多。

lsof -p 31188 | wc -l
lsof -p 31188 | grep "WEB-INF/lib" | wc -l
5.1.4 配置发布 30 秒后

过了约 30 秒后, WEB-INF/lib 下的 jar 包句柄又全部释放了。

lsof -p 31188 | wc -l
lsof -p 31188 | grep "WEB-INF/lib" | wc -l
5.2  TomcatWebappClassLoader 逻辑

通过查看 Tomcat(7.0.72 版本 ) 的逻辑,也印证了我们的实验结果。

5.2.1 加载类逻辑

Tomcat 在加载 class 时,会首先打开所有的 jar 文件,然后遍历找到对应的 jar 去加载:

 

5.2.2 关闭 jar 文件逻辑

同时会有一个后台线程定期执行文件的关闭动作来释放资源:

5.3故障场景分析

对于应用出现故障的场景,由于是应用启动后第一次配置变化,所以会使用到一个之前没有引用过的 class:  com.ctrip.framework.apollo.model.ConfigChange , 进而会触发 Tomcat 类加载,并最终打开所有依赖的 jar 包 , 从而导致在很短的时间内进程句柄数升高。 ( 对该应用而言,之前测试下来的数字是 228 )。

虽然现在无从知晓该应用在出问题前总的文件句柄数,但是从 CAT 监控可以看到光 TCP 连接数 (Established 和 TimeWait 之和 ) 就在 3200+ 了,再加上一些 jdk 加载的类库(这部分 Tomcat 不会释放)和本地文件等,应该离 4096 的上限相差不多了。所以这时候如果 Tomcat 再一下子打开本地 228 个文件,自然就很容易导致 Too manyopen files 的问题了。

六、总结

6.1 问题产生原因

所以,分析到这里,整件事情的脉络就清晰了:

1.     应用的 Max Open Files 限制设置成了 4096

2.     应用自身的文件句柄数较高,已经接近了 4096

3.     用户在配置中心操作了一次配置发布,由于 Tomcat 的类加载机制,会导致瞬间打开本地 200 多个文件,从而迅速达到 4096 上限

4.     Jedis 在运行过程中需要和 Redis 重新建立连接,然而由于文件句柄数已经超出上限,所以连接失败

5.     应用对外的服务由于无法连接 Redis ,导致请求超时,客户端请求堆积,陷入恶性循环

6.2 后续优化措施

通过这次问题排查,我们不仅对 Too many open files 这一问题有了更深的认识,对平时不太关心的 Tomcat 类加载机制也有了一定了解,同时也简单总结下未来可以优化的地方:

1、 操作系统配置

从这次的例子可以看出,我们不仅要关心应用内部,对系统的一些设置也需要保持一定的关注。如这次的 Max Open Files 配置,对于普通应用,如果流量不大的话,使用 4096 估计也 OK 。但是对于对外提供服务的应用, 4096 就显得太小了。

2 、 应用监控、报警

对应用的监控、报警还得继续跟上。比如是否以后可以增加对应用的连接数指标进行监控,一旦超过一定的阈值,就报警。从而可以避免突然系统出现问题,陷于被动。

3、 中间件客户端及早初始化

鉴于 Tomcat 的类加载机制,中间件客户端应该在程序启动的时候做好初始化动作,同时把所有的类都加载一遍,从而避免后续在运行过程中由于加载类而产生一些诡异的问题。

4、 遇到故障,不要慌张,保留现场

生产环境遇到故障,不要慌张,如果一时无法解决问题的话,可以通过重启解决。不过应该至少保留一台有问题的机器,从而为后面排查问题提供有利线索。

时间: 2024-09-19 10:08:10

分析Tomcat 类加载机制触发的 Too many open files 问题的相关文章

图解Tomcat类加载机制

说到本篇的tomcat类加载机制,不得不说翻译学习tomcat的初衷. 之前实习的时候学习javaMelody的源码,但是它是一个Maven的项目,与我们自己的web项目整合后无法直接断点调试.后来同事指导,说是直接把java类复制到src下就可以了.很纳闷....为什么会优先加载src下的java文件(编译出的class),而不是jar包中的class呢? 现在了解tomcat的类加载机制,原来一切是这么的简单. 类加载 在JVM中并不是一次性把所有的文件都加载到,而是一步一步的,按照需要来加

Java虚拟机类加载机制——案例分析

  在<Java虚拟机类加载机制>一文中详细阐述了类加载的过程,并举了几个例子进行了简要分析,在文章的最后留了一个悬念给各位,这里来揭开这个悬念.建议先看完<Java虚拟机类加载机制>这篇再来看这个,印象会比较深刻,如若不然,也没什么关系~~ 下面是程序代码: package jvm.classload; public class StaticTest { public static void main(String[] args) { staticFunction(); } st

深入理解JVM之六:类加载机制

前言 虚拟机的类加载机制可以简单描述如下:Java虚拟机把描述类的数据从Class文件中加载到内存中,并对数据进行校验.解析和初始化,最终形成可以被虚拟机直接使用的Java类型.虚拟机加载进行类加载的过程是在程序运行期间完成的,在程序运行期间加载的好处是可以动态扩展,说白了就是在编译期间虚拟机是不知道要加载哪些类或者接口的,只有在程序运行的时候才知道需要加载的类.举个例子,我们在一个包下面写了很多类,我们在在别的包中可能只是需要其中的几个类,但是出于习惯我们可能会写成import test.*

深入理解Java:类加载机制及反射

一.Java类加载机制 1.概述 Class文件由类装载器装载后,在JVM中将形成一份描述Class结构的元信息对象,通过该元信息对象可以获知Class的结构信息:如构造函数,属性和方法等,Java允许用户借由这个Class相关的元信息对象间接调用Class对象的功能. 虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制. 2.工作机制 类装载器就是寻找类的字节码文件,并构造出类在JVM内部表示

【转载】JVM类加载机制小结

本文转载自http://shift-alt-ctrl.iteye.com/blog/1845137   一.类加载        虚拟机把class文件加载至内存之后,对字节码数据进行校验/解析/初始化等操作,最终形成可被VM直接使用的java类型,这就是虚拟机类加载机制.类的加载完全可以在运行时进行,这给VM提供了动态加载类提供了可行性.     类生命周期过程大概分为:加载-->校验-->解析-->初始化-->使用-->卸载;类在被"使用"之前必须进行

Java虚拟机类加载机制

看到这个题目,很多人会觉得我写我的java代码,至于类,JVM爱怎么加载就怎么加载,博主有很长一段时间也是这么认为的.随着编程经验的日积月累,越来越感觉到了解虚拟机相关要领的重要性.闲话不多说,老规矩,先来一段代码吊吊胃口. public class SSClass{ 运行结果: SSClassSuperClass init!123 答案答对了嚒? 也许有人会疑问:为什么没有输出SubClass init.ok~解释一下:对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父

Java魔法堂:类加载机制入了个门

一.前言   当在CMD/SHELL中输入 $ java Main<CR><LF> 后,Main程序就开始运行了,但在运行之前总得先把Main.class及其所依赖的类加载到JVM中吧!本篇将记录这些日子对类加载机制的学习心得,以便日后查阅.若有纰漏请大家指正,谢谢!   以下内容均基于JDK7和HotSpot VM.   二.执行java的那刻     大家都知道通过java命令来启动JVM和运行应用程序,但实际的流程又是如何的呢?   1. 首先根据java后的运行模式配置项或

深入研究Java虚拟机的类加载机制

说到Java虚拟机的类加载机制,很多朋友第一反应想到的应该就是ClassLoader,我也如此,不过ClassLoader其实只是Java虚拟机加载机制中的一部分,最近在看<深入理解Java虚拟机>,对Java虚拟机的类加载机制有了更深入的了解,不吐不快. JVM中类的整个生命周期如下: 加载=>验证=>准备=>解析=>初始化=>使用=>卸载 使用和卸载这两个步骤不在今天的讨论范围之内,今天我们将着重讨论一下前5个步骤,也就是JVM中类的整个加载机制. 1.

JVM类加载机制小结

一.类加载        虚拟机把class文件加载至内存之后,对字节码数据进行校验/解析/初始化等操作,最终形成可被VM直接使用的java类型,这就是虚拟机类加载机制.类的加载完全可以在运行时进行,这给VM提供了动态加载类提供了可行性.     类生命周期过程大概分为:加载-->校验-->解析-->初始化-->使用-->卸载;类在被"使用"之前必须进行前4个阶段,其中初始化工作可以被延迟进行,直到类需要创建实例/static方法或属性被调用(非编译常量)