The Concurrency Utilities: Executors, Synchronizers, and Locks

Published on 2026-08-13·v1.1

Objective

Thread, Runnable, and synchronized (covered in depth by the companion concept on the legacy vs. virtual thread model) are the primitives Java has always had for concurrency. JDK 5 added a layer on top of them — the concurrency utilities, spread across java.util.concurrent, java.util.concurrent.atomic, and java.util.concurrent.locks — that gives programs higher-level, purpose-built tools instead of hand-rolled wait()/notify() code: thread pools managed by an Executor, tasks that return a value via Callable/ Future, ready-made synchronizers (Semaphore, CountDownLatch, CyclicBarrier), an explicit Lock alternative to synchronized, and lock-free atomic variables. This concept covers that toolkit; the Fork/Join framework (JDK 7's addition for divide-and-conquer parallelism) is a separate, closely related concept.

Use Cases

  • Running a bounded number of concurrent tasks — HTTP handlers, background jobs — without creating a raw Thread per task, by submitting them to a pooled ExecutorService.
  • Getting a result (or a checked exception) back from a background computation via Callable+Future, instead of writing it into a shared field a Runnable mutates.
  • Making one thread wait for several others to finish a one-time startup sequence (CountDownLatch), or making a fixed group of threads rendezvous repeatedly at the same point in a loop (CyclicBarrier).
  • Throttling concurrent access to a limited resource — a connection pool, a rate limit — to at most N threads at once with a Semaphore.
  • Needing tryLock() (never block), a lock that can be acquired across multiple methods without nesting synchronized blocks, or per-condition await()/ signal() — none of which plain synchronized offers.
  • Maintaining a simple shared counter or flag under concurrent updates without taking out a lock at all, via AtomicInteger/AtomicLong.

Deep Dive

ExecutorService and thread pools

An Executor decouples submitting a task from deciding how (and on which thread) it runs. ExecutorService extends it with lifecycle control — submit()/ execute() to hand off work, shutdown() to stop accepting new work once queued tasks finish. Executors is the factory: newFixedThreadPool(n) caps the pool at n threads, newCachedThreadPool() grows on demand and reuses idle threads, newScheduledThreadPool(n) adds delayed/periodic execution.

java
import java.util.concurrent.*; class SimpExec { public static void main(String[] args) { ExecutorService es = Executors.newFixedThreadPool(2); System.out.println("Starting"); // Four tasks share a pool of two threads. es.execute(new MyThread("A")); es.execute(new MyThread("B")); es.execute(new MyThread("C")); es.execute(new MyThread("D")); es.shutdown(); // without this call, the program never terminates System.out.println("Done"); } } class MyThread implements Runnable { String name; MyThread(String n) { name = n; } public void run() { for (int i = 0; i < 5; i++) { System.out.println(name + ": " + i); } } }

Only two of the four MyThread tasks run at any instant; the rest wait for a pool thread to free up. shutdown() is not optional cleanup — an ExecutorService that is never shut down keeps its threads alive and the JVM running.

Callable and Future: tasks that return a value

Runnable.run() returns nothing and cannot declare a checked exception. Callable<V> fixes both: its single method, V call() throws Exception, returns a result and is allowed to fail. Submitting a Callable to an ExecutorService returns a Future<V> — a handle to a result that doesn't exist yet. Future.get() blocks until the task finishes (or a timeout, in the overload that takes a TimeUnit) and either returns the value or rethrows the task's failure wrapped in an ExecutionException.

java
import java.util.concurrent.*; class CallableDemo { public static void main(String[] args) { ExecutorService es = Executors.newFixedThreadPool(3); Future<Integer> f; Future<Double> f2; Future<Integer> f3; System.out.println("Starting"); f = es.submit(new Sum(10)); f2 = es.submit(new Hypot(3, 4)); f3 = es.submit(new Factorial(5)); try { System.out.println(f.get()); System.out.println(f2.get()); System.out.println(f3.get()); } catch (InterruptedException exc) { System.out.println(exc); } catch (ExecutionException exc) { System.out.println(exc); } es.shutdown(); System.out.println("Done"); } } class Sum implements Callable<Integer> { int stop; Sum(int v) { stop = v; } public Integer call() { int sum = 0; for (int i = 1; i <= stop; i++) sum += i; return sum; } } class Hypot implements Callable<Double> { double side1, side2; Hypot(double s1, double s2) { side1 = s1; side2 = s2; } public Double call() { return Math.sqrt((side1 * side1) + (side2 * side2)); } } class Factorial implements Callable<Integer> { int stop; Factorial(int v) { stop = v; } public Integer call() { int fact = 1; for (int i = 2; i <= stop; i++) fact *= i; return fact; } }

All three call() methods run concurrently on the pool; get() on each Future simply waits for that particular task. Future predates CompletableFuture (added in Java 8): it has no way to attach a callback or chain a follow-up computation — get() is the only way out, and it blocks the calling thread. CompletableFuture adds thenApply()/thenCompose()/thenCombine() and friends for composing async work without blocking; reach for it over raw Future in new code.

Coordination primitives: CountDownLatch, CyclicBarrier, and Semaphore

These three solve different shapes of the same problem — "make threads wait for each other" — and picking the wrong one usually means fighting the API instead of using it.

CountDownLatch is a one-shot gate: created with a count of events to wait for, it opens permanently once countDown() has been called that many times. It cannot be reset or reused.

java
import java.util.concurrent.CountDownLatch; class CDLDemo { public static void main(String[] args) { CountDownLatch cdl = new CountDownLatch(5); System.out.println("Starting"); new Thread(new MyThread(cdl)).start(); try { cdl.await(); // blocks until the count reaches zero } catch (InterruptedException exc) { System.out.println(exc); } System.out.println("Done"); } } class MyThread implements Runnable { CountDownLatch latch; MyThread(CountDownLatch c) { latch = c; } public void run() { for (int i = 0; i < 5; i++) { System.out.println(i); latch.countDown(); // decrement count } } }

CyclicBarrier makes a fixed set of threads rendezvous at a point and then resets automatically, so it can be reused across repeated rounds — useful when the same group of threads needs to sync up once per loop iteration rather than once ever:

java
CyclicBarrier barrier = new CyclicBarrier(3, () -> System.out.println("All three reached the barrier")); // each of the three worker threads, once per round: barrier.await(); // blocks until all 3 have called await(), then all resume together

Semaphore counts permits rather than arrivals: acquire() blocks only when the permit count is already zero, and release() gives one back. With an initial count of 1 it behaves like a mutex; with a higher count it caps concurrent access at that many threads.

java
import java.util.concurrent.Semaphore; class SemDemo { public static void main(String[] args) { Semaphore sem = new Semaphore(1); new Thread(new IncThread(sem, "A")).start(); new Thread(new DecThread(sem, "B")).start(); // mirrors IncThread, decrements } } class Shared { static int count = 0; } class IncThread implements Runnable { String name; Semaphore sem; IncThread(Semaphore s, String n) { sem = s; name = n; } public void run() { try { sem.acquire(); // blocks here if the permit is already taken for (int i = 0; i < 5; i++) { Shared.count++; System.out.println(name + ": " + Shared.count); Thread.sleep(10); // give the other thread a chance to try acquire() } } catch (InterruptedException exc) { System.out.println(exc); } sem.release(); } }

Without the acquire()/release() pair, both threads would interleave their increments and decrements on Shared.count; with a single permit, one thread runs its whole five-iteration loop before the other is let in. Two more specialized synchronizers live in the same package — Exchanger, which pairs exactly two threads to swap a value, and Phaser, which generalizes CyclicBarrier to multiple named phases — reach for them only when none of the three above fit.

Explicit locks: Lock, ReentrantLock, and Condition

Lock is an interface — lock(), unlock(), tryLock(), newCondition() — that does the same job as synchronized but as an ordinary object instead of a language keyword. ReentrantLock is its standard implementation: a thread that already holds the lock can re-acquire it (each lock() call must be matched by an unlock()).

java
import java.util.concurrent.locks.*; class LockDemo { public static void main(String[] args) { ReentrantLock lock = new ReentrantLock(); new Thread(new LockThread(lock, "A")).start(); new Thread(new LockThread(lock, "B")).start(); } } class Shared { static int count = 0; } class LockThread implements Runnable { String name; ReentrantLock lock; LockThread(ReentrantLock lk, String n) { lock = lk; name = n; } public void run() { try { lock.lock(); // blocks here if another thread already holds the lock Shared.count++; System.out.println(name + ": " + Shared.count); Thread.sleep(1000); // proves the second thread really does wait } catch (InterruptedException exc) { System.out.println(exc); } finally { lock.unlock(); // must run even if the try block throws } } }

Unlike synchronized, which releases automatically when the block exits, Lock gives no such guarantee — unlock() belongs in a finally block, always. In exchange, Lock offers what synchronized cannot: tryLock() to attempt acquisition without blocking, and newCondition() for multiple independent wait sets on the same lock (versus Object.wait()/notify(), which every object has only one of).

Atomic variables: lock-free updates

java.util.concurrent.atomic classes such as AtomicInteger and AtomicLong perform get/set/compare-and-swap as a single uninterruptible hardware operation — no Lock, no synchronized, and no blocking, for the common case of a single shared counter or flag.

java
import java.util.concurrent.atomic.AtomicInteger; class AtomicDemo { public static void main(String[] args) { new Thread(new AtomThread("A")).start(); new Thread(new AtomThread("B")).start(); new Thread(new AtomThread("C")).start(); } } class Shared { static AtomicInteger ai = new AtomicInteger(0); } class AtomThread implements Runnable { String name; AtomThread(String n) { name = n; } public void run() { for (int i = 1; i <= 3; i++) { // getAndSet() reads the old value and stores a new one, atomically — // no two threads can interleave inside that read-then-write. System.out.println(name + " got: " + Shared.ai.getAndSet(i)); } } }

No thread can observe ai mid-update, and no lock is ever acquired or released. Beyond get()/set()/compareAndSet()/getAndSet(), the package also has LongAdder/DoubleAdder (cumulative sums) and LongAccumulator/ DoubleAccumulator (user-specified combining operations) for high-contention counters where even an atomic compare-and-swap loop becomes a bottleneck.

Trade-offs

  • Thread pool sizing is a tuning problem you now own — newFixedThreadPool(2) caps concurrency at 2 regardless of how many tasks are submitted, so 4 tasks queue in pairs rather than all running at once; too small a pool underuses available cores, too large a one risks the same resource exhaustion pools exist to prevent. There is no single right number — it depends on whether tasks are CPU-bound or I/O-bound.
  • Lock trades automatic release for manual discipline — synchronized releases its monitor when the block exits, exception or not; Lock.unlock() does not happen unless you call it, so skipping the finally leaves the lock held forever after an exception.
    java
    lock.lock(); doWork(); // throws lock.unlock(); // never reached — lock is now stuck locked, every future // lock() from any thread blocks forever
  • Future.get() blocks; it does not compose — calling get() ties up the calling thread until the task finishes, and there's no built-in way to chain a follow-up action or combine two Futures without blocking on each in turn. CompletableFuture (Java 8) exists specifically to close this gap with callback-based composition — prefer it when the workflow needs to react to a result rather than just wait for one.
  • Picking the wrong synchronizer costs a rewrite, not a compile error — a one-shot startup gate implemented with CyclicBarrier "works" until the second round of the loop needs the barrier to reset, or a repeating rendezvous implemented with CountDownLatch "works" until the second round needs to wait again — nothing catches the mismatch until the code actually runs a second time.
  • Atomics only cover a single variable at once — AtomicInteger makes one read-modify-write atomic, but updating two related atomics together is not:
    java
    AtomicInteger balance = new AtomicInteger(100); AtomicInteger txCount = new AtomicInteger(0); balance.addAndGet(-50); // atomic on its own... txCount.incrementAndGet(); // ...but another thread can observe the gap // between these two calls — this pair still // needs a Lock or synchronized block to be // atomic together.

Documentation Links