Java

Chapter 15: Concurrency and Multithreading Fundamentals

By Ali Naqi | October 25, 2026

Chapter 15: Concurrency and Multithreading Fundamentals


🧡 Chapter 15: Concurrency and Multithreading Fundamentals

1. Concurrency vs. Parallelism: The Core Distinction

These two terms are frequently conflated, but they describe different concepts:

  • Concurrency (Dealing with lots of things at once): Structuring a program into distinct, out-of-order execution paths. On a single-core machine, the operating system achieves concurrency through time slicing (rapidly context-switching between tasks so quickly that they appear simultaneous).
  • Parallelism (Doing lots of things at once): Literally executing distinct computations at the exact same physical instant on separate CPU cores or hardware execution units.
Concurrency (1 CPU Core):
Task A: [==]    [==]    [==]
Task B:     [==]    [==]    [==]  <-- Fast context switching gives the illusion of simultaneity

Parallelism (2+ CPU Cores):
Core 1: [======================]  <-- Task A
Core 2: [======================]  <-- Task B (Running at the exact same physical instant)

In Java, the fundamental unit of concurrent execution is the Thread.


2. Processes vs. Threads: Where Memory Lives

Understanding the distinction between an operating system process and a thread is essential:

  • Process: An executing instance of an entire program (e.g., your running JVM). Every process gets its own isolated virtual memory space. One process cannot touch another process's memory without OS-level Inter-Process Communication (IPC).
  • Thread: A lightweight path of execution inside a process.
  • Every thread gets its own private Program Counter (PC) and Call Stack (local variables live here).
  • Crucially, all threads inside the same process share the exact same Heap memory space (objects and static fields live here).

This shared memory is both a blessing and a curse. It makes passing data between tasks fast, but it opens the door to memory corruption and synchronization bugs.


3. Creating and Launching Threads in Java

Java provides three primary avenues to define and run a thread.

Approach A: Extending the Thread Class (Discouraged)

class WorkerThread extends Thread {
    @Override
    public void run() {
        System.out.println("Executing inside: " + Thread.currentThread().getName());
    }
}

public class ThreadDemo {
    public static void main(String[] args) {
        WorkerThread worker = new WorkerThread();
        // NEVER call worker.run() directly! That just runs it sequentially on the main thread.
        worker.start(); // Allocates native OS thread and invokes run() concurrently
    }
}

Why this is discouraged: Java does not support multiple inheritance. By extending Thread, your class burns its single inheritance slot, and the code tightly couples the task definition with the thread execution mechanism.


Approach B: Implementing the Runnable Interface (Idiomatic)

The Runnable functional interface separates the work to be done from the thread executing it:

@FunctionalInterface
public interface Runnable {
    void run();
}

Because it is a functional interface, you can pass lambdas directly:

public class RunnableDemo {
    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 1; i <= 3; i++) {
                System.out.println(Thread.currentThread().getName() + " -> Count: " + i);
                try {
                    Thread.sleep(500); // Pause thread for 500ms
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt(); // Restore interrupted status
                    System.err.println("Thread interrupted!");
                }
            }
        };

        Thread threadA = new Thread(task, "Worker-Alpha");
        Thread threadB = new Thread(task, "Worker-Beta");

        threadA.start();
        threadB.start();
    }
}


Approach C: Returning Values with Callable<V> and Future<V>

Runnable has a serious limitation: its run() method returns void and cannot throw checked exceptions. If you need a background task to compute and return a value (like fetching an API payload), use Callable<V>:

import java.util.concurrent.*;

public class CallableDemo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> calculationTask = () -> {
            Thread.sleep(1000);
            return 42 * 2;
        };

        // Wrap Callable in a FutureTask
        FutureTask<Integer> futureTask = new FutureTask<>(calculationTask);
        Thread worker = new Thread(futureTask);
        worker.start();

        System.out.println("Main thread continues working...");

        // .get() is a blocking call: it waits until the worker completes and yields the value
        Integer result = futureTask.get(); 
        System.out.println("Result received from background: " + result);
    }
}


4. The Lifecycle of a Thread

A thread inside the JVM transitions through distinct states defined in Thread.State:

  1. NEW: The thread object has been created via new Thread(), but start() has not been called.
  2. RUNNABLE: The thread is executing or ready to execute in the JVM, waiting for the OS thread scheduler to assign it a CPU time slice.
  3. BLOCKED: The thread is waiting to acquire a monitor lock (e.g., trying to enter a synchronized block held by another thread).
  4. WAITING: The thread is waiting indefinitely for another thread to perform a specific action (via Object.wait(), Thread.join(), or LockSupport.park()).
  5. TIMED_WAITING: Waiting for a specified time period (e.g., Thread.sleep(ms), wait(ms)).
  6. TERMINATED: The thread has completed its run() method execution or exited due to an unhandled exception.

