Java Thread Class

In the last lesson you learned an introduction to threads. Now let’s look closely at the tool that makes a thread: the Thread class. We will create a thread, settle the famous start vs run confusion, and walk through Thread’s handy methods.

🤔 Why look at the Thread class?

Every thread in your program is a Thread object, even the main thread. Understand this one class and threads become normal objects you can name, inspect, pause, and wait on.

This lesson fixes real pain:

  • People call run() thinking it starts a thread, and nothing runs in parallel.
  • People start the same thread twice and get a crash.
  • People assume output comes out in a tidy order and write buggy code.

All of these come from not knowing how the Thread class works. Let’s learn it.

🛠️ Creating a thread by extending Thread

The first way to make a thread is to write a class that extends Thread and override run. Whatever code you put inside run becomes the new thread’s job. Here is a thread that prints three numbers.

class CounterThread extends Thread {
@Override
public void run() {
for (int i = 1; i <= 3; i++) {
System.out.println("Counting: " + i);
}
}
}
public class Main {
public static void main(String[] args) {
CounterThread t = new CounterThread();
t.start(); // ✅ launches a NEW thread that runs run()
System.out.println("Main is done setting up");
}
}

Reading it step by step:

  • class CounterThread extends Thread makes our class a kind of Thread. It inherits start, getName, and the rest.
  • public void run() holds the work. This is the only method the new thread executes.
  • new CounterThread() builds the object, but nothing runs yet.
  • t.start() creates a real new thread, which calls run for you.
  • The println in main runs on the main thread, which did not wait.

Output (order may vary)

Main is done setting up
Counting: 1
Counting: 2
Counting: 3

The main line and the counting lines can appear in different orders on different runs. That is normal, explained later.

⚠️ start() vs run(): the difference that trips everyone

This is the most important idea in the lesson. You call start() to run code on a brand new thread. Calling run() yourself runs the method right here on the thread you are already on, like any normal method call.

Let’s prove it. The thread below prints the name of whatever thread runs its run method. We launch it both ways and compare.

class NameThread extends Thread {
@Override
public void run() {
System.out.println("run() is executing on: "
+ Thread.currentThread().getName());
}
}
public class Main {
public static void main(String[] args) {
NameThread t1 = new NameThread();
t1.start(); // ✅ runs run() on a new thread
NameThread t2 = new NameThread();
t2.run(); // ❌ runs run() on the main thread, no new thread
}
}

Here Thread.currentThread().getName() reports the actual thread running the code, so the output tells us where each call ran.

Output (order may vary)

run() is executing on: Thread-0
run() is executing on: main

The output proves it. start() ran the work on a separate thread named Thread-0. run() ran it on main, the same thread we were already on.

The difference in plain words:

  • start() asks the system to create a new thread, which then calls run for you. Your code runs in parallel.
  • run() is just a method. Calling it runs its code on the current thread. No new path, no parallel work.

Always use start()

To run work on a new thread, call start(). Calling run() directly just executes the method on the current thread with no new thread at all. This is the most common multithreading mistake.

🏷️ Naming threads: getName, setName, and getId

Every thread has a name and a numeric id. Names make output easier to read, because you can tell which thread printed which line. With no name set, Java picks a default like Thread-0.

The example below sets a name with the constructor and with setName, then reads the name and id back.

class TaskThread extends Thread {
public TaskThread(String name) {
super(name); // pass a name up to Thread
}
@Override
public void run() {
System.out.println("Name: " + getName() + ", id: " + getId());
}
}
public class Main {
public static void main(String[] args) {
TaskThread a = new TaskThread("Loader");
a.start();
TaskThread b = new TaskThread("temp");
b.setName("Saver"); // rename before or while it runs
b.start();
}
}

Reading it through:

  • super(name) passes a name up to the Thread constructor, so getName() returns it.
  • setName("Saver") changes the name after the object is built. Both ways work.
  • getId() returns a unique number Java assigns to each thread. You cannot set it; Java controls it.

Output (order may vary, ids will differ)

Name: Loader, id: 21
Name: Saver, id: 22

