Appearance
Thread Joining, Daemon Threads, and Thread Priority
By the time you get comfortable creating threads and using synchronized blocks with wait and notify, a new set of questions starts to emerge. How do you make one thread wait for another to actually finish before it continues? What happens to background threads when your main program shuts down? And what does it mean to give a thread a higher priority? These questions come up in real interviews and in real production systems, so understanding them clearly is important.
This article covers the join method, daemon threads versus user threads, thread priority, and why the old methods stop, suspend, and resume were removed from practical use. There is also a worked example of the producer consumer problem to anchor everything together.
The Producer Consumer Problem
Before diving into the new topics, it is worth looking at the producer consumer problem because it ties together everything from the previous discussion about wait, notify, and monitor locks.
The setup is simple. You have two threads: a producer and a consumer. They share a single queue. The producer adds items to the queue and the consumer removes them. The rules are: if the queue is full, the producer must wait. If the queue is empty, the consumer must wait. This is a coordination problem, and wait plus notify is exactly the right tool for it.
Here is a working implementation:
java
import java.util.LinkedList;
import java.util.Queue;
// The shared resource both threads operate on
class SharedBuffer {
private final Queue<Integer> queue = new LinkedList<>();
private final int bufferSize;
public SharedBuffer(int bufferSize) {
this.bufferSize = bufferSize;
}
// Producer calls this to add an item
public synchronized void produce(int item) throws InterruptedException {
// If the buffer is full, producer must wait
while (queue.size() == bufferSize) {
System.out.println("Buffer full, producer waiting...");
wait(); // releases lock and waits
}
queue.add(item);
System.out.println("Produced: " + item);
notify(); // wake up any waiting consumer
}
// Consumer calls this to remove an item
public synchronized int consume() throws InterruptedException {
// If the buffer is empty, consumer must wait
while (queue.isEmpty()) {
System.out.println("Buffer empty, consumer waiting...");
wait(); // releases lock and waits
}
int item = queue.poll();
System.out.println("Consumed: " + item);
notify(); // wake up any waiting producer
return item;
}
}
public class ProducerConsumerDemo {
public static void main(String[] args) {
SharedBuffer buffer = new SharedBuffer(3); // queue of size 3
Thread producerThread = new Thread(() -> {
for (int i = 1; i <= 6; i++) {
try {
buffer.produce(i);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
Thread consumerThread = new Thread(() -> {
for (int i = 1; i <= 6; i++) {
try {
buffer.consume();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
producerThread.start();
consumerThread.start();
}
}The key detail here is that both produce and consume are synchronized methods on the same SharedBuffer object. That means only one thread can be inside either method at any given moment. When the consumer calls wait because the queue is empty, it releases the lock so the producer can enter and add an item. When the producer calls notify, the waiting consumer wakes up and competes for the lock again. This is the classic handoff pattern.
The exact output you see may vary between runs because the thread scheduler decides the order. That is intentional and expected.
Why stop, suspend, and resume Are Deprecated
One of the most common interview questions about Java threading is: why are the stop, suspend, and resume methods on Thread deprecated? The answer matters because it reveals a deep truth about what safe thread coordination actually requires.
The Problem with stop
When you call stop on a thread, the thread dies immediately. There is no warning, no chance to run finally blocks, and no lock release. The thread is just gone.
Think about what that means in practice. Suppose a thread has acquired a lock on a shared resource, and it is halfway through updating some data structure like a linked list or a bank account balance. If you call stop on it right then, the lock is never released and the data structure is left in a broken, partially updated state. Other threads that later acquire the lock on that same object will see corrupted data and behave unpredictably.
The JVM designers realized this is simply not safe. A thread must be given the chance to clean up after itself, release locks in a controlled way, and exit gracefully. That is why stop was deprecated.
The Problem with suspend and resume
Suspend and resume have a related but distinct problem. Suspend is like putting a thread on hold. On the surface that sounds harmless, but here is the critical difference: unlike wait, which releases all monitor locks when a thread sleeps, suspend holds onto every lock the thread has acquired.
Consider this scenario. Thread one acquires a lock on a shared resource and then gets suspended by the main thread. Thread two tries to acquire the same lock and gets blocked because thread one still holds it. Now the main thread wants thread two to finish something before it calls resume on thread one. But thread two is blocked forever waiting for a lock that thread one will never release until it is resumed. That is a deadlock, and it happens easily with suspend.
Here is a demonstration of what this looks like in practice:
java
class SharedResource {
private boolean available = false;
// Both threads want to call this method
public synchronized void produce() {
System.out.println("Lock acquired by: " + Thread.currentThread().getName());
try {
// Thread holds the lock for 8 seconds
Thread.sleep(8000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Lock released by: " + Thread.currentThread().getName());
}
}
public class SuspendDeadlockDemo {
@SuppressWarnings("deprecation")
public static void main(String[] args) throws InterruptedException {
SharedResource resource = new SharedResource();
System.out.println("Main thread started");
Thread thread1 = new Thread(() -> {
System.out.println("Thread 1 calling produce...");
resource.produce();
}, "Thread-1");
Thread thread2 = new Thread(() -> {
try {
Thread.sleep(1000); // let thread1 go first
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Thread 2 calling produce, waiting for lock...");
resource.produce(); // this will block because thread1 holds the lock
}, "Thread-2");
thread1.start();
thread2.start();
// Wait 3 seconds then suspend thread1
Thread.sleep(3000);
System.out.println("Suspending Thread 1...");
thread1.suspend(); // DEPRECATED: thread1 still holds the lock!
// Thread 2 is now stuck forever unless we resume thread1
System.out.println("Main thread finished. Thread 2 is in deadlock.");
// If we never call thread1.resume(), thread2 waits forever
}
}If you run this without calling resume, the program never exits. Thread two is stuck waiting for a lock that thread one holds and will never release because thread one is suspended. You have to manually kill the process. That is the deadlock that makes suspend dangerous.
Resume itself is deprecated simply because it has no purpose without suspend. They come as a pair, and since the pair causes deadlocks, both were deprecated.
What to Use Instead
The safe alternative is cooperative interruption. Instead of forcibly stopping or suspending a thread from outside, you signal the thread using Thread.interrupt() and let the thread check for that signal and shut itself down in a controlled way.
java
public class SafeShutdownDemo {
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
System.out.println("Worker doing its job...");
Thread.sleep(500);
} catch (InterruptedException e) {
// Sleep was interrupted: restore the flag and exit cleanly
System.out.println("Worker received interrupt signal, shutting down...");
Thread.currentThread().interrupt();
break;
}
}
// At this point all locks have been released naturally
System.out.println("Worker shut down cleanly.");
});
worker.start();
Thread.sleep(2000);
worker.interrupt(); // politely ask the worker to stop
}
}The thread gets to finish its current unit of work, release any locks it holds, and exit its run method naturally. The JVM is never forced into an inconsistent state.
Thread Joining: Waiting for Another Thread to Finish
Now let us talk about thread joining, because this is one of those features that looks simple but solves a real problem elegantly.
When you call start on a thread, the main thread and the new thread run independently. The main thread does not wait for the new thread to finish. It just kicks it off and keeps going. Most of the time that is fine, but sometimes you have a dependency: you want to do something in the main thread that depends on results computed by another thread.
Think of it like dispatching workers before a meeting. You send your team members off to gather data. You cannot start the meeting until all of them have come back with their reports. Thread join is how you implement that waiting.
Calling thread1.join() on a thread object tells the current thread to pause and wait until thread1 has completely finished its run method. Once thread1 terminates, the current thread automatically wakes up and continues.
Here is a simple example to make this concrete:
java
class SharedResource {
public synchronized void produce() {
System.out.println(Thread.currentThread().getName() + " acquired lock");
try {
Thread.sleep(8000); // holds lock for 8 seconds
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println(Thread.currentThread().getName() + " released lock");
}
}
public class ThreadJoinDemo {
public static void main(String[] args) throws InterruptedException {
SharedResource resource = new SharedResource();
System.out.println("Main thread started");
Thread thread1 = new Thread(() -> {
System.out.println("Thread 1 calling produce...");
resource.produce();
}, "Thread-1");
thread1.start();
System.out.println("Main thread waiting for Thread 1 to finish...");
thread1.join(); // main thread pauses here until thread1 is done
System.out.println("Thread 1 finished. Main thread continuing.");
System.out.println("Main thread finished its work.");
}
}Without the join call, main thread would print its final messages immediately after starting thread1, without waiting. With join in place, main thread waits the full 8 seconds for thread1 to complete before printing anything further.
The output you get with join will look like this:
Main thread started
Thread 1 calling produce...
Main thread waiting for Thread 1 to finish...
Thread-1 acquired lock
Thread-1 released lock
Thread 1 finished. Main thread continuing.
Main thread finished its work.That orderly sequence is exactly what join gives you.
Joining Multiple Threads
You can call join on several threads in sequence. This is the right pattern when you have parallel work and need all of it to finish before proceeding.
java
public class MultiJoinDemo {
public static void main(String[] args) throws InterruptedException {
// Imagine these three threads doing parallel data loading
Thread loaderA = new Thread(() -> {
try {
System.out.println("Loader A: fetching user data...");
Thread.sleep(2000);
System.out.println("Loader A: done.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Loader-A");
Thread loaderB = new Thread(() -> {
try {
System.out.println("Loader B: fetching product catalog...");
Thread.sleep(1500);
System.out.println("Loader B: done.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Loader-B");
Thread loaderC = new Thread(() -> {
try {
System.out.println("Loader C: fetching config settings...");
Thread.sleep(1000);
System.out.println("Loader C: done.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Loader-C");
// Start all three in parallel
loaderA.start();
loaderB.start();
loaderC.start();
// Wait for all three to complete before moving on
loaderA.join();
loaderB.join();
loaderC.join();
// Only reaches here after ALL three loaders have finished
System.out.println("All data loaded. Starting application...");
}
}All three loaders run in parallel, and the main thread waits until the last one finishes. The total wait time is roughly equal to the longest individual task, not the sum of all of them. That is the benefit of running work in parallel and then joining.
Why Join Is Better Than Alternatives
You might wonder why you would use join rather than just polling or using some shared flag. The answer is that join is exact and efficient. The main thread does not spin in a loop checking whether a thread is done. It goes to sleep and is woken up by the JVM at the precise moment the target thread terminates. No wasted CPU cycles, no guessing, no race conditions.
Join is particularly useful when you have a dependency graph of tasks. You can express "I depend on these threads finishing before I proceed" directly in code, and the JVM enforces it for you.
Thread Priority: A Hint, Not a Guarantee
Every thread in Java has a priority, represented by an integer from 1 to 10. You can set it with setPriority and read it with getPriority. Java provides three named constants for convenience:
java
Thread.MIN_PRIORITY // value is 1, the lowest
Thread.NORM_PRIORITY // value is 5, the default
Thread.MAX_PRIORITY // value is 10, the highestWhen a new thread is created, it automatically inherits the priority of the thread that created it. Since the main thread runs at NORM_PRIORITY by default, most threads you create will also start at 5 unless you change it.
Setting a priority looks like this:
java
Thread importantTask = new Thread(() -> {
System.out.println("Running important task...");
});
importantTask.setPriority(Thread.MAX_PRIORITY); // priority 10
importantTask.start();The Critical Thing You Must Understand About Priority
Here is the part that trips people up in interviews and in production: thread priority is just a hint to the thread scheduler. It is not a guarantee. The JVM will suggest to the underlying operating system that this thread should be preferred, but the OS is free to ignore that suggestion entirely.
In practice on Linux, the default JVM thread scheduler maps most Java priorities to the same underlying OS priority. You might create four threads with priorities 10, 7, 5, and 1, and they might still run in a completely unpredictable order. Nine times out of ten, you will not see the order you expect.
java
public class PriorityDemo {
public static void main(String[] args) {
Thread low = new Thread(() -> {
for (int i = 0; i < 3; i++) {
System.out.println("Low priority thread running: " + i);
}
});
Thread high = new Thread(() -> {
for (int i = 0; i < 3; i++) {
System.out.println("High priority thread running: " + i);
}
});
low.setPriority(Thread.MIN_PRIORITY); // priority 1
high.setPriority(Thread.MAX_PRIORITY); // priority 10
low.start();
high.start();
// Output order is NOT guaranteed to follow priority
// You might see low before high, or interleaved randomly
}
}The most important rule here is: never write code that depends on thread priority to produce correct behavior. If your program's correctness depends on threads executing in a certain order, you must use proper synchronization mechanisms like wait and notify, or higher level tools from java.util.concurrent. Relying on priority for ordering is a bug waiting to happen.
In real production codebases, you will almost never see thread priority being set. It is one of those features that is technically there but practically unused because it offers no reliable guarantees.
Daemon Threads: Background Workers Tied to the JVM Lifecycle
The final major topic is daemon threads, and this one is genuinely important to understand because it affects how your application shuts down.
Think of a daemon as something that runs in the background to serve other processes. A security daemon on an operating system runs quietly in the background, protecting things, but if you shut down the whole machine there is no point in keeping the security daemon alive. The daemon's life is tied to the life of the main system.
Java threads work the same way. There are two categories:
User threads are the default. These are the threads that do actual application work. The JVM will stay alive and running as long as at least one user thread is still executing. Even if main has returned, if a user thread is still running somewhere, the JVM does not exit.
Daemon threads are background service threads. The JVM does not wait for them. As soon as all user threads have finished, the JVM shuts down immediately, killing all daemon threads in the process, even if they are in the middle of their work.
How to Create a Daemon Thread
You mark a thread as a daemon by calling setDaemon(true) before starting it. If you try to call setDaemon after calling start, you will get an IllegalThreadStateException.
java
Thread backgroundLogger = new Thread(() -> {
while (true) {
try {
System.out.println("Daemon: logging heartbeat...");
Thread.sleep(500);
} catch (InterruptedException e) {
break;
}
}
});
// Must call BEFORE start()
backgroundLogger.setDaemon(true);
backgroundLogger.start();You can also check whether a thread is a daemon using isDaemon().
Seeing Daemon Behavior in Action
Here is a demonstration that makes the behavior obvious:
java
public class DaemonThreadDemo {
public static void main(String[] args) throws InterruptedException {
Thread userThread = new Thread(() -> {
try {
System.out.println("User thread: starting work...");
Thread.sleep(3000); // works for 3 seconds
System.out.println("User thread: finished work.");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "UserThread");
Thread daemonThread = new Thread(() -> {
// This thread wants to run for a long time
try {
while (true) {
System.out.println("Daemon thread: running background task...");
Thread.sleep(500);
}
} catch (InterruptedException e) {
System.out.println("Daemon thread: interrupted.");
}
}, "DaemonThread");
// Mark it as daemon BEFORE starting
daemonThread.setDaemon(true);
userThread.start();
daemonThread.start();
System.out.println("Main thread: started both threads, now finishing.");
// Main exits here, but userThread is still alive
// So JVM stays alive
}
}When you run this, the daemon thread keeps printing background messages while the user thread and main thread are alive. As soon as the user thread finishes after 3 seconds, the JVM exits and the daemon thread is killed abruptly, even though its loop would have continued indefinitely.
Now compare that with what happens if you create that same long running thread without setDaemon(true). In that case, even after main and the user thread finish, the JVM stays alive waiting for the long running thread to complete. It never exits until you kill it manually.
Common Examples of Daemon Threads
The most famous example in the Java world is the garbage collector. While your program is running, the JVM runs a dedicated daemon thread that periodically scans for unreachable objects and frees their memory. When your program exits, there is no need for the garbage collector to keep running, so it is a daemon.
Auto save in editors works the same way. While you are working, a background daemon thread periodically saves your progress. As soon as the editor closes, that daemon stops. There is nothing to save to anymore.
Logging is another good fit. You might want a background thread that continuously flushes log buffers to disk while your application runs. When the application exits, the logging daemon goes with it.
java
// A realistic daemon thread example: background log flusher
public class BackgroundLogFlusher {
public static void main(String[] args) throws InterruptedException {
Thread logFlusher = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
// Pretend we are flushing logs to disk every second
System.out.println("Log flusher: flushing buffered logs...");
Thread.sleep(1000);
} catch (InterruptedException e) {
break;
}
}
}, "LogFlusher");
logFlusher.setDaemon(true); // dies when all user threads die
logFlusher.start();
// Simulate application doing work for 4 seconds
System.out.println("Application: starting main workload...");
Thread.sleep(4000);
System.out.println("Application: main workload complete, exiting.");
// As soon as this main thread exits, logFlusher is terminated automatically
}
}The Important Rule About Daemon Thread Lifecycle
If all user threads finish and only daemon threads remain, the JVM shuts down immediately. Daemon threads do not get to complete their current task. They are not given a chance to clean up. This is intentional behavior, not a bug. It means you should never use daemon threads for work that must complete, like writing important data to a database or sending a critical network request. Daemon threads are for optional background services that are only meaningful while the application is running.
Quick Reference: The Rules That Matter
Here is a summary of the practical rules you need to remember, especially for interviews:
On join: When you call threadA.join() from the main thread, the main thread blocks and waits until threadA has terminated. Use this when you have a dependency: you need the result or side effects of one thread before another can proceed.
On stop: Never use it. Calling stop terminates a thread without releasing its locks, leaving shared data in a potentially corrupted state.
On suspend and resume: Never use them. Suspend pauses a thread while it holds all its locks, which can instantly create a deadlock if any other thread needs those same locks.
On thread priority: Treat it as a suggestion that the OS may completely ignore. Never write code whose correctness depends on execution ordering based on priority. Use synchronization primitives instead.
On daemon threads: Set setDaemon(true) before calling start, not after. Daemon threads die when all user threads finish. They are for background services, not for critical work that must complete.
On setDaemon after start: This throws an IllegalThreadStateException. The daemon status must be set before the thread is started because the JVM needs to know the thread's category at the moment it enters the runnable state.
Interview Questions You Should Be Ready For
Why are stop, suspend, and resume deprecated? Stop kills a thread without releasing locks, which corrupts shared state. Suspend pauses a thread while it holds locks, which causes deadlocks because other threads waiting for those locks can never proceed. Resume was deprecated because it has no purpose without suspend.
What does join do and when would you use it? Join makes the calling thread wait until the target thread has finished executing. You use it when you need the output or side effects of one thread before another thread can proceed, for example waiting for parallel data loaders to complete before starting an application.
What is the difference between a daemon thread and a user thread? User threads keep the JVM alive. The JVM will not exit as long as any user thread is running. Daemon threads do not keep the JVM alive. When all user threads finish, the JVM exits and kills all daemon threads immediately, even if they are in the middle of work.
Can you call setDaemon after start? No. Calling setDaemon after the thread has been started throws an IllegalThreadStateException. You must set the daemon status before calling start.
Should you rely on thread priority for controlling execution order? No. Thread priority is a hint to the thread scheduler, not a guarantee. The underlying OS scheduler may ignore it entirely. Relying on priority for correctness is a design flaw. Use proper synchronization instead.
What happens to daemon threads when the main thread exits? If the main thread is the only user thread and it exits, the JVM checks whether any other user threads are alive. If there are none, the JVM shuts down and terminates all daemon threads immediately, regardless of what they are doing.
What is the thread priority range in Java? Priorities range from 1 to 10. Thread.MIN_PRIORITY is 1, Thread.NORM_PRIORITY is 5 (the default for all threads), and Thread.MAX_PRIORITY is 10. A new thread inherits the priority of its parent thread.
Understanding these four areas, join for coordination, stop and suspend and why they fail, priority and its lack of guarantees, and daemon threads and their lifecycle, puts you in a strong position both for writing reliable concurrent code and for answering the threading questions that come up most often in Java interviews.