Java engineer reviewing ThreadLocal isolation and inheritance tests for virtual threads.

A Java Virtual Thread Finished. What Happened to Its ThreadLocal State?

Mon, Nov 9, 2026

A completed virtual-thread task can give you a green Future.get(), a normal return value, and a terminated Thread. None of those signals, by itself, proves that application code explicitly cleared a ThreadLocal, that an InheritableThreadLocal value was copied rather than shared, or that a later task can observe the finished thread’s state. Java’s contract is more specific: ThreadLocal values belong to individual threads; InheritableThreadLocal values can be inherited when a child thread is created; and the virtual-thread-per-task executor creates a new virtual thread for each submitted task rather than pooling virtual threads.

This playbook tests only that ownership boundary. It does not benchmark virtual threads, evaluate ScopedValue, infer garbage-collection timing, or claim that successful task completion is a cleanup witness. The laboratory is a disposable local fixture using synthetic strings and mutable lists. Its oracle is authored independently from runtime output, and every case is reconciled against documented Java 21 behavior rather than judged by whether the program merely exits successfully.

The executable run reported here used OpenJDK 21.0.12.1+1-1-deb13u1-Debian, an OpenJDK 64-Bit Server VM, Linux 6.18.57, and amd64. Those are observations from the isolated execution environment used for the reported run, not claims about any production installation.

The bounded release decision is ACCEPT for the tested Java 21 ownership invariants: ordinary ThreadLocal state remained thread-owned; default inheritable state was captured at child-thread creation; inheritance could be disabled; sibling and per-task virtual threads did not inherit another finished virtual thread’s ordinary ThreadLocal; and default InheritableThreadLocal inheritance preserved the same mutable object reference unless childValue created a copy. That acceptance does not prove prompt garbage collection, explicit cleanup, or correctness on an untested JDK build.

Define the acceptance contract, pin the baseline, and prove the detector can fail

The acceptance contract must describe ownership, not API success.

For this fixture, a candidate Java 21 runtime is acceptable only if all of these propositions hold together:

  1. An ordinary ThreadLocal value set by a parent thread is not automatically visible in a newly created virtual child.

  2. A value written by one virtual thread does not become the ordinary ThreadLocal value of a separate virtual thread that starts later.

  3. A default InheritableThreadLocal value is inherited from the constructing thread when the child Thread is created, not from some later parent value observed when start() happens.

  4. Thread.Builder.inheritInheritableThreadLocals(false) prevents that inheritance.

  5. Default inheritance does not promise a deep copy. A mutable object can remain the same object in parent and child unless childValue is overridden.

  6. ThreadLocal.remove() is observable inside the owning thread: a subsequent get() reverts to that thread’s initialization behavior.

  7. Success of a task submitted to Executors.newVirtualThreadPerTaskExecutor() is not accepted as evidence that application cleanup code ran; a separate case must inspect the state before return and inspect the next task independently.

Java SE 21’s ThreadLocal specification says that each thread accessing the variable has its own independently initialized copy. It also says that a thread holds an implicit reference to its copy while the thread is alive and the ThreadLocal remains accessible, and that after the thread goes away those copies are subject to garbage collection unless other references remain. That last phrase is deliberately weaker than “immediately collected,” so collection timing is outside this acceptance gate.

Java SE 21’s InheritableThreadLocal specifies inheritance when a child is created, while the Thread specification makes the capture point more precise: initial inheritable values come from the parent at child-thread creation. The Thread.Builder default is to inherit, and the builder can disable inheritance.

Virtual threads do not create a second ThreadLocal ownership model. Oracle’s Java 21 virtual-thread guide says virtual threads support thread-local variables, but warns against using them as per-thread caches of costly reusable objects because virtual threads are not meant to be pooled and reused across unrelated tasks. Executors.newVirtualThreadPerTaskExecutor() is specified to start a new virtual thread for each task.

That distinction is narrower than the problem examined in Refonte’s Java CompletableFuture cancellation article. That page asks whether cancellation of a result handle stops the underlying supplier and separates completion ownership from execution ownership. Here, no cancellation semantics are under test. The question is whether thread-bound state belongs to one virtual Thread, how inheritable state is established, and what evidence remains meaningful after that thread terminates.

