W3docs

Java Thread Class

Create and control threads in Java by extending the Thread class or passing a Runnable — and the trade-offs between the two.

Java Thread Class

java.lang.Thread is the object you hold a handle to when you want to start, name, join, interrupt, or query a thread of execution. The previous chapter introduced threads at the concept level; this one is the API tour. Everything in java.util.concurrent — executors, futures, virtual threads — is built on top of Thread, so it pays to know the raw class even though you'll usually reach for the higher-level wrappers in production code.

Two ways to create a thread

A Thread is a Runnable wrapped in a control object. There are two ways to give it the Runnable:

// 1. Pass a Runnable to the constructor (the modern, preferred form)
Thread a = new Thread(() -> System.out.println("hello from " + Thread.currentThread().getName()));

// 2. Extend Thread and override run()
class HelloThread extends Thread {
  @Override public void run() {
    System.out.println("hello from " + getName());
  }
}
Thread b = new HelloThread();

Both work; both call your code on a fresh thread. The first form is what virtually all modern code uses, for three reasons:

  • A class can only extend one other class. If you extend Thread, you can't extend anything else — and the part of your code that is the work almost never has a good reason to be a thread in the OO sense. Passing a Runnable keeps your business class free.
  • Lambdas turn the Runnable form into a one-liner. Subclassing Thread requires a named class for the same code.
  • The Runnable you pass can also be handed to an ExecutorService later. The Thread subclass is locked to running on its own dedicated thread.

Extend Thread only when you genuinely want to add state or methods to the thread itself (rare). For everything else, pass a Runnable.

Starting and waiting

The two methods you'll use on almost every thread:

Thread t = new Thread(() -> doWork(), "worker");
t.start();                                    // schedule it; return immediately
t.join();                                     // block the caller until the thread finishes

A few things the rookie mistakes here:

  • start() is what creates the OS thread. Calling run() directly executes the body on the current thread, synchronously — no new thread is started. This is the single most common multithreading bug for newcomers. If you don't see start(), no parallelism happened.
  • start() can only be called once. A Thread is single-use. Calling start() a second time throws IllegalThreadStateException. To run the same task again, create a new Thread or use an ExecutorService.
  • join() can throw InterruptedException. It's a blocking call. If somebody calls interrupt() on the thread that's waiting in join(), the wait ends with the exception. You must handle or propagate it.

join(millis) waits for at most that many milliseconds before returning, whether the thread finished or not. Use it when you want to give a worker a bounded chance to finish gracefully before you escalate.

The constructors that matter

Thread has many constructors; in practice four matter:

ConstructorWhen to use
new Thread(Runnable)The base case. Anonymous-ish worker.
new Thread(Runnable, String name)Almost always preferable — names show in logs, profilers, thread dumps.
new Thread(ThreadGroup, Runnable, String)When you need an explicit group (rare; groups are largely deprecated).
new Thread(ThreadGroup, Runnable, String, long stackSize)When the default stack (about 1 MB) is wrong — e.g. deep recursion or memory pressure.

The empty new Thread() constructor exists and runs an empty run(), which does nothing. There's no reason to use it.

Always name your threads. "worker-1", "http-3", "flush-loop" — whatever the role is. A thread dump full of Thread-7, Thread-12, Thread-19 is a thread dump you can't read.

Properties on a Thread instance

The handful of fields and getters you'll actually touch:

t.setName("scanner-2");                       // any time before or after start()
String name = t.getName();

t.setDaemon(true);                            // BEFORE start(); else IllegalThreadStateException
boolean d = t.isDaemon();

t.setPriority(Thread.NORM_PRIORITY);          // 1..10; mostly advisory, see chapter 6
int p = t.getPriority();

Thread.State s = t.getState();                // NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
boolean alive = t.isAlive();                  // true between start() and run() returning
long id = t.threadId();                       // Java 19+; old name: getId()

