Technology  /  Java

โ˜• Java 15 guides ยท updated 2026

From the JVM to streams, generics, and concurrency โ€” the language behind most enterprise backends, taught with modern Java in mind.

Java Concurrency Basics: Threads, Executors, Race Conditions, and Virtual Threads

Concurrency is where Java programs meet reality: real servers handle thousands of simultaneous requests, and real bugs stop being deterministic. A race condition doesnโ€™t fail every time โ€” it fails on Tuesdays, under load, in production, and never in your test suite.

This guide builds the mental model in the order that actually helps: what goes wrong when threads share data, the tools that fix it, the executor machinery youโ€™ll really use (nobody starts raw threads in application code), and virtual threads โ€” the Java 21 change that rewrote the rules for servers.


Threads in Sixty Seconds

A thread is an independent line of execution sharing the processโ€™s memory with every other thread:

Thread t = new Thread(() -> System.out.println("hello from " + Thread.currentThread().getName()));
t.start(); // runs concurrently with main
t.join(); // main waits for it to finish

Two words in that sentence carry all the danger: sharing memory. Separate stacks, shared heap โ€” every object either thread can reach, both can touch. Concurrency bugs are, almost without exception, unsynchronized access to shared mutable state. Keep that phrase; itโ€™s the diagnosis for everything below.

(Also: t.start() launches the thread; calling t.run() just runs the lambda on the current thread โ€” a classic first-week bug that makes โ€œconcurrentโ€ code sequential.)


The Race Condition, Demonstrated Honestly

class Counter {
private int count = 0;
void increment() { count++; }
int value() { return count; }
}
var counter = new Counter();
try (var pool = Executors.newFixedThreadPool(4)) {
for (int i = 0; i < 100_000; i++) pool.submit(counter::increment);
}
System.out.println(counter.value()); // 100000? No: 97244. Or 98901. Different each run.

The culprit: count++ is three operations โ€” read, add, write. Two threads interleave:

Thread A: read count (500)
Thread B: read count (500) โ† reads the same stale value
Thread A: write 501
Thread B: write 501 โ† A's increment is erased

Lost updates, no exception, results that vary per run. Worse, thereโ€™s a second, sneakier problem: visibility. Without synchronization, the JVM and CPU are allowed to cache values per-core and reorder operations โ€” one threadโ€™s write may simply never be seen by another. The infamous demo is a while (!stop) loop that spins forever because the writing threadโ€™s stop = true stays invisible. Synchronization constructs fix both problems โ€” atomicity and visibility โ€” and you need both fixed.


The Toolbox, From Blunt to Sharp

synchronized โ€” mutual exclusion + visibility. One thread in the guarded section at a time; entering/exiting synchronizes memory:

class Counter {
private int count = 0;
synchronized void increment() { count++; } // locks on 'this'
synchronized int value() { return count; }
}

Correct, simple, and fine for low contention. Costs: threads queue (contention hurts throughput), and locking invites deadlock when multiple locks nest (two threads each holding one lock and waiting for the otherโ€™s โ€” the fix is a global lock-acquisition order, or better, fewer locks).

Atomic classes โ€” lock-free single-variable updates:

private final AtomicInteger count = new AtomicInteger();
void increment() { count.incrementAndGet(); } // hardware compare-and-swap

For counters, flags, and accumulators, atomics beat locks โ€” no blocking at all. AtomicLong, AtomicReference, and LongAdder (better under heavy contention) round out the family.

volatile โ€” visibility only. Guarantees reads see the latest write; provides no atomicity. Right for the stop-flag case (private volatile boolean stop;); wrong for count++. If youโ€™re unsure whether volatile suffices, it doesnโ€™t โ€” use an atomic or a lock.

Concurrent collections โ€” donโ€™t lock around HashMap, replace it:

Map<String, Integer> hits = new ConcurrentHashMap<>();
hits.merge(page, 1, Integer::sum); // atomic per-key update
var queue = new LinkedBlockingQueue<Task>(); // producer/consumer built-in

Plain HashMap under concurrent writes loses data silently (its internals assume single-threaded structure changes). ConcurrentHashMap locks per-bin, scales beautifully, and its compute/merge methods are atomic โ€” most โ€œcheck then putโ€ races dissolve into one call.

Immutability โ€” the quiet winner. Immutable objects need none of the above: no writes after construction, nothing to race on. The highest-leverage concurrency decision in most codebases isnโ€™t picking locks โ€” itโ€™s shrinking shared mutable state until thereโ€™s almost nothing left to protect. Records everywhere, confinement of what remains mutable to a single thread, and synchronization only at the seams.


Executors: How Real Code Runs Tasks

Creating a Thread per task doesnโ€™t scale (platform threads cost ~1 MB stack and a system call each) and scatters lifecycle management everywhere. Production Java submits tasks to executors that own a pool of reusable worker threads:

try (ExecutorService pool = Executors.newFixedThreadPool(8)) {
List<Future<Report>> futures = new ArrayList<>();
for (Region r : regions) {
futures.add(pool.submit(() -> buildReport(r))); // Callable<Report>
}
for (Future<Report> f : futures) {
publish(f.get()); // blocks until that task completes; rethrows its exception
}
} // Java 19+: close() awaits termination

Key ideas:

Composing async work: CompletableFuture

When tasks depend on other tasks, Future.get() chains turn into blocking spaghetti. CompletableFuture pipelines instead:

CompletableFuture<Quote> best =
CompletableFuture.supplyAsync(() -> fetchQuote("vendorA"), pool)
.thenCombine(
CompletableFuture.supplyAsync(() -> fetchQuote("vendorB"), pool),
Quote::cheaper)
.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> Quote.fallback());

