Matheion

MA-JAVA0 · Module 22

Threads: concurrent work on the JVM

Topics: Thread · Runnable · start vs run · join

Overview

Session 22 — Threads: concurrent work on the JVM

Until now every program has been a single story: main runs, methods return, the process ends. Real services print tickets, fetch data, and listen for input at the same time. This session introduces a thread: one independent path of execution inside the same JVM process. You will start threads with Thread and Runnable, wait with join, and see why calling run() by accident is not concurrency at all.

Time: about 90 minutes.

Concepts

1. Why more than one thread?

A process is the JVM program you launched. Inside it, the runtime already uses several threads (garbage collection, signal handling). Your code usually starts on a single thread: the one that entered public static void main.

A second thread is useful when:

  • work can proceed while you wait (network, disk, a slow report);
  • independent jobs should overlap (two clerks printing boarding passes);
  • a UI or server must stay responsive while a computation runs.

Concurrency is not a promise that two things finish faster on a one-core machine. It is a promise that their timelines can interleave. The operating system may pause one thread and run another at any instruction boundary (almost). You do not control the exact interleaving unless you add coordination—that is Session 23.


2. Runnable is the job; Thread is the worker

A Runnable is a bag of work: one method, run(), with no arguments and no return value. Session 21 already treated Runnable as a functional interface. Here it is the payload you hand to a thread.

Runnable printPass = () -> System.out.println("pass printed");

A Thread object is the worker that will execute that payload:

Thread clerk = new Thread(printPass, "clerk-amber");
clerk.start();

The optional name ("clerk-amber") shows up in logs and debuggers. Name threads in real programs; “Thread-0” is a poor breadcrumb.

You can also subclass Thread and override run(). Prefer composition (new Thread(runnable)) so the job stays a plain class or lambda and is easier to test.


3. start() versus run()

This is the first concurrency pitfall, and it is mechanical.

  • start() asks the JVM to create an operating-system thread (or a virtual thread on newer runtimes) and, later, to call run() on that thread.
  • run() is an ordinary method call. If main invokes clerk.run(), the work happens on the main thread, one statement after another. There is no overlap.

If your “concurrent” program always prints in a rigid order and never surprises you, check whether you called run() by mistake.

start() may be called once. A second start() on the same Thread object throws IllegalThreadStateException. To repeat work, construct a new Thread (or, better, use a pool—Session 25).


4. What main returning means

When main returns, the JVM does not always exit immediately. It waits until every non-daemon thread has finished. A daemon thread is a helper the JVM may abandon at shutdown (setDaemon(true) before start()). Background cleaners are sometimes daemons; ticket printers usually are not—you want the process to stay alive until passes are done.

Thread.sleep(milliseconds) pauses the current thread. It does not pause the whole process. Other threads keep running. sleep throws InterruptedException—a checked exception meaning “someone asked this thread to stop waiting.” Session 24 treats interrupts properly; for now, a main that sleeps in a demo can declare throws InterruptedException.


5. Waiting for a clerk: join

clerk.join() blocks the caller until clerk’s run() has returned (or the clerk was never started—then join returns quickly). Use join when main must assemble results after workers finish.

Thread a = new Thread(() -> System.out.println("dock A"));
Thread b = new Thread(() -> System.out.println("dock B"));
a.start();
b.start();
a.join();
b.join();
System.out.println("both docks quiet");

The two “dock” lines may appear in either order. The last line always appears after both workers have finished, because of the two joins.


6. Shared objects without shared mutation (yet)

Threads in the same JVM share the heap. If two runnables close over the same LanternBooth instance, they see the same fields. That is powerful and dangerous. This session only reads immutable data, or each thread uses its own local variables.

Safe pattern for now:

  • give each Runnable the strings and numbers it needs as copies or unchanging inputs;
  • print from that thread;
  • do not increment a shared int sold from two threads until Session 23.

7. Common confusions

