李锋镝的博客

  • 首页
  • 时间轴
  • 说说
  • 读书观影
  • 系列
  • AI工具集
  • 论坛
  • 友圈
    • 友链
    • 朋友圈
    • 游客中心
  • 留言
    • 留言
    • 走心评论
  • 关于
    • 关于本站
    • Now
    • 每日心情
    • 网站地图
    • 网站统计
    • 赞助
  • 星球穿梭
  • 🚇开往
静亦定 动亦定
惟坚韧者始能遂其志
  1. 首页
  2. 后端
  3. 正文

记一次Apollo配置中心+Spring配置自动刷新导致的内存泄露问题

2026年7月31日 515 1 4

背景

自己封装了一个Apollo的客户端,在官方支持@Value注解自动刷新的基础上,额外封装了@RefreshScope和@ConfigurationProperties注解的自动刷新,代码如下:

import com.ctrip.framework.apollo.model.ConfigChangeEvent;
import com.ctrip.framework.apollo.spring.annotation.ApolloConfigChangeListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cloud.endpoint.event.RefreshEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

/**
 * @ClassName: SpringBootApolloRefreshConfig
 * @Author: Dylan Li
 * @Date: 2026/4/8 10:10
 */
@Component
@Slf4j
@ConditionalOnProperty(name = "config-center.auto-refresh", havingValue = "true", matchIfMissing = false)
public class SpringBootApolloRefresh {

    private final ApplicationEventPublisher eventPublisher;

    public SpringBootApolloRefresh(
            final ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }

    @ApolloConfigChangeListener(value = "${apollo.bootstrap.namespaces}")
    public void onChange(ConfigChangeEvent changeEvent) {
        if (log.isDebugEnabled()) {
            log.debug("ConfigChangeListener refresh ConfigChangeEvent begin, changedKeys={}",
                    changeEvent.changedKeys());
        }
        eventPublisher.publishEvent(new RefreshEvent(this, changeEvent, "Refresh Apollo config"));
        if (log.isDebugEnabled()) {
            log.debug("ConfigChangeListener publish RefreshEvent success.");
        }
    }
}

原本以为很简单的配置,结果通过查看Java进程的对象数,发现com.ctrip.framework.apollo.spring.property.SpringValue对象数量在配置变更的时候疯狂增加,触发GC也无法被回收,很明显,存在内存泄露。

原因分析

SpringValue 是在 SpringValueProcessor.doRegister() 里无条件 new 出来的。

注册路径是 BeanPostProcessor.postProcessBeforeInitialization —— 也就是每个 bean 实例走一次。而SpringValueRegistry.register() 用的是 LinkedListMultimap,允许同 key 重复,没有任何去重。
所以只要 bean 实例被反复重建,SpringValue就反复堆积。

而官方的回收机制在这个场景下是失效的:
scanAndClean() 每 5 秒跑一次,唯一的删除条件是 isTargetBeanValid(),即 beanRef.get() == null—— 依赖 bean 被 GC 掉、WeakReference 被清空。但:

private final Map<BeanFactory, Multimap<String, SpringValue>> registry = Maps.newConcurrentMap();

外层 map 的 key 是强引用的 BeanFactory,而且这个 entry 永远不会被删除(整个类里没有任何 registry.remove())。BeanFactory 活着 → 它的 singletonObjects 强引用所有单例 bean → 那些 bean 的 WeakReference 永远清不掉 →
isTargetBeanValid() 恒为 true → 老的 SpringValue 一个都删不掉。

scanAndClean 只能回收那些已经从 BeanFactory 缓存里被移除的实例(@RefreshScope 被 destroy 掉的 target、prototype)。

通过jmap -histo:live <pid> | grep SpringValue可以很明显的看到这个增长曲线。

而把config-center.auto-refresh=false关掉,SpringValue的增长停了。

问题已经很明显了。

解决办法

压缩Apollo SpringValueRegistry,回收 rebind 造成的重复SpringValue。

周期性地把「同一目标 Bean 实例 + 同一成员 + 同一 key」的重复 SpringValue 压成一份 (保留最后注册的一份)。

