Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 20 · The JVM and Concurrency
Executors, Futures, Concurrent Collections and Virtual Threads
The concurrency tools you should actually use: thread pools with ExecutorService, Callable and Future, producer-consumer with BlockingQueue, ConcurrentHashMap and friends, CompletableFuture pipelines, and virtual threads from Java 21, including what they do and do not do.
For
Learners who understand races, visibility and locks and now need the standard library tools for writing real concurrent programs.
Level
Before you start
Concurrency Fundamentals (article 19) and Lambdas (article 15).
On this page
Key takeaways
- Use an ExecutorService instead of creating threads by hand. Submit Callable tasks, receive Future results, and shut the executor down, preferably with try-with-resources.
- Prefer concurrent collections and queues over hand-rolled synchronisation. ConcurrentHashMap supports atomic merge and compute operations, and a BlockingQueue coordinates producers and consumers.
- CompletableFuture lets you compose asynchronous steps with thenApply, thenCompose, thenCombine and exception handling. Always handle failures and choose the executor deliberately.
- Virtual threads (final in Java 21) make it cheap to run very many blocking tasks, one per task. They improve throughput for high-concurrency blocking workloads, not the speed of an individual task, and give no benefit for CPU-bound work.
- Do not pool virtual threads, avoid long blocking inside synchronized sections on Java 21, and measure instead of assuming. Structured concurrency is still a preview API in Java 25.
The previous article showed raw threads, which let you see the problems. Real programs seldom use them directly, for three reasons. Creating a thread per task is expensive. A raw thread cannot return a result or report an exception to the code that started it. And nobody is in charge of how many threads exist, so a burst of work can swamp the machine.
Java's java.util.concurrent package solves these. This article tours the parts you will use in practice, and ends with virtual threads, the biggest change to Java concurrency in years, with an honest account of what they are for.
What you will learn
#- How executors,
CallableandFuturereplace hand-made threads. - How to size and shut down a thread pool.
- How a
BlockingQueuecoordinates producers and consumers. - Which concurrent collections to use, and what they guarantee.
- How
CompletableFuturecomposes asynchronous work. - What virtual threads are, when they help and when they do not.
Executors: tasks and threads, separated
#The key idea of the executor framework is to separate what to run (a task) from how to run it (threads). You submit tasks to an ExecutorService, which decides which thread runs each one, typically from a thread pool of reusable threads.
A task is either a Runnable (no result) or a Callable<T> (returns a value and may throw a checked exception). Submitting a task returns a Future<T>: a handle to the result that is not available yet. The documentation describes submit as creating and returning a Future that can be used to cancel the execution or wait for its completion.
// File: Main.javaimport java.util.ArrayList;import java.util.List;import java.util.concurrent.Callable;import java.util.concurrent.ExecutionException;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;import java.util.concurrent.Future;import java.util.concurrent.TimeUnit;import java.util.concurrent.TimeoutException;public class Main { static long sumRange(long from, long to) { long total = 0; for (long i = from; i <= to; i++) { total += i; } return total; } public static void main(String[] args) throws Exception { try (ExecutorService pool = Executors.newFixedThreadPool(4)) { // closed automatically List<Future<Long>> parts = new ArrayList<>(); for (int i = 0; i < 4; i++) { long from = i * 25_000_000L + 1; long to = (i + 1) * 25_000_000L; parts.add(pool.submit(() -> sumRange(from, to))); // a Callable<Long> } long total = 0; for (Future<Long> part : parts) { total += part.get(); // waits for that result } System.out.println("sum 1..100,000,000 = " + total); Future<String> failing = pool.submit((Callable<String>) () -> { throw new IllegalStateException("task failed"); }); try { failing.get(); } catch (ExecutionException e) { System.out.println("exception from the task: " + e.getCause().getMessage()); } Future<String> slow = pool.submit(() -> { Thread.sleep(2_000); return "finished"; }); try { slow.get(100, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { System.out.println("timed out, cancelling: " + slow.cancel(true)); } } System.out.println("pool closed"); }}sum 1..100,000,000 = 5000000050000000exception from the task: task failedtimed out, cancelling: truepool closedNotice what the framework gives you for free:
- Results come back through
Future.get(), which blocks until the task is done. The four partial sums ran in parallel on up to four threads. - Exceptions are captured. An exception thrown inside the task is wrapped in an
ExecutionExceptionand rethrown when you callget(), so you can find out that a task failed. With raw threads, such an exception would just print a stack trace and vanish. - Timeouts and cancellation.
get(timeout)lets you stop waiting, andcancel(true)asks the task to stop (by interrupting its thread, which is the cooperative cancellation from the last article). - Lifecycle. Since Java 19,
ExecutorServiceisAutoCloseable, and, as the documentation notes,close()waits until all tasks have completed and the executor has terminated. Using try-with-resources means the pool is always shut down, so no threads are left alive to keep the JVM from exiting. Before Java 19 you wrote the two-phase pattern by hand:shutdown()to stop accepting tasks,awaitTermination(...)to wait, andshutdownNow()to force it if it takes too long.
The standard pools, and choosing a size
#Factory | Behaviour | Typical use |
|---|---|---|
| n threads, tasks queue up | Predictable concurrency |
| Creates threads as needed, reuses idle ones | Many short tasks (can create very many threads, so be careful) |
| One thread, tasks run in order | Serialised work |
| Run tasks later or repeatedly | Timers and periodic jobs |
| A new virtual thread per task (Java 21) | Blocking, I/O-heavy tasks |
How many threads? It depends on the kind of work:
- CPU-bound tasks (calculations) keep a core busy, so more threads than cores just add switching. A pool size close to the number of cores (
Runtime.getRuntime().availableProcessors()) is a sensible start. - I/O-bound tasks spend most of their time waiting (for a database or a remote service), so a platform-thread pool needs many more threads than cores to keep the CPU busy. This is exactly the situation that virtual threads (below) address.
Pools are configured with a core size, a maximum size, a work queue and a rejection policy for when everything is full (ThreadPoolExecutor exposes these). The most important caution: an unbounded queue hides overload. If tasks arrive faster than they finish, an unbounded queue grows until memory runs out, whereas a bounded queue with a rejection policy makes the overload visible so you can respond to it.
Coordinating threads: queues and latches
#A very common pattern is producer and consumer: some threads produce work items, others process them. A BlockingQueue is the standard tool: put waits if the queue is full, and take waits if it is empty, so the producer and consumer never need explicit locks.
// File: Main.javaimport java.util.ArrayList;import java.util.List;import java.util.concurrent.ArrayBlockingQueue;import java.util.concurrent.BlockingQueue;import java.util.concurrent.CountDownLatch;public class Main { static final String POISON = "STOP"; // a marker meaning "no more work" public static void main(String[] args) throws Exception { BlockingQueue<String> queue = new ArrayBlockingQueue<>(2); // small, so the producer must wait List<String> processed = new ArrayList<>(); // touched only by the consumer thread CountDownLatch finished = new CountDownLatch(1); Thread consumer = new Thread(() -> { try { while (true) { String job = queue.take(); // waits when the queue is empty if (job.equals(POISON)) { break; } processed.add(job.toUpperCase()); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { finished.countDown(); } }); consumer.start(); for (String job : List.of("email", "report", "backup", "invoice")) { queue.put(job); // waits when the queue is full } queue.put(POISON); finished.await(); // wait for the consumer to finish System.out.println(processed); }}[EMAIL, REPORT, BACKUP, INVOICE]The producer filled a queue of capacity two with four jobs, so it was blocked whenever the consumer fell behind. That is back-pressure, which keeps a fast producer from overwhelming a slow consumer. The "poison pill" is a simple way to tell the consumer to stop. The list processed is only touched by the consumer thread, and the main thread reads it only after finished.await(), which establishes the happens-before relationship that makes this safe.
Other coordination tools you may meet: CountDownLatch (wait until N things have happened, used above), Semaphore (limit how many threads use a resource at once), CyclicBarrier (threads wait for each other at a point), and Phaser.
Concurrent collections
#The ordinary collections are not thread-safe. You could wrap them (Collections.synchronizedList), but that serialises all access and still does not make compound actions such as "check, then add" atomic. The java.util.concurrent package offers collections designed for concurrent use.
Collection | Idea | Notes |
|---|---|---|
| A hash map for many threads | Reads do not block, atomic |
| Every write copies the array | Excellent when reads vastly outnumber writes (listeners); expensive for frequent writes |
| Lock-free, unbounded queue | |
| Queues with waiting |
|
| A sorted map that is safe for concurrency | The concurrent counterpart of |
ConcurrentHashMap deserves a closer look. Its documentation says that retrieval operations (including get) generally do not block and may overlap with updates, and that aggregate operations such as putAll and clear are not atomic: concurrent readers may see only some of the entries. Its iterators are weakly consistent: they do not throw ConcurrentModificationException, and they reflect the state at some point at or since the iterator was created. Methods such as size() are estimates while updates are in flight. The most useful feature is that compound operations are atomic per key:
// File: Main.javaimport java.util.List;import java.util.Map;import java.util.TreeMap;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;import java.util.concurrent.atomic.LongAdder;public class Main { public static void main(String[] args) throws Exception { Map<String, Integer> counts = new ConcurrentHashMap<>(); Map<String, LongAdder> adders = new ConcurrentHashMap<>(); List<String> words = List.of("java", "spring", "java", "sql", "java", "sql"); try (ExecutorService pool = Executors.newFixedThreadPool(8)) { for (int round = 0; round < 1_000; round++) { for (String word : words) { pool.submit(() -> { counts.merge(word, 1, Integer::sum); // atomic per key adders.computeIfAbsent(word, w -> new LongAdder()).increment(); }); } } } // waits for all tasks System.out.println(new TreeMap<>(counts)); Map<String, Long> fromAdders = new TreeMap<>(); adders.forEach((word, adder) -> fromAdders.put(word, adder.sum())); System.out.println(fromAdders); }}{java=3000, spring=1000, sql=2000}{java=3000, spring=1000, sql=2000}Six thousand tasks updated the same map from eight threads, and every count is exact: 3,000 for "java" and 2,000 for "sql" and 1,000 for "spring". Replace ConcurrentHashMap with a plain HashMap and the counts become wrong, or the map becomes corrupted. The two idioms shown, merge for numbers and computeIfAbsent(...).increment() with a LongAdder, are the standard ways to count concurrently, and the second is the pattern that the ConcurrentHashMap Javadoc itself shows as its example for frequency maps.
CompletableFuture: composing asynchronous work
#Future.get() blocks, which wastes a thread waiting. CompletableFuture<T> (Java 8) is a future that you can chain: describe what should happen when the result arrives, without blocking to wait for it. This is the style used for calling several remote services and combining the results.
// File: Main.javaimport java.util.concurrent.CompletableFuture;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;public class Main { static String fetchUser(ExecutorService pool, int id) { sleep(50); return "user-" + id; } static int fetchScore(int id) { sleep(50); if (id < 0) { throw new IllegalArgumentException("bad id " + id); } return id * 10; } static void sleep(long millis) { try { Thread.sleep(millis); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } public static void main(String[] args) { try (ExecutorService pool = Executors.newFixedThreadPool(4)) { // Two independent calls run at the same time, then are combined CompletableFuture<String> user = CompletableFuture.supplyAsync(() -> fetchUser(pool, 7), pool); CompletableFuture<Integer> score = CompletableFuture.supplyAsync(() -> fetchScore(7), pool); CompletableFuture<String> summary = user.thenCombine(score, (u, s) -> u + " has score " + s); System.out.println(summary.join()); // A dependent step: the second call needs the first result (thenCompose flattens) CompletableFuture<String> chained = CompletableFuture .supplyAsync(() -> 3, pool) .thenApply(id -> id + 1) .thenCompose(id -> CompletableFuture.supplyAsync(() -> fetchUser(pool, id), pool)) .thenApply(String::toUpperCase); System.out.println(chained.join()); // Failure handling: an exception travels down the chain until something handles it CompletableFuture<Integer> risky = CompletableFuture .supplyAsync(() -> fetchScore(-1), pool) .thenApply(s -> s + 1) .exceptionally(e -> -1); System.out.println("recovered value: " + risky.join()); CompletableFuture<Integer> unhandled = CompletableFuture.supplyAsync(() -> fetchScore(-5), pool); try { unhandled.join(); } catch (java.util.concurrent.CompletionException e) { System.out.println("join threw: " + e.getCause().getMessage()); } // Wait for several at once CompletableFuture<Void> all = CompletableFuture.allOf( CompletableFuture.runAsync(() -> sleep(30), pool), CompletableFuture.runAsync(() -> sleep(60), pool)); all.join(); System.out.println("all done: " + all.isDone()); } }}user-7 has score 70USER-4recovered value: -1join threw: bad id -5all done: trueThe vocabulary maps onto the lambda shapes from article 15:
Method | Meaning |
|---|---|
| Start a computation that returns a value |
| Transform the result when it arrives (like |
| The next step is itself asynchronous (like |
| Combine two independent results |
| Wait for all or any of several futures |
| Recover from a failure |
| Wait for the result ( |
Three practical cautions. First, always decide which executor runs your stages. If you omit it, the tasks run on a shared common pool, and blocking calls there can starve unrelated work. Second, handle exceptions: an exception in one stage skips the following thenApply stages and arrives wrapped in a CompletionException at join(), and a future whose failure nobody looks at fails silently. Third, do not overbuild. A chain of eight stages with mixed error handling is hard to read, and for many I/O-bound programs, simple blocking code on virtual threads (next section) is clearer than a CompletableFuture pipeline.
Virtual threads
#The problem
#A traditional Java thread (a platform thread) is a thin wrapper around an operating system thread. These are comparatively heavy: each reserves stack memory, and the operating system can handle only a limited number. The classic server design is one thread per request, which is simple to write and debug, but when each request spends most of its time waiting (on a database, a remote call), the server runs out of threads long before it runs out of CPU. JEP 444 describes the bottleneck: the JDK implements threads as wrappers around operating system threads, so the number of available threads is limited.
The old escape was asynchronous programming (callbacks, reactive libraries, long CompletableFuture chains), which scales but is harder to write, read and debug.
The solution
#Virtual threads, delivered in Java 21 (JEP 444, after previews in 19 and 20), are lightweight threads managed by the JVM. Millions can exist at once. The JVM runs many virtual threads on a small pool of platform threads called carrier threads. When a virtual thread performs a blocking operation (waiting for I/O, sleeping), it is unmounted from its carrier, which is then free to run another virtual thread, and the blocked one is resumed later. You write ordinary blocking code, and get the scalability of asynchronous code.
// File: Main.javaimport java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;import java.util.concurrent.atomic.AtomicInteger;public class Main { public static void main(String[] args) throws Exception { AtomicInteger completed = new AtomicInteger(); long start = System.nanoTime(); try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 10_000; i++) { executor.submit(() -> { Thread.sleep(500); // each task "waits" half a second completed.incrementAndGet(); return null; }); } } // waits for all 10,000 tasks long millis = (System.nanoTime() - start) / 1_000_000; System.out.println("completed: " + completed.get()); System.out.println("finished in under 5 seconds: " + (millis < 5_000)); Thread virtual = Thread.ofVirtual().name("demo-virtual").start(() -> System.out.println("is virtual: " + Thread.currentThread().isVirtual())); virtual.join(); Thread platform = Thread.ofPlatform().start(() -> System.out.println("is virtual: " + Thread.currentThread().isVirtual())); platform.join(); }}completed: 10000finished in under 5 seconds: trueis virtual: trueis virtual: falseTen thousand tasks each waited half a second. With 10,000 platform threads you would risk running out of resources, and with a pool of 100 platform threads the work would take about fifty seconds (10,000 tasks ÷ 100 threads × 0.5 s). Here, one virtual thread per task finished everything in a few seconds at most. (The program prints a yes-or-no about the duration, not the duration itself, because the exact time varies from machine to machine.) The programming model did not change: there is no callback and no CompletableFuture, just a task that sleeps.
What virtual threads are not
#The marketing-style claim "virtual threads make applications faster" is wrong, and it matters to be precise. JEP 444 says it directly: virtual threads are not faster threads. They do not run code any faster than platform threads. They exist to provide scale (higher throughput), not speed (lower latency).
- They help when there are many concurrent tasks (the JEP says more than a few thousand) and the work is not CPU-bound, mostly waiting on I/O.
- They do not help CPU-bound work: a calculation that saturates the cores gains nothing from more threads, virtual or not.
- They do not remove the need for correct synchronisation. Races, visibility and deadlocks apply exactly as before.
- They do not make a slow database faster. If a downstream system can only handle 50 concurrent requests, a million virtual threads will just queue behind it, or overload it. Limit concurrency to protected resources with a
Semaphore.
Guidelines
#- Create a new virtual thread for each task, and do not pool them. They are cheap by design, and a pool defeats the purpose. Use
newVirtualThreadPerTaskExecutor()and notnewFixedThreadPool. - Avoid long blocking inside
synchronizedblocks on Java 21. In JDK 21, a virtual thread that blocks while holding a monitor is pinned to its carrier thread, which reduces scalability. PreferReentrantLockwhere blocking occurs under a lock. Later JDK releases have reduced this limitation, so check the notes for the release you use. - Be careful with
ThreadLocal. Millions of virtual threads each with a large thread-local value use a lot of memory. - Keep CPU-bound work in a pool sized to the number of cores.
- Measure. Compare throughput and latency under realistic load before and after switching.
Spring Boot supports them with a single property. Its documentation states that when virtual threads are enabled (using Java 21 or later and spring.threads.virtual.enabled set to true), the auto-configured task executor uses virtual threads. We return to this in article 38.
Structured concurrency, briefly
#Running several related subtasks and having them succeed, fail or cancel together is called structured concurrency. In Java 25, the StructuredTaskScope API of JEP 505 is still a preview API, disabled by default and enabled only with --enable-preview, which means that it may still change. It is worth knowing about, and it is not used in this course.
Choosing the right tool
#Situation | Reach for |
|---|---|
Run independent tasks, collect results |
|
Many blocking I/O tasks (one per request) | Virtual threads (Java 21+) |
CPU-heavy work on many items | A fixed pool near the core count, or a parallel stream if justified (article 16) |
Combine results of several async calls |
|
Hand work from producers to consumers |
|
Shared counters and maps |
|
Many readers, rare writers of a list |
|
Limit concurrent access to a resource |
|
Protect compound state |
|
Common mistakes
#- Creating a thread per task by hand instead of using an executor.
- Forgetting to shut the executor down, so the JVM never exits.
- Calling
get()right aftersubmitfor every task, which serialises everything. - Unbounded queues and cached pools that hide overload.
- Ignoring
Futureresults and so missing failed tasks. - Blocking inside a
CompletableFuturestage on the common pool. - Not handling exceptions in a chain.
- Pooling virtual threads, or using them for CPU-bound work and expecting a speed-up.
- Using
nullinConcurrentHashMap, which throws. - Assuming
size()is exact during concurrent updates.
Exercises
#Exercise 1
Submit and collect
#Write code that uses an ExecutorService to compute the length of each string in a list in parallel, and returns the lengths in the original order.
Exercise 2: Predict
#What happens to the program if you forget to close or shut down a newFixedThreadPool(2) after submitting tasks? What changes if you use try-with-resources on Java 19 or later?
Exercise 3: Choose
#For each, choose platform-thread pool, virtual threads or neither: (a) resizing 1,000 large images (CPU heavy), (b) handling 20,000 concurrent HTTP requests that each call a slow external API, (c) a single background job that runs once an hour.
Exercise 4: Fix the chain
#CompletableFuture.supplyAsync(() -> riskyCall()).thenApply(this::format).thenAccept(System.out::println); sometimes prints nothing and no error appears. Explain and fix it.
Exercise 5: Right collection
#A map is read thousands of times per second and updated once an hour by replacing its entire contents. Suggest a design that avoids locking for readers.
Show solutionHide solution
try (ExecutorService pool = Executors.newFixedThreadPool(4)) { List<Future<Integer>> futures = new ArrayList<>(); for (String s : strings) { futures.add(pool.submit(() -> s.length())); } List<Integer> lengths = new ArrayList<>(); for (Future<Integer> f : futures) { lengths.add(f.get()); }}Submitting all tasks before calling get() lets them run in parallel, and reading the futures in submission order keeps the results in the original order. (For tasks this tiny, the overhead outweighs any benefit, so use the pattern for real work.)
Exercise 2
Show solutionHide solution
The pool's threads are non-daemon, so after the tasks finish, the idle threads stay alive and the JVM never exits: the program hangs at the end. With try-with-resources on Java 19 or later, close() initiates an orderly shutdown and waits until all tasks have completed, so the pool is always cleaned up and the program can exit.
Exercise 3
Show solutionHide solution
(a) Platform-thread pool sized near the number of cores: CPU-bound work gains nothing from virtual threads. (b) Virtual threads: many concurrent tasks that mostly wait for a slow API; add a Semaphore if the external API cannot handle that concurrency. (c) Neither: a scheduled executor or the framework's scheduler is enough for a single hourly job.
Exercise 4
Show solutionHide solution
The exception thrown by riskyCall() fails the future, which skips thenApply and thenAccept. Because nothing handles the failure, nothing is printed and no error is reported: the future simply completes exceptionally, silently. Fix it by handling the failure: add .exceptionally(e -> { log(e); return fallback; }) or use .handle((value, e) -> ...) before the terminal step, or call join() somewhere that propagates the exception to a place that deals with it.
Exercise 5
Show solutionHide solution
Keep the map in a volatile field (or an AtomicReference) that holds an immutable map (Map.copyOf(...)). Readers read the reference and use the map without any locks, and the hourly update builds a new map and swaps the reference in one atomic assignment. Readers always see either the old or the new complete map. This is the same "replace the whole immutable object" idea as in the previous article's exercise.
Where to go next
#- Next: File I/O and Serialization begins the tools and craft module with files and
try-with-resources. - For concurrency in a database: SQL and PostgreSQL Fundamentals and Relationships, Lazy Loading, N+1 and Transactions.
- For the interview version: Interview Concepts, Part 1.
Sources & further reading
- Technology
- Software Engineering
- #concurrency
- #Java
- #Java backend
- #Java interview
- #JPA
- #learn Java
- #modern Java
- #multithreading
- #Spring Boot
- #SQL
Connected lessons
References
- JPA Relationships, Lazy Loading, the N+1 Problem, Transactions and Optimistic Locking
- File I/O and Serialization
- Concurrency Fundamentals: Threads, Races and Synchronisation
Show all 8 →
- Lambdas, Functional Interfaces and Method References
- Streams in Practice (and When Not to Use Them)
- SQL and PostgreSQL Fundamentals for Java Developers
- Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- Java Interview Concepts, Part 1: Core Java, Collections, Functional Java, the JVM and Concurrency
Linked from 2 guides
Other pages in the library that point here.
Keep reading
More in Technology →Guide · Technology
Heaps and Priority Queues
Guide · Technology
Dynamic Programming Practice: From 1D to 2D
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

