李锋镝的博客

  • 首页
  • 时间轴
  • 说说
  • 左邻右舍
  • 博友圈
  • 关于我
    • 关于我
    • 另一个网站
    • 我的导航站
    • 网站地图
    • 赞助
  • 留言
  • 走心评论
  • 系列文章
  • Now
  • 每日心情
  • 🚇开往
Destiny
自是人生长恨水长东
  1. 首页
  2. 后端
  3. 正文

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

2026年7月31日 约 2,057 字7 分钟 8点热度 0人点赞 2条评论

背景

自己封装了一个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

推荐阅读

  • jasypt-spring-boot 使用说明
  • 重构 Controller 终极指南:从臃肿到优雅的 7 大黄金法则 + 实战技巧
  • 从3秒到30毫秒!SpringBoot树形结构深度优化指南:不止于O(n)算法的全链路提速方案
  • 解锁 Spring Boot 10 个高频 "神仙功能"
  • @Resource 和 @Autowired 的区别
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可
标签: Apollo JAVA Spring Cloud SpringBoot SpringValue
最后更新:2026年7月31日
相关文章
  • ReentrantLock深度解析2025年5月30日
  • Java 序列化和反序列化为什么要实现 Serializable 接口?2022年8月4日
  • 【收藏】从面试官角度观察到的程序员技能瓶颈,同时给出突破瓶颈的建议2019年10月16日
  • jasypt-spring-boot 使用说明2026年7月7日
  • 从"臃肿冗余"到"优雅简洁":那些让Java开发者顿悟的代码艺术与底层逻辑2025年10月28日

李锋镝

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

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

文章评论

  • 博客印象Lv 1

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

    WindowsChrome 109.0.0.0 中国-聊城市
    2026年7月31日
    00 回复
    • 李锋镝管理

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

      macOSChrome 150.0.0.0 中国-北京市
      2026年7月31日
      00 回复
  • 尘世难逢开口笑,菊花须插满头归。

    听点儿音乐吧 朋友~
    文章目录
    最新 热点 随机
    最新 热点 随机
    记一次Apollo配置中心+Spring配置自动刷新导致的内存泄露问题 给主题增加了Now、每日心情、年度回顾、岁月同一天、随机漫步等功能 增加了两套复古皮肤-牛皮纸、千禧网页 Claude Design介绍与使用 Kratos+主题新功能介绍 Taste Skill 说明与使用
    AI时代,个人技术博客的出路在哪里?增加了两套复古皮肤-牛皮纸、千禧网页给主题增加了Now、每日心情、年度回顾、岁月同一天、随机漫步等功能这个域名注册整整十年了,十年时间,真快啊WordPress实现用户评论等级排行榜插件WordPress网站换了个字体,差点儿把样式换崩了
    ReentrantLock深度解析 Spring事件驱动深度指南:从单机异步到亿级流量,比MQ更轻的架构神器 妹妹的画【2019.07.05】 jmap命令(jdk1.8) JWT 实现登录认证 + Token 自动续期方案 Java设计模式:模板方法模式
    最近评论
    李锋镝 发布于 2 天前(07月29日) 每个站长追求不一样,有的喜欢简洁,有的喜欢二次元,有的喜欢复古…喜欢就好
    老杨 发布于 2 天前(07月29日) 用什么主题不重要,简单明了最重要,用户体验的事你得问用户。
    李锋镝 发布于 1 周前(07月24日) 牛皮纸自带经典款老钱风buff~
    满心 发布于 1 周前(07月24日) 牛皮纸还是好看
    李锋镝 发布于 1 周前(07月23日) 都是AI的功劳~ :21:
    标签聚合
    AI编程 JAVA 日常 Spring Claude ElasticSearch JVM SpringBoot MySQL WordPress 数据库 IDEA 设计模式 多线程 架构 Redis 分布式 SQL K8s AI
    友情链接
    • 临窗旋墨
    • Mr.Sun的博客
    • Honesty
    • Blogs·CN
    • 懋和道人
    • 志文工作室
    • 皮皮社
    • 旧时繁华
    • 拾趣博客导航
    • 老张博客
    • 瓦匠个人小站
    • 知向前端
    • 韩情脉脉
    • 韩小韩博客
    • 彬红茶日记
    • 风渡言
    • 搬砖日记
    • 林羽凡
    • 哥斯拉

    COPYRIGHT © 2026 lifengdi.com. ALL RIGHTS RESERVED.

    域名年龄

    Theme Kratos+ By Dylan Li

    津ICP备2024022503号-3

    京公网安备11011502039375号