Coordination, Waiting & Cancellation

wait() / notify() Without a Condition Loop

wait() / notify() Without a Condition Loop: practice a Java concurrency bug with symptoms like Consumer wakes too early, Strange intermittent behavior,...

  • Guarded blocks
  • wait/notify
  • Guarded Block
  • Java
  • Beginner

Production symptoms

  • Consumer wakes too early
  • Strange intermittent behavior
  • Logic appears correct at first glance

Failure scenario

Code

Java example
synchronized (lock) {
    if (job == null) {
        lock.wait();
    }

    String jobToProcess = job;
    job = null;
    process(jobToProcess);
}

Prod Symptoms

A custom worker handoff uses wait/notify to sleep until work is available. Under concurrent consumers, a thread wakes and proceeds even though another consumer already took the work.

Key signal: wait() can return even when this thread should not proceed. Always re-check the condition.

  • Failures happen mostly under bursts or multiple waiting consumers
  • A consumer sees null, empty, or stale state after wakeup
  • Logs show a notification before the failure, which makes the code look correct
  • notifyAll makes the bug easier to trigger when one item wakes many waiters
  • Thread dumps may show waiters, but the real bug is the guarded-block protocol

Run Locally

  • Both consumers wait for job to become non-null
  • The producer publishes one job and calls notifyAll
  • One consumer consumes the job
  • The other consumer continues without re-checking and then fails with NullPointerException

Inspect hints

  • Look for wait() guarded by if instead of while
  • Review whether notifyAll can wake more threads than can actually proceed
  • Remember that spurious wakeups are allowed, but ordinary competition for the condition is enough to require a loop
Run
javac WaitWithoutLoopDemo.java
java WaitWithoutLoopDemo
WaitWithoutLoopDemo.java
import java.util.concurrent.CountDownLatch;

public class WaitWithoutLoopDemo {
    private static final Object lock = new Object();
    private static final CountDownLatch waiting = new CountDownLatch(2);
    private static String job;

    public static void main(String[] args) throws Exception {
        Thread first = new Thread(WaitWithoutLoopDemo::takeBroken, "consumer-1");
        Thread second = new Thread(WaitWithoutLoopDemo::takeBroken, "consumer-2");

        first.start();
        second.start();

        waiting.await();
        synchronized (lock) {
            job = "job";
            System.out.println("producer publishes one job and notifyAll");
            lock.notifyAll();
        }

        first.join();
        second.join();
    }

    private static void takeBroken() {
        try {
            synchronized (lock) {
                if (job == null) {
                    waiting.countDown();
                    lock.wait();
                }

                String value = job;
                job = null;
                System.out.println(Thread.currentThread().getName()
                        + " consumed " + value.toUpperCase());
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Note: This demo waits until both consumers are parked, then publishes one job and calls notifyAll.

Diagnosis and fix

Explanation

A notification is not a promise that this thread can proceed. It only means the thread may wake up, reacquire the monitor, and check the shared state again.

Key signal: The correct shape is while (!condition) wait(); then consume the state.

  • notifyAll can wake multiple consumers for one available item
  • Another consumer can consume the item before this thread resumes
  • wait() is also allowed to return without the condition becoming true
  • The condition and wait form one guarded-block protocol
  • The guard must be while, not if

How to Diagnose

This is mainly a code-review bug. Use runtime evidence to find the path, then inspect the condition protocol.

  • Look for wait() guarded by if instead of while
  • Check whether the condition tested before wait is exactly the condition needed to proceed
  • Check whether notifyAll can wake more threads than there are resources
  • Correlate null, empty, or stale-state failures with recent notifications
  • If a dump exists, use it to locate monitor waiters, then review the code around wait()
If stuck
jps
jstack <pid>
jcmd <pid> Thread.print
Typical output
producer publishes one job and notifyAll
consumer-* consumed JOB
Exception in thread "consumer-*" java.lang.NullPointerException

How to Fix

  • Guard wait() with while, not if
  • Re-check the real condition after every wakeup
  • Mutate the condition and notify while holding the same monitor
  • Keep slow processing outside the synchronized block when possible
  • Do not replace notifyAll with notify as a substitute for the condition loop
  • Prefer BlockingQueue when the problem is producer/consumer handoff
WaitWithLoopFixed.java
import java.util.concurrent.CountDownLatch;

public class WaitWithLoopFixed {
    private static final Object lock = new Object();
    private static final CountDownLatch waiting = new CountDownLatch(2);
    private static String job;

    public static void main(String[] args) throws Exception {
        Thread first = new Thread(WaitWithLoopFixed::take, "consumer-1");
        Thread second = new Thread(WaitWithLoopFixed::take, "consumer-2");

        first.start();
        second.start();

        waiting.await();
        put("job-1");
        Thread.sleep(300);
        put("job-2");

        first.join();
        second.join();
    }

    private static void put(String value) {
        synchronized (lock) {
            job = value;
            lock.notifyAll();
        }
    }

    private static void take() {
        try {
            String value;
            synchronized (lock) {
                while (job == null) {
                    waiting.countDown();
                    lock.wait();
                }

                value = job;
                job = null;
            }
            System.out.println(Thread.currentThread().getName()
                    + " consumed " + value.toUpperCase());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Note: The second consumer wakes on the first notifyAll, re-checks, and waits again until another item exists.