The Concurrency Utilities: Executors, Synchronizers, and Locks
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
Threadper task, by submitting them to a pooledExecutorService. - Getting a result (or a checked exception) back from a background computation via
Callable+Future, instead of writing it into a shared field aRunnablemutates. - 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 nestingsynchronizedblocks, or per-conditionawait()/signal()— none of which plainsynchronizedoffers. - 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.
javaimport 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.
javaimport 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.
javaimport 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:
javaCyclicBarrier 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 togetherSemaphore 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.
javaimport 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()).
javaimport 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.
javaimport 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. Locktrades automatic release for manual discipline —synchronizedreleases its monitor when the block exits, exception or not;Lock.unlock()does not happen unless you call it, so skipping thefinallyleaves the lock held forever after an exception.javalock.lock(); doWork(); // throws lock.unlock(); // never reached — lock is now stuck locked, every future // lock() from any thread blocks foreverFuture.get()blocks; it does not compose — callingget()ties up the calling thread until the task finishes, and there's no built-in way to chain a follow-up action or combine twoFutures 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 withCountDownLatch"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 —
AtomicIntegermakes one read-modify-write atomic, but updating two related atomics together is not:javaAtomicInteger 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.