Two of these matter the most:

  • setDaemon(true) decides whether the thread keeps the JVM alive. See the previous chapter — daemons die with the program; non-daemons keep it running until they return.
  • getState() is what you look at in a thread dump to diagnose "why is the thread stuck." BLOCKED means it's waiting for an intrinsic lock; WAITING/TIMED_WAITING means it's parked in wait(), join(), sleep(), LockSupport.park(), etc.

Static helpers on Thread

A few static methods you'll call from inside the worker:

Thread.currentThread();                       // the thread that's executing this code
Thread.sleep(2000);                           // pause this thread for ~2000 ms
Thread.yield();                               // hint to the scheduler "go ahead and run someone else"
Thread.interrupted();                         // returns and CLEARS the interrupt flag of currentThread

Thread.sleep is the most common; it throws InterruptedException, so callers must handle or propagate. Thread.yield is almost never the right tool — it's a vague hint that the JVM and OS can ignore. If you want to coordinate, use a real synchronisation primitive.

Thread.interrupted() returns true if the current thread has been interrupted, and clears the flag. t.isInterrupted() (instance method, on a different thread) returns the flag without clearing. Mixing them up is a common source of stuck interrupts.

Interruption: how you ask a thread to stop

There's no safe t.stop() (the method exists, but it's deprecated since 1.1 because it leaves locks held and state corrupt). The cooperative shutdown protocol is:

Thread worker = new Thread(() -> {
  while (!Thread.currentThread().isInterrupted()) {
    doOneUnitOfWork();
  }
}, "worker");
worker.start();
// ... later, from somewhere else:
worker.interrupt();
worker.join();

interrupt() sets the worker's interrupt flag. The worker is expected to check the flag at safe points and exit. If the worker is blocked in sleep, wait, join, or many java.nio calls, the blocking call throws InterruptedException immediately so the thread can react.

If you catch InterruptedException and don't want to propagate it, the convention is to re-set the flag so callers up the stack still see the interrupt:

try { Thread.sleep(1000); }
catch (InterruptedException e) {
  Thread.currentThread().interrupt();         // re-arm the flag
  return;                                     // and give up cooperatively
}

Swallowing an interrupt without re-arming the flag is a bug. The flag is how the rest of the program knows you were asked to stop.

A worked example: the full lifecycle in one program

The program below creates two workers in different ways (Runnable, subclass), watches their state transitions, joins them, and demonstrates the interrupt protocol on a third worker.

java— editable, runs on the server

What to take from the run:

  • The state transitions match the contract. Before start(), both threads were NEW. After start(), they were RUNNABLE (or TERMINATED if the work was tiny and finished before the print). After join(), both were TERMINATED. That's the lifecycle Thread.State describes.
  • The line "t3 ran on thread: main" is the bug to remember forever. t3.run() ran the body — on the calling thread, synchronously. No new thread was created. t3.isAlive() was false afterwards because start() was never called. If you're debugging "nothing seems to be running in parallel," check whether you wrote start() or run().
  • The interrupt loop didn't use Thread.sleep as its main wait — it just busy-checked the flag, with an occasional short sleep so the interrupt could end the sleep early. The contract is the same either way: isInterrupted() is what the worker polls; interrupt() is what the requester calls.
  • Re-arming the flag inside the catch (the Thread.currentThread().interrupt() line) preserved the signal for any code further up the call stack. Without that line, an interrupt caught and ignored would vanish — which is one of the easiest ways to write a thread that won't shut down cleanly.
  • The daemon at the end was about to sleep for 60 seconds; instead the JVM exited as soon as main returned, killing it mid-sleep. Daemon threads can hold any kind of resource — but they can also be cut off at any moment, which is why you shouldn't put commit-required work on them.

What's next

The next chapter, Java Runnable Interface, zooms in on Runnable itself — what it really is, why Callable and Future were added on top of it, and how lambdas changed the ergonomics of passing work to a thread.

Practice

Practice

You call `t.run()` (not `t.start()`) on a `Thread` whose `Runnable` prints the current thread's name. What does it print?