Search code examples
javamultithreadingexecutorservice

ExecutorService vs Casual Thread Spawner


I have a basic question about how ExecutorService works in Java.

It is quite hard to see the difference between simply creating Threads to perform some tasks in parallel and assigning each tasks to the ThreadPool.

The ExecutorService also looks very simple and efficient to use, so I was wondering why we don't use it all the time.

Is it just a matter of one way executing its job faster than the other ?

Here's two very simple examples to show the difference between the two ways :

Using executor service: Hello World (task)

static class HelloTask implements Runnable {
    String msg;

    public HelloTask(String msg) {
        this.msg = msg; 
    }
    public void run() {
        long id = Thread.currentThread().getId();
        System.out.println(msg + " from thread:" + id);
    }
}

Using executor service: Hello World (creating executor, submitting)

static class HelloTask {
    public static void main(String[] args) {
        int ntasks = 1000;
        ExecutorService exs = Executors.newFixedThreadPool(4);

        for (int i=0; i<ntasks; i++) { 
            HelloTask t = new HelloTask("Hello from task " + i);    
            exs.submit(t);
        }
        exs.shutdown();
    }
}

the following shows a similar example but extending the Callable interface, could you tell me the difference between the two and in which cases one should use a specific one instead of the other ?

Using executor service: Counter (task)

static class HelloTaskRet implements Callable<Long> {
    String msg;

    public HelloTaskRet(String msg) {
        this.msg = msg; }

        public Long call() {
        long tid = Thread.currentThread().getId(); 
        System.out.println(msg + " from thread:" + tid); 
        return tid;
    } 
}

Using executor service: (creating, submitting)

static class HelloTaskRet {
    public static void main(String[] args) {
        int ntasks = 1000;
        ExecutorService exs = Executors.newFixedThreadPool(4);

        Future<Long>[] futures = (Future<Long>[]) new Future[ntasks];

        for (int i=0; i<ntasks; i++) { 
            HelloTaskRet t = new HelloTaskRet("Hello from task " + i);
            futures[i] = exs.submit(t);
        }
        exs.shutdown();
    }
}

Solution

  • While the question and the sample code do not correlate, I'll try clarifying both.

    The advantage of ExecutorService over haphazardly spawning threads is that it behaves predictably and avoids the overhead of thread creation, which is relatively big on the JVM (it needs to reserve memory for each thread, for example).

    By predictability, I mean you can control the number of concurrent threads, and you know when and how they might get created and destroyed (so your JVM won't blow up in case of sudden peaks, and threads won't be left abandoned leaking memory). You can pass an ExecutorService instance around so that various parts of your program can submit tasks to it, while you still manage it in a single location, fully transparently. You can then substitute the exact implementation based e.g. on configuration or environment. For example, you may want to have a different amount of pooled threads based on the number of available CPUs.
    An ExecutorService can also be precisely scoped, and shut down when the scope is exited (via shutdown()).

    A fixedThreadPool uses a pool of threads that won't grow beyond what it is allocated.

    A cachedThreadPool doesn't have a max, but will reuse cached threads for a period of time. It's mostly used in cases where many small tasks need to be frequently executed concurrently.

    A singleThreadExecutor is for async tasks executed serially.

    There are others, like newScheduledThreadPool for periodically repeating tasks, newWorkStealingPool for tasks that can be forked into subtasks and a few others. Explore the Executors class for the details.

    As of JVM 18, virtual threads are a thing, and these can be created cheaply, so they change the picture significantly. An ExecutorService that creates a new virtual thread each time can be obtained via newVirtualThreadPerTaskExecutor. The advantage of using this versus spawning threads manually is not performance-centric as much as structural in that it allows for scoping and other benefits explained above, and interoperability with existing APIs that expect an ExecutorService.

    Now, on the topic of Runnable vs Callable, it is easy to see from your examples. Callables can return a value place-holder (Future) that will eventually be populated by an actual value in the future. Runnables can not return anything. Additionally, a Runnable also can't throw exceptions, while a Callable can.