李锋镝的博客

  • 首页
  • 时间轴
  • 说说
  • 每日心情
  • Now
  • 系列文章
  • 论坛
  • 左邻右舍
    • 左邻右舍
    • 博友圈
  • 留言
    • 留言
    • 走心评论
  • 关于
    • 关于本站
    • 网站地图
    • 网站统计
    • 另一个网站
    • 我的导航站
    • 赞助
  • 🚇开往
!Destiny
惟坚韧者始能遂其志
  1. 首页
  2. 后端
  3. 正文

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

2026年7月31日 约 2,057 字7 分钟 335 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;
    }
}
除非注明,否则均为李锋镝的博客原创文章,转载必须以链接形式标明本文链接

本文链接:https://www.lifengdi.com/hou-duan/4861

本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可
分享到

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

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

微信扫一扫分享

标签: Apollo JAVA Spring Cloud SpringBoot SpringValue
最后更新:2026年7月31日
相关文章
  • RocketMQ入门级教程2022年8月16日
  • 阿里巴巴《Java开发手册》下载2019年6月28日
  • jasypt-spring-boot 使用说明2026年7月7日
  • Java 灵魂拷问 13 个为什么,你都会哪些?2025年5月20日
  • 以面试官视角万字解读线程池10大经典面试题2025年5月14日

李锋镝

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

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

文章评论

  • blank
    HaryLv 2友

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

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

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

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

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

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

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

      macOSmacOSChrome 150.0.0.0Chrome 150.0.0.0location中国-北京
      2026年7月31日
      10 回复
  • 惟坚韧者始能遂其志。

    听点儿音乐吧 朋友~
    文章目录
    最新 热点 随机
    最新 热点 随机
    支撑全网40%网站的WordPress正在重新拥抱PHP生态 Kratos-plus v1.1.24版本更新说明 关于主题加载速度优化的一点儿小演进 市场主流AI编程大模型横向深度分析(2026-09-11) 一款节省token的利器:RTK(Rust Token Killer) RAG太难学?LLM Wiki了解一下
    给主题增加了Now、每日心情、年度回顾、岁月同一天、随机漫步等功能WordPress缓存插件WP Fastest Cache、WP Rocket 、FlyingPress对比关于使用AI的一些思考Kratos+ v1.1.16版本更新说明AI时代,个人技术博客的出路在哪里?推荐一个SVG 矢量小图标免费下载网站
    通俗解释下Spring的Ioc原理 IntelliJ IDEA 2020.3.x永久白嫖(Windows/Mac) Taste Skill 说明与使用 网易云什么时候能有杰伦的歌…… Spring Boot 2.5.0重新设计的spring.sql.init 配置有啥用? ElasticSearch入门-基本概念介绍以及安装
    最近评论
    blank
    lijie blog 发布于 2 小时前(09月14日) 友链申请 名称:lijie blog 链接:https://lijie.cool 描述:懒于当...
    blank
    Hary 发布于 4 小时前(09月14日) 等PHP9推倒重来,出个船新版本
    blank
    李锋镝 发布于 16 小时前(09月14日) 不愧是皮总,这措辞~ :30:
    blank
    皮皮社长 发布于 16 小时前(09月14日) :20: 我勒了个去,中国汉字波大精深。 :42:
    blank
    李锋镝 发布于 16 小时前(09月14日) 我这里面也带了一堆小表情包,看看不行也优化一波
    标签聚合
    IDEA Spring K8s 架构 SpringBoot Redis MySQL 分布式 多线程 WordPress JAVA MQ AI JVM ElasticSearch AI编程 日常 SQL Claude 数据库
    友情链接
    • 蜗牛工作室
    • 临窗旋墨
    • sssr7844的博客
    • 皮皮社
    • 若梦博客
    • 瓦匠个人小站
    • 哥斯拉
    • 知向前端
    • 懋和道人
    • 老张博客
    • Honesty
    • 彬红茶日记
    • Mr.Sun的博客
    • 志文工作室
    • 九仞之行
    • 林羽凡
    • 韩小韩博客
    • 韩情脉脉
    • Serendipity
    • 搬砖日记

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

    Domain age badge for lifengdi.com

    Theme Kratos-plus By Dylan Li

    津ICP备2024022503号-3

    京公网安备11011502039375号