这里以目标 Bean 实例身份而非 beanName 作为去重维度是关键: SpringValueProcessor 对每个 Bean 实例都会登记(无 singleton 判断),因此 prototype / request / session 作用域的多个存活实例会共享同一 beanName 与同一成员。

若按 beanName 去重,会误删仍然存活的实例对应的 SpringValue,使其静默失去自动刷新能力。按实例身份去重后,被折叠的 一定指向同一对象,保留任意一份功能等价,对现有刷新逻辑零影响,只是把注册表占用从「无界」压回「有界」。

代码如下:

import com.ctrip.framework.apollo.core.utils.ApolloThreadFactory;
import com.ctrip.framework.apollo.spring.property.SpringValue;
import com.ctrip.framework.apollo.spring.property.SpringValueRegistry;
import com.ctrip.framework.apollo.spring.util.SpringInjector;
import com.google.common.collect.Multimap;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;

import java.lang.ref.WeakReference;
import java.lang.reflect.Field;
import java.util.Collections;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.WeakHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

@Slf4j
@Component
@ConditionalOnProperty(name = "config-center.auto-refresh", havingValue = "true", matchIfMissing = false)
public class SpringValueRegistryCompactor implements InitializingBean, DisposableBean {

    /** 连续失败多少次后升级为 ERROR 日志(避免反射长期失效却只有 WARN、泄漏无声回归)。 */
    private static final int FAILURE_ESCALATE_THRESHOLD = 5;

    /** 重复项增长缓慢,无需高频扫描 —— 每轮都要持有 Apollo 注册表锁,间隔宁长勿短。 */
    private static final long CLEAN_INTERVAL_IN_SECONDS = 60;

    /** 独立单线程 daemon 调度器,不依赖 Spring @EnableScheduling、不占用业务调度线程。 */
    private ScheduledExecutorService scheduler;

    /** 懒加载的 Apollo 注册表单例:构造期解析会让 Guice 初始化失败直接拖垮上下文启动。 */
    private volatile SpringValueRegistry springValueRegistry;

    /** 反射缓存:SpringValueRegistry 内部的 registry 私有字段。 */
    private volatile Field registryField;

    /** 反射缓存:SpringValue 内部指向目标 Bean 实例的 beanRef 私有字段。 */
    private volatile Field beanRefField;

    /** 连续失败计数:一旦成功即清零,持续失败则升级告警。 */
    private final AtomicInteger consecutiveFailures = new AtomicInteger();

    /**
     * 上轮压缩后各 BeanFactory 的注册表条目数,用于「未增长则跳过」短路。
     * 弱键:BeanFactory 销毁后自动移除,不额外延长其生命周期。
     */
    private final Map<BeanFactory, Integer> lastSizes = Collections.synchronizedMap(new WeakHashMap<>());

    @Override
    public void afterPropertiesSet() {
        this.scheduler = Executors.newSingleThreadScheduledExecutor(
                ApolloThreadFactory.create("SpringValueRegistryCompactor", true));
        this.scheduler.scheduleAtFixedRate(this::compactQuietly,
                CLEAN_INTERVAL_IN_SECONDS, CLEAN_INTERVAL_IN_SECONDS, TimeUnit.SECONDS);
        log.info("SpringValueRegistryCompactor started, interval={}s.", CLEAN_INTERVAL_IN_SECONDS);
    }

    @Override
    public void destroy() {
        if (scheduler != null) {
            scheduler.shutdownNow();   // Bean 销毁时关闭调度器,避免线程/执行器泄漏
        }
    }

    /** 调度入口:吞掉一切异常,防止任务因抛异常被 scheduleAtFixedRate 静默终止。 */
    private void compactQuietly() {
        try {
            compact();
        } catch (Throwable ex) {
            log.warn("SpringValueRegistryCompactor unexpected error: {}", ex.toString());
        }
    }

