Core Java · Concurrency · Section 1 of 10

What Is Concurrency?

Concurrency means doing multiple things at the same time — or at least making it look that way. Understanding it is essential for writing programs that are fast and responsive.

Intermediate
~10 min read
10 sections

The Single-Threaded World

By default, every Java program runs on a single thread — the main thread. Instructions execute one after another, in order. This is simple, predictable, and safe.

But it has a hard limit: one thing at a time. If your program downloads a file, it can't respond to user input while waiting. If it processes a large dataset, the whole application freezes. This is where threads come in.

Analogy

A restaurant with one chef (single-threaded): takes order → cooks → serves → takes next order. Fast customers suffer while one table's food is being plated.

A restaurant with many chefs (multi-threaded): orders taken simultaneously, multiple dishes cooking in parallel. Much faster — but now the chefs have to coordinate so they don't grab the same ingredient at the same time.

What Is a Thread?

A thread is an independent path of execution within a program. Multiple threads share the same memory (heap), but each has its own call stack. The JVM and OS schedule threads, switching between them rapidly to create the illusion of true parallelism — even on a single CPU core.

Concurrency
Multiple tasks making progress — not necessarily at the exact same instant. One CPU rapidly switching between threads.
Parallelism
Multiple tasks running at the same instant on multiple CPU cores. True simultaneous execution.
Thread Safety
Code that behaves correctly when multiple threads run it simultaneously — no corrupted data, no crashes.

Why It Matters in Real Code

1
Responsiveness
Web servers handle thousands of requests concurrently. Without threads, every user would queue behind the previous one.
2
Performance
Modern CPUs have 8–32 cores. Single-threaded code uses 1 of them. Concurrency unlocks the rest.
3
I/O Overlap
While one thread waits for a database response, another can be serving a different request. Threads let you use idle wait time.
4
Job Market
Concurrency basics are asked in virtually every Java backend interview. Understanding threads and ExecutorService is expected.
⚠ Concurrency Is Hard
Multi-threaded bugs are among the most difficult to reproduce and fix — they may only appear under specific timing conditions, on specific hardware, or under heavy load. This lesson gives you a solid foundation and shows you the right tools to avoid the worst problems.
Section 1 of 10
Core Java · Concurrency · Section 2 of 10

Creating Threads

Java gives you three ways to create a thread. The modern approach uses lambdas and is the one you'll write most often.

Method 1: Extend Thread

ExtendThread.javaApproach 1
public class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("Running in: " + Thread.currentThread().getName());
    }
}

// Usage — call start(), NOT run()
MyThread t = new MyThread();
t.start();  // spawns new thread, calls run() on it
// t.run()  ← DON'T: runs on current thread, no new thread created

Method 2: Implement Runnable

RunnableThread.javaApproach 2 — Preferred over extending
public class MyTask implements Runnable {
    @Override
    public void run() {
        System.out.println("Task running in: " + Thread.currentThread().getName());
    }
}

Thread t = new Thread(new MyTask());
t.start();

Method 3: Lambda (Modern — Use This)

Runnable is a functional interface — one abstract method. That means you can pass a lambda directly.

LambdaThread.java✓ Modern Style
// Inline lambda — most common in modern code
Thread t1 = new Thread(() -> {
    System.out.println("Hello from thread: " + Thread.currentThread().getName());
});
t1.start();

// Named thread — helps with debugging
Thread t2 = new Thread(() -> System.out.println("Worker running"), "worker-1");
t2.start();

// Multiple threads running concurrently
for (int i = 0; i < 5; i++) {
    final int id = i;
    new Thread(() -> System.out.println("Thread " + id)).start();
}
Output (one possible ordering)Console
Thread 2
Thread 0
Thread 4
Thread 1
Thread 3
// Order is non-deterministic — OS decides scheduling
⚡ start() vs run() — Critical Distinction
Always call start() — this creates a new OS thread and eventually calls run() on it.

