- What is a Thread in Java?
- How to Create a Thread: Runnable vs. Thread
- Understanding the Java Thread Lifecycle and States
- The Core Problem of Concurrency: Race Conditions
- How to Prevent Race Conditions with Synchronization
- What is a Deadlock and How to Avoid It
- The Modern Approach: Why You Should Use an ExecutorService
- Key Takeaways: Java Threads Best Practices
Concurrency is hard. Java threads are the fundamental tool for parallelism, but they’re a tool that can easily cut you if used incorrectly. Many guides just list the features; this one walks you through what you actually need to know to write safe, modern, and efficient concurrent code.
In this practical guide, you will learn:
- The right way to create threads (and why Runnable is better than Thread).
- How to avoid critical concurrency issues like race conditions and deadlocks.
- The key difference between the synchronized and volatile keywords.
- Why the modern ExecutorService is almost always the right choice over manual thread management.
Learn Java the right way! Our course teaches you essential programming skills, from coding basics to complex projects, setting you up for success in the tech industry.
What is a Thread in Java?
A thread is the smallest unit of execution in a program. Your Java application starts with one thread, the main thread. You can create more threads to do work in parallel. For example, one thread handles the UI, while another downloads a file from the network. This prevents your application from freezing while waiting for the download.
A 4-core CPU can physically run 4 threads at the same time. If you have more threads than cores, the OS rapidly switches between them, creating the illusion of parallel execution.
How to Create a Thread: Runnable vs. Thread
You have two primary ways to create a thread.
Extend Thread:
class MyThread extends Thread {
public void run() {
System.out.println("Executing thread task.");
}
}
// To run it:
MyThread t1 = new MyThread();
t1.start(); // Never call run() directly. start() creates a new thread.
Implement Runnable:
class MyRunnable implements Runnable {
public void run() {
System.out.println("Executing runnable task.");
}
}
// To run it:
Thread t2 = new Thread(new MyRunnable());
t2.start();
Which one should you use? Implement Runnable. Always.
Here’s why:
- Java doesn’t support multiple inheritance. If your class extends Thread, it can’t extend anything else. By implementing Runnable, your class is free to extend another class.
- It separates the task from the runner. Runnable represents a task to be done. Thread is the worker that does the task. This is a better design. You can reuse the same Runnable task with different threads or, more importantly, with an ExecutorService.
- Flexibility. High-level concurrency APIs, like the ExecutorService, are designed to work with Runnable tasks, not Thread objects.
The only time extending Thread is maybe acceptable is for a tiny, throwaway application. For anything real, use Runnable.
Understanding the Java Thread Lifecycle and States
A thread isn’t just running or not running. It moves through several states. Understanding this is crucial for debugging.
- NEW: The thread object has been created, but start() has not been called. It’s not alive yet.
- RUNNABLE: The thread is ready to run or is currently running. The thread scheduler in the JVM determines which RUNNABLE thread gets CPU time.
- BLOCKED: The thread is waiting to acquire a monitor lock. This happens when it tries to enter a synchronized block or method that another thread already holds the lock for.
- WAITING: The thread is waiting indefinitely for another thread to perform a specific action. This happens when it calls Object.wait(), Thread.join(), or LockSupport.park().
- TIMED_WAITING: Same as WAITING, but it will only wait for a specified amount of time. Methods like Thread.sleep(long), Object.wait(long), and Thread.join(long) put a thread in this state.
- TERMINATED: The thread has completed its run() method or has otherwise died. It’s no longer alive.
The Core Problem of Concurrency: Race Conditions
Multithreading is difficult for one reason: managing shared, mutable state.
- Shared: Multiple threads can access the variable.
- Mutable: The variable’s value can change.
When multiple threads read and write to the same variable without control, you get a race condition. The final outcome depends on the unpredictable timing of thread execution.
The classic example is a simple counter:
class Counter {
private int count = 0;
public void increment() {
count++; // This is NOT atomic
}
}
The count++
operation seems like one action, but it’s three:
- Read the current value of
count
. - Add 1 to that value.
- Write the new value back to
count
.
Imagine Thread A reads count (value is 0). Before it can write back, the OS pauses it and runs Thread B. Thread B also reads count (still 0), increments it to 1, and writes 1 back. Now, Thread A resumes, and it writes its calculated value (which was also 1) back. You just lost an increment. Your program is now broken in a way that’s incredibly hard to reproduce.
How to Prevent Race Conditions with Synchronization
To prevent race conditions, you must ensure that only one thread can access the critical section of code at a time. This is called synchronization.
Java’s built-in lock mechanism is the synchronized keyword. It can be applied to methods or blocks. When a thread enters a synchronized block, it acquires an intrinsic lock on the object. No other thread can enter a synchronized block on the same object until the lock is released.
// Synchronized method
public synchronized void increment() {
count++;
}
// Synchronized block (generally preferred)
private final Object lock = new Object();
public void increment() {
synchronized(lock) {
count++;
}
}
Using a dedicated lock object (private final Object lock
) is better than synchronizing on this
, as it reduces the scope of the lock and prevents other unrelated code from accidentally locking on your object.
The volatile keyword is different and more subtle. It does NOT provide mutual exclusion (locking). It provides visibility.
When a variable is declared volatile
, it guarantees two things:
- Any write to that variable is flushed directly to main memory.
- Any read of that variable is read directly from main memory, not a CPU cache.
This ensures that when one thread changes a volatile variable, other threads see that change immediately. However, it does not make read-modify-write operations (like count++
) atomic. Use volatile when one thread writes to a variable and other threads only read it. If multiple threads are writing, you need synchronized or atomic classes.
What is a Deadlock and How to Avoid It
When you use locks, you create the risk of a deadlock. A deadlock occurs when two (or more) threads are blocked forever, each waiting for a lock held by the other.
Example:
- Thread A locks Resource 1.
- Thread B locks Resource 2.
- Thread A tries to lock Resource 2 (but it’s held by B). It waits.
- Thread B tries to lock Resource 1 (but it’s held by A). It waits.
Both threads are now stuck waiting for each other, and the program freezes. The simplest way to avoid this is to ensure all threads acquire locks in the same, consistent order.
The Modern Approach: Why You Should Use an ExecutorService
Manually creating and managing threads (new Thread().start()
) is old-school and inefficient. Creating a thread is expensive. If your application has many short-lived tasks, you’ll waste time creating and destroying threads.
The solution is the ExecutorService framework, which manages a pool of worker threads. You submit tasks (Runnable or Callable), and the ExecutorService assigns them to an available thread in the pool. This reuses threads, improving performance and resource management.
// Creates a thread pool with a fixed number of threads
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
Runnable task = () -> {
System.out.println("Executing task in thread: " + Thread.currentThread().getName());
};
executor.submit(task);
}
executor.shutdown(); // Always shut down the executor
For most applications, you should be using an ExecutorService, not creating threads manually. It’s a higher-level abstraction that is safer and more performant.
Key Takeaways: Java Threads Best Practices
- A thread is a single path of execution.
- Always favor implementing Runnable over extending Thread.
- Race conditions happen when multiple threads access shared, mutable data without control.
- Use synchronized to enforce that only one thread can access a block of code at a time.
- Use volatile only to guarantee the visibility of a variable between threads, not for atomic operations.
- Deadlocks occur when threads are stuck waiting for each other’s locks.
- Stop using
new Thread().start()
. Use an ExecutorService for almost everything. It manages a pool of threads for you, which is more efficient.
Also Read: