Java Multithreading Basics: Threads, Runnable and Safe Counters with Examples

Build two Java thread examples, trace a lost counter update exactly, and learn when to use synchronized, AtomicInteger, volatile or private state.

KnowledgeGate Team

Exam prep & CS education

Updated 26 Oct 20266 min read

A Java program can create threads in a few lines, yet output may change between runs and a shared counter may silently lose updates. Both surprises come from one place: the scheduler is free to interleave threads however it likes, and value++ is three separate steps rather than one.

What multithreading means in one Java process

A thread is one path of execution inside a process. Java starts with the main thread, which can start workers that make progress concurrently and share objects on the same heap. Concurrency does not guarantee simultaneous CPU execution.

The official Java SE 21 Thread documentation describes multiple concurrent threads of execution. For the process-versus-thread model behind the Java objects, revise Threads and Process Creation MCQs.

Picture main starting workers alpha and beta. Each prints three steps; main waits and prints main:done.

Three facts remain true in every correct run:

  • alpha keeps its own three steps in program order.

  • beta keeps its own three steps in program order.

  • main:done appears last because main joins both workers.

The scheduler may still mix their lines differently on each run.

Create two threads with Runnable, start() and join()

The first program starts two workers and waits for both:

java
public class ThreadSteps {
    static class StepTask implements Runnable {
        private final String label;

        StepTask(String label) {
            this.label = label;
        }

        @Override
        public void run() {
            for (int step = 1; step <= 3; step++) {
                System.out.println(label + ":" + step);
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread alpha = new Thread(new StepTask("A"), "alpha");
        Thread beta = new Thread(new StepTask("B"), "beta");

        alpha.start();
        beta.start();
        alpha.join();
        beta.join();
        System.out.println("main:done");
    }
}

One valid trace is:

Code
A:1
B:1
A:2
A:3
B:2
B:3
main:done

The guarantees are A:1 < A:2 < A:3, B:1 < B:2 < B:3, and both workers finishing before main:done. No rule orders an A line against a B line.

Extending Thread is valid: class MyThread extends Thread { public void run() { ... } }. A Runnable separates the task from the thread that executes it, and it leaves the class free to extend something else, since Java allows only one superclass.

Three-lane timeline: alpha prints A:1 to A:3, beta prints B:1 to B:3, and main prints main:done only after both joins return.

Read the lifecycle without treating it as a fixed schedule

Java defines six Thread.State values, and each one names a different reason the thread is not running your code at this instant:

  • NEW: the Thread object exists, but start() has not been called yet.

  • RUNNABLE: eligible to run. It may be on a CPU or queued for one, and Java does not distinguish the two.

  • BLOCKED: waiting to acquire a monitor lock another thread holds. This is where threads queue at a synchronized method.

  • WAITING: waiting indefinitely for another thread to act, after wait() or a join() with no timeout.

  • TIMED_WAITING: the same wait with a deadline, after Thread.sleep(ms), wait(ms) or join(ms).

  • TERMINATED: run() has returned or thrown.

In this program alpha.getState() is NEW before start() and TERMINATED after join() returns. Anywhere between those two points the state is a sample rather than a fact: it can change before the line that prints it runs. Meanwhile main itself sits in WAITING for as long as alpha.join() has not returned.

The API contracts are precise:

  • start() schedules the new thread to execute its run() method.

  • join() waits for that thread to terminate.

  • Thread.sleep(ms) asks the currently executing thread to pause for about that many milliseconds, subject to timer and scheduler precision.

Calling alpha.run() directly is an ordinary call on the current thread, not concurrent work. sleep() is not an ordering tool either.

Worked example: three workers updating one counter

Now let three workers share an unsafe counter:

java
public class CounterDemo {
    static final int WORKERS = 3;
    static final int REPEATS = 100_000;

    static class UnsafeCounter {
        int value = 0;

