Hook: Interviewers love this because synchronization is the difference between a program that works once and one that stays correct under load.
Question: What is synchronization in Java?
Answer: Synchronization is how Java makes sure only one thread at a time can run a critical section of code that touches shared mutable data. It prevents race conditions, which happen when two threads interleave in a bad way and corrupt the result. In Java, the common tool is the synchronized keyword, which uses an object's intrinsic lock, also called a monitor.
Interview-Ready Answer: In Java, synchronization is the mechanism I use to protect shared mutable state from race conditions. I usually do that with synchronized, which acquires an intrinsic lock on an object so only one thread can enter the critical section at a time. A useful detail is that it also gives memory visibility guarantees, so updates made inside the synchronized block are seen by the next thread that acquires the same lock.
Detailed Explanation: Synchronization is a rule for shared mutable state: one thread enters the protected code, everyone else waits, and the state changes become visible to the next thread that enters through the same lock. A monitor is the JVM's built-in lock attached to every object. Think of it as a room key: if you hold the key, you are the only one allowed inside that room.
synchronized method or block.this, static methods lock the Class object, and blocks lock the object inside synchronized(lock).reentrancy, which means the same thread can safely take the same lock again without deadlocking itself.Use synchronization when you need to protect a critical section: a small region where a shared field, collection, or invariant must not be observed half-finished. Classic examples are counters, bank balances, lazy initialization, and check-then-act logic such as “if stock is available, decrement it now.”
Prefer a small synchronized block over a whole method when possible, because a shorter critical section means less waiting and better throughput. Also prefer a private lock object for library code, so outside callers cannot accidentally lock on your public object and create surprises.
| Feature | synchronized | ReentrantLock | AtomicInteger |
|---|---|---|---|
| Best for | Simple critical sections | Advanced lock control | Single counters |
| Unlocking | Automatic | Manual in finally | N/A |
| Try with timeout | No | Yes | No |
| Condition queues | wait/notify | Condition | No |
| Multiple fields | Yes, with one lock | Yes, with one lock | Not ideal |
AtomicInteger is great for a single numeric value, but it does not replace a lock when several fields must change together. For example, a bank transfer needs both accounts updated as one unit, so a lock is often the clearer tool.
Acquiring and releasing an uncontended monitor is effectively O(1), and modern JVMs optimize it heavily. The expensive part is contention: if many threads want the same lock, they may block, context-switch, and wait milliseconds instead of nanoseconds. There is no fairness guarantee, no built-in timeout, and no interruptible lock acquisition with synchronized.
Important edge cases: static synchronized locks the class, not an instance; calling wait() or notify() requires holding the same monitor; and synchronizing only getters and setters does not make a multi-step operation safe if the logic spans multiple calls. Also, do not synchronize on public strings, boxed numbers, or other shared objects that code outside your class can accidentally reuse.
Memory model note: The big interview point is that synchronization is not only about exclusion. It also creates a happens-before relationship, which means the next thread that acquires the same lock sees the latest values written by the previous owner.
Real-World Example: In a checkout service for an online store, a method reserves inventory when an order is placed. If two threads read the same last item at the same time and the check plus decrement is not protected by one lock, both requests can succeed and oversell the product. In production, that shows up as duplicate confirmations, negative stock counts, and support tickets saying that a customer paid, but the item was later canceled. The logs often look suspiciously normal at first: two lines like reserved sku=123 remaining=0 appear back to back, which is a clue that the check-and-update was split across threads. The fix is to keep the whole invariant change inside one synchronized critical section, or use a database transaction / atomic compare-and-set if the state lives outside one JVM.
What goes wrong: The outage usually looks like a race, because it is one: threads interleave between the stock check and the decrement. Customers see oversells, payment capture may succeed, but fulfillment later fails.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class SynchronizationDemo {
static class UnsafeCounter {
private int value = 0;
public void increment() {
// Read-modify-write without a lock is a classic race condition.
int current = value;
Thread.yield(); // Makes the race easier to observe by widening the timing window.
value = current + 1;
}
public int getValue() {
return value;
}
}
static class SafeCounter {
private int value = 0;
public synchronized void increment() {
value++;
}
public synchronized int getValue() {
return value;
}
}
static class BankAccount {
private int balance;
BankAccount(int openingBalance) {
if (openingBalance < 0) {
throw new IllegalArgumentException("openingBalance must be non-negative");
}
this.balance = openingBalance;
}
public synchronized boolean withdraw(int amount) {
// The check and the update must happen in one critical section.
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
if (balance < amount) {
return false; // Failure path: not enough money, so we decline safely.
}
balance -= amount;
return true;
}
public synchronized void deposit(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
balance += amount;
}
public synchronized int getBalance() {
return balance;
}
}
public static void main(String[] args) throws InterruptedException {
demoCounterRace();
System.out.println();
demoBankAccount();
}
private static void demoCounterRace() throws InterruptedException {
final int threads = 8;
final int incrementsPerThread = 100_000;
UnsafeCounter unsafe = new UnsafeCounter();
SafeCounter safe = new SafeCounter();
runWorkers(threads, () -> {
for (int i = 0; i < incrementsPerThread; i++) {
unsafe.increment();
}
});
runWorkers(threads, () -> {
for (int i = 0; i < incrementsPerThread; i++) {
safe.increment();
}
});
int expected = threads * incrementsPerThread;
System.out.println("Expected count: " + expected);
System.out.println("Unsafe counter : " + unsafe.getValue());
System.out.println("Safe counter : " + safe.getValue());
}
private static void demoBankAccount() throws InterruptedException {
BankAccount account = new BankAccount(1_000);
CountDownLatch done = new CountDownLatch(2);
Runnable withdrawTask = () -> {
boolean approved = account.withdraw(800);
System.out.println(Thread.currentThread().getName()
+ " withdraw 800 -> " + (approved ? "approved" : "declined"));
done.countDown();
};
new Thread(withdrawTask, "T1").start();
new Thread(withdrawTask, "T2").start();
done.await();
System.out.println("Final balance : " + account.getBalance());
}
private static void runWorkers(int count, Runnable task) throws InterruptedException {
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(count);
List<Thread> workers = new ArrayList<>();
for (int i = 0; i < count; i++) {
Thread worker = new Thread(() -> {
try {
start.await();
task.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
}, "worker-" + i);
workers.add(worker);
worker.start();
}
start.countDown();
done.await();
}
}
Follow-up & Tricky Questions:
happens-before edge, so the next thread sees the latest writes, not stale cached values.ReentrantLock instead? Use it when you need tryLock(), timed lock attempts, fairness options, or multiple Condition queues; for simple cases, synchronized is easier and safer because it unlocks automatically.wait() and why is it related? wait() temporarily releases the same monitor and suspends the thread until another thread calls notify() or notifyAll(); the waiting thread must re-acquire the monitor before continuing.Common Mistakes:
this references, because outside code can accidentally interfere.Memory Hook: Think of synchronization as one bathroom key in a busy office: only one person can enter, and when they leave, the key is available to the next person. That picture also reminds you that the key is returned automatically when the method ends, even if an exception happens.
Cheat Sheet:
synchronized = mutual exclusion + visibility.this; static method locks the Class object.ReentrantLock for timeout/try-lock features.AtomicInteger for a single counter, not for multi-field state.Practice Tasks:
synchronized and compare the result with an unsynchronized version.this.AtomicInteger and explain why it works for counting but not for a two-field invariant.