đ€Ż Thread, Runnable, Callable, ExecutorService, and Future - all the ways to create threads in Java
Daniel Rendox
Posted on June 7, 2023
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();
}
}
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();
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();
}
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
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();
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
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();
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();
}
});
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());
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();
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();
}
Output:
Couldn't complete the task before timeout
Process finished with exit code 0
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)];
}
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
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.
- 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.
-
How to Start a Background Thread in Android (YouTube video) â A practical example of starting a background thread in Android using basic
Thread
andRunnable
(no Android knowledge required) -
Java ExecutorService - Part 1 - Introduction (YouTube video) â if you didnât understand
ExecutorService
from my article -
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.
Posted on June 7, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.