        void increment() {
            value++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        UnsafeCounter counter = new UnsafeCounter();
        Thread[] workers = new Thread[WORKERS];

        for (int i = 0; i < WORKERS; i++) {
            workers[i] = new Thread(() -> {
                for (int repeat = 0; repeat < REPEATS; repeat++) {
                    counter.increment();
                }
            }, "worker-" + (i + 1));
        }

        for (Thread worker : workers) worker.start();
        for (Thread worker : workers) worker.join();

        System.out.println(counter.value);
    }
}

The requested total is:

3 × 100,000 = 300,000

The unsafe result is nondeterministic. It may be below 300000 or occasionally equal it. No wrong output is guaranteed.

Suppose value is 9. Worker 1 reads 9 and computes 10. Before its write, worker 2 also reads 9 and computes 10. Both write 10. Two increments leave 10, although serial execution reaches 11. Conceptually, value++ is read, add 1, write.

Repair the class by changing only its method:

java
synchronized void increment() {
    value++;
}

The monitor allows one worker at a time through the increment, so correctness requires each increment to finish before the next begins. After all three joins, the output must be 300000. Synchronization in Java applies the same principle to monitor identity, lock scope, wait()/notifyAll(), and deadlock prevention.

Choose the smallest correct sharing tool

Choose by the operation you must protect:

  • Use synchronized when several statements must form one critical section.

  • Use AtomicInteger.incrementAndGet() for one atomic counter operation.

  • Use volatile when visibility matters, but remember that volatile int value; value++; is still a compound, non-atomic update.

For the atomic variant, use AtomicInteger value = new AtomicInteger(0), call value.incrementAndGet(), and print value.get() after joining. Three workers completing 100,000 calls each produce exactly 300000.

First ask whether sharing is needed at all. Where the problem allows it, give each worker a private subtotal and let main add the three subtotals after the joins: no lock, no atomic class, and still 300000.

Common Java multithreading mistakes and repairs

Pair each mistake with the matching repair:

  • Calling alpha.run() keeps the work on the caller. Use alpha.start().

  • Starting the same Thread object twice throws IllegalThreadStateException. Create a new thread object for new work.

  • Omitting join() can let main continue before a result is ready. Join the workers whose completion is required.

  • Using Thread.sleep(100) does not prove another thread has finished. Use join() or the suitable coordination primitive.

  • Marking a counter volatile does not make count++ indivisible. Use synchronized or AtomicInteger here.

Do not silently swallow InterruptedException. Propagate it when appropriate, or restore the flag with Thread.currentThread().interrupt(). Keep one lock order: worker 1 taking lockA then lockB while worker 2 takes them in reverse can deadlock. Avoid deprecated stop(), suspend() and resume().

How tests and interviews assess thread basics

Four question shapes cover most of what gets asked:

  • Given a two-thread program, which printed lines are guaranteed. Answer with per-thread program order plus whatever join() orders, and nothing else.

  • What alpha.run() prints compared with alpha.start(). Both print the same three lines, but only start() prints them from a second thread.

  • Why a counter that should read 300000 reads less. Name the read, add and write window rather than saying the threads clashed.

  • Which of synchronized, volatile and AtomicInteger repairs a given snippet. Match the tool to the operation: a compound update needs synchronized or AtomicInteger, and volatile alone never suffices.

Try these exact exercises:

  1. Change the labels to X and Y and print two steps each. The guarantees are X:1 < X:2, Y:1 < Y:2, and all four worker lines before main:done.

  2. Use three safe workers with 2,000 increments each. Check 3 × 2,000 = 6,000.

  3. Start at 9 and trace two unsynchronised increments. Both workers can read 9, both compute 10, and both can write 10, instead of the serial result 11.

For practice across the rest of the language, and for the coding-round versions of these questions, work through our Coding & DSA courses.

Java multithreading basics, the short version

Put work in run(), start it with start(), and use join() when completion is required. Expect varying schedules, minimise shared mutable state, and protect compound updates.

Three safe workers times 100,000 increments produce 300000; the unsafe version has no promised result. Run both programs, make the three exercise changes, then keep going with the Java Course.