Java Virtual Threads with Safe Shared State
Learn how to run many Java tasks with ExecutorService and virtual threads, collect results with Future, and protect shared state with atomic classes or locks.
Before this: Java Exceptions for Validation and Safe Cleanup
What you will learn
- After this lesson you can model work as Runnable or Callable tasks instead of manually managing every Thread.
- After this lesson you can run one task per Java virtual thread with ExecutorService and collect results with Future.
- After this lesson you can protect shared mutable state using AtomicInteger, synchronized, or locks.
- After this lesson you can choose between sequential code, platform threads, virtual threads, and parallel streams for common workloads.
1. Model work as tasks, not as raw thread management
A Java Thread is an execution vehicle. A Runnable is a task that returns no value. A Callable<T> is a task that returns a value and can throw checked exceptions. Keeping this distinction clear helps you avoid mixing business logic with thread lifecycle code.
You can still create a Thread directly for a small demonstration, but production code usually benefits from an ExecutorService. The executor owns scheduling and shutdown; your code submits tasks and collects results.
Use Runnable for fire-and-forget work where errors are handled inside the task. Use Callable<T> when the caller needs a result, failure, or cancellation handle through Future<T>.
2. Use a virtual-thread-per-task executor for blocking task style
Executors.newVirtualThreadPerTaskExecutor() creates an executor that starts a new virtual thread for each submitted task. This fits code that is naturally written as one independent task per request, job, message, or blocking operation.
The important design point is not “more threads everywhere”; it is structured ownership. The executor is created for a scope, tasks are submitted inside that scope, results are collected, and the executor is closed.
Do not pool virtual threads to limit concurrency. If a downstream resource must be limited, use a resource control such as a semaphore, queue, connection pool, or rate limiter around that resource.
3. Collect results with Future without serializing submission
submit(Callable<T>) returns a Future<T>. Calling get() waits for completion and either returns the result or throws an exception wrapper if the task failed.
A common performance bug is this pattern: submit one task, immediately call get(), then submit the next. That forces the caller to wait after every submission. Submit all independent tasks first, then iterate over the futures to collect results.
The example below uses a virtual-thread executor, Callable<String> tasks, Future<String> results, and AtomicInteger for a safely shared completion count.
4. Protect shared state deliberately
Shared mutable state is where most thread bugs start. counter++ looks like one operation, but it is a read, compute, and write sequence. Two tasks can read the same old value and overwrite each other’s update.
Use AtomicInteger for simple atomic counters and accumulators. Use synchronized or ReentrantLock when multiple fields must change together as one invariant. Prefer returning values from tasks and combining them in one thread when that keeps the design simpler.
Safe publication matters too: do not let other threads observe an object before its constructor and initialization are complete. Prefer final fields, immutable data, executor submission after construction, and well-defined synchronization boundaries.
5. Choose the simplest concurrency model that fits
Use sequential code when the work is small, order-sensitive, or already fast enough. Concurrency adds coordination costs and failure modes.
Use parallel streams for CPU-bound, data-parallel transformations where the stream pipeline is stateless and side effects are avoided. Use platform threads for a small number of long-lived threads or when you specifically need traditional OS-thread behavior.
Use virtual threads for clear thread-per-task code, especially when tasks spend time waiting on blocking operations. Still protect shared state, close executors, handle failures, and apply back-pressure to limited resources.
Code
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
public static void main(String[] args) throws Exception {
AtomicInteger completed = new AtomicInteger();
List<Future<String>> futures = new ArrayList<>();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 4; i++) {
final int taskId = i;
futures.add(executor.submit(() -> {
completed.incrementAndGet();
return "task-" + taskId;
}));
}
List<String> results = new ArrayList<>();
for (Future<String> future : futures) {
results.add(future.get());
}
Collections.sort(results);
System.out.println(String.join(",", results));
System.out.println("completed=" + completed.get());
}
}
}Each Callable runs in its own virtual thread. The futures are collected after all submissions, and AtomicInteger makes the shared completion count safe.
class UnsafeCounter {
private int value;
void increment() {
value++; // read-modify-write is not atomic
}
int get() {
return value;
}
}This snippet is intentionally not a runnable test because race-condition output is timing-dependent. The bug is the shared int update; use AtomicInteger or a synchronized/locked critical section.
Common mistakes
✕ Incrementing a shared int from multiple tasks with counter++ and assuming it is safe.
✓ Use AtomicInteger.incrementAndGet(), synchronize the critical section, or redesign so each task returns a value and one thread combines results.
✕ Creating an ExecutorService and never shutting it down.
✓ Use try-with-resources for ExecutorService where supported, or call shutdown() in a finally block.
✕ Using Future.get() immediately after each submit, accidentally making tasks run one-by-one from the caller’s point of view.
✓ Submit all independent tasks first, store the Future objects, then collect results in a second loop.
✕ Using virtual threads to speed up CPU-heavy loops automatically.
✓ Use virtual threads mainly for clear thread-per-task blocking code. For CPU-bound data-parallel work, consider sequential code or parallel streams after measuring.
Your challenge
Implement runCase(int tasks) so it starts one virtual-thread task for each number from 1 to tasks. Each task must safely record completion and return the square of its number. Print tasks=<completed>,sum=<sum> for the cases 0, 3, 5, and 10.
Interview questions
What is the difference between Runnable and Callable in Java concurrency?
Runnable has a run() method that returns no value and cannot declare checked exceptions. Callable<T> has a call() method that returns T and can throw checked exceptions; when submitted to an ExecutorService, its result or failure is represented by a Future<T>.
Why should an ExecutorService be closed or shut down?
An executor owns threads and queued work. If it is not closed or shut down, those resources can outlive the intended scope, causing thread leaks, delayed process termination, and unclear ownership of unfinished tasks.
Does using virtual threads make shared mutable state automatically safe?
No. Virtual threads change how tasks are scheduled, not the Java memory model. Shared mutable state still needs atomic variables, synchronization, locks, immutable design, or safe result aggregation.
When would you prefer returning values from tasks over updating shared state?
Prefer returning values when each task can compute an independent result. The caller can combine results after Future.get(), which reduces lock contention and makes correctness easier to reason about.
How can deadlocks happen with locks, and how do you reduce the risk?
Deadlocks can occur when tasks acquire multiple locks in inconsistent orders and each waits for another. Reduce risk by using fewer locks, keeping critical sections short, acquiring locks in a fixed global order, and avoiding blocking calls while holding a lock.
Practice
- Change the main example to submit 10 Callable tasks that return the square of their task id, then print the total.
- Rewrite the counter part of the example using synchronized instead of AtomicInteger.
- Create two independent task groups with two separate executors, and make sure both are closed safely.
Quick quiz
Key takeaways
- Separate task logic from thread lifecycle: prefer ExecutorService over manually managing many Thread objects.
- Use Callable plus Future when the caller needs results or task failures.
- Virtual threads fit thread-per-task blocking code, but they do not remove synchronization requirements.
- Protect shared mutable state with AtomicInteger, synchronized, locks, or by avoiding shared state entirely.
- Submit independent tasks first, then collect futures, and always close the executor scope.
References
- Java SE 21 API: Executors (docs.oracle.com)
- Java SE 21 API: ExecutorService (docs.oracle.com)
- Java SE 21 API: Future (docs.oracle.com)
- Java SE 21 API: AtomicInteger (docs.oracle.com)
- OpenJDK JEP 444: Virtual Threads (openjdk.org)
Related YouTube lessons
See the full Java learning path