Threads Basics
Threads Basics
A thread is the smallest unit of execution the JVM schedules independently. Every Java program already runs on at least one — the thread executing main() — and creating more lets separate pieces of work run concurrently. This article covers the two ways to create a thread, the critical difference between start() and run(), and how to wait for a thread to finish with join().
What Is a Thread?
A thread is an independent path of execution inside a running JVM process, with its own call stack, but sharing the same heap memory as every other thread in that process. That shared memory is exactly what makes threads useful — several threads can work on the same data at once — and exactly what makes concurrency dangerous the moment two threads touch that shared data without coordination.
Java gives a thread two forms: java.lang.Thread itself, and the task a thread runs, represented as a Runnable. Understanding how those two pieces fit together, covered next, is the foundation everything else in this section builds on.
Why Thread Safety Matters
Every problem this Multithreading section covers — race conditions, deadlocks, visibility bugs — starts from the same root: two or more threads touching shared state without the right coordination. None of that is possible to reason about correctly until the basics here are solid.
A team that is fuzzy on exactly when a thread actually starts running, or forgets that join() is the only real guarantee a background thread has finished its work, ends up chasing bugs that look random but are actually completely deterministic once you know what start(), run(), and join() each really do.
A mistake that appears often in fresher pull requests is confusing start() with run(), or reading a background thread's result with no join() at all. Both produce code that happens to work in a quick local test and then fails unpredictably once real load introduces timing variance the test never saw.
How It Works
Calling run() directly looks like it starts a thread, but it does not — it simply calls the method like any other, on whichever thread happens to call it. Only start() actually creates a new call stack and schedules run() to execute on it.
One prose sentence introducing the diagram: the flow below traces what actually happens between constructing a Thread, starting it, and joining it.
main thread worker thread
|
|-- new Thread(task) --> state: NEW (not running yet)
|
|-- start() ------------> state: RUNNABLE
| |
| (main keeps running) | run() executes here
| |
|-- join() -- blocks ---> |
| v
| state: TERMINATED
|<----------------------------+
| (join returns, main resumes)
start() is the only step that actually creates the second column in this diagram — everything before it happens entirely on the main thread, and everything after join() is guaranteed to have already finished on the worker thread.
Extending Thread and overriding run() works, but uses up a class's one shot at extending something, and ties the task itself to the mechanism that runs it. Implementing Runnable instead separates the task from the thread that executes it, and since Runnable is a functional interface, a lambda works just as well for a simple task with no state of its own to hold.
Code Examples
The version below calls run() directly — it compiles and runs, but nothing about it is actually concurrent.
1// File: BeforeThreadsBasics.java
2
3public class BeforeThreadsBasics {
4
5 static class AvailabilityChecker extends Thread {
6 @Override
7 public void run() {
8 System.out.println("Checking on: " + Thread.currentThread().getName());
9 }
10 }
11
12 public static void main(String[] args) {
13 AvailabilityChecker checker = new AvailabilityChecker();
14 checker.run();
15 System.out.println("Main thread: " + Thread.currentThread().getName());
16 }
17}Output:
Checking on: main
Main thread: main
checker.run() executed on the main thread itself — nothing about it was concurrent. start() is what actually creates a new thread and schedules run() to execute on it.
1// File: AfterThreadsBasics.java
2
3public class AfterThreadsBasics {
4
5 static class AvailabilityChecker extends Thread {
6 @Override
7 public void run() {
8 System.out.println("Checking on: " + Thread.currentThread().getName());
9 }
10 }
11
12 public static void main(String[] args) throws InterruptedException {
13 AvailabilityChecker checker = new AvailabilityChecker();
14 checker.setName("availability-checker");
15 checker.start();
16 checker.join();
17 System.out.println("Main thread: " + Thread.currentThread().getName());
18 }
19}Output:
Checking on: availability-checker
Main thread: main
Thread.currentThread().getName() now reports availability-checker, confirming run() executed on the new thread this time — and join() guarantees that line prints before main()'s own line, regardless of how the JVM actually schedules the two threads.
The two ways to construct a thread both work the same way once started — a Runnable implementation, or a lambda for something simple.
1// File: RunnableExample.java
2
3public class RunnableExample {
4
5 static class AvailabilityTask implements Runnable {
6 private final String hotelName;
7
8 AvailabilityTask(String hotelName) {
9 this.hotelName = hotelName;
10 }
11
12 @Override
13 public void run() {
14 System.out.println(hotelName + " checked on " + Thread.currentThread().getName());
15 }
16 }
17
18 public static void main(String[] args) throws InterruptedException {
19 Thread thread = new Thread(new AvailabilityTask("Taj Palace"), "hotel-checker");
20 thread.start();
21 thread.join();
22 }
23}Output:
Taj Palace checked on hotel-checker
1// File: LambdaRunnableExample.java
2
3public class LambdaRunnableExample {
4 public static void main(String[] args) throws InterruptedException {
5 Thread thread = new Thread(() ->
6 System.out.println("Running on " + Thread.currentThread().getName()), "worker");
7 thread.start();
8 thread.join();
9 }
10}Output:
Running on worker
Real-World Example
A hotel booking platform checks room availability across several partner hotels concurrently, spawning one thread per hotel and joining all of them before reading any result — the same pattern applies to naming threads meaningfully for readable stack traces, and to choosing Runnable over extending Thread whenever the task itself carries meaningful state.
1// File: HotelAvailabilityChecker.java
2import java.util.*;
3import java.util.concurrent.ConcurrentHashMap;
4
5public class HotelAvailabilityChecker {
6
7 private final Map<String, Integer> availableRooms = new ConcurrentHashMap<>();
8
9 public void checkAll(List<String> hotels) throws InterruptedException {
10 List<Thread> threads = new ArrayList<>();
11
12 for (String hotel : hotels) {
13 Thread thread = new Thread(() -> {
14 int rooms = simulateAvailabilityLookup(hotel);
15 availableRooms.put(hotel, rooms);
16 }, "check-" + hotel);
17 threads.add(thread);
18 thread.start();
19 }
20
21 for (Thread thread : threads) {
22 thread.join();
23 }
24 }
25
26 private int simulateAvailabilityLookup(String hotel) {
27 return hotel.length() * 2;
28 }
29
30 public Map<String, Integer> getAvailableRooms() {
31 return availableRooms;
32 }
33}1// File: HotelAvailabilityDemo.java
2import java.util.*;
3
4public class HotelAvailabilityDemo {
5 public static void main(String[] args) throws InterruptedException {
6 HotelAvailabilityChecker checker = new HotelAvailabilityChecker();
7
8 List<String> hotels = List.of("TajPalace", "OberoiGrand", "ItcMaurya");
9 checker.checkAll(hotels);
10
11 Map<String, Integer> results = checker.getAvailableRooms();
12 for (String hotel : hotels) {
13 System.out.println(hotel + ": " + results.get(hotel) + " rooms");
14 }
15 }
16}Output:
TajPalace: 18 rooms
OberoiGrand: 22 rooms
ItcMaurya: 18 rooms
A mistake that appears often in fresher pull requests is reading a shared result right after calling start(), with no join() at all — the background thread may not have finished, or even begun, its work yet, so the read can see a missing or stale value. Joining every spawned thread before touching checkAll's results, exactly as the second loop here does, is what makes the results complete and deterministic regardless of how the threads actually got scheduled. ConcurrentHashMap, used above to safely collect results from multiple threads, is covered in full in this section's dedicated ConcurrentHashMap article.
Best Practices
Prefer implementing Runnable over extending Thread — it keeps a class free to extend something else and separates the task itself from the mechanics of running it.
Name every thread meaningfully, either through the Thread(Runnable, String) constructor or setName(), so a stack trace or thread dump identifies what a thread was actually doing.
Never call run() expecting concurrency — only start() creates a new thread; run() is just an ordinary method call on whatever thread invokes it.
Always join() a thread before depending on any result it produced, rather than assuming enough time has passed for it to have finished.
Common Mistakes
Reading a value a background thread is supposed to produce immediately after start(), with no join(), has no guarantee that value is ready yet.
1// Illustrative only - do not rely on this: reading a shared result right
2// after start(), with no join(), has no guarantee the background thread
3// has produced - or even started producing - that result yet
4Thread thread = new Thread(() -> sharedResult = computeSomething());
5thread.start();
6System.out.println(sharedResult); // may print null, or a stale valueAssuming a Thread object can be restarted after it finishes throws IllegalThreadStateException — a Thread can only ever be started once in its lifetime.
1// File: DoubleStartMistake.java
2
3public class DoubleStartMistake {
4 public static void main(String[] args) throws InterruptedException {
5 Thread thread = new Thread(() -> System.out.println("Running"));
6 thread.start();
7 thread.join();
8
9 try {
10 thread.start();
11 } catch (IllegalThreadStateException e) {
12 System.out.println("Caught: " + e.getClass().getSimpleName());
13 }
14 }
15}Output:
Running
Caught: IllegalThreadStateException
A new Thread object is required for each run — there is no way to reset an already-started Thread back to its initial state.
Interview Questions
Q1. What is the difference between calling start() and calling run() directly on a Thread?
start() creates a new thread and schedules run() to execute on it. Calling run() directly is just an ordinary method call — it executes on whichever thread called it, with no new thread involved at all, exactly as demonstrated in this article's Code Examples section. The nuance interviewers are listening for is whether you understand that run() is genuinely just a regular method here, not that it "runs on the wrong thread somehow."
Q2. What are the two ways to create a thread in Java, and which is generally preferred?
Extending Thread and overriding run(), or implementing Runnable and passing an instance to a Thread constructor. Implementing Runnable is generally preferred, since it does not use up a class's single opportunity to extend another class, and keeps the task separate from the mechanics of running it. Interviewers listening closely want to hear the single-inheritance reasoning specifically, not just "it's best practice."
Q3. What does join() do, and why is it necessary before reading a value a thread produced?
join() blocks the calling thread until the thread it is called on finishes. It is necessary before reading a result that thread produced because there is otherwise no guarantee the background thread has finished — or even started — producing that value. This is the question most likely to expose a candidate who has never actually hit a flaky test caused by a missing join().
Q4. Can a Thread object be started more than once?
No. Calling start() on a Thread that has already been started — including one that has already finished — throws IllegalThreadStateException, as demonstrated in this article's Common Mistakes section. A new Thread object is required to run the same task again. The nuance here is that "already finished" still counts — a common wrong answer assumes a terminated thread can be restarted since it is "not running anymore."
Q5. What does Thread.currentThread() return?
It returns a reference to the Thread object representing whichever thread is currently executing the code that calls it — used throughout this article to confirm exactly which thread a piece of code is actually running on.
Q6. Why is implementing Runnable generally preferred over extending Thread?
Because Java only allows single inheritance — extending Thread uses up a class's one opportunity to extend something else. Implementing Runnable also cleanly separates what a task does from the thread mechanics that execute it.
Q7. What exception can join() throw, and why?
join() declares throws InterruptedException, since the calling thread is blocked while waiting, and can itself be interrupted by another thread while it waits — the same reason Thread.sleep() declares the same checked exception. Product-company interviewers often follow up by asking what happens if that exception is swallowed silently, which is a good moment to mention restoring the interrupt status with Thread.currentThread().interrupt().
FAQs
Does creating a Thread object start it running immediately?
No. Constructing a Thread — whether by extending it or passing a Runnable to its constructor — only creates the object. Nothing runs until start() is called on it.
Can a thread be given a custom name?
Yes, either by passing a name as an argument to a Thread constructor, as RunnableExample and LambdaRunnableExample both do, or by calling setName() before start(), as AfterThreadsBasics does.
What happens to the rest of main() if it doesn't join() a spawned thread?
main() continues immediately without waiting. If the spawned thread is not a daemon thread, the JVM itself stays alive until that thread finishes too, even after main() has already returned.
Is Runnable a functional interface?
Yes, Runnable has a single abstract method, void run(), which makes it a valid target for a lambda expression, exactly as LambdaRunnableExample demonstrates.
Can a Thread's run() method return a value?
No, Runnable.run() has a void return type by design. Callable<V>, covered in this section's next article, is the alternative to use when a task needs to return a value.
Does calling join() block the calling thread indefinitely?
By default, yes, until the joined thread finishes. An overload, join(long millis), accepts a maximum wait time and returns even if the thread has not finished within it.
What is the name of the thread that runs main()?
"main", by default — confirmed directly in this article's BeforeThreadsBasics example, where Thread.currentThread().getName() prints main both inside the directly-called run() and inside main() itself.
Summary
A thread only actually runs concurrently once start() is called — calling run() directly is just an ordinary method call on whatever thread invokes it, a distinction this article's Code Examples section verified directly by checking the executing thread's name. Implementing Runnable is generally preferred over extending Thread, and join() is what turns "probably done by now" into an actual guarantee before a thread's results are used.
The habit worth carrying forward from this article's hotel availability example is joining every spawned thread before reading anything it produced, and naming threads meaningfully so a stack trace or thread dump says something useful about what each one was actually doing.
What to Read Next
Learn the two ways to define a task for a thread to run.