Appearance
Thread Creation, Lifecycle, and Inter Thread Communication
Multithreading in Java is one of those topics that separates developers who understand the JVM from those who just copy code. In this article you will learn two ways to create threads, why Java even offers both options, how a thread moves through its entire lifecycle from birth to death, what monitor locks are and how they protect shared data, and how threads talk to each other using wait, notify, and notifyAll. By the end you will have built a working producer consumer solution and you will be ready for every interview question the topic brings.
Two Ways to Create a Thread, and Why Two Ways Exist
Before looking at code, ask yourself a more interesting question: why does Java bother giving you two different ways to create a thread in the first place? Understanding that reason makes everything else click immediately.
The answer comes down to a Java inheritance rule you already know. A class in Java can extend exactly one parent class. You write class Child extends Parent and that is it, you get one parent and nothing else. But a class can implement as many interfaces as it likes. You can write class MyClass extends SomeBase implements Runnable, Serializable, Comparable with no problems at all.
Now imagine you are working on a real application. You have a class called OrderProcessor that already extends a base class called BaseProcessor from your framework. You need that class to be able to run as a thread. What do you do? You cannot write class OrderProcessor extends BaseProcessor, Thread because Java forbids extending two classes. But you absolutely can write class OrderProcessor extends BaseProcessor implements Runnable because interface implementation has no such limit.
That is the entire reason Java provides two paths. One for classes that are free to extend Thread directly, and one for classes that already have a parent and need the flexibility of an interface. In production code at actual companies, the Runnable approach is almost always preferred because it keeps the door open. Let us look at both.
Way One: Implementing the Runnable Interface
The Runnable interface lives in java.lang and it has exactly one abstract method called run. Because it has only one abstract method, it is a functional interface, which means you can implement it either by writing a class or by using a lambda expression. More on that shortly.
Here is what the Runnable interface looks like internally:
java
// This is what java.lang.Runnable looks like (simplified)
@FunctionalInterface
public interface Runnable {
void run(); // The only abstract method
}Notice something important: Runnable is NOT a thread. It does not extend Thread, it does not spawn anything, it does not create any new execution path on its own. It is just a plain interface with one method. The task and the thread are two completely separate things. Runnable describes what needs to be done. Thread is the worker that does it.
To actually run your task on a new thread you need three steps.
Step one is to create a class that implements Runnable and write the logic you want the thread to execute inside the run method:
java
// Step 1: Define the task (what the thread should do)
class MultithreadingTask implements Runnable {
@Override
public void run() {
// This code will execute ON the new thread, not the main thread
System.out.println("Code executed by thread: "
+ Thread.currentThread().getName());
}
}Step two is to create an instance of your class and then pass it to the Thread constructor. The Thread class has a constructor that accepts a Runnable. When you pass your Runnable to it, the Thread stores a reference to it internally as something called target.
Step three is to call start() on the Thread object:
java
public class Main {
public static void main(String[] args) {
// Step 2: Create the runnable object (still NOT a thread yet)
MultithreadingTask task = new MultithreadingTask();
// Step 2 continued: Wrap it in a Thread (NOW the thread object exists, but is not started)
Thread thread = new Thread(task);
// Step 3: Start the thread (this launches a new OS thread and calls run())
thread.start();
System.out.println("Main thread name: " + Thread.currentThread().getName());
}
}When you call thread.start(), the JVM allocates a real operating system thread and then calls the run() method on your Thread object. But what does the Thread class's run() method actually do? Look at this simplified version of what happens inside:
java
// Simplified version of what Thread.run() does internally
@Override
public void run() {
if (target != null) {
// target is the Runnable you passed to the Thread constructor
target.run(); // This calls YOUR run() implementation
}
// If no target was set, this method does nothing and returns
}So the sequence is: thread.start() tells the JVM to create a new OS thread, the JVM calls Thread.run(), and inside Thread.run() it checks if a Runnable target was stored. Since you passed your MultithreadingTask object to the constructor, target is not null, and so target.run() gets called, which runs the code you wrote. The new thread executes your logic. The main thread continues its own execution independently.
Because Runnable is a functional interface you can skip writing the class entirely and use a lambda expression directly:
java
// Lambda approach: shorter, same result
Thread lambdaThread = new Thread(() -> {
System.out.println("Lambda thread running on: "
+ Thread.currentThread().getName());
});
lambdaThread.start();This is equivalent to creating a Runnable class and passing an instance of it. The lambda IS the run() method implementation.
Way Two: Extending the Thread Class
The second approach is to extend Thread directly. When your class extends Thread, your class IS a thread. It inherits all of Thread's machinery, including start(), sleep(), interrupt(), and everything else Thread can do.
java
// Way 2: Your class IS a thread
class MultithreadingTaskExtend extends Thread {
@Override
public void run() {
// This is what THIS thread will execute
System.out.println("Extended thread running on: "
+ Thread.currentThread().getName());
}
}Because your class is already a Thread, you create an object of your class directly and call start() on it without needing to create a separate Thread wrapper:
java
public class Main {
public static void main(String[] args) {
// No need to wrap in Thread, this IS a Thread already
MultithreadingTaskExtend myThread = new MultithreadingTaskExtend();
myThread.start(); // Internally calls run(), which you overrode
}
}When you call start() here, the JVM creates an OS thread. It calls run() on myThread. Since you overrode run(), your version gets called.
What if you forget to override run()? Then the Thread class's default run() kicks in. That default implementation checks whether a Runnable target was stored. Since you did not pass a Runnable target to the constructor and you did not override run(), the default run() does absolutely nothing and returns. Your thread starts, immediately does nothing, and terminates. This is why you should always override run() when extending Thread.
The Single Most Important Mistake: Calling run() Instead of start()
This is a classic interview trap. What happens if you write thread.run() instead of thread.start()?
java
Thread thread = new Thread(() -> {
System.out.println("Running on: " + Thread.currentThread().getName());
});
thread.run(); // WRONG: no new thread is created
thread.start(); // CORRECT: creates a new OS threadWhen you call run() directly, Java treats it as a completely ordinary method call. The code inside run() executes on whatever thread made the call, which is usually the main thread. No new thread is spawned. No parallelism happens. Everything runs sequentially, as if threading did not exist. The output would say "Running on: main" instead of showing a new thread name.
Always call start(). Only start() creates a real new thread.
Why Runnable Is Preferred Over Extending Thread
Beyond the inheritance constraint, there is a deeper reason to prefer Runnable. When you extend Thread, you are mixing your task logic with the thread itself. The object that represents what needs to be done is also the object that manages the OS thread. These are two different concerns bundled into one object.
When you use Runnable, you separate them cleanly. The Runnable describes the task. The Thread is the runner. You can give the same Runnable to multiple threads. You can submit a Runnable to a thread pool managed by an ExecutorService. You can schedule it. None of that flexibility exists if your task is baked into a Thread subclass, because you cannot hand a Thread subclass to an ExecutorService as a reusable task in the same clean way.
The real world tends to use thread pools rather than creating raw threads anyway, and thread pools expect Runnables. So practicing the Runnable approach from the beginning puts you in good habits.
The Thread Lifecycle: Six States Every Thread Passes Through
A Java thread can exist in exactly six states defined in the java.lang.Thread.State enum: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, and TERMINATED. Understanding when a thread enters each state, and crucially what happens to locks during those transitions, is essential.
NEW
The moment you write Thread t = new Thread(myRunnable), the thread object exists in memory but nothing real has started yet. No OS thread has been allocated. The thread is in the NEW state. It is just a Java object sitting on the heap.
java
Thread t = new Thread(() -> System.out.println("hello"));
// t is in NEW state right here
// Thread.State.NEWRUNNABLE
When you call t.start(), the thread transitions to RUNNABLE. This state actually covers two sub situations. The thread might be waiting in the OS run queue for the CPU to schedule it, or the thread might currently be executing on a CPU core. Java treats both as RUNNABLE because from the JVM's perspective, the thread is alive and ready to run.
The CPU constantly switches between threads using something called context switching. One thread runs for a slice of time, gets pulled off the CPU, another thread gets its turn. The thread being pulled off goes back to the RUNNABLE queue. The thread getting CPU time starts executing. They flicker between these two sub states continuously, but Java sees both as RUNNABLE.
java
t.start();
// t is now in RUNNABLE state
// Could be waiting for CPU or actually running on CPUBLOCKED
A thread enters the BLOCKED state when it is trying to enter a synchronized block or method but another thread already holds the monitor lock on that object. The blocked thread is stuck waiting to acquire the lock. It cannot proceed until the lock is released. Critically, while a thread is in the BLOCKED state it does NOT release any locks it already holds. It simply waits.
java
// Thread 2 tries to enter this synchronized block
// If Thread 1 already holds the lock on this object,
// Thread 2 enters BLOCKED state
synchronized (sharedObject) {
// critical section
}WAITING
A thread enters the WAITING state when it calls object.wait(), thread.join() with no timeout, or LockSupport.park(). A thread in WAITING state will stay there indefinitely until another thread explicitly calls object.notify() or object.notifyAll() to wake it up.
The critical and counterintuitive thing about WAITING is that calling wait() RELEASES the monitor lock on the object. This is the opposite of what happens in BLOCKED. When a thread calls wait(), it voluntarily gives up the lock and goes to sleep. Other threads are then free to acquire that lock and do their work. This behavior is what makes wait and notify useful for coordination.
java
synchronized (sharedObject) {
// This releases the lock and puts the thread in WAITING
// The thread will stay here until someone calls notify
sharedObject.wait();
// After being notified, the thread tries to re-acquire the lock
// and continues from this point
}TIMED_WAITING
A thread enters TIMED_WAITING when it calls Thread.sleep(milliseconds), object.wait(milliseconds), or thread.join(milliseconds). The key word is timed. The thread will automatically wake up after the specified duration without anyone needing to call notify.
Here is the critical difference between sleep and wait regarding locks:
Thread.sleep() does NOT release any locks. If you are sleeping inside a synchronized block, you hold the lock for the entire duration of the sleep. Every other thread waiting on that lock is stuck for the whole sleep period.
object.wait(timeout) DOES release the lock, just like regular wait(), but it has an automatic expiry time built in.
java
// sleep holds the lock the entire time:
synchronized (sharedObject) {
Thread.sleep(5000); // Lock is HELD for 5 full seconds
}
// wait(timeout) releases the lock and auto-wakes after timeout:
synchronized (sharedObject) {
sharedObject.wait(5000); // Lock RELEASED, wakes after 5s or on notify
}This distinction is one of the most common interview questions in the threading space. Sleep holds the lock. Wait releases it. Get that distinction tattooed in your memory.
TERMINATED
Once the run() method returns, either normally or because an unhandled exception killed the thread, the thread moves to the TERMINATED state. A terminated thread is gone forever. You cannot restart it. Calling start() on a terminated thread throws an IllegalThreadStateException. If you need the task to run again, create a new Thread object.
java
t.start();
// ... eventually t finishes its run() method
// t is now in TERMINATED state
// t.start() again would throw IllegalThreadStateExceptionThe State Transition Summary
Here is how all six states connect:
NEW
|
| t.start() called
v
RUNNABLE <-----------------------------------+
| ^ |
| | |
| | lock acquired / IO done / timed out |
v | |
BLOCKED (waiting to acquire a monitor lock) |
|
RUNNABLE |
| |
| object.wait() called |
v |
WAITING (lock released, waiting for notify) |
| |
| notify() or notifyAll() received |
+-------------------------------------------+
RUNNABLE
|
| Thread.sleep() or object.wait(timeout)
v
TIMED_WAITING
|
| timeout expires or notified
+-> RUNNABLE
RUNNABLE
|
| run() returns
v
TERMINATEDMonitor Locks: The Foundation of Synchronized Access
Before you can truly understand wait and notify, you need to understand monitor locks at a deep level, because wait and notify are entirely built around them.
Every single object in Java, every one, carries a hidden monitor lock inside its object header. You never create this lock, you never destroy it. It just exists as part of every object's structure.
When a thread enters a synchronized block or calls a synchronized method, it tries to acquire the monitor lock on a specific object. If no other thread holds it, the thread takes it, does its work, and releases it when it exits the synchronized block. If another thread already holds it, the acquiring thread goes to BLOCKED and waits.
java
class BankAccount {
private int balance = 1000;
// Synchronized method: acquires monitor lock on 'this' object
public synchronized void withdraw(int amount) {
if (balance >= amount) {
balance -= amount;
}
System.out.println("Withdrew " + amount + ", balance: " + balance);
}
// Synchronized block: same as above but explicit
public void deposit(int amount) {
synchronized (this) { // acquires monitor lock on 'this'
balance += amount;
System.out.println("Deposited " + amount + ", balance: " + balance);
}
}
// Static synchronized: acquires lock on the Class object, not instance
public static synchronized void printNotice() {
System.out.println("Bank is open");
}
}The scope of the lock matters enormously. Each object has its own independent monitor lock. If Thread 1 and Thread 2 both call methods on the SAME BankAccount object, they compete for the same lock and must take turns. But if Thread 1 calls methods on account1 and Thread 2 calls methods on account2, they have completely separate locks and can run in parallel with zero contention.
java
BankAccount account1 = new BankAccount();
BankAccount account2 = new BankAccount();
// Thread 1 and Thread 2 compete because same object:
Thread t1 = new Thread(() -> account1.withdraw(100));
Thread t2 = new Thread(() -> account1.deposit(200));
// Thread 1 and Thread 2 do NOT compete because different objects:
Thread t3 = new Thread(() -> account1.withdraw(100));
Thread t4 = new Thread(() -> account2.withdraw(50)); // separate lockHere is a concrete example demonstrating monitor lock behavior across methods. Three threads work on the same object. Two use synchronized sections, one does not:
java
class MonitorLockExample {
// Synchronized method, sleeps for 10 seconds inside
public synchronized void taskOne() {
System.out.println("Task one started by: " + Thread.currentThread().getName());
try {
Thread.sleep(10_000); // Holds the lock for 10 seconds
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Task one completed");
}
// Uses synchronized block inside, prints before the block
public void taskTwo() {
System.out.println("Task two before synchronized block");
synchronized (this) { // Must wait for taskOne to release the lock
System.out.println("Task two inside synchronized block");
}
}
// No synchronization at all, runs freely
public void taskThree() {
System.out.println("Task three runs without any lock");
}
public static void main(String[] args) {
MonitorLockExample obj = new MonitorLockExample();
// All three threads work on the SAME object
Thread thread1 = new Thread(obj::taskOne, "Thread-1");
Thread thread2 = new Thread(obj::taskTwo, "Thread-2");
Thread thread3 = new Thread(obj::taskThree, "Thread-3");
thread1.start();
thread2.start();
thread3.start();
}
}When you run this: Thread 1 enters taskOne, acquires the monitor lock on obj, and sleeps for 10 seconds while holding that lock. Thread 2 enters taskTwo, prints the "before synchronized block" message (that part needs no lock), then tries to enter the synchronized block. It wants the monitor lock on obj, but Thread 1 holds it. Thread 2 goes to BLOCKED state and waits. Thread 3 enters taskThree immediately because there is no synchronization there and no lock required. Thread 3 prints and finishes. After 10 seconds, Thread 1 wakes, prints "task one completed", exits the synchronized method, and RELEASES the monitor lock. Thread 2 immediately acquires the lock, enters the synchronized block, prints its message, and finishes.
The lesson: monitor locks are per object, synchronized methods and synchronized blocks on the same object share the same lock, and a lock held in one method blocks entry into synchronized sections of any other method on the same object.
wait(), notify(), and notifyAll(): How Threads Talk to Each Other
The methods wait(), notify(), and notifyAll() are defined on java.lang.Object, not on Thread. That means every single object in Java can be used as a communication channel between threads. This design choice reflects the fact that threads coordinate around shared data, and data lives in objects.
There is an absolute rule you must always follow: you can only call wait(), notify(), and notifyAll() from inside a synchronized block or method on the same object. If you call them outside a synchronized block, Java throws an IllegalMonitorStateException immediately.
What wait() does: When a thread calls object.wait(), three things happen simultaneously. The thread releases the monitor lock on object. The thread enters the WAITING state. The thread gets added to the object's internal wait set, which is basically a list of threads waiting to be notified about this object.
What notify() does: When a thread calls object.notify(), one thread from the object's wait set gets woken up and moved from WAITING to BLOCKED. Why BLOCKED and not directly to RUNNABLE? Because the thread that just called notify() still holds the monitor lock. The newly woken thread must wait for the notifier to release the lock before it can proceed.
What notifyAll() does: Same as notify() but it wakes every single thread in the wait set at once. All of them move to BLOCKED and compete for the lock. One wins, the rest keep waiting.
java
class SharedSignal {
private boolean ready = false;
// Waiter thread calls this
public synchronized void waitForSignal() throws InterruptedException {
while (!ready) { // while loop for safety (explained below)
wait(); // releases lock, enters WAITING
}
System.out.println("Signal received, proceeding");
}
// Signaler thread calls this
public synchronized void sendSignal() {
ready = true;
notifyAll(); // wake all waiting threads
System.out.println("Signal sent");
}
}Why You Must Use a while Loop, Never an if Statement
This is one of the most important rules in concurrent Java programming. When you call wait(), you must put it inside a while loop that checks the condition, never inside a plain if statement.
java
// DANGEROUS: Using if
synchronized (obj) {
if (conditionNotMet) {
obj.wait(); // Woke up, but condition might still not be met
}
// Proceeds even if condition is still false
doWork();
}
// SAFE: Using while
synchronized (obj) {
while (conditionNotMet) {
obj.wait(); // Woke up, checks condition again before proceeding
}
// Condition is guaranteed to be met here
doWork();
}Two distinct problems justify the while loop.
The first problem is spurious wakeups. The POSIX thread specification, which the JVM relies on at the OS level, explicitly allows a waiting thread to be woken up without anyone calling notify. This is called a spurious wakeup and it happens due to OS kernel internals that Java has no control over. If you use if, your thread can wake up for no legitimate reason, skip the condition check, and try to do work when the data it needs is not actually available. The while loop sends it back to wait if the condition is still not satisfied.
The second problem is the race condition between multiple consumers. Imagine a buffer with one item in it and three consumer threads all sleeping in wait(). The producer adds an item and calls notifyAll(). All three consumers wake up and move to BLOCKED, competing for the lock. Consumer 1 wins the lock, checks the condition ("is there data?"), finds data, consumes it, and releases the lock. Consumer 2 now wins the lock. If Consumer 2 used an if statement, it already passed the condition check back when it was woken up. It skips the check and tries to consume from an empty queue. Crash. With a while loop, Consumer 2 rechecks the condition, finds the queue empty, and calls wait() again. Problem solved.
Oracle's official documentation for the wait() method explicitly tells you to use while. It is not a preference, it is the correct way.
A Complete Producer Consumer Implementation
Let us now put all of this together with a real producer consumer implementation. The problem: a producer thread generates items and adds them to a shared buffer. A consumer thread takes items from the buffer. The buffer has a maximum capacity. The producer should wait when the buffer is full. The consumer should wait when the buffer is empty.
java
import java.util.LinkedList;
import java.util.Queue;
// The shared resource that both producer and consumer use
class SharedBuffer {
private final Queue<Integer> queue = new LinkedList<>();
private final int capacity;
public SharedBuffer(int capacity) {
this.capacity = capacity;
}
// Producer calls this
public synchronized void produce(int item) throws InterruptedException {
// Wait while buffer is full
while (queue.size() == capacity) {
System.out.println(Thread.currentThread().getName()
+ " Buffer is full, waiting...");
wait(); // releases lock, goes to WAITING
}
queue.offer(item);
System.out.println(Thread.currentThread().getName()
+ " Produced: " + item + " | Buffer size: " + queue.size());
// Tell waiting consumers that data is available
notifyAll();
}
// Consumer calls this
public synchronized int consume() throws InterruptedException {
// Wait while buffer is empty
while (queue.isEmpty()) {
System.out.println(Thread.currentThread().getName()
+ " Buffer is empty, waiting...");
wait(); // releases lock, goes to WAITING
}
int item = queue.poll();
System.out.println(Thread.currentThread().getName()
+ " Consumed: " + item + " | Buffer size: " + queue.size());
// Tell waiting producers that space is available
notifyAll();
return item;
}
}
public class ProducerConsumer {
public static void main(String[] args) {
SharedBuffer buffer = new SharedBuffer(3); // capacity of 3
// Producer thread: adds 10 items, slow pace
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 10; i++) {
buffer.produce(i);
Thread.sleep(200); // produce every 200ms
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Producer");
// Consumer thread: consumes 10 items, slow pace
Thread consumer = new Thread(() -> {
try {
for (int i = 1; i <= 10; i++) {
buffer.consume();
Thread.sleep(500); // consume every 500ms (slower than producer)
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Consumer");
consumer.start();
producer.start();
}
}Walk through what happens step by step. Both threads start. The consumer thread runs first (or very quickly). It calls buffer.consume(), which is synchronized. The consumer acquires the monitor lock on the buffer object. It checks the while condition: is the queue empty? Yes it is, nothing has been produced yet. So the consumer calls wait(). This releases the monitor lock and puts the consumer in WAITING state.
Now the producer thread can acquire the monitor lock because the consumer released it. The producer calls buffer.produce(1). It checks whether the queue is full, it is not. It adds item 1 to the queue. It calls notifyAll(), which wakes the consumer. The consumer moves from WAITING to BLOCKED. The producer finishes its synchronized method and releases the lock. The consumer acquires the lock. The consumer's while loop runs again: is the queue empty? No, there is item 1. The consumer exits the while loop, polls item 1 from the queue, prints the consumption, calls notifyAll() (waking the producer just in case it is waiting for space), and releases the lock.
This dance of producing and consuming, waiting and notifying, continues for all 10 items. When the buffer reaches capacity 3, the producer will wait. When the buffer empties, the consumer will wait. They coordinate gracefully through the shared monitor lock and the wait/notifyAll mechanism.
The Simple Demo: Wait and Notify in Action
Here is a simpler version to make the mechanics visible before tackling the full producer consumer:
java
class SharedResource {
private boolean itemAvailable = false; // starts as nothing available
// Producer calls this to add an item
public synchronized void addItem() {
itemAvailable = true;
System.out.println(Thread.currentThread().getName() + " calling notifyAll");
notifyAll(); // wake any waiting consumers
}
// Consumer calls this to take an item
public synchronized void consumeItem() throws InterruptedException {
while (!itemAvailable) {
System.out.println(Thread.currentThread().getName()
+ " waiting for item...");
wait(); // release lock, sleep
}
itemAvailable = false;
System.out.println(Thread.currentThread().getName() + " consumed the item");
}
}
public class WaitNotifyDemo {
public static void main(String[] args) {
SharedResource resource = new SharedResource();
// Producer: waits 3 seconds then adds item
Thread producerThread = new Thread(() -> {
try {
Thread.sleep(3000); // give consumer time to start waiting
resource.addItem();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "ProducerThread");
// Consumer: immediately tries to consume
Thread consumerThread = new Thread(() -> {
try {
resource.consumeItem();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "ConsumerThread");
consumerThread.start();
producerThread.start();
}
}When the consumer starts it tries to consume. itemAvailable is false. It calls wait(), releases the lock, and sleeps. The producer sleeps for 3 seconds (TIMED_WAITING state). After 3 seconds the producer wakes, acquires the lock on resource, sets itemAvailable = true, calls notifyAll(), and finishes the synchronized method, releasing the lock. The consumer is woken by notifyAll, transitions from WAITING to BLOCKED, acquires the lock, rechecks the while condition (now itemAvailable is true), exits the loop, consumes the item, and prints the result.
Every Interview Question From This Topic
Why does Java provide two ways to create a thread?
Java allows a class to extend only one parent class but implement multiple interfaces. If a class already extends another class, it cannot also extend Thread. But it can always implement Runnable. Java provides both ways so that any class, regardless of its inheritance hierarchy, can participate in threading.
Which approach is preferred and why?
Implementing Runnable is preferred. It separates the task from the thread mechanism. The same Runnable can be submitted to thread pools via ExecutorService. A class implementing Runnable can still extend another class and implement other interfaces. Extending Thread locks you into a class hierarchy commitment that limits flexibility.
What happens if you call run() instead of start()?
Calling run() directly is a plain method call. The code inside run() executes on the calling thread, usually the main thread. No new OS thread is created. No concurrency happens. You must call start() to create a real new thread.
What are the six thread states?
NEW (created but not started), RUNNABLE (started, may be waiting for CPU or running on CPU), BLOCKED (waiting to acquire a monitor lock), WAITING (waiting indefinitely for notify or join to complete), TIMED_WAITING (waiting for a specific time via sleep or wait with timeout), TERMINATED (run() returned or threw an exception, cannot be restarted).
What is the difference between BLOCKED and WAITING?
BLOCKED means the thread is trying to enter a synchronized block but someone else holds the lock. WAITING means the thread called wait() deliberately and is sleeping until explicitly notified. A BLOCKED thread holds whatever locks it already has. A WAITING thread has RELEASED its locks.
Does Thread.sleep() release the monitor lock?
No. A sleeping thread holds every lock it had when it called sleep(). Other threads wanting those locks will be stuck for the entire sleep duration. This is why using sleep inside synchronized blocks is usually a bad idea unless you are very intentional about it.
Does wait() release the monitor lock?
Yes, immediately and completely. This is the fundamental difference between sleep and wait. The wait call releases the lock so other threads can acquire it and potentially change the state that the waiting thread is waiting for.
Why must you use a while loop with wait() and never just an if statement?
Two reasons. First, spurious wakeups: the OS can wake a waiting thread for no reason, with no notify having been called. The while loop rechecks the condition and sends the thread back to wait if the condition is still not satisfied. Second, race conditions with multiple consumers: when notifyAll wakes multiple threads, only one gets the lock first and may consume the available data. The others, when they get the lock, need to recheck whether data is still there. If they used if, they would proceed without checking and crash.
What is a spurious wakeup?
A spurious wakeup is when a thread in the WAITING state is woken up by the operating system without anyone having called notify or notifyAll. It is an OS level behavior permitted by the POSIX thread specification. Java's own documentation for wait() explicitly acknowledges this and tells you to always use a while loop to guard against it.
Why are wait(), notify(), and notifyAll() defined on Object instead of Thread?
Because the lock that threads wait on and notify about belongs to the object being shared, not to the threads themselves. Threads coordinate through shared objects. Since any object can be a monitor, any object needs these methods. If these methods were on Thread, you would have no way to associate a wait with a specific shared resource.
What is a monitor lock?
Every Java object has a hidden monitor lock built into its object header. When a thread enters a synchronized block or method, it acquires the monitor lock on the object specified by the synchronization. No other thread can enter any synchronized section guarded by that same object's lock until the first thread releases it. Monitor locks are the foundation of all synchronization in Java.
Can a terminated thread be restarted?
No. Once run() completes, the thread is in TERMINATED state permanently. Calling start() again throws IllegalThreadStateException. You must create a new Thread object if you need the task to run again.
What to Practice Next
Now that you understand everything in this article, there is a concrete exercise to solidify the producer consumer pattern. Build a version where the producer generates numbers and puts them into a queue, and the consumer takes numbers from that queue. The queue should have a fixed maximum size, for example five items. The producer must wait when the queue is full. The consumer must wait when the queue is empty. Use the implementation above as a starting point and make sure to use a while loop for every wait call.
There is also one topic intentionally left for the next article: the stop(), suspend(), and resume() methods on Thread are all deprecated. You should know why they were deprecated and what the correct modern alternative is for safely stopping a thread. That will be covered next.