Negative control. A detector that never demonstrates contamination is weak evidence. The negative control deliberately executes two logical units inside the same virtual thread. Logical unit A sets TL="stale-A" and does not remove it. Logical unit B immediately reads the same ThreadLocal. The measurable pass condition for the negative control is that B sees stale-A. If it instead reports null, the fixture is not actually capable of detecting same-thread stale state. After remove(), a second read must report null.

The executed negative control produced:

N0  logicalB.seesBeforeCleanup  stale-A
N0  afterRemove                 null

That result is OBSERVED, not merely expected. It confirms that the detector can expose stale state when two logical operations share a virtual thread. The control does not imply that a virtual-thread-per-task executor reuses virtual threads; it intentionally creates the opposite arrangement so the measurement mechanism can be qualified.

Author the independent oracle, construct the fixture, and record state before completion

An acceptance test should not discover its expected answer from the same run it later judges. The expected-case manifest therefore comes from the Java 21 contracts above, not from the captured output.

The neighboring Refonte article on Python asyncio.to_thread context capture is useful as a contrast precisely because its ownership boundary is different. That article examines when Python context is copied relative to coroutine construction and execution. This fixture does not import that workflow: Java’s relevant contract is child-Thread creation for InheritableThreadLocal, ordinary ThreadLocal values remain thread-local, and the Java builder can explicitly suppress inheritance.

The independently authored manifest is:

Case

Independent expected result

Contract being tested

Failure meaning

N0

Same virtual thread: second logical unit sees stale-A; after remove(), sees null

Detector can expose same-thread contamination

Fixture cannot detect the failure it is meant to diagnose

C1

Child ordinary TL initially null; after child sets value it sees its own value; parent retains parent-tl

Ordinary ThreadLocal is per-thread

Ownership isolation violated or fixture invalid

C2

Child sees P-before-construction; parent later holds P-after-construction

ITL captured at child creation

Capture boundary inconsistent with Java 21 contract

C3

Opt-out child sees null; parent remains parent-itl

Builder can suppress inheritance

Configuration ineffective

C4

A sees A-owned; after A finishes, separate B starts with ordinary TL null; IDs differ

Separate virtual threads own separate TL copies

Cross-thread contamination

C5

Task A returns successfully while its TL was still set before return; new task B starts with null

Task success is not cleanup proof; executor uses per-task virtual threads

Evidence conflates completion and cleanup or executor boundary

C6

Same virtual thread sees temporary, then null after remove()

Explicit removal applies to current thread’s value

Cleanup primitive not behaving as documented

C7

Default ITL child and parent reference same mutable list; child mutation appears in parent

Default inheritance need not deep-copy mutable value

Incorrect assumption about value independence

C8

Overridden childValue gives child distinct mutable list; child mutation does not affect parent

Copy policy can be supplied by subclass

Intended copy policy not enforced

The strongest boundary case is C2. The parent sets an inheritable value, constructs but does not start the child, changes its own value, and only then starts the child. Java’s Thread documentation says inheritance occurs at thread creation, so the child should retain the value present when unstarted(...) created the Thread, not the value present at start().

C7 exists because the word “inherit” is easy to overread as “clone.” The InheritableThreadLocal contract allows childValue to transform the parent’s value, but absent an override the normal relationship is that child values are identical to parent values. With a mutable list, reference identity is therefore part of the evidence.

The complete runnable fixture follows. It uses no external dependency, network service, test framework, or production data.

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;

public final class VirtualThreadLocalOwnershipLab {
    static final ThreadLocal<String> TL = new ThreadLocal<>();
    static final InheritableThreadLocal<String> ITL =
            new InheritableThreadLocal<>();

    static final InheritableThreadLocal<List<String>> ITL_SHARED =
            new InheritableThreadLocal<>();

    static final InheritableThreadLocal<List<String>> ITL_COPIED =
            new InheritableThreadLocal<>() {
                @Override
                protected List<String> childValue(List<String> parentValue) {
                    return parentValue == null
                            ? null
                            : new ArrayList<>(parentValue);
                }
            };

