☕ Java

Starvation

Starvation is a liveness failure in which a thread is perpetually denied access to a resource it needs to make progress, not because of deadlock (no cycle of blocked threads exists) but because other threads are continuously given priority. The starving thread is alive, runnable, and not waiting on a condition — it simply never gets scheduled to execute, or never successfully acquires a lock it needs, because it is always losing the competition. Starvation can be caused by Java's unfair intrinsic lock scheduling (threads competing for synchronized are given no ordering guarantee), by thread priority misuse (high-priority threads monopolizing the CPU), by busy threads that hold locks for long durations, or by poorly designed lock protocols that systematically favor certain threads. This entry covers the mechanism of starvation in the JVM, unfair locking and why intrinsic locks can starve threads, thread priority and why it is an unreliable tool, fair locking with ReentrantLock(true), scheduling starvation from long-running tasks in thread pools, and how to detect and remediate starvation.

Starvation Mechanisms — Unfair Locks and Priority

Java's intrinsic synchronized lock provides no fairness guarantee. When a synchronized block is exited and multiple threads are waiting to acquire the same monitor, the JVM may grant the lock to any one of them. In practice, HotSpot JVM uses a combination of spinning and OS-level locking, which can result in recently-arrived threads or threads that spin successfully getting the lock ahead of threads that have been waiting much longer. A thread that is consistently unlucky in this competition may wait indefinitely — starved by the continuous lock acquisition of other threads. The intrinsic lock's unfairness is intentional. Enforcing strict FIFO ordering (fairness) requires maintaining a queue of waiting threads and always granting the lock to the head of the queue, which has higher overhead per acquisition. Unfair locking has higher throughput because it avoids the context switch cost of waking the specific next-in-queue thread — it can reuse a thread that is already scheduled. For most applications, this throughput advantage outweighs the fairness cost. But for applications where specific threads must not be starved — real-time systems, fair resource allocation, priority scheduling — unfairness is unacceptable. Thread priority (Thread.setPriority()) is Java's mechanism for influencing the scheduler's decisions. Priority values range from 1 (MIN_PRIORITY) to 10 (MAX_PRIORITY) with 5 as the default (NORM_PRIORITY). In theory, higher-priority threads should be scheduled more often. In practice, thread priority is implemented by mapping Java priorities to OS-level thread priorities, and the mapping and effect are platform-specific: on Linux, Java thread priorities have no effect by default (the OS uses a fair CFS scheduler); on Windows, they influence scheduling but still do not guarantee exclusion of lower-priority threads. Relying on thread priority to prevent starvation is therefore unreliable and non-portable. Long-running lock holders cause starvation without any unfairness in the scheduler. If one thread holds a lock for a long duration (performing I/O, computation, or sleeping inside a synchronized block), other threads waiting for that lock are effectively starved for the duration. This is a design problem — synchronized blocks should hold locks for as short a time as possible.
Java
// ── Intrinsic lock starvation: unfair contention ─────────────────────
public class UnfairCounter {
    private int count = 0;
    private final int[] threadAcquisitions;

    public UnfairCounter(int threads) { threadAcquisitions = new int[threads]; }

    public synchronized void increment(int threadId) {
        count++;
        threadAcquisitions[threadId]++;
    }
}

// Demonstrate unfair distribution:
int THREADS = 5;
int ITERATIONS = 1_000_000;
UnfairCounter uc = new UnfairCounter(THREADS);

Thread[] threads = new Thread[THREADS];
for (int i = 0; i < THREADS; i++) {
    final int id = i;
    threads[i] = new Thread(() -> {
        for (int j = 0; j < ITERATIONS; j++) uc.increment(id);
    });
    threads[i].start();
}
for (Thread t : threads) t.join();

System.out.printf("Total: %d%n", uc.count);
for (int i = 0; i < THREADS; i++) {
    System.out.printf("Thread %d acquired lock %d times (%.1f%%)%n",
        i, uc.threadAcquisitions[i],
        100.0 * uc.threadAcquisitions[i] / (ITERATIONS * THREADS));
}
// Typical output: distribution NOT uniform — some threads get far more acquisitions

// ── Long-running lock holder causes starvation ─────────────────────────
public class LongHolderStarvation {
    private final Object lock = new Object();

    // This method holds the lock for 500ms — starves all other waiters
    public void slowOperation() throws InterruptedException {
        synchronized (lock) {
            Thread.sleep(500);  // holding lock while sleeping — terrible practice
        }
    }

    // These threads wait up to 500ms for each slow operation to complete:
    public void fastOperation() {
        synchronized (lock) {
            // very quick
        }
    }
}

// ── Thread priority: unreliable, platform-dependent ───────────────────
Thread highPriority = new Thread(() -> {
    long count = 0;
    while (!Thread.currentThread().isInterrupted()) count++;
    System.out.println("High: " + count);
});

