← Home
Java Core

Top 10 Senior Interview Questions

Collections · Concurrency · Memory Model · Lambda · Streams · Async · GC · Virtual Threads

Questions

  1. equals() / hashCode() contract and subtle bugs
  2. HashMap vs ConcurrentHashMap — when is CHM not enough?
  3. synchronized vs ReentrantLock — when and why to choose each
  4. Java Memory Model — volatile, happens-before
  5. Lambda & Functional Interfaces — how they work under the hood
  6. Stream API — internals, lazy evaluation, parallelStream pitfalls
  7. CompletableFuture — async orchestration under concurrency
  8. Garbage Collection — G1 vs ZGC, tuning for latency
  9. Virtual Threads (Project Loom) — how they work and production trade-offs
  10. Java 21+ features — Records, Sealed Classes, Pattern Matching, SequencedCollections
Q1 · Core Java
Explain the equals/hashCode contract. What real bugs can it cause?
🎙 Model answer (60 sec)
"The contract has one rule: if two objects are equal via equals(), they must return the same hashCode(). HashMap uses hashCode to find the right bucket, then equals to find the exact entry. If you override equals but forget hashCode, two logically equal objects land in different buckets — map.get() returns null even though the key is there. The classic bug is adding a mutable field to a key object, then mutating it after it's in the map — the hash changes, so the entry is now in the wrong bucket and lost. I always use IDE-generated or Lombok @EqualsAndHashCode, and never use mutable fields in keys."

Direct answer

The contract has one core rule: if two objects are equal via equals(), they must return the same hashCode(). The reverse doesn't have to be true — two objects can share a hash code without being equal, that's just a collision. Breaking this contract silently corrupts any hash-based structure — HashMap, HashSet, anything that uses hashing to find elements.

How it works internally

When you call map.get(key), HashMap first calls hashCode() to find the right bucket, then calls equals() to find the exact entry within that bucket. If you override equals() but forget hashCode(), two logically equal objects land in different buckets. get() returns null even though the key "exists" — and you'll spend a long time debugging it because the code looks correct.

The production gotcha I always warn about

The worst case I've seen is mutable keys. You put an object into a map, then mutate one of the fields that hashCode depends on — now the object is in the wrong bucket and the map can never find it again:

Account acc = new Account("ACC001");
map.put(acc, balance);

acc.id = "ACC999";  // mutation after insert — hash changed
map.get(acc);      // returns null — it's in the wrong bucket now

Rule: anything used as a map key should be effectively immutable.

The other one I hit regularly is Lombok's @Data on JPA entities. It generates equals/hashCode from all fields including lazy collections, which either triggers N+1 queries or throws LazyInitializationException when you put the entity in a Set. The fix is always explicit:

@EqualsAndHashCode(of = "id")  // use only the business key, nothing else

Correct implementation pattern

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Transaction t)) return false;
    return id.equals(t.id);
}

@Override
public int hashCode() { return Objects.hash(id); }
Interview drill-down — expect these follow-ups:
L1 → "So if I add volatile to the id field, do I still need hashCode?"
No — volatile is about thread visibility, not immutability. You still need a stable hashCode. The fix is making the key effectively immutable, not volatile.
L2 → "What does Java 8 do when a bucket chain gets too long?"
At 8 entries, it converts the linked list to a red-black tree (TREEIFY_THRESHOLD). This changes worst-case lookup from O(n) to O(log n) and mitigates HashDoS attacks where an attacker crafts inputs that all land in the same bucket.
L3 → "Why does Hibernate docs say never use @Data on entities?"
Hibernate entities have a lifecycle — they're proxied and can be in detached state. @Data generates equals/hashCode from all fields; accessing a lazy collection in equals triggers a DB query or throws LazyInitializationException. Always use @EqualsAndHashCode(of = "id") with a business key or database surrogate key.
Trap answer to avoid: "I just use @Data from Lombok everywhere." — This is a red flag to interviewers. Correct answer: @Data is fine for simple DTOs/value objects, never on JPA entities.
Q2 · Collections & Concurrency
What's the difference between HashMap and ConcurrentHashMap? When is CHM not enough?
🎙 Model answer (60 sec)
"HashMap is not thread-safe — concurrent writes can corrupt its internal structure. ConcurrentHashMap is designed for concurrency: reads are completely lock-free using volatile, and writes only synchronize on the individual bucket they touch, not the entire map. The part most candidates miss: CHM makes individual operations atomic, but not compound check-then-act operations. If you do if (!map.containsKey(k)) map.put(k, v), another thread can insert between the check and the put. That's why CHM has putIfAbsent() and computeIfAbsent() — atomic compound operations built in."

Direct answer

HashMap is not thread-safe — concurrent writes can corrupt it, and in older JVMs could even cause an infinite loop during resize. ConcurrentHashMap is designed for concurrent use. In Java 8+, it uses CAS for inserting into an empty bucket and only synchronizes on the bucket head when there's a collision. Reads are completely lock-free — they use volatile reads on node values. So you get very high read throughput with minimal write contention.

The trap most people miss

CHM makes individual operations atomic, but not compound operations. This is the classic check-then-act race:

// BROKEN — another thread can insert between the check and the put:
if (!map.containsKey(key)) {
    map.put(key, expensiveCompute());
}

// CORRECT — single atomic operation:
map.computeIfAbsent(key, k -> expensiveCompute());

But computeIfAbsent has its own trap — it holds the bucket lock for the entire duration of the lambda. If your compute function does I/O or calls an external service, you've just serialised every thread that touches that bucket. I always move the slow work outside:

// Pre-compute outside the map operation if computation is expensive:
Value v = expensiveCompute();           // no lock held here
map.putIfAbsent(key, v);               // atomic, fast

When CHM still isn't enough

CHM has no cross-key atomicity. If you need to atomically move a value from one key to another — like transferring a balance — CHM can't help. You need a ReadWriteLock around the whole operation, or rethink the data structure entirely.

Also worth knowing: size() on CHM is approximate. It uses a LongAdder internally with per-cell counters to avoid contention, and sums them at read time. Under heavy concurrent modification, the count can be slightly stale.

merge — the cleaner pattern for aggregation