    static void emit(String caseId, String key, Object value) {
        String s = String.valueOf(value)
                .replace('\t', ' ')
                .replace('\n', ' ');
        System.out.printf("%s\t%s\t%s%n", caseId, key, s);
    }

    static void resetCurrent() {
        TL.remove();
        ITL.remove();
        ITL_SHARED.remove();
        ITL_COPIED.remove();
    }

    static void caseN0NegativeControlSameVirtualThread() throws Exception {
        var t = Thread.ofVirtual()
                .name("N0-negative-control")
                .start(() -> {
                    TL.set("stale-A");
                    emit("N0", "logicalB.seesBeforeCleanup", TL.get());

                    TL.remove();
                    emit("N0", "afterRemove", TL.get());
                });
        t.join();
    }

    static void caseC1ThreadLocalNotInherited() throws Exception {
        resetCurrent();
        TL.set("parent-tl");

        AtomicReference<String> childInitial = new AtomicReference<>();
        AtomicReference<String> childAfterSet = new AtomicReference<>();

        var t = Thread.ofVirtual()
                .name("C1-tl-child")
                .start(() -> {
                    childInitial.set(TL.get());
                    TL.set("child-tl");
                    childAfterSet.set(TL.get());

                    emit("C1", "child.threadId",
                            Thread.currentThread().threadId());
                    emit("C1", "child.isVirtual",
                            Thread.currentThread().isVirtual());
                });

        t.join();

        emit("C1", "child.initial", childInitial.get());
        emit("C1", "child.afterSet", childAfterSet.get());
        emit("C1", "parent.afterChild", TL.get());

        resetCurrent();
    }

    static void caseC2InheritableCapturedAtConstruction()
            throws Exception {
        resetCurrent();

        ITL.set("P-before-construction");

        AtomicReference<String> childSeen = new AtomicReference<>();

        var t = Thread.ofVirtual()
                .name("C2-itl-capture")
                .unstarted(() -> {
                    childSeen.set(ITL.get());
                    emit("C2", "child.threadId",
                            Thread.currentThread().threadId());
                });

        ITL.set("P-after-construction");

        t.start();
        t.join();

        emit("C2", "child.seen", childSeen.get());
        emit("C2", "parent.current", ITL.get());

        resetCurrent();
    }

    static void caseC3InheritableOptOut() throws Exception {
        resetCurrent();

        ITL.set("parent-itl");

        AtomicReference<String> childSeen = new AtomicReference<>();

        var t = Thread.ofVirtual()
                .inheritInheritableThreadLocals(false)
                .name("C3-itl-optout")
                .start(() -> {
                    childSeen.set(ITL.get());
                    emit("C3", "child.threadId",
                            Thread.currentThread().threadId());
                });

        t.join();

        emit("C3", "child.seen", childSeen.get());
        emit("C3", "parent.current", ITL.get());

        resetCurrent();
    }

    static void caseC4SiblingIsolationAfterFinish()
            throws Exception {
        resetCurrent();

        AtomicReference<Long> idA = new AtomicReference<>();
        AtomicReference<Long> idB = new AtomicReference<>();

        var a = Thread.ofVirtual()
                .name("C4-A")
                .start(() -> {
                    idA.set(Thread.currentThread().threadId());
                    TL.set("A-owned");
                    emit("C4", "A.beforeFinish", TL.get());
                });

        a.join();

        var b = Thread.ofVirtual()
                .name("C4-B")
                .start(() -> {
                    idB.set(Thread.currentThread().threadId());
                    emit("C4", "B.initial", TL.get());

                    TL.set("B-owned");
                    emit("C4", "B.afterSet", TL.get());
                });

        b.join();

        emit("C4", "A.threadId", idA.get());
        emit("C4", "B.threadId", idB.get());
        emit("C4", "distinctThreadIds",
                !idA.get().equals(idB.get()));
    }

