← Home
Java Core

Top 8 Senior Interview Questions

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

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
Q1 · Core Java
Explain the equals/hashCode contract. What real bugs can it cause?

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); }
Likely follow-up: "What is HashDoS?" → An attacker crafts request payloads where all keys hash to the same bucket, degrading HashMap from O(1) to O(n). Java 8 mitigated this by converting long bucket chains into red-black trees — O(log n) worst case — and randomising String hash seeds.
Q2 · Collections & Concurrency
What's the difference between HashMap and ConcurrentHashMap? When is CHM not enough?

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.
Likely follow-up: "What about Collections.synchronizedMap()?" → It wraps every method in a synchronized block on the whole map — one global lock. CHM is almost always better: finer-grained locking, lock-free reads, no need to manually synchronize iteration.
Q3 · Concurrency
synchronized vs ReentrantLock — when and why would you choose one over the other?

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
Likely follow-up: "What is StampedLock?" → Adds optimistic read mode — you try reading without acquiring any lock, then validate a stamp afterward. If validation fails, you fall back to a regular read lock. Best throughput for read-heavy data, but it's not reentrant and easier to misuse than ReadWriteLock.
Q4 · Concurrency
Explain the Java Memory Model. What does volatile actually guarantee — and where does it fail?

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.

Likely follow-up: "What's a memory barrier?" → A CPU instruction that prevents reordering across it. A volatile write inserts a StoreLoad barrier on x86 — all prior stores are visible before any subsequent load. This is what gives volatile its ordering guarantee at the hardware level.
Q5 · Functional Java
How do lambdas and functional interfaces work under the hood in Java?

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(...);
Likely follow-up: "Can a lambda throw a checked exception?" → Not directly if the functional interface doesn't declare it. Common workaround: wrap in a utility method that catches and rethrows as unchecked, or define a custom @FunctionalInterface that declares throws Exception.
Q6 · Functional Java
How does the Stream API work internally? What are the pitfalls of parallelStream?

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.
Likely follow-up: "What's the difference between reduce and collect?" → reduce produces a single immutable value by folding; collect mutates a mutable container (List, Map). Use collect for building collections — reduce into a List would create O(n²) intermediate copies.
Q7 · Async
How does CompletableFuture work? How do you use it correctly under high concurrency?

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.
Likely follow-up: "What happens if the thread pool is saturated?" → supplyAsync blocks trying to submit; or throws RejectedExecutionException depending on the pool's rejection policy.
Q8 · JVM Internals
How does Garbage Collection work? G1 vs ZGC — which do you choose for a low-latency service?

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.
Likely follow-up: "What is IHOP?" → InitiatingHeapOccupancyPercent — the heap fill % at which G1 starts concurrent marking. Too high → Full GC. Too low → wasted CPU on unnecessary marking.