// Atomic word count — cleaner than compute():
map.merge(word, 1, Integer::sum);
// if absent: inserts 1. If present: applies Integer::sum atomically.
Interview drill-down — expect these follow-ups:
L1 → "You said computeIfAbsent holds the bucket lock. What's the actual risk in production?"
If the compute function calls an external service (HTTP, DB), you hold that bucket lock for the entire network round-trip. Every other thread trying to compute a key that hashes to the same bucket is blocked. Under load, this creates a thread pile-up and can cascade into a timeout storm.
L2 → "What about Collections.synchronizedMap — is it ever the right choice?"
Rarely. synchronizedMap wraps every method in a synchronized block on the entire map — one global lock. The only case where it wins: you need to synchronize the entire iteration (you must do it manually with synchronized(map) anyway). For everything else CHM's finer-grained locking is superior.
L3 → "How does CHM's size() work and why is it approximate?"
CHM uses a striped LongAdder internally — each stripe tracks adds/removes for a subset of buckets. size() sums all stripes at read time. Under concurrent modification, the sum can be stale. If you need an exact count at a specific moment, you need external synchronization — CHM won't give you that.
Trap answer to avoid: "CHM is always safe for concurrent access." — The interviewer is fishing for compound-operation races. CHM atomicises individual ops, not sequences of ops. Saying it's always safe shows you haven't thought about check-then-act patterns.
Q3 · Concurrency
synchronized vs ReentrantLock — when and why would you choose one over the other?
🎙 Model answer (60 sec)
"My default is always synchronized. It's simpler, the JVM manages the release automatically even on exception, and since Java 6 it's heavily optimized — biased locking, spin locks, only escalating to a full OS mutex under real contention. I reach for ReentrantLock only when I need something synchronized can't do: timed tryLock to avoid deadlock, interruptible waiting, multiple condition variables for producer-consumer patterns, or fairness. The most important thing to know about ReentrantLock is that you must call unlock() in a finally block — miss it once and you have a permanent deadlock."

Direct answer

My default is always synchronized. It's JVM-managed — the lock is automatically released when the block exits, even if an exception is thrown. Since Java 6, the JVM also optimises it adaptively: it starts as a biased lock with near-zero cost, escalates to a thin spin lock under mild contention, and only inflates to a full OS mutex when there's real contention. So for most cases it's both simpler and fast enough.

I reach for ReentrantLock only when I need capabilities that synchronized doesn't have.

What ReentrantLock gives you

Three things come up in practice:

1. Timed lock attempt — tryLock()

Useful when you need to acquire two locks at once and can't guarantee ordering — the classic deadlock setup. Instead of blocking forever, you back off and retry:

// Transfer between two accounts — must lock both without deadlocking
boolean transfer(Account from, Account to, long amount) throws InterruptedException {
    while (true) {
        if (from.lock.tryLock(50, MILLISECONDS)) {
            try {
                if (to.lock.tryLock(50, MILLISECONDS)) {  // try second lock
                    try {
                        from.debit(amount);
                        to.credit(amount);
                        return true;
                    } finally { to.lock.unlock(); }
                }
            } finally { from.lock.unlock(); }
        }
        // both locks not acquired — back off and retry
        Thread.sleep(1);
    }
}

With synchronized you can't do this — once you're blocked waiting for the second lock, you're stuck. tryLock lets you release what you have and try again.

2. Interruptible waiting — lockInterruptibly()

Useful when threads need to respond to shutdown signals. With synchronized, a thread blocked on a lock ignores interrupts — you can't cancel it. With lockInterruptibly(), the thread wakes up and throws InterruptedException when interrupted:

void processTask() throws InterruptedException {
    lock.lockInterruptibly(); // throws if thread is interrupted while waiting
    try {
        doWork();
    } finally { lock.unlock(); }
}

// On graceful shutdown:
workerThread.interrupt(); // thread unblocks from lockInterruptibly() and exits cleanly
// With synchronized: interrupt() is ignored — thread stays blocked until lock is released

3. Multiple condition queues

With synchronized you have one condition queue, so notifyAll() wakes every waiting thread — producers and consumers alike. With ReentrantLock you create separate conditions and signal only the right threads:

ReentrantLock lock     = new ReentrantLock();
Condition     notFull  = lock.newCondition();
Condition     notEmpty = lock.newCondition();

// Producer wakes only consumers — not other producers:
lock.lock();
try {
    while (queue.size() == MAX) notFull.await();
    queue.add(item);
    notEmpty.signal();
} finally { lock.unlock(); }
ReentrantLock lock     = new ReentrantLock();
Condition     notFull  = lock.newCondition();
Condition     notEmpty = lock.newCondition();

// Producer wakes only consumers — not other producers:
lock.lock();
try {
    while (queue.size() == MAX) notFull.await();
    queue.add(item);
    notEmpty.signal();
} finally { lock.unlock(); }

The one pitfall with ReentrantLock

You must call unlock() yourself, always in a finally block. If you forget and an exception is thrown, the lock is held forever and every waiting thread hangs. That's a production incident. With synchronized the JVM handles this for you — one less thing to get wrong.

Quick reference

NeedUse
Simple mutual exclusionsynchronized
Timed / interruptible lockReentrantLock
Multiple condition queuesReentrantLock
Read-heavy, write-rareReadWriteLock / StampedLock
Interview drill-down — expect these follow-ups:
L1 → "You mentioned ReentrantLock must always unlock in finally. Show me a bug where this goes wrong."
If you call lock() before the try block and an exception is thrown between lock() and try, the finally doesn't run — lock is held forever. Rule: lock() must be the last statement before try, or use tryLock() inside the try.
L2 → "What is StampedLock and when would you use it over ReadWriteLock?"
StampedLock adds optimistic read mode: you read without acquiring a lock, then validate a stamp. If another writer wrote during your read, validation fails and you retry with a full read lock. Best throughput for read-dominant workloads (config caches, reference data). Downside: not reentrant, no Condition support, and it's easy to deadlock if you forget to validate the stamp.
L3 → "Virtual threads are in Java 21 — does that change how you think about locks?"
Yes. Virtual threads are pinned to a carrier thread when they hit a synchronized block — this can exhaust the carrier thread pool. The fix: replace synchronized with ReentrantLock inside virtual-thread-heavy code. This is one reason Tomcat and Netty are moving away from synchronized in their hot paths.
Trap answer to avoid: "I always use ReentrantLock because it's more powerful." — Interviewers see this as over-engineering. The correct signal: prefer synchronized unless you specifically need tryLock, lockInterruptibly, or multiple conditions. More power = more ways to introduce bugs.
Q4 · Concurrency
Explain the Java Memory Model. What does volatile actually guarantee — and where does it fail?
🎙 Model answer (60 sec)
"The JMM defines visibility and ordering guarantees between threads via the happens-before relationship. Volatile gives you two things: visibility — a write to a volatile variable is immediately visible to all subsequent reads of that variable — and ordering — everything written before the volatile write is also visible after the volatile read. What volatile does NOT give you is atomicity. counter++ on a volatile field is still read-modify-write: three steps, not one. Two threads can both read the same stale value and overwrite each other. The classic trap is double-checked locking without volatile — the JIT can publish the reference before the constructor completes, so another thread sees a non-null but uninitialized object."

