Stage 8: Concurrency and the JVM, lesson 2 of 7

synchronized, volatile and locks

Advanced4 min readall versions
Explain it forThe essentials plus production detail and pitfalls.

When threads share mutable data you get race conditions and visibility problems.

  • synchronized: only one thread at a time enters the block for a given lock object, and its changes become visible to the next thread that takes the lock.
  • volatile: guarantees visibility and ordering for one variable, but not atomicity; count++ is still unsafe.
  • Atomics (AtomicInteger, LongAdder): lock-free counters and updates.
  • ReentrantLock: like synchronized, plus tryLock, timeouts and fairness.
  • Concurrent collections: ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue.

Best of all, avoid sharing mutable state. Immutable objects are thread-safe for free.

Race conditions

count++ is really read, add and write. When two threads do it at the same time, updates get lost. Any shared, changing data needs protection.

Java
class Counter {
    private int count;
    void increment() { count++; }   // not thread-safe: two threads lose updates
}

synchronized methods and blocks

synchronized lets only one thread at a time hold an object's lock, and it also makes changes visible to the next thread that takes the lock. Lock the smallest block you can, and use a private lock object so outside code can't interfere. Locks are re-entrant: a thread can re-take a lock it already holds.

Java
class Counter {
    private final Object lock = new Object();
    private int count;

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

volatile: visibility, not atomicity

A volatile field is always read from and written to main memory, so every thread sees the latest value. It's right for simple flags. It does not make compound actions like count++ atomic; use AtomicInteger or a lock for those.

Java
private volatile boolean running = true;

void stop() { running = false; }          // other threads see this immediately
void loop() { while (running) doWork(); }

The Java Memory Model and happens-before

Without synchronization, one thread may never see another's writes, or may see them out of order. Happens-before rules guarantee visibility: releasing a lock happens-before the next acquire of it; a volatile write happens-before later reads of it; Thread.start() happens-before the thread's actions; and a thread's actions happen-before another thread's successful join() on it.

Deadlock and how to avoid it

Deadlock happens when two threads each hold a lock the other needs. Prevent it by always taking locks in the same order, holding locks briefly, avoiding calls to unknown code while holding a lock, or using tryLock with a timeout.

wait and notify

wait() releases the lock and sleeps until another thread calls notify()/notifyAll(). Always call wait in a loop that re-checks the condition. In new code, prefer BlockingQueue, CountDownLatch or Condition, which are far easier to get right.

ReentrantLock, ReadWriteLock and StampedLock

java.util.concurrent.locks offers more control than synchronized: tryLock with timeouts, interruptible waiting, fairness and several Conditions. ReentrantReadWriteLock lets many readers in at once but only one writer. Always unlock in finally.

Java
private final ReentrantLock lock = new ReentrantLock();

void transfer(Account from, Account to, long paise) throws InterruptedException {
    if (lock.tryLock(1, TimeUnit.SECONDS)) {
        try {
            from.debit(paise);
            to.credit(paise);
        } finally {
            lock.unlock();
        }
    } else {
        throw new IllegalStateException("busy, try again");
    }
}

ThreadLocal and ScopedValue

A ThreadLocal gives each thread its own copy of a value, such as the current user or request id. In thread pools, always remove() it when done, or the value leaks into the next task. Java 25 finalised ScopedValue, a safer, immutable alternative that works well with virtual threads.

Java
private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();

void handle(Request r) {
    REQUEST_ID.set(r.id());
    try { process(r); } finally { REQUEST_ID.remove(); }
}

static final ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "asha").run(() -> service.load());   // Java 25

Example

Java
class Counter {
    private int unsafe = 0;
    private final AtomicInteger safe = new AtomicInteger();
    private volatile boolean running = true;         // visibility flag only

    void hit() {
        unsafe++;                  // read-modify-write: updates get lost
        safe.incrementAndGet();    // atomic
    }

    private final ReentrantLock lock = new ReentrantLock();

    void transfer(Account from, Account to, int amount) throws InterruptedException {
        if (lock.tryLock(1, TimeUnit.SECONDS)) {
            try {
                from.debit(amount);
                to.credit(amount);
            } finally {
                lock.unlock();                        // always unlock in finally
            }
        }
    }
}

Common mistake

Using volatile for a counter that many threads increment. volatile doesn't make count++ atomic; use AtomicInteger or LongAdder.

Under the hood

The Java Memory Model defines "happens-before" rules: unlocking a monitor happens-before the next lock of the same monitor, and a volatile write happens-before every later read of it. Without such rules, the CPU and JIT may reorder instructions. The usual fix for deadlock is to always acquire locks in one fixed global order. Since Java 24, virtual threads no longer pin their carrier thread while inside synchronized blocks.

Check yourself

Is count++ on a volatile int thread-safe?

How this connects

Part of Crack the Java interview.

Was this lesson helpful?

Finished reading? Mark it complete to track your progress.