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.
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
2before Chef A is done with ticket1, Chef B pauses and waits. Once Chef A plates order1and rings a service bell (notifies), Chef B proceeds to plate order2.
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
numberis currently even. If so, it releases the lock and callswait(), letting the even task run. If the number is odd, it prints the value, increments the shared counter, and alerts the waiting thread usingnotifyAll(). - Even Task: Checks if
numberis currently odd. If so, it callswait(). Otherwise, it prints the even value, increments the counter, and callsnotifyAll().
- Odd Task: Checks if
- Termination: Once the counter exceeds
MAX, the loops terminate and both tasks finish. We then callexecutor.shutdown()to gracefully shut down the thread pool and free system resources.
Detailed Trace Walkthrough
Let's trace how these tasks coordinate:
- 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.
- Step 2 (Even Thread acquires lock): Suppose Thread 2 (Even) gains the lock first. It checks
number(which is 1). Since1 % 2 != 0, it goes into the waiting state, releasing the lock. - Step 3 (Odd Thread runs): Thread 1 acquires the lock, prints
pool-1-thread-1: 1, incrementsnumberto 2, and callsnotifyAll(), waking up Thread 2. - Step 4 (Alternation): Thread 2 re-acquires the lock, prints
pool-1-thread-2: 2, incrementsnumberto 3, and notifies Thread 1. This cycle continues untilnumberexceedsMAX.
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.