Java · Java Concurrency (java.util.concurrent)

Java: Locks, Atomics and Synchronizers

ReentrantLock, Conditions and read-write locks, compare-and-set and why update functions must be pure, LongAdder for hot counters, and latches, barriers and semaphores.

  • 6 min read
  • 13 practice questions
  • 1 graded problem
On this page
  1. Description
  2. Which tool for which job
  3. ReentrantLock
  4. Read-write locks
  5. Atomic variables
  6. Latches, barriers and semaphores
  7. Common mistakes
  8. Check yourself
  9. Key takeaways
  10. Quick revise
  11. Practice

Description

synchronized is Java's all-purpose lock, and it covers most needs. But some jobs call for a more specialized tool: a lock you can give up on after half a second, one that lets many readers in at once, a counter that thousands of threads can bump without waiting in line, or a gate that holds threads back until a group of them is ready. The java.util.concurrent package has all of these, and backend code uses them constantly, often without the author knowing exactly how they behave.

Each tool solves a specific problem, and each comes with its own way to hurt yourself. This skill covers what each is for, and its one big trap.

Here's the path, from easiest to most technical:

  1. Which tool for which job.
  2. ReentrantLock: a lock with extra options, and the rule you can't break.
  3. Read-write locks: many readers, one writer.
  4. Atomic variables: updates without locks.
  5. Latches, barriers and semaphores: coordinating groups of threads.

Which tool for which job

You needUse
Plain mutual exclusionsynchronized (simplest, can't be left locked)
A lock with a timeout, interruptible waiting, or fairnessReentrantLock
Many readers at once, occasional writersReentrantReadWriteLock
A single counter or reference updated by many threadsAtomicInteger, AtomicLong, AtomicReference
A very hot counter (metrics)LongAdder
Wait until N things have happened, onceCountDownLatch
N threads meet at a point, round after roundCyclicBarrier
At most N threads doing something at a timeSemaphore

ReentrantLock

A ReentrantLock does what synchronized does, but as an object you call methods on. That gives you options, and one responsibility: you must unlock it yourself, always in a finally block.

private final ReentrantLock lock = new ReentrantLock();

void post(Payment p) {
    lock.lock();
    try {
        validate(p);          // may throw
        ledger.post(p);
    } finally {
        lock.unlock();        // runs however the try block ends
    }
}

Without the finally, the first payment that fails validation leaves the lock held forever, by a thread that has long since moved on, and every later payment waits for good. synchronized can't have this bug, because the JVM releases it automatically.

What you get in return:

  • tryLock() or tryLock(500, TimeUnit.MILLISECONDS): give up instead of waiting forever. Useful for avoiding deadlocks, or for "skip this round if someone else is already doing it".
  • lockInterruptibly(): waiting for the lock can be interrupted. A thread waiting to enter synchronized can't be.
  • new ReentrantLock(true): a fair lock, where the longest-waiting thread goes next (a bit slower).
  • Conditions: several separate waiting rooms on one lock, which is how a bounded queue can wake only producers or only consumers (the previous skill showed this).

Start with synchronized. Reach for ReentrantLock when you need one of these features.

Read-write locks

A ReentrantReadWriteLock is a pair of locks. Any number of threads can hold the read lock at the same time, but the write lock is exclusive: no readers and no other writers. It helps when data is read far more often than it's changed, and reading takes real work.

Its trap is upgrading. A thread that holds the read lock and then asks for the write lock waits for every reader to leave, including itself, so it waits forever. To change data you've been reading: release the read lock, take the write lock, and check the data again, because another writer may have changed it in between.

Atomic variables

AtomicInteger, AtomicLong and AtomicReference update a single value safely without any lock. They use a CPU instruction called compare-and-set (CAS): "set the value to X, but only if it's still Y". If another thread changed it in the meantime, the update fails and is retried.

AtomicLong requests = new AtomicLong();
requests.incrementAndGet();                       // a safe count++

AtomicInteger maxLatency = new AtomicInteger();
maxLatency.accumulateAndGet(sample, Math::max);   // a safe running maximum

Two rules:

  1. Do the whole update in one atomic method. if (max.get() < sample) max.set(sample); is still a race: another thread can write a bigger value between the get and the set, and you'll overwrite it.
  2. Keep update functions pure. The function you pass to updateAndGet or accumulateAndGet may run more than once for a single update, because of the retries. No logging, no counting, no I/O inside it.

For a counter that many threads hit constantly, like a request counter, use LongAdder. It spreads increments over several internal cells, so threads rarely collide, and adds them up when you call sum(). That sum isn't an exact snapshot while increments are still happening, so it's for metrics, not for IDs or limits.

Latches, barriers and semaphores

Three tools coordinate groups of threads:

  • CountDownLatch(n): threads call await() and wait until countDown() has been called n times. It's one-shot: once it reaches zero, it stays there. Use it to wait until several services have started, or to release several threads at the same moment.
  • CyclicBarrier(n): n threads each call await(), and all of them wait until the last one arrives; then they all continue, and the barrier resets for the next round. Use it for work done in phases.
  • Semaphore(n): hands out at most n permits. acquire() takes one (waiting if none are left), and release() gives one back. Use it to limit how many threads do something at once, like making at most 10 calls to a fragile service.

The semaphore's trap: permits aren't tied to the thread that took them, and release() always adds one, even if the caller never acquired. Release exactly once, and only after a successful acquire:

if (!permits.tryAcquire(100, TimeUnit.MILLISECONDS)) {
    return fallback();                 // no permit: don't release anything
}
try {
    return callService();
} finally {
    permits.release();                 // exactly once, however the call ends
}

Common mistakes

MistakeWhat happensDo this instead
lock() without try/finallyan exception leaves the lock held foreverlock(); try { ... } finally { unlock(); }
Taking the write lock while holding the read lockwaits foreverrelease the read lock first, then recheck
get() then set() on an atomiclost updatesupdateAndGet, accumulateAndGet, compareAndSet
Side effects in updateAndGetthey run more than oncea pure function
LongAdder.sum() as an exact valueit can be slightly staleAtomicLong when you need exact
Reusing a CountDownLatchawait() returns immediatelya new latch, or a CyclicBarrier
release() without a successful acquireextra permits appearrelease only in the finally after acquiring

Check yourself

Try to answer before opening each one. Recalling beats re-reading.

1. Why must unlock() go in a finally block, when synchronized needs nothing like it?

Show answer

The JVM releases a synchronized lock however the block ends. Nothing releases a ReentrantLock except your call to unlock(), so an exception between lock() and unlock() leaves it held forever.

2. What's wrong with if (max.get() < sample) max.set(sample); on an AtomicInteger?

Show answer

Another thread can set a bigger value between the get and the set, and this code then overwrites it with a smaller one. Use max.accumulateAndGet(sample, Math::max), which does the whole update atomically.

3. Five workers must all finish phase 1 before any starts phase 2, for 10 phases. Latch or barrier?

Show answer

A CyclicBarrier(5): it releases all five when the last one arrives, then resets for the next phase. A CountDownLatch only works once.

4. A semaphore limiting calls to 10 somehow lets 11 through. What's the likely bug?

Show answer

Some path calls release() without having acquired a permit, for example releasing in a finally even when tryAcquire failed. Each stray release adds a permit for good.

Key takeaways

  • Use synchronized by default; ReentrantLock when you need timeouts, interruptible waits, fairness or conditions, always with unlock() in finally.
  • Read-write locks let readers share, but never upgrade a read lock to a write lock.
  • Atomics: one atomic method per update, with pure update functions. LongAdder for hot counters.
  • Latch: once. Barrier: every round. Semaphore: at most N at a time, released exactly once after acquiring.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

NeedToolWatch out for
Mutual exclusionsynchronizednothing extra: released automatically
Timeout, interruptible wait, fairness, conditionsReentrantLockunlock() in finally, always
Many readers, few writersReentrantReadWriteLocknever upgrade read → write
Hot read path, rare writesStampedLock optimistic readvalidate before use; not reentrant
One shared valueAtomicInteger/Long/Referenceone atomic method per update; pure functions
Hot counterLongAddersum() isn't a snapshot
Wait for N events, onceCountDownLatchone-shot
N threads meet, every roundCyclicBarrierresets after each round
At most N at a timeSemaphorerelease once, only after acquiring
Barrier with changing partiesPhaser

Patterns to know by heart

lock.lock();
try { /* work */ } finally { lock.unlock(); }

if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
    try { /* work */ } finally { lock.unlock(); }
} else { /* back off */ }

