一、背景
前几天下午飞书告警群里报起了java.lang.OutOfMemoryError: unable to create new native thread告警,看见后艾特了对应的项目负责人但是负责人说没时间,无奈自己亲自上阵。
二、事情经过
2.1 问题排查
从报错信息就可以看出是服务申请不到足够的内存去创建新的线程导致的,熟悉JVM的都知道线程用的是虚拟机栈内存,这里是虚拟机栈内存不够跟堆没有关系。
然后立马登录阿里云进入容器使用如下命令dump出线程堆栈
jstack -l 1 > dump.log
让运维帮我把文件下载下来后,就把dump.log上传到网站https://fastthread.io/进行分析,分析结果如下:
可以看到服务的线程数竟然高达2000多个!!!而且也给出来警告这么高的线程数可能导致OOM,那么问题原因就很清楚了就是线程数过多导致的,那么是哪些线程过多呢?
第二张图显示数量做多的是名字为com.alibaba.nacos.client.Worker的线程,并且排行前三名都是nacos的线程,我一度怀疑是nacos出bug了🤣🤣🤣
再点进去看看对应的线程堆栈如下图:
可以看出线程阻塞在从队列获取任务那里,说明这些线程都是没活干,空闲挂起的,具体看不出问题原因,只能看代码了。
打开项目,使用idea全局搜索com.alibaba.nacos.client.Worker,发现是ClientWorker类在构造方法里创建了一个定时线程池,线程名称就叫com.alibaba.nacos.client.Worker,其中核心线程数是4。
继续跟踪源码,发现只有在NacosConfigService的构造方法里调用了ClientWorke的构造方法。
但是点击NacosConfigService的构造方法却发现没有调用,这是什么情况呢?
点击其父类com.alibaba.nacos.api.config.ConfigService查找引用可以看到有个NacosFactory的工厂类,原来ConfigService的是实例是通过工厂方法创建的,而工厂方法内部是通过反射创建的,如下图。
最终找到了业务类NacosConfigServiceImpl如下图:
@Slf4j @Component public class NacosConfigServiceImpl implements ConfigService { /** * nacos地址key */ public static final String SERVER_ADDR = "spring.cloud.nacos.config.server-addr"; /** * nacos配置namespace */ public static final String CONFIG_NAMESPACE = "spring.cloud.nacos.config.namespace"; @Getter @Value("${spring.cloud.nacos.config.group}") private String groupId; @Value("${" + SERVER_ADDR + "}") private String nacosServerAddr; @Value("${" + CONFIG_NAMESPACE + "}") private String nacosConfigNamespace; private ConfigService configService; private static final long TIMEOUT_MILLIS = 3000L; @PostConstruct public void init() throws NacosException { this.configService = getConfigService(); } @Override public void registerListener(String dataId, ConfigListener configListener) { AssertUtil.that(StringUtils.isNotBlank(dataId), BasicErrorCode.PARAM_ERROR, "dataId empty"); AssertUtil.that(configListener != null, BasicErrorCode.PARAM_ERROR, "configListener null"); try { //注册监听器 // 第一次创建 ConfigService configService = getConfigService(); configService.addListener(dataId, getGroupId(), new DefaultListener(dataId, configListener)); //首次更新配置 // 第二次创建 String configInfo = getConfigInfo(dataId); configListener.receiveConfigInfo(configInfo); log.info(LogUtil.message("registerListener success", dataId, InvokeLineUtil.simplifiedClassName(configListener))); } catch (Exception e) { log.error(LogUtil.exceptionMessage("registerListener exception", e, dataId, configListener)); throw new ApiException(BasicErrorCode.CONFIG_ERROR, e.getMessage()); } } @Override public String getConfigInfo(String dataId) { try { ConfigService configService = getConfigService(); return configService.getConfig(dataId, getGroupId(), TIMEOUT_MILLIS); } catch (Exception e) { log.error(LogUtil.exceptionMessage("getConfigInfo exception", e, dataId)); throw new ApiException(BasicErrorCode.CONFIG_ERROR, e.getMessage()); } } private ConfigService getConfigService() throws NacosException { Properties properties = new Properties(); properties.put(PropertyKeyConst.SERVER_ADDR, nacosServerAddr); properties.put(PropertyKeyConst.NAMESPACE, nacosConfigNamespace); // 调用工厂方法创建configService ConfigService configService = NacosFactory.createConfigService(properties); return configService; } }
当时看到上面代码我都惊呆了,既然已经在init()方法里初始化了属性configService,为什么还要在registerListener()方法里两次调用getConfigService()去创建两次configService实例呢?
然后找了下registerListener()方法的调用,发现多达50处!
问题原因大概就清楚了,nacos的configService设计上是作为单例去使用的,但是被滥用导致创建了很多线程池和线程把虚拟机栈内存耗尽,再申请创建新线程时就报了OOM。
2.2 相关疑问
此时同事小A提出疑问,registerListener()方法执行完了,configService对象就会被gc回收,对应的线程池不也是被回收了吗?
这个问题看似很有道理其实不然,对象能不能回收是看这个对象到GC Root还有没有可达路径,线程池的gc root其实是线程,对应的gc路径是thread->workers->线程池,因为那些nacos线程池也没有设置属性allowCoreThreadTimeOut(true),所以就算线程空闲了核心线程也不会回收。
如果我们要在方法里创建并使用线程池,用完一定要记得调用shutdown方法,这样线程池才会被回收,当然更推荐线程池作为单例bean注入Spring容器使用。
爱专研的同事A又说你这线程数也对不上啊,50x4x2=400也没到500个啊。
大家有兴趣的可以去看看源码,NacosConfigService的构造方法里的ServerListManager类内部也有一个线程池,而且前面线程数量排行图中第三名的com.alibaba.nacos.client.remote.worker线程也跟这个有关。
2.3 解决方案
知道了原因解决起来就很快了,NacosConfigServiceImpl#registerListener()方法里创建configSerivce的代码改成用单例属性就行了。
三、总结
在用第三方开源组件的时候还是要多看看文档和其源码,使用正确的姿势不要一上来就撸代码,否则很容易产生生产事故。
不过在排查问题的过程中也顺便看了下nacos服务端和客户端配置同步的实现,算还有点收获吧。