Skip to content
Java multithreading 5 min read

Starting a Thread Twice

Once a thread finishes its job, it’s done for good — you cannot restart it by calling start() again. Attempting to do so throws an IllegalThreadStateException at runtime. Understanding why this happens helps you write safer, more predictable concurrent code.

What Happens When You Call start() Twice

Every Thread object moves through a fixed set of states during its life. When you call start() the first time, the thread transitions from NEWRUNNABLE, executes its run() method, and eventually reaches the TERMINATED state. At that point, the thread is finished — calling start() again throws an exception because the JVM rejects transitioning out of the TERMINATED state.

public class StartTwiceDemo extends Thread {

    @Override
    public void run() {
        System.out.println("Thread running: " + Thread.currentThread().getName());
    }

    public static void main(String[] args) throws InterruptedException {
        StartTwiceDemo t = new StartTwiceDemo();

        t.start();   // First call — works fine
        t.join();    // Wait for the thread to finish

        t.start();   // Second call — throws IllegalThreadStateException!
    }
}

Output:

Thread running: Thread-0
Exception in thread "main" java.lang.IllegalThreadStateException
    at java.base/java.lang.Thread.start(Thread.java:794)
    at StartTwiceDemo.main(StartTwiceDemo.java:13)

Warning: This exception is thrown even if the thread has already finished. A terminated thread cannot be restarted under any circumstances.

Why Java Forbids Restarting a Thread

The Thread Life Cycle in Java is a one-way journey. A thread progresses from NEW → RUNNABLE → (BLOCKED/WAITING/TIMED_WAITING) → TERMINATED, and there is no path from TERMINATED back to any other state.

Here’s what the lifecycle looks like in code terms:

public class ThreadStateExample extends Thread {

    @Override
    public void run() {
        System.out.println("State while running: " + getState()); // RUNNABLE
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadStateExample t = new ThreadStateExample();

        System.out.println("Before start:  " + t.getState()); // NEW
        t.start();
        t.join();
        System.out.println("After finish:  " + t.getState()); // TERMINATED
    }
}

Output:

Before start:  NEW
State while running: RUNNABLE
After finish:  TERMINATED

Note: Thread.getState() returns a Thread.State enum value. You can use this to inspect a thread’s current state programmatically.

The Right Way: Create a New Thread Instance

The simplest fix is to create a fresh Thread object each time you want the work to run again. Since the work logic lives in the Runnable, you can reuse that easily.

public class ReuseRunnableDemo {

    static Runnable task = () ->
        System.out.println("Running in: " + Thread.currentThread().getName());

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(task, "worker-1");
        t1.start();
        t1.join();

        // Don't restart t1 — create a new thread with the same Runnable
        Thread t2 = new Thread(task, "worker-2");
        t2.start();
        t2.join();
    }
}

Output:

Running in: worker-1
Running in: worker-2

Separating your logic (the Runnable) from the thread itself is a clean design habit that naturally avoids this pitfall. See Creating a Thread for the full details on both Thread subclassing and the Runnable approach.

Using a Thread Pool to Reuse Workers

For production code, you rarely manage individual Thread objects directly. A Thread Pool (via ExecutorService) handles creation and reuse automatically — you just submit tasks.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolDemo {

    public static void main(String[] args) throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(2);

        Runnable task = () ->
            System.out.println("Task by: " + Thread.currentThread().getName());

        // Submit the same logical task multiple times — no IllegalThreadStateException
        pool.submit(task);
        pool.submit(task);
        pool.submit(task);

        pool.shutdown(); // signals the pool to stop accepting new work
    }
}

Output:

Task by: pool-1-thread-1
Task by: pool-1-thread-2
Task by: pool-1-thread-1

Tip: Always prefer ExecutorService over raw Thread management in real applications. It handles thread reuse, exception handling, and shutdown gracefully.

Guarding Against Accidental Double-Start

If you ever build a component that wraps a Thread internally, add a guard to prevent double-start bugs at the design level:

public class ManagedWorker {

    private Thread thread;
    private volatile boolean started = false;

    public synchronized void start() {
        if (started) {
            throw new IllegalStateException("Worker already started. Create a new instance.");
        }
        started = true;
        thread = new Thread(this::doWork, "managed-worker");
        thread.start();
    }

    private void doWork() {
        System.out.println("Working in: " + Thread.currentThread().getName());
        // ... actual work here
    }

    public static void main(String[] args) {
        ManagedWorker worker = new ManagedWorker();
        worker.start(); // fine

        // worker.start(); // would throw IllegalStateException with a helpful message
    }
}

Using synchronized and the started flag ensures no two callers accidentally start the same worker concurrently either.

Under the Hood

When you call t.start(), the JVM calls the native method Thread.start0(). Internally, before doing anything else, the runtime checks the thread’s threadStatus field (a C-level integer). If threadStatus != 0 — meaning the thread has already been started at some point — the JVM immediately throws IllegalThreadStateException without creating a new OS thread.

This check is intentionally strict: Java does not attempt to “reset” a thread because doing so would create race conditions (what happens to the thread’s local variables, interrupt status, or daemon flag mid-execution?). A clean new Thread object gives you a predictable, zeroed-out starting state every time.

Thread state transitions are also why calling start() on a thread that is merely BLOCKED or WAITING throws the same exception — those states are also non-NEW states, even if the thread hasn’t finished yet.

Note: Checking t.getState() == Thread.State.NEW before calling start() is a useful defensive pattern when you receive a Thread reference from external code and aren’t sure of its state.

Quick Reference

ScenarioResult
Call start() on a NEW threadWorks — thread begins execution
Call start() on a RUNNABLE threadIllegalThreadStateException
Call start() on a TERMINATED threadIllegalThreadStateException
Call run() directly (instead of start())Runs on the current thread, no new thread created
Create a new Thread with same RunnableWorks — fresh thread, correct behavior

Tip: Calling run() directly is not the same as starting a thread — it executes the method synchronously on the calling thread. Always use start() when you want actual concurrency. See run() vs start() for the full comparison.

Last updated June 13, 2026
Was this helpful?