一、项目背景
本项目是基于spring boot + electron + vue 的桌面应用程序,由于涉及USB设备通讯,因此采用c++封装了一个USB通讯的动态库,由jna去调用该库。由于是桌面应用程序,spring boot部署在客户机上,因此客户机也相当于一台小型服务器。
二、问题出现
在程序第一次商用过程中,首次出现spring boot莫名崩溃的情况,同时伴随着jvm报错文件(类似于hs_err_pid8644.log文件)的输出。
三、分析
1、第一次分析
有报错文件当然先从报错文件入手,但这种jvm的报错文件实在不知道从何看起,查阅资料后发现,最有用的信息就在这个地方:
J或j开头的输出标识java层面的,c开头表示动态库的。首先看java层面,确定了是从调用动态库的发指令接口之后,jvm崩溃,之后全是c层面的输出。
由于在其他项目中经常调用dll动态库,因此看到这样的崩溃信息第一反应当然是调用dll过程中,c++发生了不可被java捕获的异常(例如c++的指针越界,导致内存泄漏),进而导致jvm的异常退出。
带着这样的猜想,首先查看了代码中会调用到dll接口的地方。
要建立与USB设备的通讯,首先需要dll初始化该设备并打开与该设备通讯的接口通道,同时获取该通道句柄,在后续发送指令的过程中,需要带上该句柄,当结束与设备的通讯,则需要释放接口并关闭设备。
在spring boot中,与设备的通讯的正常流程基本是:init(识别设备,并打开通讯接口) -> getInfo(发送指令获取设备信息) -> syncTime(发送指令同步设备时间) -> writeInfo(发送指令,修改设备信息) -> changeMode(发送指令将设备转为USB模式)。
本以为是否是频繁开关设备导致dll的崩溃,但正常流程来看并不会,反倒是没有去close设备,于是乎在流程最后加上close。这反而增大了崩溃的几率。因此开始怀疑是c++库的问题。
2、第二次分析
开始阅读c++库代码。重点关注um_usbapi_close这个接口。
usb_release_interface方法用于释放已打开的设备通讯接口,usb_close方法关闭设备,umapi_list_del从链表中删除该设备,free释放节点
通过以上代码发现,在尝试释放通讯接口之后,如果释放失败则立刻返回结果,没有去链表中删除设备。设备接入后基本只有两个结果,一个是设备拔出,一个是转U盘模式,这都会使得设备断开通讯连接,所以usb_release_interface必定是返回失败的。usb_release_interface失败,则不会再链表中删除设备。
所以分析原因,由于链表没有删除设备信息,导致链表不断累积,容量变大超过限制,导致内存溢出。
于是将umapi_list_del等方法放在return之前,无论成功失败都去删除链表中的设备。
结果是崩溃的几率更大了。。。。推翻重来!
3、第三次分析
开始从jvm的内存泄漏这个方向分析。
在调试过程遇到过一个报错:
java.lang.OutOfMemoryError: unable to create new native thread
显而易见是OOM,且是创建线程时候报的OOM。这让我很疑惑,我的jvm参数已经给到1G了,且正在运行的线程并不是很多,怎么就不够内存了?查找资料,发现创建线程不仅仅是需要jvm内存,还需要系统的空余内存。项目应用场景,运行内存只有4G,win10系统已经占了一半,开了两个spring boot服务外加一个electron,剩余内存空间已经少之又少。
于是调整jvm大小,为512M。
调整后崩溃几率小了,但依然会偶现。此次修改保留。
4、第四次分析
值得一提的是,之前一直以为只能但设备通讯,但经过前面对c++代码的研究,发现代码漏洞,在open设备成功后并没有返回,而是继续遍历设备并Open,这导致后面open到已经打开过通讯接口的设备,于是ret又被设置为失败了。修改后支持根据句柄与设备通讯,实现多设备的同时通讯,这解决了一大堆java层面的已知问题,也不再需要锁去限制通讯了。
继续分析,开始从jvm层面去查看。
这里用到一个jdk自带的jvm内存查看工具:jvisualvm,使用方式查看
https://www.jianshu.com/p/7958eead8cc8
下载插件Visual GC,使用方式查看
https://www.cnblogs.com/reycg-blog/p/7805075.html
通过对内存的监控,发现metaspace(永久代)几乎占满,同时byte[]创建速度及数量非常的多,导致young gc频繁,而当metaspace到达一定阈值,则触发full GC,full GC对这种桌面的实时通讯程序来说是致命的。于是查看代码中哪里使用了大量的byte数组。
第一反应当然是设备通讯这一块,设备通讯都是以byte数组进行数据交互的,于是发现Usb通讯库里有类似这样的代码:
cmdBuf是一个byte数组,这里根据cmdBuf的传值,来new出一个固定大小的Memory区域。Memory是jna包下的一个类,主要用于创建一块内存区域,接收c++的返回信息。
于是发问:Memory创建的内存存放在哪里?带着这个疑问查询了资料,发现:java在调用c++动态库的时候,动态库的方法接口都是存放在本地方法栈中,因此jna创建的Memory空间也是在本地方法栈,而本地方法栈不属于GC范围。
人一下子就精神起来了。每次发送一个数据包给设备,都会建立至少1KB的Memory空间,直到通讯结束都没有去释放,于是本地方法栈内存不断累积,最终触发full GC(初步分析)。于是,处理方式是在每次完成通讯后,都将建立的Memory释放。
到此,崩溃问题还未复现。
四、总结
这是第一次深入到jvm内存,从内存信息来分析问题代码。经过这次调试,后续在对这些内存空间的处理上更加谨慎了,包括stream流的close,也要引起关注。
但还有一个问题未能解决,metaspace依然几乎占满。这个问题还需要继续研究。