    static void caseC5PerTaskExecutorSuccessIsNotCleanupProof()
            throws Exception {
        resetCurrent();

        try (var executor =
                     Executors.newVirtualThreadPerTaskExecutor()) {

            Future<String> a = executor.submit(() -> {
                TL.set("task-A-unremoved");

                emit("C5", "A.threadId",
                        Thread.currentThread().threadId());
                emit("C5", "A.beforeReturn", TL.get());

                return "A-ok";
            });

            emit("C5", "A.futureResult", a.get());

            Future<String> b = executor.submit(() -> {
                emit("C5", "B.threadId",
                        Thread.currentThread().threadId());
                emit("C5", "B.initial", TL.get());
                return "B-ok";
            });

            emit("C5", "B.futureResult", b.get());
        }
    }

    static void caseC6RemoveWithinSameVirtualThread()
            throws Exception {
        var t = Thread.ofVirtual()
                .name("C6-remove")
                .start(() -> {
                    TL.set("temporary");

                    emit("C6", "beforeRemove", TL.get());

                    TL.remove();

                    emit("C6", "afterRemove", TL.get());
                });

        t.join();
    }

    static void caseC7DefaultInheritanceSharesMutableReference()
            throws Exception {
        resetCurrent();

        List<String> parentList = new ArrayList<>();
        parentList.add("parent");
        ITL_SHARED.set(parentList);

        int parentIdentityBefore =
                System.identityHashCode(parentList);

        AtomicReference<Integer> childIdentity =
                new AtomicReference<>();

        var t = Thread.ofVirtual()
                .name("C7-shared-mutable")
                .start(() -> {
                    var childList = ITL_SHARED.get();

                    childIdentity.set(
                            System.identityHashCode(childList));

                    childList.add("child-mutated");

                    emit("C7", "child.value", childList);
                });

        t.join();

        emit("C7", "parent.valueAfterChild",
                ITL_SHARED.get());

        emit("C7", "sameIdentity",
                parentIdentityBefore == childIdentity.get());

        resetCurrent();
    }

    static void caseC8ChildValueCreatesIndependentMutableCopy()
            throws Exception {
        resetCurrent();

        List<String> parentList = new ArrayList<>();
        parentList.add("parent");
        ITL_COPIED.set(parentList);

        int parentIdentityBefore =
                System.identityHashCode(parentList);

        AtomicReference<Integer> childIdentity =
                new AtomicReference<>();

        var t = Thread.ofVirtual()
                .name("C8-copied-mutable")
                .start(() -> {
                    var childList = ITL_COPIED.get();

                    childIdentity.set(
                            System.identityHashCode(childList));

                    childList.add("child-mutated");

                    emit("C8", "child.value", childList);
                });

        t.join();

        emit("C8", "parent.valueAfterChild",
                ITL_COPIED.get());

        emit("C8", "sameIdentity",
                parentIdentityBefore == childIdentity.get());

        resetCurrent();
    }

    public static void main(String[] args) throws Exception {
        System.out.println(
                "META\tjava.runtime.version\t" +
                Runtime.version());

        System.out.println(
                "META\tjava.vm.name\t" +
                System.getProperty("java.vm.name"));

        System.out.println(
                "META\tos.name\t" +
                System.getProperty("os.name"));

        System.out.println(
                "META\tos.version\t" +
                System.getProperty("os.version"));

        System.out.println(
                "META\tos.arch\t" +
                System.getProperty("os.arch"));

        caseN0NegativeControlSameVirtualThread();
        caseC1ThreadLocalNotInherited();
        caseC2InheritableCapturedAtConstruction();
        caseC3InheritableOptOut();
        caseC4SiblingIsolationAfterFinish();
        caseC5PerTaskExecutorSuccessIsNotCleanupProof();
        caseC6RemoveWithinSameVirtualThread();
        caseC7DefaultInheritanceSharesMutableReference();
        caseC8ChildValueCreatesIndependentMutableCopy();
    }
}

Compile and execute it with the pinned Java 21 toolchain:

javac VirtualThreadLocalOwnershipLab.java
java VirtualThreadLocalOwnershipLab \
  | tee virtual-thread-local-evidence.tsv

