JavaMultithreadingadvanced
Updated:

Synchronization in Java: synchronized, Locks and volatile

6 min read

Why two threads corrupt a shared counter, and how synchronized, ReentrantLock, volatile and atomic classes each fix a different part of the problem.

TL;DR – Quick Answer

Synchronization in Java is how you make shared mutable state safe when multiple threads access it. The synchronized keyword gives one thread exclusive access to a block or method via a monitor lock, ReentrantLock adds advanced control like tryLock and fairness, volatile guarantees visibility of a variable across threads, and atomic classes provide lock-free single-variable operations.

On This Page

Create two threads, have each increment a shared counter 100,000 times, and check the total. It should be 200,000. It almost never is. That missing count is the entire reason synchronization exists, and explaining why it goes missing — then choosing the right fix among synchronized, ReentrantLock, volatile and atomics — is the most reliable way to demonstrate real concurrency understanding in a senior Java interview.

The race condition, reproduced

Before fixing the problem, watch it happen. This program is deliberately broken:

public class RaceCondition {
    private static int counter = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable work = () -> {
            for (int i = 0; i < 100_000; i++) {
                counter++; // looks atomic, is not
            }
        };
        Thread t1 = new Thread(work);
        Thread t2 = new Thread(work);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Expected 200000, got " + counter);
    }
}

Run it five times and you will likely get five different results, all short of 200,000. The culprit: counter++ compiles to three separate steps — read the value, add one, write it back. When both threads read 41500 at the same moment, both write 41501, and one increment evaporates. This interleaving is called a race condition, and no amount of testing on your laptop proves its absence — it just proves you got lucky in those runs.

synchronized methods: one thread at a time

The synchronized keyword makes a thread acquire an object's monitor lock before entering the guarded code. While one thread holds the lock, every other thread trying to enter code guarded by the same lock goes to the BLOCKED state — described in detail in the thread lifecycle — until the lock frees.

public class SafeCounter {
    private int count = 0;

    public synchronized void increment() {
        count++; // now effectively atomic: lock, read-add-write, unlock
    }

    public synchronized int current() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        SafeCounter counter = new SafeCounter();
        Runnable work = () -> {
            for (int i = 0; i < 100_000; i++) {
                counter.increment();
            }
        };
        Thread t1 = new Thread(work);
        Thread t2 = new Thread(work);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Count: " + counter.current()); // always 200000
    }
}

Now the total is exactly 200,000 on every run. synchronized delivers two guarantees at once: mutual exclusion (one thread in the critical section) and visibility (writes made under the lock are visible to the next thread that acquires it — a happens-before edge in the Java Memory Model). Interviewers love asking for both; most candidates remember only the first.

An instance method locks on this; a static synchronized method locks on the Class object. Those are different locks — a common source of bugs when static and instance state mix.

synchronized blocks: shrink the critical section

Locking an entire method is often too coarse. A synchronized block lets you guard only the lines that touch shared state, and lets you choose a private lock object so external code cannot interfere with your locking:

import java.util.ArrayList;
import java.util.List;

public class AuditLog {
    private final Object lock = new Object();
    private final List<String> entries = new ArrayList<>();

    public void record(String event) {
        String stamped = System.currentTimeMillis() + " " + event; // no lock needed
        synchronized (lock) {
            entries.add(stamped); // only the shared mutation is guarded
        }
    }

    public int size() {
        synchronized (lock) {
            return entries.size();
        }
    }
}

The timestamp formatting happens outside the lock — other threads are not forced to wait for work that touches no shared state. Keeping critical sections short is the single most effective synchronization optimization available to you.

Common mistake: A common mistake beginners make is synchronizing on a mutable or shared value like a String literal or Integer — literals are interned, so unrelated classes may accidentally share your lock (see how the String pool works). Always lock on a private final Object you construct yourself.

volatile: visibility without locking

Without synchronization, the JVM is allowed to cache variables in CPU registers or per-core caches — so one thread's write may simply never be seen by another. That is not a race over who writes first; it is a visibility failure. The classic symptom is a stop flag that never stops anything:

public class StoppableWorker {
    private volatile boolean running = true;

    public void stop() {
        running = false; // guaranteed visible to the worker thread
    }

    public void workLoop() {
        long iterations = 0;
        while (running) {
            iterations++; // without volatile, this loop may never exit
        }
        System.out.println("Stopped after " + iterations + " iterations");
    }

    public static void main(String[] args) throws InterruptedException {
        StoppableWorker worker = new StoppableWorker();
        Thread t = new Thread(worker::workLoop);
        t.start();
        Thread.sleep(100);
        worker.stop();
        t.join();
    }
}

Remove volatile and, under a HotSpot server JIT, this loop can genuinely spin forever — the compiler may hoist the running read out of the loop. With volatile, every read comes from main memory and every write is published immediately.

What volatile does not give you is atomicity: volatile int count still loses updates under count++, because read-add-write remains three steps. The rule of thumb: volatile is for one-writer flags and safely publishing references; anything read-modify-write needs a lock or an atomic class.

ReentrantLock: synchronized with superpowers

