Java Synchronization

Synchronization is Java’s built-in mechanism for controlling access to shared, mutable data when multiple threads run at the same time. Without it, two threads can read and write the same variable at the exact same instant, corrupting data in ways that are notoriously hard to reproduce and debug. The synchronized keyword, along with the locking concepts it is built on, lets you guarantee that only one thread executes a critical section at a time, and that changes one thread makes become visible to the next. This lesson covers what synchronization actually does, how the JVM implements it, and how to use it correctly without introducing deadlocks or unnecessary slowdowns.

Overview: What Synchronization Is and Why It Matters

When you start multiple Thread objects in a Java program, they all run inside the same process and share the same heap memory. If two threads increment the same int field at once, the increment (count++) is not a single CPU instruction — it is really a read, an add, and a write. If both threads read the value before either writes it back, one of the increments is silently lost. This is called a race condition, and it is the core problem synchronization exists to solve.

Every Java object carries an invisible piece of state called its monitor (also called its intrinsic lock). When a thread enters a synchronized method or block, it must first acquire that object’s monitor. If another thread already holds it, the newcomer blocks until the lock is released. This gives you mutual exclusion: only one thread can execute the guarded code on that object at a time.

Synchronization also enforces something less visible but just as important: memory visibility. The Java Memory Model does not guarantee that a write made by one thread is immediately seen by another thread reading the same field, because of CPU caches and compiler reordering. Acquiring and releasing a lock creates a happens-before relationship: everything a thread wrote before it released a lock is guaranteed visible to the next thread that acquires that same lock. Without synchronization (or another visibility tool like volatile), a thread might loop forever reading a stale, cached value even after another thread has changed it.

Java’s intrinsic locks are also reentrant. If a thread already holds an object’s monitor, it can enter another synchronized method or block guarded by the same monitor without blocking on itself. The JVM keeps an internal count of how many times the current thread has acquired the lock, and only releases it to other threads once that count returns to zero. This lets a synchronized method safely call another synchronized method on the same object.

Syntax

There are three common forms of synchronized in Java:

synchronized (lockObject) {
    // critical section
}

public synchronized void instanceMethod() {
    // locks "this"
}

public static synchronized void staticMethod() {
    // locks ClassName.class
}
Form What it locks When to use it
Synchronized block Whatever object you pass in parentheses When you only need to protect part of a method, or want a dedicated lock object
Synchronized instance method this (the current object) When the whole method body reads or writes instance state
Synchronized static method The Class object for that class When the method reads or writes static (class-level) state

Related methods, callable only while a thread holds the relevant monitor, live on Object itself:

Method Purpose
wait() Releases the monitor and pauses the current thread until another thread calls notify()/notifyAll() on the same object
notify() Wakes up one waiting thread
notifyAll() Wakes up all waiting threads

Examples

Example 1: A Thread-Safe Counter with a Synchronized Method

This is the simplest case: two threads each increment a shared counter 100,000 times. Because increment() is synchronized, every increment is atomic with respect to other threads, so no updates are lost.

public class Main {
    static class Counter {
        private int count = 0;

        public synchronized void increment() {
            count++;
        }

