Java Interview Questions and Answers
Last updated:
Check out 60 of the most common Java interview questions, then take an AI-powered practice interview
Q1Walk through what actually happens between running javac Hello.java and seeing output on screen.
BasicFundamentals
Answer
javac compiles the source into Hello.class, a file of JVM bytecode, a stack-based instruction set defined by the JVM specification. Nothing is machine code yet. When you run java Hello, the JVM starts, and the class loader hierarchy (bootstrap loader for java.base, the platform loader, then the application loader reading the classpath) locates and loads the class.
The bytecode verifier then proves the class is structurally safe: no stack underflows, no illegal casts, no jumps into the middle of instructions. Execution begins in the interpreter, and as methods get hot the JIT compiler kicks in: C1 produces quickly-compiled code with light optimization, and C2 recompiles the hottest methods with aggressive optimizations like inlining and escape analysis, using profile data gathered while interpreting. This is why Java benchmarks need warmup and why a freshly started service is slower than one that has been serving traffic for ten minutes.
Two modern wrinkles interviewers like: since Java 11 you can skip the explicit compile step for single files with java Hello.java (the launcher compiles in memory), and Java 25 finalized compact source files, so a beginner program can literally be void main() { IO.println("hi"); } with no class declaration. Mentioning class loading, verification, and tiered JIT compilation, rather than just saying 'Java is compiled and interpreted', is what separates a real answer from a memorized one.
// Classic form
public class Hello {
public static void main(String[] args) {
System.out.println("Hello from bytecode");
}
}
// Compile then run
// $ javac Hello.java -> produces Hello.class (bytecode)
// $ java Hello -> class loading, verification, interpret + JIT
// Since Java 11: single-file source launch, no javac step
// $ java Hello.java
// Inspect the bytecode the JVM actually executes
// $ javap -c Hello
Key Points
- javac produces bytecode, not machine code
- Class loaders: bootstrap -> platform -> application
- Bytecode verification happens before execution
- Tiered JIT: interpreter -> C1 -> C2 with profile-guided optimization
- java Hello.java works without javac since Java 11
Q2What is the difference between the JDK, JRE and JVM, and why does nobody ship a standalone JRE anymore?
BasicFundamentals
Answer
The JVM is the virtual machine itself: the class loader, verifier, interpreter, JIT compilers and garbage collectors that execute bytecode. The JRE historically meant JVM plus the standard class library, enough to run programs but not compile them. The JDK is the full development kit: JRE plus javac, jar, javadoc, javap, jlink, jcmd, jstack and the rest of the tooling.
The catch interviewers look for: since Java 11, Oracle and the OpenJDK vendors stopped shipping a separate JRE download. The modern deployment story is either 'install a full JDK' (Temurin, Oracle, Amazon Corretto, Azul Zulu are the common distributions in Indian companies) or 'build your own minimal runtime' with jlink, which assembles a stripped runtime containing only the modules your application needs. A jlink image for a typical service is 50-90 MB instead of a 300+ MB JDK, which matters for Docker image size and cold-start time.
You find out which modules you need with jdeps, which analyzes your jar's dependencies. In containerized deployments, the standard pattern is a multi-stage Dockerfile: build with a full JDK image, then copy a jlink runtime plus your app into a slim base image. If asked 'what is JAVA_HOME', the answer is simply the environment variable pointing at the JDK installation root that build tools like Maven and Gradle use to pick which Java they run.
# Find which JDK modules your application actually uses
jdeps --print-module-deps --ignore-missing-deps app.jar
# -> java.base,java.sql,java.naming
# Build a minimal runtime with only those modules
jlink --add-modules java.base,java.sql,java.naming \
--strip-debug --no-header-files --no-man-pages \
--output custom-runtime
# Run the app on the trimmed runtime
./custom-runtime/bin/java -jar app.jar
Key Points
- JVM executes bytecode; JDK = JVM + class library + tools
- No standalone JRE downloads since Java 11
- jlink builds minimal runtimes; jdeps finds required modules
- Common distributions: Temurin, Corretto, Zulu, Oracle JDK
Q3Why is String immutable in Java, and how does the string pool interact with == and equals()?
BasicStrings
Answer
String immutability is a deliberate design decision with several payoffs. Security: strings carry file paths, JDBC URLs and class names, and an attacker must not be able to mutate one after validation. Safe sharing: immutable strings can be shared across threads with no synchronization.
Caching: String caches its hashCode after first computation (a field named hash), which is why String keys in HashMap are fast. And pooling: because strings cannot change, identical literals can share one instance. The string pool is a JVM-managed table (held on the heap since Java 7) where every compile-time literal is interned, so "gs" == "gs" is true because both refer to the same pooled object.
But new String("gs") explicitly allocates a fresh object on the heap, so == returns false while equals() returns true. You can push a runtime-computed string into the pool with intern(), though doing this at scale is usually a smell; deduplication is better handled by the collector flag -XX:+UseStringDeduplication with G1. One more internals point worth volunteering: since Java 9, String stores a byte[] rather than a char[] (compact strings), using one byte per character for Latin-1 content, which roughly halves the memory of typical English text. The practical rule stays simple: compare strings with equals() or equalsIgnoreCase(), never ==, and let the pool remain an implementation detail.
String a = "goodspace";
String b = "goodspace";
String c = new String("goodspace");
System.out.println(a == b); // true, both point at the pooled literal
System.out.println(a == c); // false, c is a distinct heap object
System.out.println(a.equals(c)); // true, same characters
String d = c.intern();
System.out.println(a == d); // true, intern() returned the pooled instance
// Concatenation of constants is folded at compile time:
String e = "good" + "space";
System.out.println(a == e); // true, javac pooled the folded literal
Q4Explain the equals() and hashCode() contract and what breaks when you violate it.
BasicLanguage Basics
Answer
The contract from java.lang.Object: if two objects are equal per equals(), they must return the same hashCode(); unequal objects may share a hash code (collisions are legal); and hashCode() must be stable across calls unless a field used in equals() changes. Violate it and hash-based collections silently corrupt. The classic failure: you override equals() but not hashCode(), put an object in a HashSet, then a logically equal object 'is not found' because it hashes to a different bucket.
Nastier still is mutating a key after insertion: the entry now sits in the bucket computed from the old hash, so map.get(key) with the very same reference can return null, and the entry becomes unreachable garbage inside the map. That is a real production bug pattern with HashSet of entities whose id is assigned only when the database persists them. Practical guidance: implement both methods from the same immutable field set, use Objects.equals() and Objects.hash() for null-safe brevity, and start equals() with an identity check (this == o) plus a getClass() or instanceof check, knowing the instanceof variant interacts with subclassing (a subclass that adds state breaks symmetry).
Better yet, use a record, which generates correct equals, hashCode and toString from its components. Interviewers often finish by asking why hashCode() matters for HashMap performance: a poor hash function clusters keys into few buckets, degrading O(1) lookups toward O(log n) after Java 8 treeifies the bin, or O(n) before that.
import java.util.Objects;
public final class OrderId {
private final String tenant;
private final long value;
public OrderId(String tenant, long value) {
this.tenant = tenant;
this.value = value;
}
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderId other)) return false; // pattern variable, Java 16+
return value == other.value && Objects.equals(tenant, other.tenant);
}
@Override public int hashCode() {
return Objects.hash(tenant, value);
}
}
// Or simply:
record OrderIdRec(String tenant, long value) {} // equals/hashCode generated
Key Points
- Equal objects must have equal hash codes; the reverse is not required
- Overriding only equals() breaks HashSet and HashMap lookups
- Never mutate fields used in hashCode() while the object is a map key
- Records generate a correct implementation for free
Q5Why does == sometimes work for small Integers but fail for larger ones?
BasicLanguage Basics
Answer
Because of the Integer cache. Autoboxing compiles to Integer.valueOf(int), and valueOf() returns cached instances for values from -128 to 127 (the upper bound is tunable with -XX:AutoBoxCacheMax or the java.lang.Integer.IntegerCache.high system property; the lower bound is fixed). So Integer a = 100; Integer b = 100; a == b is true because both variables reference the same cached object, while the identical code with 1000 creates two distinct heap objects and == is false.
This is one of the most reliable trick questions in Indian service-company interviews, and it generalizes: Long, Short, Byte and Character have similar caches, Boolean caches TRUE and FALSE, and Double and Float cache nothing. The rules to internalize: compare boxed values with equals() or unbox first; never use == between two wrapper objects. Two adjacent gotchas usually follow.
First, unboxing null: if a method returns Integer and you assign it to an int, a null triggers a NullPointerException at the unboxing site, a common crash when reading nullable database columns via JDBC's getInt versus getObject. Second, mixed comparison: with int x = 1000; Integer y = 1000; x == y is true, because y is unboxed to a primitive before comparing, so the cache never matters when one side is primitive. In modern code the deeper advice is to keep hot paths primitive (avoid boxing in loops, prefer IntStream over Stream<Integer>) because boxing allocates and pollutes cache lines, which shows up clearly in JMH benchmarks.
Integer a = 100, b = 100;
Integer c = 1000, d = 1000;
System.out.println(a == b); // true (cached, -128..127)
System.out.println(c == d); // false (two heap objects)
System.out.println(c.equals(d)); // true
int x = 1000;
System.out.println(x == c); // true, c is unboxed to int
Integer maybeNull = null;
// int boom = maybeNull; // NullPointerException at unboxing
// Hot path: avoid boxing entirely
long sum = java.util.stream.IntStream.rangeClosed(1, 1_000_000).asLongStream().sum();
Q6When do you actually need StringBuilder, and what does the + operator compile to now?
BasicStrings
Answer
Since Java 9 (JEP 280), a single expression like name + " scored " + score no longer compiles to a chain of StringBuilder.append calls; javac emits an invokedynamic call to StringConcatFactory, and the JVM picks an efficient concatenation strategy at runtime, often faster than hand-written builders. So the old advice 'always use StringBuilder' is stale for one-off concatenation. Where StringBuilder still matters is repeated concatenation in a loop: str += chunk inside a loop creates a new String every iteration, copying all previous characters, giving O(n^2) behavior; a StringBuilder appends into a growing internal buffer and is O(n).
Pre-size it with new StringBuilder(expectedLength) when you know the ballpark, because each capacity doubling copies the buffer. StringBuffer is the legacy synchronized twin from Java 1.0; its per-method locks do not make compound operations atomic and it has no place in new code, a point interviewers use to check whether you parrot old textbooks. Also know the higher-level tools: String.join(",", list) and Collectors.joining(", ", "[", "]") cover most real formatting needs, StringJoiner underlies both, and text blocks handle multi-line content. For building large payloads (say, a CSV export), write through a BufferedWriter or stream directly to the HTTP response instead of assembling a giant String in memory; assembling a 200 MB String before writing it is a genuine OutOfMemoryError pattern in export endpoints.
// Fine in modern Java: compiles to invokedynamic, not naive appends
String msg = user + " scored " + score + " at " + instant;
// BAD: O(n^2), allocates a new String each iteration
String csv = "";
for (String row : rows) csv += row + "\n";
// GOOD: O(n), single growing buffer
StringBuilder sb = new StringBuilder(rows.size() * 32);
for (String row : rows) sb.append(row).append('\n');
// Often better: intent-revealing joiners
String joined = String.join(", ", rows);
String wrapped = rows.stream()
.collect(java.util.stream.Collectors.joining(", ", "[", "]"));
Key Points
- Single + expressions use invokedynamic (JEP 280), not StringBuilder chains
- Loops still need StringBuilder: += in a loop is O(n^2)
- StringBuffer is legacy; do not use it in new code
- String.join and Collectors.joining beat manual building for readability
Q7Checked versus unchecked exceptions: how do you decide which to throw in a service codebase?
BasicExceptions
Answer
The mechanics: everything throwable extends Throwable. Error (OutOfMemoryError, StackOverflowError) signals JVM-level failure you should not catch. Exception is checked by default, meaning the compiler forces callers to catch or declare it; RuntimeException and its subclasses (NullPointerException, IllegalArgumentException, IllegalStateException, IndexOutOfBoundsException) are unchecked.
The design intent was that checked exceptions mark recoverable conditions the caller must consciously handle (file missing, connection refused) while unchecked ones mark programming bugs. In practice, modern Java service code has drifted heavily toward unchecked. Reasons worth stating in an interview: checked exceptions do not compose with lambdas and streams (Function cannot throw IOException, forcing ugly wrappers), they leak low-level detail through layers (a repository forcing SQLException on a controller), and frameworks reflect this, Spring translates SQLException into its unchecked DataAccessException hierarchy, and Hibernate went unchecked back in version 3.
A defensible policy: throw unchecked domain exceptions (InsufficientBalanceException extends RuntimeException) carrying an error code, translate third-party checked exceptions at the boundary where they occur, and reserve checked exceptions for narrow APIs where the caller realistically recovers, retryable I/O being the main case. Know UncheckedIOException as the standard wrapper the JDK itself uses (Files.lines throws it from stream operations). And never swallow: an empty catch block that logs nothing turns a ten-minute incident into a day-long hunt. Catch narrowly, add context, rethrow or handle, and let a top-level handler map exceptions to HTTP responses.
Key Points
- Checked = compiler-enforced handling; unchecked = RuntimeException subtree
- Lambdas and streams push modern code toward unchecked
- Spring and Hibernate translate checked SQL exceptions to unchecked
- Wrap third-party checked exceptions at the boundary with domain context
- Empty catch blocks are the cardinal sin interviewers probe for
Q8How does try-with-resources work, and what are suppressed exceptions?
BasicExceptions
Answer
try-with-resources (Java 7) automatically closes anything implementing AutoCloseable when the block exits, normally or via exception. Declare one or more resources in the parentheses; they are closed in reverse declaration order, which matters when resources wrap each other (close the ResultSet before the Statement before the Connection). Since Java 9 you can also use an existing effectively-final variable directly: try (connection) { ... }.
The subtle part is suppressed exceptions. Suppose the try block throws a SQLException and then close() itself throws. Pre-Java 7 finally blocks would let the close() exception replace the original, destroying the real cause. try-with-resources keeps the original exception as primary and attaches the close() failure via addSuppressed(); you can inspect them with getSuppressed(), and stack traces print them under 'Suppressed:'.
Interviewers use this to separate candidates who have read stack traces in production from those who have not. Practical notes: always use try-with-resources for JDBC objects, InputStream/OutputStream, java.nio channels, and anything from Files.lines() or Files.newBufferedReader(); a forgotten close on Files.list() leaks a file descriptor and eventually throws 'Too many open files' under load. Resources are closed even if the body returns early. And if a class you own holds a native or pooled resource, implement AutoCloseable yourself so callers get this machinery, rather than exposing a bespoke shutdown() that people forget to call.
record User(long id, String email) {}
try (var conn = dataSource.getConnection();
var ps = conn.prepareStatement("SELECT id, email FROM users WHERE id = ?")) {
ps.setLong(1, userId);
try (var rs = ps.executeQuery()) {
if (rs.next()) {
return new User(rs.getLong("id"), rs.getString("email"));
}
}
} // rs, ps, conn closed in reverse order, even on exception
// Suppressed exceptions:
try (var res = new FlakyResource()) { // close() throws too
throw new IllegalStateException("body failed");
} catch (IllegalStateException e) {
for (Throwable s : e.getSuppressed()) {
System.err.println("suppressed during close: " + s);
}
throw e;
}
Key Points
- Works with any AutoCloseable; resources close in reverse order
- Primary exception wins; close() failures become suppressed
- Java 9+: effectively-final variables usable directly in try()
- Leaked streams from Files.list() cause 'Too many open files' in production
Q9What do final, finally and the removal of finalize() each mean in modern Java?
BasicLanguage Basics
Answer
Three unrelated things that interviews bundle because juniors confuse them. final on a variable means single assignment (for object references it freezes the reference, not the object's state); on a method it forbids overriding; on a class it forbids subclassing, which is why String and Integer are final. final fields also have memory-model significance: an object whose fields are all final and whose 'this' does not escape the constructor is safely publishable across threads without synchronization. finally is the block that runs whether the try completes normally or abruptly; it is skipped only on System.exit(), a JVM crash, or the thread being killed. A classic trap: a return inside finally silently swallows any in-flight exception and overrides the try block's return value, so linters flag it. Since try-with-resources arrived, finally's main remaining uses are unlocking (lock.unlock()) and cleanup that is not an AutoCloseable. finalize() is the interesting one for 2026: it was deprecated for removal by JEP 421 in Java 18 and disabled or gone in current JDKs.
It was unpredictable (no guarantee it ever ran), resurrected objects, and added GC cost. Its replacements: try-with-resources for deterministic cleanup, and java.lang.ref.Cleaner for a safety net on native resources, where you register a cleanup action that runs when the object becomes phantom-reachable, with the strict rule that the action must not hold a reference to the object being cleaned or it will never become unreachable at all.
import java.lang.ref.Cleaner;
public final class NativeBuffer implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
// Static nested class: must NOT reference the NativeBuffer instance
private record State(long address) implements Runnable {
public void run() { freeNative(address); }
}
private final Cleaner.Cleanable cleanable;
private final long address;
public NativeBuffer(long size) {
this.address = allocateNative(size);
this.cleanable = CLEANER.register(this, new State(address));
}
@Override public void close() { cleanable.clean(); } // deterministic path
private static long allocateNative(long size) { return size; }
private static void freeNative(long addr) { /* release */ }
}
Key Points
- final freezes the reference, not the referenced object's state
- return inside finally swallows exceptions; never do it
- finalize() is deprecated for removal (JEP 421); do not mention it as a feature
- Cleaner + AutoCloseable is the modern cleanup pattern
Q10Is Java pass-by-value or pass-by-reference? Prove it with code.
BasicLanguage Basics
Answer
Java is strictly pass-by-value, always. The confusion arises because for objects, the value being copied is the reference. So a method receives a copy of the reference: it can follow that reference and mutate the object's state (the caller sees the mutation), but reassigning the parameter to a new object changes only the method's local copy (the caller sees nothing).
The two-line proof: a method that does param.setName("x") affects the caller's object; a method that does param = new Thing() does not. This is why the classic swap(a, b) method cannot work in Java for any type, primitives or objects, and interviewers still ask it. Consequences worth articulating: StringBuilder passed into a method can be appended to and the caller sees it, but a String cannot be 'changed' by a callee because String is immutable, the callee can only rebind its local copy.
Arrays are objects, so a method receiving int[] can modify elements in place, a frequent source of accidental mutation bugs; defensive copying with arr.clone() or Arrays.copyOf() at API boundaries prevents callers and callees from aliasing each other's state. The same logic drives the advice to make DTOs immutable (records) so that passing them around never creates spooky action at a distance. If the interviewer pushes on 'but C++ has references', the crisp answer is: Java has no equivalent of an lvalue reference parameter; there is no way for a callee to rebind a caller's variable, period.
class Box { String label; Box(String l) { label = l; } }
static void mutate(Box b) { b.label = "changed"; } // caller sees this
static void rebind(Box b) { b = new Box("new"); } // caller does NOT see this
static void swap(Box x, Box y) { Box t = x; x = y; y = t; } // does nothing for caller
public static void main(String[] args) {
Box box = new Box("orig");
mutate(box);
System.out.println(box.label); // changed
rebind(box);
System.out.println(box.label); // still "changed", rebinding was local
Box a = new Box("A"), b = new Box("B");
swap(a, b);
System.out.println(a.label + b.label); // AB, unswapped
}
Q11Interfaces now have default, static and private methods. When do you still need an abstract class?
BasicOOP
Answer
Java 8 gave interfaces default and static methods, and Java 9 added private methods (helpers shared between defaults). That erased the old 'interfaces have no code' distinction, so the question becomes about what each can still uniquely do. An interface cannot hold instance state: no mutable fields (interface fields are implicitly public static final), no constructors, no instance initialization.
An abstract class can define fields, constructors that enforce invariants, and non-public methods, and a class can extend only one abstract class while implementing many interfaces. So the modern decision rule: use an interface to define a capability or contract (Comparable, AutoCloseable, your own PaymentGateway), optionally with default methods for convenience logic that needs no state; use an abstract class when implementations genuinely share state and partial implementation, the template-method pattern being the canonical case (InputStream and AbstractList in the JDK). Know the diamond rule for defaults: if a class inherits the same default method from two interfaces, the code does not compile until the class overrides it, and it can delegate explicitly with InterfaceA.super.method().
Also worth raising unprompted: default methods exist primarily so the JDK could evolve, Collection gained stream() and removeIf() in Java 8 without breaking every implementation on Earth; that is API evolution, not a license to build deep behavior hierarchies in interfaces. And since Java 17, sealed interfaces let you restrict who may implement a contract, which combined with records gives you closed algebraic data types, replacing many old abstract-class hierarchies outright.
public sealed interface Payment permits Upi, Card {
long amountPaise();
default String display() {
return "₹" + format(amountPaise());
}
private static String format(long paise) { // private helper, Java 9+
return (paise / 100) + "." + String.format("%02d", paise % 100);
}
}
record Upi(long amountPaise, String vpa) implements Payment {}
record Card(long amountPaise, String last4) implements Payment {}
// Diamond resolution: class must disambiguate identical defaults
interface A { default String id() { return "A"; } }
interface B { default String id() { return "B"; } }
class C implements A, B {
@Override public String id() { return A.super.id() + B.super.id(); } // "AB"
}
Key Points
- Interfaces: no instance state, no constructors, fields are public static final
- Abstract classes win when shared mutable state or constructors are needed
- Diamond conflicts on defaults force an override; delegate via X.super.m()
- Sealed interfaces + records model closed type hierarchies since Java 17
Q12What does static really do, and why do static-heavy designs hurt testability?
BasicLanguage Basics
Answer
static binds a member to the class rather than any instance. Static fields have one copy per class per class loader (that per-class-loader detail matters in application servers and explains 'singleton duplicated' bugs when two loaders each load the class). Static methods dispatch at compile time, cannot be overridden (a subclass declaring the same signature hides, not overrides, the parent's), and cannot touch instance state.
Static initializer blocks run once at class initialization, in textual order, and an exception there surfaces as ExceptionInInitializerError, after which the class is unusable for the JVM's lifetime, a nasty production failure when a static block reads a missing config file. Static nested classes are just namespaced classes with no hidden reference to an outer instance; inner (non-static) classes capture their enclosing instance, which is a classic memory-leak source when an inner class instance outlives its parent (the Android Handler leak is the textbook case). The design angle interviewers care about: statics are compile-time-wired global state.
A service calling PaymentClient.charge() statically cannot be tested without the real client, whereas one receiving a PaymentClient through its constructor can be handed a mock; this is the entire pitch for dependency injection, and Mockito's mockStatic() exists as an escape hatch, not a lifestyle. Good uses of static remain: pure utility functions (Math.max, Objects.requireNonNull), constants, and static factory methods like List.of(), Optional.empty() and Instant.now(), which beat constructors by having names, caching instances, and returning subtypes.
Key Points
- One copy per class per class loader; watch multi-loader environments
- Static methods hide, never override; dispatch is compile-time
- Failed static init = ExceptionInInitializerError, class dead until restart
- Prefer constructor injection over static calls for anything you mock in tests
- Static factory methods (List.of, Optional.of) are the idiomatic good use
Q13ArrayList versus LinkedList: which one actually wins in practice and why?
BasicCollections
Answer
The textbook answer says ArrayList for random access (O(1) get) and LinkedList for insertion and deletion (O(1) at a known node). The practical answer, which strong interviewers want to hear, is that ArrayList wins almost everything on real hardware, and even LinkedList's original author has said as much publicly. Reasons: an ArrayList is a contiguous Object[] array, so iteration streams through memory with hardware prefetching and hot CPU caches; a LinkedList node is a separate heap object with prev, next and item pointers, so every step is a pointer chase to a random address, plus roughly 24-40 extra bytes of overhead per element and more GC pressure.
LinkedList's 'O(1) insert' requires already holding the node; getting to position n is O(n), so list.add(n, x) is O(n) for both. ArrayList's add at the end is amortized O(1), growing by ~1.5x when full (pre-size with new ArrayList<>(expected) to avoid growth copies). Removing from the middle of an ArrayList is O(n) due to System.arraycopy, but the copy is so cache-friendly it usually still beats the LinkedList traversal.
Legitimate LinkedList uses are nearly always better served elsewhere: ArrayDeque for stacks and queues (and it is the JDK's own recommendation over both Stack and LinkedList). Follow-ups to expect: fail-fast iterators (structural modification during iteration throws ConcurrentModificationException; use Iterator.remove or removeIf), and CopyOnWriteArrayList for read-heavy concurrent lists.
List<Integer> array = new ArrayList<>(1_000_000); // pre-sized, no growth copies
for (int i = 0; i < 1_000_000; i++) array.add(i);
// Iteration: contiguous memory, prefetch-friendly, fast
long sum = 0;
for (int v : array) sum += v;
// ConcurrentModificationException trap:
// for (Integer v : array) if (v % 2 == 0) array.remove(v); // throws
array.removeIf(v -> v % 2 == 0); // correct, single pass
// Queue/stack use cases: ArrayDeque, not LinkedList or Stack
Deque<String> stack = new ArrayDeque<>();
stack.push("a");
stack.push("b");
System.out.println(stack.pop()); // b
Key Points
- ArrayList: contiguous array, cache-friendly, amortized O(1) append
- LinkedList: per-node allocation, pointer chasing, heavy memory overhead
- Use ArrayDeque for queue/stack semantics
- removeIf or Iterator.remove to avoid ConcurrentModificationException
Q14Describe HashMap's internals: buckets, hash spreading, treeification and resizing.
BasicCollections
Answer
A HashMap is an array of buckets (Node<K,V>[] table, default capacity 16, always a power of two) plus a load factor (default 0.75). put() computes key.hashCode(), then spreads it with hash ^ (hash >>> 16), XORing the high bits into the low bits; the bucket index is (n - 1) & hash, which is why capacity must be a power of two (the mask picks the low bits, and without spreading, keys differing only in high bits would collide). Colliding entries chain as a linked list within the bucket; equals() walks the chain to find the exact key. Since Java 8, a bucket whose chain exceeds 8 entries converts to a red-black tree (TREEIFY_THRESHOLD), but only if the table has at least 64 buckets (MIN_TREEIFY_CAPACITY, otherwise it resizes instead), degrading worst-case lookup from O(n) to O(log n); it untreeifies back below 6.
This was added partly as a defense against hash-flooding attacks on servers that key maps by attacker-controlled strings. When size exceeds capacity times load factor, the table doubles and every entry is redistributed, an O(n) operation, so pre-size with new HashMap<>(expectedSize / 0.75f + 1) for large maps. Other facts that earn points: one null key is allowed (bucket 0) and any number of null values; iteration order is unspecified and can change after rehashing (use LinkedHashMap for insertion order, TreeMap for sorted order); and HashMap is not thread-safe, concurrent puts during resize historically corrupted the table, which is the segue into ConcurrentHashMap.
// Pre-sizing avoids rehash storms when the final size is known
Map<String, Integer> counts = new HashMap<>(1 + (int) (50_000 / 0.75f));
// The spread function in the JDK source (java.util.HashMap):
// static final int hash(Object key) {
// int h;
// return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
// }
// Idiomatic counting without get-then-put races through the API:
for (String word : words) {
counts.merge(word, 1, Integer::sum);
}
// Ordered variants when iteration order matters:
Map<String, Integer> insertion = new LinkedHashMap<>();
Map<String, Integer> sorted = new TreeMap<>();
Key Points
- Power-of-two capacity, index = (n - 1) & spreadedHash
- Chain -> red-black tree at 8 entries if table >= 64 buckets
- Resize doubles capacity at size > capacity * 0.75 and rehashes everything
- One null key allowed; iteration order unspecified; not thread-safe
Q15Comparable versus Comparator, and how do you build multi-level sorts cleanly?
BasicCollections
Answer
Comparable defines a type's single natural ordering: the class itself implements compareTo(), as String (lexicographic) and Integer (numeric) do, and TreeMap, TreeSet, and Collections.sort() use it by default. Comparator is an external, pluggable ordering: you can define as many as you want without touching the class, which is essential when you do not own the class or need different orders in different screens. Since Java 8, hand-written compareTo chains are obsolete for most purposes: Comparator.comparing(extractor) builds a comparator from a key function, thenComparing() chains tie-breakers, reversed() flips, and comparingInt/comparingLong/comparingDouble avoid boxing on primitive keys.
Null handling is built in with Comparator.nullsFirst() and nullsLast(), wrapping another comparator so nulls sort to one end instead of throwing NullPointerException. Two contracts to state before being asked: the comparison must be consistent, anti-symmetric and transitive, and a comparator that violates transitivity can make Arrays.sort() throw 'Comparison method violates its general contract!' at runtime (TimSort actively detects it); and it is strongly recommended that compareTo be consistent with equals, because TreeMap and TreeSet use compareTo for equality, so two objects that are equals() but compare non-zero behave bizarrely in sorted collections. Implementation trivia that rounds out the answer: objects sort with TimSort (stable, adaptive merge sort), primitives with dual-pivot quicksort (stability is meaningless for primitives), and records do not auto-generate compareTo, so you still supply a Comparator for them.
record Candidate(String name, int experienceYears, double matchScore, String city) {}
List<Candidate> pool = fetchCandidates();
// Multi-level: score desc, then experience desc, then name asc
pool.sort(
Comparator.comparingDouble(Candidate::matchScore).reversed()
.thenComparing(Comparator.comparingInt(Candidate::experienceYears).reversed())
.thenComparing(Candidate::name));
// Null-safe city sort, nulls last
pool.sort(Comparator.comparing(Candidate::city,
Comparator.nullsLast(Comparator.naturalOrder())));
// Natural ordering via Comparable, for types you own
class Version implements Comparable<Version> {
final int major, minor;
Version(int ma, int mi) { major = ma; minor = mi; }
@Override public int compareTo(Version o) {
int c = Integer.compare(major, o.major);
return c != 0 ? c : Integer.compare(minor, o.minor);
}
}
Q16What do records generate for you, and where do compact constructors fit in?
BasicModern Java
Answer
A record (finalized in Java 16) is a nominal tuple: record Range(int lo, int hi) declares two final components, and the compiler generates a canonical constructor, private final fields, accessor methods named lo() and hi() (no get prefix), and equals(), hashCode() and toString() based on all components. Records are implicitly final, cannot extend a class (they extend java.lang.Record), cannot declare non-static instance fields beyond the components, but can implement interfaces, declare static members, and add instance methods. The compact constructor is the validation hook: you write Range { if (lo > hi) throw new IllegalArgumentException(...); } with no parameter list, and it runs before the fields are assigned; you can also normalize by reassigning the parameter variables (not the fields) inside it.
What records are for: DTOs, API responses, map keys, event payloads, value objects, anywhere identity is defined by data. What they are not: JPA entities (Hibernate needs a no-arg constructor and mutable fields for proxying, so records do not work as entities, though they are excellent as projection DTOs in JPQL select new or Spring Data interface projections). Two nuances that upgrade the answer: records give only shallow immutability, a record holding a List can still have that list mutated, so defensively copy with List.copyOf() in the compact constructor; and records pair with pattern matching, record patterns (Java 21) let you destructure them directly in switch, which is where modern Java's data-oriented style comes from. Jackson serializes records out of the box in all maintained versions.
public record Salary(long amountPaise, String currency) {
private static final java.util.Set<String> SUPPORTED = java.util.Set.of("INR", "USD");
public Salary { // compact constructor: validate + normalize
if (amountPaise < 0) throw new IllegalArgumentException("negative salary");
currency = currency.toUpperCase(); // reassigns parameter, not field
if (!SUPPORTED.contains(currency)) throw new IllegalArgumentException(currency);
}
public Salary raiseBy(int percent) { // 'wither' style, records are immutable
return new Salary(amountPaise + amountPaise * percent / 100, currency);
}
}
// Defensive copy for shallow-immutability holes:
public record Team(String name, java.util.List<String> members) {
public Team {
members = java.util.List.copyOf(members); // throws on null, copies on mutation risk
}
}
Key Points
- Generates canonical constructor, accessors, equals/hashCode/toString
- Compact constructor validates and normalizes before field assignment
- Shallow immutability: defensively copy collection components
- Great for DTOs and map keys; not usable as JPA entities
Q17Why is an enum the recommended singleton, and what are EnumSet and EnumMap for?
BasicLanguage Basics
Answer
Java enums are full classes whose instances are fixed at compile time: each constant is a public static final instance created during class initialization, which the JVM guarantees happens once, thread-safely, per class loader. That guarantee is why Effective Java calls a single-element enum the best singleton implementation: it is immune to the two classic singleton breakages, reflection (Constructor.newInstance throws IllegalArgumentException for enum types) and serialization (enums serialize by name and deserialize to the same instance, whereas an ordinary serializable singleton silently duplicates unless you implement readResolve). Enums can carry fields, constructors and methods, implement interfaces, and give each constant its own behavior via constant-specific class bodies, so a Status enum can own its display label and transition rules instead of scattering switch statements.
Utilities to name: values(), valueOf(String) (throws IllegalArgumentException on unknown names, wrap it for user input), name(), and ordinal(), with the standing warning never to persist ordinal() because reordering constants corrupts stored data; persist name() or an explicit code field, and in JPA use @Enumerated(EnumType.STRING). EnumSet and EnumMap are the specialized collections: EnumSet is a bit-vector (a long for up to 64 constants), so contains/add are single bit operations, dramatically faster and smaller than HashSet; EnumMap is a plain array indexed by ordinal, beating HashMap for enum keys. Both maintain natural constant order. In switch expressions over an enum, covering all constants means no default is required, and the compiler then flags the switch when someone adds a new constant, a real maintenance win.
public enum JobStatus {
DRAFT("Draft") { boolean canTransitionTo(JobStatus s) { return s == PENDING; } },
PENDING("Under review") { boolean canTransitionTo(JobStatus s) { return s == ACTIVE || s == REJECTED; } },
ACTIVE("Live") { boolean canTransitionTo(JobStatus s) { return s == CLOSED; } },
REJECTED("Rejected") { boolean canTransitionTo(JobStatus s) { return false; } },
CLOSED("Closed") { boolean canTransitionTo(JobStatus s) { return false; } };
private final String label;
JobStatus(String label) { this.label = label; }
public String label() { return label; }
abstract boolean canTransitionTo(JobStatus next);
}
// Singleton
public enum Clock { INSTANCE; public long now() { return System.currentTimeMillis(); } }
// Specialized collections
var open = java.util.EnumSet.of(JobStatus.DRAFT, JobStatus.PENDING, JobStatus.ACTIVE);
var counts = new java.util.EnumMap<JobStatus, Integer>(JobStatus.class);
Key Points
- Enum singletons survive reflection and serialization attacks
- Never persist ordinal(); use name() or @Enumerated(EnumType.STRING)
- EnumSet is a bit vector; EnumMap is an ordinal-indexed array
- Exhaustive switch over enums needs no default and catches new constants
Q18What are the rules for var, and where should you avoid it?
BasicModern Java
Answer
var (Java 10, JEP 286) is local variable type inference: the compiler infers the static type from the initializer, and the variable is exactly as strongly typed as before, there is nothing dynamic about it. The rules: local variables with initializers only, plus for-loop indices and try-with-resources; not fields, not method parameters, not return types. Illegal initializers include null (no type to infer), bare lambdas and method references (they need a target type: var f = x -> x + 1 does not compile; var f = (IntUnaryOperator) x -> x + 1 does), and array initializer shorthand (var a = {1, 2} fails; var a = new int[]{1, 2} works).
Two inference surprises worth naming: var list = new ArrayList<String>() infers ArrayList<String>, not List<String>, so you lose the interface-typed variable unless you declare it explicitly; and the diamond collapses, var m = new HashMap<>() infers HashMap<Object,Object>, so keep type arguments explicit on the right-hand side. Style guidance interviewers respect (aligned with OpenJDK's own var style guide): use var when the type is obvious from the right-hand side (constructor calls, factory methods, casts) or when the type is a noisy generic like Map.Entry<String, List<Candidate>> inside a loop; avoid it when the initializer is an opaque method call (var result = service.process(input) hides the contract from the reader) and in long-lived variables where the declared type documents intent. Note that var is a reserved type name, not a keyword, so ancient code with a variable named var still compiles, a bit of trivia that occasionally surfaces.
// Good: type obvious from the right-hand side
var users = new java.util.ArrayList<String>();
var reader = java.nio.file.Files.newBufferedReader(path);
for (var entry : scoresByCity.entrySet()) {
System.out.println(entry.getKey() + " -> " + entry.getValue());
}
// Bad: what does this return? The reader cannot tell.
var result = candidateService.evaluate(profile);
// Does not compile:
// var x = null; // no type to infer
// var f = s -> s.length(); // lambda needs explicit target type
var f = (java.util.function.ToIntFunction<String>) String::length; // OK
// Inference trap: HashMap<Object,Object>, probably not what you wanted
var wrong = new java.util.HashMap<>();
var right = new java.util.HashMap<String, Integer>();
Key Points
- Locals with initializers only; never fields, parameters or returns
- Cannot infer from null, bare lambdas, or array literals
- Infers the concrete type (ArrayList, not List)
- Use where the right-hand side makes the type obvious; avoid on opaque calls
Q19How does type erasure work, and what is PECS in generics?
BasicGenerics
Answer
Java generics are compile-time only: after the compiler checks them, types are erased, List<String> and List<Integer> are both bare List in bytecode, with the compiler inserting invisible casts at use sites. Erasure preserved binary compatibility with pre-Java 5 code but produces every limitation interviewers ask about: no new T(), no new T[], no T.class, instanceof List<String> does not compile (only the raw or wildcard form does), a class cannot implement Comparable<A> and Comparable<B> simultaneously (same erasure), and overloads void f(List<String>) and void f(List<Integer>) clash. Workarounds worth naming: pass a Class<T> token and call clazz.getDeclaredConstructor().newInstance(); Jackson's TypeReference<List<User>> trick, which captures the generic type via an anonymous subclass because superclass generic info survives erasure through getGenericSuperclass().
Then invariance: List<Dog> is not a List<Animal>, otherwise you could add a Cat through the Animal-typed alias. Wildcards fix this, and PECS (Producer Extends, Consumer Super) is the rule for choosing: a parameter you only read from is a producer, type it List<? extends Animal> (you can read Animals, but add nothing except null); one you only write into is a consumer, type it List<? super Dog> (you can add Dogs, but reads give Object). The JDK's own Collections.copy(List<? super T> dest, List<? extends T> src) is the canonical illustration. Arrays, by contrast, are covariant and reified: Animal[] a = new Dog[1]; a[0] = new Cat() compiles but throws ArrayStoreException at runtime, which is exactly the hole generics were designed to close, and why generic collections beat arrays for API design.
// Invariance: does not compile, by design
// List<Animal> animals = new ArrayList<Dog>();
// PECS in a real signature:
static <T> void copy(java.util.List<? super T> dest, java.util.List<? extends T> src) {
for (int i = 0; i < src.size(); i++) dest.set(i, src.get(i));
}
static double totalPaise(java.util.List<? extends Payment> payments) { // producer
double sum = 0;
for (Payment p : payments) sum += p.amountPaise();
return sum;
}
static void fillDefaults(java.util.List<? super Upi> sink) { // consumer
sink.add(new Upi(100, "a@upi"));
}
// Erasure limitation + the Class-token workaround:
static <T> T instantiate(Class<T> type) throws Exception {
return type.getDeclaredConstructor().newInstance();
}
Key Points
- Generics erase to raw types; compiler inserts casts
- No new T(), no T.class, no instanceof with type arguments
- PECS: read-from = ? extends, write-into = ? super
- Arrays are covariant and fail at runtime; generics fail at compile time
Q20How should Optional be used, and what are orElse versus orElseGet doing differently?
BasicModern Java
Answer
Optional was designed for one purpose, stated by its own authors: a return type that makes 'no result' explicit so callers cannot forget the absent case, replacing null returns from finders like repository.findByEmail(email). Used that way it composes beautifully: map() transforms the value if present, flatMap() chains methods that themselves return Optional, filter() turns a failed predicate into empty, and the terminal choices are orElse(), orElseGet(), orElseThrow(), ifPresent(), ifPresentOrElse(), and or() (Java 9, for fallback Optionals). The orElse versus orElseGet distinction is a favorite trap: orElse(computeDefault()) evaluates its argument eagerly, always, even when the Optional has a value; orElseGet(this::computeDefault) invokes the supplier only when empty.
If the default is expensive (a database call, building an object) or worse, side-effecting, orElse will hurt you; the visible symptom is 'why is my fallback query running on every request'. Anti-patterns to call out: Optional fields in entities and DTOs (it is not Serializable and Jackson/JPA handle it poorly), Optional method parameters (forces callers to wrap; overload instead), calling get() without isPresent() (throws NoSuchElementException; orElseThrow() at least names the intent), Optional.of(possiblyNull) (throws NullPointerException immediately; use ofNullable), and wrapping collections (return an empty List, never Optional<List<T>>). Also do not use isPresent() plus get() as a fancy null check, that is the same code shape with extra allocation; use map/orElse chains.
For primitives, OptionalInt, OptionalLong and OptionalDouble avoid boxing. In performance-critical inner loops, a plain null check is still fine; Optional earns its allocation at API boundaries, not in hot paths.
Optional<User> user = userRepository.findByEmail(email);
// Composition instead of nested null checks
String city = user
.map(User::address)
.map(Address::city)
.filter(c -> !c.isBlank())
.orElse("Unknown");
// TRAP: loadDefaultProfile() runs EVERY time, even when user exists
Profile p1 = user.map(User::profile).orElse(loadDefaultProfile());
// Correct: supplier runs only when empty
Profile p2 = user.map(User::profile).orElseGet(this::loadDefaultProfile);
// Explicit failure with a domain exception
User u = user.orElseThrow(() -> new UserNotFoundException(email));
// Java 9+: fallback to another Optional source
Optional<User> resolved = user.or(() -> userRepository.findByPhone(phone));
Key Points
- Designed for return types; avoid in fields and parameters
- orElse evaluates eagerly; orElseGet is lazy, use it for costly defaults
- Never Optional.of on a nullable; never bare get()
- Return empty collections, not Optional<List<T>>
Q21What makes an interface a functional interface, and how do lambdas and method references bind to it?
BasicModern Java
Answer
A functional interface has exactly one abstract method (SAM); default, static and private methods do not count, and neither do abstract declarations of public Object methods like equals(). @FunctionalInterface is optional documentation that makes the compiler enforce the single-method property, so adding a second abstract method later fails loudly instead of silently breaking every lambda site. A lambda is not an anonymous class in disguise: it compiles to an invokedynamic instruction that bootstraps via LambdaMetafactory, so there is no extra .class file per lambda and stateless lambdas are typically cached singletons, cheaper than anonymous classes. A lambda has no identity crisis either: 'this' inside a lambda refers to the enclosing instance, unlike anonymous classes where 'this' is the anonymous object, a subtle bug source when porting old listener code.
Lambdas can capture local variables only if they are final or effectively final, because captures are copied, not referenced; the standard workaround for a mutable counter is AtomicInteger or collecting into a data structure rather than mutating a captured int. The core JDK vocabulary you must know cold: Function<T,R> (apply), BiFunction, Consumer (accept), Supplier (get), Predicate (test), UnaryOperator, plus the primitive specializations IntFunction, ToIntFunction, IntPredicate and friends that avoid boxing. Method references come in four forms: static (Integer::parseInt), instance method of a particular object (service::process), instance method of an arbitrary object of a type (String::length, where the first argument becomes the receiver), and constructor (ArrayList::new). Prefer a method reference when it reads clearly, and a named lambda parameter when the reference form becomes a puzzle.
@FunctionalInterface
interface Scorer {
double score(Candidate c); // the single abstract method
default Scorer plus(Scorer other) { // defaults are fine
return c -> this.score(c) + other.score(c);
}
}
Scorer experience = c -> Math.min(c.experienceYears() * 1.5, 10);
Scorer combined = experience.plus(c -> c.matchScore());
// The four method reference forms
java.util.function.Function<String, Integer> parse = Integer::parseInt; // static
java.util.function.Predicate<String> isBlank = String::isBlank; // arbitrary receiver
java.util.function.Supplier<java.util.List<String>> maker = java.util.ArrayList::new; // ctor
// bound: java.util.function.Consumer<String> log = logger::info;
// Effectively-final capture rule
int threshold = 5; // must not be reassigned below
java.util.function.Predicate<Candidate> senior = c -> c.experienceYears() >= threshold;
Key Points
- Exactly one abstract method; @FunctionalInterface enforces it
- Lambdas compile to invokedynamic, not anonymous classes
- 'this' in a lambda is the enclosing instance
- Captured locals must be effectively final
Q22In the Stream API, what distinguishes intermediate from terminal operations, and why does laziness matter?
BasicStreams
Answer
Intermediate operations (filter, map, flatMap, sorted, distinct, peek, limit, skip, takeWhile, dropWhile) return a new stream and do absolutely nothing when called; they build a pipeline description. Terminal operations (collect, forEach, reduce, count, findFirst, anyMatch, toList, min, max) trigger execution, pulling elements through the whole pipeline once. Laziness has concrete consequences.
Short-circuiting works: stream.filter(expensive).findFirst() stops after the first match instead of filtering everything. Fusion works: filter and map run per element in one pass, not as two full passes over intermediate collections. Infinite streams work: Stream.iterate(1, n -> n * 2).limit(10) terminates because limit cuts the pull.
The trap that catches people: a pipeline with side effects in peek() or map() that never gets a terminal operation simply never runs, and 'my logging inside the stream never fires' is the textbook symptom. Also, some operations quietly break laziness: sorted() and distinct() are stateful and must buffer or see all elements before emitting, so an infinite stream with sorted() before limit() hangs forever. Streams are single-use: calling a second terminal operation throws IllegalStateException ('stream has already been operated upon or closed'), so hand out suppliers of streams, not stream instances. Know the modern conveniences: toList() (Java 16) returns an unmodifiable list and replaces collect(Collectors.toList()) in most code, mapMulti (Java 16) is a lower-allocation alternative to flatMap for imperative expansion, and Java 24 finalized stream gatherers (JEP 485), Stream.gather(Gatherers.windowFixed(3)) and friends, which fill the long-standing gap of custom intermediate operations like sliding windows.
// Nothing happens here: only a pipeline description is built
var pipeline = candidates.stream()
.filter(c -> c.matchScore() > 80) // intermediate
.map(Candidate::name); // intermediate
// Execution happens now, one fused pass, short-circuits at 5
var top5 = pipeline.limit(5).toList(); // terminal (Java 16+)
// Infinite stream, safe because limit() bounds the pull
var powers = java.util.stream.Stream.iterate(1L, n -> n * 2).limit(10).toList();
// Single-use: this throws IllegalStateException
// pipeline.count();
// Java 24 gatherers: fixed windows as an intermediate op
var windows = java.util.stream.Stream.of(1, 2, 3, 4, 5, 6)
.gather(java.util.stream.Gatherers.windowFixed(3))
.toList(); // [[1,2,3],[4,5,6]]
Key Points
- Intermediate ops build the pipeline; terminal ops execute it once
- Laziness enables short-circuiting, fusion and infinite streams
- sorted()/distinct() are stateful and buffer elements
- Streams are single-use; reuse throws IllegalStateException
Q23How do switch expressions and pattern matching for switch change how you write Java?
BasicModern Java
Answer
Switch expressions (standard since Java 14) turn switch from a statement into a value-producing expression with arrow labels: case A -> value; no fall-through, multiple labels per case (case SAT, SUN ->), and yield for multi-statement branches. The compiler enforces exhaustiveness for expressions, which pairs perfectly with enums: cover every constant and you need no default, and adding a constant later becomes a compile error at every switch, turning a runtime bug class into a build failure. Java 21 finalized pattern matching for switch (JEP 441) and record patterns (JEP 440), which is the bigger shift: you can switch over an object's type with case Integer i ->, guard cases with when clauses (case String s when s.length() > 10 ->), destructure records in place (case Point(int x, int y) ->, nesting arbitrarily deep), and handle case null explicitly, without which a null selector throws NullPointerException as before.
Combined with sealed interfaces, this enables data-oriented programming: model a domain as sealed interface Shape permits Circle, Square with records, and every switch over Shape is compiler-checked exhaustive with no default branch to hide forgotten cases. Details that show depth: dominance ordering is enforced (a more general pattern before a more specific one is a compile error), the old instanceof-and-cast dance is replaced by pattern variables (if (o instanceof String s)), and traditional colon-style switch still exists with all its fall-through hazards, so stating 'arrow form, no fall-through, exhaustive' signals you have left legacy habits behind. This trio (sealed types, records, switch patterns) is the single most visible language change interviewers now probe when they ask what modern Java looks like.
sealed interface Event permits JobPosted, JobClosed, ApplicationReceived {}
record JobPosted(long jobId, String title) implements Event {}
record JobClosed(long jobId, String reason) implements Event {}
record ApplicationReceived(long jobId, long candidateId, double score) implements Event {}
static String describe(Event e) {
return switch (e) { // exhaustive, no default
case JobPosted(long id, String title) -> "Job " + id + ": " + title;
case JobClosed(long id, String reason) -> "Job " + id + " closed (" + reason + ")";
case ApplicationReceived(long id, long cand, double s) when s >= 90 ->
"Hot candidate " + cand + " for job " + id;
case ApplicationReceived a -> "Application for job " + a.jobId();
};
}
static String label(Object o) {
return switch (o) {
case null -> "nothing"; // explicit null case
case Integer i when i < 0 -> "negative";
case Integer i -> "int:" + i;
case String s -> "str:" + s.length();
default -> "other";
};
}
Key Points
- Arrow cases: no fall-through, expression form is exhaustiveness-checked
- Java 21: type patterns, when guards, record destructuring, case null
- Sealed hierarchy + no default = compiler catches missing cases
- Pattern order matters; dominated cases are compile errors
Q24What problems do sealed classes and interfaces solve?
BasicModern Java
Answer
Sealed types (finalized in Java 17, JEP 409) let a class or interface declare exactly who may extend or implement it: sealed interface Result permits Ok, Err. Every permitted subtype must in turn declare itself final (hierarchy stops), sealed (continues restrictively), or non-sealed (deliberately reopens extension), and must live in the same module, or same package for unnamed modules. Before sealed, Java had only two extremes: final (nobody extends) or open to the world, and 'closed for extension' domains were faked with package-private constructors and documentation.
What sealing buys you concretely: exhaustive switch, the compiler knows all subtypes of a sealed interface, so a switch over it with all cases covered needs no default branch, and adding a new subtype breaks the build at every non-updated switch instead of falling into a forgotten default at 2 AM; honest API contracts, a library can expose an interface for callers to consume without inviting them to implement it, killing the fragile-base-class problem for that hierarchy; and better modeling, sealed interface plus records is Java's version of algebraic data types, ideal for representing results (Success or Failure instead of nulls and exception control flow), states of a workflow, or message variants on a queue consumer. Reflection support exists via isSealed() and getPermittedSubclasses(). Frameworks have caught up: Jackson can use sealed hierarchy information for polymorphic deserialization, reducing @JsonSubTypes boilerplate in maintained versions. A crisp closing contrast for interviews: enums enumerate a fixed set of instances; sealed types enumerate a fixed set of subtypes, each of which can carry its own distinct data shape, which is exactly what enums cannot do.
public sealed interface FetchResult<T> permits Success, NotFound, Failure {}
public record Success<T>(T value) implements FetchResult<T> {}
public record NotFound<T>(String key) implements FetchResult<T> {}
public record Failure<T>(String code, Throwable cause) implements FetchResult<T> {}
// Exhaustive handling, no default, compiler-enforced:
static <T> T unwrapOr(FetchResult<T> r, T fallback) {
return switch (r) {
case Success<T> s -> s.value();
case NotFound<T> nf -> fallback;
case Failure<T> f -> throw new IllegalStateException(f.code(), f.cause());
};
}
// Subtype obligations: final | sealed | non-sealed
sealed class Vehicle permits Car, Truck {}
final class Car extends Vehicle {}
non-sealed class Truck extends Vehicle {} // deliberately reopened
Key Points
- permits fixes the set of subtypes; each must be final, sealed or non-sealed
- Enables default-free exhaustive switch over the hierarchy
- Sealed interface + records = algebraic data types in Java
- Enums fix instances; sealed types fix subtypes with varying data
Q25Map out the JVM's runtime memory areas and the different OutOfMemoryError messages each can produce.
IntermediateJVM Internals
Answer
The heap holds all objects and arrays, sized with -Xms (initial) and -Xmx (max); in containers, set both equal for predictable behavior or use -XX:MaxRAMPercentage so the JVM respects cgroup limits, which it has done properly since Java 10. Exhausting it throws 'java.lang.OutOfMemoryError: Java heap space', and the companion 'GC overhead limit exceeded' means the collector is burning over 98% of time to recover under 2% of heap, a leak's death spiral. Each thread gets a stack (-Xss, typically 512KB-1MB) holding frames with local variables and operand stacks; deep or infinite recursion throws StackOverflowError, and creating too many platform threads throws 'OutOfMemoryError: unable to create new native thread', which is about OS limits (ulimit -u), not heap.
Class metadata lives in Metaspace, native memory outside the heap, capped only by -XX:MaxMetaspaceSize if you set it; 'OutOfMemoryError: Metaspace' almost always means a classloader leak, classically from repeated redeploys or runtime bytecode generation gone wild. Interned strings and string constants live on the heap (since Java 7, no more PermGen since Java 8). The code cache holds JIT-compiled machine code (-XX:ReservedCodeCacheSize); filling it silently disables further compilation and tanks performance without any exception.
Direct ByteBuffers allocate off-heap ('OutOfMemoryError: Direct buffer memory', capped by -XX:MaxDirectMemorySize), the classic Netty tuning concern. The diagnostic reflex interviewers want: read which OOM message it is first, because heap-space, Metaspace, native-thread and direct-buffer failures have completely different causes and fixes, and 'increase -Xmx' is only ever the right answer for the first one, and often not even then.
Key Points
- Heap (-Xmx), per-thread stacks (-Xss), Metaspace, code cache, direct memory
- Each area has a distinct OutOfMemoryError message; read it before tuning
- 'unable to create new native thread' is an OS limit, not heap
- Metaspace OOM = classloader leak until proven otherwise
- Use -XX:MaxRAMPercentage in containers; JVM is cgroup-aware since Java 10
Q26Compare G1, ZGC and Shenandoah, and explain how you would pick a collector for a service in 2026.
IntermediateJVM Internals
Answer
All modern collectors exploit the generational hypothesis (most objects die young) but differ in how much work happens while application threads are paused. G1, the default since Java 9, divides the heap into equal regions, collects the young generation in short stop-the-world pauses, and pays down the old generation via concurrent marking plus incremental 'mixed' collections, steering itself toward the pause goal -XX:MaxGCPauseMillis (default 200ms). It is the right default for typical services with heaps from 2 to 30 GB.
Its known weak spot is humongous objects, allocations bigger than half a region go straight to the old generation and fragment it, which is why giant byte[] buffers cause G1 grief. ZGC (-XX:+UseZGC) does marking, relocation and compaction almost entirely concurrently using colored pointers and load barriers, delivering sub-millisecond pauses nearly independent of heap size, up to multi-terabyte heaps; it became generational in recent JDKs, which removed its old throughput penalty for short-lived allocation storms. The trade-off is a few percent of CPU spent on barriers, so pure-throughput batch jobs may prefer G1 or even ParallelGC (-XX:+UseParallelGC), which still wins raw throughput when pauses do not matter, think overnight batch or Spark executors.
Shenandoah is Red Hat's concurrent compactor with a similar low-pause profile, popular where it ships enabled in the distro. Decision procedure to narrate: latency-sensitive API with p99 SLOs or heap over ~30 GB, ZGC; typical microservice, keep G1 and do not touch flags beyond -Xmx; batch throughput, ParallelGC; and before switching anything, capture -Xlog:gc* evidence that GC is actually the problem, because most 'GC issues' in production turn out to be allocation storms from application code, fixable with a profiler rather than a collector swap.
# Typical service: G1 is default, just size it and log GC
java -Xms4g -Xmx4g -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar
# Latency-sensitive, large heap: generational ZGC
java -XX:+UseZGC -Xmx64g -Xlog:gc* -jar app.jar
# Batch throughput, pauses acceptable
java -XX:+UseParallelGC -Xmx8g -jar batch-job.jar
# Container-aware sizing instead of hard -Xmx
java -XX:MaxRAMPercentage=75 -XX:+UseZGC -jar app.jar
# What collector is this JVM actually running?
java -Xlog:gc --version
Key Points
- G1: region-based, pause-target-driven default; weak against humongous objects
- ZGC: concurrent, sub-ms pauses, generational in recent JDKs, slight CPU tax
- ParallelGC still wins pure throughput for batch
- Measure with -Xlog:gc* before changing collectors
Q27How do ExecutorService and thread pools work, and how do you size and shut them down correctly?
IntermediateConcurrency
Answer
Raw new Thread() per task does not scale (each platform thread costs ~1MB of stack and a kernel thread), so java.util.concurrent provides executors. ThreadPoolExecutor's behavior is governed by five things interviewers probe: corePoolSize, maximumPoolSize, keepAliveTime, the work queue, and the RejectedExecutionHandler. The counterintuitive part: with an unbounded LinkedBlockingQueue (what Executors.newFixedThreadPool uses), maximumPoolSize is irrelevant, extra tasks queue forever, and the failure mode is a silently growing queue eating heap and latency.
A bounded ArrayBlockingQueue plus an explicit rejection policy (AbortPolicy throws RejectedExecutionException, CallerRunsPolicy applies backpressure by running the task on the submitting thread) is the production-grade configuration. Sizing: CPU-bound pools at roughly the core count (Runtime.getRuntime().availableProcessors(), which respects container CPU quotas in modern JDKs); I/O-bound pools larger, cores times (1 + wait-time/compute-time), though the honest 2026 answer is that I/O-bound work should move to virtual threads instead of fat pools. Runnable versus Callable: Callable returns a value and may throw checked exceptions; submit() returns a Future whose get() rethrows task failures wrapped in ExecutionException, and a submitted Runnable that throws gets swallowed silently unless someone calls get(), a classic 'my task died and nobody noticed' bug; always attach logging or use execute() (which routes to the thread's UncaughtExceptionHandler).
Shutdown is a sequence, not a call: shutdown() stops intake, awaitTermination(timeout) waits, then shutdownNow() interrupts stragglers; since Java 19 ExecutorService is AutoCloseable so try-with-resources does an orderly close. Name your threads via a ThreadFactory, unnamed pool-1-thread-7 in a thread dump during an incident is self-inflicted pain.
var pool = new java.util.concurrent.ThreadPoolExecutor(
4, 8,
60, java.util.concurrent.TimeUnit.SECONDS,
new java.util.concurrent.ArrayBlockingQueue<>(500), // bounded!
r -> { var t = new Thread(r, "scoring-worker"); t.setDaemon(false); return t; },
new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy() // backpressure
);
java.util.concurrent.Future<Double> f = pool.submit(() -> scoreCandidate(id)); // Callable
try {
double score = f.get(2, java.util.concurrent.TimeUnit.SECONDS);
} catch (java.util.concurrent.ExecutionException e) {
log.error("scoring failed", e.getCause()); // the REAL exception
} catch (java.util.concurrent.TimeoutException e) {
f.cancel(true);
}
// Orderly shutdown
pool.shutdown();
if (!pool.awaitTermination(30, java.util.concurrent.TimeUnit.SECONDS)) {
pool.shutdownNow();
}
Key Points
- Unbounded queues make maximumPoolSize meaningless; bound the queue
- CallerRunsPolicy gives natural backpressure
- Runnable exceptions vanish unless the Future is inspected
- shutdown -> awaitTermination -> shutdownNow, or try-with-resources on Java 19+
Q28synchronized versus ReentrantLock: what does each give you, and when is the extra API worth it?
IntermediateConcurrency
Answer
synchronized is the language-level construct: it acquires the intrinsic monitor of an object (or the Class object for static methods), is reentrant, releases automatically on every exit path including exceptions, and pairs with wait()/notify()/notifyAll() for condition signaling. The JVM optimizes it heavily, biased locking is gone in modern JDKs but thin locks and lock elision via escape analysis remain, so uncontended synchronized is very cheap. Its limits: you cannot try, time out, or interrupt the acquisition; a thread blocked entering a synchronized block is stuck until the lock frees.
ReentrantLock (java.util.concurrent.locks) is a library lock with the missing capabilities: tryLock() for immediate or timed acquisition (the standard deadlock-avoidance tool: try, back off, retry in different order), lockInterruptibly() so a stuck thread can be cancelled, optional fairness (new ReentrantLock(true), FIFO handoff at a large throughput cost, rarely justified), and multiple Condition objects from newCondition(), letting one lock manage separate wait-sets like notFull and notEmpty in a bounded buffer, which wait/notify cannot express cleanly. The discipline cost: unlock() must sit in a finally block, always; a forgotten unlock on an exception path is a permanent hang. Also name ReadWriteLock and its modern successor StampedLock (optimistic reads via tryOptimisticRead() then validate(), great for read-mostly caches, but non-reentrant, mixing it with recursion deadlocks).
The virtual-thread angle that updates this classic for 2026: before JDK 24, blocking inside a synchronized block pinned a virtual thread to its carrier, so guidance said prefer ReentrantLock in virtual-thread-heavy code; JEP 491 in Java 24 fixed synchronized pinning, so on current JDKs choose by API need, not by pinning fear. Default guidance: synchronized until you concretely need tryLock, interruptibility, fairness or multiple conditions.
class BoundedBuffer<T> {
private final java.util.ArrayDeque<T> items = new java.util.ArrayDeque<>();
private final int capacity = 100;
private final java.util.concurrent.locks.ReentrantLock lock =
new java.util.concurrent.locks.ReentrantLock();
private final java.util.concurrent.locks.Condition notFull = lock.newCondition();
private final java.util.concurrent.locks.Condition notEmpty = lock.newCondition();
void put(T item) throws InterruptedException {
lock.lock();
try {
while (items.size() == capacity) notFull.await(); // while, never if
items.addLast(item);
notEmpty.signal();
} finally {
lock.unlock(); // ALWAYS in finally
}
}
T take() throws InterruptedException {
lock.lock();
try {
while (items.isEmpty()) notEmpty.await();
T item = items.removeFirst();
notFull.signal();
return item;
} finally {
lock.unlock();
}
}
}
Key Points
- synchronized: automatic release, JVM-optimized, but no tryLock/timeout/interrupt
- ReentrantLock adds tryLock, lockInterruptibly, fairness, multiple Conditions
- unlock() in finally is non-negotiable
- JDK 24 (JEP 491) removed virtual-thread pinning on synchronized
Q29What does volatile actually guarantee, and what is a happens-before relationship?
IntermediateConcurrency
Answer
volatile gives you two guarantees and withholds a third. Visibility: a write to a volatile field is flushed so that any subsequent read by any thread sees it; without it, the JIT may hoist a field read out of a loop and a thread spinning on while (!stopped) {} literally never observes the update, the canonical demo bug. Ordering: volatile accesses are fences that prevent reordering of surrounding reads and writes, which is exactly what the Java Memory Model formalizes as happens-before: program order within a thread; an unlock happens-before a subsequent lock of the same monitor; a volatile write happens-before subsequent reads of that field; Thread.start() happens-before the started thread's actions; a thread's actions happen-before another thread's successful join() on it; and the relation is transitive.
Crucially, a happens-before edge on one variable publishes everything written before it, so setting a volatile 'ready' flag after populating a plain data field safely publishes the data too, that transitivity is the whole design of one-writer flag patterns. What volatile does not give: atomicity of compound actions. counter++ is read-modify-write, and two threads can interleave and lose updates regardless of volatile; the fixes are AtomicInteger/AtomicLong (CAS loops via VarHandle/Unsafe-level compareAndSet), LongAdder when contention is high and you only need sums (it stripes across cells, trading read cost for write scalability), or a lock. Also worth stating: long and double on ancient specs could tear when non-volatile, volatile forbids that; and the modern JDK expresses finer control through VarHandle acquire/release modes, which is what java.util.concurrent itself uses internally. Rule of thumb to close with: volatile for a flag or reference that one thread writes and others read; atomics for counters; locks for multi-variable invariants.
class Worker implements Runnable {
private volatile boolean stopped = false; // without volatile: may never stop
public void stop() { stopped = true; }
@Override public void run() {
while (!stopped) {
process();
}
}
}
// Safe publication via a volatile flag (transitivity of happens-before)
class Config {
private java.util.Map<String, String> values; // plain field
private volatile boolean ready = false;
void load() {
values = loadFromDisk(); // 1: plain write
ready = true; // 2: volatile write publishes step 1
}
java.util.Map<String, String> get() {
return ready ? values : java.util.Map.of(); // volatile read sees both
}
}
// volatile does NOT fix this:
// volatile int hits; hits++; // lost updates under contention
java.util.concurrent.atomic.LongAdder hits = new java.util.concurrent.atomic.LongAdder();
// hits.increment(); long total = hits.sum();
Key Points
- Visibility + ordering, never atomicity of compound actions
- Happens-before is transitive: one volatile write publishes prior plain writes
- counter++ needs AtomicInteger, LongAdder or a lock
- LongAdder beats AtomicLong under heavy write contention
Q30How does ConcurrentHashMap achieve thread safety, and which of its methods replace check-then-act races?
IntermediateConcurrency
Answer
Since Java 8, ConcurrentHashMap abandoned the old 16-segment design. Writes use CAS to install the first node in an empty bin and synchronize only on the bin's head node when a bin is occupied, so contention is per-bucket, not per-map; resizing is cooperative, with multiple threads transferring bins simultaneously; and size() aggregates striped CounterCells rather than maintaining one hot counter. Reads are entirely lock-free, relying on volatile semantics of the node fields, which yields the map's weak consistency: iterators never throw ConcurrentModificationException and reflect some state during iteration, and aggregate views (size, isEmpty) are estimates under concurrency, all acceptable trade-offs that interviewers expect you to name rather than apologize for.
Nulls are banned for keys and values, precisely because get() returning null would be ambiguous between 'absent' and 'mapped to null' in a concurrent world where containsKey-then-get is racy. That leads to the real interview meat: individual operations being thread-safe does not make sequences thread-safe. if (!map.containsKey(k)) map.put(k, v) is a race; the atomic replacements are putIfAbsent(k, v), computeIfAbsent(k, fn) (the memoization idiom: computes at most once per key, returns existing otherwise), compute() and merge() for read-modify-write (merge(word, 1, Integer::sum) is the canonical concurrent counter), and replace(k, old, new) for optimistic updates. Two operational warnings: the mapping function in computeIfAbsent runs while the bin lock is held, so it must be short and must not touch the same map (recursive computeIfAbsent can deadlock or throw IllegalStateException); and for high-churn counting, ConcurrentHashMap with LongAdder values outperforms AtomicLong values. Contrast with Collections.synchronizedMap (one global mutex, iteration requires external locking) and with Hashtable (legacy, same global-lock problem), both of which serialize all access.
var cache = new java.util.concurrent.ConcurrentHashMap<String, Score>();
// RACE: two threads can both compute and put
// if (!cache.containsKey(id)) cache.put(id, expensiveScore(id));
// Atomic memoization: expensiveScore runs at most once per key
Score s = cache.computeIfAbsent(id, k -> expensiveScore(k));
// Atomic counting
var wordCounts = new java.util.concurrent.ConcurrentHashMap<String, Integer>();
wordCounts.merge(word, 1, Integer::sum);
// High-contention counting: LongAdder values
var hits = new java.util.concurrent.ConcurrentHashMap<String, java.util.concurrent.atomic.LongAdder>();
hits.computeIfAbsent(endpoint, k -> new java.util.concurrent.atomic.LongAdder()).increment();
// Optimistic replace
Score old = cache.get(id);
if (old != null) cache.replace(id, old, old.decayed());
Key Points
- CAS for empty bins, per-bin synchronized for occupied ones; lock-free reads
- Weakly consistent iterators; no ConcurrentModificationException
- Nulls forbidden to keep get() unambiguous
- computeIfAbsent/merge/putIfAbsent replace check-then-act races
- Keep computeIfAbsent functions short; never re-enter the same map
Q31Why is ThreadLocal dangerous in thread pools, and what do ScopedValues change?
IntermediateConcurrency
Answer
ThreadLocal gives each thread its own copy of a variable, accessed via get()/set() against a per-thread map (ThreadLocalMap) keyed weakly by the ThreadLocal object. Standard uses: per-thread SimpleDateFormat back when that class mattered, request context (user id, tenant, trace id) in servlet stacks, and per-thread buffers. The danger is the interaction with pooled threads.
Pool threads never die, so a value set during request A survives into request B on the same thread unless explicitly removed: that is context bleed, and in real systems it has meant one user's authentication or tenant id leaking into another user's request. It is also a memory leak: the entry's value is strongly referenced by the thread, so large cached objects pinned by ThreadLocals in a 200-thread pool quietly consume heap for the application's lifetime; the weak reference on the key only helps after the ThreadLocal object itself is garbage, and even then the stale value lingers until the map is touched. The discipline is try/finally with remove(), or better, framework-managed context (Spring's RequestContextHolder does exactly this dance for you).
InheritableThreadLocal copies values to child threads at creation, which is useless with pools since workers are created once, hence MDC context propagation wrappers in logging setups. ScopedValue (finalized in Java 25, JEP 506) is the redesign for the virtual-thread era: immutable, bound for a lexical scope via ScopedValue.where(KEY, value).run(task), automatically unbound on exit (no remove() to forget, no bleed), rebindable only by nesting, and cheaply inherited by children forked inside a StructuredTaskScope, which matters when a request forks thousands of virtual threads and copying InheritableThreadLocal maps per thread would be prohibitive. For request-scoped context in new virtual-thread code, ScopedValue is the answer; ThreadLocal remains for genuinely mutable per-thread state like buffers.
// Classic pooled-thread hygiene: always remove in finally
static final ThreadLocal<RequestContext> CTX = new ThreadLocal<>();
void handle(Request req) {
CTX.set(new RequestContext(req.userId(), req.traceId()));
try {
process(req);
} finally {
CTX.remove(); // forget this and the next request sees stale context
}
}
// Java 25 ScopedValue: bound for a scope, immutable, auto-cleaned
static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
void handleModern(Request req) {
ScopedValue.where(CONTEXT, new RequestContext(req.userId(), req.traceId()))
.run(() -> process(req)); // CONTEXT.get() works anywhere below this frame
}
double process(Request req) {
var ctx = CONTEXT.get(); // no set(), no remove(), no bleed
return score(ctx.userId());
}
Key Points
- Pool threads outlive requests: stale ThreadLocals = context bleed + leaks
- remove() in finally, or let the framework manage context
- ScopedValue: immutable, scope-bound, auto-unbound, cheap for virtual threads
- InheritableThreadLocal does not work with pools; copies only at thread creation
Q32How do you compose asynchronous work with CompletableFuture, and what is the common-pool trap?
IntermediateConcurrency
Answer
CompletableFuture models a value that will exist later and lets you build pipelines over it. Creation: supplyAsync(supplier) for value-producing work, runAsync for fire-and-forget. Composition: thenApply transforms the result (sync function), thenCompose flatMaps into another CompletableFuture (the async-chaining primitive; using thenApply there nests futures, a review comment you will get exactly once), thenCombine merges two independent futures, and allOf/anyOf coordinate collections, with the quirk that allOf returns CompletableFuture<Void> so you re-join individual futures afterward for their values.
Error handling: exceptionally maps a failure to a fallback, handle sees (result, throwable) either way, whenComplete observes without altering, and failures propagate down the chain wrapped in CompletionException, skipping intermediate stages exactly like a catch block skips code. The trap in the question: every *Async method without an explicit executor runs on ForkJoinPool.commonPool(), which is sized at availableProcessors() - 1 and shared JVM-wide with parallel streams. Blocking I/O on it (HTTP calls, JDBC) starves everything sharing the pool, and in a container granted 1 CPU the common pool can be a single thread, turning 'async' code into a sequential bottleneck; symptoms are mysterious latency cliffs under load.
The rule: always pass your own bounded executor to supplyAsync and the *Async variants for anything that blocks. Timeouts arrived in Java 9: orTimeout(2, SECONDS) fails the future with TimeoutException, completeOnTimeout supplies a default instead; before those, a forgotten timeout on get() could hang a request thread forever. A closing perspective interviewers increasingly expect: with virtual threads, much CompletableFuture choreography becomes unnecessary, simple blocking code on virtual threads reads better and debugs better, so reserve CompletableFuture for genuine fan-out/fan-in dataflow, not for hiding a single blocking call.
var ioPool = java.util.concurrent.Executors.newFixedThreadPool(32);
CompletableFuture<Profile> profileF =
CompletableFuture.supplyAsync(() -> fetchProfile(userId), ioPool);
CompletableFuture<List<Job>> jobsF =
CompletableFuture.supplyAsync(() -> fetchMatchingJobs(userId), ioPool);
CompletableFuture<Recommendation> rec = profileF
.thenCombine(jobsF, (profile, jobs) -> rank(profile, jobs)) // join two futures
.thenCompose(r -> enrichAsync(r, ioPool)) // flatMap, not thenApply
.orTimeout(2, java.util.concurrent.TimeUnit.SECONDS) // Java 9+
.exceptionally(ex -> {
log.warn("recommendation degraded", ex);
return Recommendation.fallback(userId);
});
// Fan-in over a collection
List<CompletableFuture<Score>> futures = ids.stream()
.map(id -> CompletableFuture.supplyAsync(() -> score(id), ioPool))
.toList();
CompletableFuture<List<Score>> all = CompletableFuture.allOf(
futures.toArray(CompletableFuture[]::new))
.thenApply(v -> futures.stream().map(CompletableFuture::join).toList());
Key Points
- thenCompose for async chaining; thenApply for sync mapping
- Default *Async executor is the shared common pool: never block it
- orTimeout/completeOnTimeout prevent forever-hangs
- Virtual threads replace much future choreography with plain blocking code
Q33How do virtual threads work under the hood, and when are they the wrong choice?
IntermediateConcurrency
Answer
Virtual threads (finalized in Java 21, JEP 444) are threads scheduled by the JVM instead of the OS. A virtual thread runs on a small pool of carrier platform threads (a ForkJoinPool sized to the CPU count); when it hits a blocking operation that the JDK has made virtual-thread-aware (socket I/O, sleep, locks, BlockingQueue), its stack is unmounted and parked on the heap, freeing the carrier to run another virtual thread. Stacks grow and shrink on demand instead of reserving a megabyte each, so millions of virtual threads are feasible where ten thousand platform threads would exhaust memory.
The programming model consequence: thread-per-request comes back. Instead of sizing pools and writing CompletableFuture chains, you write plain blocking code and create one virtual thread per task via Executors.newVirtualThreadPerTaskExecutor() or Thread.ofVirtual().start(); in Spring Boot 3.2+, spring.threads.virtual.enabled=true switches Tomcat request handling onto them. The historical caveat set, and its 2026 status: pinning, where blocking inside a synchronized block kept the carrier occupied, was the big one, diagnosable with -Djdk.tracePinnedThreads=full; JEP 491 in Java 24 eliminated synchronized pinning, leaving native frames (JNI) as the remaining pinning case.
What remains genuinely wrong for virtual threads: CPU-bound work (no blocking means no benefit, you just add scheduling overhead; use a sized pool or parallel streams), pooling them (they are cheap and disposable by design, a 'virtual thread pool' is a category error), caching expensive objects in ThreadLocals (a million threads times a pooled buffer each destroys the heap; use ScopedValue or real pools for the resources themselves), and unbounded concurrency against finite downstreams, spawning 100k virtual threads that all open JDBC connections just moves the bottleneck to the connection pool, so keep a Semaphore around scarce resources. Throughput, not latency, is what they improve: each request is not faster, you simply serve vastly more concurrent blocked requests per box.
// One virtual thread per task: the intended usage
try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
List<java.util.concurrent.Future<Resume>> results = candidateIds.stream()
.map(id -> executor.submit(() -> fetchResume(id))) // blocking code, fine
.toList();
for (var f : results) process(f.get());
} // close() waits for tasks
// Direct creation
Thread vt = Thread.ofVirtual().name("resume-fetch-", 0)
.start(() -> fetchResume(42L));
vt.join();
// Guard finite downstreams: bound concurrency, not threads
var permits = new java.util.concurrent.Semaphore(50); // e.g. DB pool size
Runnable task = () -> {
permits.acquireUninterruptibly();
try { queryDatabase(); } finally { permits.release(); }
};
// Spring Boot 3.2+: application.properties
// spring.threads.virtual.enabled=true
Key Points
- JVM-scheduled, mounted on few carrier threads, stacks parked on heap
- Bring back thread-per-request; no pools, no future chains for simple flows
- JEP 491 (Java 24) removed synchronized pinning; JNI frames still pin
- Wrong for CPU-bound work; bound scarce resources with a Semaphore
Q34A production service is hung. How do you confirm a deadlock with jstack, and how do you design it away?
IntermediateConcurrency
Answer
A deadlock needs four conditions simultaneously: mutual exclusion, hold-and-wait, no preemption, and circular wait; break any one and it cannot occur. Diagnosis first: get the PID with jps, then jstack <pid> (or jcmd <pid> Thread.print). The JVM's deadlock detector runs automatically in the dump and prints 'Found one Java-level deadlock:' followed by each thread, the monitor it holds and the one it waits for, with exact stack frames, so confirming a classic lock-ordering deadlock takes under a minute.
If threads are stuck on java.util.concurrent locks rather than monitors, the dump shows them 'parked to wait for <ownable synchronizer>', and ThreadMXBean.findDeadlockedThreads() catches those programmatically, which is worth wiring into a health check on lock-heavy services. Note what jstack cannot see: deadlocks involving external resources, two transactions waiting on each other's row locks in MySQL, or a thread pool deadlock where tasks submit subtasks to their own saturated pool and wait on them (every worker waiting on queued work that can never run). That last one is a favorite senior-round scenario; the fix is separate pools per dependency layer or never blocking on same-pool futures. Prevention hierarchy to narrate: first, do not hold two locks at once, restructure toward immutable data, ConcurrentHashMap atomic operations, or single-writer queues; second, if two locks are unavoidable, impose a global lock ordering, for example always lock the account with the lower id first in a transfer, making circular wait impossible; third, use tryLock with a timeout and back off, accepting retry complexity; and operationally, put timeouts on everything external so a wedged dependency degrades into errors instead of an ever-growing pile of blocked threads that eventually exhausts the request pool.
// Lock-ordering fix for the classic transfer deadlock
void transfer(Account from, Account to, long paise) {
Account first = from.id() < to.id() ? from : to; // global order: lower id first
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(paise);
to.credit(paise);
}
}
}
// Detection in a health check
var mx = java.lang.management.ManagementFactory.getThreadMXBean();
long[] deadlocked = mx.findDeadlockedThreads();
if (deadlocked != null) {
for (var info : mx.getThreadInfo(deadlocked, true, true)) {
log.error("DEADLOCK: {}", info);
}
}
// Shell workflow during an incident:
// jps -l -> find PID
// jstack 4242 > dump.txt -> 'Found one Java-level deadlock:' section
// jcmd 4242 Thread.print -> same, via the newer tool
Key Points
- jstack prints 'Found one Java-level deadlock' with exact monitors and frames
- ThreadMXBean.findDeadlockedThreads() for programmatic detection
- Global lock ordering eliminates circular wait
- Pool-starvation deadlocks and DB row-lock cycles never show up as JVM deadlocks
Q35List.of, Arrays.asList, Collections.unmodifiableList and List.copyOf all exist. What does each actually guarantee?
IntermediateCollections
Answer
Four different mutability contracts that look interchangeable until they throw. List.of(...) (Java 9) creates a truly immutable list: add, set and remove all throw UnsupportedOperationException, nulls are rejected at creation with NullPointerException (a surprise when converting code that previously tolerated nulls), and the implementation is a compact internal class, not ArrayList.
Arrays.asList(arr) is a fixed-size view backed by the array you passed: set() works and writes through to the array (and vice versa, mutating the array changes the list), but add/remove throw; the classic trap is Arrays.asList returning an immutable-feeling list that still mutates underneath you via the original array reference. Collections.unmodifiableList(list) is a read-only view of a live list: the caller holding the view cannot mutate, but anyone with the underlying reference still can, and the view reflects those changes; it is a fence for callers, not a freeze of state. List.copyOf(collection) (Java 10) is the defensive-copy tool: a true immutable snapshot, with the optimization that copying an already-immutable List.of-style list returns the same instance, making repeated copies free.
Practical policy for service code: return List.copyOf(internal) from getters so the class's invariants cannot be edited from outside; accept collections and copy them in constructors (records should do this in compact constructors); use unmodifiable views only when you deliberately want callers to observe live changes without being able to write. Corresponding maps and sets follow identical rules (Map.of, Map.copyOf, Collections.unmodifiableMap; Map.of also rejects duplicate keys with IllegalArgumentException at creation). And say the word 'shallow' before the interviewer does: every one of these freezes the collection structure only; mutable elements inside remain mutable, which is why immutable element types (records, String, java.time types) complete the story.
var fixed = java.util.Arrays.asList("a", "b", "c");
fixed.set(0, "z"); // OK, writes through to the backing array
// fixed.add("d"); // UnsupportedOperationException (fixed size)
var immutable = java.util.List.of("a", "b", "c");
// immutable.set(0, "z"); // UnsupportedOperationException
// java.util.List.of("a", null); // NullPointerException at creation
var source = new java.util.ArrayList<>(java.util.List.of("a"));
var view = java.util.Collections.unmodifiableList(source);
source.add("b");
System.out.println(view); // [a, b] <- the view sees live changes
var snapshot = java.util.List.copyOf(source);
source.add("c");
System.out.println(snapshot); // [a, b] <- true frozen copy
// Defensive getter pattern
class JobPosting {
private final java.util.List<String> skills = new java.util.ArrayList<>();
public java.util.List<String> skills() { return java.util.List.copyOf(skills); }
}
Key Points
- List.of: immutable, null-hostile; Arrays.asList: fixed-size, writes through
- unmodifiableList is a view over live data, not a snapshot
- List.copyOf: immutable snapshot, free when source is already immutable
- All are shallow; element immutability is a separate concern
Q36What are the sharp edges of Collectors.toMap and groupingBy, and when do parallel streams backfire?
IntermediateStreams
Answer
Collectors.toMap has two famous landmines. Duplicate keys throw IllegalStateException ('Duplicate key ...') unless you pass the three-argument form with a merge function, so any toMap keyed by something non-unique in production data (email, phone) is a latent crash; (a, b) -> b for last-wins or Integer::sum for aggregation are the standard merges. Null values throw NullPointerException, because the default HashMap-based collector uses merge() internally, which rejects nulls, so mapping to a nullable field needs filtering or a different approach. groupingBy(classifier) collects to Map<K, List<V>> and shines with downstream collectors: counting(), mapping(Candidate::name, toList()), averagingDouble, summingLong, and nested groupingBy for two-level pivots; partitioningBy(predicate) is the boolean-key specialization that always yields both true and false entries.
Java 12's teeing collector merges two downstream collectors' results in one pass, handy for computing min and max together. Order matters sometimes: groupingBy uses HashMap, so pass a TreeMap or LinkedHashMap supplier when output order is contractual. On parallel streams: parallelStream() splits the source via Spliterator and runs on ForkJoinPool.commonPool(), the same shared pool CompletableFuture defaults to.
It backfires when: the workload is I/O-bound (you block the tiny shared pool and starve every other parallel operation JVM-wide, in a 1-CPU container the pool is one thread); per-element work is trivial (split-merge overhead swamps gains, benchmark before believing); the source splits poorly (LinkedList, iterate-based streams); the pipeline uses stateful ops (sorted, distinct force barriers); or you rely on forEach ordering (use forEachOrdered, losing much parallelism). Also, shared mutable state inside a parallel pipeline is a data race; use collectors, never side effects into an ArrayList. Honest guidance for 2026: parallel streams are for CPU-bound bulk transforms of well-splitting in-memory data; for concurrent I/O fan-out, use virtual threads instead.
// toMap landmine and fix
var byEmail = candidates.stream().collect(java.util.stream.Collectors.toMap(
Candidate::email,
c -> c,
(first, dup) -> first)); // merge fn: without it, dup email = crash
// groupingBy with downstreams: avg score per city, sorted keys
var avgByCity = candidates.stream().collect(java.util.stream.Collectors.groupingBy(
Candidate::city,
java.util.TreeMap::new,
java.util.stream.Collectors.averagingDouble(Candidate::matchScore)));
// partitioningBy: both keys always present
var split = candidates.stream().collect(
java.util.stream.Collectors.partitioningBy(c -> c.experienceYears() >= 5));
// teeing: min and max in one pass (Java 12+)
var range = candidates.stream().collect(java.util.stream.Collectors.teeing(
java.util.stream.Collectors.minBy(java.util.Comparator.comparingDouble(Candidate::matchScore)),
java.util.stream.Collectors.maxBy(java.util.Comparator.comparingDouble(Candidate::matchScore)),
(min, max) -> java.util.Map.entry(min.orElseThrow(), max.orElseThrow())));
// Parallel: fine for CPU-bound transforms on ArrayList-backed data
double total = bigList.parallelStream().mapToDouble(this::cpuHeavyScore).sum();
Key Points
- toMap: IllegalStateException on duplicate keys, NPE on null values
- groupingBy + downstream collectors replaces most manual aggregation
- parallelStream shares the common pool: never block it with I/O
- Parallel pays only for CPU-bound work on splittable sources
Q37What did sequenced collections (Java 21) fix about the collections framework?
IntermediateCollections
Answer
Before Java 21, 'give me the last element' had a different answer for every collection: list.get(list.size() - 1) for List, descendingIterator().next() for Deque, and for LinkedHashSet there was simply no direct way, you iterated to the end. There was also no type you could accept in an API that promised 'has a defined encounter order' spanning those families. JEP 431 added three interfaces that retrofit the hierarchy: SequencedCollection (defines getFirst(), getLast(), addFirst(), addLast(), removeFirst(), removeLast(), and reversed()), SequencedSet extending it for ordered sets, and SequencedMap (firstEntry(), lastEntry(), putFirst(), putLast(), pollFirstEntry(), pollLastEntry(), and sequencedKeySet/sequencedValues/sequencedEntrySet views, plus reversed()).
List, Deque, LinkedHashSet and SortedSet now implement SequencedCollection; LinkedHashMap and SortedMap implement SequencedMap. The reversed() view deserves emphasis: it is a lightweight view, not a copy, so iterating a list backwards is for (var x : list.reversed()) with no index arithmetic, and the view writes through where the underlying collection allows mutation. The retrofit has honest edge cases worth naming: addFirst on a LinkedHashSet moves an existing element to the front (consistent with LinkedHashMap access semantics), while on a SortedSet the add/put-first/last methods throw UnsupportedOperationException because sort order dictates position; those details are exactly what interviewers use to check whether you have actually used the API rather than skimmed the release notes.
Practically, sequenced collections clean up LRU-ish logic over LinkedHashMap (pollFirstEntry to evict oldest), API signatures ('takes any ordered collection' rather than overloading for List and Deque), and countless get(size-1) warts. This pairs with the other Java 21 library upgrades you should be able to name in the same breath: pattern matching integration aside, Math.clamp(), String.indexOf with ranges, and Character.isEmoji arrived in the same release wave.
var recent = new java.util.LinkedHashMap<Long, String>();
recent.put(1L, "opened dashboard");
recent.put(2L, "posted job");
recent.put(3L, "invited candidate");
// Java 21 SequencedMap API
var newest = recent.lastEntry(); // 3=invited candidate
var oldest = recent.firstEntry(); // 1=opened dashboard
recent.pollFirstEntry(); // evict oldest (LRU-style)
var events = new java.util.ArrayList<>(java.util.List.of("a", "b", "c"));
String last = events.getLast(); // no more get(size() - 1)
events.addFirst("start");
for (String e : events.reversed()) { // view, not a copy
System.out.println(e);
}
// One API for anything ordered:
static <T> T newestOf(java.util.SequencedCollection<T> items) {
return items.getLast();
}
Key Points
- SequencedCollection/SequencedSet/SequencedMap unify first/last/reversed access
- reversed() is a write-through view, not a copy
- SortedSet/SortedMap throw on addFirst/putFirst; order is dictated by sorting
- LinkedHashMap + pollFirstEntry makes clean LRU eviction
Q38ClassNotFoundException versus NoClassDefFoundError, and how does class loader delegation explain them?
IntermediateJVM Internals
Answer
Both mean 'a class was not available', but at different moments and through different mechanisms. ClassNotFoundException is a checked exception thrown by explicit, by-name loading: Class.forName("com.mysql.cj.jdbc.Driver"), ClassLoader.loadClass(), reflection-driven frameworks. The class was never found at all, typically a missing jar, a typo, or (in older JDBC code) a driver not on the classpath.
NoClassDefFoundError is an Error thrown when code references a class that existed at compile time but cannot be loaded or initialized at runtime. Two distinct causes hide behind it: the class file genuinely missing at runtime (compiled against a 'provided'-scope dependency that never shipped, or a fat-jar assembly that excluded it), or, sneakier, the class failed static initialization earlier: the first touch threw ExceptionInInitializerError, the JVM marked the class dead, and every subsequent reference throws NoClassDefFoundError with the unhelpful message 'Could not initialize class X', so the real root cause is in the first occurrence in the logs, often far earlier, a genuinely useful production-debugging fact. The class loader model underneath: bootstrap loader (java.base and core modules), platform loader (JDK extras), application loader (classpath/module path), arranged with parent delegation, a loader asks its parent first and only loads itself on parent miss, which prevents user code from spoofing java.lang.String.
Frameworks break delegation deliberately: servlet containers and Spring Boot's LaunchedClassLoader prefer local classes for webapp isolation, and that is where the third sibling error lives: ClassCastException or LinkageError from the same class loaded by two loaders, 'X cannot be cast to X', the same fully-qualified name is a different runtime class per loader. Related interview staples: NoSuchMethodError signals compile-vs-runtime version skew of a dependency (the diamond dependency problem; diagnose with mvn dependency:tree and fix with dependencyManagement), and jar hell generally is why shading and the module system exist.
Key Points
- CNFE: explicit by-name loading failed; checked exception
- NCDFE: compile-time reference missing at runtime, or earlier static-init failure
- 'Could not initialize class' means look for the FIRST ExceptionInInitializerError
- Parent delegation: bootstrap -> platform -> app; containers invert it
- 'X cannot be cast to X' = same class, two loaders
Q39Why is Java native serialization avoided in modern services, and what must you know about it anyway?
IntermediateSerialization
Answer
Native serialization (implements Serializable, ObjectOutputStream/ObjectInputStream) still appears in interviews because legacy systems, HTTP session replication, and some caching layers use it, and because its failure modes are instructive. Mechanics you are expected to know: serialVersionUID is the version stamp; if you do not declare it, the compiler derives one from class structure, so an innocent refactor changes it and deserializing old data throws InvalidClassException ('local class incompatible'), which is why any Serializable class that persists data must declare private static final long serialVersionUID explicitly. transient excludes a field (it deserializes to its default value); static fields are never serialized. Constructors do not run on deserialization for the serializable class itself (the first non-serializable superclass's no-arg constructor runs instead), so invariant checks in constructors are bypassed, one reason deserialization is dangerous.
Customization hooks writeObject/readObject/readResolve exist; readResolve is how pre-enum singletons survived deserialization. The security story is the real reason for avoidance: ObjectInputStream.readObject() on untrusted bytes is remote code execution waiting to happen via gadget chains (the commons-collections exploits made this famous), and the platform's answer is deserialization filters, JEP 290's ObjectInputFilter, configurable per-stream or JVM-wide with the jdk.serialFilter property, allow-listing classes and capping depth and array sizes; context-specific filter factories arrived with JEP 415 in Java 17. State plainly in interviews: never deserialize untrusted input, and even for trusted paths prefer explicit formats, Jackson JSON for APIs, Protobuf or Avro where schemas and compactness matter, because they use documented formats, evolve schemas deliberately, and do not execute arbitrary object graphs. Also know that records serialize more safely than classes: their deserialization always goes through the canonical constructor, so validation runs, closing one classic hole.
import java.io.*;
class CachedProfile implements Serializable {
@Serial private static final long serialVersionUID = 1L; // declare it, always
private final long userId;
private transient volatile Object derivedIndex; // rebuilt, not serialized
CachedProfile(long userId) { this.userId = userId; }
}
// JEP 290 allow-list filter: refuse everything unexpected
var filter = ObjectInputFilter.Config.createFilter(
"com.goodspace.cache.*;java.util.*;java.lang.*;maxdepth=10;maxarray=10000;!*");
try (var in = new ObjectInputStream(new FileInputStream("cache.bin"))) {
in.setObjectInputFilter(filter);
var profile = (CachedProfile) in.readObject();
}
// JVM-wide default filter (ops-level guard):
// java -Djdk.serialFilter='com.goodspace.**;!*' -jar app.jar
// Records deserialize through the canonical constructor: validation runs
record Token(String value) implements Serializable {
Token { if (value == null || value.length() < 16) throw new IllegalArgumentException(); }
}
Key Points
- Declare serialVersionUID or refactors break stored data
- Constructors are bypassed for classes (not records) on deserialization
- Untrusted readObject = RCE risk; JEP 290 filters + jdk.serialFilter
- Prefer Jackson/Protobuf/Avro for anything crossing a boundary
Q40Instant, LocalDateTime and ZonedDateTime: which do you store, and why did java.time replace Date?
IntermediateCore APIs
Answer
java.util.Date and Calendar failed on every axis: mutable (a getter returning a Date hands out your internal state for editing), zone-confused (Date is a UTC instant that toString() renders in the default zone, an endless source of off-by-5:30 bugs in India), months indexed from zero, and SimpleDateFormat is both mutable and not thread-safe, so the once-common static formatter shared across request threads silently produces corrupted dates under load, a real, recurring production bug. java.time (JSR-310, Java 8) fixed all of it with immutable, thread-safe types and explicit semantics. The type choices interviewers test: Instant is a point on the UTC timeline, the correct type for event timestamps, created_at columns, and anything machines compare; LocalDateTime is a date and time with no zone at all, meaning it does not identify a moment, right for 'the interview is at 10:00' before a zone is attached, wrong for timestamps (a favorite trap question: two LocalDateTimes cannot be safely compared across systems in different zones); ZonedDateTime is an Instant plus a ZoneId like Asia/Kolkata with full DST rules, right for calendar-facing logic and recurring schedules; OffsetDateTime carries only a fixed offset (+05:30), the type JDBC's TIMESTAMP WITH TIME ZONE maps to; LocalDate and LocalTime cover dates and times alone; Duration measures machine time between instants, Period measures calendar time in years-months-days. Storage policy worth stating: persist UTC instants (TIMESTAMP columns mapped to Instant), convert to the user's zone at the display edge with atZone(); for future scheduled events keep the zone id too, since offsets change with legislation.
Formatting uses DateTimeFormatter, immutable and thread-safe, so caching one static ISO_LOCAL_DATE-style formatter is finally correct. Conversions: Date.toInstant() and Date.from(Instant) bridge legacy APIs. Never call LocalDateTime.now() in domain logic without a Clock parameter; injecting Clock.fixed() is what makes time-dependent logic unit-testable.
// Event timestamp: always Instant, always UTC in the database
Instant createdAt = Instant.now();
// Display edge: convert for the user
ZonedDateTime inIndia = createdAt.atZone(java.time.ZoneId.of("Asia/Kolkata"));
String shown = inIndia.format(java.time.format.DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a"));
// Interview slot: wall time + zone, survives DST/offset legislation
ZonedDateTime slot = ZonedDateTime.of(
java.time.LocalDate.of(2026, 9, 14), java.time.LocalTime.of(10, 0),
java.time.ZoneId.of("Asia/Kolkata"));
// Duration vs Period
var age = java.time.Period.between(java.time.LocalDate.of(1999, 4, 2),
java.time.LocalDate.now()); // years/months/days
var latency = java.time.Duration.ofMillis(37); // machine time
// Testable time: inject Clock, never call now() bare in domain code
class TrialService {
private final java.time.Clock clock;
TrialService(java.time.Clock clock) { this.clock = clock; }
boolean expired(Instant trialEnd) { return Instant.now(clock).isAfter(trialEnd); }
}
// test: new TrialService(java.time.Clock.fixed(someInstant, java.time.ZoneOffset.UTC))
Key Points
- Instant for timestamps; ZonedDateTime for calendar logic; LocalDateTime has no moment
- SimpleDateFormat is not thread-safe; DateTimeFormatter is
- Store UTC, convert at the display edge; keep ZoneId for future events
- Inject Clock for testability
Q41How does JDBC connection pooling with HikariCP work, and how do you size and monitor it?
IntermediatePersistence
Answer
Opening a database connection costs a TCP handshake, TLS negotiation and server-side session setup, tens of milliseconds plus server memory, so per-request connections collapse under load. A pool keeps warm connections; getConnection() borrows one and close() returns it (the pool hands you a proxy whose close() is a return, which is why try-with-resources remains mandatory). HikariCP is the default pool in Spring Boot and the de facto standard.
The settings that matter: maximumPoolSize (default 10; HikariCP's own guidance is that small pools outperform big ones, the classic formula being roughly cores * 2 for the database server, because a database can only actually execute a handful of queries concurrently and 200 connections mostly queue against each other while consuming Postgres/MySQL memory); connectionTimeout (default 30s, how long a borrower waits before SQLTransientConnectionException, 'Connection is not available, request timed out after 30000ms', the single most misread error in Java services, it almost always means leaked or slow-returning connections, not a database outage); maxLifetime (retire connections before infra kills them, set slightly below the LB or MySQL wait_timeout to avoid 'connection reset' storms); idleTimeout and minimumIdle for elasticity; and leakDetectionThreshold, which logs a stack trace when a connection stays borrowed too long, turning 'pool exhausted at 3 AM' into a pointed code review. The leak pattern itself: any path that borrows and does not return on exception, cured by try-with-resources on Connection, PreparedStatement and ResultSet. Always use PreparedStatement, for SQL injection safety and for statement caching (with MySQL, enable cachePrepStmts, prepStmtCacheSize and useServerPrepStmts in the JDBC URL properties). Monitor via Hikari's Micrometer metrics: hikaricp.connections.active, .idle, .pending and .acquire; sustained pending with a healthy database means the pool is sized below concurrency or transactions hold connections too long, the usual culprit being long transactions wrapped around remote HTTP calls.
var config = new com.zaxxer.hikari.HikariConfig();
config.setJdbcUrl("jdbc:mysql://db.internal:3306/goodspace");
config.setUsername("app");
config.setPassword(System.getenv("DB_PASSWORD"));
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
config.setConnectionTimeout(3_000); // fail fast; 30s default hides problems
config.setMaxLifetime(1_500_000); // 25 min, below infra idle kill
config.setLeakDetectionThreshold(10_000); // log stacks for 10s+ borrows
config.addDataSourceProperty("cachePrepStmts", "true");
config.addDataSourceProperty("prepStmtCacheSize", "250");
var ds = new com.zaxxer.hikari.HikariDataSource(config);
// Borrow-use-return, leak-proof
try (var conn = ds.getConnection();
var ps = conn.prepareStatement("SELECT title FROM jobs WHERE status = ?")) {
ps.setInt(1, 1);
try (var rs = ps.executeQuery()) {
while (rs.next()) titles.add(rs.getString(1));
}
}
// Spring Boot equivalents (application.properties):
// spring.datasource.hikari.maximum-pool-size=20
// spring.datasource.hikari.leak-detection-threshold=10000
Key Points
- close() returns the proxy to the pool; try-with-resources everywhere
- 'request timed out after 30000ms' usually means leaks, not DB death
- Small pools win: DB concurrency is bounded by cores, not connections
- leakDetectionThreshold + Micrometer pending/acquire metrics for monitoring
Q42Show how you test Java code with JUnit 5 and Mockito, including parameterized tests and captors, and what you refuse to mock.
IntermediateTesting
Answer
JUnit 5 (the Jupiter API) is the baseline: @Test, lifecycle hooks @BeforeEach/@AfterEach/@BeforeAll/@AfterAll, assertThrows for exception testing (which replaced the old expected attribute and lets you assert on the thrown exception's state), assertAll for grouped soft assertions, @Nested classes to organize scenarios, @DisplayName for readable reports, and assumptions (assumeTrue) to skip environment-dependent tests. Parameterized tests kill copy-paste suites: @ParameterizedTest with @ValueSource for single args, @CsvSource for inline tabular cases, @MethodSource for complex objects streamed from a factory method, and @EnumSource to cover every constant, the standard tool for boundary matrices like GST slabs or scoring thresholds. Mockito supplies test doubles: @ExtendWith(MockitoExtension.class) activates @Mock and @InjectMocks, when(...).thenReturn(...) stubs, thenThrow simulates failures, verify asserts interactions, and ArgumentCaptor.forClass captures what your code actually passed to a collaborator so you can assert on a built object's fields rather than just 'it was called'.
Modern Mockito mocks final classes and records' consumers fine, and mockStatic exists for the rare legacy static, with the strict caveat that reaching for it routinely signals a design problem better fixed with injection. What not to mock, which is where senior candidates differentiate: never mock the class under test; never mock value objects or entities (construct them); never mock types you do not own at the wire level, do not mock a JDBC ResultSet or an HTTP client, use Testcontainers for a real MySQL/Postgres and WireMock or MockWebServer for HTTP, because mock-heavy tests of integration seams verify your assumptions, not reality. StrictStubs (the default in current Mockito) fails tests with unused stubbings, keep it on, it catches drift. Structure every test as given-when-then, one behavior per test, and name tests after the behavior ('rejectsExpiredToken'), not the method ('testValidate2').
@org.junit.jupiter.api.extension.ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class InvoiceServiceTest {
@org.mockito.Mock PaymentGateway gateway;
@org.mockito.Mock InvoiceRepository repo;
@org.mockito.InjectMocks InvoiceService service;
@org.junit.jupiter.api.Test
void chargesGatewayWithGstInclusiveAmount() {
org.mockito.Mockito.when(gateway.charge(org.mockito.ArgumentMatchers.any()))
.thenReturn(new ChargeResult("ch_1", true));
service.bill(new Invoice(1000_00L, "INR")); // 18% GST added inside
var captor = org.mockito.ArgumentCaptor.forClass(ChargeRequest.class);
org.mockito.Mockito.verify(gateway).charge(captor.capture());
org.junit.jupiter.api.Assertions.assertEquals(1180_00L, captor.getValue().amountPaise());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({
"0, 0",
"100000, 18000",
"250050, 45009",
})
void computesGst(long base, long expectedGst) {
org.junit.jupiter.api.Assertions.assertEquals(expectedGst, service.gstPaise(base));
}
@org.junit.jupiter.api.Test
void failsClosedWhenGatewayThrows() {
org.mockito.Mockito.when(gateway.charge(org.mockito.ArgumentMatchers.any()))
.thenThrow(new GatewayTimeoutException());
org.junit.jupiter.api.Assertions.assertThrows(BillingFailedException.class,
() -> service.bill(new Invoice(1000_00L, "INR")));
}
}
Key Points
- assertThrows, @Nested, assertAll are the Jupiter staples
- @ParameterizedTest + @CsvSource/@MethodSource for boundary matrices
- ArgumentCaptor asserts what was passed, not just that a call happened
- Real DBs via Testcontainers, real HTTP via WireMock; do not mock the wire
Q43Maven versus Gradle in 2026: lifecycles, dependency scopes, BOMs, and where builds actually go wrong.
IntermediateBuild Tools
Answer
Maven is declarative XML around a fixed lifecycle: validate, compile, test, package, verify, install, deploy; running mvn package executes every phase up to it via bound plugins (surefire runs tests, jar packages). Its dependency scopes are interview staples: compile (default, everywhere), provided (compile-time only, the container supplies it at runtime, think servlet-api or Lombok), runtime (JDBC drivers: needed to run, hidden from compile so code cannot accidentally import them), test, and import, which only works inside dependencyManagement to pull in a BOM. A BOM (bill of materials) centralizes versions: importing spring-boot-dependencies means your dependency declarations omit versions and inherit tested, mutually compatible ones, which is how Spring Boot keeps hundreds of libraries aligned and how platform teams pin versions across dozens of microservice repos.
Version conflicts are Maven's chronic disease: it resolves by nearest-wins in the dependency tree (not highest version), so a transitive downgrade can ship silently until NoSuchMethodError at runtime; the reflexes are mvn dependency:tree -Dincludes=com.fasterxml.jackson.core to see who pulls what, and an explicit dependencyManagement entry to pin the winner. Gradle describes the build as a task graph in Kotlin or Groovy DSL, resolves conflicts by highest-version instead, and wins on raw speed through incremental builds, a persistent daemon, per-module compile avoidance, and build caching; its implementation versus api split in the java-library plugin also gives real encapsulation of transitive dependencies, which Maven cannot express. Android mandates Gradle; large multi-module backends increasingly choose it for build times. In interviews, the differentiator is not naming a winner but demonstrating you can debug either: explain nearest-wins versus highest-wins, read a dependency tree, know why a 'provided' jar missing at runtime throws NoClassDefFoundError, and mention reproducibility practices, the Gradle wrapper (gradlew) or Maven wrapper pinning tool versions so CI and laptops agree.
<!-- BOM import: versions come from the platform -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.4.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <!-- no version: BOM supplies it -->
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope> <!-- run yes, compile no -->
</dependency>
</dependencies>
# Debugging version conflicts:
# mvn dependency:tree -Dincludes=com.fasterxml.jackson.core
# gradle dependencies --configuration runtimeClasspath
Key Points
- Maven: fixed lifecycle, nearest-wins resolution; Gradle: task graph, highest-wins
- Scopes: provided and runtime are the ones interviews probe
- BOM via import scope keeps microservice fleets version-aligned
- dependency:tree first whenever NoSuchMethodError appears
Q44Why does Spring push constructor injection, and what is the practical difference between @Component and @Bean?
IntermediateSpring
Answer
Both register beans in the ApplicationContext, but from opposite directions. @Component (and its stereotypes @Service, @Repository, @Controller/@RestController, each adding semantics, @Repository for instance enables DataAccessException translation) annotates a class you own so component scanning discovers and instantiates it. @Bean annotates a method inside a @Configuration class whose return value becomes the bean, which is the only option for classes you do not own (an ObjectMapper, a HikariDataSource, a Java 11 HttpClient) and for beans needing construction logic or several differently-configured instances distinguished by @Qualifier. A detail that separates readers from users: @Configuration classes are CGLIB-proxied so that internal @Bean-method calls return the singleton rather than executing the method again ('proxyBeanMethods'), whereas @Configuration(proxyBeanMethods = false) trades that guarantee for faster startup, the mode Spring Boot's own autoconfigurations use. On injection style: field injection (@Autowired on a field) is the one Spring's own team tells you to avoid, because it hides dependencies (nothing in the constructor signature reveals what the class needs), makes the class unconstructable without a container (plain unit tests must use reflection), permits circular dependencies to slip through, and cannot produce final fields.
Constructor injection fixes all four: dependencies are explicit and enforced at construction, fields are final (thread-safe publication for free), tests just call new with mocks, and circular dependencies fail fast at startup with BeanCurrentlyInCreationException instead of lurking, and the correct response to that error is refactoring the cycle, not @Lazy plasters. Since Spring 4.3, a single constructor needs no @Autowired annotation at all. Round out the answer with bean scopes (singleton default, prototype, request/session in web apps) and the classic scope trap: injecting a prototype into a singleton freezes one instance at wiring time, solved with ObjectProvider<T> or scoped proxies.
@org.springframework.context.annotation.Configuration
class HttpConfig {
@org.springframework.context.annotation.Bean
java.net.http.HttpClient httpClient() { // third-party type: @Bean method
return java.net.http.HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofSeconds(2))
.build();
}
}
@org.springframework.stereotype.Service
class MatchService {
private final CandidateRepository repo; // final: set once, thread-safe
private final java.net.http.HttpClient http;
// Single constructor: no @Autowired needed since Spring 4.3
MatchService(CandidateRepository repo, java.net.http.HttpClient http) {
this.repo = repo;
this.http = http;
}
}
// Plain unit test, no Spring context required:
// var service = new MatchService(mockRepo, mockHttp);
Key Points
- @Component: scan your classes; @Bean: factory methods for anything else
- Field injection hides dependencies and blocks final fields; avoid it
- Constructor injection = testable without the container, cycles fail fast
- @Configuration proxying makes internal @Bean calls return singletons
Q45How do you detect and fix Hibernate's N+1 problem, and why is open-in-view part of the story?
IntermediateHibernate
Answer
N+1 is one query for a list plus one lazy-initialization query per row: load 50 job postings, touch posting.getApplications() in a loop, and Hibernate fires 50 extra selects. It hides in development (tiny data, local DB) and detonates in production latency. Detection first: set spring.jpa.properties.hibernate.generate_statistics=true and watch query counts per request, log SQL in dev (logging.level.org.hibernate.SQL=DEBUG), or use a detector library that fails tests when a lazy load happens inside a loop; a test asserting 'this endpoint issues at most N queries' via statistics is cheap and durable.
Fixes, in the order worth reciting: JPQL fetch join (select p from Posting p join fetch p.applications where ...), which loads the graph in one query, with the caveats that fetch-joining a collection multiplies rows (use distinct, or Hibernate 6's improved deduplication) and combining collection fetch joins with pagination forces in-memory pagination, Hibernate logs 'HHH000104: firstResult/maxResults specified with collection fetch; applying in memory', which silently loads the whole table, a production incident pattern worth quoting by its log code. @EntityGraph on a Spring Data repository method achieves the same per-use-case eager loading declaratively. Batch fetching (spring.jpa.properties.hibernate.default_batch_fetch_size=50) is the low-risk global mitigation: N+1 becomes N/50+1 by loading lazy associations with IN-clauses, usually the first thing to enable on legacy codebases. DTO projections skip entities entirely (select new com.x.PostingSummary(p.id, p.title, count(a)) ... group by), the right call for read-heavy list screens.
What not to do: switching the mapping to FetchType.EAGER globally, which trades explicit N+1 for implicit joins everywhere and cartesian blowups. Finally, spring.jpa.open-in-view (default true in Spring Boot) keeps the session open through view rendering, so lazy loads 'work' in controllers and templates, masking N+1 and holding DB connections through slow rendering; serious teams set it false and fetch deliberately, and mentioning that default unprompted signals production Hibernate experience. The related LazyInitializationException ('could not initialize proxy, no Session') is the flip side: it means you touched a lazy association after the session closed, and the fix is fetching what you need inside the transaction, not reopening sessions in views.
// Fetch join: one query for the whole graph
@org.springframework.data.jpa.repository.Query(
"select distinct p from Posting p join fetch p.applications where p.status = :status")
List<Posting> findActiveWithApplications(@org.springframework.data.repository.query.Param("status") Status status);
// Same idea, declaratively per repository method
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"applications", "recruiter"})
List<Posting> findByStatus(Status status);
// DTO projection for list screens: no entities, no lazy anything
@org.springframework.data.jpa.repository.Query(
"select new com.goodspace.jobs.PostingSummary(p.id, p.title, count(a)) " +
"from Posting p left join p.applications a group by p.id, p.title")
List<PostingSummary> summaries();
# application.properties: the safety nets
# spring.jpa.open-in-view=false
# spring.jpa.properties.hibernate.default_batch_fetch_size=50
# spring.jpa.properties.hibernate.generate_statistics=true
Key Points
- Detect with hibernate.generate_statistics and query-count assertions in tests
- fetch join / @EntityGraph per use case; batch_fetch_size as global mitigation
- HHH000104 = collection fetch + pagination paginating in memory
- open-in-view=false; never 'fix' with global EAGER
Q46How do you use java.net.http.HttpClient properly, and what did it replace?
IntermediateCore APIs
Answer
HttpURLConnection was the JDK's embarrassment for two decades: a clumsy, partially thread-unsafe API with no HTTP/2, which is why every codebase pulled in Apache HttpClient or OkHttp. java.net.http.HttpClient (standard since Java 11) is the modern built-in: fluent builders, HTTP/2 by default with transparent HTTP/1.1 fallback, synchronous send() and asynchronous sendAsync() returning CompletableFuture, and typed BodyHandlers (ofString, ofByteArray, ofFile, ofInputStream, ofLines for streaming, plus discarding). The production checklist interviewers listen for: the client is immutable and thread-safe, build one and reuse it (it holds a connection pool; per-request clients leak sockets and forfeit keep-alive); set connectTimeout on the client and a per-request timeout(Duration) on HttpRequest, because the default request timeout is infinite and an unresponsive dependency will otherwise pin threads forever; check statusCode() yourself, the client does not throw on 4xx/5xx (unlike some libraries), so a forgotten check happily parses an error page as JSON; configure followRedirects(Redirect.NORMAL) explicitly if you expect them, the default is NEVER; and pass an executor for sendAsync callbacks if you do not want the common pool. Since Java 21 HttpClient implements AutoCloseable for orderly shutdown.
JSON is deliberately out of scope, pair it with Jackson (ObjectMapper.readValue on the response body). For interviews at companies running Spring, connect it to the ecosystem: RestTemplate is in maintenance mode, Spring's RestClient (synchronous, fluent, Spring 6.1+) and WebClient (reactive) are the framework-level choices, and both can ride on the JDK client underneath; standalone services and libraries increasingly use the JDK client directly to cut dependencies. With virtual threads, the blocking send() call composes perfectly, fan out a thousand requests on virtual threads with plain code instead of callback pyramids, which has quietly made the synchronous API the default choice again.
// Build ONCE, reuse: it owns a connection pool
static final java.net.http.HttpClient CLIENT = java.net.http.HttpClient.newBuilder()
.version(java.net.http.HttpClient.Version.HTTP_2)
.connectTimeout(java.time.Duration.ofSeconds(2))
.followRedirects(java.net.http.HttpClient.Redirect.NORMAL)
.build();
var request = java.net.http.HttpRequest.newBuilder(
java.net.URI.create("https://api.goodspace.ai/v1/jobs?city=Noida"))
.timeout(java.time.Duration.ofSeconds(5)) // default is INFINITE
.header("Accept", "application/json")
.GET()
.build();
var response = CLIENT.send(request, java.net.http.HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) { // it does NOT throw on 5xx
throw new UpstreamException(response.statusCode(), response.body());
}
var jobs = objectMapper.readValue(response.body(), JobList.class);
// Async fan-out when you need it
CLIENT.sendAsync(request, java.net.http.HttpResponse.BodyHandlers.ofString())
.thenApply(java.net.http.HttpResponse::body)
.thenAccept(this::index);
Key Points
- One shared client instance; per-request clients leak connections
- Request timeout defaults to infinite: always set it
- No exceptions on 4xx/5xx; check statusCode() yourself
- Blocking send() + virtual threads beats callback chains for fan-out
Q47How do you design an exception hierarchy for a service, and when does exception cost actually matter?
IntermediateExceptions
Answer
The pattern that scales: one abstract base, say AppException extends RuntimeException, carrying a stable machine-readable error code (an enum, INSUFFICIENT_CREDITS, JOB_NOT_FOUND), an HTTP-ish severity mapping, and structured context fields; concrete subclasses per domain area. Services throw domain exceptions; one boundary translator (Spring's @RestControllerAdvice with @ExceptionHandler methods, returning ProblemDetail per RFC 9457 in Spring 6+) maps codes to HTTP status and a uniform JSON error shape, so no controller ever formats errors ad hoc, clients can switch on error codes rather than parse messages, and adding a new error is one enum entry plus one mapping row. The chaining rule is absolute: wrap with the cause (throw new PaymentFailedException(orderId, e)), because a stack trace whose root cause was dropped during rethrow is the most expensive logging mistake there is; and log an exception exactly once, at the boundary, log-and-rethrow at every layer turns one failure into five duplicate stack traces and pages worth of noise.
Now cost, because seniors get asked: constructing an exception is expensive mainly due to fillInStackTrace(), which walks and records every frame; throwing and catching adds control-flow cost; deep stacks (Spring proxies plus Hibernate can be 200+ frames) make this worse. This only matters when exceptions occur at high frequency, which is itself the smell: exceptions as control flow (using NumberFormatException to test if a string is numeric on every request, or NoSuchElementException to end loops) is both slow and unreadable. Where a hot path legitimately throws a signal-type exception, you can override fillInStackTrace() to return this, or use the Throwable constructor with writableStackTrace=false, patterns used inside frameworks and worth knowing, with the caveat that a stackless exception is undebuggable so it must be truly expected flow. Also know suppressed exceptions surface in traces from try-with-resources, and that the JVM may emit pre-allocated stackless OutOfMemoryErrors, so an OOM trace can legitimately be empty.
public abstract class AppException extends RuntimeException {
private final ErrorCode code;
protected AppException(ErrorCode code, String message, Throwable cause) {
super(message, cause); // ALWAYS carry the cause
this.code = code;
}
public ErrorCode code() { return code; }
}
public final class InsufficientCreditsException extends AppException {
public InsufficientCreditsException(long recruiterId, long needed, long available) {
super(ErrorCode.INSUFFICIENT_CREDITS,
"recruiter %d needs %d credits, has %d".formatted(recruiterId, needed, available),
null);
}
}
// One boundary translator for the whole API (Spring 6 ProblemDetail)
@org.springframework.web.bind.annotation.RestControllerAdvice
class ApiErrorHandler {
@org.springframework.web.bind.annotation.ExceptionHandler(AppException.class)
org.springframework.http.ProblemDetail handle(AppException e) {
var pd = org.springframework.http.ProblemDetail.forStatus(e.code().httpStatus());
pd.setTitle(e.code().name());
pd.setDetail(e.getMessage());
return pd;
}
}
// Hot-path signal exception: no stack capture (use sparingly)
class RetrySignal extends RuntimeException {
RetrySignal() { super(null, null, false, false); } // writableStackTrace=false
}
Key Points
- Error-code enum + one @RestControllerAdvice translator = uniform contracts
- Wrap with cause; log once at the boundary, never at every layer
- fillInStackTrace() is the cost; frequent throws signal control-flow abuse
- writableStackTrace=false exists for genuine signal exceptions
Q48What changed in everyday String handling: text blocks, formatted, strip, and what happened to string templates?
IntermediateModern Java
Answer
Text blocks (standard since Java 15) are triple-quoted multi-line literals that finally made embedded SQL, JSON and HTML readable. Their rules carry interview weight: the closing delimiter's position controls incidental-indentation stripping (the compiler removes the common leading whitespace, so re-indenting your code does not change the string), trailing spaces per line are stripped, a backslash at end of line joins lines without a newline, and \s preserves a trailing space. They are ordinary String objects at runtime, compile-time equal to their escaped equivalents, so they intern and concatenate identically.
Alongside them, the utility methods added since Java 11 remove daily friction: strip()/stripLeading()/stripTrailing() are Unicode-aware (they remove non-breaking spaces and other Character.isWhitespace matches that the ancient trim(), which only cuts chars <= U+0020, leaves behind, a real bug when cleaning user input pasted from Word); isBlank() checks whitespace-only; lines() streams a multi-line string; repeat(n) replaces loop-built separators; chars() streams code points; and formatted(args) (Java 15) is an instance-method String.format, pleasant on text blocks. On templates: string templates (STR."Hello \{name}") previewed in Java 21 and 22, and were then withdrawn in Java 23 for redesign rather than finalized, so as of current JDKs there is no string interpolation in Java; saying that plainly, and knowing formatted()/String.format/MessageFormat remain the tools, signals you track the platform rather than blog headlines. It is also worth carrying the compact-strings fact (byte[] storage with a Latin-1/UTF-16 coder flag since Java 9, halving memory for ASCII-heavy heaps) and the practical implication of immutability for substring: since Java 7, substring() copies rather than sharing the backing array, so holding a tiny substring of a huge string no longer pins the big one in memory, but building many substrings of large payloads still allocates proportionally.
String query = """
SELECT c.id, c.name, c.match_score
FROM candidates c
WHERE c.city = ?
AND c.experience_years >= ?
ORDER BY c.match_score DESC
"""; // closing delimiter sets indent stripping
String json = """
{
"job": "%s",
"openings": %d
}
""".formatted(title, openings); // instance-method formatting
// Unicode-aware cleaning: trim() would MISS the non-breaking space
String pasted = "\u00A0 Bengaluru \u00A0";
System.out.println(pasted.trim()); // "\u00A0 Bengaluru \u00A0" unchanged ends
System.out.println(pasted.strip()); // "Bengaluru"
"header\n".repeat(3).lines().forEach(System.out::println);
// No string templates in current Java: previewed in 21/22, withdrawn in 23.
// Interpolation today is formatted()/String.format, not STR."...".
Key Points
- Text blocks: indentation controlled by the closing delimiter
- strip() is Unicode-aware; trim() only cuts <= U+0020
- String templates were withdrawn in Java 23; no interpolation exists yet
- Compact strings since Java 9 halve memory for Latin-1 content
Q49How does tiered JIT compilation work, and why do naive Java microbenchmarks lie?
AdvancedPerformance
Answer
HotSpot executes bytecode in tiers. Everything starts interpreted while invocation and back-edge counters accumulate; warm methods compile with C1 (fast compilation, light optimization, instrumented to keep profiling) and hot ones recompile with C2 (slow compilation, aggressive optimization: deep inlining, loop unrolling, vectorization, escape analysis). The profile-driven parts are what make benchmarks treacherous.
C2 performs speculative optimization: if a call site has only ever seen one receiver type (monomorphic), the virtual call is devirtualized and inlined behind a cheap type guard; feed that site a second type later and the assumption fails, triggering deoptimization, the compiled frame is replaced mid-flight via an uncommon trap and the method re-profiles, which is why a service can get slower after new traffic shapes arrive. Escape analysis proves an allocation never escapes a method and scalar-replaces it, fields live in registers, no heap allocation at all, which is why 'objects are expensive' folklore often fails to reproduce. Now the benchmark consequences: measuring with System.nanoTime around a loop measures a moving target (interpreter, then C1, then C2), dead-code elimination deletes computations whose results are unused, constant folding precomputes what you thought was work, and on-stack replacement compiles your benchmark loop differently from real code.
JMH exists precisely to defeat these: @Warmup iterations get past compilation churn, @Fork isolates JVM runs (profile pollution from one benchmark corrupts the next), Blackhole.consume defeats dead-code elimination, and @State injects non-constant inputs to block folding. Reciting 'use JMH, warm up, consume results into a Blackhole, fork the JVM' with reasons is the expected answer. Useful flags for real investigation: -Xlog:jit+compilation, -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining to see inlining decisions, and JFR's compilation events; and remember the code cache, if ReservedCodeCacheSize fills, compilation stops and the whole service quietly runs slower.
// JMH: the only honest way to microbenchmark Java
@org.openjdk.jmh.annotations.State(org.openjdk.jmh.annotations.Scope.Thread)
@org.openjdk.jmh.annotations.BenchmarkMode(org.openjdk.jmh.annotations.Mode.AverageTime)
@org.openjdk.jmh.annotations.OutputTimeUnit(java.util.concurrent.TimeUnit.NANOSECONDS)
@org.openjdk.jmh.annotations.Warmup(iterations = 5, time = 1)
@org.openjdk.jmh.annotations.Measurement(iterations = 5, time = 1)
@org.openjdk.jmh.annotations.Fork(2)
public class ConcatBenchmark {
@org.openjdk.jmh.annotations.Param({"10", "1000"})
int size;
java.util.List<String> parts;
@org.openjdk.jmh.annotations.Setup
public void setup() {
parts = java.util.stream.IntStream.range(0, size)
.mapToObj(Integer::toString).toList();
}
@org.openjdk.jmh.annotations.Benchmark
public void stringBuilder(org.openjdk.jmh.infra.Blackhole bh) {
var sb = new StringBuilder();
for (String p : parts) sb.append(p);
bh.consume(sb.toString()); // defeats dead-code elimination
}
@org.openjdk.jmh.annotations.Benchmark
public void joiner(org.openjdk.jmh.infra.Blackhole bh) {
bh.consume(String.join("", parts));
}
}
Key Points
- Interpreter -> C1 (profiled) -> C2 (speculative); deopt on broken assumptions
- Devirtualization, inlining and escape analysis depend on observed profiles
- Naive loops measure warmup, dead code and folding, not your code
- JMH: warmup, forks, Blackhole, @State params; check the code cache in prod
Q50Walk through diagnosing a GC problem in production from -Xlog:gc* output to a fix.
AdvancedPerformance
Answer
Step zero is having data: run permanently with -Xlog:gc*:file=gc.log:time,uptime,level,tags,filecount=5,filesize=20m; unified logging's overhead is negligible and retrofitting logging after an incident means flying blind. Reading G1 lines: 'Pause Young (Normal) (G1 Evacuation Pause) 2048M->512M(4096M) 12.3ms' gives you occupancy before and after, committed heap, and pause duration; 'Pause Young (Concurrent Start)' begins concurrent marking; 'Pause Remark' and 'Pause Cleanup' bound the marking cycle; mixed collections then chew old regions. The pathologies and their signatures: to-space exhaustion ('Evacuation Failure' or 'to-space exhausted') means survivors could not be copied, expect a follow-up Full GC, usually from heap pressure or a burst of mid-lived objects; 'Pause Full (G1 Compaction Pause)' appearing at all is your alarm, G1 full GCs are single-digit-seconds stop-the-world; humongous allocations (objects over half a region, visible via gc+humongous logs) fragment old gen, the fix being either -XX:G1HeapRegionSize up to 32m or, better, not allocating 20 MB byte arrays per request; and a sawtooth where each collection reclaims less until OOM is a leak, not a tuning problem, go take a heap dump.
Then interpret trends, not single pauses: allocation rate (young-gen delta divided by interval) tells you whether the application is simply churning too much garbage, in which case the profitable fix is in code, object reuse, streaming instead of buffering whole payloads, killing accidental per-request ObjectMapper creation, not in flags. Only then tune: bigger heap (Xms=Xmx to avoid resize pauses), pause target realism (MaxGCPauseMillis=50 on a 30 GB heap forces tiny young gens and constant collections), or move to generational ZGC when p99 pauses on a large heap genuinely dominate, accepting its small throughput tax. Tools to name: GCEasy or gceasy-style analyzers for visualization, jstat -gcutil for live ratios, and JFR's allocation profiling to find which code allocates. The narrative interviewers reward is evidence-first: log, quantify allocation rate and pause distribution, correlate with p99, change one variable, re-measure.
# Always-on GC logging (negligible overhead, rotating files)
java -Xms8g -Xmx8g \
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m \
-XX:MaxGCPauseMillis=100 \
-jar app.jar
# Live view of generation occupancy + GC counts
jstat -gcutil <pid> 1000
# Humongous allocation visibility (G1)
# add: -Xlog:gc+humongous=debug
# What the bad lines look like:
# [12.345s][info][gc] GC(42) Pause Full (G1 Compaction Pause) 8000M->7600M(8192M) 4123ms <- alarm
# [13.001s][info][gc] GC(43) To-space exhausted <- pressure
# Sawtooth trending to OOM? Stop tuning, dump the heap:
jcmd <pid> GC.heap_dump /tmp/leak.hprof
Key Points
- Run with -Xlog:gc* permanently; retrofit is too late
- Full GC and to-space exhaustion in G1 logs are incidents, not noise
- High allocation rate is a code problem before it is a flag problem
- Trend analysis (reclaim per cycle) separates leaks from load
Q51A JVM's heap keeps growing until OutOfMemoryError. Walk through the leak hunt end to end.
AdvancedProduction Debugging
Answer
First, capture evidence before the JVM dies: run with -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps so the terminal OOM leaves a corpse to autopsy, and grab a live comparison pair with jcmd <pid> GC.heap_dump (or jmap -dump:live,format=b,file=heap.hprof <pid>, where 'live' forces a full GC first so you only see reachable objects). A quick class histogram, jcmd <pid> GC.class_histogram, sometimes ends the hunt in one command when 40 million HashMap$Node instances stare back at you. For real analysis open the .hprof in Eclipse MAT (or VisualVM): the Dominator Tree ranks objects by retained heap (what would be freed if this object died), which is the number that matters, and Path to GC Roots on the biggest dominator, excluding weak/soft references, names the exact chain keeping it alive, typically ending in a static field, a ThreadLocal, or a listener registry.
The usual suspects, in rough frequency order from real incidents: unbounded static caches (a Map used as a cache with no eviction; fix with Caffeine and a maximumSize/expireAfterWrite policy), ThreadLocals never removed on pooled threads, listeners/callbacks registered but never deregistered, ClassLoader leaks on redeploy (old app's classes pinned by a lingering thread or a static in a shared library; visible as Metaspace growth plus duplicate classes in MAT), long-lived collections keyed by objects whose hashCode changed after insertion (unremovable entries), unclosed resources buffering internally, and caching frameworks holding sessions. Two dumps taken thirty minutes apart, compared in MAT's histogram diff, separate a genuine leak (same dominator growing) from a fat-but-stable working set. If dumps are impractical (an 80 GB heap), JFR's OldObjectSample event, enabled with -XX:StartFlightRecording=settings=profile, samples long-lived allocations with stack traces at production-safe overhead and often points at the allocation site directly. State the endgame too: the fix is verified by re-running the load with the same dump-diff methodology, not by eyeballing a graph for a day.
# Insurance BEFORE the incident
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps \
-XX:StartFlightRecording=settings=profile,maxsize=512m,filename=cont.jfr \
-jar app.jar
# Fast triage: top classes by instance count and bytes
jcmd <pid> GC.class_histogram | head -25
# Reachable-only dump for MAT (forces a full GC: mind the pause)
jcmd <pid> GC.heap_dump /dumps/leak-$(date +%s).hprof
# In Eclipse MAT:
# 1. Dominator Tree -> sort by Retained Heap
# 2. Right-click top entry -> Path to GC Roots -> exclude weak/soft refs
# 3. Histogram diff of two dumps taken 30 min apart -> what GREW
// The classic culprit this finds:
class ProfileCache {
static final java.util.Map<Long, Profile> CACHE = new java.util.HashMap<>(); // unbounded
}
// Fix: bounded cache with eviction
// Caffeine.newBuilder().maximumSize(50_000).expireAfterWrite(Duration.ofMinutes(10)).build();
Key Points
- -XX:+HeapDumpOnOutOfMemoryError is free insurance; set it everywhere
- MAT Dominator Tree + Path to GC Roots names the retaining chain
- Static maps, ThreadLocals, listeners, classloaders: the big four leaks
- Dump-diff over time distinguishes leak from large working set
- JFR OldObjectSample when heaps are too big to dump
Q52Explain safe publication, final-field semantics, and why double-checked locking needs volatile.
AdvancedConcurrency
Answer
Safe publication is the discipline of making an object built by one thread visible, fully constructed, to others. Without a happens-before edge between construction and another thread's read, the JMM permits that reader to see a stale or partially-initialized object: constructor writes can be reordered with the publishing write of the reference, so thread B can observe a non-null reference whose fields are still default values. The sanctioned publication idioms: assign to a final field of a properly constructed object (final-field semantics guarantee that if 'this' does not escape the constructor, any thread that sees the object sees its final fields fully initialized, this is the strongest and cheapest guarantee, and the deep reason immutable objects are trivially thread-safe); assign to a volatile field; hand off via a concurrent collection (ConcurrentHashMap.put is a release, get an acquire); publish inside a lock; or leverage class initialization.
The last one powers the initialization-on-demand holder idiom: a static inner Holder class whose static field is created when the JVM initializes the class, and the JVM's class-init locking (per JLS 12.4.2) makes that both lazy and thread-safe with zero synchronization in user code, the cleanest lazy singleton. Double-checked locking is the historically broken version: check null, lock, check again, assign. Without volatile on the instance field it is incorrect for exactly the reordering reason above, the write of the reference can become visible before the constructor's writes, so a second thread skips the lock and uses a half-built object.
Marking the field volatile repairs it: the volatile write is a release, the first (unlocked) read an acquire, restoring the edge. Modern verdict to state: DCL-with-volatile is correct but rarely the best answer, the holder idiom is simpler for statics, enums are simpler still, and for instance-level lazy values ConcurrentHashMap.computeIfAbsent or a VarHandle-based memoizer is clearer. The 'this-escape' bug rounds out the topic: registering a listener or starting a thread inside a constructor leaks a partially built object even with final fields, since the guarantee only holds once the constructor completes.
// BROKEN without volatile: reference may be visible before fields
class DclConfig {
private static volatile DclConfig instance; // volatile is load-bearing
private final java.util.Map<String, String> values;
private DclConfig() { values = load(); }
static DclConfig get() {
DclConfig local = instance; // one volatile read
if (local == null) {
synchronized (DclConfig.class) {
local = instance;
if (local == null) instance = local = new DclConfig();
}
}
return local;
}
private static java.util.Map<String, String> load() { return java.util.Map.of(); }
}
// Prefer: initialization-on-demand holder (lazy, safe, no volatile)
class Config {
private Config() {}
private static final class Holder {
static final Config INSTANCE = new Config(); // JVM class-init lock does it all
}
static Config get() { return Holder.INSTANCE; }
}
// this-escape: breaks final guarantees
class Bad {
final int x;
Bad(EventBus bus) {
bus.register(this); // leaked before construction completes
x = 42;
}
}
Key Points
- Unsafe publication can expose half-constructed objects legally per the JMM
- final fields + no this-escape = free safe publication
- DCL requires volatile; the holder idiom usually beats it
- Class-init locking (JLS 12.4.2) is the mechanism behind the holder idiom
Q53What problem does structured concurrency solve, and how does StructuredTaskScope express it?
AdvancedConcurrency
Answer
Unstructured concurrency, submitting subtasks to an executor and juggling their Futures, loses the parent-child relationship between tasks. Concrete failure modes: the parent times out but forked subtasks keep running as orphans, burning resources ('thread leakage'); one subtask fails but its siblings grind on uselessly because nothing cancels them; an InterruptedException in the parent has no defined effect on children; and a thread dump shows a flat pile of pool threads with no clue which request owns which work. Structured concurrency (incubating since Java 19, still preview through recent JDKs, JEP 505 line) applies the block-structure principle: if a task splits into concurrent subtasks, they all complete before the enclosing scope exits, making concurrency nest like function calls.
The API: open a StructuredTaskScope in try-with-resources, fork() subtasks (each runs on a new virtual thread), join() to wait, then read results; the scope's close() cannot complete until every subtask is done or cancelled, which is the structural guarantee. Policies define shutdown behavior: the ShutdownOnFailure form cancels all siblings the moment one fails and rethrows via throwIfFailed(), the invoke-all pattern; ShutdownOnSuccess cancels the rest once one succeeds, the invoke-any/hedged-request pattern, ideal for querying two replicas and taking the fastest. Note honestly that the API surface has shifted across previews (JDK 25's revision moved to a StructuredTaskScope.open() factory with configurable joiners), so name the concept and admit the flux; that precision reads as real engagement with the platform. Combined with ScopedValue inheritance (children automatically see the parent's bound values, so trace ids flow through fan-outs for free), this becomes the intended replacement for most CompletableFuture orchestration in virtual-thread codebases: the win is not speed, it is that cancellation, error propagation and observability follow the code's block structure, and a thread dump (jcmd Thread.dump_to_file with JSON output) shows the task tree.
// Preview API (Java 21-24 form): --enable-preview
record Enriched(Profile profile, java.util.List<Job> jobs) {}
Enriched loadDashboard(long userId) throws Exception {
try (var scope = new java.util.concurrent.StructuredTaskScope.ShutdownOnFailure()) {
var profileTask = scope.fork(() -> fetchProfile(userId)); // virtual thread
var jobsTask = scope.fork(() -> fetchMatchingJobs(userId)); // virtual thread
scope.join() // wait for both
.throwIfFailed(); // one failed -> sibling cancelled, exception rethrown
return new Enriched(profileTask.get(), jobsTask.get());
} // scope cannot leak orphan tasks past this brace
}
// Hedged read: first success wins, loser cancelled
String fastestQuote() throws Exception {
try (var scope = new java.util.concurrent.StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> replicaA.quote());
scope.fork(() -> replicaB.quote());
return scope.join().result();
}
}
Key Points
- Subtasks cannot outlive their scope: no orphan threads
- ShutdownOnFailure = invoke-all; ShutdownOnSuccess = hedged invoke-any
- Still preview; API surface revised again in JDK 25
- Pairs with ScopedValue for automatic context inheritance
Q54Virtual threads, CompletableFuture pipelines, or reactive streams: how do you choose in 2026?
AdvancedArchitecture
Answer
Frame it as three concurrency programming models with different costs, not a fashion contest. Virtual threads give you the thread-per-request model at massive scale: plain blocking code, ordinary stack traces, debugger stepping that works, exceptions that propagate normally, and ThreadLocal-compatible libraries functioning unchanged. For the dominant workload shape in industry, request in, a few database and HTTP calls, response out, this is now the default answer: Spring Boot 3.2+ enables it with one property, and frameworks built natively on it (Helidon 4's server, for example) show the model handles very high concurrency without reactive contortions.
CompletableFuture remains the right tool for explicit dataflow: heterogeneous fan-out/fan-in where you combine independent results (thenCombine), attach fallbacks per branch (exceptionally), and race alternatives (anyOf), all without wanting a full reactive runtime; on virtual threads, even this shrinks, since structured concurrency covers the invoke-all and invoke-any patterns more legibly. Reactive (Project Reactor, RxJava, WebFlux) earns its complexity in a narrower band than its 2018 marketing claimed: genuine streaming (unbounded event flows, SSE/websocket fan-out), backpressure as a first-class requirement (a slow consumer must throttle a fast producer end-to-end, Reactive Streams' request(n) protocol does this and virtual threads do not give it to you automatically, you would hand-roll semaphores and bounded queues), and operator-rich event processing (windowing, debouncing, merging). Its costs are real: infectious types (one Mono in a signature colonizes the codebase), stack traces that read as operator soup, harder onboarding, and blocking-call landmines (a JDBC call on an event loop stalls the reactor; hence R2DBC exists).
The 2026 decision tree I would state: request/response services default to virtual threads; targeted parallel composition uses structured concurrency or CompletableFuture; keep reactive where streaming-plus-backpressure is the domain (market data, chat fan-out, telemetry pipelines) or where a mature WebFlux codebase already works, rewriting one is rarely justified. Kotlin coroutines occupy the same niche as virtual threads with nicer syntax where Kotlin is already the language. What interviewers penalize is absolutism in either direction; what they reward is naming backpressure as the honest discriminator.
Key Points
- Virtual threads: blocking style, debuggable, default for request/response
- CompletableFuture/structured concurrency: explicit fan-out/fan-in composition
- Reactive earns its cost only for streaming + end-to-end backpressure
- Blocking on an event loop is the reactive landmine; R2DBC exists for a reason
Q55How does the Foreign Function & Memory API replace JNI and Unsafe for native interop?
AdvancedJVM Internals
Answer
JNI made native calls a build-system ordeal: hand-written C glue per function, javah headers, per-platform shared libraries, and zero safety, an error in glue code corrupts the JVM. sun.misc.Unsafe was the other escape hatch for off-heap memory, unsupported and now on a formal deprecation path (its memory-access methods are deprecated and warned about in recent JDKs precisely because supported replacements exist). The Foreign Function & Memory API (java.lang.foreign, finalized in Java 22 by JEP 454) is that replacement, pure Java, no glue code. Memory side: MemorySegment models a region of off-heap (or on-heap) memory with spatial bounds (out-of-range access throws IndexOutOfBoundsException instead of corrupting memory) and temporal safety via Arena lifetimes: Arena.ofConfined() gives single-thread deterministic freeing when the arena closes, ofShared() allows multi-thread access, and access after close throws IllegalStateException rather than use-after-free undefined behavior.
Layouts (ValueLayout.JAVA_INT, structured MemoryLayouts with named fields) plus VarHandles give typed, alignment-checked access to native structs. Function side: Linker.nativeLinker() binds C ABI functions, SymbolLookup finds symbols in loaded libraries, downcallHandle produces an ordinary MethodHandle you invoke like a Java method, and upcallStub wraps a Java method as a C function pointer for callbacks. The jextract tool generates complete Java bindings from a C header, eliminating hand mapping.
Performance matters here: FFM downcalls are competitive with or faster than JNI (less transition overhead, and critical-section linker options exist), and MemorySegment bulk operations replace both DirectByteBuffer (with its 2 GB int-index limit, segments are long-indexed) and Unsafe. Real consumers to cite: the JDK's own NIO internals migration, tokenizer/inference libraries binding llama.cpp-style native code, Apache Lucene's memory-mapped index access via MemorySegment, and database drivers exploring it. Interview positioning: if asked about calling native ML or crypto libraries from Java in 2026, the answer is jextract plus java.lang.foreign, with JNI mentioned only as the legacy being replaced.
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
// Call C's strlen without writing a line of C
Linker linker = Linker.nativeLinker();
SymbolLookup libc = linker.defaultLookup();
MethodHandle strlen = linker.downcallHandle(
libc.find("strlen").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));
try (Arena arena = Arena.ofConfined()) {
MemorySegment cString = arena.allocateFrom("GoodSpace"); // NUL-terminated copy
long len = (long) strlen.invokeExact(cString); // 9
// Typed off-heap array with bounds + lifetime safety
MemorySegment scores = arena.allocate(ValueLayout.JAVA_DOUBLE, 1_000_000);
scores.setAtIndex(ValueLayout.JAVA_DOUBLE, 0, 99.5);
double first = scores.getAtIndex(ValueLayout.JAVA_DOUBLE, 0);
// scores.getAtIndex(ValueLayout.JAVA_DOUBLE, 2_000_000); // IndexOutOfBoundsException
} // arena closed: every segment freed, later access throws IllegalStateException
Key Points
- MemorySegment: bounds-checked, long-indexed, lifetime-scoped via Arena
- Linker + downcallHandle: C calls as MethodHandles, no glue code
- jextract generates bindings from headers
- Replaces JNI, Unsafe memory ops, and DirectByteBuffer limits
Q56How do you profile a misbehaving Java service in production with JFR and async-profiler?
AdvancedProduction Debugging
Answer
Java Flight Recorder is built into OpenJDK and designed for always-on production use (target overhead around or under one percent with the default profile). Start it at launch with -XX:StartFlightRecording=maxsize=512m,maxage=6h,settings=profile for a continuous ring buffer, or attach on demand with jcmd <pid> JFR.start duration=120s filename=incident.jfr. A recording carries far more than CPU samples: allocation profiling with stack traces (which code allocates, feeding GC analysis), lock contention (monitor-blocked events with the exact contended class and duration), thread parking, socket and file I/O latencies, GC pauses with causes, compilation events, class loading, and exception counts.
Analyze in JDK Mission Control (JMC) or dump summaries with jfr print --events jdk.JavaMonitorEnter recording.jfr. Custom business events are underused: extend jdk.jfr.Event, annotate, commit(), and your 'ScoringLatency' events sit on the same timeline as GC pauses, which is how you prove or disprove 'GC caused my spike' in one screen. JFR's classic weakness is safepoint bias for execution samples, its sampler historically favored safepoint-adjacent frames (improved in recent JDKs with a cooperative sampler effort), which is where async-profiler comes in: it samples via perf_events plus AsyncGetCallTrace outside safepoints, sees native and JVM-internal frames, and emits flame graphs directly; run asprof -d 30 -f cpu.html <pid> for CPU, -e alloc for allocation sites, -e lock for contention, and -e wall for wall-clock profiling, the mode that catches threads waiting on I/O or locks, which pure CPU profiling is blind to and which is usually the actual cause of 'service is slow but CPU is idle'.
Reading flame graphs: width is time share, and you look for unexpectedly wide plateaus (JSON serialization of huge payloads, regex backtracking, ConcurrentHashMap contention, logging). Discipline points that land well in interviews: profile before optimizing, keep continuous JFR on every service so incidents come with data attached, and diff a healthy baseline recording against the incident recording rather than staring at one file in isolation.
# Continuous JFR ring buffer from startup (production-safe)
java -XX:StartFlightRecording=maxsize=512m,maxage=6h,settings=profile \
-jar app.jar
# On-demand capture during an incident
jcmd <pid> JFR.start duration=120s filename=/tmp/incident.jfr settings=profile
jcmd <pid> JFR.dump name=1 filename=/tmp/now.jfr
jfr print --events jdk.JavaMonitorEnter,jdk.ObjectAllocationSample /tmp/incident.jfr
# async-profiler: flame graphs without safepoint bias
asprof -d 30 -f /tmp/cpu.html <pid> # CPU
asprof -e wall -d 30 -f /tmp/wall.html <pid> # wall clock: finds waiting, not just burning
asprof -e alloc -d 30 -f /tmp/alloc.html <pid> # allocation sites
// Custom JFR event: business latency on the JVM timeline
class ScoringEvent extends jdk.jfr.Event {
@jdk.jfr.Label("Candidate Id") long candidateId;
@jdk.jfr.Label("Model") String model;
}
var e = new ScoringEvent();
e.begin();
double s = score(candidateId);
e.candidateId = candidateId; e.model = "v3";
e.commit();
Key Points
- JFR: always-on, ~1% overhead, events for alloc/locks/IO/GC, analyzed in JMC
- Custom jdk.jfr.Event puts business latency on the JVM timeline
- async-profiler: no safepoint bias; wall-clock mode finds waiting threads
- Diff incident recordings against a healthy baseline
Q57An upstream slowdown took down your whole Java service via thread pool exhaustion. What happened and how do you harden against it?
AdvancedProduction Debugging
Answer
The mechanics of the cascade: your service has a bounded request pool, say Tomcat's default 200 threads (server.tomcat.threads.max). A downstream dependency, one slow HTTP endpoint or an exhausted database pool, starts taking 30 seconds instead of 50 milliseconds. Every request touching it now parks a worker for 30 seconds; arrival rate does not drop, so within seconds all 200 workers are blocked on the one sick dependency, and every endpoint of your service, including perfectly healthy ones and the /health probe, times out.
Kubernetes then fails liveness checks and restarts pods into the same traffic, amplifying the outage. The thread dump signature is unambiguous: two hundred threads in socketRead0 or awaiting a connection from HikariCP, all beneath the same client call. Hardening is layered.
Timeouts first and everywhere, aggressive and explicit: connect and request timeouts on HTTP clients (the JDK client's request timeout is infinite by default), socket timeouts on JDBC (MySQL's socketTimeout URL property, or setQueryTimeout per statement), and connectionTimeout on the pool; a dependency that answers in 50ms p99 should get a timeout near 250ms-1s, not 30s. Bulkheads second: isolate dependencies onto separate bounded resources, a dedicated small executor or a Semaphore per downstream, so the payment API melting can only consume its own 20 permits while the rest of the service breathes; this is Resilience4j's Bulkhead, and its ThreadPoolBulkhead variant adds queue isolation. Circuit breakers third: after a failure-rate threshold, fail calls instantly for a cool-down (Resilience4j CircuitBreaker with slidingWindowSize, failureRateThreshold, waitDurationInOpenState), converting 30-second hangs into immediate, cacheable fallbacks and giving the dependency room to recover.
Load shedding and backpressure fourth: bound every queue, reject with 503 plus Retry-After when saturated, and keep /health cheap and dependency-free so orchestration does not execute healthy-pod slaughter. Virtual threads change the arithmetic but not the principle: workers stop being scarce, so the service itself does not choke at 200 concurrent waits, but unbounded concurrency then hammers the dying dependency harder and exhausts its connection pool instead; the semaphore-per-dependency pattern remains mandatory. Close with the observability tie-in: per-dependency latency and saturation metrics (Micrometer timers, Hikari pending count) alarm before the cliff, and a single thread dump during the event names the culprit.
// Resilience4j: bulkhead + circuit breaker + timeout around one dependency
var breaker = io.github.resilience4j.circuitbreaker.CircuitBreaker.of("scoring",
io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.custom()
.slidingWindowSize(50)
.failureRateThreshold(50f)
.waitDurationInOpenState(java.time.Duration.ofSeconds(10))
.build());
var bulkhead = io.github.resilience4j.bulkhead.Bulkhead.of("scoring",
io.github.resilience4j.bulkhead.BulkheadConfig.custom()
.maxConcurrentCalls(20) // isolation: only 20 threads at risk
.maxWaitDuration(java.time.Duration.ofMillis(100))
.build());
var timeLimiter = io.github.resilience4j.timelimiter.TimeLimiter.of(
java.time.Duration.ofMillis(800));
java.util.function.Supplier<Score> guarded =
io.github.resilience4j.circuitbreaker.CircuitBreaker.decorateSupplier(breaker,
io.github.resilience4j.bulkhead.Bulkhead.decorateSupplier(bulkhead,
() -> scoringClient.score(candidateId)));
Score result;
try {
result = guarded.get();
} catch (Exception e) {
result = Score.cachedOrDefault(candidateId); // degrade, do not die
}
# The dump that tells the story during the incident:
# jcmd <pid> Thread.print | grep -c 'socketRead' -> ~200 = exhaustion confirmed
Key Points
- One slow dependency + infinite timeouts = whole-service thread exhaustion
- Timeouts everywhere; JDK HttpClient and JDBC defaults are too generous
- Bulkheads cap blast radius; circuit breakers convert hangs to fast failures
- Virtual threads move the bottleneck downstream; semaphores still required
Q58GraalVM native image, Project Leyden AOT, and CRaC: compare Java's startup-time strategies.
AdvancedArchitecture
Answer
The problem: a JVM service spends seconds on class loading, initialization and JIT warmup before reaching peak throughput, which hurts serverless cold starts, autoscaling reaction time, and dense microservice fleets. Three answers exist with very different trade-offs. GraalVM native-image compiles ahead-of-time to a standalone executable: startup in tens of milliseconds, memory footprint often a third of JVM mode, instant peak (no warmup).
The costs are structural: closed-world assumption, so reflection, dynamic proxies, JNI and resources must be declared in reachability metadata (frameworks generate it, Spring Boot 3's AOT engine and the -Pnative build path exist precisely for this, Quarkus and Micronaut designed for it from the start); build times of minutes; no runtime bytecode generation (agents, some mocking and APM tooling break); and peak throughput that can trail a warmed JIT since there is no profile-guided speculation by default (native PGO exists but adds a profiling build step). CRaC (Coordinated Restore at Checkpoint, from Azul, with API org.crac) takes a snapshot of a fully warmed running JVM and restores it in tens of milliseconds: you keep the real JVM, the JIT-compiled code, and library compatibility, but the application must implement checkpoint callbacks to close sockets and file handles before snapshot and reopen after restore (Spring Boot has integrated lifecycle support), checkpoint files carry heap contents so secrets handling needs care, and Linux/CRIU specifics constrain where it runs. Project Leyden is the OpenJDK-native path: JEP 483 (Java 24) caches loaded-and-linked classes via -XX:AOTCache, with Java 25 adding command-line ergonomics and ahead-of-time method profiles so the JIT starts warm; it is incremental, fully compatible, no closed world, but the wins are 'several times faster startup', not native-image's two orders of magnitude.
Decision framing for interviews: serverless functions and CLI tools favor native-image; latency-critical autoscaling fleets on standard JVMs look at CRaC or Leyden caches; long-running services with stable replica counts often need none of it, ordinary JVM with warmup is fine. Bonus credit for mentioning that AWS Lambda SnapStart applies the checkpoint/restore idea at the platform level for Java functions.
# GraalVM native image via Spring Boot 3
./mvnw -Pnative native:compile
./target/app # starts in ~50ms, no JVM installed
# Reachability metadata for reflection the analysis cannot see
# src/main/resources/META-INF/native-image/reflect-config.json
# [{ "name": "com.goodspace.dto.WebhookPayload", "allDeclaredConstructors": true }]
# Project Leyden AOT cache (JDK 24+): record a training run, then use it
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar # training
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -jar app.jar
java -XX:AOTCache=app.aot -jar app.jar # fast start
// CRaC checkpoint hooks: close/reopen resources around the snapshot
class PoolResource implements org.crac.Resource {
public void beforeCheckpoint(org.crac.Context<? extends org.crac.Resource> c) {
dataSource.close();
}
public void afterRestore(org.crac.Context<? extends org.crac.Resource> c) {
dataSource = buildPool();
}
}
Key Points
- native-image: ms startup, closed world, reachability metadata, weaker peak JIT
- CRaC: restore a warmed JVM; needs resource close/reopen hooks
- Leyden AOT cache (JEP 483+): compatible, incremental startup wins
- Match the tool to the fleet: serverless vs autoscaling vs steady-state
Q59How much memory does a Java object actually take, and what do compressed oops and compact object headers change?
AdvancedJVM Internals
Answer
On 64-bit HotSpot, every object historically starts with a 12-byte header when compressed class pointers are on: an 8-byte mark word (identity hash cache, GC age bits, lock state) plus a 4-byte compressed class pointer; arrays add 4 bytes of length. Objects are then aligned to 8 bytes, so new Object() occupies 16 bytes, and a class with a single int field also lands on 16 (12 header + 4 field). Compressed oops (-XX:+UseCompressedOops, on by default) store references as 32-bit offsets scaled by the 8-byte alignment, addressing up to about 32 GB of heap; cross that heap size and every reference silently doubles to 8 bytes, which is why a 34 GB heap can hold fewer objects than a 31 GB one, a classic capacity-planning gotcha worth stating unprompted.
Compact object headers, experimental in Java 24 (JEP 450) and a production option in Java 25 (JEP 519, -XX:+UseCompactObjectHeaders), squeeze the header to 8 bytes total, which real-world tests show cutting heap usage several percent to double digits for small-object-heavy workloads, effectively free memory for typical service heaps dominated by small objects. The arithmetic interviewers enjoy: a boxed Integer is 16 bytes plus the reference to it, so an int[] of a million elements costs ~4 MB while an ArrayList<Integer> of the same costs ~20 MB (references plus boxed objects, ignoring sharing from the Integer cache), the concrete case for primitive arrays and IntStream in hot paths. String adds its own layering: the String object (header, hash, coder, reference) plus a separate byte[] with its own header, roughly 40+ bytes of overhead per string before content, the reason string-heavy caches deduplicate or intern. The tool to verify all of this rather than folklore is JOL (Java Object Layout, org.openjdk.jol): ClassLayout.parseClass(...).toPrintable() prints exact field offsets, padding and total size for your JVM and flags, including field packing (HotSpot reorders fields to minimize padding) and false-sharing padding via @Contended (-XX:-RestrictContended), which is itself a deep follow-up: two hot atomic counters in adjacent fields ping-pong a cache line between cores, and JDK classes like LongAdder exist substantially to dodge that.
// JOL: measure, do not guess (org.openjdk.jol:jol-core)
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
class Compact { int a; }
class TwoFields { int a; long b; boolean c; }
public class LayoutDemo {
public static void main(String[] args) {
System.out.println(ClassLayout.parseClass(Compact.class).toPrintable());
// -> 12-byte header, 4-byte int, total 16 (or 8-byte header with JEP 519)
System.out.println(ClassLayout.parseClass(TwoFields.class).toPrintable());
// shows HotSpot's field reordering to minimize padding
System.out.println(GraphLayout.parseInstance("goodspace").toFootprint());
// String object + backing byte[] as separate allocations
}
}
# Enable compact headers (production option since Java 25)
# java -XX:+UseCompactObjectHeaders -jar app.jar
# Verify compressed oops status at startup
# java -Xlog:gc+heap+coops=info -version
Key Points
- 12-byte header + 8-byte alignment: new Object() = 16 bytes
- Compressed oops die past ~32 GB heaps; references double silently
- JEP 519 compact headers (Java 25) cut headers to 8 bytes
- Boxed collections cost ~5x primitive arrays; verify with JOL
Q60You own services running Java 17. Make the engineering case and migration plan for moving to Java 21 or 25.
AdvancedArchitecture
Answer
Start with the cadence facts: a feature release every six months, an LTS every two years, so 17 (2021), 21 (2023), and 25 (2025) are the LTS line, and vendors ship free updates for each LTS for years, meaning 17 is safe but increasingly costly in opportunity terms. The concrete wins to put in the document: virtual threads (21) for throughput on I/O-bound services with a one-property Spring Boot enablement; pattern matching for switch plus record patterns (21) shrinking business-logic boilerplate; sequenced collections (21); generational ZGC (21+) for latency-sensitive heaps; and on 25, scoped values, compact object headers (JEP 519) for single-digit-percent heap savings fleet-wide, AOT class-loading caches from Leyden for faster autoscaling, and flexible constructor bodies tidying validation code. Add the operational wins: each JDK ships GC and intrinsics improvements, so most services get a few percent of throughput for free, measurable in your own canary.
Then the honest risk register, which is what distinguishes a senior answer: audit for removed and restricted features, the SecurityManager is permanently disabled in 24 (anything calling System.setSecurityManager breaks), finalization is on the way out (finders: jdeprscan --for-removal against your jars), sun.misc.Unsafe memory methods now warn ahead of removal, and strong encapsulation means any lingering --add-opens hacks need cataloging; bump the toolchain (ASM-dependent libraries, Lombok, Mockito/ByteBuddy, Jacoco all track bytecode versions, so upgrade them before the JDK); and verify agents (APM, profilers) support the target. The mechanics: build with the new JDK using --release 17 first (compiles against the old API on the new toolchain, catching javac and toolchain issues with zero runtime risk), then flip runtime images service-by-service behind canaries while builds still target 17, then raise the language level last, enabling the new features deliberately. Multi-release jars (JEP 238) cover libraries that must span JDKs.
Sequence risk-first: stateless internal services, then edge services, then the stateful stragglers, with GC logs and p99 dashboards compared per canary. The framing interviewers reward: upgrades are a product decision with a measurable payoff (throughput, memory, hiring appeal, security posture), executed as a boring, reversible rollout, not a big-bang rewrite.
Key Points
- LTS line: 17 -> 21 -> 25; features land every six months regardless
- Sell with virtual threads, switch patterns, ZGC, compact headers, AOT cache
- Audit removals: SecurityManager (24), finalization, Unsafe, --add-opens debt
- jdeprscan + toolchain bumps + --release staging = boring, reversible rollout
- Canary with GC logs and p99 diffs; language level flips last
Frequently Asked Questions
What salary can a Java developer expect in India in 2026?
The realistic band is ₹7-25 LPA depending on employer tier and depth. Service companies (TCS, Infosys, Wipro, Cognizant) start freshers around ₹3.5-7 LPA, with Java-heavy digital units paying more. Product companies and GCCs (Amazon, Walmart Global Tech, Oracle, Goldman Sachs, JPMorgan) pay ₹15-30 LPA for 3-6 years of experience, and fintech (PhonePe, Razorpay, CRED) competes at the top of that range. Engineers who pair core Java with distributed-systems depth (Kafka, high-throughput services, JVM tuning evidenced by real incident stories) clear ₹35-60 LPA at senior and staff levels. The differentiator interviewers actually pay for is not syntax knowledge but concurrency, JVM internals and production debugging.
How long does it take to prepare for Java interviews?
From working-developer level, 6-8 weeks of structured preparation is typical: two weeks on core language and collections internals (HashMap, equals/hashCode, generics, records and pattern matching), two on concurrency (executors, the memory model, ConcurrentHashMap, virtual threads), one on JVM internals and GC, and the rest on the ecosystem you will be quizzed on, Spring Boot, Hibernate, JUnit 5, plus DSA practice in Java if the target company runs coding rounds. From scratch, budget 4-6 months to reach hireable junior level. The highest-leverage habit is writing and running the code for every concept: interviewers quickly distinguish candidates who have watched videos from those who have debugged a deadlock themselves.
What do interviewers expect from freshers versus experienced Java candidates?
Freshers are tested on fundamentals executed precisely: collections behavior, string handling, exception rules, OOP design, basic threading, and clean DSA solutions in Java; knowing records, switch expressions and try-with-resources signals you learned current Java rather than a 2015 tutorial. At 3-5 years, expect concurrency depth (happens-before, executor sizing, ConcurrentHashMap idioms), Spring and Hibernate internals (constructor injection reasoning, N+1 fixes, transaction boundaries), testing maturity, and at least one convincing production-debugging story with jstack or a heap dump. At senior levels, the interview shifts to system design in a Java context: GC strategy for a latency-sensitive service, virtual threads versus reactive trade-offs, resilience patterns, and JDK migration planning. The constant across levels is being probed on why, not what.
Is Java still worth learning in 2026 given Go, Rust and Node?
Yes, and the market data in India is unambiguous: Java postings consistently outnumber Go and Rust postings combined, because banking, insurance, e-commerce, telecom and the GCC ecosystem run enormous Java estates that are growing, not shrinking. The platform itself is also moving fast: virtual threads eliminated the old 'Java cannot do massive concurrency cheaply' criticism, records and pattern matching modernized the language, and startup-time work (native image, CRaC, Leyden) addressed the serverless gap. Go wins for small infra tools and Rust for systems programming, and learning one of them alongside Java is smart career construction, but as a primary employment skill in India, Java's combination of demand volume, salary ceiling and ecosystem maturity remains one of the strongest available.
Do I need to know Spring Boot for Java interviews, or is core Java enough?
For backend roles in India, treat Spring Boot as mandatory: the overwhelming majority of Java backend positions list it, and interview loops usually dedicate a full round to it. Core-Java-only preparation works for Android (where Kotlin now dominates anyway), some trading and low-latency shops that avoid frameworks, and pure DSA screening rounds. The efficient split is roughly 60/40: master core Java deeply (collections, concurrency, JVM) because framework questions ultimately reduce to it, then cover the Spring essentials interviewers actually ask: dependency injection and bean lifecycle, @Transactional semantics and where proxies break them, Spring Data JPA with the N+1 problem, configuration and profiles, and testing with @SpringBootTest versus slice tests. Hibernate knowledge is effectively bundled with Spring expectations at most companies.
Which Java version should I learn and mention in interviews?
Learn on Java 21 or 25, the two current long-term-support releases, and be explicit about version-specific features when you answer: saying 'since Java 21 I would use virtual threads here' or 'records make this a three-line class' dates your knowledge as current, which interviewers notice. Most Indian enterprises run 17 or 21 in production in 2026, with 25 adoption growing, so you should also know what each LTS added: 17 brought sealed types and modern GC baselines, 21 brought virtual threads, pattern matching for switch and sequenced collections, 25 brought scoped values and compact object headers. Avoid learning from Java 8-era tutorials: code full of anonymous classes, Date, and raw threads reads as outdated in 2026 interviews, even where it still compiles.
Introduction
Java in 2026 is a very different interview subject than it was five years ago. Virtual threads (finalized in Java 21) rewrote the concurrency conversation, records and sealed interfaces changed how domain models are written, and the two current long-term-support releases, Java 21 and Java 25, are what serious employers actually run. At the same time, the classics have not gone anywhere: HashMap internals, the equals and hashCode contract, the memory model, and garbage collection still decide most onsite rounds. India runs a huge share of the world's Java: banks, payment companies like PhonePe and Razorpay, e-commerce at Flipkart and Amazon, and the entire services industry at TCS, Infosys and Wipro.
A realistic Java loop in India today has three layers. First, data structures and algorithms solved in Java, where fluency with ArrayList, HashMap, PriorityQueue and StringBuilder is assumed. Second, a core Java round probing collections internals, the Java Memory Model, ExecutorService versus virtual threads, and what changed across recent LTS releases. Third, an ecosystem round on Spring Boot dependency injection, Hibernate's N+1 problem, JUnit 5 and Mockito, and production debugging with jstack, heap dumps and Java Flight Recorder. Candidates who can only recite definitions fail the second and third layers; interviewers want to hear how things behave under load and where they break.
This guide contains 60 questions ordered basic to intermediate to advanced, written to match what interviewers at product companies and top services accounts actually ask in 2026. Each answer explains the underlying mechanism, names the exact classes, JVM flags and tools involved, and flags the production gotchas that follow-up questions are built around. More than half the questions carry a runnable code example. Work through the basic set to close fundamentals gaps, then spend most of your preparation time on the concurrency, JVM tuning and failure-mode questions in the later sections, because that is where senior offers are decided.
Ready to practice Java interviews?
Don't just read, practice these Java questions live with an AI interviewer that asks follow-ups and scores your answers.