50 Most Asked Java Interview Questions in 2026 — Core Java, Java 25, Spring Boot & Microservices
Preparing for a Java backend interview in 2026? These are the 50 questions that come up most often — each with a substantive, interview-ready answer. Every answer links to a full deep-dive guide in this series when you want the code, the follow-up traps, and the trade-offs.
How to use this: read the question, say your answer out loud, then check. Interviewers score decision rules and failure modes, not definitions — for every answer, reach: when would you use this, what guarantee does it provide, and how does it fail?
Version note (October 2026): Java 25 is the latest LTS (released September 2025); Java 21 remains heavily used in production. Spring Boot 4.1.x (on Spring Framework 7) is the current line, with a Java 17 baseline and support through Java 26. This list weights classic fundamentals plus Java 21/25-era concurrency, Spring Boot 4, and production engineering — because modern features build on the classics.
Core Java & OOP
1. What's the difference between == and equals()?
Answer: the decision rule is identity vs logical equality. == on objects tests whether two references point to the same object in memory. equals() tests logical equality — but only if the class overrides it; the default Object.equals() is just ==. So for your own classes, equals() means whatever you define it to mean. The trap: == on String literals often "works" because of interning, then fails on new String("x") or strings built at runtime — never compare strings with ==. Deep dive →
2. Explain the equals()/hashCode() contract.
Answer: the contract: if a.equals(b), then a.hashCode() == b.hashCode() must hold. Unequal objects may share a hash code (collisions are legal, just slower). The rule is binary: override both or neither. The failure mode is hash-based collections: override equals without hashCode, and two "equal" objects land in different HashMap buckets — get returns null for a key that is logically present. The trap: mutable fields in hashCode — mutate a key after inserting it and the entry becomes unfindable.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Employee e)) return false;
return id == e.id && Objects.equals(name, e.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name); // same fields as equals
}
3. Why is String immutable in Java?
Answer: four reasons, and the interview wants at least three: (1) string pool — literals are shared, which only works if nobody can mutate them; (2) security — class names, file paths, and connection strings can't be tampered with after validation; (3) thread safety — immutable objects need no synchronization; (4) hashCode caching — safe to use as HashMap keys. The cost: every "modification" allocates a new object, which is why concatenation in a loop is a garbage factory. The trap: "immutable" describes the object, not the reference — a non-final String variable can still be reassigned. Deep dive →
4. String vs StringBuilder vs StringBuffer?
Answer: decision rule: String for values, StringBuilder for building. String is immutable — each concatenation creates a new object. StringBuilder is a mutable buffer for assembling strings efficiently. StringBuffer is the same idea but synchronized — a legacy from Java 1.0 that you almost never want: the synchronization cost is real, and sharing a mutable builder across threads is a design smell anyway. The trap: premature optimization in the other direction — for a handful of concatenations, plain + is fine (the compiler uses StringBuilder under the hood); reach for the explicit builder inside loops or hot paths. Deep dive →
5. Abstract class vs interface — when do you use each?
Answer: the decision rule is shared state vs capability. Use an abstract class for an "is-a" relationship where subclasses share state, constructors, or template-method skeletons. Use an interface for a "can-do" capability that cuts across unrelated hierarchies — and you get multiple inheritance of type. Since Java 8, default methods let interfaces carry behavior, which blurs the line, but they still can't carry state — that's the hard boundary. The trap: choosing an interface when every implementer duplicates the same fields and helper methods — that's an abstract class screaming to exist. Deep dive →
6. Method overloading vs overriding?
Answer: overloading is compile-time: same name, different parameter lists, resolved by the compiler based on the static types. Overriding is runtime: a subclass redefines a superclass method with the same signature, resolved by dynamic dispatch on the actual object. The overriding rules: you can't narrow visibility, you can't add new checked exceptions, and the return type must be covariant. The trap that actually bites: overloading combined with autoboxing and varargs — foo(1) may resolve to a different overload than you expect. And always annotate with @Override: a typo'd "override" silently becomes an overload, and the annotation turns that into a compile error. Deep dive →
7. Checked vs unchecked exceptions?
Answer: the decision rule is recoverable vs programming bug. Checked exceptions (IOException, SQLException) represent conditions the caller can plausibly anticipate and recover from — the compiler forces handling. Unchecked (RuntimeException and below: NullPointerException, IllegalArgumentException) signal bugs or invalid usage the caller generally can't fix at runtime. Modern frameworks (Spring included) lean heavily toward unchecked, because forced handling of unrecoverable conditions produces empty catch blocks and throws Exception signatures. The trap: catching Exception broadly "to be safe" — you swallow bugs and make failures invisible. Catch what you can meaningfully handle; let the rest propagate. Deep dive →
8. How do you create an immutable class?
Answer: the recipe: (1) declare the class final so it can't be subclassed into mutability; (2) make all fields private final; (3) no setters; (4) initialize everything in the constructor; (5) defensive copies of any mutable inputs and in getters. Step 5 is where interviews are won and lost — a final List field is not immutable if the caller keeps a reference to the same list.
public final class Employee {
private final String name;
private final List<String> skills;
public Employee(String name, List<String> skills) {
this.name = name;
this.skills = List.copyOf(skills); // defensive copy
}
public List<String> skills() { return skills; } // already unmodifiable
}
The trap: returning the internal mutable collection from a getter "for convenience" — one caller mutates it and every holder of the object sees the change. (For pure data carriers, a record does steps 1–4 for you — see Q26.) Deep dive →
9. final, finally, finalize() — and why is finalize() obsolete?
Answer: final = can't be changed (variable), overridden (method), or extended (class). finally = the block that runs after try/catch no matter what — your cleanup guarantee (unless the JVM dies). finalize() was the GC-called cleanup hook, and it's deprecated for removal because it was unpredictable: no guarantee when (or whether) it runs, and it could resurrect objects. The replacement is try-with-resources for scoped cleanup and java.lang.ref.Cleaner for non-scoped cleanup. The trap: putting a return in try and assuming finally is skipped — finally still runs before the method actually returns, and a return inside finally will even clobber the try's return value. Deep dive →
10. Composition vs inheritance — which do you prefer and why?
Answer: prefer composition as the default; use inheritance only for genuine "is-a" relationships. The reasoning: inheritance exposes you to the fragile base class problem — a change in the superclass silently alters subclass behavior, and you inherit everything, wanted or not. Composition (holding a reference and delegating) keeps the coupling narrow and explicit, and you can swap implementations at runtime. The interview check is Liskov substitutability: if a subclass can't stand in for its parent everywhere without surprises, it shouldn't be a subclass. The trap: inheriting purely for code reuse — that's how you get a Stack extends Vector-style hierarchy where inherited methods violate the subclass's invariants. Deep dive →
Collections & Generics
11. How does HashMap work internally?
Answer: a HashMap is an array of buckets. On put, it spreads the key's hashCode(), computes index = (n - 1) & hash, and stores the entry in that bucket. Collisions chain entries in a linked list — and since Java 8, a bucket treeifies into a red-black tree once it grows past a threshold, so worst-case lookup degrades to O(log n) instead of O(n). When the map exceeds capacity × loadFactor (default 0.75), it resizes — doubles the array and rehashes everything, which is the expensive operation to avoid in hot paths by sizing up front. The failure mode: a bad hashCode() that returns a constant piles every key into one bucket.
12. What happens when two keys have the same hash?
Answer: they share a bucket, and equals() becomes the tiebreaker — the map walks the chain comparing each entry's key with equals until it finds the match. This is exactly why the equals/hashCode contract matters: the hash gets you to the right bucket, equals identifies the key within it. The trap: overriding equals but not hashCode — equal keys scatter across buckets, so the map happily stores "duplicates" and get misses. In interviews, this question is really Q2 wearing a different hat. Deep dive →
13. HashMap vs ConcurrentHashMap?
Answer: the decision rule is who touches it concurrently. HashMap is not thread-safe — concurrent writes can corrupt it (even infinite-loop it on resize in old JDKs). ConcurrentHashMap gives you thread-safe access with fine-grained locking, weakly consistent iterators (no ConcurrentModificationException, may or may not reflect in-flight updates), and atomic compound operations like computeIfAbsent and merge. Two sharp edges: it forbids null keys and values (so a null return unambiguously means "absent"), and individual operations being atomic does not make your check-then-act sequence atomic — if (!map.containsKey(k)) map.put(k, v) still races; use putIfAbsent or compute. The trap: wrapping a HashMap in Collections.synchronizedMap and assuming it scales — every operation takes one global lock. Deep dive →
14. ArrayList vs LinkedList?
Answer: the decision rule is access pattern. ArrayList is a resizable array: O(1) random access, amortized O(1) append, but O(n) insertion/removal in the middle. LinkedList is a doubly-linked list: O(1) insertion at the ends, but O(n) indexed access — and each element carries node overhead plus pointer-chasing that destroys cache locality. In practice, ArrayList wins nearly everywhere; reach for LinkedList only when you genuinely need deque operations at both ends (and even then, ArrayDeque is usually better). The trap: removing elements while iterating with a for-each loop — use Iterator.remove() or removeIf, or you get ConcurrentModificationException. Deep dive →
15. HashSet vs TreeSet?
Answer: HashSet is a HashMap under the hood — O(1) add/contains, no ordering guarantee. TreeSet is a red-black tree — elements stay sorted, O(log n) operations, and it gives you range views (headSet, tailSet). The decision rule: need ordering or range queries → TreeSet; otherwise HashSet. The trap: TreeSet needs its elements to be Comparable or a Comparator supplied — otherwise you get a ClassCastException at runtime, not compile time. Deep dive →
16. Comparable vs Comparator?
Answer: Comparable defines the natural ordering — one per class, implemented by the class itself (compareTo). Comparator defines an external ordering — you can have as many as you want, passed in where sorting happens, and modern code writes them as lambdas (Comparator.comparing(Employee::name)). The decision rule: the class owns its natural order → Comparable; you need alternate orderings or can't modify the class → Comparator. The trap: a compareTo that's inconsistent with equals — a TreeSet then treats "unequal but comparison-zero" elements as duplicates and silently drops them. Deep dive →
17. What is fail-fast iteration?
Answer: most collection iterators are fail-fast: they track a modification count, and if the collection is structurally modified outside the iterator, the next next() throws ConcurrentModificationException. The key insight: it's best-effort detection, not a guarantee — you can't rely on it to catch every concurrent modification, and you definitely can't use the exception for control flow. The fixes: mutate through Iterator.remove(), use bulk operations like removeIf, or switch to a concurrent collection (ConcurrentHashMap, CopyOnWriteArrayList) whose iterators are weakly consistent by design. The trap: "fixing" it with synchronized blocks around iteration while another thread modifies without the same lock — the exception was telling you about a real race. Deep dive →
18. How do Java generics work — and what is type erasure?
Answer: generics give you compile-time type safety without runtime cost: the compiler checks types and inserts casts, then erases the type parameters — at runtime, List<String> is just List. The consequences shape the rules: you can't create arrays of parameterized types (new List<String>[10] is illegal), you can't use instanceof with a parameterized type, and you can't overload two methods that differ only in type parameters. The trap: raw types — using List instead of List<String> disables all checking and invites heap pollution, where a ClassCastException surfaces far from the code that caused it. Deep dive →
19. ? extends T vs ? super T — explain PECS.
Answer: PECS: Producer Extends, Consumer Super. If a generic structure produces T values for you to read, use ? extends T — you can read Ts out, but can't safely put anything in (it might be a List<Integer> behind the ? extends Number). If it consumes T values you write, use ? super T — you can put Ts in, but reads come back as Object. The canonical example is a copy method:
public static <T> void copy(List<? extends T> src, // produces T: read from it
List<? super T> dst) { // consumes T: write to it
for (T item : src) dst.add(item);
}
The trap: reaching for extends on a parameter you also write to — the compiler error is the type system saving you from corrupting someone else's list. Deep dive →
20. Why should HashMap keys generally be immutable?
Answer: because a key's hashCode() decides its bucket at insertion time. Mutate the key afterwards, and its hash changes — but the entry stays in the old bucket, so lookups compute the new hash, search the wrong bucket, and return null for a key that's logically present. The entry isn't deleted; it's lost. The decision rule: keys should be String, Integer, records, or other effectively-immutable types. The trap: using a mutable entity (like a JPA entity with a generated ID that changes from null to a value after persist) as a map key — persist it, then wonder why the cache lookup misses. Deep dive →
Java 8 to Java 25
21. What is a functional interface?
Answer: an interface with exactly one abstract method — that's the target type for lambdas and method references. Runnable, Callable, Function, Predicate, Consumer are the canonical ones. The @FunctionalInterface annotation is optional but valuable: the compiler then enforces the single-method rule, so a well-meaning teammate can't add a second abstract method and silently break every lambda assigned to it. Note the fine print: default and static methods don't count toward the limit, and methods inherited from Object (like equals) don't either. The trap: designing your own when a java.util.function type fits — prefer the standard vocabulary so readers recognize the shape instantly. Deep dive →
22. map() vs flatMap() on streams?
Answer: map is a one-to-one transform: Stream<T> → Stream<R>. flatMap is a one-to-many transform that flattens: each element maps to a stream, and the results are concatenated into a single stream. The tell: if your mapping function returns a collection or stream and you use map, you get a nested Stream<List<X>> — that's the code smell flatMap exists to fix. Classic case: orders → line items. The trap: flatMap on an infinite or very large inner stream — the flattening is lazy, but a careless terminal operation still tries to consume everything. Deep dive →
23. How do Streams work — and when shouldn't you use them?
Answer: a stream is a lazy pipeline: intermediate operations (map, filter) build the pipeline without doing work, and a single terminal operation (collect, reduce, forEach) triggers one fused pass over the data. Streams are single-use — reuse a stream and you get IllegalStateException. When not to use them: tight numeric loops where indexed array access is measurably faster, lambdas that throw checked exceptions (the wrapping ceremony kills readability), and pipelines so long they need a debugger to understand. The decision rule: streams for what (declarative transformations), loops for how (fine-grained control, early exit with complex state). The trap: assuming streams are automatically parallel or faster — a sequential stream is often slightly slower than a plain loop; you pay for readability, not speed. Deep dive →
24. Intermediate vs terminal Stream operations?
Answer: intermediate operations return a Stream and are lazy — map, filter, sorted, limit just describe the pipeline. Terminal operations return something else (or void) and are eager — collect, forEach, reduce, count, anyMatch actually pull data through. Two consequences interviewers probe: (1) a pipeline with no terminal operation does nothing at all — a classic "why didn't my filter run?" bug; (2) short-circuiting terminals like findFirst and anyMatch can stop the pipeline early, which is why limit() composes so well with expensive sources. The trap: side effects in intermediate operations — they may run zero, one, or many times depending on fusion and short-circuiting, so keep lambdas pure. Deep dive →
25. What is Optional — and where shouldn't you use it?
Answer: Optional<T> is a container that makes "maybe absent" explicit in the return type — it forces callers to confront absence instead of forgetting a null check. Use it as a method return type for lookups that may find nothing. Don't use it for fields (it's not Serializable and adds overhead), method parameters (just overload or accept null with Objects.requireNonNull), or collections (return an empty collection — an Optional<List<X>> is two layers of "maybe"). The trap: optional.get() without checking — that's a NoSuchElementException wearing a trench coat. Prefer orElseThrow, map/flatMap, and ifPresentOrElse. Deep dive →
26. What are records — and are they truly immutable?
Answer: records are compact data carriers: one declaration gives you a final class with final fields, a canonical constructor, and generated equals/hashCode/toString. They're "immutable" only shallowly — if a component is a mutable List, the record's state can still change through it. The fix is the compact constructor, which lets you normalize or defensively copy components:
public record Order(String id, List<String> items) {
public Order {
Objects.requireNonNull(id);
items = List.copyOf(items); // now genuinely immutable
}
}
The decision rule: records for transparent data holders (DTOs, keys, results); regular classes when you need mutable state, inheritance, or behavior-heavy design. The trap: treating a record as a drop-in bean — no setters, no no-arg constructor, so frameworks that demand them need configuration. Deep dive →
27. What problem do sealed classes solve?
Answer: they let you close a hierarchy: sealed interface Expr permits Const, Add, Mul declares exactly which types may implement it, and the compiler enforces it. The payoff is exhaustiveness — with pattern matching in switch (Q28), the compiler can prove you've handled every permitted subtype, so no default branch is needed and adding a new subtype breaks compilation at every switch that must handle it. The decision rule: sealed for closed domains you control (AST nodes, result types, state machines); open interfaces for extension points others implement. The trap: sealing a hierarchy that genuinely needs third-party extension — you've traded flexibility for safety, so make sure the domain is actually closed. Deep dive →
28. Explain pattern matching and the modern switch.
Answer: pattern matching collapses the type-test-and-cast ceremony: if (obj instanceof String s) binds s directly, scoped to where the test passed. The modern switch builds on this — it's an expression (yields a value), supports type patterns and when guards, and over a sealed hierarchy the compiler verifies exhaustiveness:
sealed interface Expr permits Const, Add { }
record Const(int value) implements Expr { }
record Add(Expr left, Expr right) implements Expr { }
int eval(Expr e) {
return switch (e) {
case Const(int v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
// no default needed: compiler proves exhaustiveness
};
}
Two sharp edges: a switch on null throws NullPointerException unless you add case null, and pattern variables only flow where the compiler can prove the match — the scoping rules are precise, not magical. The trap: mixing old statement-switch habits (fall-through, break) into expression switches — arrow cases don't fall through, and every arm must yield a compatible value. Deep dive →
29. What are virtual threads — and when should you use them?
Answer: virtual threads are JVM-managed threads: cheap enough to create one per task (millions are feasible), scheduled by the JVM onto a small pool of platform carrier threads. When a virtual thread blocks on I/O, the JVM unmounts it and frees the carrier for other work — so blocking code no longer wastes an OS thread. The decision rule: reach for them with high-throughput workloads dominated by blocking I/O (web servers, JDBC calls, REST clients). They improve throughput, not speed — your code doesn't run faster, you just handle far more concurrent operations. Don't use them for CPU-bound work (no benefit; a bounded platform pool is the tool) or tasks needing thread affinity. Since JEP 491 (JDK 24), blocking inside synchronized blocks also unmounts — the old pinning limitation is gone for monitor code (native/FFM calls can still pin).
// one virtual thread per task; never pooled
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var url : urls) {
executor.submit(() -> fetch(url)); // blocking I/O is fine here
}
} // try-with-resources waits for all tasks
The trap: sprinkling virtual threads over a CPU-bound computation and expecting a speedup — you'll just add scheduling overhead. Deep dive →
30. Platform threads vs virtual threads?
Answer: platform threads are OS threads — expensive (megabyte-scale stacks), so you pool them and size the pool deliberately. Virtual threads are JVM objects — cheap, so you create one per task and never pool them. The decision table: blocking I/O at high concurrency → virtual threads, unbounded; CPU-bound work → platform threads, pool sized around core count; mixed → virtual threads for the I/O fan-out, with scarce resources (DB connections, downstream rate limits) bounded separately via semaphores or pools (see Q37). The trap: porting a fixed-size platform pool to a fixed-size virtual thread pool — you've kept the exact bottleneck virtual threads were meant to remove. Deep dive →
Concurrency & JVM
31. synchronized vs ReentrantLock?
Answer: the decision rule: default to synchronized; reach for ReentrantLock only when you need its extras. synchronized is simpler, JVM-managed, and — since JEP 491 (JDK 24) — a virtual thread blocking inside a synchronized block now unmounts from its carrier instead of pinning it, removing the old reason to reflexively rewrite monitors as locks. ReentrantLock earns its verbosity when you need tryLock() with a timeout, interruptible lock acquisition, fairness policies, or multiple Condition objects — or when the critical section calls native/FFM code, which can still pin a virtual thread.
private final ReentrantLock lock = new ReentrantLock();
public void update() {
lock.lock();
try {
// critical section
} finally {
lock.unlock(); // never forget: unlock in finally
}
}
The trap: assuming synchronized is "slow" and preemptively replacing it everywhere — on modern JVMs uncontended monitors are cheap, and the readability cost of explicit locks is real. Deep dive →
32. What does volatile guarantee?
Answer: volatile gives you visibility plus ordering: every write to a volatile variable is immediately visible to other threads, and it establishes a happens-before edge that prevents instruction reordering around it. That's what makes the double-checked-locking idiom (with the field declared volatile) correct. What it does not give you is atomicity — volatile int count; count++ still races, because read-modify-write is three operations. The decision rule: volatile for flags and safe publication of immutable state (volatile boolean shutdown = false); anything involving compound updates needs synchronized, a lock, or an atomic class. The trap: "volatile makes it thread-safe" — it makes writes visible, which is only half of thread safety. Deep dive →
33. Atomicity vs visibility — what's the difference?
Answer: atomicity = an operation completes as one indivisible unit (no thread sees it half-done). visibility = a write by one thread is seen by others (not stuck in a CPU cache or reordered away). You need both, and they come from different tools: volatile fixes visibility only; synchronized/locks fix both; java.util.concurrent.atomic (AtomicInteger, AtomicReference) gives lock-free atomicity for single variables via CAS. The canonical broken example is the volatile counter from Q32 — visibility is perfect, atomicity is absent, increments get lost. The trap: reasoning about either in isolation — most real concurrency bugs are a combination (check-then-act needs atomicity of the compound action plus visibility of the check). Deep dive →
34. What is a race condition?
Answer: a race condition is when correctness depends on the timing of thread interleaving — the classic shapes are check-then-act (if (!map.containsKey(k)) map.put(k, expensiveCompute()) — two threads both see absence, both compute) and read-modify-write (balance += amount — two threads read the same value, one update is lost). The fix is making the compound action atomic: ConcurrentHashMap.computeIfAbsent, AtomicInteger.addAndGet, or a lock around the sequence. The trap that matters in interviews: testing can't prove absence — a race that never manifests on your laptop manifests under production load. You reason about races from the code (shared mutable state + no synchronization = suspect), not from test results. Deep dive →
35. What causes deadlock — and how do you diagnose it?
Answer: deadlock needs all four Coffman conditions: mutual exclusion, hold-and-wait, no preemption, and circular wait. Break any one and deadlock is impossible — in practice you break circular wait with a global lock ordering (always acquire locks in the same order) and hold-and-wait with tryLock timeouts. Diagnosis: take a thread dump (jstack <pid>) — the JVM's deadlock detector literally prints "Found one Java-level deadlock" with the cycle. The trap: assuming deadlock requires many threads — two threads and two locks acquired in opposite order is enough, and it's the most common real-world shape. Also watch for livelock (threads keep retrying and failing) when you "fix" deadlock with aggressive retry loops. Deep dive →
36. ExecutorService vs Future vs CompletableFuture?
Answer: three layers: ExecutorService manages the threads (pooling, lifecycle); Future is a handle to one async result — but get() blocks, and there's no clean way to compose or handle errors; CompletableFuture is a composable async pipeline — thenApply, thenCombine, exceptionally, allOf — without blocking threads to wait.
CompletableFuture<User> user = supplyAsync(() -> userRepo.find(id), ioPool);
CompletableFuture<Order> order = supplyAsync(() -> orderRepo.latest(id), ioPool);
user.thenCombine(order, Summary::new) // fan-in, no blocking
.exceptionally(ex -> Summary.fallback(id)); // error path is explicit
The trap: the two-argument overloads without an executor silently use the common ForkJoinPool — fine for CPU-bound, but blocking I/O there starves every other user of the pool. Always pass an explicit executor for I/O. (And note: on virtual threads, plain blocking code often beats a CompletableFuture chain for readability — see Q29.) Deep dive →
37. How should concurrency limiting change when using virtual threads?
Answer: stop limiting threads and start limiting resources — they're now separate concerns. The old pattern (newFixedThreadPool(10)) conflated "how many tasks run" with "how many may hit the database"; with virtual threads you want millions of the former and a bounded number of the latter. Oracle's guidance: don't pool virtual threads; bound scarce resources with a Semaphore (or let the connection pool do it).
Semaphore dbPermits = new Semaphore(20); // DB capacity, not thread count
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var task : tasks) {
executor.submit(() -> {
dbPermits.acquire(); // blocks the *virtual* thread: cheap
try { return query(task); }
finally { dbPermits.release(); }
});
}
}
The trap: sizing the semaphore from thread math instead of downstream capacity — the right number comes from what the database or API can handle, measured, not from core counts. Deep dive →
38. Heap vs stack vs metaspace?
Answer: the stack is per-thread: method frames holding local variables and references — small, fast, and where StackOverflowError comes from (runaway recursion). The heap is shared: all objects live here, split into young generation (eden + survivor spaces, where most objects die young) and old generation (long-lived survivors) — and it's what the garbage collector manages. Metaspace (native memory, not heap) holds class metadata — where OutOfMemoryError: Metaspace comes from when classloaders leak (hot redeploys, dynamic proxies). The interview signal: matching the error to the area — stack overflow is a code problem, heap OOM is an allocation/retention problem, metaspace OOM is a classloader problem — because each demands a different fix.
39. How does garbage collection work at a high level?
Answer: the GC reclaims objects that are unreachable from GC roots (thread stacks, statics). The generational hypothesis drives the design: most objects die young, so the collector scans the young generation frequently and cheaply (copying survivors), and the old generation rarely (mark-sweep-compact). Your collector choice is a pause-time tradeoff: G1 (the default) balances throughput and pauses; ZGC/Shenandoah target minimal pauses for latency-sensitive services. The decision rule for interviews: don't tune blindly — set a pause-time goal, measure with GC logs (-Xlog:gc), and most applications never need to touch the defaults. The trap: System.gc() — it's a hint the JVM may ignore, and calling it in production code is how you manufacture the pauses you were trying to avoid. Deep dive →
40. How would you diagnose high CPU, memory growth, deadlock, or long GC pauses?
Answer: the method is always metrics first, dumps second — don't guess. High CPU: top -H to find the spinning native thread, convert its ID to hex, and match it against a jstack thread dump to see the exact stack. Memory growth: take a heap dump (jmap -dump or on OOM automatically) and open it in a heap analyzer — look at the dominator tree for what's retaining the memory; a flat, ever-growing old gen is the leak signature. Deadlock: jstack prints detected deadlocks directly (see Q35). Long GC pauses: GC logs show pause times and causes — then check whether it's allocation rate (too much garbage) or retention (leak forcing full GCs). The trap: restarting the service "to fix it" before capturing any dump — you've destroyed the only evidence, and the problem will be back by morning. Deep dive →
Spring Boot, JPA & Microservices
41. How does Spring Dependency Injection work — and why prefer constructor injection?
Answer: the IoC container owns object creation: you declare what a component needs, and Spring provides it at startup, instead of the component constructing its own dependencies. Constructor injection is preferred because it makes dependencies explicit and immutable — required collaborators are visible in the signature, the object can't exist in a half-wired state, and tests can instantiate the class with plain new and mocks. Field injection hides dependencies (you can't tell what a class needs without reading its body) and makes unit testing awkward. The trap: circular dependencies — constructor injection fails fast at startup with a clear error, which is good; field injection lets the cycle hide until runtime. A cycle is a design smell either way: extract the shared logic. Deep dive →
42. @Component vs @Service vs @Repository vs @RestController?
Answer: @Component is the generic stereotype. @Service marks the service layer (no extra behavior, but it documents intent); @Repository marks DAOs and enables persistence exception translation (vendor-specific exceptions become Spring's DataAccessException hierarchy); @RestController is @Controller + @ResponseBody — every method's return value is serialized to the response body. The decision rule: use the most specific stereotype — future readers (and tooling) should know a class's role from its annotation. The trap: annotating everything @Component "because it works" — you lose the exception translation on repositories and the readability everywhere else. Deep dive →
43. How does Spring Boot auto-configuration work?
Answer: @SpringBootApplication bundles three things: @Configuration, @ComponentScan, and @EnableAutoConfiguration. Auto-configuration classes (from starters) declare beans conditionally — @ConditionalOnClass (only if the library is on the classpath), @ConditionalOnMissingBean (only if you didn't define your own), @ConditionalOnProperty (only if a property says so). So adding spring-boot-starter-data-jpa to the classpath silently wires a DataSource, EntityManagerFactory, and transaction manager — until you define your own bean, which takes precedence. The trap: component scanning pulling in configuration from a package you didn't intend — keep your application class at the root of your packages, and use --debug (the auto-configuration report) when a bean isn't what you expected. Deep dive →
44. How does @Transactional work — and why does self-invocation break it?
Answer: @Transactional is proxy-based AOP: Spring wraps your bean in a proxy, and the proxy starts/commits/rolls back the transaction around the method call. The failure mode is self-invocation — when outer() calls this.inner(), the call never passes through the proxy, so @Transactional on inner() is silently ignored (no transaction, no rollback semantics). The fixes: move the transactional method to a separate bean, self-inject the proxy, or use TransactionTemplate programmatically. The trap: "it worked in my test" — tests often call the method directly on the bean (through the proxy) and never exercise the internal call path that production hits.
45. Transaction propagation vs isolation?
Answer: propagation answers "what happens when a transactional method calls another transactional method": REQUIRED (default — join the existing transaction), REQUIRES_NEW (suspend the outer, start an independent one), NESTED, and others. Isolation answers "what can concurrent transactions see": READ_COMMITTED (the PostgreSQL/Oracle default — no dirty reads), REPEATABLE_READ, SERIALIZABLE (strongest, most contention). They're orthogonal knobs: propagation scopes the unit of work, isolation scopes visibility. The decision rule: REQUIRES_NEW for work that must commit independently (audit logs); raise isolation only when you've identified a real anomaly (lost update, phantom read), because each level costs concurrency. The trap: REQUIRES_NEW on a method whose failure you then swallow — the inner transaction committed, the outer rolled back, and your data is now half-written by design. Deep dive →
46. LAZY vs EAGER loading — and the JPA N+1 problem?
Answer: LAZY loads an association only when accessed; EAGER loads it immediately with the parent. The N+1 problem: one query fetches N parents, then N additional queries fetch each parent's children one by one — 101 queries for 100 orders. The fix is fetching the association in the original query:
// one query with a join, not N+1
@Query("select o from Order o join fetch o.items where o.id = :id")
Order findWithItems(@Param("id") Long id);
Alternatives: @EntityGraph for declarative fetch plans. The decision rule: default to LAZY everywhere (JPA's own default for collections), and fix N+1 at the query level where you know the access pattern. The trap: LAZY outside a transaction → LazyInitializationException; "fixing" it with Open-Session-in-View just moves the N+1 queries into the view layer where nobody's watching. Deep dive →
47. Optimistic vs pessimistic locking?
Answer: the decision rule is contention level. Optimistic locking (@Version column) takes no database lock — it checks at commit time that nobody changed the row since you read it, and throws OptimisticLockException on conflict. Cheap under low contention; you must retry on conflict or the failure just reaches the user. Pessimistic locking (SELECT ... FOR UPDATE via LockModeType.PESSIMISTIC_WRITE) takes a real DB lock — concurrent writers block, which is what you want when conflicts are likely or correctness demands serialization (seat booking, inventory decrement). The trap: optimistic locking without a retry policy — you've converted silent lost updates into loud exceptions but still haven't handled the conflict. And pessimistic locking held across slow business logic is how you build a lock-contention outage. Deep dive →
48. How do you make a REST operation idempotent?
Answer: idempotent means repeating the request has the same effect as sending it once — essential because networks retry. The pattern is the idempotency key: the client generates a unique key per logical operation and sends it in a header; the server stores the key with the result, and a repeated key returns the stored result instead of re-executing. By HTTP semantics, GET/PUT/DELETE should already be idempotent by design; POST is where keys matter (payments, order creation). Sketch:
// filter/interceptor pseudocode
String key = request.getHeader("Idempotency-Key");
if (store.exists(key)) return store.getResult(key); // replay: same response
var result = process(request);
store.save(key, result);
return result;
The trap: keys without a TTL or cleanup strategy — the store grows forever. And the deeper trap: an idempotency key on a non-deterministic operation (one that embeds timestamps or random values in the result) — replay returns a stale result that doesn't match what a fresh execution would produce. Deep dive →
49. How do you prevent cascading failures across services?
Answer: defense in layers, cheapest first: (1) timeouts on every outbound call — a call without a timeout is a thread held hostage; (2) bounded retries with exponential backoff and jitter — retry only idempotent operations, and jitter prevents synchronized retry stampedes; (3) circuit breaker — when a downstream's failure rate crosses a threshold, fail fast for a cooldown period instead of piling on; (4) bulkhead — isolate thread pools per dependency so one slow service can't exhaust the pool others need. The decision rule: timeouts are non-negotiable on day one; add breakers when you have a dependency whose failure shouldn't sink you. The trap that causes the disasters: unbounded retries — a struggling service gets hit harder the sicker it gets, and your "resilience" becomes the retry storm that finishes it off. Deep dive →
50. The DB transaction succeeds but message publication fails — how does the transactional outbox solve it?
Answer: the problem is atomicity across two systems: you can't commit a database transaction and publish to a broker in one atomic step — publish-then-commit can lose the message on crash; commit-then-publish can publish a message for a rolled-back transaction. The transactional outbox pattern: write the event to an OUTBOX table in the same database transaction as the business change, then a separate relay reads unpublished outbox rows and publishes them to the broker, marking each as sent.
The guarantee is at-least-once delivery: the relay may publish twice (crash between publish and mark-sent), so consumers must be idempotent (see Q48). The trap: implementing the relay as naive polling on a huge outbox table without an index on the unpublished flag — your reliability mechanism becomes the query that melts the database. Change-data-capture (Debezium reading the DB log) is the heavier-duty alternative. Deep dive →
In this series
- 50 Most Asked Java Interview Questions in 2026 — Core Java, Java 25, Spring Boot & Microservices (this post) — start here.
- Top 40 Java Interview Questions and Answers (2026 Edition) — the 40-question companion set.
- Java 17 to 21 Interview Questions: Records, Pattern Matching, Sealed Classes & More — modern language deep dive.
- Spring Boot Interview Questions and Answers — DI, transactions, JPA.
- Java Multithreading and Concurrency Interview Questions — threads, locks, virtual threads, JVM.
- Microservices Interview Questions for Java Developers — resilience and distributed patterns.
Related: System Design Interviews: A Practical Primer — the 4-step framework these Java questions plug into.
Comments
Post a Comment