Back to Blog
Java

Java Thread Start: How Thread.start() Works

java thread start: Understand what happens when you call Thread.start() in Java, why run() doesn't start a new thread, and how to manage thread lifecycle, errors, and...

ThreadConcurrencyMultithreadingJavaExecutorService
Diagram showing a main thread calling Thread.start() which creates a new thread executing run() concurrently.

When you call java thread start via Thread.start(), the JVM creates a new native thread and schedules it for execution. The run() method of the Thread object is then invoked on that new thread, asynchronously. This is the fundamental difference between start() and run(): start() launches a separate call stack, while run() executes in the current thread.

What Happens When You Call Thread.start()

The start() method triggers a sequence of operations in the JVM. First, the thread transitions from the NEW state to RUNNABLE. The JVM then allocates a native thread (OS-level) and registers it with the thread scheduler. When the scheduler picks the thread, the run() method is executed on the new thread's stack. The calling thread continues its own execution without waiting for the new thread to finish, unless you explicitly join it.

Thread t = new Thread(() -> System.out.println("Running in: " + Thread.currentThread().getName())); t.start(); System.out.println("Main thread: " + Thread.currentThread().getName());

This code prints both messages, but the order is not deterministic. The main thread prints immediately, while the new thread prints when the scheduler assigns it a CPU slice. The key point is that start() returns immediately, not after run() completes.

Why Calling run() Directly Does Not Start a New Thread

A common mistake is to call run() instead of start(). When you invoke run() directly, the method executes synchronously in the current thread, exactly like any other method call. No new thread is created, and the Thread object remains in the NEW state. This defeats the purpose of multithreading and often leads to subtle bugs where code that was intended to run concurrently actually runs sequentially.

Thread t = new Thread(() -> System.out.println("This runs in the main thread")); t.run(); // No new thread

The output shows the message printed by the main thread. The thread's state is still NEW after run() returns. To start a thread, you must call start(). The JVM internally invokes run() on the new thread, so you never need to call run() yourself.

Creating a Thread: Runnable vs. Subclassing Thread

Java provides two ways to define the code that runs in a new thread: implementing Runnable or extending Thread. The Runnable approach is generally preferred because it separates the task from the thread mechanics and allows the same task to be reused with different execution mechanisms, such as thread pools.

Runnable task = () -> System.out.println("Task executed"); Thread worker = new Thread(task); worker.start();

Subclassing Thread is appropriate when you need to override other methods of Thread, such as run() and perhaps interrupt() behavior. However, it ties your task to a specific thread instance and makes it harder to test or reuse.

class MyThread extends Thread { @Override public void run() { System.out.println("Custom thread"); } } MyThread t = new MyThread(); t.start();

In practice, prefer Runnable or Callable (with ExecutorService) because they are more flexible and align with composition over inheritance.

Thread States and Lifecycle After start()

After calling start(), the thread enters the RUNNABLE state, but it may not be immediately executing. The JVM's thread scheduler determines when it actually runs. The thread can transition to BLOCKED, WAITING, or TIMED_WAITING depending on synchronization and sleep calls. When run() completes, the thread moves to TERMINATED. You cannot call start() again on the same Thread instance; doing so throws IllegalThreadStateException.

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

This restriction exists because a thread can only be started once. If you need to run the same task multiple times, create a new Thread instance or use a thread pool that reuses worker threads.

Handling Exceptions in Started Threads

Exceptions thrown inside run() do not propagate to the caller of start(). Instead, they are handled by the thread's uncaught exception handler. By default, the handler prints the stack trace to System.err and the thread terminates. You can install a custom handler to log errors or take corrective action.

Thread t = new Thread(() -> { throw new RuntimeException("boom"); }); t.setUncaughtExceptionHandler((thread, throwable) -> { System.err.println("Thread " + thread.getName() + " failed: " + throwable.getMessage()); }); t.start();

If you need to know when a thread fails, you can also use Future with an ExecutorService, which captures exceptions and makes them available through Future.get().

Daemon Threads and JVM Shutdown

A thread can be marked as a daemon thread by calling setDaemon(true) before start(). Daemon threads do not prevent the JVM from exiting. When all non-daemon threads finish, the JVM terminates, and daemon threads are abruptly stopped. This is useful for background tasks like monitoring or housekeeping that should not keep the application alive.

Thread t = new Thread(() -> { while (true) { // background work } }); t.setDaemon(true); t.start();

Be careful: daemon threads are not gracefully shut down, so they may leave resources in an inconsistent state. For critical background work, consider using a non-daemon thread or a dedicated shutdown hook.

Performance and Overhead of Starting Threads

Creating a thread is not a cheap operation. The JVM must allocate a native thread, which involves OS-level resource allocation and stack memory. Each thread also consumes memory for its stack (typically 512 KB to 1 MB). Starting thousands of threads can exhaust memory or cause heavy context-switching overhead.

Instead of creating a new thread for every task, use an ExecutorService with a thread pool. The pool reuses a fixed number of threads, reducing the cost of thread creation and managing their lifecycle. For example:

ExecutorService executor = Executors.newFixedThreadPool(4); executor.submit(() -> System.out.println("Task")); executor.shutdown();

This approach is more scalable and aligns with modern Java concurrency practices. The ExecutorService also provides better error handling and future-based results.

Using ExecutorService Instead of Raw Thread.start()

While Thread.start() is the low-level API, production code typically uses ExecutorService to manage threads. It decouples task submission from thread management, allowing you to configure pool sizes, rejection policies, and shutdown behavior. The submit() method returns a Future that can retrieve results or exceptions.

ExecutorService executor = Executors.newCachedThreadPool(); Future<Integer> result = executor.submit(() -> 42); try { int value = result.get(); // blocks until result is ready } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } finally { executor.shutdown(); }

Using ExecutorService avoids the pitfalls of manually starting threads, such as forgetting to handle exceptions or leaking threads. For most applications, it is the recommended way to run tasks concurrently. However, understanding Thread.start() remains essential for debugging, low-level control, and scenarios where a simple one-off thread is sufficient.

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