本篇文章是关于 Nacos ISSUE #13546 的分析及修改过程,采用的是本地启动测试用例并借助 IDEA Profiler 分析内存快照的方法。
NacosConfigService
本地启动 NacosConfigService
,并调用 ConfigService#addListener
方法创建监听器,运行成功后,执行 NacosConfigService#shutDown
方法,并执行多次 GC。
GC 完成后,采集内存快照,并按照 alibaba.nacos 路径筛选后存在的较大的对象(按照 Retained 倒序)为:

点击 com.alibaba.nacos.common.remote.client.grpc.GrpcConnection
后查看 Shortest Paths 后发现它的最短引用路径为:

Shortest Paths (最短路径):显示从 GC Root 到当前对象的最短引用路径,帮助理解对象为什么还活着
可以发现在这个引用路径中包含了第一张图中 200KB+ 所有的大对象,分析引用链可知:
- GrpcConnection 未关闭,并且通过引用链持有其他对象
GrpcSdkClient
持有对ConfigRpcTransportClient
的引用,而后者又引用了ConfigFuzzyWatchGroupKeyHolder
根据引用链的分析结果我们先看一下 com.alibaba.nacos.common.remote.client.grpc.GrpcConnection#close
方法,看看 GrpcConnection
有没有正确地被关闭:
java
public class GrpcConnection extends Connection {
@Override
public void close() {
if (this.payloadStreamObserver != null) {
try {
payloadStreamObserver.onCompleted();
} catch (Throwable ignored) {
}
}
if (this.channel != null && !channel.isShutdown()) {
try {
this.channel.shutdownNow();
} catch (Throwable ignored) {
}
}
}
}
发现逻辑正确,所以这部分没有问题。接下来看下 ConfigFuzzyWatchGroupKeyHolder
逻辑,发现在其构造方法中注册了本身(NotifyCenter.registerSubscriber(this)
)但是没有在 shotdown
时取消注册:
java
public class ConfigFuzzyWatchGroupKeyHolder extends SmartSubscriber {
public ConfigFuzzyWatchGroupKeyHolder(ClientWorker.ConfigRpcTransportClient agent, String clientUuid) {
this.clientUuid = clientUuid;
this.agent = agent;
// register this
NotifyCenter.registerSubscriber(this);
}
// ...
}
那么需要在 ConfigFuzzyWatchGroupKeyHolder
中添加 shutdown
方法取消注册本身:
java
public class ConfigFuzzyWatchGroupKeyHolder extends SmartSubscriber implements Closeable {
@Override
public void shutdown() {
NotifyCenter.deregisterSubscriber(this);
}
// ...
}
并在 ClientWorker#shutdown
方法中添加对 ConfigFuzzyWatchGroupKeyHolder#shutdown
方法的调用:
java
public class ClientWorker implements Closeable {
@Override
public void shutdown() throws NacosException {
String className = this.getClass().getName();
LOGGER.info("{} do shutdown begin", className);
if (configFuzzyWatchGroupKeyHolder != null) {
configFuzzyWatchGroupKeyHolder.shutdown();
// help gc
configFuzzyWatchGroupKeyHolder = null;
}
if (agent != null) {
agent.shutdown();
}
LOGGER.info("{} do shutdown stop", className);
}
}
修改完成后在查看 GC 的快照,如下:

发现先前存在的较大的对象均已经被回收掉了,现在继续检查一下 shutdown
后仍然存在的线程池:

发现了 threadFactory
包含 ConfigRpcTransportClient
命名的线程池,那么实际上它便对应了 ClientWorker.ConfigRpcTransportClient#multiTaskExecutor
字段的对象,它在创建后没有被 shutdown
,所以我们需要添加针对它的 shutdown
逻辑:
java
public class ConfigRpcTransportClient extends ConfigTransportClient {
@Override
public void shutdown() throws NacosException {
super.shutdown();
synchronized (RpcClientFactory.getAllClientEntries()) {
// ...
// shutdown multiTaskExecutor
multiTaskExecutor.values().forEach((executor) ->{
if (executor != null && !executor.isShutdown()) {
LOGGER.info("Shutdown multi task executor {}", executor);
executor.shutdown();
}
});
}
}
}
除此之外还有 CacheData#scheduledExecutor
没有被 shutdown
:

