Scoped Values 通过“进作用域才分配、出作用域即释放”将上下文内存占用压至 ThreadLocal 的 1/10 以下,需声明为 static final、仅在 Scope.open() 内绑定,不跨线程继承,日志框架须升级适配。

Scoped Values 能把上下文内存占用压到 ThreadLocal 的 1/10 以下,前提是严格按作用域生命周期使用 —— 它不靠“复用线程”省空间,而是靠“进作用域才分配、出作用域即释放”彻底规避强引用滞留。
ScopedValue 必须声明为 final 且只在 Scope.open() 内绑定
这是避免内存泄漏的第一道硬约束。ScopedValue 实例本身轻量,但它的值只有在 Scope.open() 创建的作用域中调用 set() 才真正关联到当前执行流;一旦作用域退出(哪怕抛异常),绑定的值自动从栈帧或虚拟线程本地状态中清除,不留下任何强引用。
常见错误现象:
- 在 Spring Bean 初始化时直接调用
ScopedValue.newInstance()并尝试set()→ 抛IllegalStateException: not in scope - 把
ScopedValue当成全局单例,在 filter 外提前set()→ 值无法被任何作用域捕获,读取始终为null
正确做法:
-
ScopedValue声明为static final,仅用于类型标识 - 每次请求进入 filter 或 controller 时,用
try (var scope = Scope.open())开启新作用域 - 在
scope.set(REQUEST_ID, "req-12345")中完成绑定,而非在作用域外调用
不要试图让 ScopedValue 穿透 CompletableFuture 或线程池任务
Scoped Values 默认不继承、不跨线程传递 —— 这不是缺陷,而是设计选择。它只在同一线程内、嵌套的作用域中可见;一旦遇到 supplyAsync()、runOnVirtualThread() 等显式切换执行线程的操作,原作用域值立即失效。
这意味着你不能像用 InheritableThreadLocal 那样“默认继承”,必须显式重建作用域:
- 在
CompletableFuture内部手动调用ScopedValue.where(...).run(...) - 若用
ExecutorService,需包装Runnable,在执行前重新打开作用域并绑定值 - Spring AOP 切面若运行在新线程(如异步切面),也需在切面内部重绑,否则日志/MDC 会丢失上下文
否则你会看到:日志里 request ID 突然变空、链路追踪断开、监控显示 ScopedValue.get() 返回 null。
替代 ThreadLocal 时,必须升级日志框架和 MDC 集成方式
Logback、Log4j2 默认仍依赖 MDC(基于 ThreadLocal),直接替换 ScopedValue 后,[%X{traceId}] 这类占位符会失效。
实操要点:
- Logback 需引入
logback-classic1.5.0+,并配置ScopedMDCFilter(非默认启用) - Log4j2 需启用
ThreadContextMap的ScopedValue实现,替换默认的ThreadLocalMap - 自定义工具类中所有
MDC.put("key", value)必须改为ScopedValue.where(KEY, value).run(...)或配合Scope.open()使用
否则上线后最直观的问题是:日志中 traceId 全部消失,但堆内存曲线却意外平稳 —— 因为旧 ThreadLocal 已停用,而新 ScopedValue 又没被日志框架识别,导致“看似没数据、实则没泄漏”。
真正难的是边界控制:ScopedValue 的“作用域”是代码块级别的,不是线程或请求级别的抽象。一个 HTTP 请求可能横跨多个虚拟线程、多次异步回调、甚至跨 JVM 调用,这时候你得一层层手动开 scope、传值、再闭合 —— 没有银弹,只有对执行路径的清晰掌控。











