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 aRunnablekeeps your business class free. - Lambdas turn the
Runnableform into a one-liner. SubclassingThreadrequires a named class for the same code. - The
Runnableyou pass can also be handed to anExecutorServicelater. TheThreadsubclass 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 finishesA few things the rookie mistakes here:
start()is what creates the OS thread. Callingrun()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 seestart(), no parallelism happened.start()can only be called once. AThreadis single-use. Callingstart()a second time throwsIllegalThreadStateException. To run the same task again, create a newThreador use anExecutorService.join()can throwInterruptedException. It's a blocking call. If somebody callsinterrupt()on the thread that's waiting injoin(), 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:
| Constructor | When 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."BLOCKEDmeans it's waiting for an intrinsic lock;WAITING/TIMED_WAITINGmeans it's parked inwait(),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 currentThreadThread.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.
What to take from the run:
- The state transitions match the contract. Before
start(), both threads wereNEW. Afterstart(), they wereRUNNABLE(orTERMINATEDif the work was tiny and finished before the print). Afterjoin(), both wereTERMINATED. That's the lifecycleThread.Statedescribes. - 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()wasfalseafterwards becausestart()was never called. If you're debugging "nothing seems to be running in parallel," check whether you wrotestart()orrun(). - The interrupt loop didn't use
Thread.sleepas 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(theThread.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
mainreturned, 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
You call `t.run()` (not `t.start()`) on a `Thread` whose `Runnable` prints the current thread's name. What does it print?