DEV Community

Mayur Bodhare
Mayur Bodhare

Posted on

Java Threads Explained: Scheduler, sleep and synchronized

My first multithreaded program printed its lines in a different order every time I ran it. My second one had a counter that should have ended at 200,000 and ended at 143,267. I ran it again and got 151,880. Same code, different answers.

I was sure my computer was broken. It wasn't. I had just met threads, and with them, two ideas that change how you think about programs: the order isn't guaranteed, and shared data can get corrupted.

In this post, we'll learn what threads are, how to create them, what the scheduler does, how priority and sleep work, the six states of a thread, and how synchronized fixes the counter bug. I've drawn the important pictures in text, and every claim comes with code you can run.

Unless I show a full class, the snippets go inside a main method that declares throws InterruptedException.

What is a thread?

A normal program does one thing at a time. If it downloads a file, the rest of the program waits. A thread is a separate path of execution inside your program, so several things can happen at the same time (or at least look like it).

Two words are worth separating:

  • A process is a running program, like your Java app. It has its own memory.
  • A thread lives inside a process. A process can have many threads, and they share the process's memory.

Every Java program starts with one thread called main:

System.out.println(Thread.currentThread().getName());
Enter fullscreen mode Exit fullscreen mode

Output:

main
Enter fullscreen mode Exit fullscreen mode

(The JVM also starts a few background threads of its own, like the garbage collector, but main is the one that runs your code.)

Behind the scenes: what threads share

Threads share the heap, where all objects live. But each thread gets its own stack, which holds its method calls and local variables.

   PROCESS (your running Java program)
  +----------------------------------------------------+
  |  HEAP: shared by all threads (objects live here)   |
  |                                                    |
  |   Thread "main"     Thread-0         Thread-1      |
  |   +-----------+     +-----------+    +-----------+ |
  |   | own stack |     | own stack |    | own stack | |
  |   | own PC    |     | own PC    |    | own PC    | |
  |   +-----------+     +-----------+    +-----------+ |
  +----------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

(PC is the "program counter", which remembers which instruction the thread is on.)

This design is the source of both the benefit and the danger. Sharing the heap makes it easy for threads to cooperate. But when two threads change the same object at once, things can go wrong, as we'll see.

Creating threads

There are three common ways.

Way 1: extend Thread

class Worker extends Thread {
    @Override
    public void run() {
        System.out.println("Hello from " + Thread.currentThread().getName());
    }
}
Enter fullscreen mode Exit fullscreen mode
new Worker().start();
Enter fullscreen mode Exit fullscreen mode

Output:

Hello from Thread-0
Enter fullscreen mode Exit fullscreen mode

You put the work inside run(), and you launch the thread with start(). Unnamed threads get names like Thread-0, Thread-1 and so on.

Way 2: implement Runnable

Runnable is a functional interface with one method, run(). You hand it to a Thread:

Runnable task = () -> System.out.println("Hello from " + Thread.currentThread().getName());
Thread t = new Thread(task, "worker-1");
t.start();
Enter fullscreen mode Exit fullscreen mode

Output:

Hello from worker-1
Enter fullscreen mode Exit fullscreen mode

Here the lambda is the Runnable, and "worker-1" is a name we picked.

Which way is better?

Prefer Runnable (or a lambda). It separates what to do (the task) from how to run it (the thread). It also leaves your class free to extend something else, since Java allows only one parent class.

start() vs run(): the classic mix-up

Thread t = new Thread(
    () -> System.out.println("Running in " + Thread.currentThread().getName()),
    "worker");

t.run();
t.start();
Enter fullscreen mode Exit fullscreen mode

Output:

Running in main
Running in worker
Enter fullscreen mode Exit fullscreen mode

Calling run() directly is just a normal method call, so it ran on the main thread. Only start() creates a new thread. If you want concurrency, always call start().

Also, a thread can be started only once. Calling start() a second time throws an IllegalThreadStateException.

Behind the scenes: what start() does