它是 CacheData
中的静态变量,并且使用了双重检测锁机制进行实例化和引用赋值,但是原代码中没有添加上 volatile
字段保证可见性,所以在这里进行补充,并添加相应的静态方法 shutdown
完成线程池资源的关闭:
java
public class CacheData {
/**
* double check lock initialization of scheduledExecutor.
*/
static volatile ScheduledThreadPoolExecutor scheduledExecutor;
static ScheduledThreadPoolExecutor getNotifyBlockMonitor() {
if (scheduledExecutor == null) {
synchronized (CacheData.class) {
if (scheduledExecutor == null) {
scheduledExecutor = new ScheduledThreadPoolExecutor(1,
new NameThreadFactory("com.alibaba.nacos.client.notify.block.monitor"),
new ThreadPoolExecutor.DiscardPolicy());
scheduledExecutor.setRemoveOnCancelPolicy(true);
}
}
}
return scheduledExecutor;
}
public static void shutdownScheduledExecutor() {
if (scheduledExecutor != null) {
try {
scheduledExecutor.shutdown();
// help gc
scheduledExecutor = null;
} catch (Exception e) {
// ignore
}
}
}
}
同样地,也要在 ClientWorker#shutdown
方法中添加 CacheData.shutdownScheduledExecutor()
方法:
java
public class ClientWorker implements Closeable {
@Override
public void shutdown() throws NacosException {
String className = this.getClass().getName();
LOGGER.info("{} do shutdown begin", className);
if (configFuzzyWatchGroupKeyHolder != null) {
configFuzzyWatchGroupKeyHolder.shutdown();
// help gc
configFuzzyWatchGroupKeyHolder = null;
}
if (agent != null) {
agent.shutdown();
}
CacheData.shutdownScheduledExecutor();
LOGGER.info("{} do shutdown stop", className);
}
}
到这里除了堆中还有 DefaultPublisher
类型的线程没被关闭外,其他资源对象基本已经完成了回收,关于 DefaultPublisher
我们在后续小节中讨论。
NacosNamingService
在本地启动 NacosNamingService
并注册 NacosNamingService#registerInstance
和 NacosNamingService#subscribe
监听服务后,执行 NamingService#shutDown
方法,之后执行 GC。
GC 完成后,采集内存快照,并按照 alibaba.nacos 路径筛选后存在的较大的对象(按照 Retained 倒序)为:

同样地,点击 com.alibaba.nacos.common.remote.client.grpc.GrpcConnection
可以发现和在 NacosConfigService
中一样的问题:

也是在 NamingFuzzyWatchServiceListHolder
的构造方法中,调用了 NotifyCenter#registerSubscriber
方法注册了本身为订阅者:
java
public class NamingFuzzyWatchServiceListHolder extends SmartSubscriber implements Closeable {
public NamingFuzzyWatchServiceListHolder(String notifierEventScope) {
this.notifierEventScope = notifierEventScope;
NotifyCenter.registerSubscriber(this);
}
// ...
}
但是在 shutdown
时并未取消注册,所以需要在 shutdown
方法中添加上取消注册的逻辑即可:
java
public class NamingFuzzyWatchServiceListHolder extends SmartSubscriber implements Closeable {
public NamingFuzzyWatchServiceListHolder(String notifierEventScope) {
this.notifierEventScope = notifierEventScope;
NotifyCenter.registerSubscriber(this);
}
/**
* shut down.
*/
@Override
public void shutdown() {
// deregister subscriber which registered in constructor
NotifyCenter.deregisterSubscriber(this);
if (executorService != null && !executorService.isShutdown()) {
executorService.shutdown();
}
}
// ...
}
添加完成后再观察完成 shutdown
后的堆内存快照,可以发现较大的对象已经被回收了:

除此之外,也是剩余 DefaultPublisher
线程没有被关闭了,我们在下一小节中统一分析。
DefaultPublisher 等线程关闭
以 NacosNamingService#shutdown
后仍未关闭的 DefaultPublisher
类型为例,有 5 条线程没有被及时关闭,分别对应了 AbilityUpdateEvent
, InstancesChangeEvent
, ServerConfigChangeEvent
, NamingFuzzyWatchNotifyEvent
和 NamingFuzzyWatchLoadEvent
事件类型的处理线程:

