Executor Service API in Java

Saurav Kumar
8 min readSep 18, 2023

--

The ExecutorService API in Java is part of the java.util.concurrent package and provides a higher-level replacement for the traditional way of managing threads using the Thread class. It simplifies the task of managing and controlling the execution of multiple tasks concurrently. ExecutorService is often used for achieving concurrency and parallelism in Java applications.

Here’s a detailed explanation of the ExecutorService API along with examples:

Key Interfaces and Classes:

  1. ExecutorService: The primary interface that represents an asynchronous execution service. It provides methods for managing and controlling the execution of tasks.
  2. ThreadPoolExecutor: A class that implements the ExecutorService interface. It allows you to create and configure a thread pool for executing tasks.
  3. Executors: A utility class that provides factory methods for creating different types of ExecutorService instances, such as fixed-size thread pools, cached thread pools, and scheduled thread pools.

Creating an ExecutorService:

You can create an ExecutorService using the Executors class. Here are some common ways to create ExecutorService instances:

  • FixedThreadPool: A fixed-size thread pool where the number of threads is specified upfront.
ExecutorService executor = Executors.newFixedThreadPool(4);
  • CachedThreadPool: A thread pool that dynamically adjusts the number of threads based on the workload.
ExecutorService executor = Executors.newCachedThreadPool();
  • SingleThreadExecutor: A thread pool with a single thread.
ExecutorService executor = Executors.newSingleThreadExecutor();

Submitting Tasks for Execution:

You can submit tasks (usually implemented as Runnable or Callable objects) to the ExecutorService for execution.

executor.submit(new Runnable() {
public void run() {
// Your task logic here
}
});

Example:

package com.tipsontech.executorservice;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class BankTransferExample {
public static void main(String[] args) {
BankAccount accountA = new BankAccount("Account A", 1000);
BankAccount accountB = new BankAccount("Account B", 1000);

ExecutorService executor = Executors.newFixedThreadPool(2);

executor.submit(() -> {
accountA.deposit(accountB, 100);
});

executor.submit(() -> {
accountB.deposit(accountA, 50);
});

executor.shutdown();

// Wait for all tasks to complete
try {
executor.awaitTermination(Long.MAX_VALUE, java.util.concurrent.TimeUnit.NANOSECONDS);
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}

// Print the final balances
System.out.println(accountA.getName() + " balance: " + accountA.getBalance());
System.out.println(accountB.getName() + " balance: " + accountB.getBalance());
}

static class BankAccount {
private final String name;
private int balance;

public BankAccount(String name, int balance) {
this.name = name;
this.balance = balance;
}

public String getName() {
return name;
}

public int getBalance() {
return balance;
}

public synchronized void deposit(BankAccount otherAccount, int amount) {
if (balance >= amount) {
balance -= amount;
otherAccount.balance += amount;
System.out.println(name + " transferred " + amount + " to " + otherAccount.getName());
} else {
System.out.println(name + " does not have sufficient balance to transfer to " + otherAccount.getName());
}
}
}
}
  • In this part, the code specifies the package com.tipsontech.executorservice where the class BankTransferExample is located and imports necessary classes from the java.util.concurrent package for working with concurrent operations.
  • The code defines the main class BankTransferExample with a main method, which is the entry point of the program.
  • Two BankAccount objects, accountA and accountB, are created with initial balances of 1000 each. These objects represent bank accounts.
  • An ExecutorService named executor is created using Executors.newFixedThreadPool(2). This sets up a fixed-size thread pool with two threads that can be used to execute concurrent tasks.
  • The submit method of the executor is called twice. It submits two tasks for execution using lambda expressions. These tasks perform money transfers between accountA and accountB. The first task transfers 100 units from accountA to accountB.
  • The second task transfers 50 units from accountB to accountA.
  • The shutdown method is called on the executor to initiate a graceful shutdown. This means that no new tasks will be accepted, but the already submitted tasks will be allowed to complete.
  • A try-catch block is used to wait for all tasks to be completed. The awaitTermination method is called with a very long timeout (Long.MAX_VALUE, TimeUnit.NANOSECONDS) to effectively wait until all tasks have been completed. If the wait is interrupted, the executor is forcefully shut down using shutdownNow.
  • Finally, the program prints the final balances of accountA and accountB after the money transfer operations have been completed.
  • The last section defines an inner class BankAccount, which represents a bank account. It has instance variables for the account name (name) and balance (balance).
  • The BankAccount class has getter methods for the name and balance.

Now, let’s explain the potential scenarios:

  1. Race Condition: A race condition could occur if both tasks simultaneously check their respective account balances and find that there are sufficient funds. Both tasks could then proceed to perform the deposit, leading to an incorrect final balance. Synchronization ensures that only one thread can execute the deposit operation at a time, preventing this race condition.
  2. Deadlock: A deadlock scenario could occur if the synchronization mechanism is not implemented correctly. For example, if one task locks accountA and waits for accountB, while the other task locks accountB and waits for accountA, a deadlock can occur where neither task can proceed because they are each waiting for the other to release the lock. Properly implemented synchronization prevents this kind of deadlock.
  • The deposit method of BankAccount is synchronized, which means that only one thread can execute it at a time for a given instance of BankAccount. This synchronization ensures thread safety when transferring money between accounts and prevents race conditions or data corruption.

Calling shutdown() just after submitting the task:

executor.shutdown();

