The likes counter that couldn't count
Riya's canteen app has a “like” button for dishes. In testing, two phones tap Like 100,000 times each. The counter should say 200,000. It says 148,870. Run again: 169,997.
Sir, the code is just likes++. How can Java lose a plus-one?
likes++ is three steps: read, add, write. If both threads read the same old value, one write overwrites the other. That's a race condition.
So threads sharing data is dangerous?
Unprotected sharing is. The fix is to make the read-add-write happen as one indivisible step, one thread at a time. That's what synchronized does.
What are race conditions and synchronization?
A race condition occurs when the result of a program depends on the timing or interleaving of threads that access shared mutable data, at least one of them writing. The code that accesses such data is a critical section. Synchronization ensures mutual exclusion (only one thread executes a critical section at a time) and visibility (changes made by one thread are seen by others). In Java, every object has an intrinsic lock (monitor); a synchronized method or block acquires that lock on entry and releases it on exit, and a thread that cannot acquire it is BLOCKED.
Shared data is a single-person changing room. synchronized is the lock on the door: one person inside at a time, the rest wait in line.
Words you'll meet in this lesson
Your three tools
synchronized | Atomic classes | volatile | |
|---|---|---|---|
| Mutual exclusion | Yes | For one variable's update | No |
| Visibility | Yes | Yes | Yes |
Makes x++ safe | Yes | Yes (incrementAndGet) | No |
| Protects several fields together | Yes | No | No |
| Can block threads | Yes | No (lock-free) | No |
| Typical use | Balance check + debit | Counters, IDs | Stop flags, status |
Exam tip: “volatile guarantees visibility but not atomicity.” A volatile int with count++ still loses updates. Also: a synchronized instance method locks this; a static synchronized method locks ClassName.class.
See the race, then fix it
The race
public class Main {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable job = () -> {
for (int i = 0; i < 100_000; i++) {
count++;
}
};
Thread a = new Thread(job);
Thread b = new Thread(job);
a.start();
b.start();
a.join();
b.join();
System.out.println("expected 200000, got " + count);
}
}This output is from one real run. Yours will be different, and different each time; that's what makes race conditions so hard to catch in testing.
The fix: a synchronized method
public class Main {
static int count = 0;
static synchronized void increment() {
count++;
}
public static void main(String[] args) throws InterruptedException {
Runnable job = () -> {
for (int i = 0; i < 100_000; i++) {
increment();
}
};
Thread a = new Thread(job);
Thread b = new Thread(job);
a.start();
b.start();
a.join();
b.join();
System.out.println("expected 200000, got " + count);
}
}The ways to synchronize
synchronized method
The whole method is the critical section. Locks this (or the class, if static).
synchronized block
Lock only the lines that touch shared data, on a lock object you choose.
fine-grainedAtomic classes
AtomicInteger, AtomicLong, LongAdder: safe single-variable updates without locks.
volatile
Every thread always sees the latest value. For flags one thread writes and others read.
visibilitysynchronized void deposit(int amt) { … } // locks this
static synchronized void log(String s) { … } // locks the class object (Bank.class)
synchronized (lock) { … } // locks the object you choose
volatile boolean running; // visibility only, no locking
AtomicInteger hits = new AtomicInteger(); // lock-free atomic updates
hits.incrementAndGet();The trap: deadlock
// Thread 1 // Thread 2
synchronized (accountA) { synchronized (accountB) {
synchronized (accountB) { synchronized (accountA) { // each waits for the
… … // other's lock: forever
} }
} }Always take multiple locks in the same order. If every thread locks account A before account B (for example, by smaller account id first), the circular wait above can never happen. And keep locked sections short: never call a slow network service while holding a lock.
What happens at a synchronized block
A thread reaches the block
The JVM runssynchronized (account) { if (account.balance >= amt) account.balance -= amt; }monitorenteron theaccountobject's monitor.Lock free? Take it
If no one owns the monitor, this thread becomes the owner and enters. It also sees every write made by the previous owner before it released the lock.Lock taken? Wait
If another thread owns it, this thread joins the monitor's entry set and is BLOCKED. It uses no CPU while waiting.Leave the block
monitorexitreleases the lock, even if an exception is thrown inside (the compiler adds a hidden finally). All writes made inside become visible to the next owner.Next in line
One BLOCKED thread acquires the lock and becomes RUNNABLE. Which one is not guaranteed. The same thread may re-enter a lock it already owns (locks are re-entrant).
A lost update, and the lock that prevents it
Left: two threads and one shared count. Right: the bytecode steps of count++, each thread's private operand stack, and the monitor.
countis a shared field on the heap, value 5. Two threads both runcount++.count++is not one step. T1 first reads 5 into its own working space…- …and before T1 writes back, the scheduler lets T2 run. T2 also reads 5.
- Both add 1 and both write 6. Two increments, but count only went up by 1: a lost update. This is a race condition.
- Now
count++is inside asynchronizedblock. T1 takes the lock first; T2 arrives, finds the lock taken, and is BLOCKED. - T1 writes 6 and releases the lock. T2 gets the lock, reads 6, writes 7. The result is always correct.
count++looks like one action, but the compiler turns it into several bytecode instructions.getstaticcopies the current value (5) onto T1's own operand stack. The shared field isn't changed yet.- The scheduler can switch threads between any two instructions. T2 also copies 5 onto its own stack.
- Each thread adds 1 and
putstaticwrites 6. The second write overwrites the first. synchronizedcompiles tomonitorenter/monitorexit. Every object has a monitor: one owner at a time, and other threads wait in its entry set (BLOCKED).- When T1 leaves the block, the lock is released and T2 becomes the owner. Leaving the block also makes T1's write visible to the next thread that takes the lock.
Where races cost real money
Race conditions are behind overselling in flash sales, double payouts, duplicate order numbers and corrupted caches.
Watch it with an example: two ATMs, one joint account
A joint account has ₹1,000. Two holders withdraw ₹800 at two ATMs at the same moment. Without a lock, both succeed.
- A shared
Accountobject with balance ₹1,000. Two threads (two ATMs) callwithdraw(800). - Both run
if (balance >= amount)before either subtracts. Both checks pass. - ATM 1 subtracts: ₹200 left.
- ATM 2 already passed its check, so it subtracts too: ₹−600. The bank paid ₹1,600 from ₹1,000. This is a check-then-act race.
- Make
withdrawsynchronized: the check and the subtraction become one indivisible step. ATM 2 is BLOCKED until ATM 1 is done. - ATM 2 now checks against ₹200 and refuses. Real banks protect balances the same way, usually with database locks or transactions.
1. Balances and wallets
Debit must check and update the balance as one step, or two parallel payments can overdraw it.
2. Inventory in flash sales
Thousands of buyers hit “Buy” for 100 units. Reservation must be atomic so exactly 100 orders succeed.
3. IDs and counters
Order numbers, page views and rate-limit counters use AtomicLong or LongAdder so no two orders get the same number.
4. Shared caches and config
Concurrent maps (ConcurrentHashMap) and volatile references let many request threads read config and cached data safely while one thread refreshes it.
Simplified: real banking systems also rely on database transactions and row locks, because many servers (not just many threads) share the data.
The same race, fixed with a block
public class Main {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable job = () -> {
for (int i = 0; i < 100_000; i++) {
synchronized (Main.class) {
count++;
}
}
};
Thread a = new Thread(job);
Thread b = new Thread(job);
a.start();
b.start();
a.join();
b.join();
System.out.println("synchronized result: " + count);
}
}synchronized (Main.class)Both threads lock the same object (the Class object of Main), so only one is inside at a time.count++Now the read-add-write can't interleave with the other thread's.a.join(); b.join();join also guarantees main sees both threads' final writes.
A synchronized counter shared by two threads
Recorded from the real JVM, following main. Watch the single Counter object on the heap: both threads update it, and the result is right every time.
Worked examples: real problems, solved and traced in memory
Three ways to make shared data safe: a synchronized method, an atomic class, and a volatile flag. Each trace follows main; the other threads' work shows up in the shared objects on the heap.
Only one person gets the last seat
Problem statement
A concert has 1 seat left. Two customers press Book at the same moment, each on their own thread. Exactly one booking must succeed. Make book() safe and print how many bookings succeeded and how many seats are left.
seats left: 1; two booking threads
successful bookings: 1, seats left: 0
Approach
- The check (
left > 0) and the update (left--) must happen as one step, so put both inside asynchronizedmethod. - Each thread stores whether it succeeded in its own array slot.
- After joining, count the successes. The winner's name may vary; the counts never do.
Solution: run it live
What happens in memory, step by step
Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.
Cashback counter with AtomicInteger
Problem statement
Four payment servers each credit ₹1 cashback 1,000 times to the same wallet total. Use AtomicInteger so no credit is lost, without writing synchronized.
4 threads × 1,000 credits of ₹1
total cashback: Rs 4000
Approach
AtomicInteger.incrementAndGet()performs read-add-write as one indivisible hardware operation (compare-and-swap).- All four threads share one AtomicInteger.
- Join all, then print the total: always 4000.
Solution: run it live
What happens in memory, step by step
Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.
Stop a worker with a volatile flag
Problem statement
A video-encoding worker keeps processing chunks while a flag running is true. main lets it work for a moment, then sets the flag to false. Without volatile, the worker might never see the change. Make the stop reliable.
(no input)
encoder: saw running = false, stopping encoded some chunks: true
Approach
- Declare
static volatile boolean running = true;. - The worker loops
while (running). - main sleeps briefly, sets
running = false, joins the worker, and reports.
Solution: run it live
What happens in memory, step by step
Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.
Check yourself
Quick revision
Lesson 12.4 in one screen
- Race condition: result depends on thread timing; needs shared mutable data and at least one writer.
x++is read-add-write, not atomic. Check-then-act (if (bal >= amt) bal -= amt) is also a race.synchronized= mutual exclusion + visibility, using an object's monitor. Instance method →this; static →Class.- Threads must lock the same object to exclude each other.
volatile= visibility only. Atomic classes = lock-free safe updates of one variable.- Deadlock: circular wait for locks. Prevent with a fixed lock order and short critical sections.
- Locks are re-entrant and released even when an exception is thrown.
Interview and viva questions
What is a race condition? Give an example.
A bug where the outcome depends on the interleaving of threads accessing shared mutable data. Example: two threads run count++ on a shared int; both read the same value and one increment is lost. Another: two withdrawals both pass a balance check before either debits.
Difference between a synchronized method and a synchronized block?
A synchronized method locks the whole method on this (or the Class for static methods). A block locks only its statements, on any object you choose. Blocks keep critical sections smaller and let you use private lock objects that outside code can't interfere with.
What is the difference between volatile and synchronized?
volatile ensures reads and writes of one variable go to main memory and aren't reordered, giving visibility. It doesn't make compound actions atomic. synchronized provides both mutual exclusion and visibility for any block of code, at the cost of possible blocking.
What is a deadlock and how do you prevent it?
Two or more threads each hold a lock and wait for a lock held by another, forever. Prevent it by acquiring locks in a consistent global order, holding locks briefly, avoiding calling unknown code while holding a lock, or using ReentrantLock.tryLock with a timeout.
How does AtomicInteger work without locks?
It uses a CPU compare-and-swap (CAS) instruction: read the value, compute the new one, and write it only if the value is still what was read; otherwise retry. No thread blocks, so there's no deadlock risk, though heavy contention can cause retries (LongAdder reduces that).
Practice problems
Find the shared data, then protect it with synchronized, an atomic class, or volatile. Every solution prints the same result on every run. The last three are business tasks.
Synchronized counter
Two threads each call inc() 50,000 times on one counter. Make the total always 100000.
2 × 50,000
100000
Show solution and explanation
class Counter {
private int n;
synchronized void inc() { n++; }
synchronized int get() { return n; }
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter c = new Counter();
Runnable job = () -> { for (int i = 0; i < 50_000; i++) c.inc(); };
Thread a = new Thread(job), b = new Thread(job);
a.start(); b.start();
a.join(); b.join();
System.out.println(c.get());
}
}How it works: a synchronized instance method locks this. Both threads use the same Counter, so they take turns inside inc().
Synchronized block on a lock object
Do the same with a static int and a synchronized (LOCK) block around count++ only.
2 × 50,000
100000
Show solution and explanation
public class Main {
static int count = 0;
static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable job = () -> {
for (int i = 0; i < 50_000; i++) {
synchronized (LOCK) {
count++;
}
}
};
Thread a = new Thread(job), b = new Thread(job);
a.start(); b.start();
a.join(); b.join();
System.out.println(count);
}
}How it works: a block lets you lock only the lines that touch shared data. Make the lock object final so it can never be replaced.
AtomicInteger
Three threads each add 10 to an AtomicInteger 100 times using addAndGet. Print the result.
3 × 100 × 10
3000
Show solution and explanation
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
public static void main(String[] args) throws InterruptedException {
AtomicInteger total = new AtomicInteger();
Thread[] ts = new Thread[3];
for (int i = 0; i < 3; i++) {
ts[i] = new Thread(() -> { for (int k = 0; k < 100; k++) total.addAndGet(10); });
ts[i].start();
}
for (Thread t : ts) t.join();
System.out.println(total.get());
}
}How it works: atomic classes use a CPU compare-and-swap instruction, retrying if another thread got there first. No thread is ever BLOCKED.
Static synchronized across objects
Each thread uses its own Visit object, but all visits must be counted in one static total. Use a static synchronized method.
4 threads × 2,500 visits
visits: 10000
Show solution and explanation
class Visit {
private static int total = 0;
static synchronized void record() { total++; }
static synchronized int total() { return total; }
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread[] ts = new Thread[4];
for (int i = 0; i < 4; i++) {
ts[i] = new Thread(() -> { for (int k = 0; k < 2500; k++) Visit.record(); });
ts[i].start();
}
for (Thread t : ts) t.join();
System.out.println("visits: " + Visit.total());
}
}How it works: a static synchronized method locks the class object Visit.class, one lock for the whole class, which is right for a static field. An instance lock (this) would not protect it if the threads used different objects.
Thread-safe list
Two threads each add 1,000 items to a shared list. A plain ArrayList can lose items or crash. Use Collections.synchronizedList and print the size.
2 × 1,000 items
orders: 2000
Show solution and explanation
import java.util.*;
public class Main {
public static void main(String[] args) throws InterruptedException {
List<Integer> orders = Collections.synchronizedList(new ArrayList<>());
Runnable job = () -> { for (int i = 0; i < 1000; i++) orders.add(i); };
Thread a = new Thread(job), b = new Thread(job);
a.start(); b.start();
a.join(); b.join();
System.out.println("orders: " + orders.size());
}
}How it works: the wrapper makes each method synchronized. ArrayList's add does several steps (check capacity, store, size++), so unsynchronized adds can be lost. For heavy use, prefer java.util.concurrent collections such as CopyOnWriteArrayList or ConcurrentHashMap.
Check-then-act fixed
A coupon can be used at most 3 times. Ten threads try to redeem it. Make redeem() safe and print how many succeeded.
limit 3, 10 attempts
redeemed 3 times
Show solution and explanation
class Coupon {
private int uses = 0;
synchronized boolean redeem() {
if (uses < 3) {
uses++;
return true;
}
return false;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Coupon c = new Coupon();
boolean[] ok = new boolean[10];
Thread[] ts = new Thread[10];
for (int i = 0; i < 10; i++) {
int k = i;
ts[i] = new Thread(() -> ok[k] = c.redeem());
ts[i].start();
}
int wins = 0;
for (int i = 0; i < 10; i++) {
ts[i].join();
if (ok[i]) wins++;
}
System.out.println("redeemed " + wins + " times");
}
}How it works: the comparison and the increment must be in the same synchronized method. Synchronizing them separately would still allow two threads to pass the check before either increments.
Flash-sale stock reservation
A phone has 5 units in stock. Eight shoppers' threads each try to reserve one. Print reserved and sold-out counts and the final stock.
stock 5, 8 shoppers
reserved: 5, sold out for: 3, stock now: 0
Show solution and explanation
class Stock {
private int units = 5;
synchronized boolean reserve() {
if (units == 0) return false;
units--;
return true;
}
synchronized int units() { return units; }
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Stock phone = new Stock();
boolean[] got = new boolean[8];
Thread[] shoppers = new Thread[8];
for (int i = 0; i < 8; i++) {
int k = i;
shoppers[i] = new Thread(() -> got[k] = phone.reserve(), "shopper-" + i);
shoppers[i].start();
}
int reserved = 0;
for (int i = 0; i < 8; i++) {
shoppers[i].join();
if (got[i]) reserved++;
}
System.out.println("reserved: " + reserved + ", sold out for: " + (8 - reserved) + ", stock now: " + phone.units());
}
}How it works: this is the classic overselling bug in flash sales. Real systems use the same idea one level down: a database row lock or an atomic UPDATE … WHERE units > 0.
Transfers without deadlock
Two threads run transfers at once: ten of A→B ₹100 and ten of B→A ₹50. Each must lock both accounts. Always lock the account with the smaller id first, so the threads can't deadlock. Print the final balances.
A (id 1) ₹500, B (id 2) ₹300
A: 0, B: 800, total: 800
Show solution and explanation
class Account {
final int id;
int balance;
Account(int id, int balance) {
this.id = id;
this.balance = balance;
}
}
public class Main {
static void transfer(Account from, Account to, int amt) {
Account first = from.id < to.id ? from : to;
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.balance -= amt;
to.balance += amt;
}
}
}
public static void main(String[] args) throws InterruptedException {
Account a = new Account(1, 500), b = new Account(2, 300);
Thread t1 = new Thread(() -> { for (int i = 0; i < 10; i++) transfer(a, b, 100); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 10; i++) transfer(b, a, 50); });
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("A: " + a.balance + ", B: " + b.balance + ", total: " + (a.balance + b.balance));
}
}How it works: if t1 locked A then waited for B while t2 locked B then waited for A, both would wait forever: a deadlock. A fixed global lock order makes that cycle impossible. The total stays ₹800. (No balance checks here, so balances may go negative; it's only a locking demo.)
Page-view counter for a news site
Six web threads each record 5,000 page views. Use LongAdder, which is faster than AtomicLong when many threads update often, and print the total.
6 × 5,000
page views: 30000
Show solution and explanation
import java.util.concurrent.atomic.LongAdder;
public class Main {
public static void main(String[] args) throws InterruptedException {
LongAdder views = new LongAdder();
Thread[] web = new Thread[6];
for (int i = 0; i < 6; i++) {
web[i] = new Thread(() -> { for (int k = 0; k < 5000; k++) views.increment(); });
web[i].start();
}
for (Thread t : web) t.join();
System.out.println("page views: " + views.sum());
}
}How it works: LongAdder keeps several internal cells so threads rarely compete for the same one, and sum() adds them up. It's the usual choice for high-traffic counters and metrics.
Real-world scenario questions (asked in company interviews)
Concurrency bugs are favourite interview topics because they are expensive in production and invisible in simple tests.
103 orders for 100 phones
During a flash sale, 100 units were listed but 103 orders were confirmed. The code does if (stock > 0) { createOrder(); stock--; } in a request handler. Explain and fix.
The interviewer is checking: check-then-act races.
Show a strong answer
- Several request threads passed the
stock > 0check before any of them decremented: a check-then-act race. - Make the check and decrement one atomic step: a synchronized method, an
AtomicIntegerwithcompareAndSet/getAndUpdate, or (across servers) a conditional database updateUPDATE … SET stock = stock - 1 WHERE stock > 0. - Create the order only after the reservation succeeds.
Avoid: “add a small sleep” or “it only happens under load”. That's exactly when it matters.
Synchronized, yet still wrong
A colleague wrote synchronized (new Object()) { total += amount; } and says the method is now thread-safe. Tests still show wrong totals. Why?
The interviewer is checking: that exclusion requires a shared lock.
Show a strong answer
- Every call locks a brand-new object, so no two threads ever compete for the same lock: no exclusion at all.
- Use one shared, final lock object (
private final Object lock = new Object();) orsynchronized (this), or an atomic type.
Avoid: locking on strings or boxed Integers: they may be shared or replaced unexpectedly.
Transfers freeze at month end
The transfer service locks the sender account, then the receiver. On salary day it sometimes freezes completely; a thread dump shows two threads BLOCKED, each holding the lock the other needs. Fix it.
The interviewer is checking: deadlock diagnosis and lock ordering.
Show a strong answer
- A→B and B→A transfers ran at once: thread 1 holds A and wants B; thread 2 holds B and wants A. A circular wait.
- Lock by a global order (e.g. smaller account id first), whatever the transfer direction.
- Alternatively use
tryLockwith a timeout and retry, and alert on long lock waits.
Avoid: restarting the server as the fix. The deadlock will return on the next busy day.
Thread-safe, but now slow
To fix a race, someone made the whole handleRequest() method synchronized, including a 300 ms database call. Throughput dropped from 800 to 3 requests per second. What would you do?
The interviewer is checking: minimal critical sections.
Show a strong answer
- The lock serialises every request, so all requests queue behind the slow call.
- Identify the actual shared state, and lock only the few lines that touch it; do I/O outside the lock.
- Use concurrent data structures (
ConcurrentHashMap, atomics) where they fit, so most code needs no lock.
Avoid: removing synchronization entirely to regain speed; the race comes back.