start() asks the JVM to create a real operating-system thread. When that thread is ready, it calls your run() method. When run() ends, the thread ends. (Java 21 also added lightweight virtual threads, which are managed by the JVM instead of the OS. For a beginner's first steps, the ordinary threads above are what matter.)

Waiting for a thread with join()

Often main needs to wait until a worker finishes. That's what join() is for:

Thread t = new Thread(() -> System.out.println("Worker done"));
t.start();
t.join();
System.out.println("Main continues");
Enter fullscreen mode Exit fullscreen mode

Output:

Worker done
Main continues
Enter fullscreen mode Exit fullscreen mode

Without join(), main might print first. join() makes the calling thread wait until t is finished. It throws InterruptedException, which is why our examples declare throws InterruptedException.

The thread scheduler

Here's the question that confused me most: if I start two threads, who decides which one runs first?

The answer is the thread scheduler. It's the part of the JVM and the operating system that decides which thread gets the CPU, and for how long. Most systems use time slicing: each runnable thread gets a short turn, then the scheduler may switch to another one.

   Time ------------------------------------------------>

   One CPU core:    | A | B | A | C | B | A | C |
                      each box = a short time slice

   Two CPU cores:   core 1:  | A | A | C | A |
                    core 2:  | B | B | B | C |
                      (A and B can truly run at the same time)
Enter fullscreen mode Exit fullscreen mode

The key point: you don't control the scheduler, and the order isn't guaranteed. Let's prove it:

Runnable task = () -> {
    for (int i = 1; i <= 3; i++) {
        System.out.println(Thread.currentThread().getName() + " step " + i);
    }
};

new Thread(task, "A").start();
new Thread(task, "B").start();
Enter fullscreen mode Exit fullscreen mode

One possible output:

A step 1
B step 1
A step 2
A step 3
B step 2
B step 3
Enter fullscreen mode Exit fullscreen mode

Run it several times. Each thread's own steps stay in order (A1, A2, A3), but how A and B interleave changes from run to run. Sometimes A finishes entirely before B begins, and sometimes they alternate.

Two related words are often mixed up:

  • Concurrency means several tasks making progress during the same period, possibly by switching on one core.
  • Parallelism means several tasks running at the exact same moment on different cores.

States of a thread

During its life, a thread moves through several states. Java defines six of them in the Thread.State enum:

  • NEW: the Thread object exists, but start() hasn't been called yet.
  • RUNNABLE: the thread is running, or ready to run and waiting for the scheduler to give it a turn.
  • BLOCKED: the thread is waiting to get a lock held by another thread (we'll see this with synchronized).
  • WAITING: the thread waits indefinitely for another thread to do something, such as join() or wait() with no timeout.
  • TIMED_WAITING: the thread waits for a fixed time, such as sleep(1000) or join(1000).
  • TERMINATED: the thread has finished its run() method.

Here's how a thread moves between them:

                      start()
      NEW  ---------------------------->  RUNNABLE  <----------------------+
                                           |  |  |                         |
                                           |  |  |                         |
        waiting for a lock  <--------------+  |  +--------->  TIMED_WAITING|
              BLOCKED  ----- lock acquired ---+   sleep(ms), |  join(ms)   |
                                           |      wait(ms)    +--- time up -+
                                           |
                       wait(), join()      |
                  +--- WAITING <-----------+
                  |       |
                  |       +--- notified / joined thread ends ---> back to RUNNABLE
                  |
                  +---------   run() ends   ----------------->  TERMINATED
Enter fullscreen mode Exit fullscreen mode

You can watch a thread change state with getState():

Thread t = new Thread(() -> {
    try {
        Thread.sleep(500);
    } catch (InterruptedException e) {
        // ignore for this demo
    }
});

System.out.println(t.getState());
t.start();
System.out.println(t.getState());
Thread.sleep(100);
System.out.println(t.getState());
t.join();
System.out.println(t.getState());
Enter fullscreen mode Exit fullscreen mode

Output (in most runs, since it depends on timing):

NEW
RUNNABLE
TIMED_WAITING
TERMINATED
Enter fullscreen mode Exit fullscreen mode

Notice there's no RUNNING state in Java. Many textbooks describe five states (new, runnable, running, blocked, dead), where "running" means "currently on the CPU." In Java, both "on the CPU" and "ready for the CPU" are just RUNNABLE, because the scheduler, not Java, decides that.

sleep()

Thread.sleep(milliseconds) makes the current thread pause for at least that long.

for (int i = 3; i >= 1; i--) {
    System.out.println(i);
    Thread.sleep(1000);
}
System.out.println("Go!");
Enter fullscreen mode Exit fullscreen mode

Output (with about one second between lines):

3
2
1
Go!
Enter fullscreen mode Exit fullscreen mode

Some important facts:

  • It pauses the current thread. sleep is a static method, so t.sleep(1000) also pauses the thread that calls it, not t. That's a common surprise.
  • While sleeping, the thread is in the TIMED_WAITING state and doesn't use the CPU.
  • It sleeps for at least the time given. The exact wake-up depends on the scheduler.
  • A sleeping thread keeps any locks it holds. This matters for synchronized, and it's the main difference from wait(), which releases the lock.
  • It throws the checked exception InterruptedException, so you must catch or declare it.

Interrupting a sleeping thread

Another thread can wake a sleeper early by calling interrupt(). The sleeping thread then gets an InterruptedException:

Thread t = new Thread(() -> {
    try {
        Thread.sleep(5000);
        System.out.println("Woke up normally");
    } catch (InterruptedException e) {
        System.out.println("Interrupted!");
    }
});

t.start();
t.interrupt();
t.join();
Enter fullscreen mode Exit fullscreen mode

Output:

Interrupted!
Enter fullscreen mode Exit fullscreen mode

Treat an interrupt as a polite request to stop. A good habit: don't swallow it silently. Either stop the work or restore the signal with Thread.currentThread().interrupt();.

Thread priority

Every thread has a priority from 1 to 10. The constants are:

  • Thread.MIN_PRIORITY = 1
  • Thread.NORM_PRIORITY = 5 (the default)
  • Thread.MAX_PRIORITY = 10
Thread t = new Thread(() -> { });

System.out.println(t.getPriority());
t.setPriority(Thread.MAX_PRIORITY);
System.out.println(t.getPriority());
System.out.println(Thread.currentThread().getPriority());
Enter fullscreen mode Exit fullscreen mode

Output:

5
10
5
Enter fullscreen mode Exit fullscreen mode

A new thread inherits the priority of the thread that created it. Here t started at 5 because main is at 5. Setting a value outside 1 to 10 throws an IllegalArgumentException.

The important truth is that priority is only a hint to the scheduler. A higher priority means "please prefer this thread," not "run this first." Java priorities are mapped onto the operating system's own priorities, and on many systems the effect is small or none. So:

  • Never rely on priority for correctness. If your program works only when thread A runs before thread B, use join() or proper coordination.
  • Use priority only as a mild tuning hint, if at all.

The problem: race conditions

Now the bug that gave me 143,267 instead of 200,000. We'll make two threads add to the same counter:

public class RaceDemo {
    static class Counter {
        private int count = 0;

        void increment() {
            count++;
        }

        int get() {
            return count;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();

        Runnable task = () -> {
            for (int i = 0; i < 100_000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Count: " + counter.get());
    }
}
Enter fullscreen mode Exit fullscreen mode

Output (it will be different every time):

Count: 143267
Enter fullscreen mode Exit fullscreen mode

We expected 200,000. Where did the missing increments go?

The culprit is count++. It looks like one step, but it's really three: read the value, add one, write it back. Two threads can mix those steps:

   count = 5

   Thread A:  read count (5)
   Thread B:  read count (5)
   Thread A:  add 1, write 6
   Thread B:  add 1, write 6        <-- B overwrote A's work!

   Two increments happened, but count went from 5 to 6 instead of 7.
Enter fullscreen mode Exit fullscreen mode

This is called a race condition: the result depends on the exact timing of the threads. The code that touches the shared data, called the critical section, needs protection.

synchronized

The synchronized keyword makes sure that only one thread at a time can run a piece of code. Just add it to the method:

synchronized void increment() {
    count++;
}
Enter fullscreen mode Exit fullscreen mode

Run the same program again:

Count: 200000
Enter fullscreen mode Exit fullscreen mode

Every time, exactly 200,000.

How it works: the lock (monitor)

Every Java object has a hidden lock, also called a monitor. When a thread enters a synchronized method, it takes the lock of the object (this). Any other thread that tries to enter a synchronized method of that same object has to wait until the lock is released.

   counter object  (has one lock)

   Thread A  --> enters increment()  --> HOLDS the lock   --> runs count++  --> releases
   Thread B  --> tries increment()   --> BLOCKED, waiting for the lock ..........> then runs
Enter fullscreen mode Exit fullscreen mode

When the lock is free, a waiting thread gets it and continues.

synchronized blocks

Locking a whole method can be too much. A synchronized block protects only the lines that need it, using any object as the lock:

class Counter {
    private int count = 0;
    private final Object lock = new Object();

    void increment() {
        // other work that doesn't need the lock could go here
        synchronized (lock) {
            count++;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Keep the locked section as small as possible, because while one thread holds the lock, the others are stuck waiting.

There's also a version for static methods. A static synchronized method locks the class object (Counter.class), since there's no this.

Seeing the BLOCKED state

Now we can finally see the BLOCKED state in action:

Object lock = new Object();

Thread holder = new Thread(() -> {
    synchronized (lock) {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) { }
    }
});

Thread waiter = new Thread(() -> {
    synchronized (lock) {
        System.out.println("Got the lock");
    }
});

holder.start();
Thread.sleep(100);
waiter.start();
Thread.sleep(100);

System.out.println(waiter.getState());
Enter fullscreen mode Exit fullscreen mode

Output:

BLOCKED
Got the lock
Enter fullscreen mode Exit fullscreen mode

holder grabbed the lock and went to sleep while still holding it (remember, sleep doesn't release locks). waiter then arrived and found the lock taken, so it sat in the BLOCKED state. About a second later, holder finished, waiter got the lock, and "Got the lock" printed.

Behind the scenes

  • For a synchronized block, the compiler writes two bytecode instructions, monitorenter and monitorexit, around your code. It also adds a hidden exception handler, so the lock is always released, even if your code throws an exception.
  • For a synchronized method, the method is simply marked with a flag in the .class file, and the JVM takes and releases the lock around the call.
  • The lock is reentrant: a thread that already holds a lock can enter other synchronized code on the same object without blocking itself.
  • synchronized does two jobs. It gives mutual exclusion (one thread at a time), and it guarantees visibility (when a thread releases a lock, its changes are visible to the next thread that takes it).

A word on deadlock

If thread A holds lock 1 and waits for lock 2, while thread B holds lock 2 and waits for lock 1, both wait forever. That's a deadlock. The simplest defense is to always take multiple locks in the same order in every thread.

Which one should I use?

  • Thread or Runnable? Use Runnable (or a lambda). It keeps the task separate from the thread, and your class stays free to extend another class.
  • Raw threads or an ExecutorService? In real applications, prefer an executor (a thread pool), because creating threads is expensive and a pool reuses them. Raw threads are good for learning and for very simple cases. For huge numbers of blocking tasks, newer Java versions offer virtual threads.
  • synchronized method or block? Use a block when only part of the method needs protection. It keeps the locked time short.
  • synchronized or AtomicInteger? For a single counter, AtomicInteger and its incrementAndGet() method are simpler and often faster. Use synchronized when several variables must change together.
  • sleep or something smarter? sleep is fine for pauses and demos. To wait for a condition or for another thread, use join(), wait()/notify() or tools like CountDownLatch, instead of sleeping and hoping.
  • Priority? Leave it at the default. Never make correctness depend on it.

Common mistakes

  1. Calling run() instead of start(). The code runs on the current thread, and no new thread is created.
  2. Starting the same thread twice. The second start() throws IllegalThreadStateException. Create a new Thread object instead.
  3. Assuming a thread order. The scheduler decides, and the order changes between runs. Don't use sleep or priority to force an order. Use join() or proper coordination.
  4. Sharing changeable data without protection. The result is a race condition like our counter. Also check that all threads use the same lock object. Locking on a new object each time, like synchronized (new Object()), protects nothing.
  5. Swallowing InterruptedException. An empty catch hides the stop request. Either stop the work, or restore the flag with Thread.currentThread().interrupt().
  6. Holding a lock for too long. Calling sleep or doing slow work inside synchronized makes every other thread wait. Also watch out for deadlock, and take locks in a fixed order.

Quick recap

  • A thread is an independent path of execution inside a process. Threads share the heap, and each has its own stack.
  • Create threads with Runnable (or a lambda) and Thread, and launch them with start(), not run(). Use join() to wait for one to finish.
  • The thread scheduler (JVM plus OS) decides who runs and for how long. The order isn't guaranteed.
  • A thread's states: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED.
  • sleep(ms) pauses the current thread without releasing locks, and it can be interrupted.
  • Priority (1 to 10, default 5) is only a hint, so don't depend on it.
  • A race condition happens when threads change shared data at the same time. count++ is three steps, not one.
  • synchronized lets one thread at a time into a critical section by using the object's lock, and it also makes changes visible to other threads. Keep locked sections small.

Try it yourself

Here's a small practice idea: build a BankAccount with a balance and a deposit method. Start two threads that each deposit 1 rupee 100,000 times, and print the final balance. Run it a few times to see the wrong answers, then add synchronized and see it become correct every time.

For a bonus experiment, print getState() for a thread at each stage: before start(), while sleeping, while blocked on a lock, and after it finishes. Then try giving two threads different priorities and see whether the output on your machine changes at all.

If this helped, or if you spotted something I should fix, tell me in the comments. I'm still learning too.

Top comments (0)