Java Multithreading and Synchronization: A Practical Guide
Understand threads, the Java memory model, synchronization, locks, and concurrent collections. A practical guide to writing correct multithreaded Java code.
What you'll learn
- ✓How threads and the Java memory model interact
- ✓Using synchronized blocks and methods correctly
- ✓When to reach for ReentrantLock and atomics
- ✓Concurrent collections vs synchronized wrappers
- ✓Avoiding deadlocks and race conditions
Prerequisites
- •Basic Java
- •Familiarity with collections
What and Why
Multithreading lets a Java program do multiple things at the same time, using multiple CPU cores or hiding I/O latency. The benefits are speed and responsiveness. The cost is complexity: shared mutable state across threads is one of the hardest problems in software.
Java has had threads since 1.0 and the modern java.util.concurrent package since Java 5. Understanding both layers, the language primitives and the concurrency utilities, is essential for writing correct server-side Java.
Mental Model
Every thread has its own stack but shares the heap. Without coordination, two threads writing to the same field can see torn or stale values. The Java Memory Model defines when writes by one thread become visible to another. The short version: writes are not guaranteed visible unless you synchronize, use volatile, or use thread-safe classes that synchronize internally.
Synchronization gives you two guarantees: mutual exclusion (only one thread at a time inside the protected region) and a happens-before relationship (writes before unlock are visible to threads that acquire the same lock).
Atomics like AtomicInteger give you the visibility guarantee without the locking overhead, using CPU compare-and-swap instructions.
Hands-on Example
A naive counter and its corrected version:
class UnsafeCounter {
private int count = 0;
public void increment() { count++; } // RACE
public int get() { return count; } // STALE
}
class SafeCounter {
private int count = 0;
public synchronized void increment() { count++; }
public synchronized int get() { return count; }
}
class AtomicCounter {
private final AtomicInteger count = new AtomicInteger();
public void increment() { count.incrementAndGet(); }
public int get() { return count.get(); }
}
The synchronized version is correct but holds a lock. The atomic version is correct and lock-free, which matters under high contention.
Thread A Shared Counter Thread B
| | |
read count = 5 | |
| | |
| | read count = 5
| | |
write count = 6 ----->| 6 |
| |<------ write count = 6
| | 6 (one increment lost)| The diagram shows the classic lost-update race. Both threads read 5, both write 6, and one increment vanishes. Synchronization or atomics prevent this.
Common Pitfalls
Forgetting that synchronized requires the same lock object. Two synchronized methods on different instances do not exclude each other.
Holding a lock while doing I/O or calling unknown code. The lock duration balloons and other threads stall. Worse, if the called code tries to acquire another lock, you can deadlock.
Assuming Collections.synchronizedList(list) is enough. Each method is atomic, but compound operations like “check then add” are not. Use ConcurrentHashMap or explicit locking for those patterns.
Using volatile for compound updates. volatile int count; count++; is not atomic. volatile guarantees visibility, not atomicity.
Catching InterruptedException and ignoring it. Re-throw or restore the interrupt flag with Thread.currentThread().interrupt(). Swallowing it breaks shutdown semantics.
Practical Tips
Prefer high-level concurrency utilities over raw threads. ExecutorService, CompletableFuture, and the new virtual threads (Java 21+) handle most use cases better than new Thread().start().
Reach for ConcurrentHashMap, CopyOnWriteArrayList, and BlockingQueue instead of synchronizing your own collections. They are tuned, tested, and lock-striped where applicable.
Use ReentrantLock when you need features synchronized lacks: tryLock with timeout, fairness, or multiple condition variables. Otherwise, prefer synchronized for clarity and JVM optimization.
Make fields final whenever possible. Immutable objects are inherently thread-safe and free of the issues above.
Test concurrent code with tools like JCStress or simply hammer it with thousands of iterations across many threads. Bugs that appear once in a million iterations will appear in production.
Wrap-up
Java concurrency is a deep topic, but the day-to-day rules are manageable. Identify shared mutable state. Protect it with synchronization, atomics, or by switching to immutable objects. Prefer high-level utilities to hand-rolled threading.
Treat concurrency as a design concern, not a feature you bolt on at the end. The cleanest concurrent code is the code that avoids sharing state in the first place. When you must share, share carefully, document the locking strategy, and test under contention.
Related articles
- Java Java CompletableFuture Patterns for Async Code
Master CompletableFuture composition patterns including chaining, combining, error handling, timeouts, and async pipeline design in Java.
- Java A Practical Guide to Java Virtual Threads
Master Java virtual threads with hands-on examples. Learn thread creation, executor patterns, structured concurrency, pinning pitfalls, and migration strategies.
- Java Java Concurrency with CompletableFuture
How CompletableFuture composes asynchronous work in Java: chaining, combining, error handling, executors, and the patterns that keep concurrent code readable.
- JavaScript JavaScript Web Workers: Run Code Off the Main Thread
Learn how to use JavaScript Web Workers to run CPU-intensive code off the main thread, keeping your UI responsive with practical examples and patterns.