A Java thread is an independent path of execution inside a program. Every Java program starts with one thread, the one running main, and you can start more so that several tasks make progress at the same time. A download can continue while the UI stays responsive, or a server can handle many clients at once. Multithreading also brings new problems: shared data can get corrupted, and threads have to be stopped correctly.
This guide covers processes vs threads, the two ways to create a thread (and why Runnable is usually the better one), thread states, stopping threads safely, Timer vs scheduled executors, and ExecutorService, which is how most production Java code runs concurrent work. Examples target Java 17+, with notes where Java 21 changes things.
Sequential vs concurrent execution
In a sequential (single-task) program, each step waits for the previous one to finish. If step two is a 3-second network call, everything after it waits 3 seconds.
In a multithreaded program, independent tasks run on separate threads. On a multi-core CPU they can run truly in parallel. On a single core, the operating system switches between them quickly enough that they appear to run together.
Process vs thread
| Process | Thread | |
|---|---|---|
| Memory | Own address space | Shares the heap with other threads in the same process |
| Cost to create | High | Much lower |
| Communication | Pipes, sockets, files | Shared objects in memory |
| If it crashes | Other processes are unaffected | An uncaught exception ends only that thread, but corrupted shared state affects everyone |
A thread is sometimes called a lightweight process or an execution context. Because threads share memory, they communicate cheaply. That same sharing is the source of most concurrency bugs.
Creating a Java thread
Option 1: extend Thread
public class MyThread extends Thread {
private final String label;
public MyThread(String label) {
this.label = label;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(label + ": " + i);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
public static void main(String[] args) {
new MyThread("A").start();
new MyThread("B").start();
}
}The output interleaves (A: 0, B: 0, B: 1, A: 1...), and the order changes between runs because the thread scheduler decides which thread runs when. Never rely on a particular order unless you coordinate the threads explicitly.
Option 2: implement Runnable (preferred)
Runnable separates the task from the thread that runs it:
public class Printer implements Runnable {
private final String label;
public Printer(String label) {
this.label = label;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(label + ": " + i + " on " + Thread.currentThread().getName());
}
}
}
Thread a = new Thread(new Printer("A"), "worker-a");
a.start();Prefer Runnable because:
- Your class can still extend another class. Java allows only one superclass.
- The same task can run on a plain
Thread, a thread pool or a scheduler without changes. - It keeps the task focused on its job, not on thread management.
Anonymous classes and lambdas
For a short task you don't need a named class. An anonymous inner class declares and instantiates the class in one expression:
Thread c = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("C from an anonymous class");
}
});Because Runnable has a single abstract method, a lambda does the same thing more concisely, and it's what you'll see in modern code:
Thread b = new Thread(() -> System.out.println("B says hi"), "worker-b");
b.start();start() vs run()
Always call start(). It creates a new thread, which then calls run(). Calling run() yourself just executes the method on the current thread, like any other method call, and no new thread is created. Calling start() twice on the same Thread throws IllegalThreadStateException.
Java thread states
Textbooks often describe five states: New, Runnable, Running, Blocked, Dead. Java's actual Thread.State enum has six values, and there is no separate "running" state:
Thread.State | Meaning | Textbook name |
|---|---|---|
NEW | Created, start() not called yet | New |
RUNNABLE | Running, or ready and waiting for a CPU | Runnable + Running |
BLOCKED | Waiting to enter a synchronized block held by another thread | Blocked |
WAITING | Waiting indefinitely, e.g. join() or wait() | Blocked |
TIMED_WAITING | Waiting with a timeout, e.g. sleep(100) | Blocked |
TERMINATED | run() has finished | Dead |
You can check a state with thread.getState(), which is useful for debugging. join() makes the current thread wait until another thread terminates:
Thread a = new Thread(new Printer("A"));
System.out.println(a.getState()); // NEW
a.start();
a.join(); // wait for a to finish
System.out.println(a.getState()); // TERMINATEDShared data and race conditions
When two threads update the same variable, updates can be lost:
static int unsafeCount = 0;
static final AtomicInteger safeCount = new AtomicInteger();
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
unsafeCount++; // read, add, write: not atomic
safeCount.incrementAndGet(); // atomic
}
};
// run task on two threads, then join both
// unsafeCount: often less than 200000
// safeCount: always 200000count++ is three steps: read, add, write. Two threads can read the same value and both write back the same result, so one increment disappears. Fix it with AtomicInteger, a synchronized block, or a lock, or avoid sharing mutable state in the first place. The same class of bug in a business context is covered in preventing race conditions in payment systems.
How to stop a Java thread safely
Thread.stop() has been deprecated since Java 1.2, and since Java 20 it throws UnsupportedOperationException. It was dangerous because it killed a thread at an arbitrary point and released all its locks, which could leave shared objects half-updated. The safe approach is cooperative: you ask the thread to stop, and the thread checks the request and exits cleanly. There are two standard ways.
A volatile flag
public class Poller implements Runnable {
private volatile boolean running = true;
public void stop() {
running = false;
}
@Override
public void run() {
while (running) {
pollOnce();
}
// clean up here, then return
}
private void pollOnce() { /* check for work */ }
}volatile matters. Without it, the worker thread might never see the change, because the JIT can hoist the read out of the loop. A flag has a limitation, though: it doesn't wake a thread that's sleeping or waiting.
Interruption
thread.interrupt() sets the thread's interrupt flag. If the thread is blocked in sleep(), wait() or join(), that call throws InterruptedException right away:
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
System.out.println("working...");
Thread.sleep(500);
} catch (InterruptedException e) {
// sleep() cleared the interrupt flag when it threw; set it again
Thread.currentThread().interrupt();
}
}
System.out.println("worker cleaned up and exited");
});
worker.start();
Thread.sleep(1_200);
worker.interrupt(); // wakes the thread if it is sleeping
worker.join(); // wait for it to finishTwo rules:
- Never swallow
InterruptedExceptionwith an emptycatch. Either exit or restore the flag withThread.currentThread().interrupt()so that code further up can see it. - Classic socket reads ignore interrupts on platform threads. A thread stuck in
socket.getInputStream().read()won't wake up. Close the socket from another thread instead, which makes the read throw aSocketException. Keep this in mind for servers like the Java socket chat app.
Timer and TimerTask vs ScheduledExecutorService
Timer runs TimerTasks once or at a fixed interval on a single background thread:
Timer timer = new Timer("clock", true); // daemon: will not keep the JVM alive
timer.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
System.out.println("tick " + System.currentTimeMillis());
}
}, 0, 1_000);
// later: timer.cancel();It works, but it has weaknesses. All tasks share one thread, so a slow task delays the others. An uncaught exception in any task kills the thread and cancels every scheduled task. For new code, use ScheduledExecutorService:
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(
() -> System.out.println("tick " + System.currentTimeMillis()),
0, 1, TimeUnit.SECONDS);
// later: scheduler.shutdown();ExecutorService: the standard way to run tasks
Creating a new Thread for every task is expensive and has no upper limit. An ExecutorService manages a thread pool: you submit tasks, and it reuses a fixed set of threads to run them. Callable is like Runnable but returns a value, which you get back through a Future. Here fetchLength stands in for any blocking call:
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
List<Callable<Integer>> tasks = new ArrayList<>();
for (String url : List.of("/a", "/bb", "/ccc")) {
tasks.add(() -> fetchLength(url));
}
for (Future<Integer> result : pool.invokeAll(tasks)) {
System.out.println(result.get()); // blocks until that task is done
}
} finally {
pool.shutdown(); // stop accepting new tasks
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow(); // interrupt tasks still running
}
}Always shut an executor down. Its threads aren't daemon threads, so a forgotten pool keeps the JVM running. shutdownNow() works through interruption, which is another reason your tasks should respond to it.
On Java 21+, virtual threads make one thread per task cheap even for tens of thousands of blocking tasks. ExecutorService has also been AutoCloseable since Java 19, so try-with-resources waits for the tasks to finish:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int id = i;
executor.submit(() -> {
Thread.sleep(1_000);
return id;
});
}
} // close() waits for all tasks to finish| Need | Use |
|---|---|
| Learning, one-off background task | new Thread(runnable).start() |
| Many short tasks | Executors.newFixedThreadPool(n) |
| CPU-heavy work | Fixed pool sized to Runtime.getRuntime().availableProcessors() |
| Many blocking I/O tasks (Java 21+) | Executors.newVirtualThreadPerTaskExecutor() |
| Periodic or delayed tasks | ScheduledExecutorService |
If you also work in Go, Go concurrency in production shows how goroutines and channels handle the same problems.
FAQ
What is the difference between Thread and Runnable in Java?
Thread is the object that runs code on a separate path of execution. Runnable is the task to run. Implementing Runnable keeps your class free to extend something else and lets executors run it.
What happens if I call run() instead of start()?
The code runs on the current thread, like a normal method call. No new thread is created, so nothing runs concurrently.
How do I stop a thread in Java?
Ask it to stop cooperatively, with a volatile boolean flag or with interrupt(), and have the thread check for that request and exit. Don't use Thread.stop(), which no longer works on current Java versions.
How many threads should a Java application use?
For CPU-bound work, about as many as there are CPU cores. For I/O-bound work, more, because threads spend most of their time waiting. On Java 21+, virtual threads remove most of the need to tune I/O pool sizes.
Java thread checklist
- Implement
RunnableorCallableand run it with anExecutorService. - Call
start(), neverrun(), to begin a thread. - Protect shared mutable state with atomics,
synchronizedor locks, or don't share it at all. - Stop threads cooperatively: a
volatileflag orinterrupt(), neverThread.stop(). - Don't swallow
InterruptedException. Exit or restore the flag. - Use
ScheduledExecutorServiceinstead ofTimerfor new code. - Always shut down executors, or use try-with-resources on Java 19+.
If your team is running into concurrency problems in a Java or Go backend, Vectorkub can help review and fix them.
