Java Threading
Java Threading
Boot Concurrency
Table of Contents
1. Single-Threading
2. Multi-Threading
3. Future
4. CompletableFuture
5. ExecutorService
6. Virtual Threads (Java 19+)
7. Spring-Managed Threads
8. Evolution of Java Concurrency
9. Comparison Table
10. Real-World Mini-Projects
11. Interview Questions & Answers
Single-Threading
What it is
Single-threading means executing one task at a time in sequence. Each task
must complete before the next one begins. In Java, this is the default behavior
of the main thread.
Under the hood: The JVM allocates one thread of execution with its own
stack memory. Tasks are executed sequentially on the CPU core assigned to
this thread.
How to implement it
// Simple single-threaded example
public class SingleThreadedExample {
public static void main(String[] args) {
System.out.println("Task 1 starting...");
performTask("Task 1", 2000);
System.out.println("Task 2 starting...");
performTask("Task 2", 1500);
1
System.out.println("Task 3 starting...");
performTask("Task 3", 1000);
Best Practices
• Keep tasks short and non-blocking
• Handle exceptions properly
• Avoid blocking operations in critical paths
• Use for simple, sequential workflows
Multi-Threading
What it is
Multi-threading allows multiple threads to execute concurrently, potentially on
different CPU cores. Each thread has its own stack but shares heap memory.
Under the hood: The JVM creates multiple threads, each with 1MB stack
space (default). The OS scheduler distributes these threads across available
CPU cores.
How to implement it
Basic Threading
2
public class BasicMultiThreading {
public static void main(String[] args) {
// Method 1: Extending Thread
Thread thread1 = new CustomThread("Thread-1");
thread1.start();
thread2.start();
thread3.start();
@Override
public void run() {
performTask(name, 2000);
}
}
3
this.name = name;
}
@Override
public void run() {
performTask(name, 1500);
}
}
4
// Should be 10,000 with proper synchronization
}
Best Practices
• Always call start(), never run() directly
• Use join() to wait for thread completion
• Handle InterruptedException properly
• Avoid shared mutable state or use synchronization
• Use thread-safe collections when needed
Future
What it is
Future represents the result of an asynchronous computation. It provides meth-
ods to check if the computation is complete, wait for completion, and retrieve
the result.
Under the hood: Future is an interface that acts as a placeholder for a value
that will be available later. It’s typically returned by ExecutorService when
submitting tasks.
How to implement it
Basic Future Usage
import java.util.concurrent.*;
5
try {
// Submit callable tasks that return values
Future<String> future1 = executor.submit(new ApiCallTask("https://api1.com"));
Future<String> future2 = executor.submit(new ApiCallTask("https://api2.com"));
Future<Integer> future3 = executor.submit(new CalculationTask(100));
@Override
public String call() throws Exception {
// Simulate API call
Thread.sleep(2000);
return "Response from " + url;
}
}
6
static class CalculationTask implements Callable<Integer> {
private int input;
@Override
public Integer call() throws Exception {
// Simulate heavy calculation
Thread.sleep(1000);
return input * input;
}
}
}
7
break;
}
}
executor.shutdown();
}
}
Best Practices
• Always use timeouts with get(timeout, unit)
• Handle ExecutionException and InterruptedException
• Cancel futures when no longer needed
• Use isDone() and isCancelled() for status checking
CompletableFuture
What it is
CompletableFuture is an enhanced Future that supports functional-style pro-
gramming with method chaining, composition, and better exception handling.
It implements both Future and CompletionStage interfaces.
Under the hood: Built on top of ForkJoinPool, it uses a callback-based ap-
proach with continuation passing style for non-blocking operations.
How to implement it
Basic CompletableFuture
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
// 1. Completed future
CompletableFuture<String> completed = CompletableFuture.completedFuture("Hello");
8
// 2. Async supplier
CompletableFuture<String> async = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(1000);
return "Async result";
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
// Chain operations
CompletableFuture<String> chained = async
.thenApply(result -> result.toUpperCase())
.thenApply(result -> "Processed: " + result);
// Get results
try {
System.out.println(completed.get());
System.out.println(chained.get(3, TimeUnit.SECONDS));
runAsync.get();
} catch (Exception e) {
e.printStackTrace();
}
}
}
9
.thenCompose(user -> fetchUserPreferences(user))
.thenApply(preferences -> "Processed preferences: " + preferences)
.exceptionally(throwable -> {
System.err.println("Error occurred: " + throwable.getMessage());
return "Default preferences";
});
allOf.thenRun(() -> {
try {
System.out.println("Combined: " + combinedFuture.get());
System.out.println("Processed: " + processedFuture.get());
System.out.println("Handled: " + handledFuture.get());
} catch (Exception e) {
e.printStackTrace();
}
}).join();
}
10
private static CompletableFuture<String> fetchUserProfile(int userId) {
return CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(800);
return "Profile-" + userId;
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
Best Practices
• Use method chaining for readable async workflows
• Handle exceptions with exceptionally() or handle()
• Use thenCompose() for dependent operations, thenCombine() for inde-
pendent ones
• Prefer supplyAsync() over manual thread creation
• Use custom executors for better control
ExecutorService
What it is
ExecutorService is a high-level interface for managing and controlling thread
execution. It provides thread pools, task scheduling, and lifecycle management.
Under the hood: Manages a pool of worker threads, uses blocking queues for
task submission, and handles thread lifecycle automatically.
11
• Resource control to limit concurrent threads
• Task queuing when threads are busy
• Graceful shutdown of threading resources
How to implement it
Basic ExecutorService
import java.util.concurrent.*;
import java.util.List;
import java.util.ArrayList;
// 4. Scheduled executor
ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(2);
try {
// Submit tasks to fixed pool
List<Future<String>> futures = new ArrayList<>();
// Collect results
for (Future<String> future : futures) {
System.out.println(future.get());
}
12
// Schedule recurring task
scheduledExecutor.scheduleAtFixedRate(() -> {
System.out.println("Scheduled task executed at " + System.currentTimeMillis(
}, 0, 2, TimeUnit.SECONDS);
} catch (Exception e) {
e.printStackTrace();
} finally {
// Proper shutdown
shutdownExecutor(fixedPool);
shutdownExecutor(cachedPool);
shutdownExecutor(singleExecutor);
shutdownExecutor(scheduledExecutor);
}
}
Custom ThreadPoolExecutor
public class CustomThreadPoolExample {
public static void main(String[] args) {
// Custom thread pool with specific parameters
ThreadPoolExecutor customExecutor = new ThreadPoolExecutor(
2, // core pool size
4, // maximum pool size
60L, // keep alive time
TimeUnit.SECONDS, // time unit
new ArrayBlockingQueue<>(10), // work queue
new CustomThreadFactory(), // thread factory
new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy
);
13
// Submit tasks
for (int i = 1; i <= 15; i++) {
final int taskId = i;
customExecutor.submit(() -> {
try {
System.out.println("Task " + taskId + " started by " +
Thread.currentThread().getName());
Thread.sleep(2000);
System.out.println("Task " + taskId + " completed");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
customExecutor.shutdown();
}
@Override
public Thread newThread(Runnable r) {
Thread thread = new Thread(r, "CustomWorker-" + counter++);
thread.setDaemon(false);
return thread;
}
}
}
Best Practices
• Choose appropriate thread pool type for your use case
• Always shutdown executors properly
• Handle rejection policies appropriately
• Monitor thread pool metrics in production
• Use custom ThreadFactory for better thread naming
14
Virtual Threads (Java 19+)
What it is
Virtual threads are lightweight threads managed by the JVM rather than the
OS. They’re designed for high-throughput concurrent applications with millions
of threads.
Under the hood: Virtual threads are mapped to a small number of OS threads
(carrier threads) by the JVM. They’re suspended when blocked and resumed
when unblocked, allowing massive concurrency with minimal memory overhead.
How to implement it
Basic Virtual Threads (Java 21+)
// Note: This requires Java 21+ for stable virtual threads
public class VirtualThreadsExample {
public static void main(String[] args) throws InterruptedException {
// Create virtual thread executor
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
15
try {
System.out.println(futures.get(i).get());
} catch (Exception e) {
e.printStackTrace();
}
}
16
Thread.sleep(800);
return "Profile-" + userId;
}
Best Practices
• Use for I/O-intensive applications
• Avoid CPU-intensive tasks in virtual threads
• Don’t use ThreadLocal extensively (memory overhead)
• Use structured concurrency for related task groups
• Monitor carrier thread utilization
Spring-Managed Threads
What it is
Spring provides several mechanisms for async processing including @Async anno-
tation, TaskExecutor, and TaskScheduler. Spring manages the thread lifecycle
and configuration.
Under the hood: Spring uses AOP proxies to intercept @Async method calls
and execute them on configured thread pools.
How to implement it
Basic @Async Setup
// Configuration
@Configuration
@EnableAsync
17
public class AsyncConfig {
@Bean(name = "taskExecutor")
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("Async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Bean(name = "longRunningTaskExecutor")
public TaskExecutor longRunningTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(3);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("LongTask-");
executor.initialize();
return executor;
}
}
// Service class
@Service
public class AsyncService {
@Async("taskExecutor")
public CompletableFuture<String> processDataAsync(String data) {
logger.info("Processing {} on thread {}", data, Thread.currentThread().getName());
try {
// Simulate processing
Thread.sleep(2000);
String result = "Processed: " + data.toUpperCase();
return CompletableFuture.completedFuture(result);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return CompletableFuture.failedFuture(e);
}
}
18
@Async("longRunningTaskExecutor")
public void performBackgroundTask(String taskName) {
logger.info("Background task {} started on thread {}",
taskName, Thread.currentThread().getName());
try {
Thread.sleep(5000);
logger.info("Background task {} completed", taskName);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("Background task {} interrupted", taskName);
}
}
@Async
public CompletableFuture<List<String>> processMultipleItems(List<String> items) {
return CompletableFuture.supplyAsync(() -> {
return items.stream()
.map(item -> "Processed: " + item)
.collect(Collectors.toList());
});
}
}
@RestController
public class AsyncController {
@Autowired
private AsyncService asyncService;
@GetMapping("/process/{data}")
public CompletableFuture<ResponseEntity<String>> processData(@PathVariable String data)
return asyncService.processDataAsync(data)
.thenApply(result -> ResponseEntity.ok(result))
19
.exceptionally(throwable ->
ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error: " + throwable.getMessage()));
}
@PostMapping("/background-task")
public ResponseEntity<String> startBackgroundTask(@RequestBody String taskName) {
asyncService.performBackgroundTask(taskName);
return ResponseEntity.accepted().body("Task started: " + taskName);
}
@PostMapping("/process-batch")
public CompletableFuture<ResponseEntity<List<String>>> processBatch(
@RequestBody List<String> items) {
return asyncService.processMultipleItems(items)
.thenApply(results -> ResponseEntity.ok(results));
}
}
@Override
public void handleUncaughtException(Throwable throwable, Method method, Object... params
logger.error("Async method {} failed with parameters {}",
method.getName(), Arrays.toString(params), throwable);
@Configuration
@EnableAsync
public class AsyncConfigWithExceptionHandler implements AsyncConfigurer {
@Override
20
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(3);
executor.setMaxPoolSize(6);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("SpringAsync-");
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new CustomAsyncExceptionHandler();
}
}
Best Practices
• Configure appropriate thread pool sizes
• Use different executors for different task types
• Implement proper exception handling
• Avoid calling @Async methods from the same class
• Monitor thread pool metrics
@EventListener
@Async("orderProcessingExecutor")
public void handleOrderCreated(OrderCreatedEvent event) {
logger.info("Processing order {} asynchronously", event.getOrderId());
try {
// Simulate order processing
21
Thread.sleep(3000);
// Update inventory
updateInventory(event.getItems());
@EventListener
@Async("notificationExecutor")
public void handlePaymentProcessed(PaymentProcessedEvent event) {
// Send notification asynchronously
sendPaymentNotification(event.getOrderId(), event.getAmount());
}
// Event classes
public class OrderCreatedEvent {
private String orderId;
private List<String> items;
22
public String getOrderId() { return orderId; }
public List<String> getItems() { return items; }
}
@Value("${app.async.enabled:true}")
private boolean asyncEnabled;
@Async
public CompletableFuture<String> processDataAsync(String data) {
try {
Thread.sleep(2000);
return CompletableFuture.completedFuture("Async: " + data.toUpperCase());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return CompletableFuture.failedFuture(e);
}
}
23
try {
Thread.sleep(2000);
return "Sync: " + data.toUpperCase();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}
}
24
// Can handle millions of concurrent tasks
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
// I/O intensive work
});
}
}
Improvements: Massive concurrency, lower memory footprint, simpler pro-
gramming model
Comparison Table
Real-World Mini-Projects
Project 1: API Response Aggregator
@Service
public class ApiAggregatorService {
25
// Single-threaded approach (slow)
public AggregatedResponse fetchDataSingleThreaded(List<String> apiUrls) {
List<String> responses = new ArrayList<>();
long startTime = System.currentTimeMillis();
26
// Virtual threads approach (Java 21+)
public AggregatedResponse fetchDataVirtualThreads(List<String> apiUrls) {
long startTime = System.currentTimeMillis();
List<String> responses = new ArrayList<>();
@Override
public String toString() {
27
return String.format("AggregatedResponse{responses=%d, duration=%dms}",
responses.size(), durationMs);
}
}
@PostConstruct
public void startProcessing() {
// Start job processors
for (int i = 0; i < 3; i++) {
jobProcessor.submit(this::processJobs);
}
28
job.setStatus(JobStatus.PROCESSING);
job.setStartedAt(System.currentTimeMillis());
job.setStatus(JobStatus.COMPLETED);
job.setCompletedAt(System.currentTimeMillis());
} catch (InterruptedException e) {
job.setStatus(JobStatus.FAILED);
Thread.currentThread().interrupt();
} catch (Exception e) {
job.setStatus(JobStatus.FAILED);
logger.error("Job {} failed", job.getId(), e);
}
}
@PreDestroy
public void shutdown() {
jobProcessor.shutdown();
scheduler.shutdown();
try {
if (!jobProcessor.awaitTermination(10, TimeUnit.SECONDS)) {
jobProcessor.shutdownNow();
}
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
jobProcessor.shutdownNow();
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
29
// Job class
public class Job {
private String id;
private JobStatus status;
private long submittedAt;
private long startedAt;
private long completedAt;
private int durationMs;
enum JobStatus {
CREATED, QUEUED, PROCESSING, COMPLETED, FAILED
}
30
// Stage 2: Parse data (CPU intensive)
.thenComposeAsync(rawData -> parseData(rawData), cpuExecutor)
// Stage 3: Process in parallel
.thenComposeAsync(this::processDataInParallel, cpuExecutor)
// Stage 4: Aggregate results
.thenApply(this::aggregateResults)
// Stage 5: Save results (I/O intensive)
.thenComposeAsync(result -> saveResults(result), ioExecutor)
.exceptionally(throwable -> {
logger.error("Pipeline failed", throwable);
return new ProcessingResult("Failed", 0, throwable.getMessage());
});
}
31
List<List<DataItem>> chunks = partition(items, chunkSize);
return chunk.stream()
.map(item -> {
try {
Thread.sleep(50); // Simulate processing
return new ProcessedItem(item.getName(), item.getValue() * 2, "processed
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
})
.collect(Collectors.toList());
}
32
result.setSaved(true);
return result;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}, ioExecutor);
}
@PreDestroy
public void cleanup() {
ioExecutor.shutdown();
cpuExecutor.shutdown();
}
}
// Data classes
class DataItem {
private String name;
private int value;
class ProcessedItem {
private String name;
private int value;
private String status;
33
this.status = status;
}
class ProcessingResult {
private String status;
private int totalValue;
private String errorMessage;
private boolean saved = false;
@Async("notificationExecutor")
public CompletableFuture<Void> sendNotification(Notification notification) {
List<NotificationListener> listeners = subscribers.get(notification.getTopic());
34
.collect(Collectors.toList());
listener.onNotification(notification);
} catch (Exception e) {
logger.error("Failed to send notification {} to listener {}",
notification.getId(), listener.getId(), e);
}
}, notificationExecutor);
}
@PreDestroy
public void shutdown() {
notificationExecutor.shutdown();
try {
if (!notificationExecutor.awaitTermination(10, TimeUnit.SECONDS)) {
notificationExecutor.shutdownNow();
}
35
} catch (InterruptedException e) {
notificationExecutor.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
// Supporting classes
class Notification {
private String id;
private String topic;
private String message;
private long timestamp;
// getters
public String getId() { return id; }
public String getTopic() { return topic; }
public String getMessage() { return message; }
public long getTimestamp() { return timestamp; }
}
interface NotificationListener {
String getId();
void onNotification(Notification notification);
}
@Component
class EmailNotificationListener implements NotificationListener {
@Override
public String getId() {
return "email-listener";
}
@Override
public void onNotification(Notification notification) {
logger.info("Sending email for notification: {}", notification.getMessage());
// Email sending logic
}
}
36
Interview Questions & Answers
Q1: What’s the difference between submit() and execute() in Execu-
torService?
Answer: - execute(Runnable): Fire-and-forget, no return value, exceptions
are handled by UncaughtExceptionHandler - submit(Callable/Runnable):
Returns Future, exceptions can be retrieved via future.get()
// execute() - no return value
executor.execute(() -> System.out.println("Fire and forget"));
// BETTER - non-blocking
future.thenAccept(result -> processResult(result));
Q3: What happens if you call an @Async method from the same
class?
Answer: The method executes synchronously because Spring’s AOP proxy is
bypassed (self-invocation problem).
@Service
public class AsyncService {
@Async
public void asyncMethod() {
// This will be async when called from outside
}
37
}
}
@Autowired
private AsyncService self; // Self-injection
38
// Traditional Future
try {
String result = future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause(); // Original exception
}
// CompletableFuture
CompletableFuture<String> future = CompletableFuture
.supplyAsync(this::riskyOperation)
.exceptionally(throwable -> "Default value")
.handle((result, throwable) -> {
if (throwable != null) {
logger.error("Operation failed", throwable);
return "Error occurred";
}
return result;
});
// Non-blocking approach
future.thenAccept(result -> processResult(result)); // Thread continues immediately
39
// I/O-intensive pool
int ioThreads = cpuThreads * 10; // Higher multiplier for I/O
ExecutorService ioPool = Executors.newFixedThreadPool(ioThreads);
40
• Use @Retryable for transient failures
41