WEBKT

Spring Boot 3 开启虚拟线程后 ThreadLocal 内存泄露的深层原因与 ScopedValue 迁移指南

1 0 0 0

在 Spring Boot 3.2+ 中,通过一行配置 spring.threads.virtual.enabled=true 就能轻松开启虚拟线程(Virtual Threads)。这种“低成本榨干 CPU”的特性让很多开发者兴奋不已。

然而,在线上高并发场景下,不少团队兴冲冲地开启了虚拟线程,迎来的却不是性能飞跃,而是频繁的 OOM(内存溢出)和内存泄露。

这一切的幕后黑手,直指 Java 开发者最常用的工具之一 —— ThreadLocal


为什么 ThreadLocal 成了虚拟线程的“内存刺客”?

要理解这个问题,我们需要对比**平台线程(Platform Threads)虚拟线程(Virtual Threads)**在内存模型和生命周期上的本质区别。

1. 数量级的灾难:从“百级”到“百万级”

在传统的 Servlet 容器(如 Tomcat)中,平台线程是极其宝贵的资源,通常配置线程池大小为 200 左右。

  • 平台线程模式下ThreadLocalMap 的数量最多只有 200 个。即使每个 ThreadLocal 绑定的对象稍微大一点,总内存占用也是可控的(200 * 几兆 = 几百兆)。
  • 虚拟线程模式下:虚拟线程是极其廉价的,JVM 鼓励“每个任务一个线程”的编程范式。在并发峰值时,内存中可能会同时存在 10 万甚至上百万个虚拟线程。如果每个虚拟线程都往自己的 ThreadLocal 里放一个 10KB 的上下文对象,仅仅是这些上下文就会瞬间吃掉 1GB 的堆内存。

2. InheritableThreadLocal 的深层拷问

很多框架和业务系统会使用 InheritableThreadLocal 来实现父子线程之间的上下文传递(例如链路追踪 TraceId 传递)。
在创建子线程时,InheritableThreadLocal 会将父线程的 Map 完整复制一份给子线程。在虚拟线程生态中,由于经常涉及异步任务的分发和结构化并发,这种频繁的“复制”操作不仅会产生海量的垃圾对象,导致频繁 GC,还会因为引用链未及时释放而直接导致 OOM。

3. “忘记 remove()”的代价被无限放大

在平台线程池中,如果忘记调用 ThreadLocal.remove(),后果通常是下一个请求读取到了脏数据(因为线程被复用了)。
而在虚拟线程中:

  • 虚拟线程虽然生命周期短暂,用完即销毁,理论上其绑定的 ThreadLocal 也会随着线程被 GC 回收。
  • 但是,如果你的虚拟线程因为某种原因(如等待慢 SQL、I/O 阻塞、或者被 Carrier Thread 钉死/Pinning)导致生命周期变长,或者在代码中无意间使用了自定义的虚拟线程池(虽然这是反模式),那么 ThreadLocal 中未显式清除的对象将长期驻留内存。

救星降临:什么是 ScopedValue?

为了彻底解决 ThreadLocal 在虚拟线程和结构化并发(Structured Concurrency)中的缺陷,Java 21 引入了 ScopedValue(作用域值,目前处于 Preview 阶段)。

ThreadLocal 相比,ScopedValue 具有以下核心优势:

特性 ThreadLocal ScopedValue
可变性 可变(支持 set() 不可变(Immutable),绑定后无法修改
生命周期 与线程绑定,需手动 remove() 与作用域(Scope)绑定,超出作用域自动释放
内存开销 每个线程独占一个 Map,空间开销大 多个子线程可安全共享同一个值,无复制开销
安全性 容易发生内存泄露和脏数据读取 强约束,无泄露风险

ScopedValue 的核心思想是单向、不可变、受限生命周期。一旦绑定,在当前作用域内就只能读取(get()),无法修改(set())。当代码执行流走出该作用域时,绑定的数据自动失效并可被 GC 回收。


从 ThreadLocal 迁移到 ScopedValue 的实战指南

我们以最常见的 Web 请求用户上下文(UserContext)传递 为例,展示如何优雅地进行迁移。

1. 传统 ThreadLocal 实现(存在泄露风险)

public class UserContext {
    private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();

    public static void set(String userId) {
        USER_ID.set(userId);
    }

    public static String get() {
        return USER_ID.get();
    }

    public static void clear() {
        USER_ID.remove();
    }
}

在 Spring MVC 拦截器中:

public class SecurityInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String userId = request.getHeader("X-User-Id");
        UserContext.set(userId); // 写入
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        UserContext.clear(); // 必须手动清理,否则发生泄露
    }
}