Coordinating Threads: join()

If the main thread must wait for a background worker to complete its work before proceeding:

Thread loader = new Thread(() -> loadAssets());
loader.start();

loader.join(); // The calling thread pauses until loader hits the TERMINATED state
System.out.println("Assets loaded. Starting game engine...");


5. Shared Mutability and the Nightmare: Race Conditions

When multiple threads read and write to the same mutable memory location concurrently without coordination, operations overlap in unpredictable ways. This is a Race Condition.

Consider a basic counter:

class UnsafeCounter {
    private int count = 0;

    public void increment() {
        count++; // Looks atomic, but it is NOT!
    }

    public int getCount() {
        return count;
    }
}

Why count++ Breaks Under Concurrency

At the CPU bytecode level, count++ requires three distinct steps:

  1. READ: Fetch the current value of count from memory into a CPU register.
  2. MODIFY: Add 1 to the register value.
  3. WRITE: Write the updated register value back to the main memory address.

If two threads execute this simultaneously:

Thread A: Read count (0)
Thread B: Read count (0)
Thread A: Modify (0 + 1 = 1)
Thread B: Modify (0 + 1 = 1)
Thread A: Write count (1)
Thread B: Write count (1)  <-- Race condition! Two increments happened, but count is 1 instead of 2.

If both threads run this loop 10,000 times, the final count will not be 20,000; it will land on an unpredictable, lower number (e.g., 14,231).


6. Thread Synchronization: The synchronized Keyword

To prevent race conditions, Java uses Intrinsic Locks (also called Monitor Locks). Every single object in Java has an associated monitor lock.

When a thread enters a synchronized block or method:

  1. It must acquire the lock on the target object.
  2. If another thread already holds that lock, the incoming thread is paused and moved into the BLOCKED state.
  3. Once the lock holder exits the block, it releases the lock, allowing one of the blocked threads to claim it.

Synchronizing a Method

class SynchronizedCounter {
    private int count = 0;

    // Locks on 'this' instance
    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

Synchronizing a Specific Block (Finer Granularity)

Synchronizing entire methods can choke performance. Locking only the critical lines is best practice:

class AccountTransfer {
    private final Object lock = new Object(); // Dedicated monitor lock object
    private double balance = 1000.0;

    public void withdraw(double amount) {
        // Unsynchronized preparatory work
        validateTransaction(amount);

        // Critical section
        synchronized (lock) {
            if (balance >= amount) {
                balance -= amount;
            }
        }

        // Unsynchronized logging/reporting
        notifyCustomer();
    }
}


7. The Java Memory Model (JMM) and volatile

Modern CPUs use high-speed hardware caches (L1, L2, L3) between the processor cores and main RAM.

When a thread modifies a variable, the write might stay inside the CPU core's private L1 cache for a period before being flushed back down to main memory. If another thread on a different core reads that variable from main memory, it reads a stale value. This is a Memory Visibility bug.

Core 1 (Thread A)               Core 2 (Thread B)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”             β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  L1/L2 Cache    β”‚             β”‚  L1/L2 Cache    β”‚
β”‚  [flag = true]  β”‚             β”‚  [flag = false] β”‚ <-- Stale view!
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜             β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚                               β”‚
         β–Ό                               β–Ό
 ═════════════════════════════════════════════════
                 Main Memory (RAM)
                 [ flag = false ]
 ═════════════════════════════════════════════════

The volatile Keyword

Marking a variable as volatile instructs the JVM and compiler:

  1. Visibility: Writes to this variable are immediately flushed straight to main memory. Reads always bypass local CPU caches and pull directly from main memory.
  2. Instruction Reordering: Prevents CPU compilers from reordering instructions around the volatile read/write.
class WorkerTask implements Runnable {
    // Without volatile, the while loop might cache 'running = true' and never terminate!
    private volatile boolean running = true;

    public void stopTask() {
        running = false;
    }

    @Override
    public void run() {
        while (running) {
            // Keep working...
        }
        System.out.println("Worker stopped safely.");
    }
}

Warning: volatile guarantees visibility, but it does NOT guarantee atomicity. It will NOT make operations like count++ thread-safe.


8. Lock-Free Atomic Operations: java.util.concurrent.atomic

For single variables, full synchronization is heavy. Java provides atomic wrapper classes in java.util.concurrent.atomic (e.g., AtomicInteger, AtomicBoolean, AtomicReference).

These classes bypass OS-level locks entirely. Instead, they use a low-level CPU hardware primitive called CAS (Compare-And-Swap):

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private final AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet(); // Thread-safe, non-blocking atomic operation
    }

    public int getCount() {
        return count.get();
    }
}

