Threads
Start, name, join and interrupt threads, and understand why back-end code rarely creates them by hand.
Thread t = Thread.ofPlatform().name("worker").start(() -> doWork());
t.join();
t.interrupt();A thread is an independent path of execution inside one JVM process. Every thread has its own call stack but shares the heap, so two threads can read and write the same objects at the same time. That sharing is what makes threads useful and what makes them dangerous.
Starting and waiting
You give a Thread a Runnable and call start(). The JVM creates a new thread and calls run() on it. Calling run() yourself is the classic beginner mistake: it is a normal method call, so the work happens on the current thread and nothing runs in parallel.
join() blocks until the thread has finished. It also guarantees that everything the thread wrote is visible to the caller afterwards, which is why reading results after join() is safe.
Thread worker = Thread.ofPlatform().name("report-builder").start(() -> buildReport());
worker.join(); // wait, then read what it producedThe builders Thread.ofPlatform() and Thread.ofVirtual() (Java 21) are the modern way to configure names, daemon status and uncaught exception handlers. Name your threads: a thread dump full of Thread-37 is much harder to read during an incident than one showing import-3.
Interruption is a request, not a kill
Java has no safe way to stop a thread from outside. interrupt() sets a flag; blocking calls such as sleep, wait and BlockingQueue.take notice it and throw InterruptedException. The thread then decides how to clean up. Never swallow that exception with an empty catch: either rethrow it or restore the flag with Thread.currentThread().interrupt() so code further up knows a shutdown was requested.
Exceptions and daemon threads
An exception thrown inside a thread does not reach the code that started it. It ends that thread and goes to the uncaught exception handler, which by default prints a stack trace to stderr. In a service that usually means the failure is lost unless you install a handler that logs it. Daemon threads do not keep the JVM alive; when only daemon threads remain, the process exits without waiting for them.
The back-end view
In web services you seldom call new Thread directly. Servers, executors and frameworks own threads for you, and creating one platform thread per request does not scale because each reserves a large native stack. Learn threads to understand what those tools do: every request handler, scheduled job and connection pool is built on them. For new code, reach for an ExecutorService or virtual threads instead.
Example
When to use it
- Run a one-off background migration on a named thread at startup and join it before the service reports ready.
- Interrupt a queue-polling worker during shutdown so it closes its connections cleanly.
- Install an uncaught exception handler so a crashing background job is logged instead of dying silently.
More examples
start() versus run()
Only start() creates a new thread. The first line shows run() executing the task on main, which is easy to miss in a log.
void main() throws InterruptedException {
Runnable task = () -> IO.println("running on " + Thread.currentThread().getName());
Thread t = new Thread(task, "worker-1");
t.run(); // an ordinary method call on the main thread
t.start(); // a real new thread
t.join();
IO.println("alive after join: " + t.isAlive());
}
// Output:
// running on main
// running on worker-1
// alive after join: falseInterrupting a blocked worker
interrupt() makes the blocking sleep throw, and the worker uses that to clean up and finish. If the flag is set before sleep is reached, sleep throws straight away, so the result is the same.
void main() throws InterruptedException {
Thread poller = Thread.ofPlatform().name("queue-poller").start(() -> {
try {
while (true) {
Thread.sleep(1_000); // stands in for a blocking poll
}
} catch (InterruptedException e) {
IO.println("poller interrupted, closing connections");
Thread.currentThread().interrupt(); // keep the flag set for callers
}
});
poller.interrupt();
poller.join();
IO.println("poller state: " + poller.getState());
}
// Output:
// poller interrupted, closing connections
// poller state: TERMINATEDThread builders and uncaught exceptions
The builder numbers each thread it creates. The exception never reaches main; only the handler sees it, so a real service would log it there.
void main() throws InterruptedException {
Thread.Builder builder = Thread.ofPlatform()
.name("import-", 1)
.uncaughtExceptionHandler((t, e) -> IO.println(t.getName() + " failed: " + e.getMessage()));
List<Thread> threads = new ArrayList<>();
for (String file : List.of("users.csv", "orders.csv", "broken.csv")) {
threads.add(builder.start(() -> {
if (file.startsWith("broken")) throw new IllegalStateException("bad header in " + file);
}));
}
for (Thread t : threads) t.join();
IO.println(threads.stream().map(Thread::getName).toList());
}
// Output:
// import-3 failed: bad header in broken.csv
// [import-1, import-2, import-3]
Discussion