The id numbers will differ on your machine, and the two lines can swap order. Each thread has its own readable name and id.

😴 Pausing with sleep() and finding the current thread

Two helpers come up constantly. Thread.sleep(ms) pauses the current thread for a number of milliseconds. Thread.currentThread() hands you the Thread object you are running on, so you can read its name or id.

This example pauses the main thread for one second, then prints which thread it is on.

public class Main {
public static void main(String[] args) throws InterruptedException {
System.out.println("Start on: " + Thread.currentThread().getName());
Thread.sleep(1000); // pause this thread for 1 second
System.out.println("One second later");
}
}

A few things to note.

  • Thread.sleep(1000) pauses for 1000 milliseconds, which is one second. It always pauses the thread that calls it, not some other thread.
  • Thread.currentThread().getName() is "main" here, because the main thread runs main.
  • sleep can throw InterruptedException, so the method declares throws InterruptedException. You must either declare it or wrap the call in try/catch.

Output

Start on: main
One second later

⏳ Waiting with join() and checking isAlive()

Sometimes the main thread needs a worker to finish first. For that you use join(). Calling worker.join() makes the caller pause until the worker finishes. The isAlive() method tells you whether a thread has started and not yet finished.

The thread below does slow work, and the main thread waits for it.

class SlowJob extends Thread {
@Override
public void run() {
System.out.println("Working...");
try {
Thread.sleep(1000); // pretend the job is slow
} catch (InterruptedException e) {
System.out.println("Job interrupted");
}
System.out.println("Done working");
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
SlowJob job = new SlowJob();
job.start();
System.out.println("Alive before join? " + job.isAlive());
job.join(); // main waits here until job finishes
System.out.println("Alive after join? " + job.isAlive());
System.out.println("Main continues safely");
}
}

Reading the flow:

  • job.start() launches the worker, which begins its pretend one-second job.
  • job.isAlive() is true right after starting, because the thread is running.
  • job.join() pauses the main thread until the worker finishes. Without this line, “Main continues safely” could print before the work was done.
  • After join returns, isAlive() is false because the thread has ended.

Output

Working...
Alive before join? true
Done working
Alive after join? false
Main continues safely

See how join removes the guesswork. The last main line always comes after the job, because the main thread truly waited.

🎚️ Priority and daemon threads

The Thread class gives you two more knobs:

  • setPriority(int) hints which threads matter more, from Thread.MIN_PRIORITY (1) to Thread.MAX_PRIORITY (10), default Thread.NORM_PRIORITY (5). It is only a hint. The system can ignore it, so never rely on it for order.
  • setDaemon(true) marks a thread as a background helper. The program does not wait for daemon threads before it exits. User threads do keep the program alive until done.

The example shows both. setDaemon must be called before start().

class Background extends Thread {
@Override
public void run() {
System.out.println(getName()
+ " priority: " + getPriority()
+ ", daemon: " + isDaemon());
}
}
public class Main {
public static void main(String[] args) {
Background t = new Background();
t.setName("Helper");
t.setPriority(Thread.MAX_PRIORITY); // hint: higher importance
t.setDaemon(true); // must be set before start()
t.start();
}
}

What the methods do.

  • setPriority(Thread.MAX_PRIORITY) asks the scheduler to favour this thread. It is a hint, not a promise.
  • getPriority() reads the priority back, which is 10 here.
  • setDaemon(true) makes the thread a background helper that does not hold the program open.
  • isDaemon() confirms the daemon flag is on.

Output

Helper priority: 10, daemon: true

You will rarely need priority or daemon threads early on, so just keep them handy.

🎲 Why output order is not fixed

A part of the system called the scheduler decides which thread gets the CPU and for how long. It can pause one thread, let another run, then switch back. You do not control these switches, and they can land differently every run.

The example below starts two threads that each print a few lines. Run it twice and the lines mix in different orders.

class Printer extends Thread {
public Printer(String name) {
super(name);
}
@Override
public void run() {
for (int i = 1; i <= 3; i++) {
System.out.println(getName() + " -> " + i);
}
}
}
public class Main {
public static void main(String[] args) {
new Printer("Alpha").start();
new Printer("Beta").start();
}
}