Never call run() directly — this just invokes the method on the current thread like a normal function call. No new thread is created. No concurrency happens. This is a very common beginner mistake.

Thread Names and Current Thread

ThreadInfo.javaUseful Thread Methods
Thread t = new Thread(() -> {
    Thread current = Thread.currentThread();
    System.out.println("Name: "     + current.getName());
    System.out.println("ID: "       + current.getId());
    System.out.println("Priority: " + current.getPriority());
    System.out.println("Daemon: "   + current.isDaemon());
}, "my-thread");

t.start();
t.join();  // wait for t to finish before continuing
System.out.println("Main thread continues");
Section 2 of 10
Core Java · Concurrency · Section 3 of 10

Thread Lifecycle

A thread moves through defined states from creation to termination. Understanding these states helps you reason about what your threads are doing.

The Six States

NEW
created, not started
RUNNABLE
running / ready
BLOCKED
waiting for lock
WAITING
indefinitely
TIMED_WAIT
with timeout
TERMINATED
done
StateWhat It MeansHow You Get There
NEWThread created but start() not yet callednew Thread(...)
RUNNABLERunning on CPU or ready to run (waiting for CPU slice)thread.start()
BLOCKEDWaiting to acquire a synchronized lock held by another threadtrying to enter synchronized block
WAITINGWaiting indefinitely for another thread to signalwait(), join() with no timeout
TIMED_WAITINGWaiting for a specific amount of timesleep(ms), join(ms), wait(ms)
TERMINATEDrun() completed or threw an exceptionrun() returns or throws

Key Thread Methods

ThreadMethods.javasleep, join, interrupt
// sleep — pause current thread for N milliseconds (TIMED_WAITING)
try {
    Thread.sleep(1000);  // sleep 1 second
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();  // restore interrupted status
}

// join — make current thread WAIT until another thread finishes
Thread worker = new Thread(() -> {
    System.out.println("Working...");
});
worker.start();
worker.join();  // main thread waits here until worker is TERMINATED
System.out.println("Worker done, continuing");

// interrupt — signal a thread to stop what it's doing
Thread t = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // do work...
    }
    System.out.println("Thread stopping gracefully");
});
t.start();
Thread.sleep(500);
t.interrupt();  // signal the thread to stop
📐 InterruptedException
Methods like sleep() and join() throw InterruptedException — a checked exception. This is Java's way of saying "someone interrupted you while you were waiting." Always handle it properly: either propagate it or restore the interrupted flag with Thread.currentThread().interrupt().
🧠 Quick Check
What's the difference between calling thread.run() and thread.start()?
Section 3 of 10
Core Java · Concurrency · Section 4 of 10

Race Conditions

When two threads access shared data at the same time without coordination, the result depends on who gets there first. This is a race condition — and it produces silent, unpredictable bugs.

The Classic Example: Counter

Incrementing a counter seems trivially safe. It isn't.

RaceCounter.java⚡ Race Condition
public class Counter {
    private int count = 0;

    public void increment() {
        count++;  // looks like 1 operation — it's actually 3
    }

    public int getCount() { return count; }
}

Counter counter = new Counter();

// 1000 threads each increment once — expected result: 1000
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
    threads.add(new Thread(counter::increment));
}
threads.forEach(Thread::start);
threads.forEach(t -> { try { t.join(); } catch (InterruptedException e) {} });

System.out.println(counter.getCount());
// Expected: 1000. Actual: could be 947, 998, 1000 — unpredictable

Why count++ Is Not Atomic

The expression count++ compiles to three separate CPU instructions:

⚡ What Can Go Wrong (count starts at 5)
Thread-1: READ count → gets 5
Thread-2: READ count → gets 5 (context switch!)
Thread-1: ADD 1 → 6, WRITE count = 6 count = 6
Thread-2: ADD 1 → 6, WRITE count = 6 (overwrites!) count = 6 ← should be 7!

