Java Concurrency Guide: Threads, Executors and Virtual Threads

Java concurrency explained for Java 25 with thread safety, a race condition fix, a timeline from Java 1.0 to 25 and all our tutorials grouped by task.

Layers of Java concurrency from threads and the memory model up to executors, coordination tools, shared data tools and task APIs

Java concurrency is the set of language features and JDK classes that let a program run several tasks at the same time and safely share data between them. It covers threads and virtual threads, executors and CompletableFuture, locks and atomic variables, concurrent collections, and the rules of the Java Memory Model.

We use concurrency to serve many requests at once, to call several remote services in parallel instead of one after another, and to run background jobs without blocking the user. Since Java 21, virtual threads make the simple one-thread-per-task style scale to thousands of concurrent tasks.

The following example runs two lookups at the same time on virtual threads and waits for both results.

Future<String> user;
Future<Integer> cartSize;
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
    user = pool.submit(() -> "lokesh");                 // runs on its own virtual thread
    cartSize = pool.submit(() -> 3);                    // runs at the same time
}                                                       // close() waits for both tasks
String name = user.resultNow();                         // "lokesh"
int items = cartSize.resultNow();                       // 3

Notice that the code never creates a Thread by hand and never shares a mutable variable between the tasks. We explain what thread safety means, follow the concurrency features from Java 1.0 to Java 25, and give a map of all our concurrency tutorials, grouped by the task each one solves.

1. Concurrency and Threads in Java

Concurrency means that several tasks make progress during the same period, whereas parallelism means that they run at the same instant on different CPU cores. A web server that handles 500 open requests on 8 cores is concurrent, and it runs at most 8 of them in parallel at any moment. The article on concurrency vs parallelism compares both in detail.

Layers of Java concurrency from threads and the memory model up to executors, coordination tools, shared data tools and task APIs
Java concurrency builds task APIs and executors on top of threads, and protects shared data with locks, atomics and concurrent collections

In Java, every concurrent task runs on a thread. A platform thread wraps an operating system thread, which is expensive, so apps traditionally reuse a small pool of them. A virtual thread is a lightweight thread managed by the JVM, and when it blocks on I/O or a lock, it releases its carrier platform thread for other work. All threads of a JVM share the same heap, so any object that two threads can reach needs protection.

The JDK offers several layers on top of threads. Most application code works with the upper layers and rarely creates a Thread object by hand.

  • Thread and Runnable are the foundation, together with the Java Memory Model, which defines when a write in one thread becomes visible to another.
  • ExecutorService, ForkJoinPool and virtual threads decide where and when tasks run.
  • synchronized, ReentrantLock, Semaphore and CountDownLatch coordinate threads.
  • Atomic variables, volatile, concurrent collections and immutable objects keep shared data consistent.
  • CompletableFuture, structured concurrency and scoped values describe tasks and their results at a higher level.

2. What Is Thread Safety?

A class is thread-safe when it behaves correctly while several threads use it at the same time, no matter how the scheduler interleaves those threads, and without any extra synchronization in the calling code. “Correctly” means that the class keeps its own rules, such as “a counter never loses an increment” or “a balance never goes below zero”. The definition from Java Concurrency in Practice is still the most useful one, because it ties safety to the behavior callers expect.

Put differently, a thread-safe class is no more broken with many threads than with one. The class hides the synchronization it needs, so callers do not have to add their own.

2.1. A Race Condition in Practice

A race condition happens when the result depends on the timing of threads. The classic example is count++, which looks like one step but is three, namely read the value, add one and write it back. When two threads read the same old value, one increment is lost.

class VisitCounter {
    private int count;

    void increment() {
        count++;                                // read, add, write: not atomic
    }

    int get() {
        return count;
    }
}
VisitCounter visits = new VisitCounter();
ExecutorService pool = Executors.newFixedThreadPool(2);
for (int t = 0; t < 2; t++) {
    pool.submit(() -> {
        for (int i = 0; i < 100_000; i++) {
            visits.increment();
        }
    });
}
pool.close();
int counted = visits.get();                     // less than 200000 in most runs

Say a news site counts article views this way. Under load, the counter reports fewer views than the access log, and the bug never shows up in a unit test with one thread. The safe version replaces the int with an AtomicInteger, whose incrementAndGet() uses a compare-and-swap (CAS) instruction instead of a lock.

