Concurrency & Threads - Doing Many Things at Once, Safely
Every program you've written so far has done one thing at a time: line runs, finishes, next line runs. Concurrency breaks that: you ask the machine to run several paths of execution simultaneously, so a server can handle a thousand requests at once, or an app can download a file without freezing the UI.
The plain framing: starting threads is easy; sharing data without corrupting it is the hard part. A thread alone is tame. Two threads touching the same variable with no coordination is where careers' worth of subtle, intermittent, impossible-to-reproduce bugs come from. We'll make threads, learn the danger they create, then spend most of our time on the tools that tame it.
The arc: raw Threads (the bricks), race conditions (the disease), synchronized and the memory model (the cure), java.util.concurrent (the tools to actually reach for), and virtual threads (the modern leap that makes blocking code scale).
Threads - an independent path of execution
📝 A thread is an independent path of execution within your program. Your program already has one - the "main" thread running main(). A new thread is a second worker running its own code at the same time, sharing the first's memory. The OS juggles them across CPU cores (and, when threads outnumber cores, by rapidly switching between them).
You create one by giving it work: a Runnable - an object with a single run() method - written cleanest as a lambda (Phase 11):
$ java Main.java
main thread: main
worker thread: Thread-0
What just happened: new Thread(() -> ...) wrapped your lambda as the thread's job but did not run it yet. worker.start() asked the OS for a new thread and ran the lambda there, concurrently with main. The two printlns race - main printed first here, but the order can flip next run, since nobody coordinated them. worker.join() blocks main until the worker finishes, so the program doesn't exit out from under it.
⚠️ The bug that bites everyone once: calling run() instead of start(). They look interchangeable. They aren't.
Thread worker ;
worker.; // WRONG - runs the lambda on the CURRENT thread, no new thread at all
worker.; // RIGHT - spawns a new thread
$ java Main.java
on: main
on: Thread-0
What just happened: run() is a normal method call - it executes the lambda on whatever thread called it (main). No concurrency happens. Only start() asks the OS for a fresh thread. If "concurrent" code mysteriously runs perfectly in order, single-file, check whether someone called run() - the silent no-op of Java threading.
The danger - shared mutable state & race conditions
The moment two threads read and write the same data, you have a potential race condition - the central villain of all concurrency.
📝 A race condition is a bug where the correctness of your program depends on the unpredictable timing of threads. The result changes depending on which thread gets there first - the program might work a million times and fail on the million-and-first, depending on how the OS scheduled things that instant.
The classic demonstration: two threads incrementing the same counter. You'd expect 200,000; you won't get it.
$ java Main.java
expected 200000, got: 143281
What just happened: counter++ looks like one step, but it's three: read, add one, write back. Two threads can both read 5, both compute 6, both write 6 - two increments, one gain. That lost update, repeated tens of thousands of times, is why we landed at 143281 instead of 200000. ⚠️ Run it yourself and you'll get a different wrong number each time - that non-determinism is what makes race conditions so maddening to track down.
Threads are cheap. Sharing mutable state correctly is the expensive, careful work, and everything below exists to make it safe.
synchronized & the memory model
The fix is mutual exclusion: guarantee only one thread at a time can run the read-add-write sequence. Java's built-in tool is the synchronized keyword.
📝 synchronized marks a block (or method) as a critical section guarded by a lock. Every Java object has an invisible lock (a "monitor"). A thread entering a synchronized block must acquire that lock; if another holds it, the newcomer waits. Only one thread runs the protected code at a time, so the read-add-write can't be interrupted halfway.
$ java Main.java
expected 200000, got: 200000
What just happened: Each thread had to grab lock before touching counter, so the read-add-write sequence ran start-to-finish without the other sneaking in - every one of the 200,000 increments landed. The trade-off is real: threads now take turns through that block instead of running in parallel, so heavily-contended locks cost speed. The art is locking the smallest section that keeps you correct.
💡 The memory model, in one paragraph. A second, sneakier danger beyond lost updates: visibility. The JVM and CPU can cache variables in registers and reorder instructions for speed, so a write one thread makes to a shared variable might never become visible to another - it could spin forever reading a stale cached value. The Java Memory Model (JMM) defines when one thread's writes are guaranteed visible to another, via happens-before: releasing a lock happens-before another thread acquiring that same lock, so everything written inside a synchronized block is visible to the next thread entering it. That's why synchronized fixes both atomicity and visibility at once.
📝 volatile handles visibility alone. Marking a field volatile guarantees every read sees the most recent write from any thread - no stale caches, no reordering past it. But it does not give atomicity: a volatile counter still loses updates under counter++, since read-add-write is still three steps. ⚠️ Rule of thumb: volatile for a simple flag one thread sets and another reads (like boolean running); synchronized (or atomics below) the moment a thread needs to read-then-write shared state.
Higher-level concurrency - prefer it
The most important practical advice in this phase: most of the time, you should not be writing new Thread(...) or synchronized by hand at all. Hand-managing threads is error-prone and wasteful - every new Thread is a real OS thread costing memory and startup time, and writing your own locking is how the subtle bugs above creep in. java.util.concurrent gives you battle-tested tools that handle all of it.
💡 The default move: reach for java.util.concurrent first. An ExecutorService for running tasks, AtomicInteger for lock-free counters, ConcurrentHashMap for shared maps - written and tested by experts. Your hand-rolled version will be slower and buggier.
Thread pools via ExecutorService. Instead of a thread per task (exhausting the machine at a million tasks), a thread pool keeps a fixed crew of reusable threads pulling tasks from a queue. You submit work; the pool decides which thread runs it.
Callable and Future. A Runnable returns nothing; a Callable<T> returns a value. Since the result isn't ready immediately, submit hands you a Future<T>, an IOU you cash in later with .get() (which blocks until the answer is ready).
;
$ java Main.java
doing other work while it computes...
the answer: 42
What just happened: Executors.newFixedThreadPool(3) made a pool of three reusable threads. pool.submit(callable) queued the work and immediately returned a Future - main kept going and printed its message before the result existed. future.get() then blocked until the pool thread finished and handed back 42. ⚠️ Always shutdown() a pool when done; its threads are non-daemon by default and will keep the JVM alive otherwise.
CompletableFuture for composing async work. Future.get() is blocking and clumsy when chaining steps ("fetch data, then transform it, then save it"). CompletableFuture describes that pipeline declaratively and runs it without blocking:
;
$ java Main.java
[DATA]
What just happened: supplyAsync ran the first step on a background thread and returned a CompletableFuture immediately. thenApply attached the next step to run automatically when the previous one completed - no manual get(), no blocking between stages; the runtime wired up the handoffs. Modern Java composes async work this way: stages connected by thenApply, thenCompose, thenCombine, each firing when its input is ready.
💡 Lock-free counters with AtomicInteger. Remember the broken counter? AtomicInteger solves it without any lock you write: count.incrementAndGet() is a single atomic operation, implemented with a CPU compare-and-swap instruction - simpler and faster than synchronized for a shared counter. Same spirit: ConcurrentHashMap for a map many threads hammer at once. Reach for these before raw locks.
Virtual threads - the modern leap
📝 Virtual threads (Project Loom, standard since JDK 21) are extremely lightweight threads managed by the JVM rather than the OS. A traditional ("platform") thread maps one-to-one onto a heavy OS thread - realistically a few thousand before memory runs out. A virtual thread is a cheap Java object; the JVM parks it off its underlying OS thread whenever it blocks (on I/O, a sleep, a lock) and reuses that OS thread elsewhere. You can run millions of them.
;
$ java Main.java
ran a million blocking tasks
What just happened: newVirtualThreadPerTaskExecutor() gave every one of a million tasks its own virtual thread - something that would instantly exhaust memory with platform threads. Each task blocked on Thread.sleep, but a blocked virtual thread costs almost nothing: the JVM unmounted it from its OS thread, freeing that thread to serve others.
Why this matters for servers. The old way to scale to many connections was reactive/async code - non-blocking callbacks, fast but genuinely hard to read and debug. Virtual threads let you write plain, straight-line blocking code ("read the request, query the DB, write the response") that scales to enormous concurrency anyway, because blocking is now cheap. Simple code, huge throughput.
⚠️ Virtual threads do not repeal the laws of concurrency. They're cheaper threads, not safer ones. Every race condition, every visibility bug, every deadlock - two threads each holding a lock the other needs, both stuck forever - is just as possible with a million virtual threads as with two platform threads. The new tools change the cost of concurrency, never the correctness rules. Shared mutable state still needs synchronized, atomics, or java.util.concurrent - that hard part never goes away, it just gets better tools.
Recap
- A thread is an independent path of execution. Wrap work in a
Runnable/lambda and callstart()(notrun()-run()executes on the current thread and spawns nothing). - A race condition is a bug whose result depends on thread timing.
counter++is read-add-write, not atomic, so concurrent increments lose updates - the heart of why concurrency is hard. synchronizedgives mutual exclusion (one thread at a time) and, via the Java Memory Model's happens-before rules, visibility.volatilegives visibility only - good for a flag, useless for read-then-write.- Prefer
java.util.concurrent. AnExecutorServicethread pool instead of raw threads,Callable/Futurefor results,CompletableFuturefor async pipelines, andAtomicInteger/ConcurrentHashMapinstead of hand-rolled locks. - Virtual threads (JDK 21+) are JVM-managed, near-free threads that let simple blocking code scale to millions - but ⚠️ they don't make races or deadlocks go away. Better tools, same rules.
You can now make programs do many things at once and keep their shared data intact - the skill that separates code that works on your laptop from code that survives production. Next: under the hood of the machine running all this - how the JVM manages memory, garbage collection, and just-in-time compilation.
Quick check
Test yourself on the ideas that make concurrency safe rather than just fast:
[
{
"q": "What's the difference between calling `thread.start()` and `thread.run()`?",
"choices": [
"`start()` spawns a new thread and runs the work there; `run()` is a plain method call that runs the work on the current thread - no new thread at all",
"They're identical; `start()` is just the newer name for `run()`",
"`run()` spawns the thread and `start()` waits for it to finish",
"`start()` runs the work twice for safety"
],
"answer": 0,
"explain": "Only `start()` asks the OS for a new thread. `run()` is an ordinary method call that executes the Runnable on whoever called it - a common silent bug where 'concurrent' code mysteriously runs single-file."
},
{
"q": "Two threads each run `counter++` 100,000 times on a shared int, and the total comes out less than 200,000. Why?",
"choices": [
"`counter++` is read-add-write - three steps - so two threads can read the same value and one increment gets lost; it's a race condition",
"Java caps integer increments at 100,000 per variable",
"The second thread silently fails to start",
"Integer overflow wrapped the value around to a smaller number"
],
"answer": 0,
"explain": "`counter++` isn't atomic. Two threads can both read 5, both compute 6, both write 6 - two increments collapse into one. Repeated across the run, those lost updates produce a total below 200,000, different each run. Fix it with `synchronized` or `AtomicInteger`."
},
{
"q": "Do virtual threads (JDK 21+) eliminate race conditions and deadlocks?",
"choices": [
"No - they make threads far cheaper, so blocking code scales to millions, but every concurrency hazard (races, visibility bugs, deadlocks) still applies and shared state still needs synchronization",
"Yes - virtual threads are immune to race conditions by design",
"Yes - the JVM automatically synchronizes all shared state for virtual threads",
"Only deadlocks are eliminated; race conditions remain"
],
"answer": 0,
"explain": "Virtual threads change the cost of concurrency, not its correctness rules. A million virtual threads racing on a shared counter corrupt it exactly like two platform threads would. You still need synchronized, atomics, or java.util.concurrent."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. What's the difference between calling `thread.start()` and `thread.run()`?
2. Two threads each run `counter++` 100,000 times on a shared int, and the total comes out less than 200,000. Why?
3. Do virtual threads (JDK 21+) eliminate race conditions and deadlocks?