《Java 网络编程基础》12.3 上下文传播与线程安全

本节处理跨线程的请求上下文:ThreadLocal 在线程池中的残留与泄漏、ScopedValue 在 JDK 25 的绑定与恢复、事件循环到业务线程的显式传播,以及不可变消息与 happens-before 如何建立可见性;给出可运行的 RequestContext 与本机输出。

本节目标:让请求身份、租户、追踪号和截止时间安全地跨过事件循环与业务线程的边界,并说清哪些共享是安全的、哪些会串数据。
适用版本:JDK 25(Temurin 25.0.5+8);ThreadLocal、ScopedValue、record。

承接的问题

12.2 把业务处理挪到了 biz-* 线程池。请求在事件循环上解析,在业务线程上路由、鉴权、编码,最后再回到事件循环写回。这条路径上有三样东西必须跟着请求走:请求 ID(日志串联)、用户或租户(鉴权与数据隔离)、截止时间(超时与降级)。它们统称为请求上下文。

最直觉的做法是 ThreadLocal:在事件循环线程 set,业务线程 get。但业务线程来自池子,池子里的线程会复用;事件循环线程和业务线程也不是同一条。ThreadLocal 不会自动跨线程,池化线程还会把上一个请求的值留给下一个请求。一个租户隔离的系统里,这不是日志串号,而是用户 A 的请求读到了用户 B 的数据。

这一节按三层处理:先看 ThreadLocal 为什么会残留,再看 JDK 25 的 ScopedValue 如何做有界绑定和恢复,最后把上下文显式传过线程边界,并用不可变对象和 happens-before 建立可见性。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。下面的输出来自这台机器,单次运行,未做统计。

ThreadLocal:复用线程上的残留

ThreadLocal 的值存在每个 Thread 自己的 ThreadLocalMap 里。线程池的线程活到进程结束,值也就一直活着,直到被覆盖或 remove()。常见写法是:

private static final ThreadLocal<RequestContext> LEGACY = new ThreadLocal<>();

void handle(RequestContext context) {
    LEGACY.set(context);
    try {
        route();
    } finally {
        LEGACY.remove();
    }
}

finally 里的 remove() 不是可选项。少了它,任务结束、线程回到池子,ThreadLocalMap 里的 RequestContext 仍然被这条线程强引用;下一个任务如果忘了 set,get() 就会拿到上一个请求的用户。ThreadLocal 的 key 是弱引用,value 是强引用;key 被回收后 value 还可能留在这个线程的 map 里,直到下一次清理。请求上下文里如果有类加载器、缓冲区或大对象,泄漏的就不只是几个字段。

InheritableThreadLocal 也不是解药。它只在线程创建时从父线程复制一次;池子里的线程通常在第一批请求时就创建好了,后面的请求再 set 父线程的值,子线程看不到。更糟的是,第一条请求的值会被这条池化线程永久继承,成为所有后续任务的默认上下文。

ScopedValue:有界绑定与自动恢复

JDK 25 的 ScopedValue 已经正式可用(JEP 506)。它把「值绑定到一段代码的作用域」做成语言级结构:绑定只在 run 的回调里可见,回调结束自动恢复上一个绑定,不需要 remove(),也不会把值留在线程上。

public static final ScopedValue<RequestContext> CURRENT = ScopedValue.newInstance();

public static Runnable wrap(RequestContext context, Runnable task) {
    return () -> ScopedValue.where(CURRENT, context).run(task);
}

ScopedValue.where(CURRENT, context).run(task) 在当前线程上建立绑定,task 执行期间 CURRENT.get() 返回 context;task 返回或抛异常,绑定都自动撤销。嵌套绑定按栈恢复:内层 where 覆盖外层,内层结束后外层仍然可见。CURRENT.isBound() 可以判断当前线程有没有绑定,CURRENT.orElse(null) 可以给缺省值。

