beginner~2h

Synchronization & Intrinsic Locks

Nearly every threading bug traces back to one root cause: two threads touching the same mutable data at once, with no rule about who goes first. The synchronized keyword is Java's original built-in fix. This topic makes the bug happen on purpose, then builds the full toolkit for preventing it: method locks, block locks, class-level locks, reentrancy, and the visibility guarantee that comes bundled in for free.

Learning objectives

  • Reproduce a classic race condition and explain why read-modify-write operations aren't atomic
  • Fix a race condition with synchronized methods, synchronized blocks, and static synchronized methods
  • Explain why synchronized locks are reentrant and why that matters for method delegation
  • Explain the visibility guarantee synchronized provides beyond mutual exclusion
  • Choose correctly between volatile, synchronized, and AtomicInteger for a given piece of shared state

◆ The problem

Picture two bank tellers serving the same account at the exact same instant. Both look up the balance — $1,000 — before either has recorded a change. Teller A adds a $100 deposit and writes $1,100. Teller B, still working off the $1,000 they looked up a moment earlier, adds their own $200 deposit and writes $1,200. One of those two deposits just vanished — not because either teller made a mistake, but because they both acted on a number that was already out of date by the time they wrote it back.

This is a race condition, and it's exactly what happens in code when something like balance += amount looks like one atomic step but is actually three: read the current value, add to it, write the result back. If a second thread runs one of those three steps in between another thread's steps, one of the writes gets silently overwritten — a lost update. Race conditions are the single most common real-world concurrency bug, and also the hardest to catch: the code compiles fine, looks correct on inspection, and often "seems to work" in casual manual testing, because races are timing-dependent. They might not manifest on a quiet laptop or in a short test run, then corrupt data silently under real production load.

synchronized is Java's original, built-in answer: a way to say "only one thread may run this piece of code at a time." This topic builds the fix up in layers — locking a whole method, locking just the critical few lines, locking at the level of a class instead of an instance — and along the way uncovers a second job synchronized quietly does that's easy to overlook entirely.

The most direct fix for a race condition is marking the method synchronized. Before a thread can enter a synchronized instance method, it must acquire the object's intrinsic lock — also called its monitor — associated with this, the specific object the method was called on. Only one thread can hold that lock at a time, so only one thread can be inside any synchronized method of that particular object at once; every other thread queues up in the BLOCKED state until the lock is released. Two different instances of the same class have two entirely independent locks — calls through account1 and calls through account2 can run concurrently, because they synchronize on different objects.

A subtlety that catches people out: reads need synchronizing too, not just writes. A getBalance() method that only reads balance and never modifies it still needs to be synchronized, because without acquiring the same monitor deposit()/withdraw() use, a reading thread has no happens-before relationship to those writes, and per the Java Memory Model it could observe a stale, cached value even though a write already happened. Protecting every write to shared state but forgetting to protect the reads is a subtle, common mistake — it doesn't corrupt the underlying data, but it can hand a caller an outdated answer.

The code below reproduces the race condition on purpose (an unsynchronized deposit() losing updates under concurrent load), then fixes it by marking every method that touches balance — including the read — synchronized.

💻 Code example

