RU | EN | DE

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:

  1. Thread creation overhead: Each thread needs memory (default stack ~1 MB)
  2. Context switching: CPU time wasted switching between threads
  3. Resource waste: Many requests = massive thread creation
  4. 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 chaining

Combination:

// 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

  1. Use thread pools instead of raw threads

    // ❌ Bad
    new Thread(() -> task()).start();
     
    // ✅ Good
    executor.submit(() -> task());
  2. Shut down executors properly

    // ❌ Memory leak
    ExecutorService executor = Executors.newFixedThreadPool(10);
     
    // ✅ Auto-close
    try (ExecutorService executor = Executors.newFixedThreadPool(10)) {
        // use
    }
  3. Handle exceptions in async code

    CompletableFuture.supplyAsync(() -> riskyOperation())
        .exceptionally(ex -> {
            log.error("Error", ex);
            return defaultValue;
        });
  4. Use appropriate executor types

    • I/O tasks: cached or virtual threads
    • CPU tasks: fixed pool (number of cores)
    • Sequential tasks: single thread
  5. 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