关于 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 钩子方法的问题一样。


相关推荐
midsummer_woo1 小时前
基于spring boot的医院挂号就诊系统(源码+论文)
java·spring boot·后端
Olrookie2 小时前
若依前后端分离版学习笔记(三)——表结构介绍
笔记·后端·mysql
沸腾_罗强2 小时前
Bugs
后端
一条GO2 小时前
ORM中实现SaaS的数据与库的隔离
后端
京茶吉鹿2 小时前
"if else" 堆成山?这招让你的代码优雅起飞!
java·后端
长安不见2 小时前
从 NPE 到高内聚:Spring 构造器注入的真正价值
后端
你我约定有三2 小时前
RabbitMQ--消息丢失问题及解决
java·开发语言·分布式·后端·rabbitmq·ruby
程序视点3 小时前
望言OCR 2025终极评测:免费版VS专业版全方位对比(含免费下载)
前端·后端·github
rannn_1113 小时前
Java学习|黑马笔记|Day23】网络编程、反射、动态代理
java·笔记·后端·学习
一杯科技拿铁3 小时前
Go 的时间包:理解单调时间与挂钟时间
开发语言·后端·golang