A practical guide to Java concurrency with executors and CountDownLatch

Modern Java applications often perform several independent tasks at the same time: calling payment services, loading customer data, resizing images, or checking stock levels. Running these operations concurrently can reduce response times, but creating and managing raw threads quickly becomes difficult. Executors and CountDownLatch provide a clearer way to control worker threads and coordinate dependent tasks.

This guide focuses on practical patterns that work in Spring applications, REST APIs, scheduled jobs, and background services. The examples are relevant to Australian systems, where an application may need to handle traffic across Sydney, Melbourne, Brisbane, and Perth while integrating with local banks, delivery providers, or payment platforms.

Why use an executor service

An ExecutorService manages a pool of reusable threads. Instead of creating a new Thread for every operation, an application submits tasks to the executor, which controls how many can run at once. This reduces thread creation overhead and makes resource usage easier to predict.

ExecutorService executor = Executors.newFixedThreadPool(4);

Future<String> result = executor.submit(() -> {
    return "Task completed";
});

System.out.println(result.get());
executor.shutdown();

The fixed pool is useful when the workload is known and bounded. For CPU-heavy work, the pool size is often close to the number of available processors. For tasks waiting on network responses, such as calls to Australian payment gateways, a somewhat larger pool may be appropriate. Always shut down the executor when the application no longer needs it.

Coordinating tasks with CountDownLatch

A CountDownLatch lets one or more threads wait until a specific number of operations have finished. You create it with a count, and each completed task calls countDown(). A coordinating thread calls await() and continues only when the count reaches zero.

int taskCount = 3;
CountDownLatch latch = new CountDownLatch(taskCount);
ExecutorService executor = Executors.newFixedThreadPool(taskCount);

for (int i = 0; i < taskCount; i++) {
    executor.submit(() -> {
        try {
            loadData();
        } finally {
            latch.countDown();
        }
    });
}

latch.await(5, TimeUnit.SECONDS);
executor.shutdown();

The finally block is essential. If a task fails before calling countDown(), the waiting thread could remain blocked indefinitely. The timeout also prevents a slow external service from holding an API request forever, which is especially important when users are accessing a service over variable mobile connections.

Handling failures and interruption

A latch only tracks completion; it does not automatically report whether a task succeeded. For meaningful error handling, collect exceptions in a thread-safe structure or use Future objects and inspect their results.

List<Future<Boolean>> jobs = new ArrayList<>();

for (int i = 0; i < 3; i++) {
    jobs.add(executor.submit(() -> {
        verifyCustomerRecord();
        return true;
    }));
}

for (Future<Boolean> job : jobs) {
    try {
        job.get(3, TimeUnit.SECONDS);
    } catch (ExecutionException | TimeoutException ex) {
        System.err.println("Verification failed: " + ex.getMessage());
    }
}

Interrupted exceptions should usually restore the interruption flag rather than being silently ignored. This allows Spring, a servlet container, or a scheduled task manager to stop work correctly.

try {
    latch.await();
} catch (InterruptedException ex) {
    Thread.currentThread().interrupt();
    throw new IllegalStateException("Task was interrupted", ex);
}

Applying concurrency to real services

A common API pattern is to load several independent resources before building a response. For example, a property platform serving customers in Sydney could retrieve listings, inspection times, and suburb statistics concurrently. A latch can coordinate the three operations, while shared result objects should be thread-safe or independently assigned.

Concurrency is also useful for protecting downstream systems. A large promotional campaign can generate many requests at once, so a bounded executor prevents unlimited work from reaching an external provider. For request pacing, a Java rate limiter can be combined with an executor to control both parallelism and request frequency.

For Australian businesses, concurrency design should account for payment retries, Australian Eastern or Western time zones, and local privacy obligations under the Privacy Act 1988. Avoid placing personal information in task logs, and define clear limits for calls to banking, identity, and shipping services.

Choosing better modern alternatives

CountDownLatch is ideal when a group of tasks must finish once and the count never needs to increase. It cannot be reset, so a new latch is required for each operation. For repeated coordination, CyclicBarrier or Phaser may be a better fit.

In newer Java applications, CompletableFuture can express dependent and combined asynchronous operations without manually managing a latch.

CompletableFuture<String> profile =
        CompletableFuture.supplyAsync(this::loadProfile, executor);

CompletableFuture<List<String>> orders =
        CompletableFuture.supplyAsync(this::loadOrders, executor);

CompletableFuture<String> response = profile.thenCombine(
        orders,
        (user, userOrders) -> user + " has " + userOrders.size() + " orders"
);

Whichever API you choose, define pool sizes, timeouts, rejection behaviour, and shutdown rules explicitly. Measure queue length and task duration in production, particularly for services operating during Australian end-of-financial-year peaks or major retail events. Good observability turns concurrency from a source of unpredictable delays into a controlled performance tool.