Record state before cleanup. C5 deliberately captures A.beforeReturn=task-A-unremoved. That is the critical pre-completion witness. If the only evidence were A.futureResult=A-ok, an auditor could not distinguish “the task returned after explicit cleanup” from “the task returned while the ThreadLocal remained set.” The pass condition is therefore two-part: capture the value inside A immediately before return, then observe B independently.

Reproduce the misleading success, run the comparison, and capture every warning-worthy condition

The misleading success is C5. Task A uses a virtual-thread-per-task executor, writes an ordinary ThreadLocal, deliberately does not call remove(), and returns "A-ok". Its Future.get() therefore succeeds. Java’s executor contract says this executor creates a new virtual Thread for each task, so the subsequent B task is expected to own a different virtual thread and start with an independently initialized ordinary ThreadLocal.

The executed evidence was:

C5  A.threadId       26
C5  A.beforeReturn   task-A-unremoved
C5  A.futureResult   A-ok
C5  B.threadId       27
C5  B.initial        null
C5  B.futureResult   B-ok

All six rows are OBSERVED.

The wrong conclusion would be: “A completed successfully, therefore its ThreadLocal was cleaned before completion.” The evidence directly contradicts that inference: A.beforeReturn proves the value was still present immediately before returning. B’s null does not rescue the cleanup claim. B is a new virtual thread, and ordinary ThreadLocal ownership is by thread. The stronger, supportable conclusion is: task A completed while its own thread-local value had not been explicitly removed, and task B nevertheless began with no value because it ran on a different virtual thread with its own ThreadLocal state.

This is also why the fixture does not inspect a carrier thread. Virtual threads are Thread instances and can be mounted onto platform carriers by the runtime; their ThreadLocal semantics belong to the virtual thread, not to whichever carrier happens to execute a segment of it. Oracle describes virtual threads as virtual Thread instances that may mount and unmount from platform threads, while separately affirming ThreadLocal support.

The correct comparison case is C6:

C6  beforeRemove  temporary
C6  afterRemove   null

Both rows are OBSERVED. Java’s ThreadLocal.remove() contract says removal deletes the current thread’s value, and a later get() reinitializes it if necessary; the default initializer returns null.

The comparison establishes a precise distinction. C5 proves that termination of a one-task virtual thread can provide practical task isolation even when application code omits remove(). C6 proves what explicit cleanup looks like while the owning thread is still alive. Those are not interchangeable claims. A codebase that requires cleanup before handing control to another logical operation inside the same thread still needs the explicit cleanup path demonstrated by N0 and C6.

This differs from Refonte’s PostgreSQL advisory-lock lifetime playbook. That article asks whether a database session still owns a lock after transaction boundaries and uses another backend as an external acquisition witness. Here, the state owner is a Java Thread object, and the comparison is between logical work sharing one virtual thread and independently created virtual threads. A database session can outlive a transaction by design; a virtual-thread-per-task executor instead creates a new virtual thread per task. The proof mechanism therefore cannot be copied wholesale from the advisory-lock article.

Exceptions and warnings. The fixture should fail closed on uncaught exceptions, compilation failures, missing output rows, unexpected JDK major versions, or inability to prove a thread was virtual where the case requires one. The current fixture emits child.isVirtual=true in C1. For a release harness, augment the shell wrapper so that a nonzero JVM exit status rejects the run and so that the evidence reconciler requires exactly one expected row for every manifest key.

Do not turn warnings into silent successes. A different thread ID is useful evidence that C4 and C5 used separate Thread instances, but thread-ID inequality alone is not the semantic oracle; the executor and ThreadLocal contracts are. Conversely, seeing the same carrier name in diagnostic tooling would not demonstrate ThreadLocal sharing, because carrier identity is not the ThreadLocal owner under test.

Test the ownership boundaries, capture a complete evidence ledger, and reject incomplete runs

C1 establishes ordinary ThreadLocal ownership:

C1  child.threadId    21
C1  child.isVirtual   true
C1  child.initial     null
C1  child.afterSet    child-tl
C1  parent.afterChild parent-tl

