A Brief Guide to Several Ways of Implementing Asynchronous Programming in Java

In our daily development work, we often talk about asynchronous programming. 

For example, in a user registration API, after a user successfully registers, we might send them a notification email asynchronously.

But here’s a question for you: 

Do you know how many different ways there are to implement asynchronous programming in Java?

Let me walk you through the following commonly used approaches:

• Using Thread and Runnable

• Using thread pools provided by Executors

• Using a custom thread pool

• Using Future and Callable

• Using CompletableFuture

• Using ForkJoinPool

• Using Spring's @Async for asynchronous methods

• Using Message Queues (MQ) for async processing

1. Using Thread and Runnable

Thread and Runnable are the most basic ways to implement asynchronous programming in Java. 

You can directly use them to create and manage threads.

public class Test {
    public static void main(String[] args) {
        System.out.println("Main thread (registration) : " + Thread.currentThread().getName());
        Thread thread = new Thread(() -> {
            try {
                Thread.sleep(1000);
                System.out.println("Asynchronous thread test (email notification): " + Thread.currentThread().getName());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        thread.start();
    }
}

However, in real-world development, it's generally not recommended to use Thread and Runnable directly, due to the following drawbacks:

High resource consumption: Creating a new thread each time consumes system resources. Frequent creation and destruction of threads can lead to performance degradation.

Difficult to manage: Manually handling thread life cycles, exception handling, and task scheduling is complex and error-prone.

Lacks scalability: It's hard to control the number of concurrent threads, which can easily exhaust system resources.

No thread reuse: A new thread is created for each task, which prevents reuse and results in low efficiency.

2. Using Thread Pools Provided by Executors

To address the drawbacks of using Thread and Runnable directly, we can turn to thread pools. Thread pools offer several advantages:

Thread management: Thread pools manage threads for you, avoiding the overhead of frequently creating and destroying threads. After all, a thread is an object — creating it involves class loading, and destroying it involves garbage collection, both of which consume resources.

Improved response time: When a task arrives, retrieving a thread from the pool is much faster than creating a brand-new one.

Thread reuse: Once a thread finishes a task, it's returned to the pool for reuse, which helps conserve system resources.

Here’s a simple demo:

public class Test {
    public static void main(String[] args) {
        System.out.println("Main thread (Registration): " + Thread.currentThread().getName());
        ExecutorService executor = Executors.newFixedThreadPool(3);

        executor.execute(() -> {
            System.out.println("Asynchronous thread via thread pool (Email Notification): " + Thread.currentThread().getName());
        });
    }
}

Output:

Main thread (Registration): main  
Asynchronous thread via thread pool (Email Notification): pool-1-thread-1

3. Using a Custom Thread Pool

Although using built-in thread pools from Executors (like newFixedThreadPool) is simple and convenient, it has a major downside — the task queue it uses is unbounded!

Specifically, newFixedThreadPool uses a LinkedBlockingQueue by default, which is an unbounded queue (with a default capacity of Integer.MAX_VALUE). 

If tasks are submitted faster than they can be processed, the queue will keep growing, potentially leading to out-of-memory (OOM) errors.

That’s why it’s generally recommended to use a custom thread pool for better control over asynchronous execution.

public class Test {
    public static void main(String[] args) {
        // Custom thread pool
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2, // Core thread count
                4, // Maximum thread count
                60, // Keep-alive time for idle threads
                TimeUnit.SECONDS, // Time unit
                new ArrayBlockingQueue<>(8), // Task queue
                Executors.defaultThreadFactory(), // Thread factory
                new ThreadPoolExecutor.CallerRunsPolicy() // Rejection policy
        );

        System.out.println("Main thread (Registration): " + Thread.currentThread().getName());
        executor.execute(() -> {
            try {
                Thread.sleep(500); // Simulate a time-consuming task
                System.out.println("Asynchronous task with custom thread pool (Email Notification): " + Thread.currentThread().getName());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }
}

Output:

Main thread (Registration): main  
Asynchronous task with custom thread pool (Email Notification): pool-1-thread-1
4. Using Future and Callable

We may have this question: “What if we want our asynchronous code to return a result?”

In that case, we can use Future and Callable.

Introduced in Java 5, Callable and Future are designed for handling asynchronous tasks. Callable is similar to Runnable, but with two key differences:

It returns a result. and it can throw exceptions.

Future represents the result of an asynchronous computation and provides methods to check if the task is done, wait for it to complete, and retrieve the result.

Here’s a testing demo:
public class Test {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // Custom thread pool
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2, // Core thread count
                4, // Maximum thread count
                60, // Idle thread keep-alive time
                TimeUnit.SECONDS, // Time unit
                new ArrayBlockingQueue<>(8), // Task queue
                Executors.defaultThreadFactory(), // Thread factory
                new ThreadPoolExecutor.CallerRunsPolicy() // Rejection policy
        );

        System.out.println("Main thread (Registration): " + Thread.currentThread().getName());

        Callable<String> task = () -> {
            Thread.sleep(1000); // Simulate a time-consuming task
            System.out.println("Asynchronous task with custom thread pool (Email Notification): " + Thread.currentThread().getName());
            return "Hello: I am done for job!";
        };

        Future<String> future = executor.submit(task);

        String result = future.get(); // Blocks until the task is complete
        System.out.println("Asynchronous result: " + result);
    }
}
Output:
Main thread (Registration): main  
Asynchronous task with custom thread pool (Email Notification): pool-1-thread-1  
Asynchronous result: Hello: I am done for job!  

5. Using CompletableFuture

CompletableFuture was introduced in Java 8 and provides much more powerful capabilities for asynchronous programming. 

It supports features such as chained calls, exception handling, and combining multiple asynchronous tasks.

Here’s a demo:

public class Test {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // Custom thread pool
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2, // Core thread count
                4, // Maximum thread count
                60, // Idle thread keep-alive time
                TimeUnit.SECONDS, // Time unit
                new ArrayBlockingQueue<>(8), // Task queue
                Executors.defaultThreadFactory(), // Thread factory
                new ThreadPoolExecutor.CallerRunsPolicy() // Rejection policy
        );

        System.out.println("Main thread (main): " + Thread.currentThread().getName());

        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            try {
                Thread.sleep(1000); // Simulate a time-consuming task
                System.out.println("CompletableFuture async task (async-task): " + Thread.currentThread().getName());
                return "Hello: I am async task!";
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "Hello: I am async task!";
        }, executor);

        future.thenAccept(result -> System.out.println("Asynchronous result: " + result));
        future.join(); // Wait for completion
    }
}
6. Using ForkJoinPool

