# Scoped Values: Why I Banned ThreadLocal in Exeris

> Why ThreadLocal kills performance under Project Loom and how the Exeris Kernel uses Scoped Values for true zero-copy architecture.

- URL: https://foojay.io/today/banned-threadlocal-java-scoped-values/
- Published: 2026-05-28
- Authors: Arkadiusz Przychocki
- Categories: Java, JavaPro, JEPs
- Originally published at: https://blog.arkstack.dev/en/blog/why-i-banned-threadlocal-from-the-exeris-kernel


#### In a zero-copy runtime designed for 1-VT-per-Stream density, ThreadLocal is a performance serial killer. Here is the forensic analysis and how JEP 506 Scoped Values changed everything.

When I started designing the **Exeris Kernel** — a next-generation, zero-copy runtime built for Java 26+ — I established one non-negotiable architectural law: **"No Waste Compute."**

In a system designed to handle extreme density by mapping exactly one Virtual Thread to every network stream (1-VT-per-Stream), every byte of memory and every CPU cycle must be intentional.

But very quickly, I hit a **legacy wall**.

In the standard Enterprise Java ecosystem, when you need to pass a `SecurityContext`, a `TenantId`, or a `TransactionID` down to the database layer without polluting dozens of method signatures, you reach for a trusted tool: `ThreadLocal`. For over two decades, `ThreadLocal` was the backbone of Java framework magic. But in the era of [Project Loom](/pedia/project-loom/ "Project Loom") (JEP 444) and Structured Concurrency, this old friend becomes a **performance serial killer**.