Direct answer

The Java Memory Model defines what guarantees you have about visibility and ordering across threads. The key concept is happens-before: if action A happens-before action B, then everything A wrote is visible to B. Without a happens-before relationship, threads can see stale or reordered values — even if the code looks sequential.

People often describe volatile as "flushing to main memory" but that's an oversimplification. The real guarantee is: a volatile write happens-before any subsequent volatile read of the same variable. Everything written before the volatile write is also visible after the volatile read.

What volatile actually does

volatile boolean ready = false;
int data = 0;

// Thread 1:
data  = 42;           // (1)
ready = true;        // (2) volatile write — establishes HB

// Thread 2:
while (!ready);      // (3) volatile read — sees HB from (2)
System.out.println(data); // guaranteed to print 42, not 0

Without volatile on ready, the JIT or CPU is free to reorder (1) and (2), or cache ready in a register — Thread 2 might never see the update.

Where volatile fails — the two traps

1. It doesn't give you atomicity. counter++ on a volatile field is still three operations — read, increment, write. Two threads can both read the same value and both write back incremented values, losing one update. Use AtomicLong or LongAdder for counters.

2. It doesn't protect compound actions. if (!flag) { init(); flag = true; } — two threads can both pass the null check. You still need synchronized or a CAS operation for that.

The classic double-checked locking bug

This one trips people up in real code:

// BROKEN — JIT can reorder: write reference BEFORE constructor finishes
if (instance == null) {
    synchronized (MyClass.class) {
        if (instance == null)
            instance = new Singleton(); // another thread sees non-null but uninitialised!
    }
}

// CORRECT — volatile prevents the reorder:
private volatile static Singleton instance;

Without volatile, another thread can see a non-null reference to a partially constructed object. The JIT is allowed to publish the reference before the constructor body completes. Adding volatile inserts a memory barrier that prevents this reordering.

Interview drill-down — expect these follow-ups:
L1 → "Does making a field volatile mean it's thread-safe for reads and writes?"
For visibility and ordering — yes. For atomicity — no. volatile int counter++ is still a read-modify-write cycle: three non-atomic steps. Two threads can both read the same stale value and both write back. Use AtomicInteger or LongAdder for counters.
L2 → "What are the happens-before rules besides volatile?"
Thread start: everything before thread.start() is visible to the new thread. Thread join: everything the thread did is visible after thread.join() returns. Lock release/acquire: all writes before unlock() are visible after lock(). Object construction: all writes in a constructor are visible via final fields. synchronized block exit → next entry on the same monitor.
L3 → "Could you have a memory visibility bug even on x86 architecture?"
In theory x86 has a strong TSO memory model that prevents most reorderings. But the JIT compiler can still reorder instructions independent of CPU architecture — JMM defines what the JIT is allowed to do, not just the hardware. A JIT can hoist a non-volatile read out of a loop (register-cache it), so you'd never see updates even on x86.
Trap answer to avoid: "volatile makes the variable thread-safe." — Too broad. Volatile solves visibility and prevents reordering, but not atomicity of compound operations. An interviewer who hears this will immediately probe with counter++, expecting you to identify it as still broken.
Q5 · Functional Java
How do lambdas and functional interfaces work under the hood in Java?
🎙 Model answer (60 sec)
"A functional interface is any interface with exactly one abstract method — @FunctionalInterface enforces this at compile time. Lambdas implement them. Under the hood, lambdas are NOT anonymous inner classes — the compiler uses invokedynamic instead. At the first call, the JVM generates a class at runtime that implements the interface, then caches it. This means no extra .class file per lambda at compile time and the JVM can optimize the implementation strategy. The key practical consequence: lambdas that capture mutable local variables don't compile — the variable must be effectively final. This isn't arbitrary — it prevents race conditions when lambdas run in parallel."

What is a functional interface?

Any interface with exactly one abstract method (SAM — Single Abstract Method). The @FunctionalInterface annotation enforces this at compile time but is not required.

@FunctionalInterface
interface Transformer<T, R> {
    R transform(T input);           // only one abstract method
    default R andLog(T input) { ... } // default methods allowed
    static Transformer<?,?> identity() { ... } // static methods allowed
}

Built-in functional interfaces to know

InterfaceSignatureUse
Function<T,R>R apply(T t)transform
Predicate<T>boolean test(T t)filter
Consumer<T>void accept(T t)side effect
Supplier<T>T get()lazy produce
BiFunction<T,U,R>R apply(T t, U u)two-arg transform
UnaryOperator<T>T apply(T t)T → T (Function specialisation)

How lambdas work at the bytecode level

Lambdas are not anonymous inner classes. The compiler emits an invokedynamic instruction (Java 7+). At first call, the JVM calls a bootstrap method (LambdaMetafactory) which generates a class at runtime and caches it. Subsequent calls go direct — effectively as fast as a method call.

// What you write:
Function<String, Integer> len = s -> s.length();

// What happens at runtime (conceptually):
// 1. invokedynamic → LambdaMetafactory.metafactory()
// 2. JVM generates a class implementing Function<String,Integer>
// 3. apply() delegates to the lambda body compiled as a private static method
// 4. Result cached — class generated only once
Key insight: Anonymous inner classes generate a .class file at compile time. Lambdas generate a class at runtime via invokedynamic — more flexible (JVM can optimize the strategy) and avoids class explosion on disk.

