📟 引言
@RefreshScope 是 Spring Cloud 提供的一个强大注解,专用于动态刷新 Spring Bean,尤其适用于 Spring Cloud Config 配置中心。当配置发生变更时,它可以重新加载 Bean,而无需重启整个应用。通常,我们可能会按照如下方式使用
@RefreshScope:在常规场景下,这样的使用方式没有问题,许多示例代码也是如此。但是,在高并发业务或调用链较长的情况下(例如下游服务异常导致超时),
@RefreshScope 可能会引发严重的性能瓶颈甚至服务假死的问题。⌨️ 线上问题案例
近期,我们在生产环境中遇到了一个严重的性能问题,原因正是
@RefreshScope 在高频业务中的误用。在修改线上配置后,服务出现了假死,无法响应请求。注:配置中心使用的是
nacos client 2.0.3 版本。堆栈信息(部分简化):
问题分析
从堆栈信息可以看到,
@RefreshScope 的核心逻辑是通过 代理模式 拦截方法调用,并在执行前加上读锁。然而,当 RefreshScope 触发 Bean 刷新 时,它会持有写锁,导致所有获取读锁的线程被阻塞,从而形成性能瓶颈。触发条件
- 高并发:多个线程同时调用
@RefreshScope标注的 Bean 方法。
- 配置变更:触发
refresh()操作,销毁旧 Bean 并创建新 Bean。
- 方法耗时:如果方法执行较慢,刷新操作会放大影响。
影响
- 请求阻塞:大量请求在
readLock.lock()处阻塞,影响整体响应时间。
- 服务雪崩:请求积压可能导致线程池耗尽,最终导致服务假死。
下面,我们一起通过源码分析来详细了解一下问题是如何触发的。
📸 源码分析
@RefreshScope 依赖 org.springframework.cloud.context.scope.refresh.RefreshScope 这个类来管理 Bean 的生命周期。当一个 Bean 被 @RefreshScope 标记时,它不会作为标准的 Spring 单例 Bean,而是由 RefreshScope 代理管理。我们来一起看下相关的源码。
@RefreshScope 注解定义解析
- @Scope("refresh"):将作用域定义为
"refresh",并由 Spring 容器的Scope实现类RefreshScope来接管管理。
ScopedProxyMode.TARGET_CLASS:指定作用域代理模式为TARGET_CLASS,即通过 CGLIB 动态代理机制代理该类。- CGLIB 动态代理生成一个子类代理对象,并在调用方法时通过代理实现逻辑拦截。
- 与
INTERFACES模式不同,它可以直接代理类(而不仅限于接口),适合大多数业务场景。
RefreshScope 类的实现
Spring Cloud 通过自定义
Scope 实现类 org.springframework.cloud.context.scope.refresh.RefreshScope 来管理 @RefreshScope 标注的 Bean,核心功能是动态刷新 Bean。RefreshScope 类的主要逻辑
scopeCache:内部维护一个缓存ConcurrentHashMap<String, Object>,存放已经初始化的 Bean 实例。
get(String name, ObjectFactory<?> objectFactory):- 首先检查缓存中是否已经存在对应的 Bean。
- 如果存在,则直接返回。
- 如果不存在,则使用
ObjectFactory创建新的实例并存入缓存。
refresh(String name):- 清除指定名称的 Bean 实例缓存。
- 下次访问该 Bean 时,重新通过工厂方法创建新的实例。
refreshAll():- 清空整个缓存并重新触发所有 Bean 实例的初始化。
RefreshScope 是通过自动配置类
RefreshAutoConfiguration 注册到 Spring 容器中。Bean 的加载与刷新
readLock():正常访问缓存时加读锁,可以并发访问。
- 注意此时是执行完代理方法之后才会释放读锁。
-
writeLock():刷新配置时加写锁,阻止其他线程读取,保证一致性。
可以写个demo,实测触发了刷新配置后, controller bean确实是刷新了的。