How Compare-And-Swap (CAS) Works:

  1. The CPU reads the existing value $V$ from memory.
  2. It calculates the new value $N = V + 1$.
  3. When writing back, the CPU asks: "Does the memory still hold $V$? If yes, write $N$. If another thread modified it in the meantime, abort and retry."
    Because CAS happens in silicon without placing threads into sleep states, it is significantly faster than standard synchronized blocks under moderate contention.

9. Thread Safety Hazards: Deadlocks

A Deadlock occurs when two or more threads are permanently blocked, each waiting for a lock held by the other.

Thread 1 holds [Lock A] and waits for [Lock B]
Thread 2 holds [Lock B] and waits for [Lock A]
--> Neither thread can ever advance. The application freezes forever.

public class DeadlockSimulation {
    private static final Object LockA = new Object();
    private static final Object LockB = new Object();

    public static void main(String[] args) {
        // Thread 1 acquires A, then wants B
        new Thread(() -> {
            synchronized (LockA) {
                System.out.println("Thread 1: Acquired Lock A");
                try { Thread.sleep(50); } catch (Exception e) {}

                synchronized (LockB) {
                    System.out.println("Thread 1: Acquired Lock B");
                }
            }
        }).start();

        // Thread 2 acquires B, then wants A
        new Thread(() -> {
            synchronized (LockB) {
                System.out.println("Thread 2: Acquired Lock B");
                try { Thread.sleep(50); } catch (Exception e) {}

                synchronized (LockA) {
                    System.out.println("Thread 2: Acquired Lock A");
                }
            }
        }).start();
    }
}

How to Prevent Deadlocks:

  • Global Lock Ordering: Ensure every thread acquires required locks in the exact same sequential order (e.g., always acquire LockA before LockB).
  • Timed Locks: Use java.util.concurrent.locks.ReentrantLock with .tryLock(timeout), which bails out and releases acquired resources if a lock cannot be obtained in time.

10. Practical Example: Multi-Threaded File Downloader Simulation

Let's build a practical program that downloads multiple assets concurrently, coordinates thread completion using CountDownLatch, and maintains atomic progress metrics.

import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicLong;

public class ConcurrentDownloader {

    record DownloadJob(String url, int payloadSizeMb) {}

    public static void main(String[] args) throws InterruptedException {
        List<DownloadJob> jobs = List.of(
            new DownloadJob("https://cdn.example.com/asset-1.pak", 45),
            new DownloadJob("https://cdn.example.com/asset-2.pak", 120),
            new DownloadJob("https://cdn.example.com/asset-3.pak", 75)
        );

        // CountDownLatch initializes with the count of worker tasks
        CountDownLatch latch = new CountDownLatch(jobs.size());
        AtomicLong totalBytesDownloaded = new AtomicLong(0);

        System.out.println("Dispatching downloads across background threads...");

        for (DownloadJob job : jobs) {
            Thread worker = new Thread(() -> {
                try {
                    System.out.println("Starting: " + job.url());
                    // Simulate variable network latency
                    Thread.sleep(job.payloadSizeMb() * 20L);

                    totalBytesDownloaded.addAndGet(job.payloadSizeMb());
                    System.out.println("Completed: " + job.url());
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    // Decrement latch count safely in finally block
                    latch.countDown();
                }
            });
            worker.start();
        }

        // Main thread blocks here until the latch count reaches zero
        latch.await();

        System.out.println("All downloads completed!");
        System.out.println("Total downloaded size: " + totalBytesDownloaded.get() + " MB");
    }
}


πŸ“ Chapter Summary

  • Concurrency is structuring multiple tasks for interleaved execution; Parallelism is simultaneous hardware execution across multi-core processors.
  • Threads in the same process share Heap memory, which makes communication fast but introduces race conditions when mutable data is uncoordinated.
  • Implement Runnable or Callable<V> to define tasks, separating business logic from thread mechanics.
  • Use synchronized to create mutual exclusion blocks, ensuring only one thread accesses a critical section at a time.
  • The volatile keyword guarantees memory visibility across CPU caches, but does not provide atomic updates.
  • Use Atomic classes (AtomicInteger, AtomicReference) for lock-free, thread-safe counter and flag updates via hardware Compare-And-Swap (CAS).
  • Prevent Deadlocks by enforcing strict, consistent lock-acquisition hierarchies or using timed lock acquisition.

While manually spinning up threads via new Thread() works for simple tasks, manual thread creation does not scale in production. Creating a thread costs OS memory (up to 1 MB per stack) and CPU time. In enterprise Java, we do not instantiate raw threads directlyβ€”we use Thread Pools and the Executor Framework.