Variable capture — effectively final

int threshold = 100;           // effectively final — OK
list.stream().filter(x -> x > threshold);

int counter = 0;
list.forEach(x -> counter++);  // compile error — counter is mutated

// Why: lambda captures a copy of the local variable.
// If it could mutate it, you'd have a stale copy in the lambda.
// Workaround for counters:
AtomicInteger counter = new AtomicInteger();
list.forEach(x -> counter.incrementAndGet()); // reference is final, object is mutable

Method references — 4 forms

String::toUpperCase          // instance method — instance provided by stream
System.out::println          // instance method on specific object
Integer::parseInt            // static method
ArrayList::new               // constructor reference

// They are syntactic sugar — identical bytecode to lambda equivalents
// Prefer method references when the lambda just calls one method — more readable

Function composition

Function<String, String> trim  = String::trim;
Function<String, String> upper = String::toUpperCase;

// andThen: trim → upper
Function<String, String> clean = trim.andThen(upper);

// Predicate composition:
Predicate<Integer> isPositive = n -> n > 0;
Predicate<Integer> isEven     = n -> n % 2 == 0;
Predicate<Integer> both       = isPositive.and(isEven);
list.stream().filter(both).collect(...);
Interview drill-down — expect these follow-ups:
L1 → "invokedynamic sounds like reflection — is it slow?"
No. The first call pays a one-time bootstrap cost via LambdaMetafactory. After that the JVM links the call site directly — it's as fast as a virtual method dispatch. No reflection at steady state. Anonymous inner classes are actually slower at classloading time because the .class files must be loaded from disk; lambdas generate their class in memory once.
L2 → "You said lambdas capture a copy of local variables. What about instance fields?"
Instance fields are captured via a reference to this — not a copy. So the lambda can see mutations to instance fields after capture. This is a common source of bugs: you think the lambda captured a snapshot, but it's reading a live reference and will see changes made between capture and invocation.
L3 → "What's the difference between Predicate.and() and Stream.filter().filter()?"
Functionally identical — both short-circuit on false. Predicate.and() composes into a single Predicate object (one filter stage). Chained .filter() creates two pipeline stages. For simple cases it doesn't matter; for complex logic, Predicate composition is more readable and easier to unit test in isolation.
Trap answer to avoid: "Lambdas are just syntactic sugar for anonymous inner classes." — This is almost true but technically wrong. Lambdas use invokedynamic and LambdaMetafactory, not anonymous class generation. The distinction matters for class loading performance and in debugging (stack traces look different).
Q6 · Functional Java
How does the Stream API work internally? What are the pitfalls of parallelStream?
🎙 Model answer (60 sec)
"Streams are lazy pipelines. Intermediate operations — filter, map, flatMap — register themselves as stages but do no work. Only a terminal operation like collect or findFirst triggers execution. This lets the JVM fuse operations and short-circuit early. parallelStream uses ForkJoinPool.commonPool() to split the source and process chunks in parallel. The traps: first, it uses the shared common pool — if you're already under load, you're competing for threads with every other parallel stream in the JVM. Second, parallel only wins on large datasets with CPU-bound operations; for small lists or I/O-bound work it adds overhead with no benefit. Third, stateful operations like sorted() and distinct() require synchronization barriers that kill the parallel advantage."

Lazy evaluation pipeline

A stream pipeline has three parts: source → intermediate operations → terminal operation. Intermediate ops (filter, map, flatMap) are lazy — nothing executes until a terminal op (collect, forEach, findFirst) is called.

list.stream()
    .filter(u -> u.isActive())    // lazy — no work yet
    .map(User::getEmail)          // lazy — no work yet
    .findFirst();                 // terminal — pipeline executes, stops at first match

// Without laziness, filter+map would scan all 1M items even if findFirst matches element #2

Stateless vs stateful intermediate ops

  • Stateless (filter, map, flatMap): process one element at a time — streamable, parallelisable.
  • Stateful (sorted, distinct, limit): must see multiple elements before producing output — require buffering, break parallelism efficiency.

flatMap vs map

// map: one-to-one. Returns Stream<Stream<String>> — nested:
orders.stream().map(o -> o.getItems().stream())

// flatMap: one-to-many, flattens. Returns Stream<String>:
orders.stream().flatMap(o -> o.getItems().stream())
               .collect(Collectors.toList());

Collectors worth knowing

// groupingBy — Map<Department, List<Employee>>
employees.stream().collect(Collectors.groupingBy(Employee::getDepartment));

// downstream collector — Map<Department, Long>
employees.stream().collect(Collectors.groupingBy(Employee::getDepartment, Collectors.counting()));

// partitioningBy — Map<Boolean, List<T>>
employees.stream().collect(Collectors.partitioningBy(e -> e.getSalary() > 100_000));

// joining
names.stream().collect(Collectors.joining(", ", "[", "]"));

parallelStream — when it helps and when it hurts

// Uses ForkJoinPool.commonPool() — shared across the entire JVM!
list.parallelStream().map(this::heavyCpuWork).collect(...);

