前言
最近一段时间在修改一个内存溢出(性能)问题,由于过程比较反复、耗费时间比较久,所以有必要记录一下,为后续其他类似问题提供借鉴。
由于信息安全,此文无法提供真实的代码或截图,只能用文字大致描述。
问题
背景
组件提供一个 restful 接口,供外部调用,可以下载文件,文件以 zip 方式下载,大小不定,小则几 MB,大则 200 MB。
在内部实现这个接口时,需要调用另一个组件的接口,获得数据后进行相应的处理,提供下载。
原始的实现:调用另一个组件的接口,一次性将所有数据查询出来,然后进行处理,放入 StringBuilder 对象中,接着创建一个封装 ByteArrayOutputStream 流的 ZipOutputStream 流,将 StiringBuilder 中的数据写入压缩流中,最后返回一个 byte 数组用以构建下载文件。
现象
在调用下载接口时,小文件正常下载,大文件下载时会内存溢出。日志显示在 StringBuilder 的扩容时抛出 java.lang.OutOfMemoryError:Java heap space
异常。在调大堆内存后仍然出现此问题。
修改
第一次修改
由于异常日志中显示是 StringBuilder 中扩容时申请不到内存导致堆内存溢出,所以直接的修改方案就是替换 StringBuilder 的写法:定义一个匿名内部类,重写输出流,并将流传入处理函数中,用这个流来构建ZipOutputStream 流,在获取到另一个组件传过来的数据后,分批写入压缩流中,最后将新建的匿名内部类的输出流传入 response 中进行下载。这样就避免了将数据写入 StringBuilder 的对象中。
验证结果,小文件下载正常,并发请求下载大文件时异常,日志显示Read Time Out
,当时错误地认为是流开着时间太大导致超时被关闭,后面才发现是一个监控进程在重复10次无法得到应用的响应后,错误地认为 tomcat 挂掉,将其重启。
第二次修改
由于第一次的修改验证结果中,,错误地认为是流开着时间太长导致,所以第二次修改时,将创建流的时间往后推迟,所以此处不再新建匿名内部类重写输出流,而是采用之前的方式创建一个封装 ByteArrayOutputStream 流的 ZipOutputStream 流。不过将此提前到查询另一个组件的数据前面,然后在查询得到数据后,分批直接写入流中。同样省去了构建大容量 StringBuilder 的操作。最后仍然返回一个 byte 数组传入 response 中进行下载。
验证结果,小文件下载正常,并发请求下载大文件时异常,无明显异常日志,tomcat 被重启。查看日志,发现在调用另一个组件的接口后并无日志输出,因此怀疑是在调用另一个组件的接口时出现问题。再次验证,同时查看所有日志输出和 CPU 使用率,证实确实是在调用另一个组件的接口时 tomcat 被重启。将此现象和调用代码反馈给另一个组件的同事,询问原因。得知是调用方式不对,此接口是一个分页查询接口,一次性查询 10000000 的数据容易使系统处于繁忙假死状态,也容易使内存耗尽。
在得知原因后,将接口查询方式改成分批查询。再次验证结果,小文件下载正常,并发请求下载大文件仍然异常,异常日志仍然显示堆内存溢出。只不过这次并不是这块功能代码报出的异常。在苦苦思索找不到原因后,请教另一个同事,走读代码分析发现,创建 ZipOutputStream 流底层是用 ByteArrayOutputStream 流,在将所有数据写入流后,函数最后一行返回的是一个 byte 数组,当数据量大时就容易将内存撑爆。
第三次修改
经过第一次和第二次的折腾后,已经基本确定不能使用 ByteArrayOutputStream 流来创建 ZipOutputStream 流了,必须使用新建匿名内部类重写输出流的方式来创建 ZipOutputStream 流,同时分批查询另一个组件的数据并分批将处理后的数据写入压缩流中,最后将流传入 response 对象中进行文件下载。这算是结合第一次和第二次的修改吧。
验证结果,小文件下载正常,并发请求下载大文件,前期正常,后期下载文件内容缺失甚至失败,无明显异常日志,tomcat 被重启。再次探讨分析,查看堆内存使用情况,发现在压测时,堆内存使用率高达 99% 并长期居高不下。dump 堆存储快照进行分析,发现大量存活对象属于与另一个组件的接口交互的部分。联系另一个组件的同事进行分析,对此部分进行修改后,重新压测,功能正常,性能提升 40%。
总修改点
回顾整个过程,主要有以下的修改点:
- 修改构造数据下载方式,由原先的先构造 StringBuilder 和 byte 数组再一次性写流的方式改成分批直接写流的方式,这是对解决堆内存溢出和性能提升的一个重要地方。
- 修改调用另一个组件的接口的查询方式,由原先的一次性查询大量数据的方式改成分批查询小数据量的方式,同时,修改了查询接口的注册方式。这是解决堆内存溢出的重要点。
- 对部分经常调用并需要加锁查询的数据,使用缓存获取
- 对 StringBuilder 的初始化,预判其长度并传入值初始化 StringBuilder 的长度,避免其多次扩容
总结
当分析堆内存溢出或性能问题时,可以结合以下的方式进行分析:
- 在可能出现问题的地方加上日志,统计代码块耗时时长,以便发现性能瓶颈所在点
- 当程序“卡”住时,可以结合日志输出和使用 jstack 抓取堆栈进行分析,查看程序“卡”在哪里
- 当 CPU 使用率居高不下时,用 jstack 抓取堆栈,同时使用
top -p <pid> -H
查看各个子线程的 CPU 使用情况,将 CPU 使用率高的线程号(10进制)在堆栈(16进制,需要进行转换)中进行查找,可以分析是哪个线程的 CPU 占用率高 - 发生堆内存溢出时,日志记录的异常信息并不一定是出现问题的地方,有可能是其他功能代码产生大量对象长期占据内存不释放导致一些正常功能代码需要申请内存时却申请不到,从而抛出堆内存溢出的异常。此时可以将堆存储快照 dump 出来进行分析
- 当程序运行出现异常但是并没有明显的异常日志时,可以查看 CPU 使用率和堆内存使用情况,可能是由于堆内存使用率长期居高不下导致,此时可以将堆存储快照 dump 出来进行分析
- 对部分经常调用并需要加锁查询的数据,可以考虑使用缓存获取
- 对 StringBuilder 对象,如果能够预判其长度大小,可以在初始化时设置其长度,避免进行多次扩容操作