AtomicInteger views = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(2);
for (int t = 0; t < 2; t++) {
    pool.submit(() -> {
        for (int i = 0; i < 100_000; i++) {
            views.incrementAndGet();
        }
    });
}
pool.close();
int total = views.get();                        // 200000

2.2. Stateless Code Is Thread-Safe

An object without fields cannot be broken by other threads, because every call keeps its data in local variables on its own thread stack. A Spring controller or a servlet is a singleton that serves all requests, and it stays thread-safe as long as it keeps request data in locals and parameters instead of fields.

static List<Integer> primeFactors(int number) {
    List<Integer> factors = new ArrayList<>();  // local, one list per call
    for (int p = 2; number > 1; p++) {
        while (number % p == 0) {
            factors.add(p);
            number /= p;
        }
    }
    return factors;
}
List<Integer> factors = primeFactors(84);       // [2, 2, 3, 7]

Two threads calling primeFactors() at the same time each get their own factors list, so they cannot affect each other. The article on local variables and thread safety explains where this rule stops, for example when a local variable points to a shared object.

2.3. Ways to Make Code Thread-Safe

The safest shared state is no shared state, so we try to avoid sharing first and add locks or atomics only where sharing is unavoidable. Each technique fits a different kind of state.

TechniqueHow it worksGood for
ConfinementData stays in locals or in one threadRequest handling, pure functions
ImmutabilityObjects never change after construction, for example records with final fieldsConfiguration, value objects, messages
volatileEvery read sees the latest write, but compound actions stay unsafeFlags such as running, see volatile variables
Atomic variablesLock-free single-variable updates with CASCounters, sequence numbers, references
synchronized and locksOne thread at a time in a critical sectionInvariants across several fields
Concurrent collectionsThread-safe maps, lists and queuesCaches, registries, work queues
Per-thread dataEach thread or scope gets its own copyRequest context, non-thread-safe helpers such as old SimpleDateFormat

3. Evolution of Java Concurrency up to Java 25

Java has had threads since version 1.0, but the tools changed a lot. Java 5 added the java.util.concurrent package, and Java 21 changed the cost of a thread with virtual threads. Knowing the version of a feature helps when we read old code or old tutorials.

Java versionWhat it added for concurrency
1.0 (1996)Thread, Runnable, synchronized, volatile, wait()/notify(), Hashtable and Vector
1.2ThreadLocal, synchronized collection wrappers such as Collections.synchronizedMap(), and the deprecation of Thread.stop(), suspend() and resume()
5 (2004)The java.util.concurrent package (JSR 166) with executors, Callable and Future, locks, atomics, ConcurrentHashMap, blocking queues, CountDownLatch, CyclicBarrier and Semaphore, plus the revised Java Memory Model (JSR 133)
6ConcurrentSkipListMap, ConcurrentSkipListSet and LinkedBlockingDeque
7Fork/join framework (ForkJoinPool), Phaser, LinkedTransferQueue, ConcurrentLinkedDeque and ThreadLocalRandom
8CompletableFuture, StampedLock, LongAdder, parallel streams and a rewritten ConcurrentHashMap with bulk operations
9Reactive streams interfaces in Flow, VarHandle, and timeouts for CompletableFuture
19ExecutorService implements AutoCloseable, and Future gains state(), resultNow() and exceptionNow()
20Thread.stop() throws UnsupportedOperationException
21Virtual threads are final (JEP 444), with Thread.ofVirtual(), Thread.ofPlatform() and Executors.newVirtualThreadPerTaskExecutor()
23Thread.suspend() and Thread.resume() are removed
24Virtual threads no longer pin their carrier inside synchronized (JEP 491)
25Scoped values are final (JEP 506), and structured concurrency is in its fifth preview with the new StructuredTaskScope.open() and Joiner API (JEP 505)

Two practical rules follow from the timeline. Code that still calls Thread.stop() fails at runtime on current JDKs, so we stop threads by interrupting them or with a flag, as the posts in section 4.1 show. Structured concurrency needs –enable-preview on Java 25, so we keep it out of production code until it becomes final.

4. Java Concurrency Tutorials by Task

Our tutorials each cover one class or one problem in depth. The tables group them by what we want to get done, so we can go straight to the right one.

4.1. Creating and Controlling Threads

Our thread basics posts cover the Thread class itself. They are the base for everything else, even if we later hand threads over to an executor.