读写锁的处理
在
@RefreshScope 机制中,为了保证 Bean 的并发安全和一致性,Spring 使用了 java.util.concurrent.locks.ReentrantReadWriteLock 进行同步管理。读写锁的核心逻辑
RefreshScope 内部有一个 ReentrantReadWriteLock,用于保证 scopeCache 在多线程访问时的安全性:refreshLock.readLock():当访问RefreshScope代理的方法 时,加读锁,保证多个线程可以并发读取 Bean。
refreshLock.writeLock():当执行refresh()时,加写锁,阻塞所有读取操作,确保 Bean 刷新的一致性。
ReentrantReadWriteLock 分析
在 Java 并发框架
java.util.concurrent.locks.ReentrantReadWriteLock 中,ReadLock 允许多个线程同时持有,而 WriteLock 只允许单个线程独占。其底层基于 AbstractQueuedSynchronizer (AQS) 实现。先说
ReentrantReadWriteLock 在多线程中的特点:- 读线程 可以并发执行,不会互相阻塞。
- 写线程会等待所有读线程释放后再执行。
- 读线程等待写线程完成后才能继续执行。
ReentrantReadWriteLock通过 状态位state+ AQS 队列 管理锁的获取和释放,确保线程安全和高并发性能。
下面来分析具体的实现。
在
ReentrantReadWriteLock 内部,读锁和写锁共享一个同步器 Sync,其状态 state 由 AQS 维护:- 低 16 位(state & 0xFFFF):表示读锁的个数。
- 高 16 位(state >>> 16):表示写锁的重入次数。
exclusiveCount(state)获取写锁的状态。
sharedCount(state)获取读锁的状态。
假设当前多线程对读写锁的操作是:
- 前面 3 个线程获取了读锁
- 现在 1 个线程尝试获取写锁
- 后面 4 个线程尝试获取读锁
3 个线程获取读锁
当 3 个线程获取读锁时:
- 线程 T1、T2、T3 进入
readLock.lock()
tryAcquireShared()读取state
- 如果没有写锁,
sharedCount(state)自增
state低 16 位 存储读线程个数(例如state = 0x00030000表示有 3 个读线程)
- 线程进入共享模式,允许多个线程并行读取
源码分析
🔹 前 3 个线程分别执行 state += SHARED_UNIT,变为 0x00010000 → 0x00020000 → 0x00030000
1 个线程尝试获取写锁
当线程 T4 尝试获取写锁:
- 检查
state,发现sharedCount(state) != 0(有 3 个读线程)。
- 写锁不能与读锁共存,进入 AQS 等待队列。
- 进入阻塞状态,等待读锁释放。
源码分析
🔹 T4 发现 sharedCount(state) != 0,无法获取写锁,进入等待队列
后面 4 个线程尝试获取读锁
当线程 T5 - T8 尝试获取读锁:
- 进入
tryAcquireShared()方法
- 发现有 等待中的写锁(T4),读锁无法获取,进入等待队列
- T5-T8 进入阻塞状态
读锁释放 & 写锁获取
当 T1、T2、T3 释放读锁:
releaseShared()方法执行state -= SHARED_UNIT
- 读锁释放完成(
state = 0x00030000 → 0x00020000 → 0x00010000 → 0x00000000)
- 由于等待队列首位是写锁(T4),T4 唤醒并获取写锁
state变为0x00000001(表示写锁被获取)
写锁释放 & 读锁重新获取
当 T4 释放写锁:
state = 0x00000001 → 0x00000000
- T5-T8(等待中的读线程)被唤醒
- T5-T8 获取读锁
state再次增长到0x00040000
整体流程总结
时间点 | 操作 | 锁状态 ( state) | 队列等待情况 |
T1 获取读锁 | 读取 state=0,成功获取 | 0x00010000 | 无 |
T2 获取读锁 | 读取 state=0x00010000,成功获取 | 0x00020000 | 无 |
T3 获取读锁 | 读取 state=0x00020000,成功获取 | 0x00030000 | 无 |
T4 获取写锁 | 发现 sharedCount(state) != 0,进入等待 | 0x00030000 | T4 进入队列 |
T5 获取读锁 | 发现 等待中的写锁(T4),进入等待 | 0x00030000 | T4、T5 进入队列 |
T6 获取读锁 | 发现 等待中的写锁(T4),进入等待 | 0x00030000 | T4、T5、T6 进入队列 |
T7 获取读锁 | 发现 等待中的写锁(T4),进入等待 | 0x00030000 | T4、T5、T6、T7 进入队列 |
T8 获取读锁 | 发现 等待中的写锁(T4),进入等待 | 0x00030000 | T4、T5、T6、T7、T8 进入队列 |
T1-T3 释放读锁 | 依次释放读锁 | 0x00020000 → 0x00010000 → 0x00000000 | 唤醒 T4 |
T4 获取写锁 | 成功获取写锁 | 0x00000001 | T5-T8 仍在队列 |
T4 释放写锁 | state = 0 | 0x00000000 | 唤醒 T5-T8 |
T5-T8 重新获取读锁 | state += SHARED_UNIT | 0x00040000 | 读锁继续执行 |
由此,我们也可以直观的看出使用了
@RefreshScope 的类,在多线程执行同一方法时的情况。当某一个业务线程因为不知名原因,执行缓慢时,此时触发配置更新,会导致后续所有需要获取读锁的线程,都需要等待写锁释放,从而影响整体性能。
🚨 如何避免 @RefreshScope 带来的性能问题?
❌ 误用:直接作用于业务 Bean
⚠️ 问题:
- 业务方法阻塞:当
@RefreshScope作用于业务 Bean,刷新 Bean 需要销毁并重新创建,所有并发调用都会被阻塞。
✅ 最佳实践:独立配置 Bean
❗ 建议:将
@RefreshScope 作用于 专门的配置类,避免影响业务逻辑。然后在业务 Bean 中 只注入配置:
✅ 优势:
- 避免业务方法被锁:
DeviceService不需要被刷新,提升性能。
- 提高稳定性:只刷新配置,不影响正常业务调用。
- 更好的分层设计:解耦配置与业务逻辑。
📞 总结
@RefreshScope适用于动态配置刷新,但不应用于业务 Bean。
- 核心问题:
RefreshScope使用读写锁,可能导致高并发场景下的服务阻塞。
- 最佳实践:
- ✅ 将
@RefreshScope限定在配置 Bean,避免业务方法受影响。 - ✅ 分离配置类和业务逻辑,确保 Bean 只用于读取配置,而不涉及复杂计算。
🔋 参考资料
- 作者:Yibin
- 链接:https://yibin.dev/article/18e60b50-99a4-8076-beed-fe7926d565f0
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章







