Java Threads, Virtual Threads, Executors and Scoped Values (1Z0-831)
Java multithreading runs tasks concurrently on threads: in Java SE 25 a task is a Runnable or Callable, it runs on either a platform thread (backed by an operating-system thread) or a lightweight virtual thread, and it is usually handed to an ExecutorService rather than to a raw Thread. Oracle 1Z0-831 objective 8.1 asks you to create both kinds of thread, use Runnable and Callable, manage the thread lifecycle, run tasks with the different executor services and the concurrent API, and share immutable context with scoped values. This lesson covers thread builders and virtual-thread rules, start() versus run(), thread states, join and interruption, Future results and failures, invokeAll and invokeAny, shutdown and close(), scheduled executors, CompletableFuture pipelines, and ScopedValue binding, all with code verified on JDK 25.
On this page10 sections
- Creating platform and virtual threads
- Virtual threads versus platform threads
- start() versus run() and the thread lifecycle
- Interrupting a thread
- Runnable, Callable and Future
- Executor services and invokeAll / invokeAny
- Shutting down: shutdown, shutdownNow, awaitTermination and close
- Scheduling tasks with ScheduledExecutorService
- CompletableFuture: asynchronous pipelines
- Scoped values: sharing immutable context
- Create platform and virtual threads with constructors, Thread.Builder and Executors.newVirtualThreadPerTaskExecutor(), and predict their names, daemon status and priority.
- Explain start() versus run(), trace the six Thread.State values through join, sleep and a second start(), and predict the effect of interrupt(), Thread.interrupted() and isInterrupted().
- Decide whether a lambda is a Runnable or a Callable, and predict Future.get() results, null values and ExecutionException causes.
- Choose among the Executors factory methods, and trace invokeAll ordering, invokeAny failure handling, shutdown, shutdownNow, awaitTermination and close().
- Schedule delayed and periodic tasks, distinguishing scheduleAtFixedRate from scheduleWithFixedDelay and what a failing periodic task does.
- Build CompletableFuture pipelines and predict how failures propagate, which stages recover, and how get() and join() wrap exceptions.
- Bind, read and nest scoped values with where, run and call, and explain why bindings are not seen by threads created with the Thread API.
Creating platform and virtual threads
A Java thread is created by handing a task (usually a Runnable) to a Thread object and calling start(); Java SE 25 offers two kinds of thread, platform threads (a thin wrapper around an operating-system thread) and virtual threads (lightweight threads managed by the JVM and mounted on a small pool of carrier platform threads while they run). Both are instances of java.lang.Thread, and isVirtual() tells them apart.
| Code | Thread kind | Started? |
|---|---|---|
new Thread(task) | Platform | No, call start() |
Thread.ofPlatform().start(task) | Platform | Yes |
Thread.ofPlatform().unstarted(task) | Platform | No |
Thread.ofVirtual().start(task) | Virtual | Yes |
Thread.ofVirtual().unstarted(task) | Virtual | No |
Thread.startVirtualThread(task) | Virtual | Yes |
Executors.newVirtualThreadPerTaskExecutor() | Virtual, one per task | Per submitted task |
Thread.ofPlatform() and Thread.ofVirtual() return a Thread.Builder. A builder can be configured once and reused: name("job-") gives every thread the same name, while name("job-", 1) appends a counter that starts at 1 and increases with each thread the builder creates (job-1, job-2…). factory() turns a builder into a ThreadFactory for an executor. The platform builder also has daemon(boolean) and priority(int); the virtual builder does not, because those properties are fixed for virtual threads.
Default names and daemon status
A platform thread made with new Thread(...) gets a default name of the form Thread-N. A virtual thread gets no default name: getName() returns the empty string unless a builder named it. A platform thread built without daemon(...) inherits the daemon status of the thread that created it, and main is a non-daemon thread, so it is normally false. The thread that runs main is itself a platform thread, so Thread.currentThread().isVirtual() in main returns false. Virtual threads are always daemons.
public class Main {
public static void main(String[] args) throws Exception {
Thread.Builder b = Thread.ofPlatform()
.name("io-", 3).daemon(true).priority(7);
Thread p = b.start(() -> {
Thread me = Thread.currentThread();
System.out.println(me.getName() + " "
+ me.isDaemon() + " " + me.getPriority());
});
p.join();
Thread v = Thread.ofVirtual().name("vt").start(() -> {
Thread me = Thread.currentThread();
System.out.println(me.getName() + " "
+ me.isDaemon() + " " + me.getPriority());
});
v.join();
System.out.println(b.unstarted(() -> { }).getName());
}
}This prints io-3 true 7, then vt true 5, then io-4: the platform builder's daemon and priority settings reach its thread, the virtual thread is a daemon at priority 5 regardless, and the builder's counter has moved on because it already created io-3.
Virtual threads versus platform threads
Virtual threads exist to make blocking code cheap: when a virtual thread blocks on I/O, a sleep or a lock wait, the JVM unmounts it from its carrier thread and the carrier runs another virtual thread. That is why an application can have millions of them. They do not make computation faster; CPU-bound work still runs on the same number of cores, and a virtual thread is scheduled onto a small set of carrier platform threads.
| Property | Platform thread | Virtual thread |
|---|---|---|
| Backed by | One OS thread for its whole life | Mounted on a carrier platform thread only while running |
| Daemon | Inherited, or set with setDaemon / daemon(...) | Always true; setDaemon(false) throws IllegalArgumentException |
| Priority | Settable | Fixed at Thread.NORM_PRIORITY (5); setPriority has no effect |
| Default name | Thread-N | Empty string |
ThreadLocal | Supported | Supported |
| Pooling | Expensive to create, so usually pooled | Cheap: create one per task, never pool them |
| Best for | Long-running or CPU-bound work | Many concurrent tasks that mostly wait (I/O, remote calls) |
Two of these rules show up as runtime behaviour rather than compile errors. setDaemon(boolean) is declared on Thread, so virtualThread.setDaemon(false) compiles, but it throws IllegalArgumentException (message 'false' not legal for virtual threads). setPriority(Thread.MAX_PRIORITY) on a virtual thread is silently ignored: getPriority() still returns 5.
On a platform thread, daemon status must be chosen before start(): calling setDaemon(...) while the thread is alive throws IllegalThreadStateException (a different exception from the virtual-thread IllegalArgumentException). Once the thread has terminated the call no longer throws, but it is pointless by then.
Because virtual threads are daemons, the JVM does not wait for them when main ends. Code that must see a virtual thread's work has to wait for it explicitly, with join(), a Future, or by closing the executor that runs it.
Because they are not pooled, the virtual-thread-per-task executor creates a new thread for every submission, so two tasks never see the same Thread object. A newSingleThreadExecutor(), by contrast, keeps one platform worker alive and runs every task on it.
start() versus run() and the thread lifecycle
start() creates a new thread of execution and calls the task's run() on it; calling run() yourself is an ordinary method call that runs the task on the current thread. With t.run(), Thread.currentThread() inside the task returns the caller (for example main), and whatever name was set on t is never seen.
A thread's state is reported by getState() as a Thread.State value:
| State | Meaning | Typical cause |
|---|---|---|
NEW | Created, not yet started | Before start(), or after unstarted(...) |
RUNNABLE | Running or ready to run | Executing code |
BLOCKED | Waiting to acquire a monitor | Entering a synchronized block another thread holds |
WAITING | Waiting with no time limit | join(), Object.wait(), LockSupport.park() |
TIMED_WAITING | Waiting with a time limit | Thread.sleep(ms), join(ms), wait(ms) |
TERMINATED | Finished | run() returned or threw |
The lifecycle only moves forward: a thread never returns to NEW. A Thread object can be started once; calling start() again, even after it has terminated, throws the unchecked IllegalThreadStateException. To run the task again, create a new thread.
join and join with a timeout
t.join() blocks the caller until t terminates; while it waits, the caller is WAITING. t.join(200) waits at most 200 ms and then simply returns: it does not throw anything when the time runs out. Afterwards t.isAlive() tells you whether t actually finished. A thread still inside Thread.sleep(3_000) at that moment is alive and TIMED_WAITING. Both forms of join (and sleep) throw the checked InterruptedException, so the caller must catch or declare it.
BLOCKED is specific to monitor contention (synchronized); a sleeping thread is never BLOCKED. Locking itself belongs to objective 8.2.
Interrupting a thread
Interruption is a cooperative request to stop: t.interrupt() sets t's interrupt status flag and, if t is blocked in sleep, join or wait, wakes it with an InterruptedException. It never kills the thread; the thread's own code decides what to do.
- Throwing clears the flag. When a blocking method throws
InterruptedException, it clears the interrupt status first, soisInterrupted()inside thecatchblock returnsfalse. Code that wants callers to know about the interrupt callsThread.currentThread().interrupt()again in the handler. - A pending interrupt is honoured on entry. If the flag is already set when
sleepis called, it throws immediately without sleeping. So it does not matter whetherinterrupt()lands just before or during the sleep: the result is the same exception. - Running code is not stopped. A loop that never blocks and never checks the flag ignores
interrupt()completely.
| Method | Kind | Reads | Clears the flag? |
|---|---|---|---|
t.isInterrupted() | Instance | t's flag | No |
Thread.interrupted() | Static | The current thread's flag | Yes: returns it, then resets it to false |
public class Main {
public static void main(String[] args) throws Exception {
Thread w = new Thread(() -> {
Thread me = Thread.currentThread();
try {
Thread.sleep(10_000);
System.out.print("woke ");
} catch (InterruptedException e) {
System.out.print(me.isInterrupted() + " ");
me.interrupt();
System.out.print(me.isInterrupted() + " ");
}
});
w.start();
w.interrupt();
w.join();
System.out.println(w.getState());
}
}It prints false true TERMINATED: main's interrupt() wakes the sleeping worker, the throw has already cleared the flag, and re-interrupting in the handler sets it again so code further up the worker's call chain can see it. If the interrupt lands before the worker reaches sleep, the pending flag makes sleep throw at once, so the output is the same.
Executors use the same mechanism: shutdownNow() and Future.cancel(true) try to stop a running task by interrupting the worker that runs it.
Runnable, Callable and Future
Runnable and Callable<V> are the two task types: Runnable.run() returns nothing and cannot throw checked exceptions, while Callable.call() returns a V and is declared throws Exception.
Runnable | Callable<V> | |
|---|---|---|
| Method | void run() | V call() throws Exception |
| Returns a value | No | Yes |
| Checked exceptions in the body | Must be caught | May propagate |
| Accepted by | Thread, execute, submit, schedule | submit, invokeAll, invokeAny, schedule |
Future.get() returns | null | The value returned by call() |
Which interface a lambda becomes
ExecutorService.submit is overloaded for both types, and the lambda body decides which applies:
- A body that returns a value (
() -> "B"or a block withreturn 1;) fits onlyCallable, so a checked exception such as theInterruptedExceptionfromThread.sleepneeds no handler. - A void body (
() -> System.out.print("x"), or a block with noreturn) fits onlyRunnable, so a checked exception inside it must be caught or the code does not compile (unreported exception InterruptedException). - An expression that is both a valid statement and a value, such as
count++or a method call that returns something, fits both. Overload resolution then prefers the value-returningCallable, sosubmit(() -> count++)returns a future holding the value before the increment. execute(Runnable)has noCallableoverload: passing a value-returning block to it fails to compile (bad return type in lambda expression). ACallablelambda must return a value on every path (missing return valueotherwise).
Reading results and failures
Future.get() blocks until the task completes. If the task threw, get() throws a checked ExecutionException whose getCause() is the original exception; the exception never escapes on the caller's thread by itself. get(timeout, unit) throws TimeoutException if the result is not ready in time. isDone() reports completion without blocking, and cancel(boolean) attempts to cancel (with true, interrupting a running task).
import java.io.IOException;
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
try (var es = Executors.newSingleThreadExecutor()) {
Future<String> slow = es.submit(() -> {
Thread.sleep(500);
return "late";
});
try {
slow.get(50, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
System.out.print("timeout ");
}
System.out.print(slow.get() + " ");
Future<String> io = es.submit(() -> {
throw new IOException("disk");
});
try {
io.get();
} catch (ExecutionException e) {
System.out.println(e.getCause().getMessage()
+ " " + io.isDone());
}
}
}
}Output: timeout late disk true. The timed get gave up after 50 ms without cancelling the task, so the later plain get() still returned its value. A block that only throws can complete as neither a void nor a value body, so it fits both overloads and Callable is chosen; the checked IOException therefore needs no handler and arrives as the cause of the ExecutionException.
Executor services and invokeAll / invokeAny
An ExecutorService runs submitted tasks on threads it manages, so code describes what to run and the executor decides where. The Executors factory methods cover the common shapes:
| Factory method | Threads | Ordering and reuse |
|---|---|---|
newSingleThreadExecutor() | One platform worker | Tasks run one at a time, in submission order, all on the same thread |
newFixedThreadPool(n) | n platform workers | Up to n tasks at once; extra tasks queue; no ordering guarantee between workers |
newCachedThreadPool() | Grows as needed | Reuses idle threads, creates new ones when all are busy |
newVirtualThreadPerTaskExecutor() | A new virtual thread per task | No reuse, unbounded |
newThreadPerTaskExecutor(factory) | A new thread per task from the factory | No reuse |
newScheduledThreadPool(n) / newSingleThreadScheduledExecutor() | Platform workers | Delayed and periodic tasks (see below) |
execute(Runnable) (from the Executor interface) is fire-and-forget; submit(...) returns a Future. Only a single-thread executor guarantees the order in which tasks print; with a pool of several workers, output order between tasks is not predictable unless something (a Future.get(), a latch, a join) forces it.
invokeAll and invokeAny
invokeAll(tasks)runs everyCallablein the collection, blocks until all are done, and returns aList<Future<T>>in the same order as the task list, not in completion order. A failed task's future throwsExecutionExceptionfromget(). With a timeout (invokeAll(tasks, 100, MILLISECONDS)), unfinished tasks are cancelled when it expires, and their futures reportisCancelled().invokeAny(tasks)returns the result of one task that completed successfully (and cancels the rest). Failures are tolerated as long as one task succeeds; only if every task fails does it throwExecutionException. With a timeout it throwsTimeoutExceptionif no task succeeds in time.
import java.util.*;
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
List<Callable<Integer>> tasks = List.of(
() -> { Thread.sleep(150); return 1; },
() -> Integer.parseInt("two"),
() -> 3);
try (var es = Executors.newFixedThreadPool(3)) {
for (Future<Integer> f : es.invokeAll(tasks)) {
try {
System.out.print(f.get() + " ");
} catch (ExecutionException e) {
System.out.print("x ");
}
}
try {
es.invokeAny(List.of(tasks.get(1), tasks.get(1)));
} catch (ExecutionException e) {
System.out.println("none succeeded");
}
}
}
}It prints 1 x 3 none succeeded: the futures follow the list order even though the first task finished last, the failing task's future throws ExecutionException from get(), and invokeAny throws ExecutionException because no task succeeded.
Shutting down: shutdown, shutdownNow, awaitTermination and close
An executor keeps its platform worker threads alive until it is shut down, so a program that uses a fixed or single-thread pool and never shuts it down (or closes it) does not exit. Always shut down or close an executor when its work is done.
| Method | Running tasks | Queued tasks | Blocks? |
|---|---|---|---|
shutdown() | Finish | Still run | No |
shutdownNow() | Stop attempted (the standard pools interrupt them) | Removed and returned as a List<Runnable> | No |
awaitTermination(t, unit) | Waited for | Waited for (after a shutdown request); returns true if terminated, false on timeout | Yes |
close() (Java 19+) | Finish | Still run | Yes, until all tasks are done |
- After either shutdown method,
isShutdown()istrueand any newexecuteorsubmitis rejected: the default policy throws the uncheckedRejectedExecutionExceptionto the caller (notIllegalStateException). isTerminated()becomestrueonly once shutdown has been requested and every task has finished.shutdownNow()attempts to stop the running task, and theExecutorspools do so by interrupting it; whether that task stops depends on its code (a sleeping task getsInterruptedException). The tasks still waiting in the queue never start; the returned list holds them.
ExecutorService is AutoCloseable
Since Java 19, ExecutorService extends AutoCloseable. close() performs an orderly shutdown and then waits for all submitted tasks to finish, so a try-with-resources block over an executor guarantees the tasks have completed before the statement after the block runs. This applies to the virtual-thread executor too: the wait comes from close(), not from the daemon status of its threads. The resource can be declared in the header or, since Java 9, be an effectively final variable: try (es) { ... }.
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws Exception {
ExecutorService es = Executors.newSingleThreadExecutor();
es.submit(() -> {
try {
Thread.sleep(300);
} catch (InterruptedException e) { }
System.out.print("job ");
});
es.shutdown();
var ms = TimeUnit.MILLISECONDS;
System.out.print(es.awaitTermination(50, ms) + " ");
System.out.println(es.awaitTermination(2_000, ms)
+ " " + es.isTerminated());
}
}It prints false job true true: shutdown() returned at once, the first awaitTermination timed out with false while the job was still sleeping, and the second returned true once it had finished. A try-with-resources block over the executor would do the same waiting through close().
Scheduling tasks with ScheduledExecutorService
A ScheduledExecutorService runs tasks after a delay or repeatedly, and every scheduling method returns a ScheduledFuture straight away without waiting for the task.
schedule(Runnable, delay, unit)returns aScheduledFuture<?>whoseget()yieldsnull.schedule(Callable<V>, delay, unit)returns aScheduledFuture<V>;isDone()isfalseuntil the delay passes and the task runs, andget()blocks until then and returns the value.scheduleAtFixedRate(task, initialDelay, period, unit)andscheduleWithFixedDelay(task, initialDelay, delay, unit)take only aRunnable.
scheduleAtFixedRate | scheduleWithFixedDelay | |
|---|---|---|
| The interval is measured | From the start of one run to the start of the next | From the end of one run to the start of the next |
| Task shorter than the interval | Starts every period | Starts every run time + delay |
| Task longer than the interval | Next run starts late, as soon as the previous one ends; runs never overlap | Always waits the full delay after a run |
So a task that takes about 1 second, scheduled with a 4-second interval, starts about every 4 seconds at a fixed rate and about every 5 seconds with a fixed delay. When a run outlasts the interval, a fixed rate starts the next run as soon as the previous one ends, while a fixed delay still adds the whole delay after it. A periodic task never runs concurrently with itself, regardless of how many threads the pool has.
When a periodic task fails
A periodic task runs until the executor shuts down or the future is cancelled, so its future normally never completes and get() blocks indefinitely. If one run throws, the schedule is ended: later runs are suppressed and the future completes exceptionally, so get() throws ExecutionException with the thrown exception as its cause. Catch exceptions inside a periodic task if it must keep running.
CompletableFuture: asynchronous pipelines
CompletableFuture is a Future that can be completed and chained: supplyAsync(supplier) (or runAsync(runnable)) starts work on the common ForkJoinPool or a given executor, and each then... method returns a new stage that runs when the previous one completes.
| Method | Runs when the previous stage… | Produces |
|---|---|---|
thenApply(fn) | Succeeds | fn's result (like map) |
thenAccept(consumer) / thenRun(action) | Succeeds | No value (Void) |
thenCompose(fn) | Succeeds | The result of the stage fn returns (like flatMap) |
thenCombine(other, bifn) | Both it and other succeed | bifn applied to both results |
exceptionally(fn) | Fails (skipped on success) | fn's replacement value: recovers |
handle((v, e) -> ...) | Succeeds or fails | The bifunction's result: recovers |
whenComplete((v, e) -> ...) | Succeeds or fails | The original outcome: observes only, does not recover (if the action itself throws after a success, the returned stage fails with that exception) |
When a stage throws, it completes exceptionally and every dependent thenApply/thenCompose/thenCombine stage is skipped and completes with the same failure. Only exceptionally and handle turn a failure back into a value. In handle, the arguments are (result, null) on success and (null, exception) on failure.
get versus join
Both block for the result, but they wrap a failure differently. get() throws the checked ExecutionException (and declares InterruptedException); join() throws the unchecked CompletionException. Either way, getCause() is the original exception, for example ArithmeticException. The exception passed to exceptionally, handle or whenComplete is also usually wrapped: any stage whose failure came from an exception thrown in an async or dependent task (even one attached directly to supplyAsync) receives a CompletionException. Only a future failed explicitly with completeExceptionally(ex) passes ex unwrapped.
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) {
var price = CompletableFuture.supplyAsync(() -> 20);
var qty = CompletableFuture.supplyAsync(() -> 3);
System.out.println(price.thenCombine(qty, (p, q) -> p * q)
.thenApply(t -> t + 1)
.join());
var failed = CompletableFuture
.supplyAsync(() -> Integer.parseInt("x"))
.thenApply(n -> n * 2);
System.out.println(
failed.handle((v, e) -> e == null ? v : -1).join());
var seen = failed.whenComplete((v, e) -> System.out.println(
"saw " + e.getClass().getSimpleName()));
System.out.println(seen.isCompletedExceptionally());
}
}It prints 61, -1, saw CompletionException and true: the n * 2 stage never ran, handle turned the failure into -1, and whenComplete saw the (wrapped) failure while its own stage still completed exceptionally.
Scoped values: sharing immutable context
A scoped value (java.lang.ScopedValue, final in Java SE 25) is a one-way, immutable channel for passing context, such as the current user or tenant, to code deep in a call chain without adding a parameter to every method. A value is bound for a dynamic extent, the time a particular run or call executes on that thread, and is readable by anything that code calls.
- Declare it, usually as a
static finalfield:static final ScopedValue<String> USER = ScopedValue.newInstance(); - Bind it with
ScopedValue.where(USER, "ana"), which returns aScopedValue.Carrier. Chain further.where(ID, 7)calls to bind several values at once. - Run code with the bindings:
carrier.run(runnable)returns nothing;carrier.call(op)returns the operation's result (and may throw the operation's checked exception). - Read it with
get(), which throwsNoSuchElementException(messageScopedValue not bound) when there is no binding.isBound()tests first;orElse(other)returns a fallback when unbound (the fallback may not benull:orElse(null)throwsNullPointerException);orElseThrow(supplier)throws an exception of your choice.
The binding ends when run or call returns. Whether a value is bound depends only on whether the reading code is currently executing inside such a call, never on which object or method does the reading: code run before the call, after it, or by any other route sees the value as unbound.
Immutable, but can be rebound in a nested scope
There is no set method. Code inside a binding can only start a nested binding with another where(...).run(...); inner code sees the new value, and when the inner run returns the outer value is visible again. Rebinding never throws.
public class Main {
static final ScopedValue<String> REGION =
ScopedValue.newInstance();
static final ScopedValue<Integer> LIMIT =
ScopedValue.newInstance();
static String describe() {
return REGION.get() + ":" + LIMIT.get();
}
public static void main(String[] args) throws Exception {
String r = ScopedValue.where(REGION, "eu")
.where(LIMIT, 3)
.call(() -> {
String inner = ScopedValue.where(LIMIT, 9)
.call(Main::describe);
return inner + " " + describe();
});
System.out.println(r);
}
}It prints eu:9 eu:3: the nested call rebinds only LIMIT, REGION stays visible from the outer binding, and LIMIT is 3 again once the inner call returns.
Bindings belong to one thread
Bindings are not inherited by threads created with the Thread API or by tasks handed to an ordinary executor, whether the new thread is platform or virtual. A new thread starts with no binding at all, exactly like code outside any run, and creating it does not disturb the creating thread's binding. (Automatic inheritance exists only for subtasks of StructuredTaskScope, which is still a preview API in Java SE 25.)
ThreadLocal | ScopedValue | |
|---|---|---|
| Mutable | Yes, set any time | No; only nested rebinding |
| Lifetime | Until remove() or the thread ends | The dynamic extent of run/call |
| Unset read | Returns null (or initialValue) | get() throws NoSuchElementException |
| Inherited by new threads | Only InheritableThreadLocal | No (only StructuredTaskScope subtasks) |
Tip. Objective 8.1 is examined mainly through short, complete programs that ask what is printed, whether the code compiles, or which exception is thrown, plus a few statement-selection items on virtual-thread properties, scheduling and scoped-value semantics. Thread items trace start() against run(), getState() at different points, join with and without a timeout, a second start(), and how interrupts interact with sleep and the two interrupt-status methods. Executor items hinge on which submit overload a lambda selects, what Future.get() returns or throws, invokeAll ordering and invokeAny failure tolerance, the effect of shutdown, shutdownNow and close() on running and queued tasks, and the timing difference between fixed-rate and fixed-delay scheduling. CompletableFuture items follow a failure through a pipeline, and scoped-value items test where a binding is visible: inside run or call, in a nested rebinding, after the call returns, and in a newly started thread. Ordering in these programs is always forced by join, Future.get, a latch, a single-thread executor or close(), so precise knowledge of each method's blocking and exception behaviour matters more than memorised examples.
- start() runs the task on a new thread; calling run() runs it on the current thread. A Thread can be started only once: a second start() throws IllegalThreadStateException.
- Virtual threads are always daemons, have a fixed priority of 5 and an empty default name, support ThreadLocal, and should be created per task, never pooled; setDaemon(false) throws IllegalArgumentException.
- join() leaves the caller WAITING; sleep(ms) and join(ms) give TIMED_WAITING; BLOCKED means waiting for a monitor. join(ms) just returns when time runs out.
- Throwing InterruptedException clears the interrupt flag, and a pending interrupt makes sleep throw at once. Thread.interrupted() clears the flag; isInterrupted() does not.
- A value-returning lambda is a Callable (checked exceptions allowed); a void body is a Runnable. Future.get() returns null for a Runnable and throws ExecutionException, with the task's exception as cause, for a failed task.
- invokeAll returns futures in task-list order; invokeAny returns one successful result and throws ExecutionException only if every task fails.
- After shutdown, new tasks are rejected with RejectedExecutionException; shutdownNow attempts to stop running tasks (by interrupting them) and returns the queued ones; close() shuts down and waits for every task.
- scheduleAtFixedRate measures from start to start, scheduleWithFixedDelay from end to start; a periodic run that throws cancels the rest and makes get() throw ExecutionException.
- In CompletableFuture, only exceptionally and handle recover from a failure; get() wraps it in ExecutionException and join() in CompletionException.
- ScopedValue.where(...).run/call binds an immutable value for that call only; get() on an unbound value throws NoSuchElementException, and new threads do not inherit the binding.
Frequently asked questions
What is the difference between a platform thread and a virtual thread in Java?
A platform thread is a wrapper around an operating-system thread for its whole life, while a virtual thread is a lightweight thread managed by the JVM that is mounted on a carrier platform thread only while it runs and is unmounted when it blocks. Virtual threads are cheap enough to create one per task, are always daemon threads and have a fixed priority. They help applications with many tasks that mostly wait on I/O, but they do not make CPU-bound code faster.
How do you create a virtual thread in Java 25?
Use Thread.startVirtualThread(task) or Thread.ofVirtual().start(task) to create and start one, Thread.ofVirtual().unstarted(task) to create one without starting it, or Executors.newVirtualThreadPerTaskExecutor() to run each submitted task on its own new virtual thread. new Thread(task) and Thread.ofPlatform() always create platform threads.
What happens if you call run() instead of start() on a Java thread?
Calling run() executes the task as a normal method call on the thread that called it, so no new thread is created and Thread.currentThread() inside the task returns the caller, such as main. Only start() runs the task on the new thread. A thread can be started only once; calling start() a second time throws IllegalThreadStateException.
What is the difference between Runnable and Callable?
Runnable.run() returns nothing and cannot throw checked exceptions, while Callable.call() returns a value and is declared to throw Exception. When a Runnable is submitted to an ExecutorService, its Future.get() returns null; a Callable's Future returns the computed value. If either task throws, Future.get() throws ExecutionException with the task's exception as its cause.
What is the difference between shutdown() and shutdownNow() on an ExecutorService?
shutdown() stops the executor accepting new tasks but lets running and queued tasks finish. shutdownNow() also stops accepting tasks, attempts to stop the tasks that are running (the standard pools interrupt them) and returns the tasks still waiting in the queue as a List of Runnable without running them. After either call, submitting a task throws RejectedExecutionException, and awaitTermination waits until all tasks have finished.
Does closing an ExecutorService wait for its tasks?
Yes. Since Java 19 the ExecutorService interface extends AutoCloseable, and close() performs an orderly shutdown and then waits until all submitted tasks have completed. A try-with-resources block over an executor therefore ensures every task has finished before the next statement runs, including for the virtual-thread-per-task executor.
What is the difference between scheduleAtFixedRate and scheduleWithFixedDelay?
scheduleAtFixedRate measures the period from the start of one run to the start of the next, while scheduleWithFixedDelay waits the delay after each run finishes. If a run takes longer than the period at a fixed rate, the next run starts late rather than overlapping. A 1-second task with a 4-second interval therefore starts about every 4 seconds at a fixed rate and about every 5 seconds with a fixed delay.
Are Java scoped values inherited by child threads?
No. A ScopedValue binding made with ScopedValue.where(...).run(...) applies only to the thread running that call and only until it returns. Threads created with the Thread API or by an ordinary executor, platform or virtual, start with no binding, so the value reads there exactly as it does outside any binding. Only subtasks of StructuredTaskScope, a preview API in Java SE 25, inherit bindings automatically.
Source
This lesson covers the "Implementing Multithreading for Concurrent Code Execution" domain of the official 1Z0-831 exam guide. Vendors revise their guides — check the source for the current version.
- Java SE 25 Developer Professional (1Z0-831) exam page and topics — Oracle University
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.