Back to Blog
Java

Java Thread run: Difference Between run() and start()

java thread run: Understand why calling run() directly executes on the current thread, how start() creates a new thread, and when ExecutorService is the better choice.

JavaThreadsConcurrencyRunnableExecutorServiceMultithreading
Diagram showing the difference between calling run() directly on the calling thread versus start() creating a new thread in Java.

When you write thread.run() in Java, the code executes on the current thread, not on a new one. This is the most common misunderstanding around java thread run behavior. The run() method contains the code that should execute, but only start() schedules that code on a separate thread. The distinction is easy to miss because both methods compile cleanly and produce output, yet only one of them actually achieves concurrency.

The Difference Between run() and start()

The Thread class exposes both run() and start(), and they serve completely different purposes. The run() method is the entry point for the thread's work, while start() allocates a new native thread and invokes run() on it. When you call run() directly, the JVM does not create a new thread, and the code executes synchronously on the calling thread.

Thread thread = new Thread(() -> System.out.println("Running on: " + Thread.currentThread().getName())); thread.run(); // prints "main" - executes on the calling thread thread.start(); // prints "Thread-0" - executes on a new thread

The output difference is the clearest demonstration. A direct run() call prints the name of the current thread, which is main in this case. The start() call prints a thread name assigned by the JVM, confirming that a separate thread was created. This is the core behavior that separates the two methods.

Creating a Thread with Runnable

The most common way to define thread work is through the Runnable interface. A Runnable is a functional interface with a single run() method, so it can be expressed as a lambda expression. This keeps the task definition separate from the thread mechanics.

Runnable task = () -> { for (int i = 0; i < 5; i++) { System.out.println("Task running: " + i); } }; Thread worker = new Thread(task); worker.start();

The same Runnable instance can be passed to a Thread, an ExecutorService, or any other execution context. That flexibility is the main reason Runnable is preferred over subclassing Thread in most production code. The task logic does not need to know how it is executed.

Extending Thread vs Implementing Runnable

You can also create a thread by subclassing Thread and overriding run():

class Worker extends Thread { @Override public void run() { System.out.println("Worker thread executing"); } } Worker worker = new Worker(); worker.start();

Extending Thread couples the task with the thread itself. The class cannot extend anything else, and the task logic is not reusable outside a thread context. Implementing Runnable is generally the better choice because it preserves the single-inheritance slot and allows the same task to be submitted to different execution mechanisms.

What Happens When You Call run() Directly

Calling run() directly is not a compile-time error in Java, but it is almost always a mistake. The method executes on the current thread, so any code that assumes concurrent execution will behave incorrectly. This is especially dangerous in production code where timing-dependent bugs may surface only under load.

Thread thread = new Thread(() -> { System.out.println("Inside run: " + Thread.currentThread().getName()); }); thread.run(); // "Inside run: main" thread.start(); // "Inside run: Thread-0"

The compiler will not warn you, and the program may appear to work correctly in simple tests. The failure appears only when the code is moved into a context where the calling thread is different from the expected worker thread, or when blocking operations in run() stall the caller.

Using ExecutorService Instead of Raw Threads

For production code, ExecutorService provides a higher-level abstraction for running tasks. It manages thread creation, pooling, and lifecycle, which reduces the risk of thread leaks and makes shutdown behavior explicit.

ExecutorService executor = Executors.newFixedThreadPool(4); executor.submit(() -> { System.out.println("Task executed by: " + Thread.currentThread().getName()); }); executor.shutdown();

The submit() method accepts a Runnable or a Callable and schedules it on a pooled thread. The shutdown() method prevents new tasks from being accepted and allows existing tasks to complete. This approach is preferable when the application submits many tasks, because creating a new Thread for each task adds overhead and makes resource management harder.

Thread Lifecycle and Runtime Behavior

A thread moves through several states: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED. The start() method transitions the thread from NEW to RUNNABLE. Calling start() twice throws an IllegalThreadStateException because the thread has already left the NEW state.

Thread thread = new Thread(() -> {}); thread.start(); thread.start(); // throws IllegalThreadStateException

The run() method, by contrast, can be called any number of times because it is just an ordinary method. This asymmetry is a strong signal that run() is not the intended entry point for concurrent execution. The thread state model exists specifically to prevent the same thread from being started more than once.

Concurrency Considerations

When a task runs on a separate thread, shared state becomes a concern. Fields that are read or written from multiple threads need proper synchronization, volatile visibility guarantees, or confinement to avoid data races.

class Counter { private int count = 0; public synchronized void increment() { count++; } public synchronized int getCount() { return count; } }

The synchronized keyword ensures that only one thread can execute the method at a time, protecting the count field from concurrent modification. Without this, multiple threads could read and write the same field simultaneously, producing incorrect results that are difficult to reproduce.

Thread Safety and Memory Visibility

Beyond synchronization, memory visibility matters. Changes made by one thread are not guaranteed to be visible to another thread without proper happens-before relationships. The volatile keyword provides visibility guarantees without locking.

class Flag { private volatile boolean running = true; public void stop() { running = false; } public void work() { while (running) { // loop body } } }

The volatile keyword ensures that writes to running are immediately visible to other threads. Without it, the loop in work() might never observe the change, causing the thread to run indefinitely. This is a common failure mode in multithreaded Java code that uses a boolean flag for shutdown coordination.

When Direct run() Calls Are Legitimate

There are a few cases where calling run() directly is intentional. Testing is one example. A unit test can call run() on a Runnable to execute its logic synchronously without spawning a thread, which makes assertions deterministic.

Runnable task = () -> System.out.println("Test execution"); task.run(); // synchronous, no thread created

Another case is implementing template-method patterns where the run() logic is part of a larger synchronous flow. In these situations, the direct call is deliberate and should be documented so readers do not mistake it for a concurrency bug.

Choosing the Right Threading Approach

The choice between raw Thread, Runnable, and ExecutorService depends on the context:

ApproachBest forLifecycle control
Thread subclassSimple one-off tasksManual
Runnable + ThreadReusable task logicManual
ExecutorServiceMultiple tasks, poolingAutomatic

Use a raw Thread when you need direct control over the thread object and the task is simple. Use ExecutorService when you have multiple tasks or need to manage thread counts and shutdown behavior. The run() method should almost always be invoked by the framework or the start() method, not by application code. When you do call run() directly, make the intent explicit and document why the synchronous execution is correct for that specific context.

java thread run: Practical Usage and Code Examples | RYUSLOG DEV