SaveMyCert
Log in
5 of 5 free questions left today·for 30 a day
Implementing Multithreading for Concurrent Code Execution

Java Threads, Virtual Threads, Executors and Scoped Values (1Z0-831)

21 min read1Z0-831 · Implementing Multithreading for Concurrent Code ExecutionUpdated

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.

What you’ll learn
  • 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.

CodeThread kindStarted?
new Thread(task)PlatformNo, call start()
Thread.ofPlatform().start(task)PlatformYes
Thread.ofPlatform().unstarted(task)PlatformNo
Thread.ofVirtual().start(task)VirtualYes
Thread.ofVirtual().unstarted(task)VirtualNo
Thread.startVirtualThread(task)VirtualYes
Executors.newVirtualThreadPerTaskExecutor()Virtual, one per taskPer 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.

PropertyPlatform threadVirtual thread
Backed byOne OS thread for its whole lifeMounted on a carrier platform thread only while running
DaemonInherited, or set with setDaemon / daemon(...)Always true; setDaemon(false) throws IllegalArgumentException
PrioritySettableFixed at Thread.NORM_PRIORITY (5); setPriority has no effect
Default nameThread-NEmpty string
ThreadLocalSupportedSupported
PoolingExpensive to create, so usually pooledCheap: create one per task, never pool them
Best forLong-running or CPU-bound workMany 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:

StateMeaningTypical cause
NEWCreated, not yet startedBefore start(), or after unstarted(...)
RUNNABLERunning or ready to runExecuting code
BLOCKEDWaiting to acquire a monitorEntering a synchronized block another thread holds
WAITINGWaiting with no time limitjoin(), Object.wait(), LockSupport.park()
TIMED_WAITINGWaiting with a time limitThread.sleep(ms), join(ms), wait(ms)
TERMINATEDFinishedrun() 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, so isInterrupted() inside the catch block returns false. Code that wants callers to know about the interrupt calls Thread.currentThread().interrupt() again in the handler.
  • A pending interrupt is honoured on entry. If the flag is already set when sleep is called, it throws immediately without sleeping. So it does not matter whether interrupt() 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.
MethodKindReadsClears the flag?
t.isInterrupted()Instancet's flagNo
Thread.interrupted()StaticThe current thread's flagYes: 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.

RunnableCallable<V>
Methodvoid run()V call() throws Exception
Returns a valueNoYes
Checked exceptions in the bodyMust be caughtMay propagate
Accepted byThread, execute, submit, schedulesubmit, invokeAll, invokeAny, schedule
Future.get() returnsnullThe 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 with return 1;) fits only Callable, so a checked exception such as the InterruptedException from Thread.sleep needs no handler.
  • A void body (() -> System.out.print("x"), or a block with no return) fits only Runnable, 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-returning Callable, so submit(() -> count++) returns a future holding the value before the increment.
  • execute(Runnable) has no Callable overload: passing a value-returning block to it fails to compile (bad return type in lambda expression). A Callable lambda must return a value on every path (missing return value otherwise).

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 methodThreadsOrdering and reuse
newSingleThreadExecutor()One platform workerTasks run one at a time, in submission order, all on the same thread
newFixedThreadPool(n)n platform workersUp to n tasks at once; extra tasks queue; no ordering guarantee between workers
newCachedThreadPool()Grows as neededReuses idle threads, creates new ones when all are busy
newVirtualThreadPerTaskExecutor()A new virtual thread per taskNo reuse, unbounded
newThreadPerTaskExecutor(factory)A new thread per task from the factoryNo reuse
newScheduledThreadPool(n) / newSingleThreadScheduledExecutor()Platform workersDelayed 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 every Callable in the collection, blocks until all are done, and returns a List<Future<T>> in the same order as the task list, not in completion order. A failed task's future throws ExecutionException from get(). With a timeout (invokeAll(tasks, 100, MILLISECONDS)), unfinished tasks are cancelled when it expires, and their futures report isCancelled().
  • 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 throw ExecutionException. With a timeout it throws TimeoutException if 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.

MethodRunning tasksQueued tasksBlocks?
shutdown()FinishStill runNo
shutdownNow()Stop attempted (the standard pools interrupt them)Removed and returned as a List<Runnable>No
awaitTermination(t, unit)Waited forWaited for (after a shutdown request); returns true if terminated, false on timeoutYes
close() (Java 19+)FinishStill runYes, until all tasks are done
  • After either shutdown method, isShutdown() is true and any new execute or submit is rejected: the default policy throws the unchecked RejectedExecutionException to the caller (not IllegalStateException).
  • isTerminated() becomes true only once shutdown has been requested and every task has finished.
  • shutdownNow() attempts to stop the running task, and the Executors pools do so by interrupting it; whether that task stops depends on its code (a sleeping task gets InterruptedException). 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 a ScheduledFuture<?> whose get() yields null.
  • schedule(Callable<V>, delay, unit) returns a ScheduledFuture<V>; isDone() is false until the delay passes and the task runs, and get() blocks until then and returns the value.
  • scheduleAtFixedRate(task, initialDelay, period, unit) and scheduleWithFixedDelay(task, initialDelay, delay, unit) take only a Runnable.
scheduleAtFixedRatescheduleWithFixedDelay
The interval is measuredFrom the start of one run to the start of the nextFrom the end of one run to the start of the next
Task shorter than the intervalStarts every periodStarts every run time + delay
Task longer than the intervalNext run starts late, as soon as the previous one ends; runs never overlapAlways 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.

MethodRuns when the previous stage…Produces
thenApply(fn)Succeedsfn's result (like map)
thenAccept(consumer) / thenRun(action)SucceedsNo value (Void)
thenCompose(fn)SucceedsThe result of the stage fn returns (like flatMap)
thenCombine(other, bifn)Both it and other succeedbifn applied to both results
exceptionally(fn)Fails (skipped on success)fn's replacement value: recovers
handle((v, e) -> ...)Succeeds or failsThe bifunction's result: recovers
whenComplete((v, e) -> ...)Succeeds or failsThe 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 final field: static final ScopedValue<String> USER = ScopedValue.newInstance();
  • Bind it with ScopedValue.where(USER, "ana"), which returns a ScopedValue.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 throws NoSuchElementException (message ScopedValue not bound) when there is no binding. isBound() tests first; orElse(other) returns a fallback when unbound (the fallback may not be null: orElse(null) throws NullPointerException); 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.)

ThreadLocalScopedValue
MutableYes, set any timeNo; only nested rebinding
LifetimeUntil remove() or the thread endsThe dynamic extent of run/call
Unset readReturns null (or initialValue)get() throws NoSuchElementException
Inherited by new threadsOnly InheritableThreadLocalNo (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.

Key takeaways
  • 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.

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?