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

🏢 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:

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.

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:

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)

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

UtilityKey APIUse Case
AtomicIntegerincrementAndGet() / get()Lock-free shared counter
ConcurrentHashMapmerge() / compute()Shared map, many writers
CopyOnWriteArrayListadd() / iterateRead-heavy list (listeners)
BlockingQueueput() / take()Producer / consumer handoff
Semaphoreacquire() / release()Limit concurrent access
CountDownLatchcountDown() / await()Wait for N completions (once)
CyclicBarrierawait()Reusable phase rendezvous
ReentrantLocklock() / unlock()Flexible mutual exclusion
ReadWriteLockreadLock() / 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

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.