关于 Nacos 在 war 包部署应用关闭部分资源未释放的原因分析

本篇文章是关于 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+ 所有的大对象,分析引用链可知:

  1. GrpcConnection 未关闭,并且通过引用链持有其他对象
  2. 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#registerInstanceNacosNamingService#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, NamingFuzzyWatchNotifyEventNamingFuzzyWatchLoadEvent 事件类型的处理线程:

DefaultPublisher 类型的线程对象都被保存在了 NotifyCenter#publisherMap 中,这些线程会在 NotifyCenter#shutdown 方法执行后关闭,而且可以在NotifyCenter 的静态代码块中发现定义了调用 shutdown 的钩子函数:

java 复制代码
public class NotifyCenter {

    static {
        // ...
        
        ThreadUtils.addShutdownHook(NotifyCenter::shutdown);
    }

}

这个方法会在 JVM 退出时调用,这样内存快照中的 DefaultPublisherDefaultSharePublisher 线程都会被统一关闭掉,相关发生引用的对象也会被回收。但是如果 JVM 不退出呢?问题就出在这里:war 包部署的应用在应用退出时不会主动关闭 JVM 那么便不会调用到这个钩子方法,这些线程就没办法清理掉。

所以,如果想关闭掉这些线程,那么便需要在执行 NacosConfigService#shutdownNacosNamingService#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();
    }
}

这样改动完成后我们再来分析堆内存快照:

发现其中还有 DefaultPublisherDefaultSharePublisher 类型的线程对象未被回收,查看 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 中注册一个 ScheduledExecutorServiceThreadPoolManager#resourcesManager 中管理的所有资源都会在 ThreadUtils#addShutdownHook 的钩子方法中统一被释放,如果这个钩子方法不被触发的话,那么这个线程池资源肯呢个没办法得到释放,而且对于 ThreadPoolManager#shutdown方法的主动调用目前还不清楚放在哪里比较合适,与 NotifyCenter#shutdown 钩子方法的问题一样。


相关推荐
二闹4 分钟前
三个注解,到底该用哪一个?别再傻傻分不清了!
后端
用户490558160812515 分钟前
当控制面更新一条 ACL 规则时,如何更新给数据面
后端
林太白17 分钟前
Nuxt.js搭建一个官网如何简单
前端·javascript·后端
码事漫谈19 分钟前
VS Code 终端完全指南
后端
该用户已不存在44 分钟前
OpenJDK、Temurin、GraalVM...到底该装哪个?
java·后端
怀刃1 小时前
内存监控对应解决方案
后端
码事漫谈1 小时前
VS Code Copilot 内联聊天与提示词技巧指南
后端
Moonbit1 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞 (上):编译前端实现
后端·算法·编程语言
Moonbit1 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞(下):llvm IR 代码生成
后端·程序员·代码规范
Moonbit2 小时前
MoonBit Pearls Vol.05: 函数式里的依赖注入:Reader Monad
后端·rust·编程语言