Both threads read the same value (5), both compute 6, and both write 6. One increment is lost. At scale, with 1000 threads, many increments are lost.

⚡ Shared Mutable State = Danger
A race condition requires all three of these:
1. Shared — multiple threads access the same data
2. Mutable — at least one thread writes to it
3. Uncoordinated — no synchronisation mechanism

Remove any one of these and you're safe. The next sections show you how.
Section 4 of 10
Core Java · Concurrency · Section 5 of 10

synchronized & Locks

Java's built-in answer to race conditions. synchronized ensures only one thread can execute a block of code at a time.

synchronized Method

SyncCounter.java✓ Thread-Safe
public class SyncCounter {
    private int count = 0;

    // synchronized — only one thread at a time can call this
    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}
// Now 1000 threads → always 1000. Guaranteed.

synchronized Block — Finer Control

Synchronising an entire method can be too broad. A synchronized block lets you protect only the critical section:

SyncBlock.javaSynchronized Block
public class BankAccount {
    private double balance;
    private final Object lock = new Object();  // dedicated lock object

    public void deposit(double amount) {
        // non-critical code can run without lock
        if (amount <= 0) throw new IllegalArgumentException("Invalid amount");

        synchronized (lock) {   // acquire lock
            balance += amount;   // critical section
        }                        // release lock automatically
    }

    public void withdraw(double amount) {
        synchronized (lock) {
            if (balance < amount) throw new IllegalStateException("Insufficient funds");
            balance -= amount;
        }
    }

    public synchronized double getBalance() { return balance; }
}

AtomicInteger — Simpler for Counters

For simple numeric operations, Java provides atomic classes in java.util.concurrent.atomic that are lock-free and faster than synchronized:

AtomicCounter.java✓ Lock-Free Counter
import java.util.concurrent.atomic.AtomicInteger;

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

    public void increment() { count.incrementAndGet(); }
    public int  getCount()   { return count.get(); }
}
// No synchronized keyword needed. Always correct. Faster for high contention.

// Other useful operations:
AtomicInteger ai = new AtomicInteger(10);
ai.getAndIncrement();   // returns 10, then increments to 11
ai.incrementAndGet();   // increments to 12, returns 12
ai.addAndGet(5);        // adds 5, returns 17
ai.compareAndSet(17, 0); // if value is 17, set to 0 — atomic swap
🔑 volatile — Visibility Without Locking
The volatile keyword ensures that writes to a variable are immediately visible to all threads — without using a lock. It doesn't make compound operations (like count++) atomic. Use it for simple flags:

private volatile boolean running = true;

One thread writes, others read — volatile ensures they see the latest value without cache issues.
Section 5 of 10
Core Java · Concurrency · Section 6 of 10

Why ExecutorService?

Creating threads manually works for toy examples. In real applications, you use ExecutorService — a higher-level framework that manages threads for you.

The Problem with Raw Threads

RawThreadProblems.java⚠ Problems with new Thread()
// Problem 1: Thread creation is expensive
// Creating 10,000 threads for 10,000 tasks burns memory and CPU
for (Request req : requests) {
    new Thread(() -> handle(req)).start();  // ❌ new OS thread per request
}

// Problem 2: No return values
// Thread.run() is void — you can't get a result back easily

// Problem 3: No lifecycle management
// No built-in way to wait for all tasks, handle errors, or shut down cleanly
Analogy

Raw threads are like hiring a new employee for each task, then firing them the moment it's done. ExecutorService is like having a pool of permanent employees — when a task arrives, you hand it to an available worker. When they finish, they wait for the next task. No constant hiring and firing.

ExecutorService Solves All Three Problems

Thread Reuse
A pool of threads is created once and reused across many tasks. No repeated thread creation overhead.
Return Values
Callable tasks can return a value via Future<T>. Retrieve it later with future.get().
Lifecycle
Clean shutdown with shutdown(), wait for all tasks with awaitTermination(), submit many at once with invokeAll().
BasicExecutor.java✓ ExecutorService
import java.util.concurrent.*;

