🤯 Thread, Runnable, Callable, ExecutorService, and Future - all the ways to create threads in Java

Daniel Rendox - Jun 7 '23 - - Dev Community

Table of Contents

 1. Intro
 2. By extending Thread class
 3. By implementing Runnable interface
 4. ☠️ How does a Java thread die?
 5. By creating a new thread pool
 6. By using ExecutorService with Callable and Future
 7. By using CompletableFuture (for those who want to dive even deeper)
 8. Conclusion
 9. Useful resources

Intro

If you read the previous article, you already know how a computer interprets threads. Now we are going to look at 5 ways to create threads in Java and the differences between them.

1. By extending Thread class

The most apparent (but in many cases not a proper) way to create a thread is to extend Thread class and override run() method. Use it only when you want to expand the functionality of Thread.

public class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Hello from a new thread!");
    }
}

public class Main {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
    }
}
Enter fullscreen mode Exit fullscreen mode

2. By implementing Runnable interface

If you don’t want to expand Thread functionality but just want to do some stuff in a new thread, implementing Runnable is the best way to do it. But creating it, you only specify what should be done in a separate thread. To actually launch it you should create a new Thread, pass the Runnable as a parameter and run the start() method.

Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello from a new thread!");
    }
});
thread.start();
Enter fullscreen mode Exit fullscreen mode

If you are going to use the Runnable several times, it’s worth making a separate interface instead of using an anonymous class. Otherwise, you should use lambdas to make it more concise (I didn’t use lambdas for clarity).

☠️ How does a Java thread die?

When a thread has done all the operations it was programmed to do, it dies. And after that happens, you will not be able to start it again. To demonstrate this, I wrote this code, which is not necessary for you to analyze, but may be helpful to understand the topic, especially for those who like to do a deep dive. You can also try to do that yourself and then see my code, which will be good practice for the topic.

public static void main(String[] args) throws Exception{
  Thread stopTestThread = new Thread(() -> {
      System.out.println("Hello from " + Thread.currentThread().getName());
      for (int i = 0; i < 3; i++){
          System.out.println("I'm running " + (i+1));
          // this method tells the current thread (which is stopTestThread in our
          // case, as the code is written within Runnable run() method) to pause
          // for 1 sec (1000 milliseconds)
          try {
              Thread.sleep(1000);
          } catch (InterruptedException e) {
              throw new RuntimeException(e);
          }
      }
      System.out.println("Now I'm going to stop");
  }, "StopTestThread");

  // the following line invokes the stopTestThread for the first time
  // the code in the lambda gets executed and the thread stops
  stopTestThread.start();

  stopTestThread.join();

  // now it's main (root) thread's job
  // this is the thread that runs the app by default
  System.out.println();
  System.out.println("Hello from " + Thread.currentThread().getName());
  System.out.println("Trying to invoke " + stopTestThread.getName());

  // We'll get IllegalThreadStateException, because a thread
  // can't be started for the second time
  stopTestThread.start();
}
Enter fullscreen mode Exit fullscreen mode

Output:

Hello from StopTestThread
I'm running 1
I'm running 2
I'm running 3
Now I'm going to stop

Hello from main
Trying to invoke StopTestThread
Exception in thread "main" java.lang.IllegalThreadStateException
Enter fullscreen mode Exit fullscreen mode

In this code, we create a new thread and call it StopTestThread using the 2-param constructor. In the first param, we use lambda to create a new Runnable telling the thread what to do. In particular, the thread is going to greet the user, run for 3 sec, and tell when it's going to stop.

Then the newly created StopTestThread gets executed. While the separate thread does its work, the main thread, which executes every Java program by default, continues its work. It prints some statements and then tries to start the StopTestThread again.

You may notice that despite StopTestThread’s death we can call its getName() and isAlive() methods. However, it doesn’t mean we can bring it back to life.

join()

You may also note that unless we write stopTestThread.join() StopTestThread and main will work simultaneously and the lines will not be printed sequentially, which is unwanted in our situation.

