Synchronization in Java – Locks, Monitors and Thread Safety
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:
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)");
}
}
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:
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)");
}
}
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:
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)");
}
}
Unique IDs generated: 20000 (no duplicates)
| Form | Lock 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:
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();
}
}
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/notifyonly while holding the object's lock, or you getIllegalMonitorStateException. - Always wait in a
whileloop that rechecks the condition, because of spurious wake-ups and competing threads. - In real code, prefer
BlockingQueue(such asArrayBlockingQueue), 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:
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();
}
}
}
Both transfers completed, no deadlock
- Always call
unlock()in afinallyblock. ReentrantLockalso supports fairness, interruptible locking and multipleConditions;ReadWriteLockandStampedLockallow 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
| Need | Use |
|---|---|
| Counter or single variable updated atomically | AtomicInteger, AtomicLong, LongAdder |
| Simple flag visible to all threads | volatile |
| Thread-safe maps, lists and queues | ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue |
| Data that never changes | Immutable objects and records |
| Per-thread data | ThreadLocal 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.
synchronizedmethods lockthis(or the class for static methods); blocks lock a chosen object.wait/notifyAllcoordinate threads around conditions;BlockingQueueis usually simpler.ReentrantLockadds timeouts, fairness and conditions; consistent lock ordering prevents deadlocks.- Atomics, concurrent collections and immutability often avoid explicit locking altogether.
Related Tutorials
Frequently Asked Questions (FAQ)
Q1. What is synchronization in Java?
A mechanism that lets only one thread at a time execute code protected by a given lock, ensuring consistent shared data and visibility of changes.
Q2. What is a monitor or intrinsic lock?
The lock built into every Java object. synchronized acquires it on entry and releases it on exit.
Q3. What is the difference between a synchronized method and a synchronized block?
A synchronized method locks its whole body on this (or the class). A block locks only selected statements, on any object you choose.
Q4. What does a static synchronized method lock on?
The Class object of its class, such as Counter.class.
Q5. Can two threads run different synchronized methods of the same object at once?
No. Both methods use the same lock (this), so one thread must wait for the other.
Q6. Is a lock released when an exception is thrown?
Yes for synchronized: the monitor is released automatically. With ReentrantLock, you must unlock in a finally block.
Q7. What is the difference between wait() and sleep()?
wait() must be called while holding a lock, releases it, and waits for a notification. sleep() pauses for a time and keeps any locks it holds.
Q8. What is the difference between notify() and notifyAll()?
notify() wakes one waiting thread; notifyAll() wakes all of them. notifyAll() is safer when threads wait for different conditions.
Q9. Why must wait() be called in a loop?
A thread can wake up spuriously or find that another thread already changed the condition, so it must recheck before continuing.
Q10. What is IllegalMonitorStateException?
It is thrown when wait, notify or notifyAll is called without holding the object's monitor.
Q11. What is a deadlock and how can it be avoided?
Threads waiting forever for locks held by each other. Avoid it by acquiring locks in a consistent order, holding fewer locks, or using tryLock with timeouts.
Q12. What is the difference between synchronized and ReentrantLock?
Both provide mutual exclusion and visibility. ReentrantLock adds tryLock, timeouts, interruptible locking, fairness and multiple conditions, but must be unlocked manually.
Q13. What is reentrancy?
The ability of a thread to acquire a lock it already holds. Java's intrinsic locks and ReentrantLock are reentrant.
Q14. Can a constructor be synchronized?
No. The object is not shared until construction finishes, so the keyword is not allowed on constructors. A synchronized block inside is possible.
Q15. Does synchronization hurt performance?
Uncontended locks are cheap. Heavy contention serialises threads and limits scalability, so keep critical sections short.
Q16. Why shouldn't I synchronize on a String?
String literals are interned and shared across the JVM, so unrelated code could lock the same object and cause contention or deadlocks.
Q17. Is synchronization needed for read-only data?
No, if the data is immutable and safely published, for example through final fields. Mutable data needs synchronized reads as well as writes.
Q18. What is a race condition?
A bug where the outcome depends on the timing of threads, such as lost updates in count++. Synchronization prevents it.
Q19. Does synchronized work with virtual threads?
Yes. Since Java 24, virtual threads blocked in synchronized no longer pin their carrier thread; earlier versions recommended ReentrantLock to avoid pinning.
Q20. Are Vector and Hashtable synchronized?
Yes, every method is synchronized, but they are legacy classes. Use ConcurrentHashMap or other concurrent collections instead.