// Create a pool of 4 threads
ExecutorService executor = Executors.newFixedThreadPool(4);

// Submit 10 tasks — pool handles scheduling
for (int i = 0; i < 10; i++) {
    final int taskId = i;
    executor.submit(() -> {
        System.out.printf("Task %d on %s%n", taskId,
            Thread.currentThread().getName());
    });
}

// Shutdown: no new tasks accepted, existing tasks complete
executor.shutdown();

// Wait up to 10s for all tasks to finish
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
    executor.shutdownNow();  // force stop if still running
}
Section 6 of 10
Core Java · Concurrency · Section 7 of 10

Thread Pools

Java's Executors factory provides ready-made thread pool configurations for different workload patterns.

newFixedThreadPool(n)

Executors.newFixedThreadPool(int n)

Exactly N threads. Tasks queue up if all threads are busy. Best general-purpose choice.

newSingleThreadExecutor()

Executors.newSingleThreadExecutor()

One thread, sequential execution. Useful when tasks must run in order without concurrency issues.

newCachedThreadPool()

Executors.newCachedThreadPool()

Creates threads as needed, reuses idle ones. Good for many short-lived tasks. Dangerous for long tasks — can create thousands of threads.

newScheduledThreadPool(n)

Executors.newScheduledThreadPool(int n)

Runs tasks on a schedule — after a delay or at fixed intervals. Like a built-in cron job.

Fixed Pool — Most Common

FixedPool.javaFixed Thread Pool
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService pool = Executors.newFixedThreadPool(cores);

List<String> urls = List.of("url1", "url2", "url3", "url4", "url5");

for (String url : urls) {
    pool.submit(() -> {
        System.out.println("Fetching: " + url + " on "
            + Thread.currentThread().getName());
        // simulate network call
        Thread.sleep(100);
    });
}

pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);

Scheduled Pool — Repeating Tasks

Scheduled.javaScheduledExecutorService
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);

// Run once after 2 second delay
scheduler.schedule(() -> System.out.println("Delayed task"),
    2, TimeUnit.SECONDS);

// Run every 5 seconds after initial 1 second delay
scheduler.scheduleAtFixedRate(() -> {
    System.out.println("Heartbeat: " + System.currentTimeMillis());
}, 1, 5, TimeUnit.SECONDS);

// Let it run for 20 seconds then shut down
Thread.sleep(20_000);
scheduler.shutdown();
✓ How Many Threads?
A common rule of thumb:
CPU-bound tasks (heavy computation): Runtime.getRuntime().availableProcessors() threads — one per core.
I/O-bound tasks (network, disk): more threads, often 2× or 4× core count — threads spend most time waiting, not computing.
Section 7 of 10
Core Java · Concurrency · Section 8 of 10

Callable & Future

Runnable tasks can't return values or throw checked exceptions. Callable solves both. Future lets you retrieve the result when it's ready.

Callable vs Runnable

RunnableCallable<V>
Methodvoid run()V call() throws Exception
Return valueNone (void)Returns V
Checked exceptionsCannot throwCan throw Exception
Submit viaexecute() or submit()submit() only
Result handleNoneFuture<V>
CallableFuture.javaCallable + Future
import java.util.concurrent.*;

ExecutorService pool = Executors.newFixedThreadPool(3);

// Callable — returns a value
Callable<Integer> task = () -> {
    Thread.sleep(500);       // simulate work
    return 42;               // return the result
};

// submit() returns a Future — a promise of a result
Future<Integer> future = pool.submit(task);

// Do other work while task runs in background...
System.out.println("Task submitted, doing other work");

// When you need the result, call get() — blocks until ready
Integer result = future.get();  // blocks here if task not yet done
System.out.println("Result: " + result);  // Result: 42