关键限制:ScopedValue 的绑定是线程本地的,不会自动传给 ExecutorService.submit() 的任务。把 wrap 过的任务提交到业务池,是因为 wrap 在业务线程上重新建立了绑定;如果直接提交一个普通 lambda,业务线程上 CURRENT.isBound() 仍然是 false。结构化并发(JDK 25 仍是预览 API)的 StructuredTaskScope fork 出的子任务会继承绑定,这是它和普通线程池的区别;linkd 主线不用预览 API,所以统一用 wrap。

两者的取舍可以压成一张表:

维度ThreadLocalScopedValue
绑定范围线程生命周期,直到 remove词法作用域,回调结束自动撤销
清理义务调用方负责运行时负责
普通线程池传播不传播不传播,仍需 wrap
嵌套绑定手动保存和恢复按栈自动恢复
适用场景线程独占的长期状态每请求一份的短生命周期数据

显式参数传递当然也能解决传播问题,而且最容易被编译器检查。代价是每个需要 requestId 的深层方法都要多一个参数,日志、鉴权、限流这些横切逻辑会把签名弄得很长。ScopedValue 是在「隐式」和「有界」之间取平衡:读的时候不用传参,但绑定只在明确的作用域内有效,不会像 ThreadLocal 那样留到下一个请求。

从事件循环到业务线程:显式传播

上下文应该在解析完请求头之后创建,在进入业务池之前捕获,在业务线程上重新绑定。不要依赖「事件循环线程设了 ThreadLocal,业务线程自然能读到」。

RequestContext context = RequestContext.of(requestId, userId, traceId, deadline);
business.execute(RequestContext.wrap(context, () -> {
    HttpResponse response = route(request);
    byte[] body = encode(response);
    loop.execute(() -> connection.enqueue(ByteBuffer.wrap(body)));
}));

这段代码里有四个决策:

  1. 上下文在事件循环上创建。 requestId、userId、traceId 都来自已经解析好的请求,创建过程只读,不碰连接状态。
  2. wrap 在提交时捕获,不在业务线程里查。 业务线程没有事件循环的 ThreadLocal,也不该反向依赖循环线程的状态。
  3. 写回任务不携带上下文。 loop.execute 里的 lambda 只捕获 connection 和 body;如果写回日志也需要 requestId,在提交前把字符串取出来,或者再用 RequestContext.wrap 包一层。不要捕获整个 RequestContext 后又去读它的可变字段。
  4. 截止时间在业务线程入口检查。 context.remainingMillis() 小于下游 p99 时直接降级,不再发起新的远程调用;已经过期的请求回 504。
if (context.remainingMillis() < downstreamP99Millis) {
    byte[] body = encode(gatewayTimeout());
    loop.execute(() -> connection.enqueue(ByteBuffer.wrap(body)));
    return;
}

这个检查要放在访问下游之前,而不是等下游超时再判断;否则业务线程已经被占住,截止时间只剩日志意义。

虚拟线程场景下,每个任务一条虚拟线程,ThreadLocal 仍然可用,但每个虚拟线程都有自己的 map,数量大时内存占用更明显;ScopedValue 的绑定随作用域消失,更适合「每请求一份」的短生命周期数据。两者都不解决「跨线程自动传播」,显式 wrap 仍然要做。

不可变消息与可见性

跨线程传递的上下文必须是不可变的。可变对象即使安全发布,也会在后续并发读写中产生竞态;HashMap 在同时 put 时可能损坏,SimpleDateFormat 一类非线程安全对象更不能共享。

RequestContext 用 record 加 Map.copyOf 固定下来:

package com.plume.linkd.http;