These rows are OBSERVED. The child did not inherit the parent’s ordinary value. Setting a child-specific value did not overwrite the parent’s value. That aligns with Java SE 21’s definition of a thread-local variable as a separate copy per accessing thread.

C2 isolates the creation boundary:

C2  child.threadId  22
C2  child.seen      P-before-construction
C2  parent.current  P-after-construction

These rows are OBSERVED. The parent changed its value after unstarted() created the child Thread, but before the child was started. The child still observed the earlier value. That matches the Java 21 Thread contract stating that inherited initial values are taken when the child thread is created.

C3 verifies the opt-out path:

C3  child.threadId  23
C3  child.seen      null
C3  parent.current  parent-itl

These rows are OBSERVED. inheritInheritableThreadLocals(false) prevented inheritance while leaving the parent’s own value intact. The builder documentation specifies both the option and the default-to-inherit behavior.

C4 asks the article’s title question operationally. A sets a ThreadLocal and terminates. Only after join() confirms A has finished does B begin:

C4  A.beforeFinish      A-owned
C4  B.initial           null
C4  B.afterSet          B-owned
C4  A.threadId          24
C4  B.threadId          25
C4  distinctThreadIds   true

These rows are OBSERVED. The admissible claim is that B did not acquire A’s ordinary ThreadLocal value and that the two tasks ran as distinct virtual Thread instances. The Java API additionally states that after a thread goes away its thread-local copies are subject to garbage collection unless referenced elsewhere. The fixture does not claim that GC occurred before B started; no such observation was made, and no such timing guarantee is needed to prove ownership isolation.

C7 and C8 are the most important failure-mode pair:

C7  child.value             [parent, child-mutated]
C7  parent.valueAfterChild  [parent, child-mutated]
C7  sameIdentity            true

C8  child.value             [parent, child-mutated]
C8  parent.valueAfterChild  [parent]
C8  sameIdentity            false

All six rows are OBSERVED.

C7 demonstrates a boundary condition that can produce a false sense of isolation. The ThreadLocal binding belongs to the child thread, but the bound object can still be the same mutable object held by the parent. Thread-local ownership does not imply object-graph isolation. Default InheritableThreadLocal behavior permits the child’s value to be identical to the parent’s, while overriding childValue can supply a transformed value.

C8 implements that transformation with new ArrayList<>(parentValue). The child and parent then own different list objects. This is an application policy layered on top of the inheritance mechanism, not a general JVM promise.

A complete case-by-case evidence ledger is therefore:

Case

Expected

Observed in this run

Status

N0

Same-thread logical B sees stale-A; post-remove null

Exact match

PASS (OBSERVED)

C1

Ordinary TL not inherited; child and parent retain own values

Exact match

PASS (OBSERVED)

C2

Child sees value present at child construction

Exact match

PASS (OBSERVED)

C3

ITL inheritance disabled

Exact match

PASS (OBSERVED)

C4

Finished A’s ordinary TL not visible in later B

Exact match

PASS (OBSERVED)

C5

A may complete with TL still set; B starts clean on new virtual thread

Exact match

PASS (OBSERVED)

C6

remove() clears current thread’s binding

Exact match

PASS (OBSERVED)

C7

Default ITL can share mutable object identity

Exact match

PASS (OBSERVED)

C8

Custom childValue can create independent mutable copy

Exact match

PASS (OBSERVED)

No row in this table is invented. Every listed observed value came from the local execution described above.

Reject incomplete evidence. A run is not acceptable if, for example, it records only:

C5  A.futureResult  A-ok
C5  B.futureResult  B-ok

That output proves merely that two tasks returned values. It does not tell reviewers whether A had state before return, whether B saw that state, whether B was virtual, or whether both invocations used the same thread. The measurable pass condition for evidence completeness is that every manifest key required by the selected cases appears exactly once and that no case exits through an uncaught exception.

Reconcile state, identity, counts, and the line between documentation and observation

Operational ThreadLocal validation becomes unreliable when four different concepts are collapsed: value, binding, thread identity, and object identity.