DefaultPublisher
类型的线程对象都被保存在了 NotifyCenter#publisherMap
中,这些线程会在 NotifyCenter#shutdown
方法执行后关闭,而且可以在NotifyCenter
的静态代码块中发现定义了调用 shutdown
的钩子函数:
java
public class NotifyCenter {
static {
// ...
ThreadUtils.addShutdownHook(NotifyCenter::shutdown);
}
}
这个方法会在 JVM 退出时调用,这样内存快照中的 DefaultPublisher
和 DefaultSharePublisher
线程都会被统一关闭掉,相关发生引用的对象也会被回收。但是如果 JVM 不退出呢?问题就出在这里:war 包部署的应用在应用退出时不会主动关闭 JVM 那么便不会调用到这个钩子方法,这些线程就没办法清理掉。
所以,如果想关闭掉这些线程,那么便需要在执行 NacosConfigService#shutdown
和 NacosNamingService#shutdown
方法时添加上 NotifyCenter#shutdown
方法的调用:
java
public class NacosNamingService implements NamingService {
@Override
public void shutDown() throws NacosException {
serviceInfoHolder.shutdown();
clientProxy.shutdown();
namingFuzzyWatchServiceListHolder.shutdown();
// NotifyCenter#shutdown will shutdown all subscribers and publishers, so we don't need to
// NotifyCenter.deregisterSubscriber(changeNotifier);
// Shutdown NotifyCenter, include all subscribers and publishers
NotifyCenter.shutdown();
}
}
而且我们还需要针对 DefaultPublisher#shutdown
方法进行优化,添加 this.interrupt()
的逻辑,要不然它会被 DefaultPublisher#openEventHandler
方法中 final Event event = queue.take();
步骤阻塞中。打断它,让它抛出 InterruptedException
异常便能直接完成线程的关闭:
java
public class DefaultPublisher extends Thread implements EventPublisher {
@Override
public void run() {
openEventHandler();
}
void openEventHandler() {
try {
// This variable is defined to resolve the problem which message overstock in the queue.
int waitTimes = 60;
// To ensure that messages are not lost, enable EventHandler when
// waiting for the first Subscriber to register
while (!shutdown && !hasSubscriber() && waitTimes > 0) {
ThreadUtils.sleep(1000L);
waitTimes--;
}
while (!shutdown) {
// block
final Event event = queue.take();
receiveEvent(event);
UPDATER.compareAndSet(this, lastEventSequence, Math.max(lastEventSequence, event.sequence()));
}
} catch (Throwable ex) {
LOGGER.error("Event listener exception : ", ex);
}
}
@Override
public void shutdown() {
this.shutdown = true;
this.queue.clear();
// Interrupt the thread to stop processing events: queue.take().
this.interrupt();
}
}
这样改动完成后我们再来分析堆内存快照:

发现其中还有 DefaultPublisher
和 DefaultSharePublisher
类型的线程对象未被回收,查看 DefaultPublisher
的对象引用链来分析:

在引用链中能够发现引用与 NotifyCenter#INSTANCE
字段的引用有关,它是在 NotifyCenter
类中的静态字段,如果想去除这个引用对 DefaultPublisher
等类型线程的影响,我们可以将其在 NotifyCenter#shutdown
时声明为 null
:
java
public class NotifyCenter {
private static NotifyCenter INSTANCE = new NotifyCenter();
/**
* Shutdown the several publisher instance which notify center has.
*/
public static void shutdown() {
// ...
// help gc
INSTANCE = null;
}
}
这样我们再分析堆内存快照便能够发现那些线程已经都被回收掉了:

但是从根本上来说,关于 NotifyCenter
中存在未及时关闭的线程的原因都是因为 ThreadUtils.addShutdownHook(NotifyCenter::shutdown)
钩子方法没有被及时的触发,如果能在 war 包部署的程序中也及时触发到 NotifyCenter#shutdown
方法那么也不会有相关资源未关闭的问题。
未释放的 ScheduledExecutorService
完成以上改动后,发现在 NacosLogging#scheduleReloadTask
方法中会向 ThreadPoolManager#resourcesManager
中注册一个 ScheduledExecutorService
,ThreadPoolManager#resourcesManager
中管理的所有资源都会在 ThreadUtils#addShutdownHook
的钩子方法中统一被释放,如果这个钩子方法不被触发的话,那么这个线程池资源肯呢个没办法得到释放,而且对于 ThreadPoolManager#shutdown
方法的主动调用目前还不清楚放在哪里比较合适,与 NotifyCenter#shutdown
钩子方法的问题一样。