// Custom pool to isolate parallelism:
ForkJoinPool pool = new ForkJoinPool(4);
pool.submit(() -> list.parallelStream().map(this::work).collect(...)).get();
Pitfalls of parallelStream:
  • Shared commonPool — heavy tasks starve other users (e.g. Spring's async methods).
  • Stateful lambdas with shared mutable state → race conditions.
  • Poor for I/O-bound work — threads block, no benefit, overhead from splitting.
  • Ordered streams (findFirst, forEachOrdered) kill parallel speedup with coordination overhead.
  • Small collections: thread coordination cost exceeds computation benefit.
Rule: parallelStream is only worth it for CPU-bound work on large collections (thousands+ elements) with stateless operations.
Interview drill-down — expect these follow-ups:
L1 → "When is parallelStream actually faster in practice?"
CPU-bound work on large collections — typically 10,000+ elements — with stateless operations that don't share any mutable state. Benchmarks consistently show parallel loses to sequential for lists under ~1,000 elements due to ForkJoinPool coordination overhead. Always benchmark with JMH before committing to parallelStream in a hot path.
L2 → "What's a spliterator and why does it matter for parallel streams?"
A Spliterator is how a data source describes how to split itself for parallel processing. ArrayList's spliterator splits by index — O(1), perfectly balanced. LinkedList's spliterator splits by traversal — O(n), terrible for parallelism. For custom data sources, implementing a Spliterator that splits cheaply is essential for parallel speedup.
L3 → "reduce() vs collect() — why does reduce into a list have O(n²) complexity?"
reduce() requires an immutable accumulator. Adding to a list via reduce creates a new list on every element: list1 → list1+e2 → list1+e2+e3... Each step copies all previous elements. n elements × average n/2 copies = O(n²). collect() uses a mutable container and just calls container.add(e) — O(1) per element, O(n) total.
Trap answer to avoid: "I use parallelStream when I need better performance." — Without qualification this is wrong. Interviewers want to hear: CPU-bound, large data, stateless ops, with an understanding of the shared ForkJoinPool problem. Saying "better performance" generically shows you haven't measured.
Q7 · Async
How does CompletableFuture work? How do you use it correctly under high concurrency?
🎙 Model answer (60 sec)
"CompletableFuture is a composable async primitive — you chain stages with thenApply, thenCompose, combine with allOf, and attach error handlers with exceptionally. Three production rules I always follow: one, always pass an explicit executor to every Async method — never rely on ForkJoinPool.commonPool() because it's shared JVM-wide and can starve under load. Two, use separate executor pools for I/O-bound and CPU-bound work — one blocking thread waiting on I/O shouldn't compete with a compute thread. Three, allOf silently swallows individual future exceptions — each future must have its own exceptionally handler, or failures disappear. The thenApply vs thenCompose distinction is also classic: thenApply maps T→R, thenCompose flatMaps T→CompletableFuture<R> to avoid nesting."

Future vs CompletableFuture

  • Future: blocking .get(), no chaining, no combining.
  • CompletableFuture: non-blocking, chainable (thenApply, thenCompose), combinable (allOf, anyOf), error-handling (exceptionally, handle).

Which thread runs the continuation?

CompletableFuture.supplyAsync(() -> fetch())    // ForkJoinPool.commonPool()
    .thenApply(data -> transform(data))         // same thread OR calling thread — unpredictable!
    .thenApplyAsync(data -> enrich(data), exec) // explicit executor — predictable
Pitfall: Without Async suffix, the continuation runs on whichever thread completed the previous stage. This causes subtle bugs under load. Always use explicit executors in production — ForkJoinPool.commonPool() is shared JVM-wide.

Parallel fan-out pattern

ExecutorService io  = Executors.newFixedThreadPool(50);
ExecutorService cpu = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

CompletableFuture<Profile> pf = CompletableFuture.supplyAsync(() -> fetchProfile(id), io);
CompletableFuture<Recs>    rf = CompletableFuture.supplyAsync(() -> fetchRecs(id), io);
CompletableFuture<History> hf = CompletableFuture.supplyAsync(() -> fetchHistory(id), io);

CompletableFuture.allOf(pf, rf, hf)
    .thenApplyAsync(__ -> build(pf.join(), rf.join(), hf.join()), cpu)
    .orTimeout(500, TimeUnit.MILLISECONDS)
    .exceptionally(ex -> buildFallback());

thenApply vs thenCompose

// thenApply: maps T → R. If R is another CF, you get CF<CF<R>> — nested!
CF<CF<User>> bad  = cf.thenApply(id -> fetchUser(id));   // fetchUser returns CF<User>

// thenCompose: flatMap — unwraps nested CF
CF<User>     good = cf.thenCompose(id -> fetchUser(id));

Error handling methods

  • exceptionally(fn): only on exception, recovers to a value.
  • handle((r, ex) -> ...): always runs, handles both success and failure.
  • whenComplete((r, ex) -> ...): side-effect only, doesn't change the result.
  • join() vs get(): join throws unchecked CompletionException; use it inside lambdas.

allOf — the silent exception trap

This is one of the most common bugs with CompletableFuture in production code. allOf() returns a CF<Void> that completes when all futures complete — but if one fails, the rest still run to completion. The exception from the failed future is swallowed unless you explicitly handle it:

// BROKEN — if pf fails, the exception is silently swallowed!
CompletableFuture.allOf(pf, rf, hf)
    .thenApplyAsync(__ -> build(pf.join(), rf.join(), hf.join()), cpu)
    .join();  // this throws CompletionException, but pf.join() inside may already have failed

// CORRECT — handle exceptions on each individual future:
CompletableFuture<Profile> pf = CompletableFuture.supplyAsync(() -> fetchProfile(id), io)
    .exceptionally(ex -> { log.error("profile failed", ex); return Profile.empty(); });

// Or collect exceptions and fail fast if any task failed:
CompletableFuture.allOf(pf, rf, hf)
    .whenComplete((ignored, ex) -> {
        if (ex != null) throw new RuntimeException("one or more tasks failed", ex);
        Stream.of(pf, rf, hf)
            .filter(f -> f.isCompletedExceptionally())
            .forEach(f -> f.exceptionally(t -> { log.error("task error", t); return null; }));
    });

Also: allOf() does not cancel the remaining futures when one fails. If profile fetch fails after 10ms, Redis and DB queries still run to completion. To actually cancel siblings, you must track the individual CompletableFuture references and call .cancel(true) in the exception handler.

Thread pool starvation — the commonPool trap

supplyAsync() and continuations without an explicit executor all use ForkJoinPool.commonPool(), which is shared JVM-wide. If any of those tasks block (waiting for a lock, I/O, or a downstream CF.get()), they hold a thread in the common pool and prevent other tasks from running:

// STARVATION: both levels use the common pool
CompletableFuture<String> outer = CompletableFuture.supplyAsync(() -> {
    // this blocks waiting for 'inner' — occupies a common pool thread
    return inner.join();   // inner also needs a common pool thread → DEADLOCK under load
});

CompletableFuture<String> inner = CompletableFuture.supplyAsync(() -> callService());
// FIX: use separate executors for blocking and non-blocking work
ExecutorService ioPool  = Executors.newFixedThreadPool(50);   // for blocking I/O
ExecutorService cpuPool = Executors.newFixedThreadPool(
        Runtime.getRuntime().availableProcessors());            // for CPU work

CompletableFuture.supplyAsync(() -> callService(), ioPool)
    .thenApplyAsync(data -> transform(data), cpuPool);

With virtual threads (Java 21+): use Executors.newVirtualThreadPerTaskExecutor() as the I/O executor — each blocking call gets its own virtual thread, carrier threads are never blocked, and starvation is eliminated by design.

Interview drill-down — expect these follow-ups:
L1 → "thenApply vs thenApplyAsync — what's the real-world risk of getting this wrong?"
If the previous stage completes on a Netty I/O thread (from a WebClient call) and you chain .thenApply() without Async, your transformation runs on that I/O thread. I/O threads are precious — blocking them stalls unrelated HTTP connections. Always use thenApplyAsync(executor) in production to push CPU work off I/O threads.
L2 → "How do you handle timeouts in a fan-out pattern without cancelling all futures?"
orTimeout() on the allOf stage cancels the entire combined future but the individual futures continue running (they're not cancelled). For true cancellation you need to track each future reference and call cancel(true) in the exceptionally handler. Or use a structured approach with StructuredTaskScope in Java 21+.
L3 → "CompletableFuture vs reactive (Project Reactor/RxJava) — when do you reach for which?"
CompletableFuture is good for bounded fan-out: fetch A, B, C in parallel, combine. Reactor is better for streaming/backpressure — when you're processing potentially unbounded data or need flow control. If you're in a Spring WebFlux stack, use Reactor's Mono/Flux throughout for non-blocking from HTTP to DB. Mixing CF and Reactor creates blocking points at boundary conversions.
Trap answer to avoid: "CompletableFuture is non-blocking." — It enables non-blocking composition, but supplyAsync() still runs the task on a thread pool thread, which blocks a thread. True non-blocking (no thread held while waiting) requires reactive frameworks with event loops.
Q8 · JVM Internals
How does Garbage Collection work? G1 vs ZGC — which do you choose for a low-latency service?
🎙 Model answer (60 sec)
"GC works on the generational hypothesis — most objects die young. The heap has Young Gen where new objects are allocated and Minor GC runs frequently, and Old Gen for survivors where Major GC runs less often and takes longer. G1 is the default since Java 9: it splits the heap into ~2048 equal regions, uses concurrent marking, and targets a configurable pause time via MaxGCPauseMillis — typical pauses are 50–200ms. ZGC does almost all work concurrently with the application using colored pointers and load barriers — pauses are under 1ms regardless of heap size, at the cost of about 4–5% throughput overhead. My rule: G1 for most services. ZGC for anything with a strict latency SLO under 10ms p99, or heaps above 32GB where G1 pauses grow."

Generational GC basics

  • Heap split into Young Gen (Eden + Survivor S0/S1) and Old Gen.
  • Minor GC: collects Young Gen. Fast, stop-the-world (STW), frequent.
  • Major/Full GC: collects Old Gen or full heap. Slow — you want to avoid this.

G1GC (default since Java 9)

  • Heap split into equal-size regions (~2048). Young/Old/Humongous regions assigned dynamically.
  • Concurrent marking + Mixed GC: collects lowest-liveness Old regions to meet MaxGCPauseMillis target.
  • Good balance of throughput vs pause time. Typical pauses: 50–200ms.

ZGC (Java 15+ production-ready)

  • Almost all GC work runs concurrently with application threads.
  • Uses colored pointers: GC state encoded in the 64-bit pointer itself.
  • Uses load barriers: JIT inserts a check on every reference load (~4% throughput overhead).
  • Pause times: <1ms regardless of heap size. 100GB heap = same pauses as 1GB.

When to choose what

ScenarioGC
General REST serviceG1 (default)
Low-latency API (<10ms p99)ZGC
Batch / throughput-heavyParallelGC
Large heap (>32GB) with latency needsZGC

Key G1 tuning flags

-XX:MaxGCPauseMillis=100              # pause target (not a hard limit)
-XX:G1HeapRegionSize=16m             # larger = fewer humongous objects
-XX:InitiatingHeapOccupancyPercent=35 # start concurrent marking earlier
-Xms8g -Xmx8g                        # fix heap size to avoid expansion STW
Key insight: Most GC problems are fixed by reducing allocation rate in code (object pooling, avoiding boxing), not just tuning JVM flags.
Interview drill-down — expect these follow-ups:
L1 → "What is a humongous object in G1 and why is it a problem?"
Any object larger than 50% of a G1 region is "humongous" and allocated directly into Old Gen regions, bypassing Eden. This means it's never collected in Minor GC — only in Mixed or Full GC. A common cause is large byte[] buffers in HTTP frameworks or Kafka producers. Fix: increase G1HeapRegionSize so more objects fit in a single region, or reduce object sizes.
L2 → "ZGC has sub-millisecond pauses — what's the trade-off?"
~4-5% throughput overhead from load barriers (JIT inserts a check on every reference load). Higher CPU usage overall. And it needs more headroom — ZGC works best with the heap at ~30% utilisation; pack it too full and concurrent GC can't keep up with allocation, causing "allocation stalls". With G1 you can push the heap harder before hitting GC trouble.
L3 → "A service has 100ms p99 GC pauses. Walk me through diagnosing it."
First: gc logs (-Xlog:gc*:file=gc.log:time). Look for Full GCs — if present, check if Old Gen is filling (IHOP too high, promotion failure, humongous objects). Second: check allocation rate with JFR or async-profiler — high allocation rate promotes objects faster than GC can collect. Third: look for off-heap memory usage (direct buffers, metaspace). Fourth: if G1 and pauses are in mixed GC, lower G1MixedGCCountTarget to do fewer mixed GC operations per cycle.
Trap answer to avoid: "I'd switch to ZGC to fix GC pauses." — Switching GC is the last resort. Interviewers want to hear you diagnose first (GC logs, JFR, allocation profiling) and consider fixing the application (reducing allocation, avoiding humongous objects). Blindly switching collectors without understanding the cause often makes things worse.
Q9 · Java 21+
Explain Virtual Threads (Project Loom). How do they change how you write concurrent code in Spring Boot?
🎙 Model answer (60 sec)
"Virtual threads are JVM-managed lightweight threads — you can create millions for a few hundred bytes each, versus ~1MB for OS threads. When a virtual thread blocks on I/O, the JVM automatically unmounts it from its carrier OS thread and parks it, freeing the carrier to run another virtual thread. The result: blocking I/O code scales like async/reactive code, without the callback complexity. In Spring Boot 3.2, one property enables them: spring.threads.virtual.enabled=true — Tomcat switches from a thread pool to virtual threads per request. The important caveats: synchronized blocks pin virtual threads to their carrier, which kills the scalability benefit — use ReentrantLock instead. And ThreadLocal becomes expensive at millions of threads — ScopedValues are the replacement."

Direct answer

Virtual threads are lightweight threads managed by the JVM — not mapped 1:1 to OS threads. You can create millions of them cheaply (a few hundred bytes each vs ~1MB for OS threads). When a virtual thread blocks on I/O, the JVM automatically unmounts it from its carrier (OS) thread and mounts another virtual thread — so the carrier thread is never idle waiting. This makes blocking I/O code as scalable as async/reactive code, without the callback complexity.

Enabling in Spring Boot 3.2+

# application.yaml — one line to enable everywhere
spring:
  threads:
    virtual:
      enabled: true

// Or explicitly in Java config:
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadTomcat() {
    return handler -> handler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}

With this, each HTTP request gets its own virtual thread. You can write plain blocking JDBC, RestTemplate, or Redis calls — the JVM handles the scheduling. No need for WebFlux.

The pinning problem — the critical gotcha

Virtual threads are pinned to their carrier OS thread when they hit a synchronized block or a native method. If I/O happens inside a synchronized block, the carrier thread blocks — defeating the whole point.

// PROBLEM: if doNetworkCall() blocks inside synchronized, carrier is pinned
synchronized (lock) {
    result = doNetworkCall(); // carrier thread stuck — can't mount other virtual threads
}

// FIX: replace synchronized with ReentrantLock — supports unmounting
lock.lock();
try {
    result = doNetworkCall(); // virtual thread unmounts while blocked, carrier is free
} finally { lock.unlock(); }

This is why the Spring team and library authors (JDBC drivers, Redis clients) are actively replacing synchronized with ReentrantLock. Check your dependencies for pinning with -Djdk.tracePinnedThreads=full.

Thread-local variables and virtual threads

ThreadLocal still works but becomes expensive: with millions of virtual threads, each carrying their own ThreadLocal copy, memory pressure explodes. Java 20+ introduces ScopedValues as a replacement — immutable, inherited by child threads, GC'd when scope exits:

// ScopedValue — available in Java 21 as preview, 23 as final
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

ScopedValue.where(CURRENT_USER, user)
    .run(() -> {
        processOrder();  // CURRENT_USER.get() works here and in any method called
    });
// after run() returns, value is gone — no cleanup needed

Virtual threads vs reactive — when to choose

FactorVirtual ThreadsReactive (Reactor)
Code stylePlain blocking — easy to readFunctional pipelines — learning curve
DebuggingNormal stack tracesReactor stacktraces are hard to read
BackpressureNot built-inFirst-class (Flux, onBackpressure*)
CPU-bound workSame as OS threadsSame — both block the event loop
Streaming dataNot designed for itNatural fit (Flux.fromStream)
Recommendation: For new Spring Boot services doing request/response I/O — virtual threads. For streaming, backpressure, or if the whole stack is already reactive — stay with WebFlux/Reactor.
Interview drill-down — expect these follow-ups:
L1 → "Do connection pools still make sense with virtual threads?"
Yes, more than ever. Virtual threads are cheap but database connections are not — each costs memory and a server-side slot. Without a pool, a million virtual threads would open a million DB connections. HikariCP with a pool size of ~10-50 is still the right approach. Virtual threads queue waiting for a connection, but that's fine — they unmount and don't waste an OS thread while waiting.
L2 → "What is StructuredTaskScope and when would you use it over CompletableFuture?"
StructuredTaskScope (Java 21 preview, 23 finalised) gives you structured concurrency: child tasks are scoped to a parent scope, and the scope guarantees all children finish before the parent exits — no orphaned threads. ShutdownOnFailure cancels siblings if one fails. ShutdownOnSuccess returns as soon as one succeeds. Much cleaner than allOf() + cancel tracking with CompletableFuture.
L3 → "A service has 5,000 virtual threads. CPU is at 5%. Where's the bottleneck?"
Likely a pinned virtual thread or a shared lock contention point. Steps: enable -Djdk.tracePinnedThreads=full and look for carrier thread pinning events. Profile with async-profiler in wall-clock mode to find threads blocked on locks or I/O. Also check for synchronized blocks in third-party libraries (old JDBC drivers are notorious). High virtual thread count + low CPU usually means threads are mostly blocked — which is fine unless you're missing SLA.
Trap answer to avoid: "Virtual threads replace thread pools entirely." — You still need connection pools (DB, HTTP clients). And virtual threads don't help with CPU-bound work — you still need real parallelism (ForkJoinPool) for that. Virtual threads solve the blocking I/O scalability problem, not all concurrency problems.
Q10 · Java 21+
What are the most impactful Java 21+ language features? Show practical use cases for Records, Sealed Classes, and Pattern Matching for switch.
🎙 Model answer (60 sec)
"The three features I use most in production: Records — a single-line immutable data class with equals, hashCode, and accessors generated by the compiler. Perfect for DTOs and API response types. Sealed Classes — restrict which classes can implement an interface; combined with pattern matching for switch, the compiler enforces exhaustive handling of all cases. Adding a new subtype breaks every switch that doesn't handle it, which is exactly what you want for a payment result or event type. Pattern Matching for switch — you can match on type, destructure, and add guard clauses in one expression. The difference from Lombok @Data: records are a language feature with compiler-verified contracts, not annotation processing. They're final, truly immutable, and work natively with pattern matching and reflection."

Records — immutable data carriers

Records replace boilerplate POJOs: the compiler generates equals(), hashCode(), toString(), and accessor methods from the header. They're final (can't be extended) and all fields are final.

// Before Java 16 — 30+ lines of boilerplate
public class Point {
    private final double x, y;
    public Point(double x, double y) { this.x = x; this.y = y; }
    public double x() { return x; }
    public double y() { return y; }
    // equals, hashCode, toString...
}

// Java 16+ — same contract, one line
public record Point(double x, double y) {}

// Compact constructor for validation
public record UserId(String value) {
    public UserId {  // compact constructor — no parameter list
        Objects.requireNonNull(value);
        if (value.isBlank()) throw new IllegalArgumentException("UserId must not be blank");
    }
}

// Records work great as DTO / API response types
public record OrderResponse(String orderId, BigDecimal total, OrderStatus status) {}

// Jackson serializes/deserializes records natively (Spring Boot 2.7+)
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable String id) {
    return new OrderResponse(id, total, status);
}
Key constraint: Records cannot extend classes (they implicitly extend java.lang.Record). They can implement interfaces — useful for making records part of a sealed hierarchy.