Sometimes, we want to perform asynchronous tasks by breaking a large task into smaller sub-tasks (Fork) and then combining the results of those sub-tasks (Join). 

In this case, we can use ForkJoinPool.

ForkJoinPool was introduced in Java 7 as a specialized thread pool implementation designed for divide-and-conquer tasks. 

It features task splitting (Fork), result merging (Join), and work-stealing

It is particularly suitable for recursive or de-composable parallel tasks.

Here’s a demo:

public class Test {
    public static void main(String[] args) {
        ForkJoinPool pool = new ForkJoinPool(); // Create ForkJoinPool
        int result = pool.invoke(new SumTask(1, 100)); // Submit task and get the result
        System.out.println("The sum from 1 to 200 is: " + result);
    }

    static class SumTask extends RecursiveTask<Integer> {
        private final int start;
        private final int end;

        SumTask(int start, int end) {
            this.start = start;
            this.end = end;
        }

        @Override
        protected Integer compute() {
            if (end - start <= 10) { // Directly compute small tasks
                int sum = 0;
                for (int i = start; i <= end; i++) sum += i;
                return sum;
            } else { // Split the task
                int mid = (start + end) / 2;
                SumTask left = new SumTask(start, mid);
                SumTask right = new SumTask(mid + 1, end);
                left.fork(); // Execute left task asynchronously
                return right.compute() + left.join(); // Wait for left task and merge results
            }
        }
    }
}

7. Spring's @Async for Asynchronous Execution

Spring provides the @Async annotation to enable asynchronous method execution, which is very convenient. 

By using @Async, a method can run in a separate thread without blocking the main thread.

The usage of @Async in Spring Boot is straightforward and involves the following steps:

✅ Enable Asynchronous Support

In your Spring Boot project, you need to add the @EnableAsync annotation to a configuration class or the main application class:

@SpringBootApplication
@EnableAsync // Enable asynchronous support
public class AsyncDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(AsyncDemoApplication.class, args);
    }
}

✅ Define an Asynchronous Service

@Service
public class DemoAsyncService {
    @Async // Mark this method as asynchronous
    public void asyncTianLuoTask() {
        try {
            Thread.sleep(3000); // Simulate a time-consuming task
            System.out.println("Async task completed. Thread: " + Thread.currentThread().getName());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

By default, Spring uses a simple executor called SimpleAsyncTaskExecutor, which creates a new thread for each invocation.

To improve performance and thread reuse, it's recommended to define a custom thread pool.

✅ Configure a Custom Thread Pool

@Configuration
public class AsyncConfig {

    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10); // Core thread count
        executor.setMaxPoolSize(20);  // Max thread count
        executor.setQueueCapacity(50); // Queue capacity
        executor.setThreadNamePrefix("AsyncThread-"); // Thread name prefix
        executor.initialize();
        return executor;
    }
}

✅ Specify the Thread Pool in @Async

You can specify which thread pool to use by setting the @Async annotation’s value:

@Async("taskExecutor") // Use the custom thread pool named "taskExecutor"
public void asyncTianLuoTask() {
    // Task logic here
}

8. Asynchronous Implementation with MQ

When discussing MQ (Message Queue), it is often mentioned for its benefits in asynchronous processing, decoupling, and traffic shaping. 

Indeed, MQ is frequently used to implement asynchronous programming. The simplified code is as follows:

First, save user information, then send an MQ message upon successful registration:

// User registration method
public void registerUser(String username, String email, String phoneNumber) {
    // Save user information (simplified version)
    userService.add(buildUser(username, email, phoneNumber));

    // Send message
    String registrationMessage = "User " + username + " has registered successfully.";
    // Send message to the queue
    rabbitTemplate.convertAndSend("registrationQueue", registrationMessage);
}
The consumer reads the message from the queue and sends an SMS or email:
@Service
public class NotificationService {

    // Listen to messages in the queue and send SMS/email
    @RabbitListener(queues = "registrationQueue")
    public void handleRegistrationNotification(String message) {
        // Here you can perform SMS or email sending operations
        System.out.println("Sending registration notification: " + message);

        // Assume this is an SMS sending operation
        sendSms(message);

        // Other notifications can also be done (e.g., sending emails)
        sendEmail(message);
    }
}

Comments

Popular posts from this blog

Why Do Remote Java Transmission Objects Need to Be Serialized?

Usage of MD5 Encryption and Decryption Technology in Java Application Development

For storing mobile phone numbers of 3 billion global users, should the data type be int, string, varchar, or char? And why?