package concurrency.synchronization; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; /** * BrokenAccount reproduces a classic lost-update race condition. * SafeAccount fixes it by synchronizing every method that touches balance, * including the read. */ public class RaceConditionAndSynchronizedFix { static class BrokenAccount { private int balance = 1000; // Deliberately unsynchronized: balance += amount is read, add, // write -- three separate steps with no atomicity guarantee. public void deposit(int amount) { balance += amount; } public int getBalance() { return balance; } } static class SafeAccount { private int balance = 1000; public synchronized void deposit(int amount) { balance += amount; } public synchronized void withdraw(int amount) { if (balance >= amount) balance -= amount; else throw new IllegalStateException("Insufficient funds"); } // Synchronized even though it only reads: without the same lock, // this thread has no happens-before edge to deposit()/withdraw(). public synchronized int getBalance() { return balance; } } public static void main(String[] args) throws Exception { BrokenAccount broken = new BrokenAccount(); ExecutorService exec1 = Executors.newFixedThreadPool(10); for (int i = 0; i < 10_000; i++) { exec1.submit(() -> broken.deposit(1)); } exec1.shutdown(); exec1.awaitTermination(5, TimeUnit.SECONDS); // Mathematically 1000 + 10,000 = 11,000, but this is almost always // less, and a different amount less on every run. System.out.println("Broken final balance (expected 11000): " + broken.getBalance()); SafeAccount safe = new SafeAccount(); ExecutorService exec2 = Executors.newFixedThreadPool(10); for (int i = 0; i < 10_000; i++) { exec2.submit(() -> safe.deposit(1)); } exec2.shutdown(); exec2.awaitTermination(5, TimeUnit.SECONDS); // Deterministically 11000, every single run. System.out.println("Safe final balance (expected 11000): " + safe.getBalance()); } }

Synchronizing an entire method locks everything inside it, including code that never touches shared state at all — input validation, an external service call. That serializes work that never needed to be serialized, throwing away concurrency for no correctness benefit. A synchronized block locks only the specific lines that actually read or write shared data, letting everything else run freely. It's also best practice to lock on a dedicated private final Object lock = new Object(); instead of this — locking on this risks contention (or deliberate interference) from unrelated external code that also happens to synchronize on your object. Separate, independent lock objects for logically independent pieces of state — one lock for inventory, another for payments — is the basis of "lock striping," which is exactly how high-throughput concurrent data structures achieve their performance.

Static fields belong to the class, not to any instance, so an instance-level lock is useless for protecting them — different threads might go through different instances (or none at all), each acquiring a different, unrelated monitor while the same static field races underneath. static synchronized locks on the unique Class object itself — there is exactly one IdGenerator.class object for the entire JVM, so every caller, no matter which instance they came through, funnels through that same single monitor. A common interview trap is mixing static and instance synchronization on related state and assuming any synchronized keyword automatically protects everything it touches — it doesn't. The lock has to match the scope of the data it protects.

💻 Code example

package concurrency.synchronization; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; /** * A synchronized block locking only the critical section with a private * lock object, plus a static synchronized method locking the Class object. */ public class FineGrainedAndClassLevelLocking { static class OrderProcessor { private int orderCount = 0; private double totalRevenue = 0.0; // Dedicated lock objects: unreachable from outside this class, // and independent of each other (lock striping). private final Object revenueLock = new Object(); private final Map<String, Integer> inventory = new ConcurrentHashMap<>(); private final List<String> paymentLog = new CopyOnWriteArrayList<>(); void processOrder(double price) { // Everything outside the block can run fully concurrently. synchronized (revenueLock) { orderCount++; totalRevenue += price; } } int getOrderCount() { synchronized (revenueLock) { return orderCount; } } } static class IdGenerator { private static int nextId = 0; // Locks IdGenerator.class -- the single monitor shared by every // caller, regardless of which (or whether any) instance they used. public static synchronized int getNextId() { return nextId++; } } public static void main(String[] args) throws InterruptedException { OrderProcessor processor = new OrderProcessor(); Thread[] workers = new Thread[10]; for (int i = 0; i < 10; i++) { workers[i] = new Thread(() -> { for (int j = 0; j < 100; j++) processor.processOrder(9.99); }); workers[i].start(); } for (Thread t : workers) t.join(); System.out.println("Orders processed: " + processor.getOrderCount()); // 1000, every run for (int i = 0; i < 5; i++) { System.out.println("Generated ID: " + IdGenerator.getNextId()); } } }

▲ Common mistake

Synchronizing on a non-final reference, or on a shared, interned object like a String literal or a cached boxed Integer/Boolean. If the lock variable can be reassigned, different threads can end up synchronizing on entirely different objects, providing zero real mutual exclusion. String literals are interned by the JVM, so synchronized("lock") anywhere in the process shares the exact same monitor as any other unrelated code that also happens to synchronize on the literal "lock" — an accidental, invisible coupling. The fix is always the same: use a dedicated private final Object lock = new Object();.

▲ Common mistake

Using synchronized for read-only operations that don't actually need mutual exclusion between readers — only between a reader and a writer. This unnecessarily serializes reads that could otherwise run concurrently. A ReadWriteLock (covered in a later topic) lets multiple readers proceed together while still excluding writers.

▲ Edge case

Forgetting to synchronize on all access paths to a piece of shared state. One unsynchronized read, sitting alongside otherwise-correct synchronized writes, can still observe stale data — because that one read has no happens-before edge to the synchronized writes at all. Every write and every read of shared mutable state needs to go through the same lock; a single gap breaks the whole guarantee.

▲ Edge case

The visibility guarantee synchronized provides only holds between threads using the same lock object. If deposit() synchronizes on this but getBalance() synchronizes on some unrelated Object, there is no happens-before relationship between them at all — two different monitors provide two completely independent guarantees. "Everything is synchronized, so it must be safe" is a dangerous instinct; the lock objects have to actually match.

A question worth sitting with before reading the answer: if methodA() is synchronized and, while still holding its lock, calls methodB() — also synchronized on the same object — does the calling thread deadlock waiting for a lock it already holds? It does not, because Java's intrinsic locks are reentrant by deliberate design. The JVM's monitor tracks a per-thread hold count, not just "locked or unlocked": entering a synchronized region a thread already owns just increments the count, exiting decrements it, and the lock is only actually released to other threads once the count returns to zero. Without reentrancy, any synchronized method calling another synchronized method on the same object — directly, through several layers of delegation, or via recursion — would deadlock the calling thread against itself.

volatile and synchronized solve two genuinely different problems that look similar from a distance. volatile guarantees visibility alone: a write becomes visible to other threads promptly, and reads/writes around it aren't reordered. synchronized guarantees visibility and atomicity and mutual exclusion. Reaching for volatile when the actual need is atomicity is one of the most common concurrency bugs in Java — it compiles, looks "thread-safe" because the field is marked volatile, and then silently loses updates under real contention, exactly like the unsynchronized balance += amount from earlier in this topic. volatile is right for simple flags and single-variable status fields; synchronized (or AtomicInteger for a lock-free counter) is right for compound read-modify-write operations.

The one place both work together is double-checked locking for lazy singleton initialization: the check runs once without the lock (fast, for the common already-initialized case) and again inside the lock (to catch the race where two threads both passed the first check before either created the instance). synchronized makes the check-then-create sequence atomic; volatile on the field stops another thread from observing a reference to a half-constructed object, since without it, instruction reordering could let a thread see a non-null reference whose constructor hasn't finished publishing all of its fields yet.

💻 Code example

package concurrency.synchronization; import java.util.concurrent.atomic.AtomicInteger; /** * Reentrancy: methodA() calls methodB(), both synchronized on `this` -- * no deadlock. Plus double-checked-locking Singleton, the one place * volatile and synchronized must work together. */ public class ReentrancyAndDoubleCheckedLocking { public synchronized void methodA() { System.out.println("methodA acquired the lock, calling methodB..."); methodB(); // re-enters the SAME lock -- hold count goes to 2, not a deadlock } public synchronized void methodB() { System.out.println("methodB running on the same lock, hold count 2."); } static class Singleton { // volatile prevents another thread from observing a reference to // a half-constructed instance due to instruction reordering. private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { // 1st check, no lock: fast path synchronized (Singleton.class) { if (instance == null) { // 2nd check, with lock: closes the race instance = new Singleton(); } } } return instance; } } public static void main(String[] args) { new ReentrancyAndDoubleCheckedLocking().methodA(); Singleton s1 = Singleton.getInstance(); Singleton s2 = Singleton.getInstance(); System.out.println("Same instance: " + (s1 == s2)); // AtomicInteger: lock-free alternative to synchronized for simple counters. AtomicInteger counter = new AtomicInteger(0); counter.incrementAndGet(); System.out.println("Atomic counter: " + counter.get()); } }

synchronized and intrinsic locks are the foundation ConcurrentHashMap, Collections.synchronizedList, and countless in-house thread-safe wrapper classes are built on. Lock striping — the technique of splitting one coarse lock into several independent, fine-grained locks so unrelated operations never wait on each other — is literally how ConcurrentHashMap achieves high throughput internally (historically via segment locks, and in modern versions via per-bucket locking), rather than protecting the entire map with a single monitor.

Double-checked locking with a volatile field is the standard, correct pattern for lazy, thread-safe singleton initialization across a huge fraction of real Java codebases — dependency injection frameworks, connection pool managers, and configuration loaders all use some variant of it when a shared resource must be created exactly once, on demand, the first time any thread needs it.

Class-level (static synchronized) locking shows up wherever a JVM-wide shared resource — a global ID generator, a process-wide cache registry, a singleton connection manager — needs protecting independent of any particular instance. Getting this wrong (using instance-level synchronization on state that's actually static) is a genuinely common interview trap precisely because it also happens in real code: a refactor that moves a field from instance to static without also moving its locking strategy silently reintroduces a race condition that passed all existing tests, because those tests never exercised more than one instance concurrently.

Q: Why is balance += amount not atomic, and what does that have to do with race conditions?

A: It's actually three separate steps -- read the current value, add to it, write the result back. If another thread's steps interleave between those three steps, one thread's write can silently overwrite another's, losing an update. This read-modify-write pattern is the root cause of the classic lost-update race condition.

Q: Why does getBalance() need to be synchronized even though it never modifies balance?

A: Without acquiring the same monitor the writing methods use, a reading thread has no happens-before relationship to those writes, and the Java Memory Model makes no promise it will see the latest value -- it could observe a stale, cached read even though a write already happened.

Q: Why use a synchronized block with a private lock object instead of synchronizing the whole method on this?

A: A synchronized method locks the entire method, including code that doesn't touch shared state, serializing work unnecessarily. A block locks only the critical section. A private final Object lock also prevents external code that happens to synchronize on the same instance from causing unrelated contention or interference.

Q: Why doesn't a synchronized method calling another synchronized method on the same object deadlock?

A: Java's intrinsic locks are reentrant -- the JVM tracks a per-thread hold count. Re-entering a lock the calling thread already owns just increments the count; the lock is only released to other threads once the count returns to zero.

Q: When should you reach for volatile versus synchronized versus AtomicInteger?

A: volatile guarantees visibility only, for a single variable (like a status flag) -- it does not make compound operations atomic. synchronized guarantees visibility, atomicity, and mutual exclusion for a whole critical section. AtomicInteger gives lock-free atomicity for simple counters. Double-checked-locking singletons need both volatile and synchronized together.

Want a visual for this concept?

Generate a diagram tailored to “Synchronization & Intrinsic Locks” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to wait(), notify() & notifyAll()← Back to all Java Concurrency & Multithreading chapters