There are two ways to define a thread's work — subclass Thread, or pass a Runnable/Callable task — and you should almost always do the latter, via an ExecutorService. Runnable returns nothing; Callable returns a value and can throw a checked exception.
Subclassing Thread (discouraged)
You override run() and call start(). This couples your logic to the Thread class, wastes your single inheritance slot, and gives you no result.
class Worker extends Thread {
@Override public void run() { System.out.println("working"); }
}
new Worker().start();
Runnable: the task abstraction
A Runnable is a @FunctionalInterface with a single void run(). You pass it to a Thread or, better, an executor. It separates what to do from how it's scheduled.
Runnable task = () -> System.out.println("working");
new Thread(task).start();
Callable + Future: when you need a result
Callable<V> has V call() throws Exception. You submit it to an ExecutorService and get a Future<V> to retrieve the result (blocking on get()).
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Integer> f = pool.submit(() -> 40 + 2); // Callable
System.out.println(f.get()); // 42, may block
}
The try-with-resources form works because ExecutorService is AutoCloseable since Java 19 — close() calls shutdown() and awaits termination.
What seniors actually say
The honest answer is: "Conceptually two — extend Thread or implement Runnable/Callable. In practice I never new Thread() manually. I submit tasks to an ExecutorService, or since Java 21 use Executors.newVirtualThreadPerTaskExecutor() for I/O-bound work, and CompletableFuture for async composition." Mentioning that Runnable/Callable are functional interfaces (so lambdas work) and that Callable carries a return value and checked exception is the discriminator.