Skip to content
Theory · Threads: a first look · lesson 3 of 6

Starting a thread

So far one cook works in our kitchen: the main thread main. To hire a second one, you need two things: a task for them to do, and the cook who takes it on.

A task and a thread

In Java a task is described by the interface Runnable from the standard library. It has one method, void run(), and its body holds what needs to be done. There is one method, so a Runnable is handy to write as a lambda: () -> System.out.println("cook").

A cook is described by the class Thread. Its constructor takes the task: new Thread(task). The Thread object itself does nothing yet: it is a cook who was hired but not yet put at the stove.

Here is a program in which two threads work:

JAVA
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> System.out.println("cook");
        Thread t = new Thread(task);
        t.start();
        System.out.println("main");
        t.join();
        System.out.println("done");
    }
}

Let's go through it step by step:

  1. Runnable task = … is the task. It is only written down, and nothing runs yet.
  2. new Thread(task) creates a thread object, but the thread is not working yet.
  3. t.start() starts a new thread, and that thread runs our task's run(). start() itself returns at once and waits for nothing.
  4. main goes on without waiting for t and prints main.
  5. t.join() waits until thread t finishes.
  6. The program prints done.

The lines cook and main can come in either order. Usually main gets there first: a new thread needs time to start. But the operating system decides, not the order of lines in the code, and sometimes the line cook comes first. The line done always comes last: before it, main waited for t.

join and InterruptedException

Calling join() means "wait until this cook finishes". Without it, done could be printed before cook. The line cook would still appear: the JVM does not end the program while at least one ordinary thread is running.

Why did main get throws InterruptedException? While a thread waits in join(), it can be asked to stop, and then join() throws the checked exception InterruptedException. A checked exception must be caught or declared, and nobody is going to interrupt our program, so declaring it in the header of main is enough. Without throws, the compiler answers unreported exception InterruptedException; must be caught or declared to be thrown.

start and run

Interviewers like to ask this: how does start() differ from run()? A Thread object has both methods, but they do different things:

  • start() creates a new thread, and that thread calls run();
  • run() is an ordinary method call: the task runs in the thread that called it, and no new thread appears.
JAVA
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            System.out.println(Thread.currentThread().getName());
        });
        t.run();   // main
        t.start(); // Thread-0
        t.join();
    }
}

Let's see what happens:

  1. The task prints the name of the thread that runs it.
  2. t.run() runs the task right in main, like a call to any other method, and main is printed.
  3. t.start() starts a new thread, and the task prints its name, Thread-0. The JVM numbers threads without a name itself: Thread-0, Thread-1 and so on.

You can also give the name yourself, as the constructor's second argument: the thread new Thread(task, "cook") is called cook.

Calling run() instead of start() is like the head cook making the dish from the helper's recipe. The dish is ready, but the helper never stepped up to the stove.

Virtual threads

An ordinary Java thread is an operating system thread, and it is not cheap: memory is reserved for it up front, usually about a megabyte. A program can bear thousands of such threads, but not a million.

Java 21 added virtual threads, lightweight threads that the JVM manages itself. It spreads many virtual threads over a few ordinary ones. You can start a million virtual threads, and they are most useful where a task mostly waits: for an answer over the network or from a database. Here the kitchen picture stops working: you cannot hire a million cooks for one kitchen, but you can start a million virtual threads in one program.

JAVA
Thread v = Thread.startVirtualThread(() -> {
    System.out.println("virtual cook"); // virtual cook
});
v.join();
System.out.println(v.isVirtual()); // true

Let's go through it line by line:

  1. Thread.startVirtualThread(…) creates a virtual thread and starts it at once: there is no need to call start().
  2. v.join() waits for it, the same way as for an ordinary thread.
  3. v.isVirtual() confirms the thread is virtual, and the program prints true.

Here join() is required. The JVM does not wait for virtual threads, and without join() the program can end before the task has printed anything.

Common mistake

The same thread was started a second time:

JAVA
Thread t = new Thread(() -> System.out.println("cook"));
t.start();
t.join();
t.start(); // ✗
TEXT
Exception in thread "main" java.lang.IllegalThreadStateException

The program compiles, prints cook and stops at the second start(). A thread starts only once, and a finished thread cannot be started again. To run the task once more, create a new thread: new Thread(task).start().

Key points

  • Runnable describes the task, a Thread object is the thread, and start() starts a new thread.
  • run() is an ordinary method call: the task runs in the current thread, and there is no new thread.
  • join() waits until a thread finishes; without it, the order of output from different threads is not fixed.
  • Thread.startVirtualThread(…) starts a virtual thread (Java 21); the JVM does not wait for it, so you need join().