Skip to content
C Codeloom
Java

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.

·4 min read · By Codeloom
Intermediate 8 min read

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)|
Two threads contending on a shared counter

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.