Threads and Concurrency

advanced30 min

Doing two things at once

Everything you have written so far runs one statement after another. A thread is an independent line of execution — with several, parts of your program run at the same time.

The typical reason is that one task should not block another. Reading a slow sensor, processing a camera frame, or waiting on a network response can all proceed while the rest of the program keeps going.

Thread worker = new Thread(() -> {
    for (int i = 0; i < 3; i++) {
        System.out.println("worker: " + i);
    }
});

worker.start();                       // begins running alongside main

for (int i = 0; i < 3; i++) {
    System.out.println("main: " + i);
}

The output interleaves, and the exact order changes between runs. That unpredictability is the defining feature of concurrent code, and the source of most of its difficulty.

start(), not run()

worker.start();      // runs on a NEW thread
worker.run();        // runs on the CURRENT thread — just a normal method call

Calling run() compiles and executes the code, but sequentially, with no second thread. Nothing appears wrong; you simply do not get concurrency. This is the single most common beginner mistake here.

Creating threads

Three forms, all equivalent in effect:

// 1. lambda — clearest for short tasks
Thread t = new Thread(() -> System.out.println("hello"));

// 2. a Runnable stored first
Runnable task = () -> System.out.println("hello");
Thread t = new Thread(task);

// 3. extending Thread — rarely the right choice
class Worker extends Thread {
    @Override public void run() { System.out.println("hello"); }
}

Prefer the first two. Extending Thread spends your one inheritance slot and couples the task to the mechanism running it — the task is a Runnable, and how it is executed should be a separate decision.

Waiting for a thread

start() returns immediately. To wait for a thread to finish, join() it:

Thread worker = new Thread(() -> {
    heavyCalculation();
});

worker.start();
doSomethingElse();       // happens concurrently
worker.join();           // blocks until worker finishes
System.out.println("both done");

join() throws InterruptedException, so it must be handled:

try {
    worker.join();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();   // restore the flag
}

Restoring the interrupt flag matters. Catching InterruptedException clears it, and swallowing it silently means code further up can no longer tell it was asked to stop.

Sleeping

try {
    Thread.sleep(1000);      // milliseconds
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

sleep pauses the current thread. It is useful for pacing a loop, but it is not a synchronisation tool — sleeping “long enough” for another thread to finish is a guess that will eventually be wrong.

Why this is hard

Concurrency introduces a class of bug that sequential code cannot have.

What makes concurrent code difficult

  • Non-determinism. The interleaving differs each run, so a bug may appear once in a thousand executions and never under a debugger.
  • Shared state. Two threads touching the same variable can corrupt it, even for operations that look atomic. See Race Conditions.
  • Visibility. A change made by one thread is not guaranteed to be seen by another without proper synchronisation.
  • Deadlock. Two threads can each wait for something the other holds, and both stop forever.
  • Testing is unreliable. Passing a test proves the bug did not happen this time.

Because of this, the guiding principle is to share as little mutable state as possible. A thread working only on its own data is straightforward; the trouble starts when two threads write the same thing.

Thread states

StateMeaning
NEWCreated but start() not yet called
RUNNABLERunning, or ready and waiting for a processor
BLOCKEDWaiting to acquire a lock
WAITINGWaiting indefinitely for another thread
TIMED_WAITINGWaiting with a timeout, such as during sleep
TERMINATEDFinished

Thread.getState() reports these, which is occasionally useful when diagnosing a hang.

Daemon threads

A daemon thread does not keep the program alive:

Thread background = new Thread(this::pollSensor);
background.setDaemon(true);           // must be set BEFORE start()
background.start();

Java exits when all non-daemon threads finish. Use daemon threads for background work that should not prevent shutdown — and remember they are killed abruptly, so never use one for work that must complete.

A note on robot code

FTC and FRC frameworks call your code on a fixed loop, usually every 20 milliseconds. That loop is already the concurrency model, and adding threads is usually the wrong instinct.

Before adding a thread to robot code

  • Blocking the main loop is the real problem. If a task takes too long, the fix is usually to break it into steps across loop iterations rather than to spawn a thread.
  • Hardware access is often not thread-safe. Reading a motor or sensor from two threads can produce garbage or throw.
  • The framework may already provide a mechanism — a notifier, a separate vision thread, an async command. Use that instead of a raw Thread.

Genuine cases exist, mainly long-running independent work like image processing. Even then, share as little as possible with the main loop and share it safely.

Common mistakes

  • Calling run() instead of start().
  • Assuming an order. Without synchronisation there is none.
  • Swallowing InterruptedException without restoring the flag.
  • Using sleep to coordinate threads. Use join or a proper synchroniser.
  • setDaemon after start(), which throws IllegalThreadStateException.
  • Starting the same Thread twice. Also throws — create a new one.
  • Creating threads in a loop. Each carries real cost; use a pool. See Executors and Thread Pools.

Practice

  1. Start a thread printing 1 to 5 while the main thread prints A to E. Run it several times and note the order changes.
  2. Modify it so main waits for the worker with join(), and confirm the output is now ordered.
  3. Write a thread that sleeps one second then prints, and confirm main continues meanwhile.
  4. Call run() instead of start() and observe that the interleaving disappears.
  5. Start a daemon thread looping forever, and confirm the program still exits when main finishes.
Hints
  1. Run it at least five times — a single run proves nothing about ordering.
  2. join() after the main loop.
  3. Print a timestamp before and after to see the overlap.
  4. This is the mistake worth making deliberately once.
  5. setDaemon(true) before start(). Try it without and see that the program hangs.

Next

Related