Backend Development 15 min read

10 Tips and Tricks for Using ExecutorService in Java

This article presents ten practical tips for Java developers on naming thread‑pool threads, dynamically changing thread names, safely shutting down executors, handling interruptions, bounding queue sizes, proper exception handling, monitoring wait times, preserving client stack traces, preferring CompletableFuture, and using SynchronousQueue with ThreadPoolExecutor.

Cognitive Technology Team
Cognitive Technology Team
Cognitive Technology Team
10 Tips and Tricks for Using ExecutorService in Java

ExecutorService has been part of Java since version 5 (2004). Java 5 and 6 are no longer supported and Java 7 will lose support soon, yet many Java developers still do not fully understand how ExecutorService works. This article shares several lesser‑known features and best‑practice techniques aimed at intermediate programmers.

1. Name the threads in a thread pool

The default naming scheme for threads created by a pool is pool-N-thread-M , where N is the pool sequence number and M is the thread sequence within that pool. This scheme is not very informative. Guava provides a helper class to customize the names:

import com.google.common.util.concurrent.ThreadFactoryBuilder;
final ThreadFactory threadFactory = new ThreadFactoryBuilder()
        .setNameFormat("Orders-%d")
        .setDaemon(true)
        .build();
final ExecutorService executorService = Executors.newFixedThreadPool(10, threadFactory);

By default the pool creates non‑daemon threads; you decide whether daemon threads are appropriate.

2. Change the name according to context

When you know the logical operation a thread is performing, you can rename it at runtime. This helps to trace slow or dead‑locked tasks. Example:

private void process(String messageId) {
    executorService.submit(() -> {
        final Thread currentThread = Thread.currentThread();
        final String oldName = currentThread.getName();
        currentThread.setName("Processing-" + messageId);
        try {
            // real logic here …
        } finally {
            currentThread.setName(oldName);
        }
    });
}

During the try‑finally block the thread is named Processing‑<messageId> , which is useful for tracking message flow.

3. Shut down explicitly and safely

When the application stops you must decide what to do with queued and running tasks. Use shutdown() to let queued tasks finish, or shutdownNow() to discard them. Example of graceful shutdown:

private void sendAllEmails(List
emails) throws InterruptedException {
    emails.forEach(email ->
        executorService.submit(() -> sendEmail(email)));
    executorService.shutdown();
    final boolean done = executorService.awaitTermination(1, TimeUnit.MINUTES);
    log.debug("All emails sent so far? {}", done);
}

If tasks remain after the timeout, awaitTermination() returns false . An alternative is to use shutdownNow() and inspect the list of rejected tasks.

4. Be careful with interruptions

The Future interface allows cancellation. When a Runnable throws an exception, the thread pool swallows it unless you handle it yourself. For Callable you must call Future.get() to re‑throw the exception.

5. Monitor queue length and keep it bounded

An unbounded queue can cause memory leaks or slowdowns. Use a bounded ArrayBlockingQueue instead of the default LinkedBlockingQueue :

final BlockingQueue
queue = new ArrayBlockingQueue<>(100);
executorService = new ThreadPoolExecutor(n, n, 0L, TimeUnit.MILLISECONDS, queue);

When the queue reaches its capacity, new tasks are rejected with a RejectedExecutionException . You can periodically log queue.size() for monitoring.

6. Remember exception handling

Submitting a task that throws an exception without handling it will silently swallow the error. Example:

executorService.submit(() -> {
    System.out.println(1 / 0);
});

To see the exception you must either submit a Callable and call Future.get() , or wrap the Runnable in a try‑catch that logs the error.

7. Monitor wait time in the queue

Measuring the time a task spends waiting before execution can reveal bottlenecks. A simple wrapper around ExecutorService can log this duration:

public class WaitTimeMonitoringExecutorService implements ExecutorService {
    private final ExecutorService target;
    public WaitTimeMonitoringExecutorService(ExecutorService target) {
        this.target = target;
    }
    @Override
    public
Future
submit(Callable
task) {
        final long startTime = System.currentTimeMillis();
        return target.submit(() -> {
            final long queueDuration = System.currentTimeMillis() - startTime;
            log.debug("Task {} waited {} ms in queue", task, queueDuration);
            return task.call();
        });
    }
    // other submit overloads omitted for brevity
}

This implementation records the moment of submission and logs the elapsed time when the task actually starts.

8. Preserve client stack trace

When a task fails, the standard stack trace shows only the worker thread, not the code that submitted the task. By capturing a client‑side exception at submission time you can log the original location:

public class ExecutorServiceWithClientTrace implements ExecutorService {
    protected final ExecutorService target;
    public ExecutorServiceWithClientTrace(ExecutorService target) {
        this.target = target;
    }
    @Override
    public
Future
submit(Callable
task) {
        return target.submit(wrap(task, clientTrace(), Thread.currentThread().getName()));
    }
    private
Callable
wrap(final Callable
task, final Exception clientStack, String clientThreadName) {
        return () -> {
            try {
                return task.call();
            } catch (Exception e) {
                log.error("Exception in task submitted from {}: {}", clientThreadName, clientStack, e);
                throw e;
            }
        };
    }
    private Exception clientTrace() {
        return new Exception("Client stack trace");
    }
    // other methods omitted for brevity
}

The logged output now includes both the original submission location and the worker thread information.

9. Prefer CompletableFuture

Java 8 introduced CompletableFuture , which offers a richer API than the plain Future . Prefer it over submitting tasks directly to an ExecutorService :

// Instead of:
final Future
future = executorService.submit(this::calculate);
// Use:
final CompletableFuture
future = CompletableFuture.supplyAsync(this::calculate, executorService);

CompletableFuture extends Future and provides powerful composition features.

10. SynchronousQueue

SynchronousQueue is a special BlockingQueue with zero capacity; each insert must wait for a matching remove . Using it with ThreadPoolExecutor creates a pool that only accepts a task when a thread is idle:

BlockingQueue
queue = new SynchronousQueue<>();
ExecutorService executorService = new ThreadPoolExecutor(2, 2, 0L, TimeUnit.MILLISECONDS, queue);

When all threads are busy, new tasks are rejected immediately, which can be useful for workloads that must start instantly or be discarded.

These ten tips should help you write more robust, observable, and maintainable concurrent Java code.

Javaconcurrencythreadpoolbest practicesExecutorService
Cognitive Technology Team
Written by

Cognitive Technology Team

Cognitive Technology Team regularly delivers the latest IT news, original content, programming tutorials and experience sharing, with daily perks awaiting you.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.