Java10 min read

Concurrency in Java: A guide to ExecutorService and Future

Andres Tascon

Andres Tascon

Senior Software Engineer ·

Concurrency in Java: A guide to ExecutorService and Future

Java provides ExecutorService to manage asynchronous tasks and Future to track their results. Together, they let you submit work, wait for results, and manage task execution without handling each thread directly.

Table of Contents

1. Understanding ExecutorService

The ExecutorService interface is part of the java.util.concurrent package, and represents a pool of threads that can be used to execute tasks concurrently. ExecutorService handles thread creation and management through a higher-level API for asynchronous task execution.

1.1 Key Features of ExecutorService

  1. Thread Pool Management: ExecutorService manages a pool of threads, reusing them for executing multiple tasks. This approach reduces the overhead associated with thread creation and destruction.
  2. Task Submission: Tasks can be submitted to the ExecutorService using various methods like execute(Runnable command) for fire-and-forget tasks or submit(Callable<T> task) for tasks that return a result.
  3. Graceful Shutdown: ExecutorService provides methods like shutdown() and shutdownNow() to stop the execution of tasks gracefully or forcefully.

2. How to instantiate an ExecutorService

Java provides several ways to instantiate an ExecutorService, each suited to different use cases. The Executors utility class is commonly used to create different types of thread pools.

2.1 Single Thread Executor

A single-thread executor creates an ExecutorService that uses a single worker thread to execute tasks sequentially.

java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
public class SingleThreadExecutorExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newSingleThreadExecutor();
 
        for (int i = 0; i < 5; i++) {
            executorService.execute(new Task(i));
        }
 
        executorService.shutdown();
    }
}
 
class Task implements Runnable {
    private final int taskId;
 
    public Task(int taskId) {
        this.taskId = taskId;
    }
 
    @Override
    public void run() {
        System.out.println("Executing task " + taskId
            + " by " + Thread.currentThread().getName());
    }
}

2.2 Fixed Thread Pool

A fixed thread pool creates an ExecutorService with a specified number of threads.

java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
public class FixedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(3);
 
        for (int i = 0; i < 10; i++) {
            executorService.execute(new Task(i));
        }
 
        executorService.shutdown();
    }
}

2.3 Cached Thread Pool

A cached thread pool creates an ExecutorService that can dynamically create new threads as needed, but will reuse previously constructed threads when available.

java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
public class CachedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newCachedThreadPool();
 
        for (int i = 0; i < 10; i++) {
            executorService.execute(new Task(i));
        }
 
        executorService.shutdown();
    }
}

2.4 Scheduled Thread Pool

A scheduled thread pool creates an ExecutorService that can schedule commands to run after a given delay, or to execute periodically.

java
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
 
public class ScheduledThreadPoolExample {
    public static void main(String[] args) {
        ScheduledExecutorService scheduledExecutorService =
            Executors.newScheduledThreadPool(2);
 
        for (int i = 0; i < 5; i++) {
            scheduledExecutorService.schedule(new Task(i), 2, TimeUnit.SECONDS);
        }
 
        scheduledExecutorService.shutdown();
    }
}

2.5 Work Stealing Pool

A work stealing pool creates an ExecutorService that maintains enough threads to support a given parallelism level and uses multiple queues to reduce contention.

java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
public class WorkStealingPoolExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newWorkStealingPool();
 
        for (int i = 0; i < 10; i++) {
            executorService.submit(new Task(i));
        }
 
        executorService.shutdown();
    }
}

2.6 Custom Thread Pool

In some cases, you may need more control over the thread pool configuration, such as setting custom thread factories or handlers for rejected tasks.

java
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
 
public class CustomThreadPoolExample {
    public static void main(String[] args) {
        ThreadPoolExecutor executorService =
            (ThreadPoolExecutor) Executors.newFixedThreadPool(3);
 
        for (int i = 0; i < 10; i++) {
            executorService.execute(new Task(i));
        }
 
        executorService.shutdown();
        try {
            executorService.awaitTermination(1, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

3. Example: Using ExecutorService

This example runs multiple I/O-bound operations concurrently.

java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
public class ExecutorServiceExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(5);
 
        for (int i = 0; i < 10; i++) {
            executorService.execute(new Task(i));
        }
 
        executorService.shutdown();
    }
}
 
class Task implements Runnable {
    private final int taskId;
 
    public Task(int taskId) {
        this.taskId = taskId;
    }
 
    @Override
    public void run() {
        System.out.println("Executing task " + taskId
            + " by " + Thread.currentThread().getName());
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

4. Introducing Future

The Future interface represents the result of an asynchronous computation. It provides methods to check if the computation is complete, wait for its completion, and retrieve the result.

4.1 Key Features of Future

  1. Result Retrieval: Future allows you to retrieve the result of a computation once it's done using the get() method. This method blocks until the result is available.
  2. Cancellation: You can cancel the execution of a task using the cancel(boolean mayInterruptIfRunning) method.
  3. Status Check: Future provides methods like isDone() to check if the task is completed and isCancelled() to check if the task was cancelled.

5. Example: Using Future

The next version uses Future to handle tasks that return results.

java
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
 
public class FutureExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(5);
 
        Future<Integer>[] futures = new Future[10];
        for (int i = 0; i < 10; i++) {
            futures[i] = executorService.submit(new TaskWithResult(i));
        }
 
        for (Future<Integer> future : futures) {
            try {
                System.out.println("Result: " + future.get());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
 
        executorService.shutdown();
        try {
            executorService.awaitTermination(1, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
 
class TaskWithResult implements Callable<Integer> {
    private final int taskId;
 
    public TaskWithResult(int taskId) {
        this.taskId = taskId;
    }
 
    @Override
    public Integer call() {
        System.out.println("Executing task " + taskId
            + " by " + Thread.currentThread().getName());
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return taskId * 2;
    }
}

6. Best Practices

  1. Proper Shutdown: Always ensure that the ExecutorService is properly shut down using shutdown() or shutdownNow() to free up resources.
  2. Handling Exceptions: Be mindful of exceptions in tasks. Use appropriate exception handling in the call or run methods to avoid unexpected crashes.
  3. Timeouts: Use timeouts with the get method of Future to prevent indefinite blocking.
  4. Resource Management: Be cautious with the number of threads in the pool. Too many threads can lead to resource exhaustion, while too few can cause performance bottlenecks.
  5. Avoiding Deadlocks: Ensure tasks do not hold locks or resources for long periods, as this can lead to deadlocks.

7. Conclusion

ExecutorService handles thread management and task submission. Future lets you retrieve results, check task status, and cancel work.

Shut down executors when they are no longer needed, handle task exceptions, and use timeouts to avoid waiting indefinitely. Choose the pool size with resource use and performance in mind.

Test Your Understanding

Question 1 of 5 · Score: 0/0 correct

Your first answer counts toward your score.

What is the main advantage of ExecutorService over manually creating Thread instances?

Share this article