Sealed Classes — controlled inheritance hierarchies

Sealed classes restrict which classes can extend them. Combined with permits, the compiler knows the complete set of subtypes — enabling exhaustive pattern matching and eliminating default cases.

// Sealed hierarchy for a payment result
public sealed interface PaymentResult
    permits PaymentResult.Success, PaymentResult.Failure, PaymentResult.Pending {

    record Success(String transactionId, BigDecimal amount) implements PaymentResult {}
    record Failure(String reason, int retryAfterSeconds) implements PaymentResult {}
    record Pending(String correlationId) implements PaymentResult {}
}

// Pattern matching for switch — exhaustive, no default needed
String message = switch (result) {
    case PaymentResult.Success s  -> "Paid: " + s.transactionId();
    case PaymentResult.Failure f  -> "Failed: " + f.reason() +
                                        ", retry after " + f.retryAfterSeconds() + "s";
    case PaymentResult.Pending p  -> "Pending: " + p.correlationId();
    // No default: compiler verifies all cases are handled
};

// Guards in pattern matching (Java 21+)
String category = switch (result) {
    case PaymentResult.Success s when s.amount().compareTo(BigDecimal.valueOf(1000)) > 0
                                -> "high-value";
    case PaymentResult.Success s -> "standard";
    case PaymentResult.Failure f -> "failed";
    case PaymentResult.Pending p -> "pending";
};
Why this matters: Sealed classes + pattern matching replaces the visitor pattern and massive if-instanceof chains. The compiler enforces that all cases are handled — you can't add a new subtype without updating all switch sites. This is the Java equivalent of Kotlin's when on a sealed class or Rust's exhaustive match.

