Search⌘ K
AI Features

Creating Threads

Understand how to create threads in Java by implementing Runnable or extending Thread. Learn to start threads properly, use lambdas for concise task definitions, and synchronize execution with join to build responsive, concurrent applications.

We now understand that threads are independent paths of execution that allow our programs to do multiple things at once. But knowing the theory doesn’t run code. To build responsive applications, we need to know exactly how to create these execution paths and control their lifecycles.

In this lesson, we will move from concept to implementation. We will write code to define tasks, hand them off to the JVM for execution, and ensure they complete exactly when we need them to.

Implementing the Runnable interface

The primary way to define a task in Java is by implementing the Runnable interface. By using Runnable, we separate the task (the logic) from the runner (the thread). This is considered best practice because it keeps the business logic separate from the threading infrastructure.

Java only allows a class to extend one superclass. If we were to define a thread by extending the Thread class directly, we would lose the ability to extend any other class. Implementing Runnable avoids this limitation, leaving our class inheritance open for other uses.

We define the work in a Runnable object and pass it to a generic Thread instance.

Java 25
class TextTask implements Runnable {
@Override
public void run() {
// This code runs in the new thread
System.out.println("Task is running in a separate thread.");
}
}
public class RunnableExample {
public static void main(String[] args) {
// Create the task
TextTask task = new TextTask();
// Pass the task to a Thread
Thread thread = new Thread(task);
thread.start(); // Starts the new thread
System.out.println("Main thread continues...");
}
}
  • Line 1: We implement Runnable to define a task.

  • Lines 3–6: We place our logic inside the run() method.

  • Line 15: We wrap our Runnable instance inside a Thread object.

  • Line 16: We call start(). This triggers the JVM to create a new execution thread and call the run() method of our task.

  • Execution order: Once start() is called, the main thread continues immediately to line 18. Because the new thread runs independently, “Main thread continues...” might print before or after “Task is running...” depending on how the JVM schedules them.

Using lambdas for concise tasks

Because Runnable is a functional interface (it has only one abstract method, run()), we can use lambda expressions to define threads without writing a separate class. This is a common way to write short, one-off tasks in Java.

Java 25
public class LambdaThreadExample {
public static void main(String[] args) {
// Define task using lambda
Thread thread = new Thread(() -> {
System.out.println("Task is running in a separate thread.");
});
thread.start();
System.out.println("Main thread continues...");
}
}
  • Line 4: We pass a lambda () -> { ... } directly to the Thread constructor. The compiler treats this as a Runnable.

  • Line 8: Just like the previous example, start() launches the thread asynchronously.

  • Line 10: The main thread continues immediately, retaining the same non-deterministic execution order (either message may print first).

Extending the Thread class

Alternatively, we can define a thread by directly extending the java.lang.Thread class. When we extend Thread, our class becomes a specialized thread that carries its own instructions.

To give the thread work to do, we must override the run() method. This method contains the code that will execute in the new call stack. However, simply creating the object isn’t enough; we must call the start() method to instruct the Java Virtual Machine (JVM) to create a new thread and execute run() inside it.

Java 25
class NumberPrinter extends Thread {
@Override
public void run() {
for (int i = 0; i < 3; i++) {
System.out.println("Worker thread: " + i);
}
}
}
public class ExtendingThreadExample {
public static void main(String[] args) {
System.out.println("Main thread starts");
NumberPrinter worker = new NumberPrinter();
worker.start();
System.out.println("Main thread ends");
}
}
  • Lines 1–8: We define NumberPrinter as a subclass of Thread and override run() to define the task.

  • Line 15: We create an instance of our custom thread.

  • Line 16: We call start() to begin execution. The main thread does not wait; it proceeds immediately to line 18. Consequently, “Main thread ends” will likely print before the worker thread finishes counting.

The critical difference: start() vs. run()

A common mistake when learning threads is calling run() directly instead of start().

  • start(): Tells the JVM to create a new thread and execute the code there.

  • run(): Executes the method on the current thread, just like any regular method call.

If we call run(), our code runs sequentially, defeating the purpose of threading.

Java 25
public class StartVsRunExample {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("Running in: " + Thread.currentThread().getName());
});
System.out.println("Calling run() directly:");
t.run(); // Wrong! Runs on main thread
System.out.println("\nCalling start():");
t.start(); // Correct! Runs on a new thread
}
}
  • Lines 3–5: We define a thread inline using a lambda. This task simply prints the name of the thread executing it, which allows us to verify where the code is actually running.

  • Line 8: We call run() directly. The output will show “main”, indicating no new thread was created.

  • Line 11: We call start(). The output will show “Thread-0” (or similar), indicating the code ran in a new execution context.

Waiting for completion with join()

When we start a thread, it runs asynchronously. This means the main thread continues executing without waiting for the new thread to finish. Sometimes, this is problematic. For example, if a worker thread calculates a value the main thread needs, the main thread might try to use the result before the worker finishes.

To solve this, we use the join() method. When we call thread.join(), the current thread pauses execution and waits until thread terminates.

Java 25
public class ThreadJoinExample {
public static void main(String[] args) {
Thread worker = new Thread(() -> {
// Performing a CPU-intensive task: count prime numbers up to 500,000
int primeCount = 0;
for (int i = 2; i < 500000; i++) {
boolean isPrime = true;
for (int j = 2; j * j <= i; j++) {
if (i % j == 0) {
isPrime = false;
break;
}
}
if (isPrime) primeCount++;
}
System.out.println("Worker finished. Primes found: " + primeCount);
});
worker.start();
System.out.println("Main thread waiting...");
try {
worker.join(); // Blocks here until worker finishes
} catch (InterruptedException e) {
System.out.println("Main thread interrupted.");
}
System.out.println("Main thread resumes after worker.");
}
}
  • Lines 5–16: We perform a CPU-intensive task (checking thousands of numbers for primality) instead of sleeping. This ensures the worker takes real time to complete.

  • Line 19: We call start() to launch the worker thread.

  • Line 23: The main thread calls worker.join(). This halts the main thread's execution at this line, forcing it to wait until the worker thread completes its calculation.

  • Lines 22–26: Because the main thread is blocked while waiting, the join() method can throw InterruptedException, which the compiler requires us to handle.

  • Line 28: This line executes only after the worker thread has finished, ensuring we don't print the final message prematurely.

We can now create concurrent applications by defining tasks and starting threads to execute them. We have learned to use Runnable for flexibility and join() to coordinate timing so that our program steps happen in the correct order. However, running threads is the easy part; managing how they share data without corruption is the challenge.