Run work in parallel with threads, coordinate it with ExecutorService, and scale to thousands of tasks with lightweight virtual threads.
Why: a thread is a separate line of execution running alongside your main program, so two things happen at once. Pass a Runnable (a lambda with no arguments) and call start() to run it on a new thread.
Thread worker = new Thread(() -> {
System.out.println("running on a separate thread");
});
worker.start(); // runs alongside main
System.out.println("main keeps going");Why: start() does not block — the main thread races ahead. Call join() to pause until the other thread finishes, so you can safely use its results afterwards.
Thread worker = new Thread(() -> {
for (int i = 0; i < 3; i++) System.out.println("tick " + i);
});
worker.start();
worker.join(); // wait here until worker is done
System.out.println("worker finished");Why: creating threads by hand does not scale. An ExecutorService manages a pool of reusable threads — you submit tasks and it runs them. Always shut it down when finished. Try-with-resources closes it for you (Java 19+).
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
pool.submit(() -> System.out.println("task A"));
pool.submit(() -> System.out.println("task B"));
} // pool shuts down and waits for tasks hereWhy: a task that computes something returns a Callable, and submit() hands back a Future — a placeholder for the result that is not ready yet. Call future.get() to wait for and retrieve it.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
try (ExecutorService pool = Executors.newSingleThreadExecutor()) {
Future<Integer> future = pool.submit(() -> 21 * 2);
System.out.println(future.get()); // 42 — blocks until ready
}Why: virtual threads (Java 21+) are extremely lightweight — you can run millions of them. They shine for tasks that mostly wait (network or file calls), letting you write simple blocking code that still scales. Create a pool with newVirtualThreadPerTaskExecutor.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
int id = i;
pool.submit(() -> System.out.println("task " + id));
}
} // 10,000 cheap virtual threads, no fixed-size pool needed