A ThreadLocal<List<String>> can give two threads separate bindings that nevertheless point to the same List. C7 demonstrates exactly that for an inheritable value. Therefore the evidence ledger records both contents and System.identityHashCode comparison. The identity hash is not treated as a globally unique identifier; the comparison is performed within the same short-lived process and only answers whether the references involved in that case were the same Java object.

Counts matter too. For C5 there must be two submitted tasks and, under the executor’s documented contract, one newly started virtual thread for each task. The observed thread IDs were 26 and 27. That is consistent with the contract, but the contract, not the numeric progression, is authoritative. Java does not promise adjacent thread IDs, so an acceptance test must check inequality, not arithmetic succession.

This identity discipline resembles the general lesson in Refonte’s SQS receipt-handle identity article, where a successful API result is insufficient unless the correct delivery attempt and receipt handle are bound together. The topics remain materially different: SQS validates protocol identity across receives, while this Java fixture validates ownership between Thread instances and distinguishes a thread-local binding from the mutable object referenced by that binding. The shared engineering lesson is only that success signals should not substitute for the identity relation that the contract actually requires.

Documentation, expectation, observation, and inference should appear as separate evidence classes:

Evidence class

Example here

What it can support

Documented contract

ThreadLocal: each thread has its own independently initialized copy

What Java SE 21 specifies

Documented contract

Builder defaults to inheriting ITL; false disables it

Expected configuration behavior

Expected oracle

C2 child should see P-before-construction

What the fixture predicts before comparison

Observation

C2 actually printed P-before-construction

What occurred on OpenJDK 21.0.12.1 in this run

Inference

Later task isolation does not prove A called remove()

Logical conclusion from C5 plus executor ownership model

Unsupported claim

A’s ThreadLocal value was immediately garbage-collected

Not established by this fixture

The main primary references are the Java SE 21 ThreadLocal API, InheritableThreadLocal API, Thread and Thread.Builder APIs, Oracle’s Java 21 virtual-thread guide, and JEP 444. Oracle’s guide describes ThreadLocal support and the non-pooling virtual-thread model; the Java APIs supply the ownership and inheritance rules used by the oracle.

JEP 444’s final virtual-thread work is also reflected in the JDK issue record, which states that virtual threads support both ThreadLocal and InheritableThreadLocal and warns against using thread locals to pool costly resources across tasks.

There is no contradiction between “the finished thread’s copies are subject to garbage collection” and “a later virtual thread cannot see that ordinary ThreadLocal value.” The latter follows from ownership separation; the former concerns reclamation of objects after the owner thread goes away. The experiment proves the former and deliberately leaves GC timing outside scope.

Revalidate the repaired invariant. If application policy requires mutable inheritable context to be independent, repair the policy by overriding childValue, then rerun both C7 and C8. Acceptance requires C7 to remain the negative boundary (same identity under default behavior) and C8 to show sameIdentity=false plus an unchanged parent list. Removing C7 would weaken the suite because a fixture that always copies values could make a broken assumption look safe.

Repair the workflow, validate rerun safety, and apply the release matrix

The workflow needs repair whenever it treats one of three weak signals as sufficient evidence: successful task completion, a new task’s clean state, or inheritance of a mutable reference.

First, if cleanup is a required application invariant before a virtual thread continues to another logical unit, use structured try/finally cleanup around ThreadLocal.set() and qualify that path with the same-thread N0/C6 pattern. Per-task virtual-thread isolation does not excuse code that deliberately multiplexes several logical operations inside one virtual thread.

A minimal ownership pattern is:

TL.set(context);
try {
    runLogicalOperation();
} finally {
    TL.remove();
}

That pattern should not be justified by “virtual threads need removal to prevent the next executor task from seeing the value.” Under newVirtualThreadPerTaskExecutor(), the next task gets another virtual thread. The justification is narrower: explicit removal bounds the current thread’s state lifetime while that thread remains alive, protects code that performs multiple logical phases in one thread, and documents the ownership boundary. Oracle separately cautions that virtual threads can be extremely numerous, so ThreadLocal-held objects deserve scrutiny even when cross-task leakage is not the concern.