Read it like a stream pipeline for async results: fetch two quotes concurrently, combine, bound the wait, degrade gracefully. thenApply (transform), thenCompose (chain a dependent async call), allOf (fan-in) complete the core vocabulary.


Virtual Threads: The Java 21 Rewrite of Server Concurrency

The classic server dilemma: thread-per-request is the simplest programming model (sequential code, real stack traces, normal debugging), but platform threads are too heavy to have 50,000 of them, so the industry detoured into async frameworks and callback/reactive code โ€” powerful, and famously hard to reason about.

Virtual threads (Project Loom, standard in Java 21) end the dilemma: threads managed by the JVM instead of the OS, costing kilobytes instead of a megabyte. When a virtual thread blocks on IO, the JVM unmounts it from its carrier OS thread, which immediately runs another virtual thread. Blocking becomes nearly free:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request req : requests) {
executor.submit(() -> handle(req)); // 100,000 concurrent? Fine.
}
}

What this changes in practice:

If you learn concurrency in 2026, learn it in this order: correctness rules first (theyโ€™re eternal), executors second (ubiquitous in existing code), virtual threads as your default for IO-bound services going forward.


Structured Concurrency: The Missing Piece Arriving Now

One more Loom-era feature deserves a mention because it fixes a real everyday problem: concurrent subtasks whose lifetimes nobody manages. The classic bug โ€” fan out three calls, one fails, the other two keep running (and consuming resources) for a result nobody will use; or worse, the method returns while orphaned tasks still run.

Structured concurrency (finalized in recent JDKs after previewing through 21โ€“24) scopes subtasks to a block, the way a try block scopes resources:

try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> fetchUser(id)); // runs on a virtual thread
var orders = scope.fork(() -> fetchOrders(id));
scope.join(); // waits; cancels siblings on failure
return new Profile(user.get(), orders.get());
} // guarantee: no subtask outlives this block

The guarantees: all subtasks complete or are cancelled before the block exits, a failure in one cancels the others promptly, and thread dumps show the parent-child task tree instead of a flat thread soup. If CompletableFuture chains have ever left you unsure โ€œwhat is still running right now?โ€, this is the language-level answer. For new fan-out/fan-in code on current JDKs, prefer it over hand-rolled future bookkeeping.

Frequently Asked Questions

Whatโ€™s the difference between concurrency and parallelism? Concurrency is structuring a program to make progress on many tasks at once (even on one core, by interleaving); parallelism is executing on multiple cores simultaneously. IO-bound servers need concurrency (waiting efficiently); numeric batch jobs need parallelism (computing efficiently). Virtual threads serve the first; sized platform pools and parallel streams serve the second.

Is SimpleDateFormat/my favorite class thread-safe? How do I tell? Check the Javadoc โ€” thread-safety is (usually) documented. Default assumption for mutable classes: not safe. The modern fix for the date-format classic: DateTimeFormatter is immutable and safe; legacy SimpleDateFormat shared across threads produces corrupted dates silently.

Do I need synchronized if I only ever read the data? If the data is safely published (assigned before threads start, or via a final field, or through a concurrent structure) and never mutated afterwards โ€” no. Thatโ€™s the immutability dividend. The danger zone is mostly-read data with occasional writes; โ€œoccasionally mutated without synchronizationโ€ is just โ€œbrokenโ€ with better PR.

Why did my multithreaded test pass 1,000 times and fail in production? Races are probabilistic and environment-sensitive โ€” different core counts, JIT states, and load change the interleavings explored. A passing test proves little; reasoning about shared mutable state (or removing it) is the only reliable tool. For serious cases, stress-test harnesses like jcstress explore interleavings deliberately.

A Checklist That Prevents Most Concurrency Bugs

  1. Minimize shared mutable state. Immutable values (records), thread-confined working data, share only results.
  2. Shared counter/flag/reference โ†’ atomic classes. Shared map/queue โ†’ concurrent collections. Compound invariants across fields โ†’ one lock guarding all of them, documented.
  3. Never do check-then-act on shared state without atomicity โ€” if (!map.containsKey(k)) map.put(k, v) is a race; putIfAbsent/computeIfAbsent is the fix.
  4. Tasks + executors, not raw threads; virtual threads for IO-bound fan-out.
  5. Always harvest task results/exceptions (get, join, or an exception-logging wrapper) โ€” silent failure is worse than crashing.
  6. Suspect a race? Look for the phrase: unsynchronized access to shared mutable state. Itโ€™s there.

Self-Check

  1. The counter demo lost updates. Name the two distinct problems unsynchronized sharing causes, and one tool that fixes both.
  2. Why is volatile int count; count++; still broken?
  3. pool.submit(() -> risky()) โ€” the task threw NullPointerException. Where did it go, and how do you get it back?
  4. Your service makes 3 downstream HTTP calls per request, 10k concurrent requests. Platform-thread pool of 200 keeps saturating. Whatโ€™s the Java 21 answer, and why does it work?
  5. When are virtual threads the wrong tool?

(Answers: atomicity (lost interleaved updates) and visibility (stale per-core caches) โ€” synchronized or AtomicInteger fixes both. Volatile fixes visibility only; ++ remains a non-atomic read-modify-write. Itโ€™s stored inside the returned Future โ€” call get()/join() to rethrow it; unharvested futures swallow errors. Virtual thread per task โ€” blocked-on-IO virtual threads unmount and free carriers, so 30k concurrent blocking calls need only a handful of OS threads. CPU-bound computation โ€” cores are still the limit; use a sized platform pool.)

Final stop in the series: Memory Management and Garbage Collection โ€” heap regions, GC algorithms, memory leaks in a garbage-collected language, and reading an OutOfMemoryError like a pro.