Java Synchronization — $50k Volatile Lost Update
Production volatile counter lost 0.5% trades — read-increment-write not atomic.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- Synchronized provides mutual exclusion, visibility, and atomicity via the JVM monitor
- volatile = visibility only — no atomicity, no mutual exclusion
- ReentrantLock adds tryLock(), fair ordering, and multiple Condition objects
- JVM lock escalation: biased → thin lock (CAS) → fat lock (OS mutex) — uncontended locks are nearly free
- Biggest production mistake: using volatile for compound actions (read-modify-write) — you lose updates silently
Java synchronization is the mechanism that prevents thread interference and memory consistency errors when multiple threads access shared mutable state. Without it, the JVM's memory model allows threads to cache variables in local registers or processor caches, meaning one thread's write may never be visible to another — leading to data races that manifest as lost updates, stale reads, or corrupted data structures.
The infamous "$50k volatile lost update" scenario occurs when developers naively mark a shared counter as volatile (which only guarantees visibility, not atomicity) and then perform read-modify-write operations like count++ without synchronization, causing interleaved increments to silently drop updates under load. Synchronization solves this by acquiring an intrinsic lock (monitor) on an object, creating a critical section where only one thread executes at a time, and establishing a happens-before relationship that flushes thread-local caches to main memory upon unlock.
Under the hood, the JVM implements monitors via bytecode instructions monitorenter/monitorexit, which rely on operating system mutexes and biased locking optimizations in HotSpot — a synchronized block can start as a cheap atomic compare-and-swap on the object header before escalating to a full OS-level lock under contention. The choice between synchronized and java.util.concurrent.locks.ReentrantLock depends on your concurrency profile: synchronized is simpler, automatically releases locks on exceptions, and benefits from JVM-level optimizations like lock coarsening and biased locking, but lacks features like timed waits, interruptible locks, or fairness policies.
ReentrantLock gives you explicit control with tryLock(), lockInterruptibly(), and Condition objects for advanced coordination, at the cost of manual unlock handling and slightly higher memory overhead. In practice, use synchronized for most straightforward mutual exclusion and ReentrantLock when you need non-block-structured locking, fairness guarantees, or multiple condition queues — but never use volatile as a substitute for synchronization on compound operations.
Imagine a single bathroom in a busy office. If two people walk in at the same time, chaos happens. So you put a lock on the door — one person goes in, locks it, does their thing, then unlocks it for the next person. Java synchronization is exactly that lock for your data. Without it, multiple threads crash into each other's work and corrupt everything silently.
Every production Java system eventually faces the same invisible enemy: two threads touching shared data at the exact same moment. The symptoms are maddening — a counter that's off by one, a bank balance that quietly goes negative, a cache that returns stale data for a random 0.1% of requests. These bugs don't crash your app loudly; they corrupt it silently, only surfacing in production under load, impossible to reproduce in your IDE. That's what makes concurrency bugs the most expensive kind.
Why Java Synchronization Is Not Optional
Java synchronization is the mechanism that ensures mutual exclusion and visibility when multiple threads access shared mutable state. At its core, it uses intrinsic locks (monitors) on objects: a thread acquires the lock before entering a synchronized block or method, and releases it upon exit. This guarantees that only one thread executes the critical section at a time, preventing race conditions like the classic lost update — where two threads read a value, increment it, and write back, losing one increment. Without synchronization, a volatile counter can lose $50k in a high-frequency trading system in seconds.
Synchronization provides two key properties: atomicity and visibility. Atomicity ensures that the block executes as an indivisible unit — no thread sees an intermediate state. Visibility ensures that changes made by one thread before releasing the lock are visible to another thread after acquiring the same lock. This is the Java Memory Model's happens-before guarantee. Note that volatile only provides visibility, not atomicity — a common pitfall. Synchronized blocks are reentrant: the same thread can acquire the same lock multiple times without deadlocking itself.
Use synchronization when you have mutable shared state that must be updated atomically — counters, caches, queues, or any object invariant. In real systems, skipping synchronization on a seemingly simple increment leads to silent data corruption, production outages, and impossible-to-reproduce bugs. The rule: if a field is accessed by multiple threads and at least one writes, synchronize all accesses. Prefer higher-level concurrency utilities (Locks, AtomicInteger, ConcurrentHashMap) for better performance and clarity, but understand that synchronized remains the simplest correct tool for many cases.
How the JVM Monitor Actually Works Under the Hood
Every Java object carries an invisible header — 8 or 16 bytes depending on your JVM flags — that contains what's called a mark word. That mark word encodes the object's identity hash code, GC age, and, critically for us, its lock state. When a thread enters a synchronized block, the JVM doesn't immediately go to the OS for a heavyweight mutex. It first tries a biased lock — it literally writes the thread ID into the mark word and assumes ownership. If that same thread comes back, it re-enters for free. Zero CAS operations, zero OS involvement.
If a second thread shows up and contends for the lock, the JVM upgrades to a thin lock using a Compare-And-Swap (CAS) on the mark word. Still no OS involvement — pure user-space spin. Only when contention is high does it escalate to a fat lock (an inflated monitor object backed by a real OS mutex), which is expensive because it can cause a thread context switch.
Understanding this escalation path matters in production. It's why briefly-held locks on uncontended objects are nearly free, but high-contention synchronized blocks can devastate throughput. The JVM can never downgrade from a fat lock back to biased locking on the same object without a Stop-The-World safepoint — a painful detail that affects long-running server applications.
import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class MonitorLockDemo { private int ticketsSold = 0; public synchronized void sellTicket() { ticketsSold++; } public int getTicketsSold() { return ticketsSold; } public static void main(String[] args) throws InterruptedException { MonitorLockDemo box = new MonitorLockDemo(); int threadCount = 10; int salesPerThread = 100_000; ExecutorService pool = Executors.newFixedThreadPool(threadCount); CountDownLatch allDone = new CountDownLatch(threadCount); long startTime = System.currentTimeMillis(); for (int i = 0; i < threadCount; i++) { pool.submit(() -> { for (int sale = 0; sale < salesPerThread; sale++) { box.sellTicket(); } allDone.countDown(); }); } allDone.await(); long elapsed = System.currentTimeMillis() - startTime; System.out.println("Expected tickets sold : " + (threadCount * salesPerThread)); System.out.println("Actual tickets sold : " + box.getTicketsSold()); System.out.println("Time taken : " + elapsed + "ms"); pool.shutdown(); } }
volatile vs synchronized — They Solve Different Problems
This is the most dangerously misunderstood topic in Java concurrency. Developers often reach for volatile as a 'lightweight synchronized' and ship race conditions to production. Let's be precise about what each one actually guarantees.
volatile gives you two things: visibility and ordering. Every write to a volatile variable is flushed from the thread's CPU cache to main memory immediately, and every read fetches from main memory. It also establishes a happens-before relationship — all writes before the volatile write are visible to any thread that reads the volatile variable. What volatile does NOT give you is atomicity. Reading a long variable on a 32-bit JVM is two separate 32-bit reads. volatile makes both reads visible, but if another thread writes the long between your two reads, you get a torn read. More critically, volatile doesn't protect compound actions like check-then-act (if count == 0 then reset it) — that sequence is still a race condition.
synchronized gives you atomicity, visibility, AND mutual exclusion. Only one thread can execute the synchronized block at a time. The memory semantics are stronger: entering a synchronized block refreshes all variables from main memory; exiting flushes all writes. Use volatile for simple boolean flags and single-variable state changes where atomicity isn't needed. Use synchronized (or AtomicXxx classes) the moment you have a compound action.
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class VolatileVsSynchronized { private volatile int volatileCounter = 0; private int synchronizedCounter = 0; public void unsafeIncrement() { volatileCounter++; } public synchronized void safeIncrement() { synchronizedCounter++; } public static void main(String[] args) throws InterruptedException { VolatileVsSynchronized demo = new VolatileVsSynchronized(); ExecutorService pool = Executors.newFixedThreadPool(8); int iterations = 50_000; for (int i = 0; i < 8; i++) { pool.submit(() -> { for (int j = 0; j < iterations; j++) { demo.unsafeIncrement(); demo.safeIncrement(); } }); } pool.shutdown(); pool.awaitTermination(30, TimeUnit.SECONDS); int expected = 8 * iterations; System.out.println("Expected count : " + expected); System.out.println("volatile counter (UNSAFE) : " + demo.volatileCounter); System.out.println("synchronized counter (SAFE): " + demo.synchronizedCounter); boolean volatileFailed = demo.volatileCounter < expected; System.out.println("\nvolatile lost updates : " + volatileFailed); } }
Advantages vs Disadvantages of Synchronization in Java
Every concurrency tool involves tradeoffs. Understanding when to use synchronized and when to avoid it is a mark of a senior developer.
| Advantages | Disadvantages |
|---|---|
Simplicity: The synchronized keyword is built into the language. No explicit lock/unlock calls, no risk of forgetting to release the lock. | No timeouts: A thread waiting for a synchronized block blocks indefinitely. There is no tryLock(timeout) escape hatch. |
| Reentrancy: The same thread can acquire the same monitor multiple times without deadlocking itself. This is essential for recursive calls. | Not interruptible: You cannot cancel a thread that is blocked on a synchronized lock via Thread.interrupt(). |
Automatic memory visibility: Entering/exiting a synchronized block guarantees that all variables become visible to other threads — the happens-before edge covers the entire block. | No fairness: synchronized does not guarantee thread ordering. Starvation is possible under high contention. |
| Low overhead when uncontended: The JVM's biased locking makes uncontended synchronized blocks nearly free — just a single thread-ID check. | Single condition per lock: You can only have one wait-set per monitor (via wait/notify). For complex signalling, ReentrantLock with multiple Condition objects is more flexible. |
Built-in, no extra imports: Works with any object — no need to create Lock instances. | Escalation cost under contention: Under heavy contention, the lock inflates to an OS mutex, causing context switches. ReentrantLock offers more control like tryLock() to avoid blocking entirely. |
| Reliable for basic mutual exclusion: For straightforward critical sections, it's hard to misuse compared to explicit locks. | Difficult to test: Race conditions can hide in production because synchronized doesn't log or report failures. |
synchronized- You need timed or interruptible lock acquisition.
- You need fair lock admission (first come, first served).
- You have multiple producer/consumer pairs that need separate wait-sets.
- You are building high-throughput concurrent data structures under extreme contention (consider lock-free alternatives).
synchronized is still the best choice- Simple, short critical sections where lock hold time is microseconds.
- Code where readability and low error risk matter more than absolute peak throughput.
- When you already depend on the JVM's built-in monitor for
wait/notifyin legacy code.
synchronized with ReentrantLock everywhere 'for performance.' Profile first: if biased locking is active and contention is low, synchronized often outruns explicit locks because the JVM can inline and optimize the monitor entry. Premature optimization with explicit locks introduces more bugs than it solves.synchronized is the go-to for simple, short critical sections. Switch to explicit locks only when you need timeouts, fairness, or multiple conditions.ReentrantLock — When synchronized Isn't Enough
The synchronized keyword is elegant but inflexible. You can't try to acquire a lock without blocking forever. You can't acquire two locks in a way that avoids deadlock. You can't interrupt a thread that's waiting for a lock. java.util.concurrent.locks.ReentrantLock solves all of this.
ReentrantLock is explicit — you call lock() and you must call unlock() yourself, typically in a finally block. It's reentrant just like synchronized, meaning the same thread can acquire it multiple times without deadlocking itself (it keeps a hold count). The critical extras are tryLock() — which returns false immediately if the lock is unavailable instead of blocking — and tryLock(timeout, unit) — which blocks for at most a given duration. lockInterruptibly() lets another thread cancel a waiting thread via Thread.interrupt(), which is impossible with synchronized.
ReentrantLock also supports fairness mode via new ReentrantLock(true). In fair mode, threads acquire the lock in the order they requested it (FIFO queue), preventing thread starvation. The tradeoff is lower throughput — the JVM can't do lock batching or barging optimizations. Use fair mode only when you have a specific correctness requirement around ordering, not as a default.
Condition objects from ReentrantLock replace wait/notify with named, granular signals — one of the most powerful concurrency patterns in Java.
import java.util.ArrayDeque; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class BoundedTicketQueue { private final Queue<String> tickets = new ArrayDeque<>(); private final int maxCapacity; private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); public BoundedTicketQueue(int maxCapacity) { this.maxCapacity = maxCapacity; } public void produce(String ticket) throws InterruptedException { lock.lock(); try { while (tickets.size() == maxCapacity) { System.out.println(Thread.currentThread().getName() + " waiting — queue full"); notFull.await(); } tickets.offer(ticket); System.out.println(Thread.currentThread().getName() + " produced: " + ticket + " | Queue size: " + tickets.size()); notEmpty.signal(); } finally { lock.unlock(); } } public String consume() throws InterruptedException { lock.lock(); try { while (tickets.isEmpty()) { System.out.println(Thread.currentThread().getName() + " waiting — queue empty"); notEmpty.await(); } String ticket = tickets.poll(); System.out.println(Thread.currentThread().getName() + " consumed: " + ticket + " | Queue size: " + tickets.size()); notFull.signal(); return ticket; } finally { lock.unlock(); } } public static void main(String[] args) { BoundedTicketQueue queue = new BoundedTicketQueue(3); Thread producer = new Thread(() -> { String[] events = {"Concert-A", "Concert-B", "Concert-C", "Concert-D", "Concert-E"}; for (String event : events) { try { queue.produce(event); Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, "TicketProducer"); Thread consumer = new Thread(() -> {\n for (int i = 0; i < 5; i++) { try { queue.consume(); Thread.sleep(150); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, "TicketConsumer"); producer.start(); consumer.start(); } }
await()) — never if. Spurious wakeups are real: the JVM spec permits a thread to wake from await() without being signalled. Using if instead of while means you proceed on a spurious wakeup and corrupt your invariants. This is one of the most common senior-level concurrency bugs.lock() in any blocking path.unlock() in finally.synchronized vs Lock Comparison: Feature-by-Feature
While both synchronized and ReentrantLock provide mutual exclusion and memory visibility, they differ in several important capabilities. The following table summarises the key differences that matter most to working developers:
| Feature | synchronized Keyword | ReentrantLock |
|---|---|---|
| Reentrancy | Yes — same thread can re-acquire the monitor multiple times. | Yes — same thread can re-acquire the same lock (hold count incremented). |
| Interruptible waiting | No — a thread blocked on synchronized cannot be interrupted. | Yes — lockInterruptibly() allows cancellation of waiting thread. |
| Timed lock acquisition | No — the thread blocks until the lock is released. | Yes — tryLock(long time, TimeUnit unit) returns false after timeout instead of blocking forever. |
| Fairness | No — locking is non-fair (barging). Any thread can acquire the lock even if others have been waiting longer. | Optional — constructor with true enables FIFO fairness. |
| Multiple conditions | One implicit condition (via wait/notify). | Multiple Condition objects per lock (e.g., notFull, notEmpty). |
| Non-blocking try | Not available. | tryLock() returns immediately if lock is unavailable, enabling lock-free flight. |
| Lock inspection | Not possible. | getHoldCount(), isHeldByCurrentThread(), getQueueLength() — useful for monitoring. |
| Performance uncontended | Very fast (biased lock). | Slightly more overhead due to object creation. |
| Performance under high contention | Degrades to OS mutex quickly. | Similar, but tryLock can back off. |
| Error-proneness | Low (automatically released). | Medium (must manually unlock in finally). |
- Default to
synchronizedfor simple critical sections. It's simpler and less error-prone. - Switch to
ReentrantLockwhen you need timed or interruptible waits, fairness, or multiple conditions. - Never use
ReentrantLockeverywhere just because it's 'more flexible' — the extra complexity costs you in code reviews and bug rates.
synchronized is an OS mutex context switch when contention spikes. For ReentrantLock, the worst case is a deadlock caused by a missing unlock call. Both are equally hard to debug. Use synchronized for short, non-blocking, predictable critical sections; use ReentrantLock when you need control over waiting behavior.synchronized for simplicity and low bug rate; choose ReentrantLock for timeouts, fairness, or interruptible operations.ReadWriteLock for Read-Heavy Workloads
When your data is read by many threads but written infrequently, a single mutual-exclusion lock is wasteful. ReadWriteLock solves this by allowing multiple concurrent readers, but exclusive writer access. In Java, ReentrantReadWriteLock implements this pattern.
The contract: multiple threads can hold the read lock simultaneously as long as no thread holds the write lock. Write access is exclusive — when a writer holds the lock, no readers can read. This dramatically improves throughput in read-dominated workloads like caches, configuration stores, or market data feeds.
- Read-lock acquisition is typically a cheap CAS, even under many readers.
- Write-lock acquisition must wait for all readers to release, then grants exclusive access.
- Warning: If a read-heavy workload also has frequent writes, the writer can starve — threads pile up waiting for the write lock. Consider
StampedLockfor optimistic reads.
- A HashMap that is read 1000x per second but updated once per minute.
- A configuration registry updated on admin action.
- A customer cache refreshed nightly.
- If writes are frequent or hold time is long, the read lock prevents writes and can cause latency spikes.
- For extremely hot data (updated every few microseconds), the overhead of two separate locks may not pay off.
import java.util.HashMap; import java.util.Map; import java.util.concurrent.locks.ReentrantReadWriteLock; public class ReadWriteConfigStore { private final Map<String, String> config = new HashMap<>(); private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); public String get(String key) { rwLock.readLock().lock(); try { return config.get(key); } finally { rwLock.readLock().unlock(); } } public void set(String key, String value) {\n rwLock.writeLock().lock();\n try {\n config.put(key, value);\n } finally { rwLock.writeLock().unlock(); } } public static void main(String[] args) throws InterruptedException { ReadWriteConfigStore store = new ReadWriteConfigStore(); store.set("db.url", "jdbc:mysql://localhost:3306/prod"); // Simulate concurrent readers Runnable reader = () -> { for (int i = 0; i < 10; i++) { System.out.println(Thread.currentThread().getName() + " read: " + store.get("db.url")); try { Thread.sleep(1); } catch (InterruptedException ignored) {} } }; Thread t1 = new Thread(reader, "Reader-1"); Thread t2 = new Thread(reader, "Reader-2"); Thread writer = new Thread(() -> { store.set("db.url", "jdbc:mysql://backup:3306/prod"); System.out.println("Writer updated config"); }, "Writer"); t1.start(); t2.start(); Thread.sleep(2); writer.start(); t1.join(); t2.join(); writer.join(); System.out.println("Final config: " + store.get("db.url")); } }
StampedLock which offers even better throughput for read-heavy workloads by using optimistic reads that don't block writers at all. If your reads are extremely frequent and your writes rare, consider StampedLock.tryOptimisticRead().Semaphore for Resource Pool Limiting
java.util.concurrent.Semaphore controls access to a finite set of resources. Unlike synchronized which grants exclusive access to one thread at a time, a Semaphore allows up to N threads to access a resource simultaneously — a perfect fit for connection pools, rate limiters, or any scenario where you want to throttle concurrency.
- Initialize a Semaphore with a fixed number of permits.
- Threads call
before using the resource, andacquire()when done.release() - If no permits are available,
blocks until one is released.acquire() tryAcquire()offers a non-blocking alternative.
- Database connection pool: Limit to 10 concurrent connections.
- Rate limiting: Allow at most 5 API calls per second (with time-based release).
- Bounded concurrent processing: Process at most 100 files in parallel.
- Myth: Semaphore permits are a fixed cap that cannot be changed. Reality: You can
more permits than you acquired, effectively increasing the pool size — but this is almost always a bug.release() - Myth: Semaphore is like a lock. Reality: A lock grants exclusive access (one thread). A semaphore with 1 permit is a mutex, but semaphores are typically used for bounded concurrency, not mutual exclusion.
Production warning: If a thread acquires a permit but never releases it (e.g., due to exception), the permit is lost forever. Always use try-finally around acquire/release, just like with explicit locks.
import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; public class DatabaseConnectionPool { private final Semaphore available; public DatabaseConnectionPool(int maxConnections) { this.available = new Semaphore(maxConnections, true); // fair ordering } public void useConnection(String query) throws InterruptedException { available.acquire(); try { System.out.println(Thread.currentThread().getName() + " executing: " + query); // Simulate DB call TimeUnit.MILLISECONDS.sleep(200); System.out.println(Thread.currentThread().getName() + " done."); } finally { available.release(); } } public static void main(String[] args) { DatabaseConnectionPool pool = new DatabaseConnectionPool(2); // only 2 connections for (int i = 1; i <= 5; i++) { final int taskId = i; new Thread(() -> { try { pool.useConnection("SELECT * FROM orders WHERE id = " + taskId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, "Worker-" + taskId).start(); } } }
acquireUninterruptibly() sparingly; prefer acquire() with proper exception handling. Monitor availablePermits() in production alerts.Deadlock — How It Happens and How to Prevent It Systematically
Deadlock is when two or more threads each hold a lock the other needs, so they all wait forever. No exception is thrown. No log line appears. The threads just freeze silently. In production this manifests as a hung service that passes health checks (the health check endpoint runs on a different thread) but stops processing work.
Deadlock requires four conditions simultaneously, known as Coffman's conditions: mutual exclusion (locks can only be held by one thread), hold-and-wait (a thread holds one lock while waiting for another), no preemption (you can't take a lock away from a thread), and circular wait (thread A waits for thread B's lock, and thread B waits for thread A's lock). Remove any one condition and deadlock becomes impossible.
The most practical prevention technique is lock ordering: always acquire multiple locks in a globally consistent order across all code paths. If every thread acquires lock A before lock B, circular wait is impossible. ReentrantLock's tryLock(timeout) is a second line of defence — you can back off and retry if you can't get all the locks you need. Java's thread dump (kill -3 on Linux, jstack, or VisualVM) will show DEADLOCK detected and print the exact lock cycle — learn to read them.
import java.util.concurrent.locks.ReentrantLock; public class DeadlockPrevention { static class BankAccount { private final String owner; private double balance; private final ReentrantLock lock = new ReentrantLock(); BankAccount(String owner, double initialBalance) { this.owner = owner; this.balance = initialBalance; } ReentrantLock getLock() { return lock; } String getOwner() { return owner; } double getBalance() { return balance; } void debit(double amount) { balance -= amount; } void credit(double amount) { balance += amount; } } public static void unsafeTransfer(BankAccount from, BankAccount to, double amount) throws InterruptedException {\n from.getLock().lock();\n try {\n Thread.sleep(50);\n to.getLock().lock();\n try { from.debit(amount); to.credit(amount); } finally { to.getLock().unlock(); } } finally { from.getLock().unlock(); } } public static void safeTransfer(BankAccount a, BankAccount b, double amount) {\n int hashA = System.identityHashCode(a);\n int hashB = System.identityHashCode(b);\n ReentrantLock first = (hashA <= hashB) ? a.getLock() : b.getLock();\n ReentrantLock second = (hashA <= hashB) ? b.getLock() : a.getLock();\n first.lock();\n try {\n second.lock();\n try { a.debit(amount); b.credit(amount); } finally { second.unlock(); } } finally { first.unlock(); } } public static void main(String[] args) { BankAccount alice = new BankAccount("Alice", 1000); BankAccount bob = new BankAccount("Bob", 1000); Thread t1 = new Thread(() -> safeTransfer(alice, bob, 100)); Thread t2 = new Thread(() -> safeTransfer(bob, alice, 50)); t1.start(); t2.start(); try { t1.join(); t2.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println("Alice: " + alice.getBalance() + " Bob: " + bob.getBalance()); } }
Lock-Free Programming with java.util.concurrent.atomic
When contention is high, synchronized blocks and ReentrantLock cause context switches that tank throughput. Lock-free alternatives use CAS (Compare-And-Swap) operations at the hardware level — your CPU provides a single atomic instruction (CMPXCHG on x86) that does read-modify-write in one step. Java's AtomicInteger, AtomicLong, AtomicReference, and friends wrap this in a simple API.
Lock-free doesn't mean no waiting — it means no OS-level blocking. Threads spin (busy-wait) in a loop until the CAS succeeds. Under low contention, this is faster than mutexes because there's no context switch. Under high contention, spinning wastes CPU cycles. That's why the JVM's thin lock is essentially a spin lock before escalating to a fat lock.
The atomic classes also provide getAndUpdate(), accumulateAndGet(), and updateAndGet() for compound updates. For fields, AtomicReferenceFieldUpdater lets you update a volatile field atomically without wrapping an object.
A classic pattern is the lock-free stack. Each push atomically swaps the head reference using compareAndSet. If another thread changes the head between your read and CAS, the CAS fails and you retry. This is optimistic concurrency — assume no conflict, detect if it happens, and retry.
import java.util.concurrent.atomic.AtomicReference; public class LockFreeStack<T> { private static class Node<T> { final T value; Node<T> next; Node(T value) { this.value = value; } } private AtomicReference<Node<T>> head = new AtomicReference<>(); public void push(T value) { Node<T> newNode = new Node<>(value); while (true) { Node<T> currentHead = head.get(); newNode.next = currentHead; if (head.compareAndSet(currentHead, newNode)) {\n return; // success\n } // else: another thread changed head, retry } } public T pop() { while (true) { Node<T> currentHead = head.get(); if (currentHead == null) { return null; // empty stack } Node<T> newHead = currentHead.next; if (head.compareAndSet(currentHead, newHead)) {\n return currentHead.value;\n } } } public static void main(String[] args) throws InterruptedException { LockFreeStack<String> stack = new LockFreeStack<>(); Thread t1 = new Thread(() -> { stack.push("A"); stack.push("B"); }); Thread t2 = new Thread(() -> { stack.push("C"); }); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(stack.pop()); System.out.println(stack.pop()); System.out.println(stack.pop()); } }
Practice Problems for Java Synchronization
Apply your knowledge with these five real-world synchronization problems. Each one is designed to expose common misconceptions and production pitfalls.
---
### Problem 1: Thread-Safe Bank Account Transfer Task: Implement a BankAccount class with deposit(), withdraw(), and transferTo(BankAccount other, double amount) methods. Ensure that no lost updates or race conditions occur. Use synchronized or ReentrantLock.
Solution hint: Use lock ordering to avoid deadlock. Each account has a unique ID; always acquire locks in the same order (e.g., lower ID first).
---
### Problem 2: Thread-Safe Singleton (Double-Checked Locking) Task: Implement a lazily-initialized singleton that is thread-safe without creating a performance bottleneck on every call. The classic getInstance() method must work correctly with multiple threads.
Solution hint: Use volatile on the instance field and double-checked locking with a local variable for performance. Or use an enum singleton if the class can be package-private.
---
### Problem 3: Producer-Consumer with a Bounded Buffer Task: Implement a bounded buffer (circular queue) that supports put(item) and . Use take()ReentrantLock with two Condition objects (notFull, notEmpty). Prevent spurious wakeup issues by using while loops.
Solution hint: See the BoundedTicketQueue example in the earlier section — adapt it for generic items.
---
### Problem 4: Read-Write Lock on a Simple Cache Task: Build a thread-safe cache Map<String, String> that allows multiple concurrent reads but exclusive writes. Use ReadWriteLock. Ensure that writers do not starve under heavy read load (consider using fair mode).
Solution hint: Use ReentrantReadWriteLock with fairness set to true if write starvation is a concern. Alternatively, StampedLock with optimistic reads.
---
### Problem 5: Rate Limiter Using Semaphore Task: Create a rate limiter that allows at most 10 requests per second. Use Semaphore with a scheduled release of permits every second. The limiter must be thread-safe.
Solution hint: Initialize Semaphore with 10 permits. A scheduled executor re-fills 10 permits every second by calling semaphore.release(10) (but cap at max to avoid accumulation). Use tryAcquire() to fail fast if no permits available.
Synchronized Instance Methods: The Obvious But Costly Default
You reach for synchronized on a method when you don't want to think. Fine for prototypes. In production, it's a bottleneck waiting to happen. Instance methods lock on this — the entire object instance. That means any synchronized method on the same object blocks every other thread, even if they're touching completely unrelated data. You've just serialized all access to that object, which defeats the purpose of threads. Use instance method sync only when the method body is trivially short and the object naturally scopes the critical data. Otherwise, you're paying for a mutex on code that doesn't need it. The JVM's monitor will queue threads on the object's intrinsic lock, and if that method takes 200ms, your throughput collapses. Prefer synchronized blocks with a dedicated lock object that isolates only the critical section. This isn't theory — I've debugged a trading engine where synchronized on a PriceFeed method caused cascading timeouts across 12 microservices. The monitoring graph looked like a sawtooth.
// io.thecodeforge public class PriceFeed { private final Object priceLock = new Object(); private Map<String, Double> latest; // bad: whole method serialized public synchronized void updateBad(String ticker, double price) { // 50 lines of logic, only two touch shared Map } // good: only lock the critical assignment public void updateGood(String ticker, double price) { // non-shared validation here synchronized (priceLock) { latest.put(ticker, price); } } }
this — it serializes every caller. Use blocks with a private lock object to scope contention to only the shared data.Static Synchronization: When the Class Itself Is the Lock
Static synchronized methods lock on the Class object — PriceFeed.class — not an instance. That means every thread calling any static synchronized method on that class queues up, regardless of which instance they're using. This is useful for singleton resources like a registry of all active WebSocket connections, but it's also the fastest way to accidentally serialize your entire service if you scatter static sync methods across a utility class. The crime I see most: a CacheManager with static synchronized get/put methods. Every thread in every pod contends for that single lock, turning your shiny 16-core machine into a single-threaded queue. The fix: use ReentrantReadWriteLock for read-heavy caches, or better, a ConcurrentHashMap with atomic compute methods. Static sync is a blunt instrument — fine for one-off counters, deadly for performance-sensitive code. The JVM monitor for class-level locks resolves identity via the Class object's hash, so two unrelated classes with the same name in different classloaders won't interfere. Don't rely on that.
// io.thecodeforge public class CacheManager { private static final Map<String, Session> cache = new ConcurrentHashMap<>(); // anti-pattern: static sync queues ALL callers public static synchronized void putBad(String key, Session s) { cache.put(key, s); } // better: atomic ops, no class-level lock public static Session putGood(String key, Session s) { return cache.put(key, s); } // when you need compound ops, use computeIfAbsent public static Session computeIfAbsent(String key, Function<String, Session> f) { return cache.computeIfAbsent(key, f); } }
ConcurrentHashMap.computeIfAbsent() often eliminates the contention entirely while preserving atomicity for create-or-retrieve patterns.The $50,000 Lost Update: A Production Volatile Failure
- volatile does not make compound actions atomic.
- AtomicLong handles counters correctly with CAS.
- Always verify concurrency under production load — stress testing is non-negotiable for shared mutable state.
jstack <pid> | grep -A 30 "Found one Java-level deadlock"kill -3 <pid> (Linux) or jcmd <pid> Thread.printgrep -r "volatile.*int\|volatile.*long" src/Review increment sites: if it's not AtomicInteger/AtomicLong, change it.top -H -p <pid> to find hot threads, then jstack <pid> | grep -A 20 <thread-id>Look for atomic spin loops (while(true) with compareAndSet).grep -r "wait()\|await()" src/ and check contextJVM spec: spurious wakeups are allowed. Change if to while.await().Look for I/O, network calls, or Thread.sleep() inside synchronized blocks.Profile to see lock contention duration (jvisualvm or async-profiler).| Feature | synchronized | ReentrantLock | AtomicInteger etc. |
|---|---|---|---|
| Mutual exclusion | Yes | Yes | No (volatile-style visibility) |
| Atomicity for single operation | Yes (any code) | Yes (any code) | Yes (only specific operations like increment, CAS) |
| Compound action support | Yes (arbitrary code blocks) | Yes (arbitrary code blocks) | No (only pre-defined atomic ops) |
| Blocking vs spin | Block (can escalate to spin lock, then block) | Block (with tryLock option) | Spin (busy-wait until CAS succeeds) |
| Fairness | Barge (not fair) | Optional (fair mode) | Not applicable |
| Interruptible waiting | No | Yes (lockInterruptibly()) | No |
| Multiple conditions | One monitor per object | Multiple Condition objects | Not applicable |
| Performance under low contention | Very fast (biased lock) | Slightly slower (object overhead) | Very fast (CAS, no scheduler involvement) |
| Performance under high contention | Degrades to fat lock (OS mutex) | Degrades to OS mutex, but tryLock can back off | Degrades to heavy spinning, CPU intensive |
| Error-prone | Low (auto-release) | Medium (must unlock in finally) | Low (no locks to release) |
Key takeaways
System.identityHashCode() for a tie-breaking ordering key that works without business logic assumptions.Common mistakes to avoid
4 patternsSynchronizing on a non-final field
Object(); Avoid String literals or Integer as locks (they are interned and shared across the JVM).Calling wait() or await() outside a while loop
wait(); } pattern always. For ReentrantLock Conditions, use while(condition) condition.await();.Holding a lock while making network calls or doing I/O
Using volatile for a compound action like check-then-act
Interview Questions on This Topic
What is the difference between synchronized and volatile, and can you give a scenario where using volatile instead of synchronized would introduce a bug?
Explain how the JVM implements locking internally – what are biased locks, thin locks, and fat locks, and what triggers the transitions between them?
If two threads call synchronized methods on the same object, they contend on the same monitor. But what happens if one thread calls a synchronized method and another calls a non-synchronized method on the same object simultaneously – is there any protection?
What is the ABA problem in lock-free programming, and how does Java address it?
How do ReentrantLock's Condition objects differ from the traditional wait/notify mechanism?
await() is interruptible), support timed waits, and are not subject to the same spurious wakeup issues if used with while loops. The key advantage: you can have a 'notEmpty' condition and a 'notFull' condition on the same lock for a bounded queue, whereas with wait/notifyAll you wake up all threads and they check condition unnecessarily.Frequently Asked Questions
Yes — and this is often missed. The Java Memory Model specifies that entering a synchronized block causes a thread to re-read all variables from main memory, and exiting it flushes all writes. So synchronized provides both mutual exclusion and full memory visibility, whereas volatile provides only visibility without mutual exclusion.
Use ReentrantLock when you need any of: a timed tryLock to avoid indefinite blocking, the ability to interrupt a waiting thread via lockInterruptibly(), a fairness policy to prevent thread starvation, or multiple distinct Condition objects to signal different waiting thread groups independently. For simple critical sections, synchronized is cleaner and less error-prone.
No. Java's synchronized keyword is reentrant by design — if a thread already holds a lock and re-enters a synchronized block or method guarded by the same lock, it succeeds immediately. The JVM tracks a hold count and only releases the lock when the hold count reaches zero. This is why synchronized on recursive method calls doesn't deadlock.
A spurious wakeup is when a thread returns from wait() or await() without a corresponding notify/signal. The JVM allows this for implementation reasons. You must always use a while loop to recheck the condition after waking up. Using if leaves your code vulnerable to proceeding when the condition isn't met.
For a simple atomic increment or compare-and-set, use AtomicInteger — it's lock-free and fast under low contention. If your operation is a compound action (e.g., increment only if some other condition holds), use synchronized because AtomicInteger only supports single operations atomically. For very high contention (>50% thread failures on CAS), consider synchronized or stripping the counter into multiple slots.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
That's Multithreading. Mark it forged?
15 min read · try the examples if you haven't