Multithreading
A complete guide to Java threading: creating and starting threads, the thread lifecycle, synchronization, race conditions, deadlocks, inter-thread communication, and thread-local storage.
What Concurrency Actually Means
A thread is an independent path of execution within a program. A single-threaded Java application runs everything in one sequence: one instruction leads to the next, nothing runs simultaneously. Adding threads means the program can do more than one thing at the same time, or more precisely, the OS interleaves the execution of multiple threads fast enough that they appear simultaneous. On a multi-core machine, some of that is genuinely parallel: two cores can run two threads at exactly the same moment.
The appeal of threads is obvious. A web server can handle multiple requests at the same time rather than queuing them. A desktop application can run a heavy computation in the background while keeping the UI responsive. A file processor can read, transform, and write data in a pipeline where each stage runs concurrently with the others.
The difficulty of threads is equally obvious once you encounter it. When multiple threads share data, the order in which they read and write it becomes unpredictable. A result that depends on one thread completing a write before another thread reads it can fail silently whenever the OS schedules threads in a different order. These bugs are intermittent, hard to reproduce, and occasionally impossible to test for exhaustively. Understanding the mechanics of Java threading is the prerequisite for avoiding them.
Creating Threads: Extending Thread and Implementing Runnable
Java gives you two standard ways to define the work a thread should do. The first is to extend java.lang.Thread and override its run() method. The second is to implement java.lang.Runnable and pass an instance to a Thread constructor.
In both cases, the work is defined in the run() method, and the thread is started by calling start(). This distinction is critical: calling run() directly just executes the method body on the calling thread, exactly as if it were a regular method call. No new thread is created. Only start() creates a new OS thread and schedules it to execute run() concurrently.
Implementing Runnable is almost always preferred over extending Thread. Java supports only single inheritance: if your class already extends something else, it cannot also extend Thread. More importantly, there is a design principle at play: extending Thread mixes what the thread does with how it runs. Using Runnable separates the task from the threading mechanism, which is cleaner and more flexible. Since Runnable is a functional interface, lambdas make this approach even more concise.
Creating Threads: Two Approaches
JavaExtending Thread, implementing Runnable, and using a lambda Runnable, all running concurrently.
Thread Lifecycle
Every Java thread moves through a well-defined set of states during its life. Understanding these states makes it possible to reason about what a thread is doing at any moment and why it might not be doing what you expect.
Thread states
A thread's current state is always accessible via thread.getState(), which returns a value from the Thread.State enum.
- NEW:The Thread object has been created but start() has not been called yet. The thread does not exist at the OS level.
- RUNNABLE:The thread is either running on a CPU or ready to run and waiting for the scheduler to give it CPU time. From Java's perspective both sub-states look the same.
- BLOCKED:The thread is waiting to acquire a monitor lock (a synchronized block or method) that is currently held by another thread.
- WAITING:The thread is indefinitely suspended, waiting for another thread to signal it. Caused by wait(), join() with no timeout, or LockSupport.park().
- TIMED_WAITING:Like WAITING but with a timeout. Caused by sleep(ms), wait(ms), join(ms), or LockSupport.parkNanos().
- TERMINATED:The thread's run() method has returned normally or thrown an unhandled exception. The Thread object still exists but the OS thread is gone.
Thread Lifecycle and State Transitions
JavaObserving state changes as a thread moves from NEW through RUNNABLE to TIMED_WAITING and finally TERMINATED.
Thread Methods: sleep(), join(), yield(), interrupt()
Thread.sleep(ms) suspends the current thread for at least the specified number of milliseconds. It releases the CPU but does not release any locks the thread holds. This is important: a thread holding a lock that calls sleep() keeps other threads waiting for that lock for the full sleep duration.
join() causes the calling thread to wait until the target thread finishes. This is the standard way to ensure one thread's output is available before another thread tries to use it. Calling t.join() in the main thread says "main should not proceed past this point until t is done." A timeout version, join(ms), waits at most that many milliseconds before proceeding regardless.
Thread.yield() is a hint to the scheduler that the current thread is willing to give up its CPU time slice and let other threads of equal or higher priority run. The scheduler may or may not honour this hint. It is rarely used in production code, but it appears in busy-wait loops as a way to reduce CPU spinning.
interrupt() sets an interrupt flag on the target thread. If that thread is currently in a blocking call like sleep(), wait(), or join(), the call returns immediately with an InterruptedException and the interrupt flag is cleared. The correct response inside a catch block is almost always to either propagate the exception or restore the interrupt flag with Thread.currentThread().interrupt() so that calling code can see that an interruption occurred.
sleep(), join(), yield(), and interrupt()
JavaPausing execution, coordinating completion, and cleanly stopping a thread via interruption.
Thread Priority and Daemon Threads
Thread priority is a hint to the scheduler, ranging from Thread.MIN_PRIORITY (1) to Thread.MAX_PRIORITY (10), with NORM_PRIORITY (5) as the default. Higher-priority threads are preferred by the scheduler, but the behaviour is platform-dependent and not guaranteed. Relying on priority for correctness is a design mistake. It should only be used as a performance tuning hint when real profiling evidence supports it.
A daemon thread is a background service thread. When the last non-daemon thread in a JVM exits, the JVM terminates all daemon threads immediately and shuts down. Garbage collection, the JIT compiler, and the finalizer thread are all examples of JVM daemon threads. You mark a thread as a daemon by calling setDaemon(true) before start(). Calling it after start throws an IllegalThreadStateException. Because daemon threads can be killed at any moment without warning, they must not perform operations that require cleanup, such as writing to a database or a file.
Thread Priority and Daemon Threads
JavaSetting priority as a scheduling hint, and creating a daemon background service that stops when the main thread exits.
Thread Synchronization and the synchronized Keyword
Shared mutable state is the root cause of almost all threading bugs. When two threads read and modify the same variable without coordination, their operations can interleave in ways that produce incorrect results. The classic example is a shared counter: two threads each increment it a thousand times, and you expect a final value of 2000. Without synchronization you reliably get something less because the read-modify-write sequence is not atomic.
Java's answer to this is the monitor lock, also called an intrinsic lock. Every Java object has one. The synchronized keyword uses this lock to create a mutual exclusion zone. When a thread enters a synchronized method or block, it acquires the lock. Any other thread that tries to enter any synchronized code on the same object blocks until the first thread exits and releases the lock. This ensures that only one thread at a time executes the protected code, making the read-modify-write sequence appear atomic to the other threads.
Synchronized can be applied to an entire method or to a block of code. The method form is simpler. The block form gives you more control: you can synchronize on any object, not just this, and you can minimize the size of the critical section to only the code that actually needs protection. Locking for longer than necessary hurts performance by forcing other threads to wait for unrelated code to finish.
For static synchronized methods, the lock is acquired on the Class object rather than an instance. This means static synchronized methods and instance synchronized methods use different locks and do not block each other.
Race Condition and the synchronized Fix
JavaA shared counter without synchronization produces wrong results; with synchronization it is correct every time.
The volatile Keyword
Modern CPUs cache variables in registers or CPU-level caches to avoid slow main-memory reads on every access. In a single-threaded program this optimization is invisible and beneficial. In a multi-threaded program it creates a problem: one thread might write a new value to a variable, but another thread reads from its own cache and sees the old value. The write is not immediately visible across threads.
Declaring a variable volatile forces every read and write to go directly to main memory. This guarantees visibility: any thread that reads a volatile variable always sees the most recently written value. However, volatile does not make compound operations atomic. An operation like count++ is three steps (read, add, write), and volatile does not prevent two threads from interleaving those steps. For compound operations you still need synchronized or the atomic classes in java.util.concurrent.atomic. The ideal use case for volatile is a simple flag variable: one thread writes it, another reads it, and no check-then-act logic depends on it.
volatile for Visibility
JavaA stop flag shared between threads: without volatile the reader may never see the update; with volatile it always does.
Race Conditions, Deadlocks, Livelock, and Starvation
These four terms describe different failure modes in concurrent programs. Understanding each one helps you recognize them in production symptoms and design code that avoids them.
Concurrency failure modes
All four can occur without any compile-time or obvious runtime error, making them among the hardest bugs to diagnose.
- Race condition:The correctness of the program depends on the relative timing of two or more threads. The thread that "wins the race" determines the outcome, which is non-deterministic.
- Deadlock:Two or more threads each hold a lock the other needs, so none can proceed. The classic pattern: Thread A holds lock 1 and waits for lock 2; Thread B holds lock 2 and waits for lock 1.
- Livelock:Threads are not blocked. They are actively running but keep responding to each other in a way that prevents any actual progress. Like two people in a doorway who each step aside to let the other through, simultaneously, forever.
- Starvation:A thread is perpetually denied the CPU or a lock because other threads of higher priority or faster response consistently claim the resource first. The starved thread makes no progress but is not technically blocked.
Preventing deadlock requires careful design. The most reliable rule is consistent lock ordering: if all threads that need locks A and B always acquire them in the same order (A before B), a deadlock cycle cannot form. Other approaches include using timed lock acquisition (available in java.util.concurrent.locks), which lets a thread give up and retry if it cannot acquire the second lock within a timeout, and using tryLock() to detect and back off from potential deadlocks at runtime.
Deadlock Demonstration
JavaTwo threads acquiring shared locks in opposite orders create a deadlock. The fix is a consistent lock ordering.
Inter-thread Communication: wait(), notify(), notifyAll()
Synchronization with synchronized prevents race conditions, but sometimes one thread needs to wait for a specific condition that another thread will create. For example, a consumer thread should not try to take an item from an empty queue. It should wait until a producer thread adds one. Checking the condition in a loop with sleep() (busy-waiting) wastes CPU cycles. Java provides a proper solution through wait() and notify().
Both methods must be called from within a synchronized block or method on the object being used as the monitor. Calling them outside of a synchronized context throws IllegalMonitorStateException.
When a thread calls obj.wait(), two things happen atomically: the thread releases the lock on obj and suspends itself. This is crucial: releasing the lock allows other threads to enter synchronized blocks on obj and make the condition true. When another thread calls obj.notify(), one waiting thread is woken up and re-enters the race for the lock. Once it reacquires the lock, it returns from wait() and should always re-check the condition in a loop, because notify can have spurious wake-ups in some JVM implementations.
Producer-Consumer with wait() and notify()
JavaA bounded buffer where the producer waits when full and the consumer waits when empty, coordinated via wait/notifyAll.
ThreadLocal Variables
Sometimes you need a variable that is global within a single thread but completely independent of the same variable in other threads. ThreadLocal<T> gives each thread its own private copy of a value. When one thread writes to the variable, it only writes to its own copy. Other threads see their own copies, which are unaffected. Because threads never share the same storage location, no synchronization is needed for that variable.
The canonical use case is per-thread state that would otherwise require passing a value through every method call: a database connection, a formatting object, a request context in a web framework, or a transaction identifier. SimpleDateFormat (which is not thread-safe) was historically stored in a ThreadLocal to give each thread its own safe instance. Modern code uses DateTimeFormatter instead, which is immutable and safe to share, but the pattern remains valid for genuinely stateful objects.
The most important operational rule for ThreadLocal is that you must call remove() when the thread is done with the value, especially in thread-pool environments like servlet containers. Thread pools reuse threads across requests. If a thread-local value is not cleared, the next request handled by that thread will inherit the previous request's data. This is a data-leakage bug that is hard to detect and can have security implications.
ThreadLocal Variables
JavaEach thread maintains its own request context without any shared state or synchronization.
ThreadGroup
ThreadGroup is a legacy mechanism for organizing threads into a named hierarchy. A group can hold threads and other groups, and operations like interrupt() can be applied to all threads in a group at once. In practice, ThreadGroup fell out of favour quickly after Java 5 introduced the java.util.concurrent framework, which provides far superior tools for managing collections of tasks. Most production code does not use ThreadGroup directly. You should know it exists because you will encounter it when reading old code, but for new code prefer the higher-level concurrency abstractions inExecutorService and structured concurrency.
ThreadGroup
JavaCreating a named group, assigning threads to it, and querying the group's contents.
Putting It All Together: A Thread-Safe Task Queue
The example below combines synchronization, inter-thread communication, ThreadLocal, and clean shutdown handling into a minimal but realistic worker-pool pattern. Multiple producer threads add tasks. Multiple consumer threads process them. The queue signals consumers to wait when empty and producers to wait when full. A poison-pill shutdown signal tells workers to exit cleanly.
Thread-Safe Work Queue
JavaA bounded task queue with multiple producers and consumers, graceful shutdown via poison pill, and per-worker ThreadLocal context.
Quiz - Test Your Knowledge
Ten questions covering thread creation, the thread lifecycle, the difference between run() and start(), synchronization, volatile, deadlock vs. livelock, wait/notify, daemon threads, and ThreadLocal. Read each option carefully before selecting your answer.
Knowledge Check
1. What is the difference between calling run() and start() on a Thread object?
2. A thread is in the BLOCKED state. What is the most likely cause?
3. What does the synchronized keyword guarantee when applied to an instance method?
4. What problem does the volatile keyword solve?
5. What is a deadlock?
6. You call obj.wait() inside a synchronized block. What happens?
7. What is the difference between notify() and notifyAll()?
8. What is a daemon thread?
9. What does ThreadLocal<T> provide?
10. What is livelock, and how does it differ from deadlock?