This method tells the thread that calls it (in our case main thread), to wait until the thread on which it’s being called (stopTestThread) finishes its work.

Another option is to specify a time interval as an argument for join. This way, the thread will pause for that duration and then resume its task regardless of whether another thread has completed the operation or not.

3. By creating a new thread pool

When you use several threads in your program, you probably do this to speed up a process. But a Java thread corresponds to a system thread, and as was mentioned in the previous article, their number is limited to the number of CPUs and their cores. Also remember, there are other apps that require some threads too.

Creating too many threads, for example, 100, is not efficient because only some of them will be scheduled. The rest will wait until the ones that are executing finish their work and die. Only then will they take their place. In addition, many threads consume lots of time and resources being born and dying.

That’s why, you should prefer thread pools over multiple instances of Thread. This way allows us to create a reasonable number of threads that will not die as long as they have done an operation, they will switch to another one instead. And this is how to do it:

ExecutorService executorService = Executors.newFixedThreadPool(threadsNumber);
for (int i = 0; i < 20; i++) {
    executorService.submit(() -> System.out.println("Hello from: " + Thread.currentThread().getName()));
}
executorService.submit(() -> System.out.println("Another task executed by " + Thread.currentThread().getName()));
executorService.shutdown();
Enter fullscreen mode Exit fullscreen mode

Output:

Hello from: pool-1-thread-3
Hello from: pool-1-thread-8
Hello from: pool-1-thread-6
Hello from: pool-1-thread-6
Hello from: pool-1-thread-6
Hello from: pool-1-thread-6
Hello from: pool-1-thread-6
Hello from: pool-1-thread-6
Hello from: pool-1-thread-6
Hello from: pool-1-thread-6
Hello from: pool-1-thread-6
Hello from: pool-1-thread-6
Hello from: pool-1-thread-6
Hello from: pool-1-thread-6
Hello from: pool-1-thread-7
Hello from: pool-1-thread-5
Hello from: pool-1-thread-3
Hello from: pool-1-thread-2
Hello from: pool-1-thread-1
Hello from: pool-1-thread-4
Another task executed by pool-1-thread-6
Enter fullscreen mode Exit fullscreen mode

For this, you just do the same but use ExecutorService . In the code above a new ThreadPool is created with the number of threads equal to threadsNumber. It’s usually a good decision to make as many threads as there are processors available for the JVM:

int threadsNumber = Runtime.getRuntime().availableProcessors();
Enter fullscreen mode Exit fullscreen mode

The value of this number depends on the machine and its current state, such as the available resources and the apps that are running. For example, my computer had 8 available CPU cores at that time.

So the code above just creates an X number of threads, which do some action, 20 times, in this case, print their names. Mind you, the action is performed 20 times in total, not by each thread individually. You can also submit other tasks as I did below the for loop.

The output shows that the programmer does not control which thread executes a task, but the ExecutorService and the ThreadScheduler do. As you see, for some reason, the 6th thread does most of the work, and it’s completely ok. The results are going to be different each time.

submit() method tells a thread what should be done and starts it. You should also shut down the executor to stop the program from running forever.

There are also other factory methods for creating thread pools, other overloaded submit() methods, as well as other methods to shut down the executor, but these are beyond the article.

4. By using ExecutorService with Callable and Future

All this time we’ve created new Threads using Runnable. But you may have noticed that it has a downside - it can’t return a value.

Of course, you can create a data class and use Runnable to keep some values. But there is a problem. In single-thread programs, you can use a variable immediately after it has been populated. In multi-thread programs, a variable may be assigned a value in a different thread. How do you determine if the variable has a value or not?

So this approach isn’t effective and may lead to errors, but the problem can be solved by using Callable instead of Runnable:

executorService.submit(new Callable<>() {
    @Override
    public Integer call() {
        return new Random().nextInt();
    }
});
Enter fullscreen mode Exit fullscreen mode

This code makes a separate thread to make up a random int value and return it.

