Concurrency Basics

Creating Threads

15 min Lesson 2 of 13

Creating Threads

In Java there are two classic ways to define the unit of work that a thread should execute: extend Thread or implement Runnable. A third option — Callable with a Future — is covered in a later lesson on thread pools. For now we focus on the fundamentals, and on the single most important rule beginners break every day: calling start(), not run().

Option 1: Extending Thread

Because java.lang.Thread implements Runnable itself, you can subclass it and override run() directly:

public class CounterThread extends Thread { private final String label; public CounterThread(String label) { super(label); // sets the thread name — useful in logs this.label = label; } @Override public void run() { for (int i = 1; i <= 5; i++) { System.out.println(label + " → " + i + " [" + Thread.currentThread().getName() + "]"); } } } // Starting it CounterThread t1 = new CounterThread("Thread-A"); CounterThread t2 = new CounterThread("Thread-B"); t1.start(); // ← creates a new OS thread and calls run() on it t2.start();

The two threads run concurrently: the output lines from Thread-A and Thread-B will interleave in a non-deterministic order.

Never call run() directly. Calling t1.run() instead of t1.start() does not create a new thread — it is a plain method call on the current thread, the same as calling any other method. No concurrency happens at all. This is one of the most common mistakes in beginner concurrent code.

Option 2: Implementing Runnable

Runnable is a functional interface (one abstract method: void run()). You pass an instance of it to a Thread constructor:

public class CounterTask implements Runnable { private final String label; public CounterTask(String label) { this.label = label; } @Override public void run() { for (int i = 1; i <= 5; i++) { System.out.println(label + " → " + i + " [" + Thread.currentThread().getName() + "]"); } } } // Wrapping Runnable in Thread and starting Thread t1 = new Thread(new CounterTask("Task-A"), "worker-1"); Thread t2 = new Thread(new CounterTask("Task-B"), "worker-2"); t1.start(); t2.start();

Because Runnable is a functional interface you can also write the task inline with a lambda — no separate class needed:

Runnable counter = () -> { for (int i = 1; i <= 5; i++) { System.out.println(Thread.currentThread().getName() + " → " + i); } }; new Thread(counter, "lambda-thread-1").start(); new Thread(counter, "lambda-thread-2").start();
Lambdas make short tasks readable, but extract the lambda into a named class or variable as soon as the logic exceeds a few lines. Anonymous lambdas are hard to name in stack traces and hard to unit-test in isolation.

start() vs run() — the mechanics

Understanding what start() actually does prevents a whole class of bugs:

  • start() asks the JVM to allocate a native OS thread, schedule it, and eventually invoke run() on that new thread. The calling thread returns immediately from start() and continues its own work.
  • run() is just a normal Java method. Calling it directly runs the logic on the caller's thread, sequentially, with zero concurrency.
  • Calling start() on a thread that has already been started throws IllegalThreadStateException. A Thread object is single-use.
Thread t = new Thread(() -> System.out.println("Hello from " + Thread.currentThread().getName())); t.run(); // prints: Hello from main — NO new thread t.start(); // prints: Hello from Thread-0 — new thread // t.start(); // ← would throw IllegalThreadStateException — can't reuse

Extending Thread vs Implementing Runnable — the trade-offs

Both approaches create threads, but they differ in design:

  • Extending Thread — easy for small demos, but Java only allows single inheritance. If your class already extends something else, this door is closed. It also tightly couples the task logic to the threading mechanism.
  • Implementing Runnable — separates what to do from how it is scheduled. The same Runnable can be submitted to a thread pool (ExecutorService), run inline in tests, or wrapped in a virtual thread (Java 21). This flexibility is why Runnable is almost always the right choice in production code.
Rule of thumb: Use Runnable (or a lambda) for tasks; use the Thread class only when you specifically need to configure thread properties (setDaemon, setName, setPriority) or when you extend it to build a specialised thread type such as a pooled worker. In modern Java you will almost never extend Thread in application code.

Naming threads

Always give threads meaningful names. The default names (Thread-0, Thread-1, …) are useless when reading a thread dump during a production incident:

Thread worker = new Thread(this::processQueue, "order-processor-1"); worker.setDaemon(true); // daemon threads do not prevent JVM shutdown worker.start();

With a named thread, a stack trace immediately tells you which logical component is stuck or consuming CPU.

Summary

There are two classical ways to create threads: extend Thread (override run()) or implement Runnable (pass it to a Thread constructor). Always call start() to get a new thread — run() is just a method call. Prefer Runnable or a lambda in production code because it decouples the task from the execution mechanism, leaves your inheritance hierarchy open, and works seamlessly with ExecutorService and virtual threads. Next we will look at the full lifecycle a thread passes through from creation to termination.