π§΅ 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:
NEW: The thread object has been created vianew Thread(), butstart()has not been called.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.BLOCKED: The thread is waiting to acquire a monitor lock (e.g., trying to enter asynchronizedblock held by another thread).WAITING: The thread is waiting indefinitely for another thread to perform a specific action (viaObject.wait(),Thread.join(), orLockSupport.park()).TIMED_WAITING: Waiting for a specified time period (e.g.,Thread.sleep(ms),wait(ms)).TERMINATED: The thread has completed itsrun()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:
- READ: Fetch the current value of
countfrom memory into a CPU register. - MODIFY: Add
1to the register value. - 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:
- It must acquire the lock on the target object.
- If another thread already holds that lock, the incoming thread is paused and moved into the
BLOCKEDstate. - 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:
- Visibility: Writes to this variable are immediately flushed straight to main memory. Reads always bypass local CPU caches and pull directly from main memory.
- 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:
volatileguarantees visibility, but it does NOT guarantee atomicity. It will NOT make operations likecount++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:
- The CPU reads the existing value $V$ from memory.
- It calculates the new value $N = V + 1$.
- 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 standardsynchronizedblocks 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
LockAbeforeLockB). - Timed Locks: Use
java.util.concurrent.locks.ReentrantLockwith.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
RunnableorCallable<V>to define tasks, separating business logic from thread mechanics. - Use
synchronizedto create mutual exclusion blocks, ensuring only one thread accesses a critical section at a time. - The
volatilekeyword guarantees memory visibility across CPU caches, but does not provide atomic updates. - Use
Atomicclasses (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.