pool.shutdown();

Future Methods

FutureMethods.javaFuture API
Future<String> f = pool.submit(() -> "hello");

// isDone() — non-blocking check
if (f.isDone()) {
    System.out.println("Already done: " + f.get());
}

// get() with timeout — don't wait forever
try {
    String result = f.get(2, TimeUnit.SECONDS);
} catch (TimeoutException e) {
    System.out.println("Task took too long!");
    f.cancel(true);  // interrupt the task
} catch (ExecutionException e) {
    // Task threw an exception — wrapped in ExecutionException
    System.out.println("Task failed: " + e.getCause().getMessage());
}

invokeAll — Submit Many, Get All Results

InvokeAll.javaBatch Processing
List<Callable<Integer>> tasks = List.of(
    () -> computeExpensiveResult(1),
    () -> computeExpensiveResult(2),
    () -> computeExpensiveResult(3)
);

// Submit all, wait for all to complete
List<Future<Integer>> futures = pool.invokeAll(tasks);

// Collect results
for (Future<Integer> future : futures) {
    System.out.println("Result: " + future.get());
}
🧠 Quick Check
What happens when you call future.get() before the task has finished?
Section 8 of 10
Core Java · Concurrency · Section 9 of 10

Common Pitfalls

Concurrency bugs are subtle and dangerous. Here are the most important ones to recognise and avoid.

Pitfall 1: Not Shutting Down the Executor

NoShutdown.java❌ JVM Never Exits
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(() -> System.out.println("done"));
// ❌ No shutdown() — pool threads are non-daemon, JVM hangs forever
WithShutdown.java✓ Always Shut Down
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
    pool.submit(() -> System.out.println("done"));
} finally {
    pool.shutdown();  // always in finally block
}

Pitfall 2: Deadlock

Thread A holds Lock 1 and waits for Lock 2. Thread B holds Lock 2 and waits for Lock 1. Both wait forever.

Deadlock.java⚡ Deadlock
Object lock1 = new Object();
Object lock2 = new Object();

Thread t1 = new Thread(() -> {
    synchronized(lock1) {
        Thread.sleep(50);
        synchronized(lock2) { /* ... */ }  // waits for lock2
    }
});

Thread t2 = new Thread(() -> {
    synchronized(lock2) {
        Thread.sleep(50);
        synchronized(lock1) { /* ... */ }  // waits for lock1 ← DEADLOCK
    }
});
// Fix: always acquire locks in the same order in every thread

Pitfall 3: Calling get() Without a Timeout

InfiniteWait.java❌ Blocks Forever if Task Hangs
Future<String> f = pool.submit(() -> riskyNetworkCall());
String result = f.get();  // ❌ hangs if network never responds
TimedGet.java✓ Always Use a Timeout
String result = f.get(5, TimeUnit.SECONDS); // ✓ fail fast after 5s

Pitfall 4: Sharing Mutable State Without Synchronisation

SharedState.java✓ Three Safe Approaches
// Option 1: Use thread-safe collections List<String> safe = new CopyOnWriteArrayList<>(); Map<String,Integer> safeMap = new ConcurrentHashMap<>(); // Option 2: Use atomic variables AtomicInteger counter = new AtomicInteger(0); AtomicLong total = new AtomicLong(0L); // Option 3: Don't share — give each thread its own data ThreadLocal<SimpleDateFormat> fmt = ThreadLocal.withInitial(SimpleDateFormat::new);
⚠ Concurrency Checklist
□  Always call shutdown() on executors — use try/finally
□  Always use a timeout on future.get()
□  Never share mutable state without synchronisation
□  Acquire locks in a consistent order to avoid deadlock
□  Prefer ConcurrentHashMap / AtomicInteger over raw synchronized where possible
□  Don't call thread.run() — always thread.start()
Section 9 of 10
Core Java · Concurrency · Section 10 of 10