Second, if inheritable context must not propagate, repair thread creation rather than clearing individual values after the fact:

Thread.ofVirtual()
      .inheritInheritableThreadLocals(false)
      .start(task);

The Java 21 builder explicitly supports this configuration.

Third, if propagation is required but the inherited object must not be jointly mutable, define an explicit child policy:

static final InheritableThreadLocal<List<String>> CONTEXT =
    new InheritableThreadLocal<>() {
        @Override
        protected List<String> childValue(List<String> parent) {
            return parent == null ? null : List.copyOf(parent);
        }
    };

Whether a shallow copy is sufficient is application-specific; this fixture does not claim it solves arbitrary object-graph aliasing.

Rollback is intentionally simple because the fixture has no persistent external state. Each top-level parent case removes its own local bindings where relevant, and child virtual threads terminate at case completion. A rerun should start in a fresh JVM so that static ThreadLocal objects, thread IDs, and evidence buffers cannot accidentally couple two acceptance runs. The expected values and relations must match; thread IDs themselves need not repeat.

The same separation of lifecycle evidence appears, in another domain, in Refonte’s HTML dialog focus lifecycle article: closing a dialog is not by itself proof that focus reached the required destination. That article therefore checks post-close focus ownership. This playbook similarly refuses to treat “virtual thread finished” as evidence for claims the completion event does not establish, but the concrete state machine and acceptance evidence are specific to Java ThreadLocal ownership rather than UI focus.

Assign operational ownership explicitly. The application concurrency owner owns the choice between ordinary and inheritable thread-local state and the decision to disable inheritance. The component defining a mutable inheritable value owns its childValue policy. The runtime qualification owner pins the JDK build and runs the manifest. Release engineering owns preservation of the raw TSV and the reconciled ledger. No owner may convert a missing case into a pass because neighboring cases happened to succeed.

The operational matrix is:

Condition

Decision

Required action

All mandatory cases executed on pinned runtime; observations match independent manifest; raw evidence complete

ACCEPT

Archive JDK metadata, source, manifest and TSV together

Code depends on task completion as cleanup proof, assumes ITL deep-copy semantics, or unintentionally propagates ITL

REPAIR

Change ownership/cleanup/inheritance policy, then rerun the affected cases and controls

JDK build, OS image, fixture source or inheritance configuration changed; evidence is otherwise healthy

RERUN

Recompile and execute the complete manifest on the new pinned environment

Missing rows, uncaught exceptions, unknown runtime version, ambiguous thread identity, or unexplained contract mismatch

HOLD

Do not infer correctness; resolve fixture/runtime discrepancy first

For the execution captured in this playbook, the bounded decision is ACCEPT. N0 through C8 all matched the independently authored expectations. That acceptance covers the tested ownership semantics on OpenJDK 21.0.12.1+1-1-deb13u1-Debian. It does not certify a production application, prove GC timing, prove absence of all ThreadLocal memory pressure, or replace a rerun on the actual release JDK.

Build relevant skills with Refonte Learning

The Refonte Learning Software Engineering Program page currently describes a three-month program with a stated dedication of 12–14 hours per week. It lists full-stack development, cloud computing, microservices, real-time data processing, performance optimization, application security, scalable software solutions, software-development lifecycle work, and real-world software-engineering projects among its competencies or curriculum areas. The page states a bachelor’s-level study requirement and says successful completion offers a Training Certificate and a Certificate of Internship; it also identifies educational mentorship on the page.

Those page-level claims support only broad software-engineering relevance. The program description reviewed for this playbook does not establish specific instruction in ThreadLocal and InheritableThreadLocal ownership in virtual threads.

This playbook provides an independently authored Java 21 oracle, a same-virtual-thread negative control, executable ownership cases, explicit mutable-reference inheritance boundaries, a case-level expected-versus-observed ledger, and an ACCEPT / REPAIR / RERUN / HOLD gate.

For engineers who want broader structured practice in software construction, testing, lifecycle reasoning and release discipline, the Refonte Software Engineering program is relevant at that general skill-domain level; consult the exact program page for current enrollment details.