00:00

Synchronization in Java – Locks, Monitors and Thread Safety

Last updated: 8 min read

Synchronization controls how multiple threads access shared data, so that only one thread at a time can execute a critical section. Without it, threads interleave their reads and writes and corrupt data in hard-to-reproduce ways. Java's built-in tool is the synchronized keyword, backed by a lock (a monitor) that every object has.

This tutorial covers synchronized methods and blocks, static synchronization, thread coordination with wait() and notifyAll(), explicit ReentrantLocks, deadlocks, and lighter alternatives.

1. Why Synchronization Is Needed

Synchronization provides two guarantees:

  • Mutual exclusion (atomicity): only one thread runs a synchronized section on a given lock at a time, so multi-step operations are not interleaved.
  • Visibility: changes made by one thread before releasing a lock are visible to the next thread that acquires it (a happens-before relationship).

2. Synchronized Methods

A synchronized instance method locks on this. Three threads deposit 100,000 times each:

SynchronizedMethod.java
public class SynchronizedMethod {
    static class BankAccount {
        private int balance = 0;

        // Only one thread at a time can run a synchronized method on the same object
        synchronized void deposit(int amount) { balance += amount; }
        synchronized int getBalance() { return balance; }
    }

    static class UnsafeAccount {
        private int balance = 0;
        void deposit(int amount) { balance += amount; }
        int getBalance() { return balance; }
    }

    public static void main(String[] args) throws InterruptedException {
        BankAccount safe = new BankAccount();
        UnsafeAccount unsafe = new UnsafeAccount();
        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) { safe.deposit(1); unsafe.deposit(1); }
        };
        Thread t1 = new Thread(task), t2 = new Thread(task), t3 = new Thread(task);
        t1.start(); t2.start(); t3.start();
        t1.join();  t2.join();  t3.join();
        System.out.println("Expected:     300000");
        System.out.println("synchronized: " + safe.getBalance());
        System.out.println("unsafe:       " + unsafe.getBalance() + "  (usually less)");
    }
}
Output (sample run; the unsafe balance varies)
Expected:     300000
synchronized: 300000
unsafe:       298784  (usually less)

Note that getBalance() is synchronized too: reads also need the lock (or volatile) to be guaranteed to see the latest value.

3. Synchronized Blocks

A synchronized block locks only the code that needs it and lets you choose the lock object. Using a private final lock object prevents outside code from interfering with your locking:

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

public class SynchronizedBlock {
    static class Inventory {
        private final Object lock = new Object();         // private lock object
        private final List<String> log = new ArrayList<>();
        private int stock = 5;

        boolean buy(String customer) {
            String request = customer + " requests 1 item";  // no lock needed here
            synchronized (lock) {                           // only the critical section is locked
                log.add(request);
                if (stock > 0) {                            // check-then-act must be atomic
                    stock--;
                    log.add(customer + " bought; stock=" + stock);
                    return true;
                }
                log.add(customer + " out of stock");
                return false;
            }
        }
        int stock() { synchronized (lock) { return stock; } }
    }

    public static void main(String[] args) throws InterruptedException {
        Inventory inv = new Inventory();
        List<Thread> buyers = new ArrayList<>();
        for (int i = 1; i <= 8; i++) {
            String name = "customer-" + i;
            buyers.add(new Thread(() -> inv.buy(name)));
        }
        buyers.forEach(Thread::start);
        for (Thread t : buyers) t.join();
        System.out.println("Final stock: " + inv.stock() + " (never negative, exactly 5 sold)");
    }
}
Output
Final stock: 0 (never negative, exactly 5 sold)

Checking the stock and decrementing it form a check-then-act sequence. If it were not atomic, two customers could both see stock == 1 and both buy the last item.

4. Static Synchronization

A static synchronized method locks on the Class object (IdGenerator.class), protecting static data shared by all instances:

StaticSync.java
public class StaticSync {
    static class IdGenerator {
        private static long next = 1000;
        // static synchronized: locks on IdGenerator.class, shared by all callers
        static synchronized long nextId() { return next++; }
    }

    public static void main(String[] args) throws InterruptedException {
        java.util.Set<Long> ids = java.util.concurrent.ConcurrentHashMap.newKeySet();
        Runnable task = () -> { for (int i = 0; i < 10_000; i++) ids.add(IdGenerator.nextId()); };
        Thread a = new Thread(task), b = new Thread(task);
        a.start(); b.start(); a.join(); b.join();
        System.out.println("Unique IDs generated: " + ids.size() + " (no duplicates)");
    }
}
Output
Unique IDs generated: 20000 (no duplicates)
FormLock used
synchronized void m()this
static synchronized void m()ClassName.class
synchronized (obj) { ... }obj

Instance and static synchronized methods use different locks, so they do not block each other.

5. Thread Coordination: wait() and notifyAll()

Inside a synchronized section, wait() releases the lock and pauses until another thread calls notify() or notifyAll() on the same object. This classic producer-consumer buffer holds at most two items:

