DEV Community

Solon Framework
Solon Framework

Posted on

From ThreadLocal to ScopedValue: Solon's ScopeLocal Bridge

Virtual threads changed the math on thread-local state. When your app runs on a small pool of platform threads, a ThreadLocal per thread is cheap and contained. When every request gets its own virtual thread — millions of them, created and recycled at will — the same pattern starts to leak values across requests and quietly multiply memory per thread.

Java's answer is ScopedValue, a JDK 21 preview that shipped in JDK 25. But most production codebases still run on Java 8 through 21, and nobody wants to rewrite their context propagation layer twice. Solon's answer is a thin abstraction called ScopeLocal: write your business code against one interface, and the underlying implementation switches with the JDK you run on.

What ScopeLocal is

ScopeLocal lives in org.noear.solon.util and is marked @Preview("3.8"). The official positioning is direct: it's an interface designed to bridge the transition from Java 8's ThreadLocal to Java 25's ScopedValue, with two implementations shipped — ScopeLocalJdk8 (the default) and ScopeLocalJdk25.

The interface itself is small:

@Preview("3.8")
public interface ScopeLocal<T> {
    static <T> ScopeLocal<T> newInstance() {
        return newInstance(ScopeLocal.class);
    }

    static <T> ScopeLocal<T> newInstance(Class<?> applyFor) {
        return FactoryManager.getGlobal().newScopeLocal(applyFor);
    }

    T get();                                  // get the value
    void with(T value, Runnable runnable);    // run with a value
    <R> R with(T value, Supplier<R> callable);// call with a value
    <X extends Throwable> void withOrThrow(T value, RunnableTx<X> runnable) throws X;
    <R, X extends Throwable> R withOrThrow(T value, CallableTx<? extends R, X> callable) throws X;

    @Deprecated  // deprecated since 3.8.0
    ScopeLocal<T> set(T value);
    @Deprecated  // deprecated since 3.8.0
    void remove();
}
Enter fullscreen mode Exit fullscreen mode

Notice the shape. with and withOrThrow take a value and a block to run within that value — a "wrap it, run, unwind" feeling. That's the ScopedValue model leaking through on purpose: values are bound to a call scope rather than to a thread, so child tasks inherit them and recycled virtual threads can't pollute each other.

Basic usage is exactly what you'd expect:

public class Demo {
   static ScopeLocal<String> LOCAL = ScopeLocal.newInstance();

   public void test(){
       LOCAL.with("test", ()->{
           System.out.println(LOCAL.get());
       });
   }
}
Enter fullscreen mode Exit fullscreen mode

The set/remove methods are marked deprecated since 3.8.0 — a trace of the old ThreadLocal habits. New code should use the with family.

The full switch: three steps

ScopeLocal only becomes ScopedValue-backed when you actually enable virtual threads and opt into the JDK 25 adapter. The chain is deliberate:

1. Enable virtual threads (Solon has supported this since v2.7):

solon.threads.virtual.enabled: true # enable virtual thread pool (default false)
Enter fullscreen mode Exit fullscreen mode

Per the official docs, once enabled, HTTP requests, async annotation handling, and some job executions run on the virtual thread pool. On Java 21+ you can also query the state at runtime: Solon.cfg().isEnabledVirtualThreads();

2. Add the solon-java25 adapter dependency (supported since v3.8.0):

<dependency>
    <groupId>org.noear</groupId>
    <artifactId>solon-java25</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

It's described as a base extension plugin that provides adapters for Java 25 features that aren't practical to adapt via reflection.

3. Swap the ScopeLocal factory:

public class App {
    public static void main(String[] args) {
        Solon.start(App.class, args, app->{
            // replace the ScopeLocal implementation (wrapped on Java 25's ScopedValue)
            app.factories().scopeLocalFactory(ScopeLocalJdk25::new);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

That's it. The official docs add one notable side benefit: after switching to the ScopeLocalJdk25 adapter, cross-thread JDBC transactions are also supported.

So the full picture lines up with the framework's own JDK-evolution checklist: enable virtual threads (v2.7), stop using synchronized in favor of ReentrantLock or other locks (v2.7), and move off ThreadLocal toward ScopedValue (v3.8).

A real-world before/after: NamiAttach

The most visible payoff is in Nami, Solon's HTTP client. In 3.x, the habit was to stuff attachments into a ThreadLocal and let the outbound call pick them up:

@Controller
public class Demo {
    @NamiClient(url="https://api.github.com")
    GitHub gitHub;

    @Mapping
    public Object test(){
        NamiAttachment.put("a", "1");
        return gitHub.contributors("OpenSolon", "solon");
    }
}
Enter fullscreen mode Exit fullscreen mode

In 4.x, Nami moved the attachment API onto the scoped model — NamiAttach. The attachment is now bound to the call domain: wrap the work, and it lives and dies with that block:

@Controller
public class Demo {
    @NamiClient(url="https://api.github.com")
    GitHub gitHub;

    @Mapping
    public Object test(){
        return NamiAttach.apply((attach)->{
            attach.put("a", "1");
            return gitHub.contributors("OpenSolon", "solon");
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

The 3.x NamiAttachment form still works in existing codebases, but 4.x has moved on — and the scoped shape is the direction of travel, because it matches how the underlying JDK feature behaves: values bound to a scope, inherited by sub-tasks, immune to thread-reuse contamination.

Honest boundaries

A few things worth being explicit about, because they're easy to overstate:

  • Default is still ThreadLocal. Unless you enable virtual threads and swap in ScopeLocalJdk25, ScopeLocal runs on the Jdk8 implementation. This is a compatibility bridge, not a forced migration.
  • It's aimed at system-level code. The official docs describe ScopeLocal as an open tool interface, leaning toward system-level use. Most application code never touches it directly.
  • It's a preview. The @Preview("3.8") annotation signals the API is still settling — that's exactly why the abstraction layer is worth having. When ScopedValue semantics settle further, your business code doesn't need to chase the JDK.

The reasoning behind it is straightforward: one interface, two adapters, zero rewrites when you move a codebase from Java 8 to Java 25. If you're already running virtual threads, or planning the move, ScopeLocal is the part of Solon that makes the transition boring — which, for infrastructure, is the highest compliment.

References

Top comments (0)