Basically, you do the same, but when you submit a task to the ExecutorService, you use Callable. Again, I used Anonymous class for clarity, but you should use lambda for conciseness.

If you replace the respective part in the code snippet from the “By creating a new thread pool” section with this one that uses Callable, the code will compile and run without any issues, but you will not get the returned result because you just don’t assign it to any variable.

To get the result, you should write:

// If your value is not int, set the generic type.
Future<Integer> future = executor.submit(() -> new Random().nextInt());
Enter fullscreen mode Exit fullscreen mode

In our case, we generate a random int, which is done instantly. But you will usually want to do some code that performs some long-time operations and only then returns the result. That’s why we can’t simply assign that result to an int or smth else. Future is used instead. Future is a placeholder. It doesn’t contain any value as long as the new thread hasn’t finished its work.

While the separate thread is calculating something, the main thread continues its work. And when you think it’s finally time the value has got calculated, you write future.get() and get the actual value. But be careful: this time if the value hasn’t yet been assigned and the future is still empty, the main thread will have to wait until it happens. Fortunately, we have some useful methods to control the process, such as:

// wait for 1 sec and then throw TimeoutException, if it still hasn't finished
future.get(1, TimeUnit.SECONDS);

future.cancel(true);
future.isCancelled();
future.isDone();
Enter fullscreen mode Exit fullscreen mode

Finally, Callable can not be used with Thread, so if you want to create a single thread with Callable write Executors.*newSingleThreadExecutor*().

Here is the full code for ExecutorService with Callable:

public static void main(String[] args) {
    ExecutorService executor = Executors.newSingleThreadExecutor();
    Future<Integer> future = executor.submit(() -> {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return new Random().nextInt();
    });

    try {
        System.out.println("Result: " + future.get(1, TimeUnit.SECONDS));
    } catch (InterruptedException | ExecutionException e) {
        e.printStackTrace();
    } catch (TimeoutException e) {
        System.out.println("Couldn't complete the task before timeout");
    }

    executor.shutdown();
}
Enter fullscreen mode Exit fullscreen mode

Output:

Couldn't complete the task before timeout

Process finished with exit code 0
Enter fullscreen mode Exit fullscreen mode

5. By using CompletableFuture (for those who want to dive even deeper)

You may have noticed that while Future provides convenient functionality, we still can’t perform a task right after it has been populated because we don’t know the exact time. Actually, we can, but this approach may lead to blocking the main thread (it will have to wait if the future is still a placeholder).

If we don’t want to block the main thread, why not use another separate thread 💁‍♂️ ? Ok, but how can we notify it that the task is already performed? CompletableFuture can help. Let's consider the following code:

public static void main(String[] args) {
  String[] names = {"Daniel", "Mary", "Alex", "Andrew"};

  CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(() -> {
      System.out.println("Hello from " + Thread.currentThread().getName());
      System.out.println("I'll give you the name in 5 sec");
      for (int i = 5; i > 0; i--){
          System.out.println(i);
          try {
              Thread.sleep(1000);
          } catch (InterruptedException e) {
              System.out.println("Exception while sleeping in " + Thread.currentThread().getName());
              e.printStackTrace();
          }
      }
      return randomElement(names);
  });

  completableFuture.thenAcceptAsync(name -> {
      System.out.println("Hello from " + Thread.currentThread().getName());
      System.out.println("Now I'm going to print you the name");
      System.out.println("Result: " + name);
  });

  try {
      Thread.sleep(10_000);
  } catch (InterruptedException e) {
      System.out.println("Exception while sleeping in " + Thread.currentThread().getName());
      e.printStackTrace();
  }
}

private static String randomElement(String[] array) {
  return array[new Random().nextInt(array.length)];
}
Enter fullscreen mode Exit fullscreen mode

In this code, we create a CompletableFuture<T> using the factory method supplyAsync. The code within the lambda expression is going to be executed in a separate thread. What exactly will happen is this thread will print a 5-sec countdown to the console and then return a random name from the names[] array, which will be assigned to the completableFuture variable.

