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.
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.
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.
Why It Matters in Real Code
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
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
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.
// 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(); }
Thread 2
Thread 0
Thread 4
Thread 1
Thread 3
// Order is non-deterministic — OS decides scheduling
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
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");
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
| State | What It Means | How You Get There |
|---|---|---|
NEW | Thread created but start() not yet called | new Thread(...) |
RUNNABLE | Running on CPU or ready to run (waiting for CPU slice) | thread.start() |
BLOCKED | Waiting to acquire a synchronized lock held by another thread | trying to enter synchronized block |
WAITING | Waiting indefinitely for another thread to signal | wait(), join() with no timeout |
TIMED_WAITING | Waiting for a specific amount of time | sleep(ms), join(ms), wait(ms) |
TERMINATED | run() completed or threw an exception | run() returns or throws |
Key Thread Methods
// 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
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().
thread.run() and thread.start()?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.
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:
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.
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.
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
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:
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:
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 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.
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
// 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
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
Callable tasks can return a value via Future<T>. Retrieve it later with future.get().shutdown(), wait for all tasks with awaitTermination(), submit many at once with invokeAll().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 }
Thread Pools
Java's Executors factory provides ready-made thread pool configurations for different workload patterns.
newFixedThreadPool(n)
Exactly N threads. Tasks queue up if all threads are busy. Best general-purpose choice.
newSingleThreadExecutor()
One thread, sequential execution. Useful when tasks must run in order without concurrency issues.
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)
Runs tasks on a schedule — after a delay or at fixed intervals. Like a built-in cron job.
Fixed Pool — Most Common
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
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();
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.
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
| Runnable | Callable<V> | |
|---|---|---|
| Method | void run() | V call() throws Exception |
| Return value | None (void) | Returns V |
| Checked exceptions | Cannot throw | Can throw Exception |
| Submit via | execute() or submit() | submit() only |
| Result handle | None | Future<V> |
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
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
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()); }
future.get() before the task has finished?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
ExecutorService pool = Executors.newFixedThreadPool(4); pool.submit(() -> System.out.println("done")); // ❌ No shutdown() — pool threads are non-daemon, JVM hangs forever
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.
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
Future<String> f = pool.submit(() -> riskyNetworkCall()); String result = f.get(); // ❌ hangs if network never responds
String result = f.get(5, TimeUnit.SECONDS); // ✓ fail fast after 5s
Pitfall 4: Sharing Mutable State Without Synchronisation
// 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);
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()
Quiz & Coding Challenge
Final Quiz
Runnable and Callable?Coding Challenges
Callable<Long> that sums its sliceimport 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 } }
List<String> of at least 10 sentencesConcurrentHashMap or by collecting Futures into partial mapsimport 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(); } }