ReentrantLock (from java.util.concurrent.locks) does everything synchronized does, plus the tricks synchronized cannot: attempt a lock without blocking, give up after a timeout, respond to interruption while waiting, and hand out multiple Condition queues.

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class InventoryService {
    private final ReentrantLock lock = new ReentrantLock();
    private int stock = 10;

    public boolean reserve(int qty) {
        boolean acquired = false;
        try {
            acquired = lock.tryLock(200, TimeUnit.MILLISECONDS);
            if (!acquired) {
                return false; // do not hang the request thread — fail fast
            }
            if (stock >= qty) {
                stock -= qty;
                return true;
            }
            return false;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        } finally {
            if (acquired) {
                lock.unlock();
            }
        }
    }
}

The pattern to internalize: lock() (or a successful tryLock) is always paired with unlock() in a finally block. Unlike synchronized, which releases its monitor automatically even when exceptions fly, a forgotten unlock() leaves the lock held forever. tryLock with a timeout is also a practical deadlock defense — a thread that cannot get the second lock backs off instead of waiting forever.

Interview note: "ReentrantLock vs synchronized" is a standard question at the 3-years-experience level. The strong answer: prefer synchronized for simplicity and automatic release; switch to ReentrantLock only when you need tryLock, timed or interruptible acquisition, fairness, or multiple conditions.

Atomic classes: lock-free single variables

For the counter problem specifically, locking is heavier than necessary. AtomicInteger uses the CPU's compare-and-swap (CAS) instruction — an optimistic, lock-free update that retries on conflict instead of blocking:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private static final AtomicInteger counter = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable work = () -> {
            for (int i = 0; i < 100_000; i++) {
                counter.incrementAndGet(); // atomic read-add-write via CAS
            }
        };
        Thread t1 = new Thread(work);
        Thread t2 = new Thread(work);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("Count: " + counter.get()); // always 200000
    }
}

No thread ever blocks, no thread ever enters BLOCKED — a losing thread just retries its CAS. The same idea scales up: AtomicLong, AtomicReference, and LongAdder for extremely hot counters. The whole java.util.concurrent package is built on these primitives, including ConcurrentHashMap — compared against plain HashMap in HashMap vs ConcurrentHashMap.

Choosing the right tool

Situation Reach for
Boolean flag, one writer volatile
Single counter or reference AtomicInteger / AtomicReference
Multi-step invariant (check-then-act) synchronized block
Need tryLock, timeout, fairness, conditions ReentrantLock
Shared map/list under concurrency Concurrent collections
State that never changes Immutability — no synchronization at all

Pro tip: The best synchronization is the kind you delete. Before reaching for any lock, ask whether the state can be immutable, thread-confined (each task owns its own data — the default when you structure work as independent tasks), or replaced by a java.util.concurrent utility. Manual locking should be the last resort, not the first instinct — every lock you write is a deadlock you might debug later.

Synchronization questions escalate fast: from "what does synchronized do" to "why is volatile not enough for count++" to "design a thread-safe rate limiter". If you can reproduce the race condition from the first example on demand, name both guarantees of synchronized, and justify each row of the table above, you are operating at the level these interviews target.

Frequently Asked Questions

What does the synchronized keyword do in Java?
It makes a thread acquire the monitor lock of an object before executing a block or method, so only one thread can run any code guarded by that same lock at a time. It also creates a happens-before relationship, meaning changes made inside the block become visible to the next thread that acquires the lock.
What is the difference between synchronized and volatile?
synchronized provides both mutual exclusion and visibility — one thread at a time, and its writes are published to others. volatile provides only visibility: reads always see the latest write, but compound operations like count++ are still unsafe. Use volatile for simple flags, synchronized or atomics for read-modify-write logic.
When should I use ReentrantLock instead of synchronized?
Reach for ReentrantLock when you need something synchronized cannot do: a non-blocking tryLock, a timed lock attempt, an interruptible wait for the lock, fairness ordering, or multiple Condition objects on one lock. For plain mutual exclusion, synchronized is simpler and equally fast on modern JVMs.
Is AtomicInteger faster than synchronized?
For a single hot variable under moderate contention, usually yes — AtomicInteger uses a hardware compare-and-swap instruction with no lock, no blocking and no thread state change. Under very heavy contention the CAS retry loop can suffer, which is why LongAdder exists for high-throughput counters.
What does reentrant mean in ReentrantLock?
A reentrant lock can be acquired again by the thread that already holds it, incrementing a hold count instead of deadlocking against itself. Both synchronized monitors and ReentrantLock behave this way, which is what allows one synchronized method to safely call another on the same object.
Why is String a good candidate for sharing between threads?
Because String is immutable — its state can never change after construction, so there is nothing to synchronize. Sharing immutable objects is the cheapest thread-safety strategy in Java, which is why the first design question should always be whether the state needs to be mutable at all.

Want to Build Your Career in Java Full Stack with AI?

Join CodeBegun and train with working industry engineers — Explore the Program

Apply for Demo Class →
Siva Prasad Galaba
Founder, CodeBegun · Staff Engineer

Founder of CodeBegun. 15+ years building Java systems at companies like Crunchyroll. Teaches Java, Spring Boot and system design the way the industry actually works, and mentors students through projects, mock interviews and placement preparation.

Technically reviewed by CodeBegun Technical TeamLast reviewed 14 July 2026 LinkedIn
Chat with us