Modern Multithreading in Java
Parallelism vs. Concurrency
Parallelism means multiple tasks are actually executing simultaneously on different CPU cores.
Concurrency means multiple tasks appear to execute simultaneously by switching between them, but actually run sequentially on the same core.
Why not just create threads for everything?
public Order createOrder(Long clientId) {
Thread threadClient = new Thread(() -> getClient(clientId));
Thread threadItem = new Thread(() -> getItem(clientId));
Thread threadCity = new Thread(() -> getCity(clientId));
threadClient.start();
threadItem.start();
threadCity.start();
threadClient.join();
threadItem.join();
threadCity.join();
return createOrder(clientId, item, city);
}Problems with this approach:
- Thread creation overhead: Each thread needs memory (default stack ~1 MB)
- Context switching: CPU time wasted switching between threads
- Resource waste: Many requests = massive thread creation
- Hard to manage: No central control over thread pool
How many threads can a system create?
A simple test:
public class ThreadCapacityTest {
public static void main(String[] args) {
int count = 0;
try {
while (true) {
new Thread(() -> {
try {
Thread.sleep(Long.MAX_VALUE);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
count++;
if (count % 100 == 0) {
System.out.println("Created threads: " + count);
}
}
} catch (OutOfMemoryError e) {
System.out.println("Max threads created: " + count);
}
}
}Typical results:
- Windows: ~10,000-30,000 threads
- Linux: ~30,000-100,000 threads
- Depends on: available RAM, stack size per thread, OS limits
Executor Framework – Better Approach
public Order createOrder(Long clientId) {
try (ExecutorService executor = Executors.newFixedThreadPool(5)) {
Future<Client> clientFuture = executor.submit(() -> getClient(clientId));
Future<Item> itemFuture = executor.submit(() -> getItem(clientId));
Future<City> cityFuture = executor.submit(() -> getCity(clientId));
return createOrder(
clientId,
itemFuture.get(),
cityFuture.get()
);
}
}Benefits:
- Thread pool reuse: Threads are reused, not created each time
- Resource control: Fixed number of threads
- Centralized management: One executor manages all tasks
- Auto-shutdown: try-with-resources closes the pool
Future – Getting Results
Future<String> future = executor.submit(() -> {
Thread.sleep(1000);
return "Result";
});
// Blocks until result is available
String result = future.get();
// With timeout
String result = future.get(2, TimeUnit.SECONDS);
// Check status
boolean isDone = future.isDone();
boolean isCancelled = future.isCancelled();
// Cancel
future.cancel(true);Future limitations:
- Blocking when getting result
- Can’t chain tasks
- No error handling
- Can’t combine multiple futures
CompletableFuture – Modern Asynchrony
public Order createOrder(Long clientId) {
CompletableFuture<Client> clientFuture =
CompletableFuture.supplyAsync(() -> getClient(clientId));
CompletableFuture<Item> itemFuture =
CompletableFuture.supplyAsync(() -> getItem(clientId));
CompletableFuture<City> cityFuture =
CompletableFuture.supplyAsync(() -> getCity(clientId));
CompletableFuture<Void> anyWorkFuture =
CompletableFuture.runAsync(() -> doAnyWork());
return CompletableFuture.allOf(
clientFuture, itemFuture, cityFuture, anyWorkFuture
).thenApply(v -> createOrder(
clientId,
itemFuture.join(),
cityFuture.join()
)).join();
}CompletableFuture methods:
Creation:
// With result
CompletableFuture.supplyAsync(() -> "result");
// Without result (Runnable)
CompletableFuture.runAsync(() -> doWork());
// Already completed
CompletableFuture.completedFuture("value");Chaining:
future.thenApply(result -> transform(result)) // Transformation
.thenAccept(result -> consume(result)) // Consumption
.thenRun(() -> doSomething()) // Action
.thenCompose(r -> anotherAsync(r)); // Flat chainingCombination:
// Wait for both
future1.thenCombine(future2, (r1, r2) -> combine(r1, r2));
// First to complete
CompletableFuture.anyOf(future1, future2, future3);
// Wait for all
CompletableFuture.allOf(future1, future2, future3);Error handling:
future.exceptionally(ex -> defaultValue)
.handle((result, ex) -> {
if (ex != null) return handleError(ex);
return result;
})
.whenComplete((result, ex) -> cleanup());Async variants:
// Default pool (ForkJoinPool.commonPool())
future.thenApplyAsync(r -> transform(r));
// Custom executor
future.thenApplyAsync(r -> transform(r), customExecutor);Practical Example: Parallel API Calls
public OrderDetails getOrderDetails(Long orderId) {
CompletableFuture<Order> orderFuture =
CompletableFuture.supplyAsync(() -> orderService.getOrder(orderId));
CompletableFuture<List<Item>> itemsFuture =
CompletableFuture.supplyAsync(() -> itemService.getItems(orderId));
CompletableFuture<Customer> customerFuture =
orderFuture.thenComposeAsync(order ->
CompletableFuture.supplyAsync(() ->
customerService.getCustomer(order.getCustomerId())
)
);
return CompletableFuture.allOf(orderFuture, itemsFuture, customerFuture)
.thenApply(v -> new OrderDetails(
orderFuture.join(),
itemsFuture.join(),
customerFuture.join()
))
.exceptionally(ex -> {
log.error("Error loading order details", ex);
return OrderDetails.empty();
})
.join();
}Virtual Threads (Project Loom) – Java 19+
Platform Threads (traditional):
- 1:1 mapping to OS threads
- Expensive (each ~1 MB stack)
- Limited by OS
- Blocking operations block the OS thread
Virtual Threads:
- Many-to-few mapping to platform threads (carrier threads)
- Lightweight (minimal overhead)
- Managed by JVM
- Blocking operations only block the virtual thread
// Create virtual thread
Thread.ofVirtual().start(() -> {
System.out.println("Hello from virtual thread");
});
// With ExecutorService
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> future = executor.submit(() -> {
Thread.sleep(1000);
return "Result";
});
System.out.println(future.get());
}
// Structured Concurrency (Preview)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> fetchUser(userId));
Future<List<Order>> orders = scope.fork(() -> fetchOrders(userId));
scope.join();
scope.throwIfFailed();
return new UserWithOrders(user.resultNow(), orders.resultNow());
}When to use virtual threads:
- I/O-intensive operations (DB, HTTP, files)
- Many concurrent connections (web servers)
- Blocking APIs
When to use platform threads:
- CPU-intensive computations
- Long-lived threads
- When pinning is an issue (old synchronized blocks)
Executor Types
// Fixed pool size
ExecutorService fixed = Executors.newFixedThreadPool(10);
// Cached – creates on demand, reuses when possible
ExecutorService cached = Executors.newCachedThreadPool();
// Single thread – guarantees task order
ExecutorService single = Executors.newSingleThreadExecutor();
// Scheduled – for delayed/periodic tasks
ScheduledExecutorService scheduled = Executors.newScheduledThreadPool(5);
scheduled.schedule(() -> task(), 5, TimeUnit.SECONDS);
scheduled.scheduleAtFixedRate(() -> task(), 0, 1, TimeUnit.MINUTES);
// Work stealing – for recursive tasks
ExecutorService workStealing = Executors.newWorkStealingPool();
// Virtual thread executor
ExecutorService virtual = Executors.newVirtualThreadPerTaskExecutor();Best Practices
-
Use thread pools instead of raw threads
// ❌ Bad new Thread(() -> task()).start(); // ✅ Good executor.submit(() -> task()); -
Shut down executors properly
// ❌ Memory leak ExecutorService executor = Executors.newFixedThreadPool(10); // ✅ Auto-close try (ExecutorService executor = Executors.newFixedThreadPool(10)) { // use } -
Handle exceptions in async code
CompletableFuture.supplyAsync(() -> riskyOperation()) .exceptionally(ex -> { log.error("Error", ex); return defaultValue; }); -
Use appropriate executor types
- I/O tasks: cached or virtual threads
- CPU tasks: fixed pool (number of cores)
- Sequential tasks: single thread
-
Avoid shared state
// ❌ Race condition private int counter = 0; executor.submit(() -> counter++); // ✅ Use atomics private AtomicInteger counter = new AtomicInteger(0); executor.submit(() -> counter.incrementAndGet());
Conclusion
Modern Java applications should:
- Use ExecutorService for thread management
- Use CompletableFuture for async operations
- Consider Virtual Threads for highly concurrent I/O
- Avoid raw thread creation
- Manage thread pools properly
This leads to:
- Better resource utilization
- Simpler code
- Better performance
- Easier maintenance