Pattern matching for instanceof (Java 16+)

// Before: verbose cast-after-check
if (obj instanceof String) {
    String s = (String) obj;  // redundant cast
    System.out.println(s.length());
}

// After: binding variable in the pattern
if (obj instanceof String s) {
    System.out.println(s.length());  // s is in scope and typed
}

// Useful in equals() — the correct pattern for records/classes
@Override
public boolean equals(Object o) {
    return o instanceof Point p && p.x == x && p.y == y;
}

SequencedCollections (Java 21)

Java 21 adds SequencedCollection, SequencedSet, and SequencedMap interfaces that provide uniform first/last access across List, LinkedHashSet, LinkedHashMap, TreeMap, etc.:

// Before Java 21 — inconsistent and verbose
list.get(0);                            // first element of List
deque.peekFirst();                       // first element of Deque
sortedSet.first();                       // first element of SortedSet
linkedHashMap.entrySet().iterator().next(); // first entry of LinkedHashMap

// Java 21 — uniform API
collection.getFirst();   // works on List, Deque, LinkedHashSet
collection.getLast();
collection.reversed();   // returns reversed view — no copy
map.firstEntry();        // works on LinkedHashMap, TreeMap
map.lastEntry();

Feature timeline quick reference

FeatureVersionStatus
Text Blocks ("""...""")Java 15Final
RecordsJava 16Final
Pattern matching instanceofJava 16Final
Sealed ClassesJava 17Final
Switch Expressions (value-returning)Java 14Final
Pattern Matching for switchJava 21Final
Virtual ThreadsJava 21Final
SequencedCollectionsJava 21Final
StructuredTaskScopeJava 21Preview → Final in 23
ScopedValuesJava 21Preview → Final in 23
String TemplatesWithdrawn from Java 23Being redesigned
Interview drill-down — expect these follow-ups:
L1 → "Can a Record implement an interface? Can it extend a class?"
Records can implement interfaces (including sealed interfaces) — this is the primary extension point. Records cannot extend classes because they implicitly extend java.lang.Record, and Java has single inheritance. This makes sealed interface + record subtype the correct pattern for sum types (ADTs): define the sealed interface, then implement it with records for each case.
L2 → "You have a legacy codebase using a big inheritance hierarchy of shape classes. The interviewer asks: how would you refactor this using modern Java features?"
Model the variants as a sealed interface with record implementations: sealed interface Shape permits Circle, Rectangle, Triangle. Each shape becomes a record holding only its specific data. Replace instanceof chains and visitor classes with pattern matching switch expressions. Benefits: compiler verifies all shapes are handled; adding a new shape forces updates at every switch site; records provide value semantics naturally. This converts an open inheritance hierarchy into a closed discriminated union — much safer and more maintainable.
L3 → "What are the serialization implications of Records vs regular classes? How does Jackson handle them in Spring Boot?"
Records are serialized/deserialized by Jackson 2.12+ natively. Jackson uses the canonical constructor (the one matching all fields in the header) for deserialization. Potential issues: (1) Jackson by default uses accessor names without "get" prefix (x() not getX()) — ensure jackson-module-parameter-names is on the classpath or use @JsonProperty. (2) Records are immutable, so no setter-based deserialization — Jackson uses the constructor. (3) Spring Boot 2.7+ includes spring-boot-starter-json which auto-configures Jackson with record support. Production issue: if you're running an older Spring Boot, add @JsonDeserialize or upgrade — silently-null fields after deserialization are the failure mode.
Trap answer to avoid: "Records are just Lombok @Data." — Records have stronger guarantees: they're final (no subclassing), truly immutable (final fields), and the compiler enforces the contract. Lombok @Data generates setters — records have none. More importantly, records are a language feature (not annotation processing), so they work with pattern matching, exhaustiveness checks, and reflection without any annotation processor on the classpath.