Quiz & Coding Challenge

Final Quiz

Question 1 of 3
You have 1000 tasks that each make a network request (I/O-bound). What pool size is most appropriate?
Question 2 of 3
What is a race condition?
Question 3 of 3
What is the difference between Runnable and Callable?

Coding Challenges

🏋 Challenge 1 — Beginner
Parallel Sum
Split a large list of numbers into chunks and sum each chunk in parallel using Callable and Future.
01Create a list of 1,000,000 integers (1 to 1,000,000)
02Split into 4 equal chunks
03Submit each chunk as a Callable<Long> that sums its slice
04Collect all 4 Futures and add their results
05Verify: total should be 500,000,500,000
ParallelSum.javaSolution
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;

public class ParallelSum {
    public static void main(String[] args) throws Exception {
        List<Long> numbers = LongStream.rangeClosed(1, 1_000_000)
            .boxed().collect(Collectors.toList());

        int chunks = 4;
        int size   = numbers.size() / chunks;

        ExecutorService pool = Executors.newFixedThreadPool(chunks);
        List<Future<Long>> futures = new ArrayList<>();

        for (int i = 0; i < chunks; i++) {
            final int from = i * size;
            final int to   = (i == chunks - 1) ? numbers.size() : from + size;
            List<Long> chunk = numbers.subList(from, to);
            futures.add(pool.submit(() ->
                chunk.stream().mapToLong(Long::longValue).sum()
            ));
        }

        long total = 0;
        for (Future<Long> f : futures) total += f.get();

        pool.shutdown();
        System.out.println("Total: " + total);  // 500000500000
    }
}
🏋🏋 Challenge 2 — Intermediate
Thread-Safe Word Counter
Count word frequencies from a large list of sentences using multiple threads safely.
01Start with a List<String> of at least 10 sentences
02Split sentences across 4 threads, each counting words in its slice
03Merge results using ConcurrentHashMap or by collecting Futures into partial maps
04Print the top 5 most frequent words
WordCounter.javaSolution
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;

public class WordCounter {
    public static void main(String[] args) throws Exception {
        List<String> sentences = List.of(
            "the quick brown fox", "the fox jumped over",
            "a quick brown dog",   "the dog and the fox",
            "java is the language", "the quick java fox",
            "over the brown hill",  "a dog over a fox",
            "java and the dog",     "quick brown java"
        );

        int chunks = 4;
        int size   = sentences.size() / chunks;
        ExecutorService pool = Executors.newFixedThreadPool(chunks);
        List<Future<Map<String,Long>>> futures = new ArrayList<>();

        for (int i = 0; i < chunks; i++) {
            final List<String> slice = sentences.subList(
                i * size, Math.min((i+1)*size, sentences.size()));
            futures.add(pool.submit(() ->
                slice.stream()
                    .flatMap(s -> Arrays.stream(s.split(" ")))
                    .collect(Collectors.groupingBy(w -> w, Collectors.counting()))
            ));
        }

        Map<String,Long> total = new HashMap<>();
        for (var f : futures)
            f.get().forEach((k,v) -> total.merge(k, v, Long::sum));

        total.entrySet().stream()
            .sorted(Map.Entry.<String,Long>comparingByValue().reversed())
            .limit(5)
            .forEach(e -> System.out.println(e.getKey() + ": " + e.getValue()));

        pool.shutdown();
    }
}
✓ What You've Learned
Concurrency vs parallelism · Creating threads three ways (extend, Runnable, lambda) · start() vs run() · Thread lifecycle and states · Race conditions and why they happen · synchronized methods/blocks and AtomicInteger · Why ExecutorService beats raw threads · Fixed, cached, scheduled, and single-thread pools · Callable + Future for result-returning tasks · invokeAll for batch work · Deadlock, missing shutdown, infinite get() — and how to avoid them.
Section 10 of 10
Roadmap