import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public record RequestContext(String requestId, String userId, String traceId,
                             Instant deadline, Map<String, String> attributes) {
    public static final ScopedValue<RequestContext> CURRENT = ScopedValue.newInstance();

    public RequestContext {
        Objects.requireNonNull(requestId);
        Objects.requireNonNull(userId);
        Objects.requireNonNull(traceId);
        Objects.requireNonNull(deadline);
        attributes = attributes == null ? Map.of() : Map.copyOf(attributes);
    }

    public static RequestContext of(String requestId, String userId,
                                    String traceId, Instant deadline) {
        return new RequestContext(requestId, userId, traceId, deadline, Map.of());
    }

    public static Runnable wrap(RequestContext context, Runnable task) {
        return () -> ScopedValue.where(CURRENT, context).run(task);
    }

    public RequestContext withAttribute(String key, String value) {
        Map<String, String> copy = new HashMap<>(attributes);
        copy.put(key, value);
        return new RequestContext(requestId, userId, traceId, deadline, copy);
    }

    public long remainingMillis() {
        return Math.max(0L, deadline.toEpochMilli() - System.currentTimeMillis());
    }
}

record 的字段是 final,构造完成后不会被重新赋值;Map.copyOf 返回不可修改的副本,调用方拿到的 attributes() 不能 put。withAttribute 不改原对象,而是复制一份再构造新记录;这样多个线程读到的是同一个不可变快照,不需要锁。

安全发布靠线程池的队列。ThreadPoolExecutor.execute 把任务放进 BlockingQueue,工作线程取出执行;JMM 用队列的发布语义建立「提交前的写」happens-before「任务开始执行」,所以 context 的 final 字段和 Map.copyOf 的结果对业务线程可见。自定义的无锁队列、数组传递如果缺少 volatile 或 VarHandle 的发布语义,就没有这个可见性约束;linkd 不自己造这种队列。

HttpRequest 的 byte[] body 是另一个边界。record 只固定引用,数组内容仍可能被解析器复用。跨线程前复制 body 和 headers,或者让解析器每次分配新数组;不要因为「它是个 record」就默认可安全共享。

在 linkd 里落地:ContextPropagationDemo

下面的程序把三种情况放在一条池化线程上:ThreadLocal 不清理、ScopedValue 显式包装、未包装的任务。包名 com.plume.linkd.http。

package com.plume.linkd.http;

import java.time.Instant;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public final class ContextPropagationDemo {
    private static final ThreadLocal<RequestContext> LEGACY = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        RequestContext alice = RequestContext.of(
                "req-A", "alice", "trace-A", Instant.now().plusSeconds(2));
        RequestContext bob = RequestContext.of(
                "req-B", "bob", "trace-B", Instant.now().plusSeconds(2));
        ExecutorService pool = Executors.newSingleThreadExecutor(r -> new Thread(r, "biz-0"));

        pool.submit(() -> {
            LEGACY.set(alice);
            System.out.println("taskA threadlocal=" + LEGACY.get().userId() + " removed=false");
        }).get();

        pool.submit(() -> {
            RequestContext leaked = LEGACY.get();
            System.out.println("taskB threadlocal=" + (leaked == null ? "null" : leaked.userId()));
        }).get();

        pool.submit(RequestContext.wrap(bob, () ->
                System.out.println("wrapped scoped=" + RequestContext.CURRENT.get().userId()))).get();

        pool.submit(() ->
                System.out.println("unwrapped bound=" + RequestContext.CURRENT.isBound())).get();

        ScopedValue.where(RequestContext.CURRENT, alice).run(() -> {
            System.out.println("outer=" + RequestContext.CURRENT.get().userId());
            ScopedValue.where(RequestContext.CURRENT, bob).run(() ->
                    System.out.println("inner=" + RequestContext.CURRENT.get().userId()));
            System.out.println("restored=" + RequestContext.CURRENT.get().userId());
        });

        pool.submit(LEGACY::remove).get();
        pool.shutdown();
    }
}

本机输出(单次运行,未做统计):

taskA threadlocal=alice removed=false
taskB threadlocal=alice
wrapped scoped=bob
unwrapped bound=false
outer=alice
inner=bob
restored=alice