max.accumulateAndGet(sample, Math::max);        // not get() then set()

if (!permits.tryAcquire(100, TimeUnit.MILLISECONDS)) return fallback();
try { return call(); } finally { permits.release(); }

The traps

  • lock() with no finally: one exception leaves it locked forever.
  • Write lock requested while holding the read lock: waits forever.
  • get()-then-set() on atomics; logging or counting inside updateAndGet.
  • Reusing a CountDownLatch.
  • release() on a path that never acquired: permits multiply.

Staff-level points, one line each

  • AQS underneath: a CAS'd int state + a queue of parked threads; waiters show WAITING, not BLOCKED.
  • Lock state: hold count; semaphore: permits; latch: count; read-write: 16 bits each (max 65,535 read holds).
  • Unfair (default) locks let arriving threads barge: higher throughput; fair locks prevent starvation; tryLock() barges even on a fair lock.
  • CAS = lock cmpxchg (x86), LL/SC or LSE CAS (ARM64); contention means retries and cache-line bouncing.
  • ABA: the value returns to A and CAS succeeds; AtomicStampedReference adds a version.
  • weakCompareAndSet may fail spuriously: only use it in a retry loop.
  • LongAdder: base + @Contended cells chosen by a per-thread probe, grown up to the CPU count.
  • StampedLock optimistic reads: no shared writes by readers; validate; no reentrancy, no conditions.
  • On Java 21, synchronized pins virtual threads around blocking calls; ReentrantLock doesn't (fixed in JDK 24, JEP 491).

Say it in an interview

  • "I'd use synchronized unless I need a timeout or interruptible waiting, then ReentrantLock with unlock in finally."
  • "For a request counter I'd use LongAdder; for an ID generator, AtomicLong, since I need exact values."
  • "A semaphore with tryAcquire and a fallback gives us a bulkhead around the payment service."

Practice

This skill comes with 13 multiple-choice questions that tell you which misconception a wrong answer reveals and 1 graded problem. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Java: Locks, Atomics and Synchronizers free

A Bulkhead for a Fragile Service

MediumProCoding problemJava

A travel site in Denver calls a fragile airline-pricing service that falls over if it gets more than a few requests at once. The team wraps every call in a bulkhead: a limit on how many calls can be in flight, so a burst of traffic can't overwhelm the service, and so a slow…

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Java: Locks, Atomics and Synchronizers free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime