On this page
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:
- Which tool for which job.
ReentrantLock: a lock with extra options, and the rule you can't break.- Read-write locks: many readers, one writer.
- Atomic variables: updates without locks.
- Latches, barriers and semaphores: coordinating groups of threads.
Which tool for which job
| You need | Use |
|---|---|
| Plain mutual exclusion | synchronized (simplest, can't be left locked) |
| A lock with a timeout, interruptible waiting, or fairness | ReentrantLock |
| Many readers at once, occasional writers | ReentrantReadWriteLock |
| A single counter or reference updated by many threads | AtomicInteger, AtomicLong, AtomicReference |
| A very hot counter (metrics) | LongAdder |
| Wait until N things have happened, once | CountDownLatch |
| N threads meet at a point, round after round | CyclicBarrier |
| At most N threads doing something at a time | Semaphore |
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()ortryLock(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 entersynchronizedcan'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:
- 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 thegetand theset, and you'll overwrite it. - Keep update functions pure. The function you pass to
updateAndGetoraccumulateAndGetmay 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 callawait()and wait untilcountDown()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 callawait(), 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), andrelease()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
| Mistake | What happens | Do this instead |
|---|---|---|
lock() without try/finally | an exception leaves the lock held forever | lock(); try { ... } finally { unlock(); } |
| Taking the write lock while holding the read lock | waits forever | release the read lock first, then recheck |
get() then set() on an atomic | lost updates | updateAndGet, accumulateAndGet, compareAndSet |
Side effects in updateAndGet | they run more than once | a pure function |
LongAdder.sum() as an exact value | it can be slightly stale | AtomicLong when you need exact |
Reusing a CountDownLatch | await() returns immediately | a new latch, or a CyclicBarrier |
release() without a successful acquire | extra permits appear | release 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
synchronizedby default;ReentrantLockwhen you need timeouts, interruptible waits, fairness or conditions, always withunlock()infinally. - 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.
LongAdderfor hot counters. - Latch: once. Barrier: every round. Semaphore: at most N at a time, released exactly once after acquiring.