Calling executor.shutdown() after submitting tasks to an ExecutorService is a common practice in multi-threaded Java programs. The purpose of calling shutdown() immediately after submitting tasks is to signal that no new tasks will be submitted for execution, and it prepares the ExecutorService for a graceful shutdown once all currently submitted tasks have been completed. Here's why this is done:

  1. Preventing New Task Submissions: By calling executor.shutdown(), you prevent any further submission of new tasks to the ExecutorService. This is often desirable when you want to control the number of tasks that can be executed concurrently and ensure that no additional tasks are added to the queue after a certain point.
  2. Orderly Shutdown: Calling executor.shutdown() initiates an orderly shutdown of the ExecutorService. It allows the already submitted tasks to complete their execution (unless they are explicitly interrupted) before the service is terminated. This helps in avoiding abrupt termination of tasks in progress, which could lead to resource leaks or incomplete operations.
  3. Resource Cleanup: The shutdown() method is often used in combination with other shutdown-related methods, such as awaitTermination(), to wait for all tasks to be complete before proceeding with further actions or cleanup. It ensures that the program doesn't exit prematurely, especially in cases where the main thread needs to wait for task completion.

Shutting Down the ExecutorService:

Shutting down the ExecutorService is essential to ensure proper resource management and graceful termination of threads. Here are different ways to do it:

  • executor.shutdown(): This method initiates an orderly shutdown of the ExecutorService. It allows previously submitted tasks to be completed but does not accept new tasks.
  • executor.shutdownNow(): This method attempts to stop all actively executing tasks, halts the processing of waiting tasks, and returns a list of the tasks that were waiting to be executed.
  • awaitTermination(): After calling executor.shutdown(), you can use awaitTermination() to block until all tasks have completed execution or until a timeout occurs.

Significance of Proper Shutdown:

Properly shutting down the ExecutorService is significant for the following reasons:

  1. Resource Management: It releases the resources associated with threads, preventing resource leaks and memory issues.
  2. Graceful Termination: It allows executing tasks to be completed gracefully, ensuring that no ongoing operations are abruptly terminated.
  3. Preventing Deadlocks: It helps prevent deadlocks and ensures that the application exits cleanly.
  4. Control and Predictability: It provides better control over the application’s lifecycle and ensures that threads are not left running when they are no longer needed.

In summary, using the ExecutorService in real-life scenarios, along with proper shutdown mechanisms, is crucial for efficient and reliable management of concurrent tasks in Java applications.

Blocking of the main thread and waiting for all tasks to be submitted:

while (!executor.isTerminated()) {
// Wait for all tasks to complete
}

The code snippets above is used to wait for all tasks submitted to an ExecutorService to complete before proceeding with further actions in your program. It's essentially a way to block the main thread until all tasks have finished executing.

Here’s the breakdown of what’s happening:

  1. After you have submitted tasks to the ExecutorService for execution, calling executor.shutdown() initiates the process of shutting down the ExecutorService. However, this does not mean that all submitted tasks have finished executing; some of them may still be running in separate threads.
  2. To ensure that your program does not proceed until all tasks are completed, you can use the executor.isTerminated() method. This method returns true if all tasks have been completed and the ExecutorService has been terminated, and false otherwise.
  3. The while (!executor.isTerminated()) the loop keeps checking the isTerminated() condition repeatedly. As long as there are tasks still executing, it remains in this loop, effectively blocking the main thread.
  4. When all tasks have been completed, executor.isTerminated() returns true, and the loop exits. At this point, you can be sure that all submitted tasks have finished their execution.
  5. Your program can then proceed with any necessary post-processing or reporting that depends on the completion of these tasks.

In summary, this construct is used to synchronize the main thread with the completion of tasks running in an ExecutorService. It's especially useful when you need to perform actions after all tasks are finished, ensuring that the program doesn't proceed prematurely.

Synchronized method:

public synchronized void deposit(BankAccount otherAccount, int amount) {
if (balance >= amount) {
balance -= amount;
otherAccount.balance += amount;
System.out.println(name + " transferred " + amount + " to " + otherAccount.getName());
} else {
System.out.println(name + " does not have sufficient balance to transfer to " + otherAccount.getName());
}
}

The synchronized keyword is used to make the transfer method a synchronized method. This means that only one thread can execute this method at a time for a given instance of the BankAccount class. Here's why synchronization is used in this context:

  1. Ensuring Thread Safety: In a multi-threaded environment, when multiple threads access and modify shared data (in this case, the balance of BankAccount objects), there is a risk of data corruption and race conditions. Synchronization helps ensure that only one thread can execute the transfer method at any given time, preventing concurrent access and potential data inconsistencies.
  2. Atomicity: By making the transfer method synchronized, it ensures that the entire method is treated as a single atomic operation. In other words, no other thread can interrupt the execution of this method once it has started, which is important when dealing with operations that involve multiple steps like deducting from one account and adding to another.
  3. Preventing Concurrent Modification: Without synchronization, multiple threads could potentially modify the balance of a BankAccount object simultaneously, leading to incorrect results. By synchronizing the method, you prevent such concurrent modifications.
  4. Blocking Other Threads: While one thread is executing the synchronized method, other threads attempting to access it will be blocked and put in a queue. This ensures that they wait their turn to execute the method, avoiding concurrent access conflicts.

In a scenario like a money transfer between accounts, ensuring thread safety is crucial to maintain the integrity of the accounts and prevent issues like overdrawn balances or incorrect transactions. The use of synchronization in this context is a common practice to guarantee that the operation is carried out safely and consistently in a multi-threaded environment. The ExecutorService, by providing multiple threads, allows for concurrent execution while the synchronization mechanism maintains data consistency and prevents conflicts.

--

--

Saurav Kumar

Experienced Software Engineer adept in Java, Spring Boot, Microservices, Kafka & Azure.