Introduction #
Here, we will discuss the core concurrency hazards and control concepts in Java: race conditions, visibility, atomicity, deadlocks, starvation, livelock, contention, backpressure, interruption, and cancellation.
Race Conditions #
A race condition occurs when the correctness of a task depends on the relative timing or interleaving of threads accessing shared mutable state. The bug is not “thread A ran before B”, it is “if they interleave in this specific way, invariants break.”
Let’s consider the below example and try to figure out what could be going wrong here :-
class BrokenCounter {
private int count = 0;
public void increment() {
// Non-atomic read-modify-write
count = count + 1;
}
public int get() {
return count;
}
}
void demo() throws InterruptedException {
BrokenCounter counter = new BrokenCounter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1_000_000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1_000_000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(counter.get()); // Almost never 2_000_000
}The problem here is that the read‑modify‑write on count is raced; the increments almost always get lost.
A fixed version of this counter will look like :-
class AtomicCounter {
private final java.util.concurrent.atomic.AtomicInteger count =
new java.util.concurrent.atomic.AtomicInteger();
public void increment() {
count.incrementAndGet(); // atomic
}
public int get() {
return count.get();
}
}Visibility #
Visibility is whether a write to a shared variable by one thread becomes observable by another thread. This is because, in Java, threads may cache variables in their local memory, leading to situations where one thread’s updates to a variable are never seen by another. Without proper happens‑before edges (locks, volatile etc) threads can see stale values indefinitely.
class StoppableWorker implements Runnable {
private boolean running = true; // not volatile
@Override
public void run() {
while (running) {
// do work
}
System.out.println("Stopped");
}
public void stop() {
running = false; // may never be seen
}
}
void demo() throws InterruptedException {
StoppableWorker worker = new StoppableWorker();
Thread t = new Thread(worker);
t.start();
Thread.sleep(100);
worker.stop(); // might not stop the thread
}The problem here is that worker may spin forever because the write to running might not become visible in the loop.
class VisibleWorker implements Runnable {
private volatile boolean running = true; // establishes happens-before
@Override
public void run() {
while (running) {
// do work
}
System.out.println("Stopped");
}
public void stop() {
running = false;
}
}Atomicity #
Atomicity means an operation (or group of operations) appears to happen as one indivisible step: no other thread observes partial state. Most domain‑level actions (transfer funds, update a status plus timestamp) require multiple field updates, so they are not atomic by default.
class Account {
int balance;
}
class Bank {
public void transfer(Account from, Account to, int amount) {
if (from.balance >= amount) {
from.balance -= amount; // step 1
to.balance += amount; // step 2
}
}
}The problem with the above is that two threads calling transfer concurrently on shared accounts can violate invariants (negative balances, lost money).
class SafeBank {
public void transfer(Account from, Account to, int amount) {
// naive: lock ordering problems, but shows atomicity
synchronized (from) {
synchronized (to) {
if (from.balance >= amount) {
from.balance -= amount;
to.balance += amount;
}
}
}
}
}Though there is a lock ordering problem that we’ll solve next, atomicity is now at the method level: other threads never see a half‑applied transfer.
Deadlocks #
Deadlock is a liveness failure where a set of threads permanently blocks, each waiting for a resource held by another in the set, so nobody can make progress.
In the atomicity example above, there could be a scenario where t1 holds lockA and t2 holds lockB and t1 tries to acquire lockB while t2 tries to acquire lockA, resulting in a deadlock.
To avoid this at code level, enforce a global lock ordering, avoid holding locks while calling external code, and prefer higher‑level constructs (e.g., ExecutorService + message passing) over manual nested locks
In the above example, say always acquire the lock on the smaller account number first so that a deadlock is avoided.
Starvation #
Starvation occurs when some threads or tasks are perpetually denied CPU or lock access, so they never make progress even though others do. This is often a scheduling or policy bug, not a correctness bug.
class StarvationDemo {
private final Object lock = new Object();
void run() {
// "Selfish" thread hogging the lock
Thread hog = new Thread(() -> {
while (true) {
synchronized (lock) {
// long computation
}
}
});
hog.setPriority(Thread.MAX_PRIORITY);
hog.start();
// Lower-priority worker that may rarely acquire the lock
Thread victim = new Thread(() -> {
while (true) {
synchronized (lock) {
// work that seldom runs
}
}
});
victim.setPriority(Thread.MIN_PRIORITY);
victim.start();
}
}The victim thread may be effectively starved.
Livelock #
Livelock is a liveness failure where threads are not blocked but still can’t make progress because they keep changing state in response to each other in a way that prevents completion.
class DiningFriend {
private final String name;
private volatile boolean polite = true;
public DiningFriend(String name) { this.name = name; }
public void eatWith(DiningFriend partner) {
while (true) {
if (polite && partner.polite) {
// Both decide to yield to the other...
System.out.println(name + ": you go first");
try { Thread.sleep(10); } catch (InterruptedException ignored) {}
} else {
// One eventually decides to eat
System.out.println(name + ": I will eat now");
break;
}
}
}
}Both threads can keep toggling polite flags and never actually “eat”.
Fix: Introduce randomness or a deterministic tie‑breaker (ID ordering) so someone wins.
Contention #
Contention is the performance cost when many threads compete for the same lock or mutually exclusive resource. The program is correct but throughput drops and latency gets noisy because threads are queuing on locks.
class HotCounter {
private long count = 0;
public synchronized void increment() {
count++;
}
public synchronized long get() {
return count;
}
}A single monitor can become a scalability bottleneck under heavy multi‑threaded updates.
Reduced contention with striping #
class StripedCounter {
private final java.util.concurrent.atomic.AtomicLongArray cells;
public StripedCounter(int stripes) {
this.cells = new java.util.concurrent.atomic.AtomicLongArray(stripes);
}
public void increment() {
int idx = (int) (Thread.currentThread().getId() % cells.length());
cells.incrementAndGet(idx);
}
public long get() {
long sum = 0;
for (int i = 0; i < cells.length(); i++) {
sum += cells.get(i);
}
return sum;
}
}Backpressure #
Backpressure is how a system signals producers to slow down or stop when downstream components are saturated. Without it, you get unbounded queues, OOMs, or meltdown.
class BackpressureDemo {
private final ThreadPoolExecutor executor;
BackpressureDemo() {
int core = 8;
int max = 8;
int queueCapacity = 1000;
this.executor = new ThreadPoolExecutor(
core, max, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(queueCapacity),
new ThreadPoolExecutor.CallerRunsPolicy() // backpressure
);
}
public void submitTask(Runnable task) {
executor.execute(task);
}
}With CallerRunsPolicy, when the queue is full, the caller thread runs the task, effectively slowing the producer.
Interruption #
Interruption in Java is a cooperative signal that a thread should stop what it is doing (or change behavior). Thread.interrupt() sets the interrupted status; many blocking methods respond by throwing InterruptedException and clearing the flag.
class InterruptibleWorker implements Runnable {
@Override
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
// Do a unit of work
doWorkChunk();
}
} catch (InterruptedException e) {
// Restore interrupted status if you want callers to see it
Thread.currentThread().interrupt();
} finally {
cleanup();
}
}
private void doWorkChunk() throws InterruptedException {
// Simulate blocking operation
Thread.sleep(100);
}
private void cleanup() {
// release resources
}
}Thread worker = new Thread(new InterruptibleWorker());
worker.start();
// later
worker.interrupt();Cancellation #
Cancellation is the cooperative process of stopping an in‑flight task in a controlled way, typically because of timeouts, shutdown, or user actions. Interruption is a common mechanism for cancellation, but cancellation also needs protocol and ownership: who is allowed to cancel what and how that propagates.
class CancellableTask implements Callable<Void> {
@Override
public Void call() throws Exception {
while (!Thread.currentThread().isInterrupted()) {
// do work
doUnit();
}
return null;
}
private void doUnit() throws InterruptedException {
// Potentially blocking operation
Thread.sleep(100);
}
}
void demo() throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Void> future = executor.submit(new CancellableTask());
// Let it run for a bit
Thread.sleep(500);
// Cancel with interrupt
future.cancel(true); // mayInterruptIfRunning = true
executor.shutdownNow();
}The task cooperates by checking isInterrupted() and by letting blocking calls throw InterruptedException which unwinds the task.
Cancellation via shared flag (non-blocking work) #
class FlagCancellableTask implements Runnable {
private volatile boolean cancelled = false;
public void cancel() {
cancelled = true;
}
@Override
public void run() {
while (!cancelled) {
// do pure CPU work that doesn't block
}
}
}Note:
For blocking operations (e.g., BlockingQueue.put), a volatile flag alone is not enough - you need interruption-aware code or non‑blocking APIs.
Thread.interrupt() just sets the thread’s interrupt flag. Java interruption is cooperative, not pre‑emptive: nothing automatically stops your code when the flag is set. For the interrupt to matter, either:
-
The thread must be blocked in an interruptible call (sleep, wait, join, many java.util.concurrent operations, NIO), in which case that call will throw InterruptedException and clear the flag.
-
Or your code must periodically check the flag (isInterrupted() or Thread.interrupted()) and react to it (e.g., break out of the loop, clean up, and return).