    // 锁的是注册表内的共享 multimap 实例(与 Apollo scanAndClean 同一把锁),非普通局部变量,故抑制该误报
    @SuppressWarnings("SynchronizationOnLocalVariableOrMethodParameter")
    void compact() {
        try {
            Map<BeanFactory, Multimap<String, SpringValue>> registry = resolveRegistry();
            if (registry == null || registry.isEmpty()) {
                consecutiveFailures.set(0);
                return;
            }

            int removed = 0;
            for (Map.Entry<BeanFactory, Multimap<String, SpringValue>> registryEntry : registry.entrySet()) {
                // springValues 是 Multimaps.synchronizedListMultimap(...) 包装实例:其内部 mutex 即自身
                // (构造时未传外部 mutex),因此锁住该实例即与 Guava 每次 put()/get() 的内部锁互斥,
                // 也与 Apollo scanAndClean() 的 synchronized(springValues) 一致。此依赖 Guava 内部行为,
                // 若未来 Apollo 改用其它 Multimap 包装或显式外部 mutex,此互斥将失效,需同步调整。
                Multimap<String, SpringValue> springValues = registryEntry.getValue();
                synchronized (springValues) {
                    // 短路:条目数未超过上轮压缩后的水位,说明没有新增登记,无需全量扫描。
                    // size() 与压缩同处一把锁内,读到的水位与后续扫描一致。
                    Integer lastSize = lastSizes.get(registryEntry.getKey());
                    if (lastSize != null && springValues.size() <= lastSize) {
                        continue;
                    }
                    removed += compactOne(springValues);
                    lastSizes.put(registryEntry.getKey(), springValues.size());
                }
            }
            consecutiveFailures.set(0);
            if (removed > 0 && log.isDebugEnabled()) {
                log.debug("SpringValueRegistry compacted, removed {} duplicated SpringValue(s).", removed);
            }
        } catch (Throwable ex) {
            // 反射面属 Apollo 内部结构,单次失败仅跳过本轮、绝不影响业务;
            // 但持续失败意味着压缩器已失效、SpringValue 泄漏会无声回归 —— 升级为 ERROR 以便告警。
            int failures = consecutiveFailures.incrementAndGet();
            if (failures >= FAILURE_ESCALATE_THRESHOLD) {
                log.error("SpringValueRegistry compact FAILED {} consecutive times; "
                        + "SpringValue leak protection is INACTIVE (Apollo internals may have changed).",
                        failures, ex);
            } else {
                log.warn("SpringValueRegistry compact skipped ({} consecutive): {}", failures, ex.toString());
            }
        }
    }

    /** 保留每个「目标实例 + 成员」最后注册的一份,删除其余重复项;返回删除数量。 */
    private int compactOne(Multimap<String, SpringValue> springValues) throws Exception {
        // 第一遍:LinkedListMultimap 保持插入顺序,后写覆盖 => winner 即最新注册的一份。
        // 外层按实例身份分桶(IdentityHashMap 用 == 比较,不依赖 identityHashCode,无碰撞风险)
        Map<Object, Map<String, SpringValue>> winners = new IdentityHashMap<>();
        for (Map.Entry<String, SpringValue> entry : springValues.entries()) {
            Object bean = targetBeanOf(entry.getValue());
            if (bean == null) {
                continue;   // 目标已 GC,留给 Apollo scanAndClean
            }
            winners.computeIfAbsent(bean, k -> new HashMap<>())
                    .put(memberIdentity(entry.getKey(), entry.getValue()), entry.getValue());
        }

        // 第二遍:非 winner(指向同一实例的旧重复项)删除
        int removed = 0;
        Iterator<Map.Entry<String, SpringValue>> it = springValues.entries().iterator();
        while (it.hasNext()) {
            Map.Entry<String, SpringValue> entry = it.next();
            Object bean = targetBeanOf(entry.getValue());
            if (bean == null) {
                continue;
            }
            Map<String, SpringValue> perBean = winners.get(bean);
            if (perBean == null) {
                continue;   // 第一遍时目标尚存、此刻已 GC,同样留给 scanAndClean
            }
            if (perBean.get(memberIdentity(entry.getKey(), entry.getValue())) != entry.getValue()) {
                it.remove();
                removed++;
            }
        }
        return removed;
    }