2. 使用 ScopedValue 重构

由于 ScopedValue 是基于作用域的,我们需要用 ScopedValue.where(...).run(...)call(...) 来定义其生命周期。

在 Spring Boot 中,最适合承载这种生命周期边界的地方是 Filter(过滤器),而不是 Interceptor。因为 Filter 的 doFilter 刚好包裹了整个 Servlet 请求的执行过程。

第一步:定义 ScopedValue 容器

public class UserContext {
    // 定义一个全局只读的 ScopedValue
    public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
    
    // 提供便捷获取方法
    public static String getUserId() {
        return USER_ID.isBound() ? USER_ID.get() : null;
    }
}

第二步:在 Web Filter 中绑定作用域

利用 Filter 的调用链,我们将整个请求的执行过程包装在 ScopedValue.where 的作用域内:

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.stereotype.Component;
import java.io.IOException;

@Component
public class ScopedValueFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
            throws IOException, ServletException {
        
        if (request instanceof HttpServletRequest httpRequest) {
            String userId = httpRequest.getHeader("X-User-Id");
            
            if (userId != null) {
                // 核心:将请求后续的执行流绑定到该 ScopedValue 的作用域中
                try {
                    ScopedValue.where(UserContext.USER_ID, userId)
                               .call(() -> {
                                   chain.doFilter(request, response);
                                   return null;
                               });
                } catch (Exception e) {
                    if (e instanceof IOException ioEx) throw ioEx;
                    if (e instanceof ServletException servletEx) throw servletEx;
                    throw new ServletException(e);
                }
                return;
            }
        }
        
        chain.doFilter(request, response);
    }
}

第三步:在业务层无感读取

在 Controller 或 Service 层,你依然可以像以前一样简单地调用 UserContext.getUserId()

@RestController
@RequestMapping("/order")
public class OrderController {

    @PostMapping("/create")
    public ResponseEntity<String> createOrder() {
        // 直接安全读取,无需担心线程安全和内存泄露
        String currentUserId = UserContext.getUserId(); 
        return ResponseEntity.ok("Order created for user: " + currentUserId);
    }
}

避坑与进阶思考

Q1:开启 ScopedValue 需要注意什么?

由于 ScopedValue 在 Java 21 和 22 中仍属于 Preview 功能(但在 Java 23 中已经基本定型并有望正式发布),在编译和运行时需要添加 --enable-preview 参数。

  • Maven 编译配置
<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <compilerArgs>
            <arg>--enable-preview</arg>
        </compilerArgs>
    </configuration>
</plugin>
  • JVM 启动参数
java --enable-preview -jar your-app.jar

Q2:如果在作用域内需要修改值怎么办?

ScopedValue不可变的,你无法像 ThreadLocal 一样在执行中途调用 .set() 修改它。如果你需要临时改变它,可以采用**重新绑定(Rebinding / Shadowing)**的方式:

System.out.println(UserContext.getUserId()); // 输出 "UserA"

ScopedValue.where(UserContext.USER_ID, "UserB").run(() -> {
    // 在这个内部作用域中,值被临时“覆盖”为 UserB
    System.out.println(UserContext.getUserId()); // 输出 "UserB"
});

System.out.println(UserContext.getUserId()); // 回到外层,依然输出 "UserA"

这种嵌套绑定的设计,完美避开了多线程并发修改导致的数据错乱问题,也极大地简化了调试和追踪的难度。

总结

开启虚拟线程能显著提升 Spring Boot 3 应用的吞吐量,但传统的 ThreadLocal 已经不适应“百万级线程”的新时代。
通过将上下文管理迁移到 ScopedValue,不仅能消除手动 remove() 带来的泄露风险,还能优雅地适配未来的结构化并发编程。如果你的项目正在进行 JDK 21+ 与虚拟线程的升级,建议立刻评估并逐步淘汰传统的 ThreadLocal

码道人 虚拟线程

评论点评