LearnFrenzy
Sign in Take a free test

Multithreading and Concurrency · Lesson 39 of 42

Threads, Race Conditions and synchronized

5 min read Updated Nov 7, 2026 Saurabh Samir

A thread is an independent path of execution inside a program. A web server handles each request on its own thread; an app downloads a file in the background while the screen stays responsive. Threads make programs faster and more responsive – and introduce a new class of bugs. This lesson covers both.

In this lesson

  • Creating threads with Runnable and lambdas
  • start() vs run(), join() and thread states
  • Race conditions – and three ways to fix them
  • Deadlock and the rules that avoid it

Starting threads

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            String name = Thread.currentThread().getName();
            int sum = 0;
            for (int i = 1; i <= 1000; i++) sum += i;
            System.out.println(name + " computed " + sum);
        };
        Thread t1 = new Thread(task, "worker-1");
        Thread t2 = new Thread(task, "worker-2");
        t1.start();          // runs in parallel with main
        t1.join();           // wait for worker-1 to finish
        t2.start();
        t2.join();
        System.out.println("main done");
    }
}

Output

worker-1 computed 500500
worker-2 computed 500500
main done
  • Put the work in a Runnable (usually a lambda) and pass it to a Thread. Extending Thread also works but wastes your one chance at inheritance.
  • start() creates a new thread that calls run(). Calling run() directly just runs the code on the current thread – a classic interview trap.
  • join() waits for a thread to finish. Without it, the order of output from different threads is unpredictable.

Thread states

Thread lifecycle

  1. NEWcreated, start() not called yet
  2. RUNNABLErunning or ready to run
  3. BLOCKED / WAITING / TIMED_WAITINGwaiting for a lock, join(), sleep()…
  4. TERMINATEDrun() finished
public class Main {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });
        System.out.println(t.getState());
        t.start();
        Thread.sleep(50);
        System.out.println(t.getState());
        t.join();
        System.out.println(t.getState());
    }
}

Output

NEW
TIMED_WAITING
TERMINATED

A race condition

When threads share data and at least one writes it, results can depend on timing. count++ looks like one step but is really three: read, add, write. Two threads can read the same old value, and one update is lost.

public class Main {
    static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable add = () -> { for (int i = 0; i < 100_000; i++) count++; };
        Thread a = new Thread(add), b = new Thread(add);
        a.start(); b.start();
        a.join(); b.join();
        System.out.println("expected 200000, got " + count);
    }
}

Output (varies – one possible run)

expected 200000, got 137482

How an update is lost (count = 5)

  1. Thread A reads 5…
  2. Thread B reads 5before A writes
  3. A writes 6…
  4. B writes 6two increments, but count is 6, not 7

Three ways to fix it

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;

public class Main {
    static int syncCount = 0;
    static int lockCount = 0;
    static final AtomicInteger atomicCount = new AtomicInteger();
    static final ReentrantLock lock = new ReentrantLock();

    static synchronized void incrementSync() { syncCount++; }     // 1. synchronized method

    static void incrementLock() {                                 // 2. explicit lock
        lock.lock();
        try { lockCount++; } finally { lock.unlock(); }
    }

    public static void main(String[] args) throws InterruptedException {
        Runnable work = () -> {
            for (int i = 0; i < 100_000; i++) {
                incrementSync();
                incrementLock();
                atomicCount.incrementAndGet();                    // 3. atomic variable
            }
        };
        Thread a = new Thread(work), b = new Thread(work);
        a.start(); b.start();
        a.join(); b.join();
        System.out.println(syncCount + " " + lockCount + " " + atomicCount.get());
    }
}

Output

200000 200000 200000
ToolUse when
synchronizedsimple mutual exclusion on a method or block (synchronized (obj) { … })
ReentrantLockyou need tryLock, timeouts or fairness; always unlock in finally
AtomicInteger, AtomicLonga single counter or value – fastest, lock-free
ConcurrentHashMap, CopyOnWriteArrayListshared collections

Interview angle: volatile

volatile makes a variable's latest value visible to all threads (no stale cached copies), but it does not make count++ atomic. Use it for simple flags such as volatile boolean running; use atomics or locks for read-modify-write updates.

Deadlock

A deadlock happens when thread 1 holds lock A and waits for lock B, while thread 2 holds B and waits for A – both wait forever. Avoid it by:

  • always acquiring multiple locks in the same order everywhere;
  • holding locks for as short a time as possible;
  • using tryLock(timeout) so a thread can give up and retry;
  • preferring higher-level tools (executors, concurrent collections) over hand-written locking.

Common mistake: synchronizing on different objects

synchronized instance methods lock this. If two threads use two different objects, they don't block each other at all, and a shared static field is still unprotected. Lock on the object that actually owns the shared data.

Practice

Try it yourself

  1. Use 4 threads to sum the numbers 1–4,000 in four ranges, adding into an AtomicLong.

    Show solution
    import java.util.ArrayList;
    import java.util.List;
    import java.util.concurrent.atomic.AtomicLong;
    
    public class Main {
        public static void main(String[] args) throws InterruptedException {
            AtomicLong total = new AtomicLong();
            List<Thread> threads = new ArrayList<>();
            for (int t = 0; t < 4; t++) {
                int from = t * 1000 + 1, to = (t + 1) * 1000;
                Thread th = new Thread(() -> {
                    long sum = 0;
                    for (int i = from; i <= to; i++) sum += i;
                    total.addAndGet(sum);
                });
                threads.add(th);
                th.start();
            }
            for (Thread th : threads) th.join();
            System.out.println(total.get());
        }
    }

    Output

    8002000

Quick check

What is the difference between start() and run()?

start() creates a new thread that runs run(); calling run() directly executes it on the current thread.

Why is count++ not thread-safe?

It is three steps (read, add, write); threads can interleave and lose updates.

Does volatile make count++ safe?

No – it only guarantees visibility. Use AtomicInteger or synchronization.

Summary

  • Create threads from Runnables, call start(), and join() to wait.
  • Shared mutable data causes race conditions; protect it with synchronized, locks or atomics.
  • Avoid deadlock with consistent lock order and short critical sections.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Popular