TutorialWhat it covers
Create and start a threadThread, Runnable, lambdas, builders and ThreadFactory
Runnable vs ThreadImplementing Runnable or extending Thread
Runnable vs CallableTasks without and with a return value
Thread namesNaming threads for logs and dumps
Thread life cycle and statesNEW, RUNNABLE, BLOCKED, WAITING and the transitions
Daemon threadsBackground threads that do not keep the JVM alive
Joining threadsjoin(), timeouts and Thread.yield()
Waiting for threads to finishjoin(), CountDownLatch and executors
Stopping a threadFlags and interruption instead of stop()
Handling InterruptedExceptionRestoring the interrupt flag and cancelling cleanly
Thread.sleep() vs wait()Pausing a thread versus waiting on a monitor
UncaughtExceptionHandlerLogging and restarting threads that die

4.2. Virtual Threads, Structured Concurrency and Scoped Values

Project Loom delivered the features in this group. Virtual threads and scoped values are final in Java 25, while structured concurrency is still a preview.

TutorialWhat it covers
Virtual threadsHow virtual threads work and when they help
newVirtualThreadPerTaskExecutor()One virtual thread per task with an executor
Virtual threads in Spring BootTurning virtual threads on for web requests
Structured concurrencyStructuredTaskScope and joiners (preview)
Scoped valuesSharing request data without ThreadLocal

4.3. Executors, Thread Pools and Async Tasks

An executor separates submitting a task from running it. These posts cover the pool types, the lifecycle and the result handling.

TutorialWhat it covers
ExecutorService guideCreating executors and submitting tasks
Thread pools and ThreadPoolExecutorCore and maximum size, queues and fixed-size pools
ThreadPoolExecutor with BlockingQueueChoosing the work queue of a pool
RejectedExecutionHandlerWhat happens when the pool and queue are full
ScheduledExecutorServiceDelayed and periodic tasks
invokeAll() and invokeAny()Running a batch of tasks and collecting the results
Callable and FutureGetting results and exceptions from tasks
Cancelling a taskFuture.cancel() and interruption
Shutting down an executorshutdown(), shutdownNow() and close()
CompletableFutureChaining, combining and timing out async tasks
Fork/join frameworkSplitting CPU-bound work with ForkJoinPool

4.4. Locks and Thread Coordination

The coordination posts show how threads take turns and wait for each other. Most coordination problems in real apps come down to one of these tools.

TutorialWhat it covers
Object vs class level lockingWhat synchronized locks on
wait(), notify() and notifyAll()Monitor-based signaling
IllegalMonitorStateExceptionCalling monitor methods without holding the lock
Lock and ReentrantLockExplicit locks, tryLock() and conditions
SemaphoreLimiting concurrent access and throttling
Binary semaphoreA semaphore with one permit as a lock
Semaphore vs ReentrantLockChoosing between permits and ownership
CountDownLatchWaiting until a number of events happen
DeadlocksCreating, detecting and avoiding a deadlock

4.5. Shared Data and Memory Visibility

Per-thread copies of data are covered in the ThreadLocal tutorial, including the memory leak that a forgotten remove() causes in thread pools. The posts on local variables, volatile, AtomicInteger and compare-and-swap are linked from section 2, where we compare all the techniques side by side.

4.6. Concurrent Collections and Queues

A concurrent collection lets many threads read and write without an external lock. Producer-consumer designs and thread pools are built on blocking queues.

TutorialWhat it covers
ConcurrentHashMapAtomic updates, bulk operations and sizing
ConcurrentMapThe interface and its atomic methods
Synchronizing a HashMapsynchronizedMap() versus ConcurrentHashMap
ConcurrentSkipListMapA sorted concurrent map
CopyOnWriteArrayListLists that are read far more often than changed
CopyOnWriteArraySetThe set version of copy-on-write
ConcurrentLinkedDequeA non-blocking deque
ArrayBlockingQueueA bounded blocking queue backed by an array
PriorityBlockingQueueA blocking queue ordered by priority
DelayQueueElements that become available after a delay
SynchronousQueueA hand-off queue with no capacity
LinkedTransferQueueProducers that wait until a consumer receives
Producer-consumer with BlockingQueueBounded buffers, poison pills and backpressure

4.7. Troubleshooting Concurrent Code

When a concurrent app hangs or burns CPU, a thread dump shows what every thread is doing.

