A thread doesn't just run from start to finish in one continuous burst. It moves through different states: waiting to start, actively running, blocked on I/O, waiting for a lock, and eventually terminating.
When your multi-threaded application hangs or behaves unexpectedly, understanding these states is often the key to diagnosing what went wrong.
A thread's lifecycle is the sequence of states it passes through from creation to termination. Think of it like the lifecycle of an employee at a company: hired (created), onboarding (ready), actively working (running), waiting for resources or approvals (blocked/waiting), and eventually leaving the company (terminated).
At any given moment, a thread exists in exactly one state. External events and method calls cause transitions between states. The operating system's scheduler decides which runnable threads actually get CPU time.
At the operating system level, threads have these fundamental states:
Every language's thread states map to these OS-level concepts, but the granularity of exposure varies widely. The diagram below shows the complete state model that we'll reference throughout this chapter:
Languages expose thread states in very different ways, and those differences reflect deliberate design philosophies.
Java provides Thread.State, an enum with six values: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED. You can query any thread's state at any time with thread.getState().
This makes Java excellent for debugging concurrency issues. You can programmatically detect deadlocks by finding threads in BLOCKED state waiting for each other's locks.
C#'s ThreadState is a flags enum, meaning a thread can be in multiple states simultaneously. For example, a background thread that's sleeping might have ThreadState.WaitSleepJoin | ThreadState.Background. This is more flexible but requires bitwise operations to check states properly.
Python's threading module only tells you if a thread is_alive(). C++ only tells you if a thread is joinable() (started but not yet joined). For detailed state analysis, you need external tools or platform-specific APIs.
Go takes a different approach: goroutine states aren't exposed at all. There's no getState() method and no state enum, and that is intentional.
Go's philosophy is that if you need to check a goroutine's state, you're probably doing something wrong. Instead of inspecting state, you communicate completion through channels, wait for goroutines with sync.WaitGroup, and handle cancellation with context.Context. This design pushes you toward patterns that are safer and more composable.
Each state is worth examining in detail, with code examples showing how to observe or trigger it in each language.
A thread in the NEW state has been created as an object in memory, but hasn't started executing yet. The operating system hasn't allocated resources for its execution.
In this state, the thread object exists in your program's heap, but no OS-level thread has been created. The thread cannot run any code yet, and calling most thread methods will fail or have no effect.
Once start() is called, the thread moves to the RUNNABLE state. The OS has created the thread and it's eligible to run, but it might not be running right now. The scheduler decides which runnable threads get CPU time.
In this state, the OS thread exists and is scheduled, but it may or may not be executing at any given moment. It competes with other threads for CPU time, and the scheduler can preempt it at any point.
A thread is RUNNING when it's actively executing instructions on a CPU core. This is a sub-state of RUNNABLE in most models. The thread has been selected by the scheduler and is consuming CPU cycles.
A thread leaves the RUNNING state when its time slice expires (preemption), when it voluntarily yields, when it blocks on I/O or synchronization, or when it terminates.
The scheduler continuously moves threads between RUNNABLE and RUNNING. On a 4-core machine, at most 4 threads can be RUNNING simultaneously. Others wait in the runnable queue.
In this example, threads X and Y are RUNNING (assigned to cores, shown in green), threads A-E are RUNNABLE (waiting in the queue, shown in orange), and cores 3-4 are idle (shown in gray).
The distinction between "ready to run" and "actually running" exists at the OS level, but user-space programs typically can't observe it reliably.
By the time you query a thread's state and receive the answer, the thread may have been preempted or resumed multiple times. Java, C#, Python, and Go all combine these into a single "alive and not blocked" concept.
A thread enters the BLOCKED state when it tries to acquire a lock (monitor) that another thread holds. It cannot proceed until the lock becomes available.
This is different from WAITING. BLOCKED specifically means "waiting to enter a synchronized block or method." The thread isn't waiting for a signal; it's waiting for exclusive access to a resource.
A thread enters the WAITING state when it explicitly waits for another thread to perform an action. Unlike BLOCKED, the thread isn't competing for a lock; it's parked until signaled.
Common triggers for WAITING:
Object.wait(), Thread.join(), LockSupport.park()Monitor.Wait(), Thread.Join(), ManualResetEvent.WaitOne()Condition.wait(), Thread.join(), Event.wait()condition_variable.wait(), thread.join()sync.WaitGroup.Wait()The thread will stay in WAITING forever unless another thread wakes it up.
TIMED_WAITING is similar to WAITING, but with a timeout. The thread will wake up either when signaled or when the timeout expires, whichever comes first.
Common triggers:
Thread.sleep(millis), Object.wait(millis), Thread.join(millis)Thread.Sleep(millis), Monitor.Wait(obj, millis), Thread.Join(millis)time.sleep(secs), Condition.wait(timeout), Thread.join(timeout)sleep_for(), wait_for(), join() doesn't have native timeouttime.Sleep(), select with timeout, context.WithTimeout()A thread enters the TERMINATED state when its execution completes, either normally or by throwing an uncaught exception. The thread can never run again. Its resources are released.
In this state, the thread has finished executing and the OS thread no longer exists. isAlive() returns false, though the Thread object may still exist in memory, and calling start() again throws an exception.