Class 46: Building a Real-World Multithreaded Java Application
Part 8 โ Reliability, Debugging and Real-World Applications
๐ Congratulations! You Made It to the Final Class!
Before we begin, take a moment to appreciate how far you have come.
You started this course by learning what a thread is and how to create one in Java.
From there, you gradually explored synchronization, shared data, thread communication, thread pools, concurrent collections, asynchronous programming, virtual threads, debugging, and much more.
And now, you are here โ Class 46, the final class of our Java Multithreading course!
46 classes. 8 parts. One complete learning journey.
Well done! Give yourself a pat on the back. Completing a course is an achievement, but understanding what you have learned and applying it in real projects is where the real growth begins.
Let's finish this journey by building something practical and bringing our learning together.
1. What Are We Going to Build?
Imagine that we are building a simple order processing system for an online shopping application.
Customers place orders, and our application needs to process them.
Suppose five customers place five orders.
We could process these orders one by one. But that would mean waiting for each order to finish before starting the next one.
Instead, we can use multiple worker threads to process different orders concurrently.
Our application will:
- Create a collection of customer orders.
- Use an ExecutorService to manage worker threads.
- Submit each order as a separate task.
- Use Callable to return the result of each task.
- Use AtomicInteger to maintain a thread-safe processed-order count.
- Use ConcurrentHashMap to maintain order statuses safely.
- Use Future to collect task results.
- Shut down the ExecutorService properly.
This is a simplified example of how we can apply multithreading concepts in an application.
2. Understanding the Application Design
Let's first understand the different responsibilities.
Order
Represents an order placed by a customer. It contains an order ID and a product name.
OrderProcessor
Represents the task responsible for processing an individual order.
It implements Callable because we want each task to return a result after processing.
OrderService
Manages the execution of order-processing tasks. It submits tasks, collects their results, and manages the executor's lifecycle.
Shared Data
Multiple worker threads update the processed-order count and order-status collection.
We will use AtomicInteger and ConcurrentHashMap to handle these shared updates safely.
3. Create the Order Class
Let's start with a simple Order class.
public class Order {
private final int orderId;
private final String productName;
public Order(int orderId, String productName) {
this.orderId = orderId;
this.productName = productName;
}
public int getOrderId() {
return orderId;
}
public String getProductName() {
return productName;
}
}
Notice that we have used private final fields.
Once an Order object is created, its data cannot be changed through this class.
This makes it easier to share Order objects between threads because their state is immutable.
4. Create the OrderProcessor Class
Now let's create the task that processes an order.
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class OrderProcessor implements Callable<String> {
private final Order order;
private final AtomicInteger processedCount;
private final ConcurrentHashMap<Integer, String> orderStatuses;
public OrderProcessor(
Order order,
AtomicInteger processedCount,
ConcurrentHashMap<Integer, String> orderStatuses) {
this.order = order;
this.processedCount = processedCount;
this.orderStatuses = orderStatuses;
}
@Override
public String call() throws Exception {
String threadName =
Thread.currentThread().getName();
try {
orderStatuses.put(
order.getOrderId(),
"PROCESSING"
);
System.out.println(
threadName + " started processing Order "
+ order.getOrderId()
);
// Simulate order processing time
Thread.sleep(1000);
orderStatuses.put(
order.getOrderId(),
"COMPLETED"
);
int totalProcessed =
processedCount.incrementAndGet();
return "Order " + order.getOrderId()
+ " for " + order.getProductName()
+ " completed by " + threadName
+ ". Total processed: " + totalProcessed;
} catch (InterruptedException e) {
orderStatuses.put(
order.getOrderId(),
"INTERRUPTED"
);
Thread.currentThread().interrupt();
throw e;
}
}
}
Let's understand what is happening here.
First, our OrderProcessor implements Callable with String as its return type.
This means that the call() method returns a String containing the processing result.
We also receive three objects through the constructor:
- The order being processed.
- An AtomicInteger that maintains the total number of completed orders.
- A ConcurrentHashMap that stores the status of each order.
Multiple OrderProcessor tasks share these objects.
Let's understand why we chose these particular classes.
Why AtomicInteger?
Multiple threads can complete their orders at approximately the same time.
We want to increment the processed-order count safely.
Therefore, we use:
processedCount.incrementAndGet();
This performs an atomic increment operation.
Why ConcurrentHashMap?
Multiple threads update the order-status collection.
A normal HashMap is not designed for concurrent updates without appropriate synchronization.
ConcurrentHashMap provides thread-safe operations for concurrent access.
Therefore, we use it to store order statuses.
5. Create the OrderService Class
Now let's create the service responsible for managing the order-processing tasks.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class OrderService {
private final AtomicInteger processedCount =
new AtomicInteger(0);
private final ConcurrentHashMap<Integer, String> orderStatuses =
new ConcurrentHashMap<>();
public void processOrders(List<Order> orders)
throws InterruptedException {
ExecutorService executor =
Executors.newFixedThreadPool(3);
List<Future<String>> futures =
new ArrayList<>();
try {
// Submit all orders for processing
for (Order order : orders) {
Callable<String> task =
new OrderProcessor(
order,
processedCount,
orderStatuses
);
Future<String> future =
executor.submit(task);
futures.add(future);
}
// No more tasks will be submitted
executor.shutdown();
// Collect the results
for (Future<String> future : futures) {
try {
String result = future.get();
System.out.println(result);
} catch (ExecutionException e) {
System.out.println(
"Order processing failed: "
+ e.getCause()
);
}
}
// Wait for executor termination
if (!executor.awaitTermination(
10, TimeUnit.SECONDS)) {
System.out.println(
"Processing is taking too long."
);
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
throw e;
} finally {
if (!executor.isTerminated()) {
executor.shutdownNow();
}
}
System.out.println(
"Total completed orders: "
+ processedCount.get()
);
System.out.println(
"Final order statuses: "
+ orderStatuses
);
}
}
This is where we bring several concepts together.
Creating the Thread Pool
ExecutorService executor =
Executors.newFixedThreadPool(3);
We create a fixed thread pool containing three worker threads.
This means that up to three order-processing tasks can execute concurrently.
If more than three tasks are submitted, the remaining tasks wait in the executor's work queue until a worker becomes available.
Submitting Tasks
Future<String> future =
executor.submit(task);
We submit each OrderProcessor task to the ExecutorService.
Because the task implements Callable, submit() returns a Future containing the task's result.
Collecting Results
String result = future.get();
We use get() to retrieve the result of each task.
Remember that get() blocks until that particular task completes.
If a task fails, get() throws ExecutionException. We can inspect its cause to understand the original failure.
Shutting Down the Executor
executor.shutdown();
Once all tasks have been submitted, we call shutdown().
This prevents new tasks from being accepted while allowing already submitted tasks to complete.
We then use awaitTermination() to wait for the executor to finish its work.
If the timeout expires, shutdownNow() requests interruption of active tasks and attempts to stop queued tasks from starting.
It does not forcibly terminate threads.
6. Create the Main Class
Finally, let's create the main class that starts our application.
import java.util.Arrays;
import java.util.List;
public class Main {
public static void main(String[] args)
throws InterruptedException {
List<Order> orders = Arrays.asList(
new Order(101, "Laptop"),
new Order(102, "Mobile Phone"),
new Order(103, "Headphones"),
new Order(104, "Keyboard"),
new Order(105, "Monitor")
);
OrderService orderService =
new OrderService();
orderService.processOrders(orders);
}
}
That's it!
We have now created a simple multithreaded order-processing application.
7. Understanding the Complete Execution Flow
Let's understand what happens when we run Main.
- The main thread creates five Order objects.
- OrderService creates a fixed thread pool with three workers.
- Each order is submitted as a Callable task.
- The executor assigns tasks to available worker threads.
- Each worker processes an order concurrently.
- AtomicInteger safely tracks the number of completed orders.
- ConcurrentHashMap safely stores the order statuses.
- Future objects allow the main thread to collect task results.
- The executor is shut down gracefully.
- The application displays the final processing count and order statuses.
Notice that we have five orders but only three worker threads.
Therefore, the executor can process up to three orders concurrently, while the remaining orders wait for an available worker.
8. What Will the Output Look Like?
Your output may look similar to this:
pool-1-thread-1 started processing Order 101
pool-1-thread-2 started processing Order 102
pool-1-thread-3 started processing Order 103
Order 101 for Laptop completed by pool-1-thread-1. Total processed: 1
Order 102 for Mobile Phone completed by pool-1-thread-2. Total processed: 2
Order 103 for Headphones completed by pool-1-thread-3. Total processed: 3
pool-1-thread-1 started processing Order 104
pool-1-thread-2 started processing Order 105
Order 104 for Keyboard completed by pool-1-thread-1. Total processed: 4
Order 105 for Monitor completed by pool-1-thread-2. Total processed: 5
Total completed orders: 5
Final order statuses: {
101=COMPLETED,
102=COMPLETED,
103=COMPLETED,
104=COMPLETED,
105=COMPLETED
}
Your actual output may be different.
The order in which threads start and finish is not guaranteed.
Even though we collect Future results in submission order, the tasks themselves execute concurrently.
This is an important characteristic of multithreaded applications.
9. Which Concepts Did We Use?
Let's connect our application to the concepts we learned throughout this course.
| Java Concept | How We Used It |
|---|---|
| Thread | Worker threads execute order-processing tasks. |
| Callable | Each order-processing task returns a result. |
| ExecutorService | Manages task execution. |
| Fixed Thread Pool | Limits concurrent worker threads to three. |
| Future | Retrieves task results. |
| AtomicInteger | Maintains a thread-safe processed-order count. |
| ConcurrentHashMap | Stores order statuses safely across threads. |
| Thread Interruption | Allows processing tasks to respond to interruption. |
| Graceful Shutdown | Ensures the executor is shut down properly. |
| Immutable Objects | Order data is not modified after creation. |
10. How Can We Improve This Application?
Our application is intentionally simple.
A real-world order-processing system would require additional features.
For example:
- Validate customer and order details.
- Check product availability and inventory.
- Integrate with a payment service.
- Persist order information in a database.
- Retry failed operations where appropriate.
- Implement proper logging and monitoring.
- Handle duplicate requests safely.
- Use message queues when processing orders across multiple services.
We could also introduce concepts such as CompletableFuture, BlockingQueue, Semaphore, and CountDownLatch depending on the requirements.
But remember something important:
We should not use every concurrency feature simply because it exists.
We should choose the simplest tool that solves the actual problem.
That is an important part of becoming a good software engineer.
11. Final Revision โ What Have We Learned?
Let's take a moment to revisit the complete journey of this course.
- We learned what threads and multithreading are.
- We created threads using Thread and Runnable.
- We explored thread lifecycle and thread states.
- We learned about sleep(), join(), and interrupt().
- We understood shared data and race conditions.
- We used synchronized methods and blocks.
- We explored locks, volatile, and atomic variables.
- We learned how threads communicate using wait(), notify(), and notifyAll().
- We explored BlockingQueue, CountDownLatch, CyclicBarrier, and Semaphore.
- We learned how to manage tasks using ExecutorService and thread pools.
- We worked with Runnable, Callable, Future, and ScheduledExecutorService.
- We explored ReentrantLock, ReadWriteLock, and concurrent collections.
- We learned about ThreadLocal and immutable objects.
- We explored CompletableFuture and asynchronous programming.
- We learned about parallel streams and the Fork/Join Framework.
- We explored virtual threads introduced as a standard feature in Java 21.
- We understood deadlock, starvation, and livelock.
- We explored the Java Memory Model and happens-before relationships.
- We learned how to debug and test multithreaded applications.
- And finally, we built a practical multithreaded application.
Test Your Understanding
Let's see how well you understood this final class!
Question 1: Why did we use a fixed thread pool of three threads?
Question 2: Why did OrderProcessor implement Callable<String>?
Question 3: Why did we use AtomicInteger?
Question 4: Why did we use ConcurrentHashMap?
Question 5: What does executor.shutdown() do?
Final Class Summary
In this class, we built a simple multithreaded order-processing application.
- We used ExecutorService to manage worker threads.
- We used Callable to execute tasks that return results.
- We used Future to collect task results.
- We used AtomicInteger to maintain a thread-safe counter.
- We used ConcurrentHashMap to store shared order statuses.
- We handled task failures and interruption.
- We shut down the executor properly.
Most importantly, we saw how individual multithreading concepts can work together to solve a practical problem.
You have completed 46 out of 46 classes.
๐ Course Completed!
Java Multithreading โ 46 / 46 Classes
You have successfully reached the end of this course. Be proud of the effort you have put into learning these concepts.
Your next achievement will not come from simply finishing another lesson. It will come from using what you have learned to build something meaningful.
Keep going. Your learning journey continues!