How Hirenix teaches
One chapter. 90 minutes.
Interview-ready.
Every concept starts with a real-world problem — the kind that actually shows up in production code. Nothing to cram; it just clicks. Every question comes with a model answer: exactly what to say in the room, and why. Then an AI mock interview on the same chapter.
- 📖Concept in 5 minutesNo jargon — straight to the point
- 🛠️Real-world problemThe kind production code throws at you
- 💬Model answerExactly what to say in the room
- 🧠FlashcardsRevise in 10 minutes
- 🤖AI mock interviewIt asks follow-ups too
- 📊Weak topicsSee exactly where you're stuck

The difference isn’t the content — it’s the filter. Only what’s actually used in production and actually asked in interviews. Textbook topics the industry never touches don’t make the cut.
What you’ll learn
- ●Why Java programs need concurrency
- ●Creating threads: Thread vs Runnable
- Thread lifecycle, sleep, join, interruptFree account
- synchronized keyword and explicit LocksFree account
- volatile and the Java Memory ModelFree account
- wait/notify and the producer-consumer patternFree account
- Race conditions, deadlock, livelock, starvationFree account
- ExecutorService and thread poolsFree account
- Callable and FutureFree account
- CompletableFuture: async chainingFree account
- ForkJoinPool and the common pool behind parallelStreamFree account
- Concurrent collections: ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueueFree account
- Atomic classes and compare-and-swapFree account
- CountDownLatch, CyclicBarrier, SemaphoreFree account
- RecapFree account
- ●Project: Multi-threaded Word Counter
- Project: Producer-Consumer Task QueueFree account
- Project: Parallel File ProcessorFree account
Why Java programs need concurrency
A restaurant with one chef can only do one thing at a time - chop, then stir, then plate. Add a second chef and the chopping and the stirring can happen at once. Concurrency is that second chef: a program doing more than one thing in overlapping time. On a machine with only one CPU core, that overlap is an illusion the OS creates by rapidly switching between tasks. On a machine with several cores, some of that overlap is real and simultaneous - which is the specific case called parallelism.
Process vs thread. A process is an independent running program with its own memory space - your browser and your IDE are two processes, and one crashing does not take the other down. A thread is a lightweight unit of execution inside a process; every process has at least one (the main thread), and threads in the same process share the same heap memory. That sharing is the whole reason this chapter exists: shared memory is what makes two threads fast to coordinate, and it is also exactly what makes them dangerous to coordinate badly - Ch7 onward is entirely about controlling that shared access safely.
Multitasking, multithreading, multiprocessing. Multitasking is the OS running several processes at once (your OS juggling the browser and the IDE). Multithreading is one process running several threads. Multiprocessing is a machine with more than one CPU actually executing more than one thing at the same instant. A single-core machine can multitask and multithread but cannot truly multiprocess - it fakes simultaneity by switching fast enough that a human cannot tell.
Concurrency vs parallelism - the distinction that actually gets asked. Concurrency is about managing more than one task in the same time window; parallelism is about executing more than one task at literally the same instant. A single core juggling two threads is concurrent but not parallel. A quad-core machine genuinely running four threads at once is both. Every parallel program is concurrent, but not every concurrent program is parallel - Node.js's single-threaded event loop is a famous example of concurrency without parallelism.
Why bother, in Java specifically
- CPU-bound work gets faster on multi-core hardware. Split independent chunks of work across threads and the OS scheduler can run them on separate cores literally at once.
- I/O-bound work stops blocking everything else. A thread reading a file, calling a slow API, or waiting on a database should not freeze a UI or stall unrelated requests - a server handling one request per thread can serve others while one waits.
- Responsiveness. A GUI or a web server that never does anything concurrently would feel frozen the instant any single operation was slow.
The cost, honestly stated up front, is the entire rest of this chapter: threads sharing memory can corrupt it, race for it, or deadlock over it. Every topic from here on is a tool for getting concurrency's speed without that cost.
When to reach for concurrency: I/O-bound work that would otherwise block everything else (a slow API call, a file read), or CPU-bound work that can be split into independent chunks across multiple cores.
When NOT to reach for it / the trade-off: a handful of trivial, fast, independent operations - thread creation and coordination cost real overhead, and for tiny workloads a plain sequential loop is both simpler and faster. The real cost is everything the rest of this chapter teaches how to manage: shared memory that can corrupt, race, or deadlock if accessed carelessly.
Standard definition: Concurrency is a program's ability to manage multiple tasks that overlap in time; parallelism is the stronger, hardware-backed case where multiple tasks genuinely execute at the same instant. A process is an independently memory-isolated running program; a thread is a lightweight, independently schedulable path of execution that shares its process's heap with every other thread in that process.
public class Demo {
static long busyWork(long iterations) {
long sum = 0;
for (long i = 0; i < iterations; i++) sum += i % 7;
return sum;
}
public static void main(String[] args) throws InterruptedException {
long n = 400_000_000L;
long t0 = System.nanoTime();
busyWork(n);
busyWork(n);
long singleThreadMs = (System.nanoTime() - t0) / 1_000_000;
System.out.println("A single-threaded, two halves sequentially took ~" + singleThreadMs + " ms");
long t1 = System.nanoTime();
Thread worker1 = new Thread(() -> busyWork(n));
Thread worker2 = new Thread(() -> busyWork(n));
worker1.start();
worker2.start();
worker1.join();
worker2.join();
long twoThreadMs = (System.nanoTime() - t1) / 1_000_000;
System.out.println("B same work split across 2 threads took ~" + twoThreadMs + " ms");
System.out.println("C available processors on this machine = " + Runtime.getRuntime().availableProcessors());
System.out.println("D this JVM process id = " + ProcessHandle.current().pid());
}
}Creating threads: Thread vs Runnable
Java gives you two ways to describe a thread's job. Extend Thread and override run() - you now have a new kind of Thread that knows how to do one specific thing. Implement Runnable and hand it to a plain Thread - you have described the job as a separable piece of behaviour, and any Thread (or, later, any ExecutorService) can carry it out.
Prefer Runnable. Java has no multiple inheritance, so extending Thread burns your one extends slot - a class that already extends something else cannot also extend Thread. Runnable costs nothing: your class stays free to extend whatever it needs, and the same Runnable can be reused across many threads or handed straight to a thread pool, which is the only way real production code creates threads (executorservice-and-thread-pools).
start() vs run() - the single most-asked question in this chapter
Calling run() directly does not create a new thread - it is a plain method call, executed synchronously on whichever thread called it. Calling start() asks the JVM to allocate a new OS-backed thread of execution, and that new thread is the one that eventually calls run(). The demo below proves it: t3.run() prints the main thread's name, not a new one, while t1.start() and t2.start() print distinct thread names.
start() can only be called once per Thread object. Calling it a second time throws IllegalThreadStateException, because a Thread object's lifecycle only moves forward (thread-lifecycle covers the full state machine) - there is no way to rewind a TERMINATED thread back to NEW. If you need to redo the work, create a new Thread object.
Default thread naming
A Thread created without an explicit name gets Thread-0, Thread-1, Thread-2... in creation order, process-wide - this is a JVM-managed counter, not something scoped per parent thread. Giving threads explicit names (new Thread(r, "worker-2")) is worth doing for anything beyond a toy program, because a thread dump or a stack trace with worker-2 in it is instantly more useful than one with Thread-7.
When to reach for which: Runnable (or, in modern code, a lambda implementing it) for essentially everything - it composes with executors and does not spend your inheritance. Extend Thread only in the rare case where you are genuinely building a specialised kind of thread that needs extra state or overridden thread-level behaviour, not just a job to run.
Trade-off: a raw new Thread(...) per task, either way, does not scale - each one costs real OS resources and nobody manages its lifecycle. This topic's two constructors are the vocabulary; executorservice-and-thread-pools is where that cost gets managed properly for anything beyond a handful of threads.
Standard definition: A thread's work can be supplied either by subclassing Thread and overriding run(), or by implementing the Runnable functional interface and passing an instance to a Thread constructor; the latter is idiomatic because it avoids consuming Java's single-inheritance slot and is reusable across execution contexts including thread pools. start() schedules a new OS thread to invoke run() asynchronously; calling run() directly executes it synchronously on the calling thread and creates no new thread at all.
public class Demo {
static class GreetThread extends Thread {
public void run() { System.out.println("A extends Thread: " + Thread.currentThread().getName()); }
}
public static void main(String[] args) throws InterruptedException {
GreetThread t1 = new GreetThread();
t1.start();
t1.join();
Runnable r = () -> System.out.println("B implements Runnable: " + Thread.currentThread().getName());
Thread t2 = new Thread(r, "worker-2");
t2.start();
t2.join();
Thread t3 = new Thread(r, "worker-3");
t3.run();
System.out.println("C after run() directly, thread name still = " + Thread.currentThread().getName());
Thread t4 = new Thread(r);
System.out.println("D default name pattern = " + t4.getName());
}
}Project: Multi-threaded Word Counter
This project chains together three topics into one working program: creating-threads (a Runnable job per chunk), callable-and-future (ExecutorService.submit(Callable<...>) returning a Future per chunk's partial result), and concurrent-collections (ConcurrentHashMap to merge partial results safely).
The shape: split a body of text into independent chunks, submit one Callable<Map<String,Integer>> per chunk to a fixed thread pool (each Callable counts words in its own local HashMap, so there is no shared mutable state inside a single task - only when merging), collect every chunk's Future, and merge each chunk's result into one ConcurrentHashMap using merge(key, value, Integer::sum), which atomically adds counts for keys any two chunks share.
Why local HashMap per task, then merge into ConcurrentHashMap, rather than every task writing directly into one shared ConcurrentHashMap from the start? Both are correct, but per-task local maps avoid any contention at all during the counting phase - each thread works entirely independently - and only pay the synchronization cost once, during the much shorter merge step. Writing directly into one shared map from every thread throughout the whole counting phase would work (ConcurrentHashMap is safe for it) but contends on the same map the entire time instead of only briefly at the end.
Running it on three overlapping sentences about Java threads and the JVM: 'java' appears 3 times, 'jvm' 3 times, 'threads' 3 times, and the counts merge correctly across chunk boundaries even though 'java' appears in every single chunk - proving the merge step, not just the per-chunk counting, is doing real work.
Standard definition: A multithreaded word counter demonstrates the map-reduce shape common to real concurrent data processing: partition input into independent chunks, process each chunk concurrently via ExecutorService/Callable/Future with zero shared state during processing, then reduce (merge) each chunk's independent result into one final structure using a thread-safe collection or explicit synchronization only at the merge boundary.
import java.util.*;
import java.util.concurrent.*;
public class Demo {
static Map<String, Integer> countWords(String chunk) {
Map<String, Integer> local = new HashMap<>();
for (String w : chunk.toLowerCase().split("[^a-z0-9]+")) {
if (w.isBlank()) continue;
local.merge(w, 1, Integer::sum);
}
return local;
}
public static void main(String[] args) throws Exception {
List<String> chunks = List.of(
"Java threads run concurrently on the JVM",
"The JVM schedules Java threads across cores",
"Concurrency in Java uses threads and the JVM scheduler");
ExecutorService pool = Executors.newFixedThreadPool(3);
List<Future<Map<String, Integer>>> futures = new ArrayList<>();
for (String chunk : chunks) futures.add(pool.submit(() -> countWords(chunk)));
ConcurrentHashMap<String, Integer> total = new ConcurrentHashMap<>();
for (Future<Map<String, Integer>> f : futures) {
for (var entry : f.get().entrySet()) {
total.merge(entry.getKey(), entry.getValue(), Integer::sum);
}
}
pool.shutdown();
System.out.println("A total distinct words = " + total.size());
System.out.println("B count of 'java' = " + total.get("java"));
System.out.println("C count of 'jvm' = " + total.get("jvm"));
System.out.println("D count of 'threads' = " + total.get("threads"));
int grandTotal = total.values().stream().mapToInt(Integer::intValue).sum();
System.out.println("E sum of all word counts = " + grandTotal);
}
}Concurrencyinterview questions & answers
10 sample questions below — 205+ in the full bank inside.
What does thenApply() do, and how do chained thenApply calls behave?
thenApply(fn) transforms the result of the upstream stage once it arrives, returning a new CompletableFuture for the transformed value - chains compose left to right, similar to Stream operations, except each step runs only after the previous one has actually completed asynchronously. Verified: supplyAsync(() -> 10).thenApply(n -> n*2).thenApply(n -> n+1) produced 21.
In simple terms: The Stream-pipeline resemblance is useful for building intuition, but it is worth stating the real difference explicitly: a Stream pipeline is lazy and synchronous when it runs; a CompletableFuture chain is genuinely asynchronous at every stage.
When would you use thenAccept instead of thenApply?
thenApply transforms the result and returns a new CompletableFuture carrying that transformed value, so it should be used when there is more chaining to do afterward. thenAccept is the terminal, side-effecting version - it consumes the result (e.g. printing it, saving it) and returns CompletableFuture<Void>, used when there is nothing left to compute after this step.
In simple terms: This is a straightforward but essential API-choice question that checks whether the candidate can pick the right method based on whether a value needs to flow forward.
What is the difference between Runnable and Callable?
Runnable.run() returns void and cannot throw a checked exception. Callable<V>.call() returns a value of type V and is declared to throw any checked exception. Submitting a Callable to an ExecutorService returns a Future<V> representing the eventual result; submitting a Runnable returns a Future<?> whose result is always null on success.
In simple terms: The checked-exception capability is the part people forget: Callable exists not just for return values but specifically so tasks whose natural implementation throws checked exceptions (file or network I/O) don't need extra wrapping to compile.
Does isDone() returning true mean the task completed successfully?
No - isDone() is true whether the task completed normally, threw an exception, or was cancelled. It only tells you the task has reached SOME terminal state, not which one; you still need get() (to see the result or catch ExecutionException) or isCancelled() to know exactly which terminal state it reached.
In simple terms: This closes a common misreading of isDone() as a success indicator, which it explicitly is not.
If a program calls t.run() instead of t.start(), how many threads exist by the time run() returns?
Still just the one thread that made the call - calling run() directly is an ordinary synchronous method call, executed on the caller's own thread, with no new thread ever created. Thread.currentThread().getName() inside run() would report the caller's name (e.g. 'main'), not a new worker thread's name.
In simple terms: This restates the chapter's single most-asked distinction in a slightly different framing (thread count) to make sure the mental model, not just the vocabulary, is understood.
Why is an AtomicLong a natural fit for generating unique IDs across multiple threads?
An ID generator needs a simple compound operation (read the current counter, produce a unique next value) performed correctly under concurrent access from many threads. AtomicLong.incrementAndGet() does exactly this atomically without any locking overhead - each call returns a genuinely unique, never-repeated value even when called simultaneously from many threads.
In simple terms: This is a practical, real-world use-case question that grounds atomic classes in a familiar application beyond a bare counter demo.
Why is ExecutorService preferred over creating a raw new Thread() per task?
Thread creation is not free, and an unbounded number of raw threads can exhaust memory and scheduling overhead with no lifecycle management. ExecutorService runs submitted tasks on a managed, reusable pool of worker threads, amortizing creation cost across many tasks and giving control over pool size, queueing behavior, and shutdown.
In simple terms: The reuse point is the key detail: a fixed pool of N threads can process an unlimited stream of tasks without ever paying thread-creation cost per task, unlike spawning a new Thread for every single unit of work.
What is the difference between getAndIncrement() and incrementAndGet()?
Both atomically increment the value; they differ only in what they return. getAndIncrement() returns the value BEFORE incrementing - verified: starting from 0, it returned 0 while the field became 1. incrementAndGet() returns the value AFTER incrementing. Neither is more correct - choose based on whether the caller needs the old or new value.
In simple terms: This naming pattern (getAndX returns old, XAndGet returns new) is consistent across the whole Atomic* API, including getAndSet/updateAndGet, so learning it once here transfers directly.
Besides single-value atomics, does java.util.concurrent.atomic offer anything for a whole array?
Yes - AtomicIntegerArray, AtomicLongArray, and AtomicReferenceArray provide atomic operations on individual elements of an array, so a specific index can be updated with CAS-based atomicity without needing a separate Atomic* wrapper object per element and without locking the whole array.
In simple terms: This is a breadth check beyond the four classes the chapter's demo focuses on, testing whether the candidate knows the package has more surface than just the single-value wrappers.
When would a plain Future/Callable be a better choice than CompletableFuture?
For a single simple background task with no need to chain follow-up work, combine it with other independent async results, or handle failure without a blocking try/catch - a plain Future/Callable is simpler with less API surface to reason about, and reaching for CompletableFuture's composition machinery there is unnecessary complexity.
In simple terms: This is the chapter's stated 'when NOT to' guidance, checking that the candidate does not treat CompletableFuture as an unconditional upgrade over Future in every case.
195+ more Concurrency questions inside
Create a free account to read the full question bank, learn every topic, and practise with an AI mock interview.
Unlock all questions — freeReady to practise Concurrency?
Unlock every topic free, then face an AI interviewer that asks follow-ups and grades your answers.