Concurrency Basics
A Thread is an independent path of execution within a process, sharing the same heap memory as every other thread in that JVM. Two ways to define what a thread runs:
🎯 What you'll learn
- Threads: the Unit of Concurrent Execution
- Race Conditions: Why Shared Mutable State Is the Problem
synchronized: Mutual Exclusion- Visibility: Why
volatileExists ExecutorService: Thread Pools Instead of ManualThreadManagement
1Threads: the Unit of Concurrent Execution
A Thread is an independent path of execution within a process, sharing the same heap memory as every other thread in that JVM. Two ways to define what a thread runs:
// implementing Runnable — preferred, doesn't burn your one shot at extending a class
Runnable task = () -> System.out.println("running on: " + Thread.currentThread().getName());
Thread t = new Thread(task);
t.start(); // schedules the thread to run — NOT the same as calling task.run() directly
t.join(); // blocks the CALLING thread until t finishes
// extending Thread directly — works, but couples your class to being a Thread
class Worker extends Thread {
public void run() { System.out.println("worker running"); }
}
new Worker().start();Calling .run() directly executes the code synchronously on the current thread — no new thread is created. Only .start() actually schedules a new OS-backed thread.
2Race Conditions: Why Shared Mutable State Is the Problem
A race condition happens when multiple threads read-modify-write shared state without coordination, and the outcome depends on unpredictable timing.
class Counter {
private int count = 0;
void increment() { count++; } // NOT atomic — read, add 1, write back
int get() { return count; }
}count++ is really three separate steps (read count, compute count + 1, write it back). If two threads interleave those steps, one increment can be lost entirely — thread A reads 5, thread B reads 5 before A writes back, both compute 6, both write 6, and one increment vanishes. Running this with enough threads and iterations reliably produces a final count less than the true number of increments performed.
3synchronized: Mutual Exclusion
synchronized ensures only one thread at a time can execute a given block/method for a given lock object, fixing the race above:
class Counter {
private int count = 0;
synchronized void increment() { count++; } // lock is `this` — only one thread inside at a time
synchronized int get() { return count; }
}class Counter {
private final Object lock = new Object();
private int count = 0;
void increment() {
synchronized (lock) { count++; } // synchronized block — lock on an explicit object
}
}A synchronized instance method locks on this; a synchronized static method locks on the Class object; a synchronized (obj) { ... } block locks on whatever object you name. Only one thread can hold a given lock at a time — any other thread calling a method synchronized on the same lock blocks until the lock is released.
4Visibility: Why volatile Exists
Mutual exclusion (synchronized) solves atomicity. A separate problem is visibility: without synchronization, one thread's write to a shared variable is not guaranteed to ever become visible to another thread reading it, because each thread/CPU core may cache the variable rather than re-reading main memory every time.
class Flag {
private boolean running = true; // no volatile: another thread's write to `running`
// might never become visible to a thread spinning here
void stop() { running = false; }
void loopUntilStopped() {
while (running) { /* busy loop */ } // can spin FOREVER on a stale cached `true`
}
}class Flag {
private volatile boolean running = true; // volatile: every read sees the latest write,
// every write is immediately visible to other threads
void stop() { running = false; }
void loopUntilStopped() {
while (running) { /* now guaranteed to observe stop() eventually */ }
}
}volatile guarantees visibility and ordering for reads/writes of that single variable, but not atomicity for compound operations — volatile int count; count++; is still a race, because ++ is still three separate steps even though each individual read and write is now visible. synchronized (or java.util.concurrent.atomic classes) is still required for compound read-modify-write operations.
5ExecutorService: Thread Pools Instead of Manual Thread Management
Creating a raw Thread per task doesn't scale — OS threads are expensive to create and there's no bound on how many pile up. ExecutorService manages a reusable pool of worker threads that tasks are submitted to:
java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newFixedThreadPool(4);
pool.submit(() -> System.out.println("task on a pooled thread"));
pool.shutdown(); // stop accepting new tasks; lets already-submitted tasks finishSubmitted tasks are queued and run by whichever pooled thread becomes free — you never manage Thread objects directly, and the pool size bounds how much concurrent work actually happens regardless of how many tasks are submitted.
6Future: Getting a Result Back From an Async Task
.submit() on an ExecutorService returns a Future<T> representing a result that isn't ready yet:
java.util.concurrent.ExecutorService pool = java.util.concurrent.Executors.newFixedThreadPool(2);
java.util.concurrent.Future<Integer> future = pool.submit(() -> {
Thread.sleep(100);
return 42;
});
System.out.println("submitted, doing other work...");
int result = future.get(); // BLOCKS the calling thread until the task finishes, then returns 42
pool.shutdown();.get() blocks the caller until the task completes (or throws, wrapped as ExecutionException, if the task itself threw); an overload .get(timeout, unit) bounds how long the caller will wait before giving up with a TimeoutException.
Under the HoodHow it works internally▾
Threads are backed by real OS threads, and the JVM memory model is what volatile/synchronized actually control. Each Java Thread maps to a native OS thread (on the HotSpot JVM), each with its own stack but sharing the process's heap. Because modern CPUs have per-core caches and compilers/CPUs are allowed to reorder instructions for performance as long as single-threaded behavior looks unchanged, a plain (non-volatile, non-synchronized) write on one thread has no guaranteed point at which another thread must observe it — the Java Memory Model (JMM) formally defines exactly which operations establish a happens-before relationship between threads. synchronized (both entering and exiting a monitor) and volatile reads/writes are two of the JMM's happens-before-establishing actions: exiting a synchronized block happens-before a subsequent thread entering a block synchronized on the same lock, and a volatile write happens-before any subsequent volatile read of the same variable. Without one of these, the JMM makes literally no visibility guarantee at all — not "probably fine," genuinely undefined, which is why the Flag example without volatile can spin forever on real hardware, not just in theory.
synchronized is implemented via the object's intrinsic monitor, with biased/lightweight/heavyweight locking as a performance ladder. Every Java object has an associated monitor (historically tracked via bits in the object header). Uncontended locking — the extremely common case where one thread repeatedly locks/unlocks an object nobody else is touching — is handled by the JVM with cheap, sometimes lock-free bookkeeping (biased or thin/lightweight locking, implementation-dependent by JVM version); only genuine contention, where a second thread actually tries to acquire a lock another thread holds, triggers an expensive "heavyweight" OS-level mutex/monitor with real thread blocking and a wait queue. This is why synchronized code with little real contention is often much cheaper than people assume, and why contended locks — not the mere presence of synchronized — are the actual performance concern.
⚠️ Common Pitfalls
- Calling
.run()instead of.start()— executes the task synchronously on the current thread; no concurrency happens at all, and bugs from this are easy to miss because the code still "works," just sequentially. - Assuming
volatilemakes compound operations (count++,if (x) x = false;) atomic — it only guarantees visibility/ordering for single reads/writes; compound operations still needsynchronizedor anAtomicInteger/AtomicBoolean. - Forgetting
pool.shutdown()— anExecutorService's threads are non-daemon by default, so a program that creates a thread pool and never shuts it down will hang on exit instead of terminating. - Synchronizing on different lock objects for the same shared state —
synchronized(new Object())inside a method creates a fresh lock every call, providing zero actual mutual exclusion; the lock object must be the same instance across every thread/call site protecting that state. - Calling
future.get()immediately aftersubmit()with no other work in between — defeats the purpose of submitting asynchronously; if the calling thread has nothing else to do until the result arrives, an asyncFuturebought nothing over just calling the task directly.
📋 Quick Reference
new Thread(runnable).start(); // schedule on a new OS-backed thread
t.join(); // block caller until t finishes
synchronized void m() { } // lock on `this`
synchronized (obj) { } // lock on an explicit object
volatile boolean flag; // visibility + ordering, NOT atomicity, for one variable
ExecutorService pool = Executors.newFixedThreadPool(n);
Future<T> f = pool.submit(callable); // async task -> result-in-the-future handle
T result = f.get(); // BLOCKS until the task completes
pool.shutdown(); // stop accepting new tasks
Race condition: unsynchronized read-modify-write on shared state -> lost updates
happens-before: what the JMM actually guarantees; synchronized/volatile establish it🎤 Interview Q&A
Q1How do you run tasks concurrently in Java?core▾
Executors (ExecutorService thread pools), CompletableFuture for async composition, and in Java 21 virtual threads (Executors.newVirtualThreadPerTaskExecutor()), cheap threads for massive I/O concurrency.
- Why not new Thread() everywhere? Unbounded thread creation and no lifecycle management.
Q2What does synchronized do and what's volatile?core▾
synchronized provides mutual exclusion and memory visibility (happens-before) for a monitor; volatile guarantees visibility and ordering of a single variable's reads/writes but not atomicity of compound actions.
- Atomic counter? AtomicInteger/LongAdder.
Q3What is the Java Memory Model's happens-before?senior▾
Rules guaranteeing that one action's effects are visible to another: unlock before subsequent lock, volatile write before read, thread start/join, and executor submission/completion.
- Why does it matter? Without it, threads may see stale values due to caching/reordering.
Q4What are virtual threads and their caveats?core▾
Lightweight threads scheduled by the JVM onto carrier threads, letting blocking code scale like async code. Caveats: pinning in synchronized blocks with blocking I/O (improved in newer JDKs), don't pool them, and ThreadLocal-heavy code uses more memory.
- Good fit? Thread-per-request servers with blocking I/O.
Q5How do you avoid deadlocks in Java?core▾
Acquire locks in a consistent order, use tryLock with timeouts (ReentrantLock), keep critical sections small, prefer concurrent collections and higher-level utilities, and detect with thread dumps (jstack).
- What does a thread dump show? Threads, states and which locks they hold/wait for.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.