Thread lowPriority = new Thread(() -> {
    long count = 0;
    while (!Thread.currentThread().isInterrupted()) count++;
    System.out.println("Low:  " + count);
});

highPriority.setPriority(Thread.MAX_PRIORITY);  // 10
lowPriority.setPriority(Thread.MIN_PRIORITY);   // 1

highPriority.start();
lowPriority.start();
Thread.sleep(1000);
highPriority.interrupt();
lowPriority.interrupt();
// On Linux (default config): both threads get roughly equal CPU time — priority ignored
// On Windows: highPriority may get significantly more CPU time
// DO NOT rely on priority for correctness

Fair Locking — ReentrantLock(true) and Thread Pools

java.util.concurrent.locks.ReentrantLock accepts a boolean fairness parameter: new ReentrantLock(true) creates a fair lock that grants access to the longest-waiting thread, implementing FIFO ordering among waiting threads. Fair mode is backed by a CLH (Craig-Landin-Hagersten) queue that maintains the arrival order of waiting threads and always wakes the head of the queue when the lock is released. The cost of fairness is throughput: a fair ReentrantLock can be 5-10x slower in high-contention scenarios because it always context-switches to wake the queued thread rather than allowing a currently-scheduled thread (a barger) to take the lock. The throughput penalty is significant, and fair mode should be used only when starvation is a measured problem, not as a default. For most applications, unfair mode (the default) provides better performance and acceptable fairness in practice. Thread pool starvation is a distinct form: it occurs when all threads in a fixed-size thread pool are blocked waiting for results from tasks that need to be submitted to the same pool. This is thread pool deadlock: task A runs in the pool, submits task B to the same pool, and waits for B's result, but the pool has no free threads to execute B, so A waits forever and B never starts. The solution is to ensure that tasks submitted to a pool never synchronously wait for other tasks in the same pool, or use separate pools for tasks with dependencies, or use a pool that can grow (like Executors.newCachedThreadPool()). Work starvation in thread pools also occurs when long-running tasks monopolize all pool threads, leaving short tasks queued for an unreasonable time. Thread pools with priority queues (custom ThreadPoolExecutor with a PriorityBlockingQueue) can address this by prioritizing short or high-priority tasks. Monitoring queue depth, thread utilization, and task wait time are essential for detecting pool starvation before it becomes a user-visible problem.
Java
// ── Fair ReentrantLock — FIFO ordering ────────────────────────────────
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.atomic.AtomicLong;

public class FairLockDemo {
    // true = fair mode: threads acquire in arrival order
    private final ReentrantLock fairLock = new ReentrantLock(true);
    private final int[] acquisitions;

    public FairLockDemo(int threads) { acquisitions = new int[threads]; }

    public void doWork(int threadId) {
        fairLock.lock();
        try {
            acquisitions[threadId]++;
            // critical section
        } finally {
            fairLock.unlock();   // always unlock in finally
        }
    }

    public static void main(String[] args) throws InterruptedException {
        int THREADS = 5, ITERS = 100_000;
        FairLockDemo demo = new FairLockDemo(THREADS);

        Thread[] threads = new Thread[THREADS];
        for (int i = 0; i < THREADS; i++) {
            final int id = i;
            threads[i] = new Thread(() -> {
                for (int j = 0; j < ITERS; j++) demo.doWork(id);
            });
            threads[i].start();
        }
        for (Thread t : threads) t.join();

        for (int i = 0; i < THREADS; i++) {
            System.out.printf("Thread %d: %d (%.1f%%)%n",
                i, demo.acquisitions[i],
                100.0 * demo.acquisitions[i] / (ITERS * THREADS));
        }
        // With fair=true: distribution is approximately equal — ~20% each
        // With fair=false: distribution may be uneven — some threads dominate
    }
}

// ── Thread pool deadlock (a form of starvation) ───────────────────────
ExecutorService pool = Executors.newFixedThreadPool(2);   // only 2 threads

// This task submits another task and WAITS for it — with only 2 pool threads,
// if both threads are running outer tasks, the inner tasks can never start:
Future<Integer> f1 = pool.submit(() -> {
    Future<Integer> innerFuture = pool.submit(() -> 42);
    return innerFuture.get();   // BLOCKS — waiting for a thread that may not exist
});
// If both pool threads are occupied with outer tasks: inner tasks never run → DEADLOCK

pool.shutdown();

// Fix 1: don't wait for inner tasks synchronously — use callbacks:
pool.submit(() -> {
    pool.submit(() -> {
        System.out.println("Inner task completed");
    });
    // Return immediately — don't wait for inner
});

// Fix 2: separate pools for outer and inner tasks:
ExecutorService outerPool = Executors.newFixedThreadPool(4);
ExecutorService innerPool = Executors.newFixedThreadPool(4);

