The landscape of Java development has evolved dramatically over the last decade. By 2025, with the maturity of Java 21+ and the widespread adoption of Virtual Threads (Project Loom), the way we handle concurrency has shifted. However, the fundamental laws of physics within the JVM—shared mutable state, memory visibility, and race conditions—remain unchanged.
Whether you are building high-frequency trading platforms, scalable microservices with Spring Boot 3.x, or optimizing legacy monoliths, deep knowledge of multithreading is what separates a junior developer from a lead engineer.
In this comprehensive guide, we will move beyond the basics. We will dissect the Java Memory Model, compare legacy locking with modern atomic structures, analyze performance pitfalls like False Sharing, and explore how Virtual Threads change the scalability equation.
Prerequisites and Environment #
To follow the code examples in this article, ensure your environment meets these criteria:
- JDK: Java 21 LTS or higher (Java 25 is preferred for the latest structured concurrency features).
- IDE: IntelliJ IDEA 2024.x or Eclipse.
- Build Tool: Maven or Gradle.
- Hardware: A multi-core processor is recommended to observe true parallelism.
No specific external dependencies are required for the core concurrency examples, as java.util.concurrent is built-in. However, for benchmarking, we recommend JMH (Java Microbenchmark Harness).
1. The Anatomy of a Race Condition #
Before fixing thread safety, we must understand exactly how it breaks. A race condition occurs when the correctness of a computation depends on the relative timing or interleaving of multiple threads.
The most common culprit is the “check-then-act” or “read-modify-write” sequence on shared mutable state.
The Broken Counter #
Let’s look at a classic example that still trips up developers during code reviews.
package com.javadevpro.concurrency;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class UnsafeCounter {
private int count = 0;
// This method is NOT thread-safe
public void increment() {
count++;
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
UnsafeCounter counter = new UnsafeCounter();
ExecutorService executor = Executors.newFixedThreadPool(10);
// Submit 1000 tasks, each incrementing the counter 1000 times
// Total expected: 1,000,000
for (int i = 0; i < 1000; i++) {
executor.submit(() -> {
for (int j = 0; j < 1000; j++) {
counter.increment();
}
});
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
System.out.println("Final Count: " + counter.getCount());
// Output will likely be < 1,000,000 (e.g., 985,421)
}
}Why Did It Fail? #
The operation count++ looks like a single instruction, but at the bytecode level, it is three distinct operations:
- LOAD: Read the current value of
countfrom memory into a register. - INCREMENT: Add 1 to the register value.
- STORE: Write the new value back to memory.
If Thread A reads 100, gets suspended, and Thread B reads 100, both will increment to 101 and write it back. One increment is lost forever. This is an Atomicity failure.
2. Synchronization Mechanisms: A Comparative Analysis #
Java provides multiple tools to solve the race condition. Choosing the right one impacts code readability, maintainability, and throughput.
Option A: The synchronized Keyword (Intrinsic Locks)
#
This is the oldest and simplest mechanism. It ensures that only one thread can execute the protected block at a time.
public synchronized void increment() {
count++;
}Pros: Simple; easy to reason about; automatically unlocks on exception. Cons: Coarse-grained; threads block indefinitely waiting for the lock (no timeout); performance overhead in high-contention scenarios (though greatly optimized in modern JVMs).
Option B: ReentrantLock (Explicit Locks)
#
Found in java.util.concurrent.locks, this offers more control than intrinsic locks.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockingCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // Acquire lock
try {
count++;
} finally {
lock.unlock(); // CRITICAL: Always unlock in finally
}
}
}Option C: AtomicInteger (CAS - Compare and Swap)
#
For simple counters and flags, non-blocking synchronization is often superior. This uses CPU-level instructions (CAS) to update values without putting threads to sleep.
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Atomic operation
}
}Locking Strategy Comparison #
Below is a detailed comparison to help you choose the right tool for the job.
| Feature | synchronized |
ReentrantLock |
ReadWriteLock |
AtomicInteger |
StampedLock |
|---|---|---|---|---|---|
| Type | Intrinsic (Monitor) | Explicit | Explicit | Non-blocking (CAS) | Explicit (Optimistic) |
| Fairness | No | Optional | Optional | No | No |
| Interruptible | No | Yes | Yes | N/A | Yes |
| Try/Timeout | No | Yes | Yes | N/A | Yes |
| Read/Write Split | No | No | Yes | No | Yes |
| Performance (Low Contention) | High | High | Medium | Very High | High |
| Performance (High Contention) | Medium | Medium | Low (Writer starvation) | Medium (Spinning) | Very High (Readers) |
3. The Java Memory Model (JMM) and Visibility #
Fixing atomicity is only half the battle. You must also ensure Visibility.
In modern architecture, each CPU core has its own L1, L2, and L3 caches. A thread running on Core 1 might update a variable, but that update might sit in the L1 cache and not be flushed to main memory (RAM) immediately. Thread B on Core 2 will continue to see the stale value.
The volatile Keyword
#
The volatile keyword guarantees visibility and ordering (happens-before relationship), but not atomicity.
public class StoppableTask implements Runnable {
// volatile ensures other threads see the change to 'running' immediately
private volatile boolean running = true;
public void stop() {
running = false;
}
@Override
public void run() {
while (running) {
// heavy processing
}
System.out.println("Task stopped.");
}
}Without volatile, the run() loop might never terminate because the CPU core running the loop might cache running = true indefinitely.
Decision Flow: Which Lock to Use? #
Use the following flowchart to determine the appropriate synchronization strategy for your component.
4. Advanced Coordination: Latch vs. Barrier #
Beyond simple locking, you often need to coordinate multiple threads. Two common utilities are CountDownLatch and CyclicBarrier.
CountDownLatch #
Use this when one (or more) threads need to wait for a set of operations to complete before proceeding. It is a “one-shot” event.
Scenario: A server startup sequence must initialize Cache, DB, and Messaging before opening the HTTP port.
import java.util.concurrent.CountDownLatch;
public class ServiceStartup {
public static void main(String[] args) throws InterruptedException {
int services = 3;
CountDownLatch latch = new CountDownLatch(services);
new Thread(new Service("Database", latch)).start();
new Thread(new Service("Cache", latch)).start();
new Thread(new Service("Messaging", latch)).start();
System.out.println("Main thread waiting for services...");
latch.await(); // Blocks until count reaches 0
System.out.println("All services up. Starting HTTP Server.");
}
static class Service implements Runnable {
private String name;
private CountDownLatch latch;
public Service(String name, CountDownLatch latch) {
this.name = name;
this.latch = latch;
}
public void run() {
try {
Thread.sleep(1000); // Simulate work
System.out.println(name + " initialized.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
}
}
}CyclicBarrier #
Use this when a group of threads must wait for each other to reach a common barrier point before any of them can proceed. It is reusable.
Scenario: A parallel image processing algorithm where the image is split into tiles. All tiles must be processed before the image is saved.
5. Performance Tuning and Common Pitfalls #
Writing correct concurrent code is hard; writing fast concurrent code is harder.
Deadlocks #
A deadlock happens when two threads hold locks that the other needs, and neither will release their own.
The Fix: Always acquire locks in a consistent global order.
Deadlock Visualization #
The following sequence diagram illustrates how a standard deadlock occurs between two threads and two resources.
False Sharing (CPU Cache Lines) #
This is a silent performance killer. CPU caches work in lines (typically 64 bytes). If two independent volatile variables exist in the same cache line, and two different threads update them, the cores will invalidate each other’s cache lines continuously. This is “cache thrashing.”
Solution: Use the @Contended annotation (requires -XX:-RestrictContended) or pad your classes with unused variables to space out the fields.
LongAdder vs. AtomicLong #
If you have extremely high contention on a counter (e.g., a metrics collector in a high-traffic web server), AtomicLong performs poorly because every update requires a successful CAS.
Java 8+ Solution: Use LongAdder.
It maintains a set of variables (cells) and sums them only when the final value is requested. This reduces contention significantly.
import java.util.concurrent.atomic.LongAdder;
LongAdder adder = new LongAdder();
adder.increment(); // extremely fast under high load6. The Future: Virtual Threads (Project Loom) #
In Java 21, Virtual Threads became a standard feature. This represents a paradigm shift.
Historically, Java threads mapped 1:1 to OS threads (Platform Threads). OS threads are heavy (MBs of stack memory) and limited in number (usually thousands).
Virtual Threads are lightweight (bytes of memory), managed by the JVM, and you can have millions of them.
When to use Virtual Threads? #
- I/O Bound Tasks: Database calls, REST API calls, file reading. Virtual threads unmount when blocked, freeing the carrier thread.
- Not for CPU Bound Tasks: Video encoding, heavy math. Stick to Platform threads (ForkJoinPool) for these.
Code Example: Virtual Thread Executor #
The try-with-resources block below creates a structured scope for concurrency.
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class VirtualThreadDemo {
public static void main(String[] args) {
long start = System.currentTimeMillis();
// New in Java 21: A built-in Executor for Virtual Threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// Launch 10,000 tasks
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
try {
// Simulate blocking I/O (e.g., DB call)
Thread.sleep(Duration.ofMillis(100));
} catch (InterruptedException e) {
// Handle interruption
}
return i;
});
});
} // Executor implicitly waits for all tasks to finish here
long end = System.currentTimeMillis();
System.out.println("Processed 10,000 tasks in: " + (end - start) + "ms");
}
}In a traditional thread pool, this would likely exhaust the pool and queue tasks, taking significantly longer. With Virtual Threads, the JVM handles the scheduling efficiently.
7. Best Practices for Production #
As we wrap up, here are the golden rules for writing concurrent Java in 2025:
- Prefer Immutability: If an object cannot be changed, it is inherently thread-safe. Use Java Records (
record User(String name) {}) extensively. - Use
java.util.concurrent: Never usewait()andnotify()unless you are writing a low-level library. UseCountDownLatch,CyclicBarrier, orCompletableFuture. - Concurrent Collections: Use
ConcurrentHashMapinstead ofCollections.synchronizedMap(). The former uses bucket-level locking (segmentation), while the latter locks the entire map. - Avoid
synchronized(this): Locking onthisexposes your lock to the outside world. External code could synchronize on your object and cause a deadlock. Always use a private final lock object.private final Object mutex = new Object(); // ... synchronized(mutex) { ... } - Leverage Virtual Threads for I/O: If you are running a web server (Spring Boot 3.2+, Quarkus, Helidon), enable Virtual Threads to handle high throughput with lower memory footprint.
Conclusion #
Concurrency in Java is powerful but unforgiving. While tools like Virtual Threads make scaling easier, they do not eliminate the need for proper synchronization when accessing shared state. By understanding the underlying mechanics—from CPU caches to the Java Memory Model—you can build systems that are not only correct but also performant.
Next Steps:
- Refactor a legacy
synchronizedblock to useReentrantReadWriteLockand benchmark the difference. - Experiment with
CompletableFuturefor composing asynchronous tasks. - Enable Virtual Threads in your Tomcat or Jetty configuration and load test your API.
Happy Coding!
Disclaimer: The code provided is for educational purposes. Always test concurrent code thoroughly in a staging environment before deploying to production.