Output (one possible order)

Alpha -> 1
Beta -> 1
Alpha -> 2
Alpha -> 3
Beta -> 2
Beta -> 3

Run it again and the mix differs. The rule: never write code that depends on thread order. If you need one thread done first, make the waiting explicit with join.

🔀 extends Thread vs implements Runnable

There are two ways to give a thread its work, and the preferred way for real code is Runnable:

  • Extend Thread uses up your one inheritance slot, so the class cannot extend anything else.
  • Implement Runnable is an interface, so the class stays free to extend something else.
  • Runnable also separates the “what to do” from the “how to run it.”

Extending Thread is the simple route. Runnable, covered next, is the one you reach for in real projects.

⚠️ Common Mistakes

A few Thread slip-ups to watch for.

  • Calling run() instead of start(). This runs the code on the current thread, with no parallel work at all.
t.run(); // ❌ no new thread; runs on the current thread
t.start(); // ✅ launches a new thread, then calls run() on it
  • Starting the same thread twice. A thread can only be started once. Starting it again throws IllegalThreadStateException.
Printer p = new Printer("Alpha");
p.start();
p.start(); // ❌ throws IllegalThreadStateException
Printer p2 = new Printer("Alpha");
p2.start(); // ✅ create a fresh thread instead

That second start() produces an error like this.

Error

Exception in thread "main" java.lang.IllegalThreadStateException
at java.base/java.lang.Thread.start(Thread.java:794)
at Main.main(Main.java:13)
  • Relying on output order. Threads run independently, so their lines can interleave differently each run.
new Printer("Alpha").start();
new Printer("Beta").start();
// ❌ do not assume "Alpha" prints before "Beta"
// ✅ if order matters, use join() or proper coordination

✅ Best Practices

Habits for working with the Thread class.

  • Use start() to launch a thread. Keep run() for the work itself, never as the way to start.
  • Start a thread only once. If you need to run the work again, create a fresh Thread object.
  • Give threads clear names. A name like “Loader” in the output makes threaded logs far easier to read.
  • Do not assume an order. Design so the result is correct no matter how the scheduler interleaves the threads.
  • Use join() to wait when needed. If one thread needs another’s result, have it wait instead of guessing.
  • Treat priority as a hint. Never depend on setPriority to control which thread runs first.
  • Handle InterruptedException. Either declare throws or wrap sleep and join in try/catch so the code compiles and behaves.

🧩 What You’ve Learned

Nicely done. Let’s recap the Thread class.

  • ✅ A thread is a Thread object; you can make one by extending Thread and overriding run.
  • ✅ Call start() to run run on a new thread; calling run() directly runs it on the current thread with no parallelism.
  • getName, setName, and getId label and identify threads; Thread.currentThread() gives the running thread.
  • sleep(ms) pauses the current thread, join() waits for another to finish, and isAlive() checks if it is still running.
  • setPriority is only a hint, and setDaemon(true) (set before start()) makes a background helper thread.
  • ✅ Output order across threads is not fixed, starting a thread twice throws IllegalThreadStateException, and Runnable is usually preferred over extending Thread.

Check Your Knowledge

Test what you learned. Pick an answer for each question, then click Check.

  1. 1

    Which method runs run() on a brand new thread?

    Why: start() creates a new thread and then calls run() on it. Calling run() directly runs on the current thread.

  2. 2

    What happens if you call start() twice on the same Thread object?

    Why: A thread can only be started once. Calling start() again throws IllegalThreadStateException; create a new object instead.

  3. 3

    What does Thread.currentThread() return?

    Why: Thread.currentThread() hands you the Thread object that is executing the current code.

  4. 4

    What is true about setPriority?

    Why: Priority is just a hint. The scheduler is free to ignore it, so never rely on it to control order.

🚀 What’s Next?

Extending Thread is the simple way to make a thread, but it locks up your single chance to extend another class. The cleaner and more common approach is to implement the Runnable interface and pass it to a Thread. Let’s learn that next.

Java Runnable Interface

Share & Connect