Concurrency with Threads and Runnables in Java
Java provides powerful tools for achieving concurrency – the ability to execute multiple tasks seemingly simultaneously. Two fundamental ways to create concurrent tasks are by extending the Thread class or implementing the Runnable interface. This tutorial will explore both approaches, outlining their differences, benefits, and when to choose one over the other.
Understanding Threads and Runnables
At its core, a thread represents a single flow of execution within a program. The Java Virtual Machine (JVM) manages these threads, allowing them to run concurrently.
ThreadClass: Directly extending theThreadclass creates a new type of thread. You override therun()method to define the task this thread will execute.RunnableInterface: TheRunnableinterface is a functional interface with a single method,run(). ImplementingRunnableallows you to define a task that can be executed by a thread. You then create aThreadobject and pass yourRunnableinstance to it.
Implementing Threads vs. Runnables: The Code
Let’s examine how both approaches look in code.
Extending Thread:
public class MyThread extends Thread {
@Override
public void run() {
// Code to be executed by the thread
System.out.println("MyThread is running");
}
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start(); // Starts the thread, calling the run() method in a new thread of execution
}
}
Implementing Runnable:
public class MyRunnable implements Runnable {
@Override
public void run() {
// Code to be executed by the thread
System.out.println("MyRunnable is running");
}
public static void main(String[] args) {
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start(); // Starts a new thread, executing the run() method of MyRunnable
}
}
Both code snippets achieve the same outcome: a new thread is created and executes a specified task. However, there’s a crucial difference in terms of design and flexibility.
Key Differences and Considerations
1. Flexibility and Inheritance:
The most significant difference lies in flexibility. Java only supports single inheritance – a class can extend only one other class. If your class needs to extend another class for any reason, you cannot directly extend Thread. However, you can implement Runnable and still extend another class, providing greater design freedom.
2. Separation of Concerns:
Implementing Runnable promotes a clearer separation of concerns. It separates the task (what the thread does) from the thread itself (how the task is executed). This makes your code more modular and easier to test. You can reuse the same Runnable instance with different threads or even execute it in a single-threaded environment without modification.
3. Reusability:
Because Runnable is an interface, you can easily pass instances of classes implementing it to thread pools or other concurrent execution frameworks. This reusability is a major advantage in complex applications.
4. Functional Programming:
With the introduction of lambda expressions in Java 8, implementing Runnable becomes even more concise:
Runnable myRunnable = () -> System.out.println("Runnable with lambda");
new Thread(myRunnable).start();
Modern Concurrency: Beyond Raw Threads
While directly creating and managing threads is a fundamental concept, modern Java concurrency often utilizes higher-level abstractions like ExecutorService. ExecutorService manages a pool of threads, simplifying thread management and improving performance. ExecutorService typically accepts Runnable or Callable tasks.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExecutorExample {
public static void main(String[] args) {
ExecutorService executor = Executors.newCachedThreadPool();
Runnable task = () -> System.out.println("Task executed by the executor");
executor.execute(task);
executor.shutdown(); // Important to shutdown the executor when finished
}
}
Best Practices
- Prefer
Runnableover extendingThread: Unless you have a specific reason to extendThread, implementingRunnableprovides greater flexibility and promotes better design. - Use
ExecutorServicefor thread management: LeverageExecutorServiceto simplify thread management, improve performance, and avoid common concurrency pitfalls. - Consider
Callablefor tasks that return a value: TheCallableinterface is similar toRunnable, but it allows the task to return a result. - Handle exceptions appropriately: Properly handle exceptions within your threads to prevent unexpected crashes and ensure application stability.