    /** 同一实例内的成员唯一标识:占位符 key + 字段/方法成员。 */
    private String memberIdentity(String key, SpringValue springValue) {
        String member;
        if (springValue.isField()) {
            Field field = springValue.getField();
            member = "F:" + field;
        } else {
            MethodParameter mp = springValue.getMethodParameter();
            member = "M:" + mp.getMethod() + '#' + mp.getParameterIndex();
        }
        return key + '|' + member;
    }

    /** 读取 SpringValue 的目标 Bean 实例;已被 GC 时返回 null。 */
    private Object targetBeanOf(SpringValue springValue) throws NoSuchFieldException, IllegalAccessException {
        Field field = beanRefField;
        if (field == null) {
            synchronized (this) {
                field = beanRefField;
                if (field == null) {
                    field = SpringValue.class.getDeclaredField("beanRef");
                    field.setAccessible(true);
                    beanRefField = field;
                }
            }
        }
        WeakReference<?> beanRef = (WeakReference<?>) field.get(springValue);
        return beanRef == null ? null : beanRef.get();
    }

    @SuppressWarnings("unchecked")
    private Map<BeanFactory, Multimap<String, SpringValue>> resolveRegistry() throws NoSuchFieldException, IllegalAccessException {
        Field field = registryField;
        if (field == null) {
            synchronized (this) {
                field = registryField;
                if (field == null) {
                    field = SpringValueRegistry.class.getDeclaredField("registry");
                    field.setAccessible(true);
                    registryField = field;
                }
            }
        }
        return (Map<BeanFactory, Multimap<String, SpringValue>>) field.get(resolveSpringValueRegistry());
    }

    /** 懒解析 Apollo 注册表单例:失败被 compact() 的 catch 兜住,不影响业务与启动。 */
    private SpringValueRegistry resolveSpringValueRegistry() {
        SpringValueRegistry registry = springValueRegistry;
        if (registry == null) {
            synchronized (this) {
                registry = springValueRegistry;
                if (registry == null) {
                    registry = SpringInjector.getInstance(SpringValueRegistry.class);
                    springValueRegistry = registry;
                }
            }
        }
        return registry;
    }
}
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可
分享到

记一次Apollo配置中心+Spring配置自动刷新导致的内存泄露问题

也可使用浏览器菜单中的「分享」功能

微信扫一扫分享

标签: Apollo JAVA Spring Cloud SpringBoot SpringValue
最后更新:2026年7月31日

岁月同一天 10 月 30 日

回望过去的今天,你在写什么

  • 1 年前 2025年10月30日
    深度解析 Kafka Rebalance:从原理到实战,彻底解决消息积压、重复与丢失

    在 Kafka 消费者集群运维中,你是否遇到过这些棘手问题? 生产环境突然告警,Topic 消息积压量10分钟内飙升至5…

  • 杂谈
    1 年前 2025年10月30日
    秦始皇为什么焚书坑儒?

    秦始皇“焚书坑儒”本质是为巩固中央集权、统一思想文化而推行的政治举措,并非单纯的文化毁灭或迫害儒生,其背后有明确的政治动…

  • 中间件
    1 年前 2025年10月30日
    MySQL 同步 ElasticSearch 深度指南——6 种方案的原理、实战与避坑

    在分布式架构中,MySQL 与 Elasticsearch(ES)的组合早已成为“事务存储+高效检索”的黄金搭档——My…

  • 其他
    7 年前 2019年10月30日
    程序员的自我修养

    首先要谈的是,今天的话题所聊的程序员包含哪些人? 在中国,写程序,不仅仅是一种兴趣,更多的时候,还是一种普通职业和谋生工…

  • 7 年前 2019年10月30日
    分布式之数据库和缓存双写一致性方案解析

    引言 为什么写这篇文章? 首先,缓存由于其高并发和高性能的特性,已经在项目中被广泛使用。在读取缓存方面,大家没啥疑问,都…

相关文章
  • 关闭apache httpclient4.5 DEBUG日志2019年7月9日
  • 分布式锁-Zookeeper实现分布式锁2019年11月11日
  • Java之五种遍历Map集合的方式2021年2月19日
  • MyBatis vs Spring Data JPA 从原理到实战全解析2025年10月21日
  • ConcurrentHashMap常用方法源码解析(jdk1.8)2019年10月10日