逐行看:

  • taskA 把 alice 放进 ThreadLocal 后没有 remove;taskB 是另一个请求、另一个用户,却在同一条 biz-0 上读到了 alice。这就是租户串数据的路径。
  • wrapped scoped=bob:RequestContext.wrap 在业务线程上重新绑定了 bob,CURRENT.get() 拿到的是本次请求。
  • unwrapped bound=false:普通提交没有绑定,ScopedValue 不会从提交线程自动带过来。
  • outer/inner/restored:嵌套绑定按作用域恢复,内层结束后外层仍是 alice。

程序最后在池化线程上 LEGACY.remove(),是为了演示结束后清理;真实代码应该把 remove() 放在每个任务的 finally,或者直接用 ScopedValue 消除这个清理义务。

常见错误与边界

错误一:在事件循环上 ThreadLocal.set,在业务线程上 get。 两条线程不同,业务线程读到的是 null 或上一次任务的值。必须显式传参或用 wrap 重新绑定。

错误二:ThreadLocal 用完不 remove()。 池化线程复用后,上一个请求的用户、租户、追踪号会留给下一个请求。上面 taskB 读到 alice 就是最小复现;在鉴权场景里这是越权。

错误三:用 InheritableThreadLocal 传递请求上下文。 它只在线程创建时复制,池化线程创建一次后就固定了;后续请求的父线程值不会更新,反而把第一批请求的值永久继承。

错误四:以为 ScopedValue 会自动传播到线程池。 ScopedValue 的绑定是线程本地的;ExecutorService.submit 不会继承。要么 wrap,要么用会继承绑定的结构化并发 API。

错误五:把可变 RequestContext 共享给多个线程。 一个线程 withAttribute 改字段,另一个线程正在读,结果取决于时序。上下文要么全不可变,要么每个线程持有自己的副本。

错误六:把 Connection、SelectionKey 放进上下文。 上下文是数据,不是连接句柄;业务线程拿到它就会越界操作循环本地状态。连接相关的东西留在事件循环,业务线程只拿不可变数据和写回回调。

边界:ScopedValue 不是 ThreadLocal 的全面替代。 需要跨方法隐式传值、作用域清晰的场景用它;需要在线程生命周期内长期保存、且明确由该线程独占的状态,ThreadLocal 仍然合适。两者都不应该用来缓存请求之间的共享数据。

边界:上下文不等于日志 MDC。 用 System.Logger 时没有现成的 MDC 绑定;要么在日志消息里显式带 requestId,要么写一层薄封装从 CURRENT 取值。不要为了日志引入第三方日志框架,本卷只用 JDK。

小结

  • 请求上下文必须显式跨线程:事件循环创建,提交业务任务时捕获,业务线程重新绑定。
  • ThreadLocal 在池化线程上会残留,finally 里的 remove() 是硬要求;InheritableThreadLocal 只在线程创建时复制,不能解决池化复用。
  • ScopedValue 在 JDK 25 正式可用,绑定有作用域、结束自动恢复,但不会自动传播到普通线程池,需要 wrap。
  • 上下文对象用 record 加 Map.copyOf 做成不可变快照;修改用 withAttribute 返回新对象,不做原地更新。
  • 安全发布依赖线程池队列的 happens-before;自定义传递路径必须自己提供 volatile 或 VarHandle 语义。
  • byte[] 字段的 record 不是深度不可变,跨线程前复制数组;连接句柄不要放进上下文。

上下文解决了「请求是谁」,下一章换一个更底层的问题:数据从内核到用户态要复制几次。13.1 从 FileChannel.transferTo 和系统调用边界讲零拷贝,先分清「少一次复制」和「完全不复制」的差别。

阅读导航:上一节:12.2 业务线程池与阻塞隔离 · 下一节:13.1 零拷贝的系统调用边界 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发