TutorialWhat it covers
Thread dumpsCapturing and reading dumps, including virtual threads
jstack on LinuxTaking a dump from the command line

5. Choosing the Right Concurrency Tool

Most concurrency problems in business apps fall into a few shapes. Starting from the shape of the problem, rather than from the API we know best, leads to simpler code.

TaskUse in Java 25Avoid
Run many blocking calls (HTTP, JDBC) at onceVirtual threads, newVirtualThreadPerTaskExecutor()Huge platform thread pools
Run CPU-heavy work on all coresA fixed pool sized to the cores, ForkJoinPool or parallel streamsVirtual threads, which add nothing for CPU work
Combine results of async callsCompletableFuture, or structured concurrency once it is finalThreads that write results into shared fields
Count or sum from many threadsAtomicInteger, LongAddersynchronized around a plain int on hot paths
Share a cache or registryConcurrentHashMap with computeIfAbsent()HashMap with outside locking
Limit calls to a slow serviceSemaphoreA small thread pool used only as a limiter
Hand work from producers to workersA bounded BlockingQueuewait()/notify() written by hand
Wait for N tasks to finishExecutorService.close(), invokeAll(), CountDownLatchPolling a shared flag
Pass request context down the call stackScopedValueThreadLocal with virtual threads in large numbers

Say a trip planner page needs a flight quote, a hotel quote and a weather forecast, each from a different service with about 100 ms latency. Called one after another, the page waits about 300 ms, while three tasks on virtual threads finish in about the time of the slowest call. The trip total itself is CPU work of a few microseconds, so it runs on the request thread and needs no executor at all.

6. Common Concurrency Bugs

Concurrency bugs are hard because they depend on timing. They rarely show up on a developer laptop and appear under production load, often only once a day. Knowing their names helps us recognize them in a thread dump or a bug report.

BugWhat happensTypical fix
Race conditionThe result depends on thread timing, for example lost updatesAtomic variables, locks or confinement
Visibility problemA thread never sees another thread’s write, for example a stop flagvolatile, locks or concurrent classes
DeadlockThreads wait for each other’s locks foreverTake locks in one global order, use tryLock() with a timeout
StarvationA thread never gets a lock or CPU timeFair locks, shorter critical sections
Thread leakExecutors are never shut down, so threads pile uptry-with-resources on ExecutorService
Lost interruptCode swallows InterruptedException, so cancellation stops workingRestore the flag with Thread.currentThread().interrupt()

7. Java Concurrency FAQs

Virtual threads changed parts of the classic advice, so these answers separate what still applies from what is outdated.

7.1. Do we still need to learn threads and synchronized when virtual threads exist?

Yes. Virtual threads are still threads, so race conditions, visibility rules, locks and the memory model apply to them the same way as before. What changed is the cost of a thread, which makes thread pools for blocking work unnecessary.

7.2. In which order should we learn Java concurrency?

Start with threads and thread safety, because every other tool builds on them. Continue with executors and Future, concurrent collections and atomic variables, and move on to CompletableFuture and virtual threads. Locks, semaphores and wait()/notify() come after that, since most application code needs them less often than the higher-level tools.

7.3. What is the difference between multithreading and concurrency?

Multithreading is one way to achieve concurrency, namely by running tasks on several threads. Concurrency is the broader idea of dealing with several tasks at once, which also includes event loops and asynchronous I/O on a single thread.

7.4. Is HashMap thread-safe?

No. Concurrent writes to a HashMap can lose entries or corrupt its internal structure. Use ConcurrentHashMap for shared maps, or wrap the map with Collections.synchronizedMap() when contention is low.

7.5. Is Java Concurrency in Practice still worth reading?

Yes, for the fundamentals. The book is from 2006, so it predates CompletableFuture, virtual threads and structured concurrency, but its chapters on thread safety, visibility and composing thread-safe classes are still accurate. We read it together with the JDK 25 Javadoc of java.util.concurrent for the newer APIs.

8. Conclusion

Java concurrency rests on threads that share one heap, and thread safety is about keeping a class correct while several of them use it. We avoid sharing where we can, prefer immutable objects and atomic variables, and use locks only when an invariant spans several fields.

The tools changed a lot since Java 5, and Java 21 to 25 changed them again with virtual threads, scoped values and structured concurrency in preview. For blocking I/O, virtual threads with an executor are the default today, for CPU work a sized pool is, and the tutorials in section 4 cover each tool in depth.

9. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.