李锋镝

既然选择了远方,便只顾风雨兼程。

打赏 点赞
< 上一篇
下一篇 >
1234567891112131415161718192021222324252627282930313233343536373839404142434446474849505152535455575859606162636465666769727476777879808182858687909293949596979899
取消回复
…

文章评论

  • HaryLv 3友

    这机场又出来了,我也收到这个垃圾评论了,大哥赶紧删除它

    AndroidAndroidChrome 150.0.0.0Chrome 150.0.0.0中国中国-安徽-马鞍山
    2026年8月1日
    00 回复
    • 李锋镝博主

      @Hary 删了,哈哈哈,已经把这个机场关键字加黑名单了

      WindowsWindowsChrome 150.0.0.0Chrome 150.0.0.0中国中国-北京
      2026年8月1日
      00 回复
  • 博客印象Lv 1

    从定位到根治完整闭环,干货满满的后端排障实录

    WindowsWindowsChrome 109.0.0.0Chrome 109.0.0.0中国中国-山东-聊城
    2026年7月31日
    10 回复
    • 李锋镝博主

      @博客印象 :44: 踩坑踩多了

      macOSmacOSChrome 150.0.0.0Chrome 150.0.0.0中国中国-北京
      2026年7月31日
      10 回复
  • 从此无心爱良夜,任他明月下西楼。

    听点儿音乐吧 朋友~
    文章目录
    最新 热点 随机
    最新 热点 随机
    又一个逆天改命的人:小小徐 AI的生成能力等于生产力吗? NGINX严重漏洞(CVE-2026-15176、CVE-2026-93479、CVE-2026-97205) Kratos-plus v1.1.27更新说明 php2shell远程执行漏洞修复(CVE-2026-109618 和 CVE-2026-109619) 聊一聊DeepSeek开放自研搜索API这事
    给主题增加了游客中心功能心气没了,干什么都不得劲我觉得是时候公布我的导航网站了游本昌去世关于主题加载速度优化的一点儿小演进给主题增加了Now、每日心情、年度回顾、岁月同一天、随机漫步等功能
    jmap命令(jdk1.8) Elasticsearch常用查询 Dapr 知多少 | 分布式应用运行时 TestNG基本注解 跨平台版本管理神器,开发者的环境配置救星:vfox LDC是什么?
    最近评论
    李锋镝 发布于 3 天前(10月27日) 没毛病
    李锋镝 发布于 3 天前(10月27日) AI还得人给它堆算力,而且现在放出来的AI都是阉割、限制版,不是完全体
    fairy 发布于 4 天前(10月27日) 这就没办法了,本身就是双刃剑。AI本身只是工具而已。
    Hary 发布于 4 天前(10月26日) AI能用AI完善它自己么,到时候程序员亲手把自己干掉了
    海风心情 发布于 4 天前(10月26日) 生成能力只是起点,价值还得靠落地和验证,中间那些环节才是真功夫。
    标签聚合
    Redis ElasticSearch K8s Claude Theme 日常 WordPress 分布式 MySQL SQL IDEA SpringBoot 数据库 Spring JVM 架构 AI编程 JAVA 多线程 AI
    友情链接
    • 瓦匠个人小站
    • 皮皮社
    • sssr7844的博客
    • 彬红茶日记
    • 懋和道人
    • 知向前端
    • 林羽凡
    • Mr.Sun的博客
    • 哥斯拉
    • 志文工作室
    • Honesty
    • lijie blog
    • 韩情脉脉
    • Serendipity
    • 搬砖日记
    • 九仞之行
    • 刘永博客
    • 老张博客
    • 韩小韩博客

    COPYRIGHT © 2016-2026 lifengdi.com. ALL RIGHTS RESERVED.

    lifengdi.com 十年之约虫洞

    Domain age badge for lifengdi.com

    Theme Kratos-plus By Dylan Li

    津ICP备2024022503号-3

    京公网安备11011502039375号