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();
}
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());
});
}
}
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)
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>
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);
});
}
}
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");
}
}
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");
});
}
}
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,ScopeLocalruns 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
- Open tool interfaces in Solon: https://solon.noear.org/article/1271
- The solon-java25 adapter: https://solon.noear.org/article/1259
- Enabling Java 21 virtual threads: https://solon.noear.org/article/698
- Solon on GitHub: https://github.com/opensolon/solon
Top comments (0)