[THREADING 101] 03 – Synchronization in Java

[

Why Synchronize?

Synchronization solves both race conditions and data races by providing two guarantees:

  1. Mutual exclusion — Only one thread can execute the protected section at a time.
  2. Memory visibility — Changes made by a thread inside a synchronized block are visible to the next thread that enters the same synchronized block.

The synchronized Keyword

Synchronized Methods

public class BankAccount {
    private double balance;

    public synchronized void deposit(double amount) {
        balance += amount;
    }

    public synchronized void withdraw(double amount) {
        balance -= amount;
    }

    public synchronized double getBalance() {
        return balance;
    }
}

When a method is marked synchronized, it acquires the lock on this (the instance) before executing. This means all synchronized methods on the same object share one lock — if Thread A is inside deposit(), Thread B cannot enter withdraw() on the same instance.

For static synchronized methods, the lock is on the Class object:

public static synchronized void globalOperation() {
    // Locks on BankAccount.class
}

Synchronized Blocks

Synchronized blocks give finer-grained control by letting you choose which object to lock on:

public class TransferService {
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();
    private double accountA = 1000;
    private double accountB = 1000;

    public void creditAccountA(double amount) {
        synchronized (lock1) {
            accountA += amount;
        }
    }

    public void creditAccountB(double amount) {
        synchronized (lock2) {
            accountB += amount;
        }
    }
}

Using separate lock objects means operations on accountA and accountB can execute in parallel — they don’t block each other.

Choosing What to Lock On

Lock TargetUse CaseRisk
thisSimple cases, small classesOther code can lock on your instance externally
private final ObjectEncapsulated lockingNone — recommended for libraries
ClassName.classStatic synchronizationCoarse, blocks all static synchronized methods

Always prefer private final Object lock = new Object() for lock objects:

  • private prevents external code from locking on it
  • final prevents accidental reassignment (which would change the lock identity)

Synchronization Approaches Comparison

Comparison Table

ApproachMutual ExclusionVisibilityTryableTimeoutFairInterruptibleCondition Support
volatile
synchronizedvia wait/notify
ReentrantLock✓ (Condition)
ReadWriteLock
StampedLock
Atomic classes✗ (lock-free)

Pros and Cons of synchronized

Pros

  • Simple syntax — built into the language, no import needed.
  • Automatic release — the lock is always released when the block exits, even on exceptions.
  • JVM optimizations — biased locking, lock coarsening, lock elision by HotSpot.
  • No boilerplate — no try/finally needed.

Cons

  • No timeout — a thread waiting for a synchronized lock waits forever.
  • Not interruptible — you cannot interrupt a thread waiting to enter a synchronized block.
  • No fairness — threads can starve; no FIFO guarantee.
  • No tryLock — you cannot attempt to acquire and fall back if unavailable.
  • Coarse granularity — method-level synchronization locks the entire object.
  • No read/write distinction — readers block other readers unnecessarily.

Inter-Thread Communication with wait/notify

Every Java object has a built-in wait set. Threads can wait for a condition and be notified when it changes:

public class BoundedBuffer<T> {
    private final Queue<T> queue = new LinkedList<>();
    private final int capacity;

    public BoundedBuffer(int capacity) {
        this.capacity = capacity;
    }

    public synchronized void put(T item) throws InterruptedException {
        while (queue.size() == capacity) {
            wait(); // Release lock and wait until notified
        }
        queue.add(item);
        notifyAll(); // Wake up consumers
    }

    public synchronized T take() throws InterruptedException {
        while (queue.isEmpty()) {
            wait(); // Release lock and wait until notified
        }
        T item = queue.poll();
        notifyAll(); // Wake up producers
        return item;
    }
}

Rules of wait/notify

  1. Must be called from within a synchronized block on the same object.
  2. Always use wait() inside a while loop — spurious wakeups can occur.
  3. Prefer notifyAll() over notify() unless you have exactly one waiter and one condition.
  4. wait() releases the lock atomically and reacquires it when woken up.

Why a while Loop?

// WRONG — if/then
synchronized (lock) {
    if (condition) {
        lock.wait(); // Might wake up spuriously — condition could still be true
    }
    // proceed — but condition might not actually hold!
}

// CORRECT — while loop
synchronized (lock) {
    while (condition) {
        lock.wait(); // Re-check after every wakeup
    }
    // proceed — condition is guaranteed to be false
}

Deadlocks

A deadlock occurs when two or more threads each hold a lock and wait for a lock held by another, creating a circular dependency.

Four Conditions for Deadlock

All four must be true simultaneously for a deadlock to exist:

  1. Mutual exclusion — The resource can only be held by one thread.
  2. Hold and wait — A thread holds one resource while waiting for another.
  3. No preemption — Resources cannot be forcibly taken from a thread.
  4. Circular wait — A cycle of threads, each waiting for the next.

Deadlock Example

public class DeadlockDemo {
    private final Object lockA = new Object();
    private final Object lockB = new Object();

    public void method1() {
        synchronized (lockA) {           // Acquires A
            synchronized (lockB) {       // Waits for B
                // work
            }
        }
    }

    public void method2() {
        synchronized (lockB) {           // Acquires B
            synchronized (lockA) {       // Waits for A — DEADLOCK!
                // work
            }
        }
    }
}

Prevention Strategies

StrategyHowTrade-off
Lock orderingAlways acquire locks in the same global orderRequires discipline; hard to enforce in large codebases
Try-lock with timeoutUse ReentrantLock.tryLock(timeout)Adds complexity; need fallback logic
Single lockProtect everything with one lockReduces parallelism
Lock-free algorithmsAvoid locks entirely using CASComplex to implement correctly
Avoid nested locksRestructure code to acquire only one lockNot always possible

Rule of Thumb

Avoid unsorted locking. If you must acquire multiple locks, always acquire them in a consistent, globally defined order (e.g., by object hash code or ID):

public void transfer(Account from, Account to, double amount) {
    // Always lock the account with the smaller ID first
    Account first = from.getId() < to.getId() ? from : to;
    Account second = from.getId() < to.getId() ? to : from;

    synchronized (first) {
        synchronized (second) {
            from.withdraw(amount);
            to.deposit(amount);
        }
    }
}

Summary

  • synchronized is the simplest synchronization tool — automatic lock release, JVM-optimized.
  • Use dedicated private final Object lock objects for fine-grained control.
  • wait/notify enable inter-thread communication but must always be used inside while loops.
  • Deadlocks require all four conditions simultaneously — break any one to prevent them.
  • When synchronized isn’t flexible enough (timeout, tryLock, fairness), reach for ReentrantLock.

References

About the author

Maksim

I build AI-powered products and lead engineering teams. I've launched platforms from zero to millions of users and learned most lessons the hard way. I write about the gap between engineering theory and practice, what actually matters when building products, and the decisions that shape teams and systems.

Add Comment

By Maksim

Maksim

Get in touch

Reach out if you want to discuss engineering leadership, collaborate on something interesting, or suggest topics you'd like me to write about.