Here is why I enforced a strict, kernel-wide ban on `ThreadLocal` in Exeris, and how adopting [**JEP 506 (Scoped Values)**](https://openjdk.org/jeps/506 "**JEP 506 (Scoped Values)**") completely changed the game for high-performance architecture.

## The Forensic Analysis: The 3 Sins of ThreadLocal

Treating Virtual Threads like OS threads discards most of their scalability advantages — especially around context propagation and allocation behavior. When you combine `ThreadLocal` with a highly concurrent, thread-per-request architecture, you introduce three critical flaws:

### 1. The Spaghetti State (Unconstrained Mutability)

Any code deep in the call stack that can read a `ThreadLocal` can also call `.set()` on it. If a nested library mutates the `SecurityContext` mid-flight, tracking down who changed it and when is a debugging nightmare. Data flow becomes completely unpredictable.
![Figure 1: The uncontrolled mutability of ThreadLocal versus the strict, read-only data flow guarantees of a lexically bounded Scoped Value.](fig1_spaghetti_state-9fe986e4.png)  

### 2. The Memory Leak Trap (Unbounded Lifetime)

A `ThreadLocal` survives until the thread dies or someone explicitly calls `.remove()`. In legacy thread pools, forgetting to clean up means a security context bleeds into the next user's request.

### 3. The Inheritance Tax (The RAM Killer)

This is the fatal blow. To share context with child threads, frameworks use `InheritableThreadLocal`. When a parent thread creates a child, the JVM must **eagerly clone** the parent's `ThreadLocalMap`. This typically allocates between **32 and 128 bytes** per entry on the heap, depending on the load factor and key distribution.

Now, imagine a single HTTP request where your logic forks **50 concurrent sub-tasks** (Virtual Threads) to fetch data. You just triggered **50 expensive map allocations** . Multiply that by 10,000 concurrent requests, and your Garbage Collector stalls your application just to clean up useless context clones. This becomes a **pure GC tax with no business value**.
![Figure 2: The O(N) memory copy penalty of InheritableThreadLocal compared to the O(1) constant-time pointer inheritance introduced in JEP 506.](fig2_inheritance_tax-95f7d4eb.png)  

## The Missing Link: Structured Concurrency Incompatibility

Beyond performance, `ThreadLocal` is fundamentally incompatible with Structured Concurrency. `StructuredTaskScope` relies on deterministic, tree-like execution where child tasks are strictly bound to the lifetime of their parent. `ThreadLocal`, being non-deterministic and fully mutable at any level of the tree, completely breaks this model.
> You cannot build a reliable, fail-fast concurrent tree if any leaf node can secretly mutate the global state of the branch.

## Exhibit A: The Zero-Waste Solution (JEP 506)

To survive millions of Virtual Threads, we need a mechanism that is **immutable** , **temporally bounded** , and **virtually free to inherit** . Enter **Scoped Values**.

Instead of a globally mutable variable, a `ScopedValue` defines a **Dynamic Scope**. It binds a value to a specific block of code (and all methods called within it). Once the block finishes, the binding vanishes.

### The Scoreboard

|                      |             ThreadLocal             |                          ScopedValue                           |
|----------------------|-------------------------------------|----------------------------------------------------------------|
| **Immutability**     | Mutable (Anyone can overwrite)      | Immutable (Read-only for callees)                              |
| **Lifetime**         | Unbounded (Requires manual cleanup) | Lexically bounded (tied to the `.run()` block)                 |
| **Inheritance Cost** | O(N) memory copy                    | O(1) constant-time inheritance with negligible allocation cost |

## Exhibit B: "Show, Don't Tell" — The Exeris Implementation

In the Exeris Kernel, context propagation is strictly separated. The Security module authenticates, and the Persistence module applies Row-Level Security. They never talk directly. They communicate purely through an **"Invisible Wall"** using `ScopedValue`.
![Figure 3: Context propagation in the Exeris Kernel. Security and Persistence modules remain completely decoupled, sharing identity strictly through an immutable dynamic scope.](fig3_context_scope-b2531cbc.png)

Here is how identity is injected at the gateway. Notice the complete absence of `.set()` methods:

```
// 1. Decode token directly from off-heap memory (Zero-Alloc)
AuthenticationResult result = securityProvider.authenticate(tokenBuffer);

// 2. Open a lexically bounded, immutable Dynamic Scope
// Note: Chained .where() calls create efficient nested scopes.
ScopedValue
    .where(KernelProviders.PRINCIPAL_CONTEXT, result.principal())
    .where(KernelProviders.STORAGE_CONTEXT,   result.storage())
    .run(() -> {
        // Inside this block, the context is safe.
        // It will be inherited by any Virtual Thread spawned via StructuredTaskScope.
        dispatchRequest(request);
    });

// 3. Scope closes automatically. No .remove() needed. Zero leaks.
```

Later, deep in the Persistence module, the `TransactionOrchestrator` needs to know the Tenant ID to append it to the SQL query. It simply queries the active scope:

```
public class TransactionOrchestrator {

    private static StorageContext resolveStorageContext() {
        // Zero ThreadLocal, fully Virtual-Thread safe (JEP 506)
        // isBound() is an O(1) check
        if (KernelProviders.STORAGE_CONTEXT.isBound()) {
            return KernelProviders.STORAGE_CONTEXT.get();
        }
        // Fallback to system context without allocating objects
        return ImmutableStorageContext.system();
    }

    // ... transaction execution logic
}
```

Because `ScopedValue` is immutable, the `TransactionOrchestrator` is **guaranteed by lexical scoping and immutability** that the `StorageContext` it reads is exactly the one set by the gateway, untampered by any interceptor along the way.

## The Paradigm Shift

By ripping `ThreadLocal` out of the kernel, we eliminated an entire category of memory leaks and GC pressure. When a system spawns 1,000,000 Virtual Threads, the difference between *"copying a map 1 million times"* and *"sharing a pointer in constant time"* is the difference between a crashed server and a stable infrastructure.

Java 26 is not just *"Java 8 with `var`"* . Features like Project Loom, Panama (FFM), and Scoped Values require a **fundamental shift** in how we architect systems. If we keep building frameworks using patterns from 2014, we will never unlock the true performance of modern hardware.

**Would you be willing to refactor your application to drop `ThreadLocal` and embrace `ScopedValue`?** Let me know in the comments.

## Explore the Exeris Kernel

The zero-allocation architecture described in this article isn't just theory — it's running code. Exeris is an open-core, post-container cloud kernel built for extreme density. If you're tired of GC pauses and want to see how native I/O, Panama FFM, and Virtual Thread orchestration look in practice, explore the Exeris Kernel:

🔗 **GitHub Repository:** [exeris-systems/exeris-kernel](https://github.com/exeris-systems/exeris-kernel)