WaitNotify.java
import java.util.LinkedList;
import java.util.Queue;

public class WaitNotify {
    static class Buffer {
        private final Queue<Integer> items = new LinkedList<>();
        private final int capacity = 2;

        synchronized void put(int item) throws InterruptedException {
            while (items.size() == capacity) wait();     // always wait in a loop
            items.add(item);
            System.out.println("produced " + item + "  buffer=" + items);
            notifyAll();
        }

        synchronized int take() throws InterruptedException {
            while (items.isEmpty()) wait();
            int item = items.remove();
            notifyAll();
            return item;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Buffer buffer = new Buffer();
        Thread producer = new Thread(() -> {
            try { for (int i = 1; i <= 5; i++) buffer.put(i); } catch (InterruptedException e) { }
        });
        Thread consumer = new Thread(() -> {
            try {
                int sum = 0;
                for (int i = 1; i <= 5; i++) sum += buffer.take();
                System.out.println("consumer total = " + sum);
            } catch (InterruptedException e) { }
        });
        producer.start(); consumer.start();
        producer.join();  consumer.join();
    }
}
Output (one possible run; the interleaving varies)
produced 1  buffer=[1]
produced 2  buffer=[1, 2]
produced 3  buffer=[3]
produced 4  buffer=[3, 4]
produced 5  buffer=[5]
consumer total = 15
  • Call wait/notify only while holding the object's lock, or you get IllegalMonitorStateException.
  • Always wait in a while loop that rechecks the condition, because of spurious wake-ups and competing threads.
  • In real code, prefer BlockingQueue (such as ArrayBlockingQueue), which implements this pattern for you.

6. ReentrantLock and Deadlocks

A deadlock happens when two threads each hold a lock the other needs, as in two transfers that lock accounts in opposite order. java.util.concurrent.locks.ReentrantLock offers features synchronized lacks, such as tryLock with a timeout, which lets threads back off instead of waiting forever:

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

public class LockAndDeadlock {
    static final ReentrantLock accountA = new ReentrantLock();
    static final ReentrantLock accountB = new ReentrantLock();

    // tryLock with a timeout avoids waiting forever (no deadlock)
    static boolean transfer(ReentrantLock from, ReentrantLock to, String name) throws InterruptedException {
        if (from.tryLock(100, TimeUnit.MILLISECONDS)) {
            try {
                Thread.sleep(50);                                  // widen the deadlock window
                if (to.tryLock(100, TimeUnit.MILLISECONDS)) {
                    try { return true; } finally { to.unlock(); }
                }
                return false;
            } finally {
                from.unlock();                                     // always unlock in finally
            }
        }
        return false;
    }

    public static void main(String[] args) throws InterruptedException {
        // Opposite lock order is the classic deadlock recipe; tryLock lets both threads back off
        Thread t1 = new Thread(() -> retry(accountA, accountB, "A->B"));
        Thread t2 = new Thread(() -> retry(accountB, accountA, "B->A"));
        t1.start(); t2.start();
        t1.join();  t2.join();
        System.out.println("Both transfers completed, no deadlock");
    }

    static void retry(ReentrantLock from, ReentrantLock to, String name) {
        try {
            while (!transfer(from, to, name)) Thread.sleep((long) (Math.random() * 50));
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
Output
Both transfers completed, no deadlock
  • Always call unlock() in a finally block.
  • ReentrantLock also supports fairness, interruptible locking and multiple Conditions; ReadWriteLock and StampedLock allow concurrent readers.
  • The simplest deadlock prevention is to always acquire locks in the same global order.

7. Reentrancy

Java locks are reentrant: a thread that holds a lock can acquire it again. A synchronized method can therefore call another synchronized method on the same object without deadlocking itself.

8. Alternatives to Locking

NeedUse
Counter or single variable updated atomicallyAtomicInteger, AtomicLong, LongAdder
Simple flag visible to all threadsvolatile
Thread-safe maps, lists and queuesConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue
Data that never changesImmutable objects and records
Per-thread dataThreadLocal or local variables

9. Best Practices

  • Keep synchronized sections small; never perform slow I/O while holding a lock if you can avoid it.
  • Synchronize every access (reads and writes) to shared mutable state with the same lock.
  • Prefer private lock objects over synchronized(this) in public classes.
  • Do not lock on strings, boxed numbers or other shared/interned objects.
  • Prefer higher-level concurrency utilities over hand-written wait/notify.

10. Summary

  • Synchronization ensures mutual exclusion and visibility for shared data.
  • synchronized methods lock this (or the class for static methods); blocks lock a chosen object.
  • wait/notifyAll coordinate threads around conditions; BlockingQueue is usually simpler.
  • ReentrantLock adds timeouts, fairness and conditions; consistent lock ordering prevents deadlocks.
  • Atomics, concurrent collections and immutability often avoid explicit locking altogether.

Related Tutorials