Java Task Executor in Spring Boot

Discover the differences between a task executor for multithreading and EDA

Auriga Aristo
XTra Mile Development
6 min readJul 13, 2024

--

Photo by airfocus on Unsplash

Imagine you own a small, bustling cafe named Java Brew. Your cafe has become famous for its excellent coffee and pastries, and now you’re facing an influx of customers. To meet the growing demand, you’ve developed a software system to manage orders efficiently.

Initially, you consider using an event-driven architecture (EDA) for the order processing system. In this setup, an OrderCreated event is emitted when a customer places an order, which is placed in a message queue. Multiple order processors (baristas) listen to the queue and pick up the order events for processing (making coffees and pastries).

While EDA sounds excellent for scalability and decoupling, you quickly encounter some issues.

  • The overhead of managing an event queue and multiple listeners adds complexity to your relatively small-scale operation.
  • Orders need to be coordinated carefully to ensure they are prepared in the correct sequence, especially when multiple items are involved.
  • The added latency of events being placed in a queue and picked up by listeners can slow down order processing, leading to longer wait times for customers.

Recognizing that EDA might be too complex for your cafe’s needs, you opt for a more straightforward multithreading approach using a task executor. With its direct control over the number of concurrent tasks, this approach ensures efficient resource utilization. It improves customer satisfaction by processing orders in the order they are received.

Spring Boot provides excellent support for concurrent programming and makes it easier to manage and configure concurrency-related tasks.

Thread Pool Management

Spring Boot allows easy configuration and managing thread pools, making setting up and tuning for different workloads simple.

@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("Executor-");
executor.initialize();
return executor;
}

Thread Pool provides a straightforward way to configure a thread pool with core pool size, max pool size, queue capacity, etc., using Spring’s configuration properties. It uses a fixed or dynamic number of reused threads for multiple tasks. It is suitable for most web applications that need to handle multiple concurrent tasks efficiently.

Asynchronous Methods

Spring Boot provides the @Async annotation to mark methods for asynchronous execution, allowing you to run them in a separate thread.

@Service
public class MyService {

@Async
public void asyncMethod() {
// Perform asynchronous task
}
}

Scheduling Tasks

Spring Boot offers the @Scheduled annotation to schedule tasks at fixed rates, with fixed delays, or according to a cron expression.

@Scheduled(fixedRate = 5000)
public void scheduledTask() {
// Task to run every 5 seconds
}

Task Executors

Spring Boot provides abstractions for task executors, making it easy to configure and use different types of executors for various needs.

@Bean
public TaskExecutor taskExecutor() {
return new SimpleAsyncTaskExecutor();
}

This task executor doesn’t use a thread pool but creates a new thread for each task. It can be helpful for lightweight, short-lived tasks but is inefficient for high-load scenarios. It requires minimal configuration and is easy to set up for basic asynchronous processing. It suits applications with low to moderate concurrency requirements where tasks are infrequent or lightweight.

Using the Spring Boot’s task executors might be useful for simple applications. But you can create your custom task executor by customizing our queues and thread pools.

Executors

Java provides several types of executors in the java.util.concurrent package that can be used depending on the use case. Here are some of the commonly used ones:

SingleThreadExecutor

This executor has only one thread operating off an unbounded queue. It is useful when you want to ensure that tasks are executed sequentially.

ExecutorService executor = Executors.newSingleThreadExecutor();

CachedThreadPool

This executor creates new threads as needed but will reuse previously constructed threads when available. It is suitable for applications that launch many short-lived tasks.

ExecutorService executor = Executors.newCachedThreadPool();

ScheduledThreadPool

This executor is designed to schedule tasks to run after a given delay or execute periodically. It is helpful for tasks that need to run at regular intervals.

ScheduledExecutorService executor = Executors.newScheduledThreadPool(int corePoolSize);

WorkStealingPool

This executor uses a work-stealing algorithm and can improve throughput in multi-processor systems. It dynamically adjusts the number of threads based on the system’s workload.

ExecutorService executor = Executors.newWorkStealingPool();

Custom ThreadPoolExecutor

Suppose the predefined executors need to meet your needs. In that case, you can create a custom ThreadPoolExecutor to control the number of threads, queue type, and other parameters.

ThreadPoolExecutor executor = new ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue
);

Each executor type serves different purposes, so choose the one that best meets your application’s requirements.

Suppose you want to integrate the custom task processing system within the Spring Boot Application. In that case, you can utilize Spring’s dependency injection and lifecycle management features. Here’s how you can do it:

Create the Task Server Bean

Inside the TaskServer bean, there are some components that we have to take note of:

  • BlockingQueue: A LinkedBlockingQueue holds the tasks. This queue is thread-safe and blocks if necessary when adding or taking elements.
  • ThreadPoolExecutor: A fixed thread pool is created with Executors.newFixedThreadPool(maxThreads), limiting the number of concurrent threads to maxThreads.
  • Listener Thread: A separate listener thread (listenerThread) continuously waits for new tasks to be added to the queue and submits them to the executor for processing. This thread runs as long as the running flag is true.
  • Task Submission: The submitTask method allows tasks to be added to the queue. If the server is running, tasks are added to the taskQueue.
  • Graceful Shutdown: The shutdown method gracefully stops the listener thread and the executor service. It ensures all running tasks are completed and interrupts the listener thread if it’s waiting for new tasks.

The TaskServer class is annotated with @Component to make it a Spring-managed bean. The start method, annotated with @PostConstruct, starts the listener thread after initializing the bean. The shutdown method, annotated with @PreDestroy, ensures the server shuts down gracefully when the application context is closed.

Create a REST Controller to Submit Tasks

The TaskController class is a REST controller that handles HTTP POST requests to submit tasks to the TaskServer. In this example, when a request is made to /tasks/submit, it submits 10 functions to the server. Each request will log started and completed within 5 seconds delay.

Output

When we send a request to /tasks/submit, you can see the output in your log like this:

Task 3 started
Task 0 started
Task 4 started
Task 1 started
Task 2 started
Task 0 completed
Task 5 started
Task 3 completed
Task 6 started
Task 1 completed
Task 2 completed
Task 4 completed
Task 7 started
Task 8 started
Task 9 started
Task 5 completed
Task 6 completed
Task 7 completed
Task 9 completed
Task 8 completed

In this example, we set the max thread pool size to 5, so there are only 5 currently running tasks. The TaskServer will take the remaining tasks in the queue when the thread becomes available.

The choice between using a task executor waiting for tasks to be added and an Event-Driven Architecture (EDA) depends on your application’s specific requirements and constraints. Both approaches have advantages and use cases.

Use the task executor when you have a well-defined set of tasks that need to be executed concurrently and need to control the order and execution of tasks directly. In the implementation, the task executor is suitable for applications with predictable workloads and limited scalability requirements.

Meanwhile, the EDA can be used to build a highly scalable and decoupled system. It is suitable for microservices architectures where services must communicate asynchronously or applications that must process a high volume of events or real-time data.

If your application requires simple concurrent task execution with direct control and predictable workloads, using a task executor is a suitable approach. However, adopting an Event-Driven Architecture would be more beneficial if you need a highly scalable, flexible, and resilient system that can handle complex workflows and asynchronous communication.

In many modern applications, especially those designed to scale and handle real-time data, EDA is becoming increasingly popular due to its decoupling, scalability, and resilience advantages. However, for more straightforward or more predictable tasks, the simplicity and efficiency of a task executor can be more than sufficient.

--

--

Auriga Aristo
XTra Mile Development

4+ years in Backend Developer | PHP, Java/Kotlin, MySQL, Golang | New story every week