        public synchronized int getCount() {
            return count;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable task = () -> {
            for (int i = 0; i < 100000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Final count: " + counter.getCount());
    }
}

Output:

Final count: 200000

If increment() were not synchronized, this program would (usually) print a number less than 200000, and that number would change from run to run, because increments from the two threads could interleave and overwrite each other.

Example 2: A Synchronized Block for Finer-Grained Locking

Sometimes you only want to lock part of a method, not the whole thing. Here, three "teller" threads deposit into the same bank account concurrently, and only the balance update itself is guarded by a synchronized block.

public class Main {
    static class BankAccount {
        private double balance;

        public BankAccount(double balance) {
            this.balance = balance;
        }

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

        public double getBalance() {
            return balance;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        BankAccount account = new BankAccount(1000.0);

        Runnable depositTask = () -> {
            for (int i = 0; i < 1000; i++) {
                account.deposit(10.0);
            }
        };

        Thread teller1 = new Thread(depositTask);
        Thread teller2 = new Thread(depositTask);
        Thread teller3 = new Thread(depositTask);

        teller1.start();
        teller2.start();
        teller3.start();

        teller1.join();
        teller2.join();
        teller3.join();

        System.out.println("Final balance: " + account.getBalance());
    }
}

Output:

Final balance: 31000.0

Starting balance is 1000.0, and three threads each add 10.0 a thousand times (30000.0 total), giving a deterministic 31000.0 every run — because the synchronized block guarantees each deposit is applied completely before the next one starts.

Example 3: Coordinating Threads with wait() and notifyAll()

Synchronization is not just about protecting data — it also lets threads signal each other. This producer/consumer example uses a one-slot shared buffer: the producer waits if the slot is full, the consumer waits if it is empty.

class SharedBuffer {
    private int value;
    private boolean hasValue = false;

    public synchronized void produce(int val) throws InterruptedException {
        while (hasValue) {
            wait();
        }
        value = val;
        hasValue = true;
        System.out.println("Produced: " + val);
        notifyAll();
    }

    public synchronized int consume() throws InterruptedException {
        while (!hasValue) {
            wait();
        }
        hasValue = false;
        System.out.println("Consumed: " + value);
        notifyAll();
        return value;
    }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        SharedBuffer buffer = new SharedBuffer();

        Thread producer = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    buffer.produce(i);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        Thread consumer = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    buffer.consume();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        producer.start();
        consumer.start();

        producer.join();
        consumer.join();
    }
}

Output:

Produced: 1
Consumed: 1
Produced: 2
Consumed: 2
Produced: 3
Consumed: 3
Produced: 4
Consumed: 4
Produced: 5
Consumed: 5

Because the buffer only holds one value, the producer must always wait for the consumer to empty it and vice versa, forcing a strict alternation. Note the while loops around wait() rather than if — this guards against spurious wakeups, where a thread can wake up from wait() without anyone calling notify(), a rare but documented JVM behavior.

How It Works Under the Hood

Every Java object has an object header in memory that includes a "mark word," which encodes the object’s locking state. When the compiler generates bytecode for a synchronized block, it emits a monitorenter instruction before the block and a matching monitorexit after it (synchronized methods are marked with an access flag instead, but the effect is the same). At runtime, monitorenter attempts to claim the object’s monitor; if it is already held by another thread, the JVM parks the current thread (via the operating system scheduler) until the monitor is released.

Modern JVMs optimize this heavily because most locks are never actually contended. HotSpot uses a progression of lock states: biased locking (a lock repeatedly acquired by the same thread skips real synchronization almost entirely — deprecated and removed in recent JDKs, but historically important), lightweight/thin locking using a compare-and-swap on the object header when there is no contention, and heavyweight locking, which falls back to an OS-level mutex only when two or more threads genuinely contend for the same lock at the same time. This means an uncontended synchronized block is cheap; the real cost only appears when threads are actually blocking on each other.

When a thread calls wait(), it fully releases the monitor (even if it was reentered multiple times, tracking the original count) and is placed on that object’s wait set. A call to notify() or notifyAll() moves one or all waiting threads from the wait set back into contention for the monitor — they do not resume immediately, they must re-acquire the lock first, which is why the buffer example checks its condition again in a while loop after waking up.

Common Mistakes

Mistake 1: Unsynchronized Check-Then-Act Logic

A very common bug is checking a condition and then acting on it in two separate steps, assuming nothing can happen in between. Here, two threads can both read ticketsLeft > 0 as true before either one decrements it, selling more tickets than actually exist.

class TicketBooth {
    private int ticketsLeft = 1;

    public void sell() {
        if (ticketsLeft > 0) {
            // Another thread can slip in right here, between the check
            // and the update, and also see ticketsLeft > 0 before either
            // thread has a chance to decrement it.
            ticketsLeft--;
            System.out.println("Ticket sold! Remaining: " + ticketsLeft);
        } else {
            System.out.println("Sold out!");
        }
    }
}

The fix is to make the entire check-then-act sequence atomic by wrapping it in a single synchronized method, so no other thread can observe or modify the state in the middle of it:

public class Main {
    static class TicketBooth {
        private int ticketsLeft;

        public TicketBooth(int ticketsLeft) {
            this.ticketsLeft = ticketsLeft;
        }

        public synchronized boolean sell() {
            if (ticketsLeft > 0) {
                ticketsLeft--;
                return true;
            }
            return false;
        }

        public synchronized int getTicketsLeft() {
            return ticketsLeft;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        TicketBooth booth = new TicketBooth(3);
        int buyerCount = 10;
        Thread[] buyers = new Thread[buyerCount];
        java.util.concurrent.atomic.AtomicInteger successCount =
            new java.util.concurrent.atomic.AtomicInteger(0);

        for (int i = 0; i < buyerCount; i++) {
            buyers[i] = new Thread(() -> {
                if (booth.sell()) {
                    successCount.incrementAndGet();
                }
            });
        }

        for (Thread buyer : buyers) {
            buyer.start();
        }
        for (Thread buyer : buyers) {
            buyer.join();
        }

        System.out.println("Tickets sold: " + successCount.get());
        System.out.println("Tickets remaining: " + booth.getTicketsLeft());
    }
}

Output:

Tickets sold: 3
Tickets remaining: 0

With 10 threads racing for 3 tickets, exactly 3 succeed and 7 fail every single run — which thread wins is not guaranteed, but the count is, because sell() is now atomic.

Mistake 2: Deadlock from Inconsistent Lock Ordering

Acquiring two locks in nested synchronized blocks is dangerous if different threads can acquire them in different orders. If thread A locks account a then waits for b, while thread B locks b then waits for a, neither thread can ever proceed — a classic deadlock.

class Account {
    private double balance;
    private final int id;

    Account(int id, double balance) {
        this.id = id;
        this.balance = balance;
    }

    // BUG: the locks are acquired in whatever order the caller passes the
    // accounts in. If thread A calls a.transfer(b, ...) while thread B calls
    // b.transfer(a, ...) at the same time, A can lock "a" and wait for "b"
    // while B locks "b" and waits for "a" -- a deadlock.
    void transfer(Account to, double amount) {
        synchronized (this) {
            synchronized (to) {
                this.balance -= amount;
                to.balance += amount;
            }
        }
    }
}

The fix is to always acquire locks in a fixed, consistent order — here, by each account’s unique id — regardless of which account initiated the transfer:

public class Main {
    static class Account {
        private double balance;
        private final int id;

        Account(int id, double balance) {
            this.id = id;
            this.balance = balance;
        }

        double getBalance() {
            return balance;
        }

        void transfer(Account to, double amount) {
            Account first = this.id < to.id ? this : to;
            Account second = this.id < to.id ? to : this;

            synchronized (first) {
                synchronized (second) {
                    this.balance -= amount;
                    to.balance += amount;
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Account a = new Account(1, 1000.0);
        Account b = new Account(2, 1000.0);

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 500; i++) {
                a.transfer(b, 1.0);
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 500; i++) {
                b.transfer(a, 1.0);
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Total balance: " + (a.getBalance() + b.getBalance()));
    }
}

Output:

Total balance: 2000.0

No matter how the two threads interleave, the total money in the system is conserved and the program always finishes, because both threads now agree on the order in which they grab locks.

Best Practices

  • Keep synchronized blocks as small as possible — lock only around the actual shared-state access, not around I/O, logging, or unrelated computation.
  • Prefer a small, dedicated private final Object lock = new Object(); field over synchronizing on this when the class is part of a public API, so external code cannot accidentally lock on (and interfere with) your object.
  • Never synchronize on a mutable or reassignable reference (like a boxed Integer or a String literal) — different threads may end up locking on different objects without realizing it.
  • Always acquire multiple locks in the same, consistent order everywhere in your codebase to avoid deadlocks.
  • Always call wait() inside a while loop that re-checks the condition, never inside an if, to protect against spurious wakeups and multiple waiting threads.
  • Prefer java.util.concurrent utilities (ConcurrentHashMap, AtomicInteger, ReentrantLock, ExecutorService) over hand-rolled synchronized code when they fit the problem — they are typically more efficient and less error-prone.
  • Don’t call unfamiliar or overridable code while holding a lock; it can call back into your synchronized code and deadlock, or hold the lock far longer than necessary.
  • Document which lock protects which fields, especially in classes accessed by multiple threads — future maintainers (including you) cannot infer this from the code alone.

Practice Exercises

  • Exercise 1: Write a Counter class with an unsynchronized increment() method. Start four threads that each call it 50,000 times, join them all, and print the final count. Run the program a few times and observe that the result is usually less than 200,000 and changes between runs. Then add synchronized and confirm it becomes reliably 200,000.
  • Exercise 2: Build a simple Logger class with a synchronized log(String message) method that appends messages (with a sequence number) to a shared StringBuilder. Have three threads log 100 messages each, then print the total number of characters logged and confirm no messages were lost or corrupted.
  • Exercise 3: Extend the producer/consumer example from this lesson (Example 3) to use a buffer that can hold up to 3 items instead of 1, using a simple array or List guarded by the same object’s monitor. The producer should wait() when the buffer is full, and the consumer should wait() when it is empty. Verify your program still produces and consumes exactly 5 items in order.

Summary

  • Synchronization prevents race conditions by giving one thread at a time exclusive access to a critical section, guarded by an object’s intrinsic monitor.
  • Beyond mutual exclusion, synchronized blocks establish a happens-before relationship that guarantees memory visibility between threads.
  • Java’s intrinsic locks are reentrant: a thread already holding a lock can re-acquire it without blocking itself.
  • Synchronized methods lock on this (instance methods) or the Class object (static methods); synchronized blocks let you lock on any chosen object for finer control.
  • wait(), notify(), and notifyAll() let threads coordinate, but wait() must always be called inside a while loop that re-checks its condition.
  • Uncontended synchronized code is cheap thanks to JVM lock optimizations; the real cost appears only when threads genuinely contend for the same lock.
  • Check-then-act sequences must be wrapped entirely in synchronization, and multiple locks must always be acquired in a consistent order to avoid deadlocks.