Thread Coordination & The ExecutorService

In multithreaded Java applications, orchestrating execution order across threads is a common synchronization task. Traditionally, developers achieve this by spawning raw threads and using synchronized wait() and notifyAll() blocks. However, directly managing thread lifecycles (new Thread().start()) introduces overhead and couples task logic with thread creation.

To address this, Java's concurrency framework offers the ExecutorService. It decouples task submission from execution details, managing threads via a managed pool. Using a fixed thread pool allows us to reuse existing threads, minimize thread creation costs, and orchestrate execution sequences using high-level concurrency structures.

Visualizing ExecutorService task coordination
Real-World Analogy: Expediters and Chefs

To understand this setup, let's look at a restaurant kitchen analogy:

  • The Dispatcher (ExecutorService): A head expeditor manages order tickets and assigns them to available chefs.
  • The Chefs (Threads): Two chefs (Chef A and Chef B) prepare dishes. Chef A only cooks odd-numbered orders (1, 3, 5), while Chef B handles even-numbered orders (2, 4, 6).
  • The Order Slip (Shared Lock): Since they share a single assembly counter, they must communicate. If Chef B gets ticket 2 before Chef A is done with ticket 1, Chef B pauses and waits. Once Chef A plates order 1 and rings a service bell (notifies), Chef B proceeds to plate order 2.
By coordinating via a shared counter and waiting for notifications, the chefs ensure that food is plated in sequential order, even though the expeditor hands out tickets dynamically.

Coordinating Wait/Notify inside Executor Tasks

Our code utilizes a FixedThreadPool of size 2. We submit two long-running tasks: one responsible for printing odd numbers and the other for printing even numbers.

  • Mutual Exclusion: Both tasks synchronize on a shared object lock.
  • Conditional Checking:
    • Odd Task: Checks if number is currently even. If so, it releases the lock and calls wait(), letting the even task run. If the number is odd, it prints the value, increments the shared counter, and alerts the waiting thread using notifyAll().
    • Even Task: Checks if number is currently odd. If so, it calls wait(). Otherwise, it prints the even value, increments the counter, and calls notifyAll().
  • Termination: Once the counter exceeds MAX, the loops terminate and both tasks finish. We then call executor.shutdown() to gracefully shut down the thread pool and free system resources.

Detailed Trace Walkthrough

Let's trace how these tasks coordinate:

  1. Step 1 (Submit Tasks): The two runnables are submitted to the thread pool. Thread 1 picks up the odd runnable, and Thread 2 picks up the even runnable.
  2. Step 2 (Even Thread acquires lock): Suppose Thread 2 (Even) gains the lock first. It checks number (which is 1). Since 1 % 2 != 0, it goes into the waiting state, releasing the lock.
  3. Step 3 (Odd Thread runs): Thread 1 acquires the lock, prints pool-1-thread-1: 1, increments number to 2, and calls notifyAll(), waking up Thread 2.
  4. Step 4 (Alternation): Thread 2 re-acquires the lock, prints pool-1-thread-2: 2, increments number to 3, and notifies Thread 1. This cycle continues until number exceeds MAX.

Full Code Solution

By using a thread pool and submitting tasks, we can alternate printing task classes dynamically:

package io.practise.string;
 
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
public class TestEvenOddUsingExecutorService {
    private static final Object lock = new Object();
    private static int number = 1;
    private static final int MAX = 20;
 
    public static void main(String[] args) {
        // Create a fixed thread pool of 2 threads
        ExecutorService executor = Executors.newFixedThreadPool(2);
 
        executor.submit(() -> {
            while (number <= MAX) {
                synchronized (lock) {
                    if (number % 2 == 0) {
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                    } else {
                        System.out.println(Thread.currentThread().getName() + ": " + number++);
                        lock.notifyAll();
                    }
                }
            }
        });
 
        executor.submit(() -> {
            while (number <= MAX) {
                synchronized (lock) {
                    if (number % 2 != 0) {
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            Thread.currentThread().interrupt();
                        }
                    } else {
                        System.out.println(Thread.currentThread().getName() + ": " + number++);
                        lock.notifyAll();
                    }
                }
            }
        });
 
        executor.shutdown();
    }
}

Conclusion & Takeaways

Using ExecutorService lets you easily manage threads and submit tasks dynamically, ensuring that threads are automatically recycled when the application completes execution. By managing tasks in a shared execution context, we achieve thread reuse while maintaining traditional synchronization tools.