For now, it is similar to Future and we may get the value from the placeholder using .get(). But then we invoke completableFuture.thenAcceptAsync, which creates a new thread, and the task within the lambda expression is executed right after the completableFuture variable has got populated. Now, we’ve achieved the functionality we wanted.

While other threads are meticulously choosing a name from the array and printing it, the main thread does some more important stuff (sleeps for 10 sec) 😆.

😈 Daemon thread - Another important thing

There is an interesting thing here. If you remove the try-catch block that invokes the Thread.sleep(10_000) , the output will be as follows:

Process finished with exit code 0
Enter fullscreen mode Exit fullscreen mode

Why do you think it’s so? Nothing gets printed on the console.

That’s because we didn’t write join(). The main thread doesn’t wait until the other ones finish. As it is not programmed to do anything after launching them, it finishes its work and exits the program. However, other threads are still running but don't print anything on the console because they don't have access to it. If we’d invoked join() it would’ve waited for them to finish.

But it only applies to 😈 Daemon threads. The main thread never waits for them to finish unless join() is invoked. In our case, the thread internally created by completableFuture is a daemon thread. Another example of a daemon thread is Garbage collector (it may still run after the program has finished).

More info about daemon threads: The created thread is a daemon by default if it’s created by another daemon thread, the opposite is true. The main thread is by default non-daemon.

Why then the thread internally created by completableFuture is a daemon? Because CompletableFuture.supplyAsync is managed by an internal ForkJoinPool. The default behavior of the ForkJoinPool is to create daemon threads.

You can control a thread’s respective behavior with the methods:

  • public final boolean isDaemon()
  • public final void setDaemon(boolean on)

🤯 Why should I use it?

Now you may be wondering why should we do all this complicated stuff if we can just use simple Runnable with Thread and achieve the same with more concise and effective code. We shouldn’t! Remember: it’s just an example. With CompletableFuture we can solve a wide range of problems including chaining multiple asynchronous operations together, exception handling, completion handling, and other advanced features. So if you are working on a complicated project that requires them, don’t reinvent the wheel, use such efficient libraries and frameworks as CompletableFuture instead. By the way, you can use CompletableFuture with ExecutorService.

To my mind, you shouldn’t master CompletableFuture as long as you don’t need it. Just know that there are ways to perform a task asynchronously without blocking. But if you indeed need CompletableFuture, I advise you to read this article as well as other articles from the “Useful resources” section.

Conclusion

Phew! Well done if you’ve worked through all of these! You are an exceptionally good learner, if you ran the code from the last section and noticed that the operations actually run in the same thread. Again, it has many other nuances. In this article, I tried to cover only the necessary things.

To sum up, Java offers various options for multithreading, ranging from the simple Thread and Runnable to the complex CompletableFuture. Use Thread with Runnable to create a new thread that performs an operation, but doesn’t return anything. If you want to use multiple threads, prefer ExecutorService. If you want to return something, use it with Callable. For complicated stuff like chaining multiple asynchronous operations together, exception handling, and completion handling use CompletableFuture and other useful frameworks.

Useful resources

Here are some useful resources that I learned from. You can check them out if you didn’t understand something or want to learn more about some topics.

  1. Thread Scheduler & Thread Priority | GeeksforGeeks (YouTube video) — Good visual representation of how threads are scheduled, and why not much depends on the programmer. Also explains the uncovered topic here - Thread Priority.
  2. How to Start a Background Thread in Android (YouTube video) — A practical example of starting a background thread in Android using basic Thread and Runnable (no Android knowledge required)
  3. Java ExecutorService - Part 1 - Introduction (YouTube video) — if you didn’t understand ExecutorService from my article
  4. Java CompletableFuture Tutorial with Examples (article) — if you want to master CompletableFuture.

Thank you for reading this article! I hope you learned something from it. If you did, please provide some feedback. If you have any questions or corrections, I would love to hear from you in the comments.

. . . . . . . . . . . . . . . . . . . .