Concurrency Utilities
Reviewed & published by Brayan K
The java.util.concurrent toolkit lets many threads share data safely without you hand-writing locks. Learn atomics, concurrent collections, blocking queues, and the coordination tools (latches, barriers, semaphores, locks) the pros reach for.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
You should already understand Multithreading — creating threads, synchronized, and race conditions. This lesson gives you sharper, ready-made tools from java.util.concurrent so you rarely have to write low-level locking by hand.
What You'll Learn in This Lesson
- Use AtomicInteger to count across threads without locks
- Pick ConcurrentHashMap and CopyOnWriteArrayList over unsafe collections
- Pass work safely between threads with a BlockingQueue
- Wait for tasks with CountDownLatch and sync phases with CyclicBarrier
- Limit concurrent access with a Semaphore
- Use ReentrantLock and ReadWriteLock the right way (unlock in finally)
🏢 Real-World Analogy: An Office Building
Imagine threads as employees sharing one building, and the concurrency utilities as the building's systems that keep everyone safe and orderly:
- AtomicInteger is the turnstile counter at the door — every entry clicks it up by one, and two people pushing through at once can never make it lose a count.
- ConcurrentHashMap / CopyOnWriteArrayList are filing cabinets built so many clerks can use them at once without jamming or tearing pages.
- BlockingQueue is the mail room conveyor — senders drop parcels on, receivers take them off, and each side simply waits if the belt is full or empty.
- Semaphore is the car park barrier — only N cars allowed in; arrival N+1 waits for someone to leave.
- CountDownLatch is the launch gate that opens once three setup crews each report "done."
- CyclicBarrier is a meeting room where everyone waits for the whole team before each phase — and it is reused every phase.
- ReentrantLock / ReadWriteLock are keycards for a room: one writer gets exclusive access, while many readers can browse together.
1️⃣ Atomics — Lock-Free Counters
The classic concurrency bug is count++. It looks like one step but it is really three: read the value, add one, write it back. If two threads read the same value before either writes, one update is silently lost.
An atomic class fixes this. AtomicInteger.incrementAndGet() performs the whole read-modify-write as a single, uninterruptible operation — no lock, no lost updates. There are matching AtomicLong, AtomicBoolean, and AtomicReference types too.
Run the worked example below. The plain int almost always finishes below 200,000 because updates collide; the AtomicInteger always lands exactly on 200,000.
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
// A plain int is NOT thread-safe: count++ is really read, add, write —
// three steps. Two threads can read the same value and one update is lost.
static int unsafeCount = 0;
// AtomicInteger does the whole "read-modify-write" as ONE atomic step,
// with no lock needed. incrementAndGet() can't be interrupted halfway.
static AtomicInteger safeCount = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Runnable bumpUnsafe = () -> { for (int i = 0; i < 100_000; i++) unsafeCount++; };
Runnable bumpSafe = () -> { for (int i = 0; i < 100_000; i++) safeCount.incrementAndGet(); };
Thread t1 = new Thread(bumpUnsafe), t2 = new Thread(bumpUnsafe);
Thread t3 = new Thread(bumpSafe), t4 = new Thread(bumpSafe);
t1.start(); t2.start(); t3.start(); t4.start();
t1.join(); t2.join(); t3.join(); t4.join(); // wait for all four
// Expected total for each = 2 threads * 100,000 = 200,000.
System.out.println("Unsafe int total : " + unsafeCount + " (usually < 200000 — lost updates!)");
System.out.println("Atomic total : " + safeCount.get() + " (always 200000)");
// A few other atomic operations:
AtomicInteger a = new AtomicInteger(10);
System.out.println("getAndAdd(5) : returns " + a.getAndAdd(5) + ", now " + a.get());
System.out.println("compareAndSet : " + a.compareAndSet(15, 99) + ", now " + a.get());
}
}2️⃣ Concurrent Collections
A plain HashMap or ArrayList is not thread-safe. Used from several threads at once it can corrupt its internal structure, lose data, or throw ConcurrentModificationException. The java.util.concurrent package gives you drop-in safe replacements.
- ConcurrentHashMap — the default shared map. It locks only small slices internally, so many threads read and write different keys simultaneously. Use merge() or compute() to update a key atomically.
- CopyOnWriteArrayList — every write makes a fresh copy of the backing array. That is expensive for frequent writes but perfect when reads vastly outnumber writes (event listeners, config snapshots) because iteration never needs a lock and never throws.
import java.util.Map;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
public class Main {
public static void main(String[] args) throws InterruptedException {
System.out.println("=== Thread-safe collections ===\n");
// 1. ConcurrentHashMap — many threads can read/write at once safely.
// A plain HashMap can corrupt or throw under concurrent writes.
ConcurrentHashMap<String, Integer> votes = new ConcurrentHashMap<>();
Runnable voter = () -> {
for (int i = 0; i < 1000; i++) {
// merge() does the whole "get + add + put" atomically per key.
votes.merge("apples", 1, Integer::sum);
}
};
Thread v1 = new Thread(voter), v2 = new Thread(voter);
v1.start(); v2.start(); v1.join(); v2.join();
System.out.println("ConcurrentHashMap votes for apples: " + votes.get("apples"));
// 2. CopyOnWriteArrayList — every write copies the array.
// Great when reads vastly outnumber writes (e.g. listeners).
List<String> listeners = new CopyOnWriteArrayList<>();
listeners.add("logger");
listeners.add("metrics");
// Safe to iterate even if another thread adds during the loop —
// the iterator sees a stable snapshot, never ConcurrentModificationException.
for (String name : listeners) {
System.out.println("Notifying listener: " + name);
}
System.out.println("Listener count: " + listeners.size());
}
}3️⃣ BlockingQueue — Producer / Consumer
A BlockingQueue is the cleanest way to hand work from one thread to another. put() blocks the producer when the queue is full; take() blocks the consumer when it is empty. That built-in waiting means you never busy-loop and never write your own wait()/notify().
ArrayBlockingQueue has a fixed capacity (good for back-pressure); LinkedBlockingQueue can be unbounded. A common pattern is to send a special "poison pill" value (here, "DONE") to tell the consumer to stop.
🎯 Your Turn #1 — Thread-Safe Page Counter
Fill in the three blanks so two threads can count page hits without losing any. Each thread adds 50,000, so the total must be exactly 100,000. Hints are on each line after // 👉.
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
// 🎯 YOUR TURN — fill in the blanks marked with ___
public static void main(String[] args) throws InterruptedException {
// 1) Create a shared counter that is safe across threads.
AtomicInteger hits = new ___; // 👉 new AtomicInteger(0)
// 2) Two threads each add 50,000 page hits.
Runnable visit = () -> {
for (int i = 0; i < 50_000; i++) {
hits.___; // 👉 incrementAndGet() (atomic +1)
}
};
Thread a = new Thread(visit), b = new Thread(visit);
a.start(); b.start();
a.join(); b.join(); // wait for both before reading
// 3) Read the final value with get().
System.out.println("Total hits: " + hits.___); // 👉 get()
// ✅ Expected output: Total hits: 100000
}
}4️⃣ Coordinating Threads: Semaphore, CountDownLatch, CyclicBarrier
These three tools let threads agree on timing without sharing data:
- Semaphore hands out a fixed number of permits. acquire() takes one (blocking if none are free), release() returns one. Perfect for connection pools and rate limiting. tryAcquire() returns false instead of blocking.
- CountDownLatch is a one-shot gate. You set a count, threads call countDown(), and whoever called await() stays blocked until the count hits zero. Once open it cannot be reset.
- CyclicBarrier is a reusable rendezvous. A fixed number of threads each call await(); when the last one arrives they are all released together, then the barrier resets for the next phase.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.Semaphore;
public class Main {
public static void main(String[] args) throws InterruptedException {
System.out.println("=== Coordinating threads ===\n");
// 1. Semaphore — a pool of 3 permits (e.g. DB connections).
// tryAcquire() returns false instead of blocking when none are free.
System.out.println("1. SEMAPHORE (pool of 3):");
Semaphore pool = new Semaphore(3);
for (String c : new String[]{"A", "B", "C", "D"}) {
boolean got = pool.tryAcquire();
System.out.println(" Conn-" + c + ": " + (got ? "acquired" : "WAIT (full)")
+ " — free: " + pool.availablePermits());
}
pool.release(); // give one permit back
System.out.println(" Released one — free: " + pool.availablePermits());
// 2. CountDownLatch — ONE-SHOT gate. Main waits for 3 startup tasks.
System.out.println("\n2. COUNTDOWNLATCH (server startup):");
CountDownLatch latch = new CountDownLatch(3);
for (String task : new String[]{"Database", "Cache", "Config"}) {
new Thread(() -> {
System.out.println(" " + task + " ready");
latch.countDown(); // -1 from the count
}).start();
}
latch.await(); // blocks until count hits 0
System.out.println(" All ready — server up! (count=" + latch.getCount() + ")");
// 3. CyclicBarrier — REUSABLE. All 3 workers meet, then the barrier resets.
System.out.println("\n3. CYCLICBARRIER (3 workers, 2 phases):");
CyclicBarrier barrier = new CyclicBarrier(3,
() -> System.out.println(" -- all arrived, phase complete --"));
for (int w = 1; w <= 3; w++) {
int id = w;
new Thread(() -> {
try {
System.out.println(" Worker " + id + " phase 1 done");
barrier.await(); // wait for the other two
System.out.println(" Worker " + id + " phase 2 done");
barrier.await(); // barrier reused — that's "cyclic"
} catch (Exception e) { Thread.currentThread().interrupt(); }
}).start();
}
Thread.sleep(200); // give the demo threads time to finish printing
}
}5️⃣ ReentrantLock & ReadWriteLock
synchronized is fine for simple cases, but a ReentrantLock gives you more control: a timeout with tryLock(), the ability to interrupt a waiting thread, and fairness options. "Reentrant" means the same thread can lock it again — the lock counts holds and only frees when the count returns to zero.
The golden rule: a ReentrantLock does not release itself. You must call unlock(), and it must live in a finally block so it runs even if an exception is thrown.
A ReadWriteLock splits locking into two: many threads can hold the read lock at once, but the write lock is exclusive. That is a big win for data read far more often than it is written.
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Main {
public static void main(String[] args) {
System.out.println("=== Locks ===\n");
// 1. ReentrantLock — the SAME thread can lock again (reentrant).
// getHoldCount() tracks how many times it has locked.
System.out.println("1. REENTRANTLOCK:");
ReentrantLock lock = new ReentrantLock();
lock.lock(); // acquire
try {
System.out.println(" Locked (hold count: " + lock.getHoldCount() + ")");
lock.lock(); // same thread re-enters
try {
System.out.println(" Re-entered (hold count: " + lock.getHoldCount() + ")");
} finally {
lock.unlock(); // each lock needs an unlock
System.out.println(" Inner unlock (hold count: " + lock.getHoldCount() + ")");
}
} finally {
lock.unlock(); // ALWAYS unlock in finally
System.out.println(" Outer unlock (held by us? " + lock.isHeldByCurrentThread() + ")");
}
// 2. ReadWriteLock — MANY readers OR ONE writer (never both).
System.out.println("\n2. READWRITELOCK:");
ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
rw.readLock().lock();
rw.readLock().lock(); // a second concurrent read is allowed
System.out.println(" Read locks held: " + rw.getReadLockCount());
System.out.println(" Write locked? " + rw.isWriteLocked() + " (writers wait for readers)");
rw.readLock().unlock();
rw.readLock().unlock();
rw.writeLock().lock(); // exclusive — no readers now
try {
System.out.println(" Write lock acquired? " + rw.isWriteLocked());
} finally {
rw.writeLock().unlock();
}
}
}🎯 Your Turn #2 — Guard a Bank Balance
Fill in the two blanks so the withdraw method locks before touching the shared balance and always unlocks afterwards. Notice the unlock() belongs in the finally block.
import java.util.concurrent.locks.ReentrantLock;
public class Main {
// 🎯 YOUR TURN — protect a shared balance with a lock
static int balance = 100;
static ReentrantLock lock = new ReentrantLock();
static void withdraw(int amount) {
// 1) Acquire the lock before touching shared state.
lock.___; // 👉 lock()
try {
if (balance >= amount) {
balance -= amount;
System.out.println("Withdrew " + amount + ", balance: " + balance);
}
} finally {
// 2) ALWAYS release in finally, even if an exception is thrown.
lock.___; // 👉 unlock()
}
}
public static void main(String[] args) {
withdraw(30);
withdraw(50);
// ✅ Expected output:
// Withdrew 30, balance: 70
// Withdrew 50, balance: 20
}
}🧗 Mini-Challenge — Wait for the Crew
Now write it yourself. You only get a comment outline — no filled-in logic. Use a CountDownLatch of 3, start three worker threads that each print and count down, then print a final message after await().
import java.util.concurrent.CountDownLatch;
public class Main {
public static void main(String[] args) throws InterruptedException {
// 🎯 MINI-CHALLENGE: Wait for a worker crew to finish
// 1. Create a CountDownLatch starting at 3.
// 2. Start 3 threads. Each prints "Worker N finished" then counts down.
// 3. After latch.await(), print "All workers done!"
//
// ✅ Expected (order of "Worker" lines may vary):
// Worker 1 finished
// Worker 2 finished
// Worker 3 finished
// All workers done!
// your code here
}
}Common Errors (and the Fix)
- ❌ Sharing a non-thread-safe collection: using a plain HashMap from many threads can throw ConcurrentModificationException or silently corrupt data. Fix: use ConcurrentHashMap (or CopyOnWriteArrayList for read-heavy lists).
- ❌ Lock not released in finally: calling lock.lock() and then throwing before unlock() leaves the lock held forever — other threads hang. Fix: always lock(), then try { ... } finally { lock.unlock(); }.
- ❌ Atomic vs synchronized confusion: an AtomicInteger makes one variable safe, not a whole block. Reading two atomics and combining them is still racy. Fix: use a lock or synchronized when several updates must happen together as one unit.
- ❌ Calling unlock() without holding the lock: an extra unlock() throws IllegalMonitorStateException. Fix: pair exactly one unlock() with each lock() (re-entrant locks count holds).
- ❌ Reusing a CountDownLatch: once its count hits zero it stays open and cannot be reset, so a second phase never blocks. Fix: use a CyclicBarrier for repeated sync points.
Pro Tips
💡 tryLock() with a timeout avoids deadlocks: if (lock.tryLock(5, TimeUnit.SECONDS)) { ... } gives up rather than waiting forever.
💡 StampedLock (Java 8+) can beat ReadWriteLock by allowing optimistic reads that take no lock at all.
💡 LongAdder outperforms AtomicLong under very high contention because it spreads the count across cells.
💡 Prefer the high-level tools. A BlockingQueue or ExecutorService is almost always clearer and safer than hand-rolled wait()/notify().
📋 Quick Reference
| Utility | Key API | Use Case |
|---|---|---|
| AtomicInteger | incrementAndGet() / get() | Lock-free shared counter |
| ConcurrentHashMap | merge() / compute() | Shared map, many writers |
| CopyOnWriteArrayList | add() / iterate | Read-heavy list (listeners) |
| BlockingQueue | put() / take() | Producer / consumer handoff |
| Semaphore | acquire() / release() | Limit concurrent access |
| CountDownLatch | countDown() / await() | Wait for N completions (once) |
| CyclicBarrier | await() | Reusable phase rendezvous |
| ReentrantLock | lock() / unlock() | Flexible mutual exclusion |
| ReadWriteLock | readLock() / writeLock() | Read-heavy concurrency |
🎉 Lesson Complete!
You can now reach for the right tool from java.util.concurrent: atomics for lock-free counters, concurrent collections instead of unsafe ones, a BlockingQueue to pass work between threads, and latches, barriers, semaphores, and locks to coordinate timing — always unlocking in a finally.
Practice quiz
Why is a plain int count++ unsafe across threads?
- int is too small to hold the value
- the JVM forbids sharing primitives
- count++ is really read-modify-write, so concurrent updates can be lost
- ++ is not defined for fields
Answer: count++ is really read-modify-write, so concurrent updates can be lost. count++ is three steps (read, add, write); two threads can read the same value and one update is silently lost.
After new AtomicInteger(10), what does getAndAdd(5) return and what is the new value?
- returns 10, value 15
- returns 15, value 15
- returns 15, value 10
- returns 10, value 10
Answer: returns 10, value 15. getAndAdd returns the previous value (10) then adds, leaving 15. Verified by running it on Java 21.
What does ConcurrentHashMap.merge("apples", 1, Integer::sum) do under concurrent access?
- Replaces the value with 1
- Throws ConcurrentModificationException
- Locks the entire map for every call
- Atomically does get + add + put for that key
Answer: Atomically does get + add + put for that key. merge performs the read-add-write for a key atomically, so two threads each merging 1000 times yield 2000.
When is CopyOnWriteArrayList the right choice?
- When writes vastly outnumber reads
- When reads vastly outnumber writes (e.g. listeners)
- When you need sorted order
- When the list must be fixed size
Answer: When reads vastly outnumber writes (e.g. listeners). Every write copies the backing array (expensive), but iteration never locks or throws, which suits read-heavy data like listener lists.
In a BlockingQueue, what does put() do when the queue is full?
- blocks until space is free
- throws an exception
- returns false
- overwrites the oldest element
Answer: blocks until space is free. put() blocks the producer when full and take() blocks the consumer when empty, giving built-in back-pressure with no busy-waiting.
Why must a ReentrantLock be unlocked in a finally block?
- finally runs faster
- It is required to compile
- It does not release automatically, so an exception before unlock() would leave it held forever
- finally re-enters the lock
Answer: It does not release automatically, so an exception before unlock() would leave it held forever. Unlike synchronized, a ReentrantLock isn't released on method exit; a finally block guarantees unlock() runs on every path out.
What is the key difference between CountDownLatch and CyclicBarrier?
- A latch is reusable; a barrier is one-shot
- A latch is one-shot and cannot reset; a barrier is reusable across phases
- They are identical
- Only a barrier blocks threads
Answer: A latch is one-shot and cannot reset; a barrier is reusable across phases. Once a CountDownLatch hits zero it stays open; a CyclicBarrier releases all waiters when they arrive and then resets for the next phase.
What does Semaphore.tryAcquire() do when no permits are available?
- blocks until one is free
- throws InterruptedException
- creates a new permit
- returns false instead of blocking
Answer: returns false instead of blocking. tryAcquire() returns false immediately when no permit is free, unlike acquire() which blocks.
What does it mean that ReentrantLock is reentrant?
- Any thread can unlock it
- The thread already holding it can acquire it again without deadlocking
- It can only be locked once
- It automatically retries on failure
Answer: The thread already holding it can acquire it again without deadlocking. The holding thread can lock again; a hold count rises on each lock() and the lock frees only when the count returns to zero.
With a ReadWriteLock, which combination is allowed at the same time?
- Many readers AND many writers
- One reader and one writer together
- Many readers OR one writer (never both)
- Only one reader at a time
Answer: Many readers OR one writer (never both). A ReadWriteLock permits multiple concurrent readers or a single exclusive writer, but never readers and a writer simultaneously.
Continue this course
- Previous: Multithreading in Java: Threads, Runnables & Executors
- Next: CompletableFuture & Asynchronous Programming — Chain async operations and handle results with CompletableFuture
- Quick reference: Java cheat sheet
- From the blog: Java Collections Framework: A Deep Dive
Frequently asked questions
When should I use AtomicInteger instead of synchronized?
Use an atomic class when all you need is a single counter or flag updated by many threads. An AtomicInteger turns read-modify-write (like count++) into one uninterruptible CPU instruction with no lock, so it is faster and simpler than a synchronized block. Reach for synchronized or a ReentrantLock only when you must update several fields together as one consistent unit.
What is the difference between ConcurrentHashMap and Collections.synchronizedMap?
Collections.synchronizedMap wraps a HashMap and locks the whole map on every operation, so only one thread works at a time. ConcurrentHashMap splits the map internally so many threads can read and write different keys at once, giving far better throughput. ConcurrentHashMap is the modern default for shared maps.
Why must I always unlock() in a finally block?
Unlike synchronized, a ReentrantLock does not release automatically when a method exits or throws. If an exception fires between lock() and unlock() and you did not put unlock() in finally, the lock is never released and every other thread waiting on it hangs forever. The finally block guarantees release on every path out of the method.
What is the difference between CountDownLatch and CyclicBarrier?
A CountDownLatch is one-shot: once its count reaches zero it stays open and cannot be reset, so it is ideal for waiting until N startup tasks finish once. A CyclicBarrier is reusable: it releases all waiting threads when they all arrive, then automatically resets for the next phase, which is why it suits repeated multi-phase computations.
What does it mean that ReentrantLock is reentrant?
Reentrant means the thread that already holds the lock can acquire it again without deadlocking itself. The lock keeps a hold count that goes up on each lock() and down on each unlock(); the lock is only truly released when the count returns to zero. This lets a synchronized method safely call another method that locks the same object.