Lesson 27 • Advanced
Concurrency Utilities
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.
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());
}
}Unsafe int total : 137204 (usually < 200000 — lost updates!)
Atomic total : 200000 (always 200000)
getAndAdd(5) : returns 10, now 15
compareAndSet : true, now 992️⃣ 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()orcompute()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());
}
}=== Thread-safe collections ===
ConcurrentHashMap votes for apples: 2000
Notifying listener: logger
Notifying listener: metrics
Listener count: 23️⃣ 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.
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class Main {
public static void main(String[] args) throws InterruptedException {
System.out.println("=== Producer / Consumer with BlockingQueue ===\n");
// A BlockingQueue hands work safely from producers to consumers.
// put() blocks if the queue is full; take() blocks if it is empty —
// so you never busy-wait and never need manual locks.
BlockingQueue<String> queue = new ArrayBlockingQueue<>(2); // capacity 2
Thread producer = new Thread(() -> {
String[] orders = {"Pizza", "Burger", "Salad", "DONE"};
for (String order : orders) {
try {
queue.put(order); // waits if queue is full
System.out.println("Produced: " + order);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
});
Thread consumer = new Thread(() -> {
try {
String order;
while (!(order = queue.take()).equals("DONE")) { // waits if empty
System.out.println(" Consumed: " + order);
}
System.out.println(" Got DONE — kitchen closing.");
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
producer.start();
consumer.start();
producer.join();
consumer.join();
System.out.println("All orders handled.");
}
}=== Producer / Consumer with BlockingQueue ===
Produced: Pizza
Produced: Burger
Consumed: Pizza
Consumed: Burger
Produced: Salad
Produced: DONE
Consumed: Salad
Got DONE — kitchen closing.
All orders handled.java.util.concurrent. Run it free at onecompiler.com/java. Because the producer and consumer interleave, the exact ordering of "Produced"/"Consumed" lines can vary slightly between runs.🎯 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()returnsfalseinstead of blocking. - CountDownLatch is a one-shot gate. You set a count, threads call
countDown(), and whoever calledawait()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
}
}=== Coordinating threads ===
1. SEMAPHORE (pool of 3):
Conn-A: acquired — free: 2
Conn-B: acquired — free: 1
Conn-C: acquired — free: 0
Conn-D: WAIT (full) — free: 0
Released one — free: 1
2. COUNTDOWNLATCH (server startup):
Database ready
Cache ready
Config ready
All ready — server up! (count=0)
3. CYCLICBARRIER (3 workers, 2 phases):
Worker 1 phase 1 done
Worker 2 phase 1 done
Worker 3 phase 1 done
-- all arrived, phase complete --
Worker 3 phase 2 done
Worker 1 phase 2 done
Worker 2 phase 2 done
-- all arrived, phase complete --java.util.concurrent. Run it free at onecompiler.com/java. The Semaphore section is deterministic; the latch and barrier sections run on separate threads, so lines within each phase may print in any order.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.
try { ... } finally { lock.unlock(); }. If you skip the finally and an exception escapes the body, the lock stays held forever and every waiting thread hangs.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();
}
}
}=== Locks ===
1. REENTRANTLOCK:
Locked (hold count: 1)
Re-entered (hold count: 2)
Inner unlock (hold count: 1)
Outer unlock (held by us? false)
2. READWRITELOCK:
Read locks held: 2
Write locked? false (writers wait for readers)
Write lock acquired? true🎯 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
HashMapfrom many threads can throwConcurrentModificationExceptionor silently corrupt data. Fix: useConcurrentHashMap(orCopyOnWriteArrayListfor read-heavy lists). - ❌ Lock not released in finally: calling
lock.lock()and then throwing beforeunlock()leaves the lock held forever — other threads hang. Fix: alwayslock(), thentry { ... } finally { lock.unlock(); }. - ❌ Atomic vs synchronized confusion: an
AtomicIntegermakes one variable safe, not a whole block. Reading two atomics and combining them is still racy. Fix: use a lock orsynchronizedwhen several updates must happen together as one unit. - ❌ Calling unlock() without holding the lock: an extra
unlock()throwsIllegalMonitorStateException. Fix: pair exactly oneunlock()with eachlock()(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
CyclicBarrierfor 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 |
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.
🎉 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.
Next up: CompletableFuture & async programming — compose non-blocking tasks and chain results without raw threads.
Sign up for free to track which lessons you've completed and get learning reminders.