“Threads are free.” Each platform thread costs memory (its stack). Dozens are fine for a learning demo; thousands of new Thread in a server is a smell—use a pool.

“I started them, so they ran in parallel.” On a busy machine they might interleave without overlapping in wall-clock time. Correctness must not depend on luck.

“Thread.currentThread() is the object I constructed.” It is the thread currently executing this line. Inside run(), that is the worker. Inside main before start returns, it is still main.

“Lambdas cannot be threads.” A lambda that implements Runnable is an excellent payload. The Thread is still required to schedule it.


8. What you should be able to do

Start two named threads from main, join both, and explain why the middle print order is unspecified while the line after join is not. Next session, those clerks will share a seat counter.

Examples

Example A — two named clerks (start and join)

public class TwoClerks {
    public static void main(String[] args) throws InterruptedException {
        Thread amber = new Thread(
            () -> System.out.println(Thread.currentThread().getName() + " printed pass"),
            "clerk-amber"
        );
        Thread nico = new Thread(
            () -> System.out.println(Thread.currentThread().getName() + " printed pass"),
            "clerk-nico"
        );
        amber.start();
        nico.start();
        amber.join();
        nico.join();
        System.out.println("booth idle");
    }
}

Run it several times. The two “printed pass” lines may swap order. “booth idle” stays last because of join. If you replace both start() calls with run(), you get a strict sequence on the main thread and the names will both be main.


Example B — run() by mistake

public class AccidentalSerial {
    public static void main(String[] args) {
        Runnable job = () -> System.out.println("job on " + Thread.currentThread().getName());
        Thread t = new Thread(job, "worker");
        t.run();
        System.out.println("back in " + Thread.currentThread().getName());
    }
}

Both lines belong to main. There was never a second worker. This is the demo to keep in your notes.


Example C — immutable input per thread

public class ManifestLine {
    public static Runnable lineFor(String passenger, String berth) {
        return () -> System.out.println(passenger + " → " + berth);
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(lineFor("Rafi", "A12"));
        Thread t2 = new Thread(lineFor("Mei", "A13"));
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

Each runnable closes over its own strings. No shared counter. Overlap is still real (prints can interleave); mutation races are not.


Example D — sleep does not freeze the process

public class Nap {
    public static void main(String[] args) throws InterruptedException {
        Thread snore = new Thread(() -> {
            try {
                Thread.sleep(400);
                System.out.println("snore done");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "napper");
        snore.start();
        System.out.println("main still moving");
        snore.join();
    }
}

Typical output:

main still moving
snore done

main prints immediately after start. The napper sleeps in parallel. join only waits before main returns. sleep never froze the whole process.

Bridge

Threads and the rest of the path

  1. Runnable is the same functional interface you used for lambdas in Session 21. Here the lambda is a payload; Thread.start is the scheduler.

  2. Shared objects you built in Sessions 6–8 become dangerous the moment two threads call mutating methods. Session 23 is the lock story for those classes.

  3. I/O from Session 11 (files, scanners) blocks a thread. A second thread can keep a demo “alive” while one clerk waits on input—still do not share unsynchronized buffers.

  4. Pools (Session 25) are how production code avoids new Thread in a loop. Learn start/join first so the pool is not magic.


Your turn (informal)

Write a four-line main that starts two threads printing your initials and joins both. Then change start to run and note the thread names in the output—one sentence.

Practice check — threads and start vs run

Multiple-choice check from the Matheion app lesson. Pick an answer for each question, then submit.

  1. 1.A `Thread` in Java is best described as…

  2. 2.You construct `Thread t = new Thread(job, "clerk");`. Which call actually overlaps work with `main`?

  3. 3.After `a.start(); b.start(); a.join(); b.join();`, the print **after** the joins…

  4. 4.Calling `start()` a second time on the **same** `Thread` instance…

  5. 5.`Thread.sleep(400)` inside a worker…