outerPool.submit(() -> {
    Future<Integer> innerFuture = innerPool.submit(() -> 42);
    return innerFuture.get();   // OK — inner runs in a different pool, never starved
});

// Fix 3: use ForkJoinPool which supports task stealing and managed blocking:
ForkJoinPool fjPool = new ForkJoinPool(4);
fjPool.submit(new RecursiveTask<Integer>() {
    @Override protected Integer compute() {
        ForkJoinTask<Integer> sub = this.fork();  // ForkJoin: can run sub on idle threads
        return sub.join();
    }
});

// ── Detecting starvation via thread monitoring ─────────────────────────
ThreadMXBean tmx = ManagementFactory.getThreadMXBean();
tmx.setThreadCpuTimeEnabled(true);

// Schedule periodic check:
ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor();
monitor.scheduleAtFixedRate(() -> {
    long[] ids = tmx.getAllThreadIds();
    for (long id : ids) {
        ThreadInfo info = tmx.getThreadInfo(id);
        long cpu = tmx.getThreadCpuTime(id);   // nanoseconds of CPU time
        if (info != null && cpu == 0 && info.getThreadState() == Thread.State.RUNNABLE) {
            System.out.printf("Possible starvation: %s has 0 CPU time but is RUNNABLE%n",
                info.getThreadName());
        }
    }
}, 0, 5, TimeUnit.SECONDS);

Related Topics in Multithreading

Process vs Thread
A process is an independent program in execution with its own isolated memory space, file handles, and system resources, managed by the operating system and separated from all other processes by strict boundaries. A thread is a unit of execution that lives inside a process, sharing that process's memory, heap, and resources with every other thread in the same process. Java programs run inside a JVM process; the JVM itself creates and manages threads, and every Java application starts with at least one thread — the main thread — with additional threads created by the JVM for garbage collection, JIT compilation, signal handling, and other runtime tasks. Understanding the distinction between processes and threads is the foundation for all concurrent programming in Java: it determines what is shared and what is isolated, what is fast and what is expensive, what fails independently and what fails together. This entry covers the OS-level and JVM-level model of processes and threads, the memory model that follows from the shared-versus-isolated distinction, the cost model for creation and context switching, failure isolation and its consequences, inter-process and inter-thread communication mechanisms, and the practical decision of when to use multiple processes versus multiple threads.
Thread Basics
A Java thread is an instance of java.lang.Thread that represents an independent path of execution within the JVM process. Every thread has a lifecycle — from creation through runnable, running, blocked, waiting, timed-waiting, and terminated states — and a set of properties including its name, priority, daemon status, thread group, and uncaught exception handler. The Java memory model specifies what visibility guarantees exist between threads and when writes by one thread are guaranteed to be visible to another. Thread scheduling is controlled by the OS scheduler subject to hints from the JVM via thread priority; the JVM does not provide real-time scheduling guarantees. This entry covers the complete thread lifecycle and its state machine, thread properties and how they affect scheduling and JVM shutdown, the happens-before relationship and why it matters for visibility, daemon threads and their relationship to JVM shutdown, thread interruption as a cooperative cancellation mechanism, and the methods on Thread that every Java developer must understand.
Creating Threads
Java provides three primary abstractions for defining the work a thread will execute: the Thread class itself (subclassed to override run()), the Runnable interface (a task with no return value and no checked exception), and the Callable interface (a task with a return value and a declared checked exception). Each represents a different contract between the task and the infrastructure that runs it. Thread subclassing couples the task definition to the execution mechanism and is the oldest and least flexible approach. Runnable decouples the task from the thread, allowing the same Runnable to be submitted to thread pools, scheduled executors, or wrapped in Thread objects. Callable extends that decoupling to include a return value and exception propagation, returning a Future that allows the caller to retrieve the result or handle exceptions asynchronously. Understanding all three — their contracts, their limitations, and when to use each — is the foundation of concurrent programming in Java before reaching for higher-level constructs.
Thread Lifecycle
The Java thread lifecycle is the complete sequence of states a thread passes through from the moment a Thread object is constructed to the moment its execution ends. Java defines six states in the Thread.State enum — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED — and the JVM transitions threads between these states in response to specific method calls, lock acquisitions, monitor notifications, timeouts, and exceptions. Each state has a precise meaning, a defined set of entry conditions, and a defined set of exit conditions. Understanding the lifecycle in full is prerequisite knowledge for diagnosing deadlocks, thread leaks, performance bottlenecks in thread dumps, and incorrect synchronization — all of which manifest as threads stuck in specific states. This entry covers every state in the lifecycle with its entry and exit conditions, all legal and illegal state transitions, how thread dumps represent each state, the interaction between lifecycle states and interruption, the effect of uncaught exceptions on lifecycle, and how to observe lifecycle transitions programmatically.