Java / Java 21 Interview Questions
Java 21, released in September 2023, is a Long-Term Support (LTS) release of the Java platform, making it the next major production-grade baseline after Java 17. It is the first LTS release to deliver Virtual Threads as a stable feature (Project Loom), Record Patterns, Pattern Matching for switch, and Sequenced Collections — all finalised and ready for production use.
The LTS designation means Oracle and other JDK vendors commit to extended support windows (typically 5–8 years depending on the vendor), making Java 21 the version most enterprises will target for migration from Java 11 or 17.
| JEP | Feature | Category |
|---|---|---|
| 444 | Virtual Threads | Concurrency (Loom) |
| 441 | Pattern Matching for switch | Language |
| 440 | Record Patterns | Language |
| 431 | Sequenced Collections | Core Libraries |
| 439 | Generational ZGC | GC |
| 445 | Unnamed Classes and Instance Main Methods (Preview) | Language |
| 453 | Structured Concurrency (Preview) | Concurrency |
| 446 | Scoped Values (Preview) | Concurrency |
Java 21 also continues the deprecation of thread primitives like Thread.stop(), and permanently removes the Security Manager, Applet API, and older RMI features that were deprecated in prior releases.
Virtual Threads (JEP 444) are lightweight threads managed by the JVM rather than the operating system. A platform (OS) thread maps 1:1 to a kernel thread and consumes roughly 1–2 MB of stack memory each, limiting practical concurrency to a few thousand threads per JVM. Virtual threads are multiplexed over a small pool of carrier (platform) threads by the JVM scheduler, consuming only a few hundred bytes of heap per thread, enabling millions of concurrent threads in the same process.
| Aspect | Platform Thread | Virtual Thread |
|---|---|---|
| Managed by | OS kernel | JVM (Project Loom) |
| Memory (stack) | ~1–2 MB each | ~few hundred bytes (heap) |
| Max practical count | Thousands | Millions |
| Blocking I/O | Blocks OS thread | Parks virtual thread; carrier thread freed |
| Creation | Thread / ExecutorService | Thread.ofVirtual() / Executors.newVirtualThreadPerTaskExecutor() |
// Creating a virtual thread directly
Thread vt = Thread.ofVirtual().name("my-vt").start(() -> {
System.out.println("Running on: " + Thread.currentThread());
});
// Per-task executor — one virtual thread per submitted task
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofMillis(100)); // parks, not blocks carrier
return i * i;
}));
} // executor.close() waits for all tasksWhen a virtual thread blocks on I/O or Thread.sleep(), the JVM parks it (saves its continuation to the heap) and reassigns the carrier thread to another runnable virtual thread — no OS thread is held idle. This is fundamentally different from reactive/async programming: you write simple blocking code and the JVM handles the event-loop-like scheduling transparently.
Key constraints: Synchronized blocks pin the virtual thread to its carrier (no parking during a synchronized block). Use java.util.concurrent.locks.ReentrantLock instead of synchronized in hot paths to avoid pinning. Thread-locals work but can cause high memory usage at scale — prefer Scoped Values (JEP 446).
JEP 441 finalises pattern matching in switch expressions and statements, extending the type-test patterns introduced in Java 16 (instanceof) to the full switch construct. It eliminates long chains of if-else instanceof casts and brings exhaustiveness checking to the compiler.
sealed interface Shape permits Circle, Rectangle, Triangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double w, double h) implements Shape {}
record Triangle(double base, double height) implements Shape {}
// Pattern matching switch expression
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.w() * r.h();
case Triangle t -> 0.5 * t.base() * t.height();
// No default needed — sealed hierarchy is exhaustive
};
}
// Guarded patterns — 'when' clause adds a boolean condition
static String classify(Object obj) {
return switch (obj) {
case Integer i when i < 0 -> "Negative int";
case Integer i when i == 0 -> "Zero";
case Integer i -> "Positive int: " + i;
case String s when s.isEmpty() -> "Empty string";
case String s -> "String: " + s;
case null -> "null value";
default -> "Other: " + obj;
};
}
// Dominance rule: more specific patterns must come first
// case Integer i when i < 0 must precede case Integer iThree important rules in Java 21 pattern switch:
- Exhaustiveness: the compiler checks that all possible input values are covered; missing a subtype of a sealed class is a compile error, not a runtime problem.
- Guarded patterns (
whenclause): replaces the old&&idiom inside case blocks. - Null handling: a
case nullarm can be written explicitly; without it a null input still throwsNullPointerExceptionas before.
JEP 440 finalises record patterns, which extend the type-test pattern (instanceof Point p) to simultaneously match a record's type and destructure its components into named variables. This eliminates the boilerplate of calling accessor methods after a type check.
record Point(int x, int y) {}
record Line(Point start, Point end) {}
// Before Java 21: type check + manual accessor calls
if (obj instanceof Point p) {
System.out.println(p.x() + ", " + p.y()); // still need accessors
}
// Java 21 record pattern: match + deconstruct in one step
if (obj instanceof Point(int x, int y)) {
System.out.println(x + ", " + y); // x and y bound directly
}
// Nested record patterns — deconstruct a Line into its Points' components
if (shape instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
double length = Math.hypot(x2 - x1, y2 - y1);
System.out.println("Length: " + length);
}
// Record patterns in switch
static String describe(Object obj) {
return switch (obj) {
case Point(int x, int y) when x == 0 && y == 0 -> "Origin";
case Point(int x, int y) when x == 0 -> "On Y-axis";
case Point(int x, int y) -> "Point(" + x + "," + y + ")";
case Line(Point s, Point e) -> "Line from " + s + " to " + e;
default -> "Unknown";
};
}
// Unnamed patterns: _ to ignore components you don't need
if (obj instanceof Point(int x, _)) {
System.out.println("x = " + x); // y component ignored
}Record patterns compose with sealed types, switch expressions, and the when guard clause. The unnamed pattern _ (JEP 443, preview in Java 21) lets you discard components you do not need, avoiding the need to name every field.
JEP 431 introduces three new interfaces — SequencedCollection, SequencedSet, and SequencedMap — to the collections hierarchy. Before Java 21 there was no unified API to access the first or last element of a collection, or to iterate in reverse order; each concrete class had its own ad-hoc approach.
| Collection | Get first | Get last | Reverse iteration |
|---|---|---|---|
| List | list.get(0) | list.get(list.size()-1) | Collections.reverse() / listIterator |
| Deque | deque.peekFirst() | deque.peekLast() | descendingIterator() |
| SortedSet | sortedSet.first() | sortedSet.last() | No standard way |
| LinkedHashSet | iter.next() hack | No direct way | No standard way |
// SequencedCollection adds:
interface SequencedCollection extends Collection {
void addFirst(E e);
void addLast(E e);
E getFirst(); // replaces get(0) / peekFirst()
E getLast(); // replaces get(size-1) / peekLast()
E removeFirst();
E removeLast();
SequencedCollection reversed(); // reversed VIEW
}
// Now works uniformly on List, Deque, LinkedHashSet...
List list = new ArrayList<>(List.of("a", "b", "c"));
System.out.println(list.getFirst()); // "a"
System.out.println(list.getLast()); // "c"
list.addFirst("z"); // ["z", "a", "b", "c"]
for (String s : list.reversed()) {
System.out.print(s + " "); // c b a z
}
// SequencedMap adds firstEntry(), lastEntry(), reversed()
SequencedMap map = new LinkedHashMap<>();
map.put("one", 1); map.put("two", 2); map.put("three", 3);
System.out.println(map.firstEntry()); // one=1
System.out.println(map.lastEntry()); // three=3 The reversed() method returns a view — it does not copy the collection. Modifications through the reversed view affect the backing collection. All List, Deque, SortedSet, LinkedHashSet, SortedMap, and LinkedHashMap implementations now implement the appropriate sequenced interface.
Sealed classes (JEP 409, finalised in Java 17, heavily used with Java 21 pattern matching) restrict which classes can extend or implement them. You declare the permitted subtypes explicitly in a permits clause, giving the compiler a closed world of possibilities.
// Sealed interface — only these three can implement it
public sealed interface Expr
permits Num, Add, Mul {
}
public record Num(int value) implements Expr {}
public record Add(Expr l, Expr r) implements Expr {}
public record Mul(Expr l, Expr r) implements Expr {}
// Evaluator — exhaustive switch, no default needed
static int eval(Expr e) {
return switch (e) {
case Num(int v) -> v;
case Add(Expr l, Expr r) -> eval(l) + eval(r);
case Mul(Expr l, Expr r) -> eval(l) * eval(r);
};
}
// Usage: (2 + 3) * 4 = 20
Expr expr = new Mul(new Add(new Num(2), new Num(3)), new Num(4));
System.out.println(eval(expr)); // 20Permitted subclasses must be in the same package (or module). Each permitted class must be one of: final (no further extension), sealed (extends the hierarchy with its own permits), or non-sealed (reopens the hierarchy for any extension).
| Modifier | Meaning |
|---|---|
| final | No further subclassing allowed |
| sealed | Can be extended, but only by its own permits clause |
| non-sealed | Opens the type back up — any class can extend it |
The key synergy with Java 21: because the compiler knows the complete set of sealed subtypes, a switch over a sealed type can be checked for exhaustiveness at compile time. If you add a new subtype to the sealed hierarchy, every exhaustive switch in the codebase becomes a compile error until it is updated.
Records (JEP 395, finalised in Java 16) are a concise syntax for declaring immutable data-carrier classes. A single record declaration replaces a full class with private final fields, a canonical constructor, accessors, equals(), hashCode(), and toString().
// Declaration — the header defines the record's components
public record Person(String name, int age) {}
// Equivalent traditional class (simplified):
// private final String name;
// private final int age;
// public Person(String name, int age) { this.name = name; this.age = age; }
// public String name() { return name; } // accessors, NOT getName()
// public int age() { return age; }
// public boolean equals(Object o) { ... }
// public int hashCode() { ... }
// public String toString() { return "Person[name=" + name + ", age=" + age + "]"; }
Person p = new Person("Alice", 30);
System.out.println(p.name()); // Alice (accessor, not getName)
System.out.println(p); // Person[name=Alice, age=30]
// Compact canonical constructor — validate without repeating assignments
public record Range(int lo, int hi) {
Range { // compact form — no parameter list
if (lo > hi) throw new IllegalArgumentException(lo + " > " + hi);
// assignments lo = lo; hi = hi; happen automatically at end
}
}
// Records CAN have:
// - static fields and methods
// - instance methods (non-component)
// - custom constructors that delegate to canonical
// - implement interfaces
// Records CANNOT:
// - extend any class (implicitly extend java.lang.Record)
// - declare instance fields beyond components
// - be abstractRecords work seamlessly with Java 21 features: they are the natural carrier type for record patterns, they compose well with sealed interfaces for algebraic data types, and their auto-generated equals()/hashCode() make them safe as map keys and set elements.
Text Blocks (JEP 378, finalised in Java 15) provide a multi-line string literal that preserves formatting without escape sequences. They are delimited by """ and the content starts on the line after the opening delimiter.
// Traditional string — escape-heavy
String json = "{\n" +
" \"name\": \"Alice\",\n" +
" \"age\": 30\n" +
"}";
// Text block — same result, readable
String json = """
{
"name": "Alice",
"age": 30
}
""";
// Indentation is stripped automatically:
// Java strips the minimum leading whitespace (re-indentation)
// based on the position of the closing triple quote.
// SQL example
String sql = """
SELECT id, name
FROM customers
WHERE active = true
ORDER BY name
""";
// Escape sequences still work inside text blocks
String noTrailingNewline = """
Hello\
World""";
// Backslash at end of line joins the next line — no newline inserted
// \s — a space that prevents trailing whitespace stripping
String aligned = """
one \s
two \s
""";Re-indentation rules: Java finds the minimum indentation of all non-blank content lines plus the closing delimiter line, and strips that many leading spaces from every line. Placing the closing """ at the start of the line preserves all indentation of the content; indenting it further strips more.
Structured Concurrency (JEP 453, second preview in Java 21) is an API — built on StructuredTaskScope — that treats a group of concurrent subtasks as a single unit of work whose lifetime is scoped to the code block that created them. It solves the problem of unreliable cancellation, lost exceptions, and dangling threads that plague ad-hoc ExecutorService usage.
// Classic problem: if userTask or orderTask throws, the other leaks
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork subtasks — each runs on its own virtual thread
Subtask userTask = scope.fork(() -> fetchUser(userId));
Subtask orderTask = scope.fork(() -> fetchOrder(orderId));
scope.join() // wait for both
.throwIfFailed(); // propagate first failure as exception
// Both succeeded — safe to access results
return new Response(userTask.get(), orderTask.get());
} // scope.close() cancels any still-running subtasks automatically
// ShutdownOnSuccess — return the first result that succeeds
try (var scope = new StructuredTaskScope.ShutdownOnSuccess()) {
scope.fork(() -> callPrimaryService());
scope.fork(() -> callFallbackService());
scope.join();
return scope.result(); // whichever finished first
} The key guarantee is the structure invariant: a subtask never outlives the scope that forked it. When control leaves the try block (normally or via exception), StructuredTaskScope.close() cancels all still-running subtasks and waits for them to finish — preventing thread leaks entirely.
| Policy | Shuts down scope when... | Use case |
|---|---|---|
| ShutdownOnFailure | Any subtask fails | All subtasks must succeed (fan-out + join) |
| ShutdownOnSuccess | Any subtask succeeds | First result wins (hedged request) |
Scoped Values (JEP 446, first preview in Java 21) provide an immutable, bounded alternative to ThreadLocal designed specifically for virtual threads. A ScopedValue holds a value for the duration of a bounded scope (a ScopedValue.where(...).run(...) block) and is automatically removed when the scope exits — no manual remove() required.
| Aspect | ThreadLocal | ScopedValue |
|---|---|---|
| Mutability | Mutable — set() anytime | Immutable — set once per scope, read-only inside |
| Lifetime | Lives until remove() or thread death | Lives only within the where().run() scope |
| Inheritance | Must opt-in (InheritableThreadLocal) | Inherited automatically by child scopes |
| Memory with VTs | Memory leak risk at millions of VTs | Bounded — cleaned up at scope exit |
| Thread safety | Per-thread copy, OK | Immutable, inherently safe |
// Declare a scoped value (typically static final)
private static final ScopedValue CURRENT_USER =
ScopedValue.newInstance();
// Bind and run — value is available inside the lambda
ScopedValue.where(CURRENT_USER, currentUser)
.run(() -> {
processRequest(); // can read CURRENT_USER anywhere in call tree
});
// Inside processRequest or any method it calls:
void processRequest() {
User user = CURRENT_USER.get(); // always present in this scope
// ... use user ...
}
// Nested rebinding — inner scope shadows outer
ScopedValue.where(CURRENT_USER, adminUser)
.run(() -> {
// CURRENT_USER is adminUser here
ScopedValue.where(CURRENT_USER, guestUser)
.run(() -> {
// CURRENT_USER is guestUser here
});
// CURRENT_USER is adminUser again
}); Scoped Values are particularly useful for passing context (request IDs, current user, database transaction) down through a call stack without threading it through every method parameter — the same use case as ThreadLocal, but safe and efficient at millions of virtual threads.
JEP 439 finalises Generational ZGC, which extends the existing low-latency Z Garbage Collector with separate young and old generations. Classic ZGC (introduced in Java 11) collects all live objects on every GC cycle, which is thorough but wastes CPU on long-lived objects that will not be collected.
| Aspect | Classic ZGC | Generational ZGC |
|---|---|---|
| Generations | Single generation — all objects | Young + Old (weak generational hypothesis) |
| GC frequency | Full heap every cycle | Young GC often, Old GC rarely |
| CPU overhead | Higher (scans all live data) | Lower (mostly scans short-lived young gen) |
| Allocation rate | Can struggle at very high rates | Handles higher allocation rates |
| Max pause | <1 ms (both) | <1 ms (both) |
| Enable flag | Default in Java 21 | ZGC is generational by default in Java 21 |
// Generational ZGC is the default ZGC mode in Java 21
// Enable ZGC (generational by default):
// java -XX:+UseZGC MyApp
// To use non-generational ZGC (legacy mode):
// java -XX:+UseZGC -XX:-ZGenerational MyApp
// Key tuning flags:
// -Xms / -Xmx — min and max heap
// -XX:SoftMaxHeapSize=N — soft limit ZGC tries to stay under
// -XX:ZUncommitDelay=N — delay before returning memory to OS
// Monitoring GC:
// -Xlog:gc*:file=gc.log
// jcmd GC.run — trigger a GC cycle
// jstat -gcutil 1s — watch GC stats every second The weak generational hypothesis states that most objects die young. By collecting the young generation far more frequently than the old generation, Generational ZGC spends less CPU per GC cycle and handles higher allocation rates — particularly beneficial for microservices and high-throughput applications running on Java 21 virtual threads.
The String class received significant API improvements across several Java releases. Knowing the version they arrived in is common interview territory.
| Method | Since | Purpose |
|---|---|---|
| isBlank() | 11 | Returns true if string is empty or contains only whitespace |
| strip() / stripLeading() / stripTrailing() | 11 | Unicode-aware whitespace stripping (vs trim() which uses ASCII <= 32) |
| lines() | 11 | Returns a Stream<String> of lines split by line terminators |
| repeat(int n) | 11 | Returns the string repeated n times |
| indent(int n) | 12 | Adds/removes leading whitespace per line |
| formatted(Object... args) | 15 | Instance version of String.format() |
| stripIndent() | 15 | Removes incidental whitespace (used by text blocks) |
| translateEscapes() | 15 | Interprets \n \t etc. as escape sequences |
| chars() / codePoints() | 9 | Returns IntStream of char/codepoint values |
// isBlank and strip — Java 11
" \t ".isBlank(); // true
" hello ".strip(); // "hello" (Unicode-aware)
" hello ".trim(); // "hello" (ASCII only — legacy)
// lines() — Java 11
"a\nb\nc".lines().count(); // 3 (Stream)
// repeat() — Java 11
"ab".repeat(3); // "ababab"
// formatted() — Java 15
"Hello %s, you are %d".formatted("Alice", 30);
// "Hello Alice, you are 30"
// indent() — Java 12
"hello\nworld".indent(4);
// " hello\n world\n"
// chars() — Java 9
"Java".chars().forEach(c -> System.out.print((char) c + " "));
// J a v a
var (JEP 286, Java 10) introduces local variable type inference. The compiler infers the type from the initialiser on the right-hand side; you do not need to write the type explicitly. It is not a dynamic type — the variable is still strongly typed at compile time; var is just syntactic sugar.
// Without var
Map> scores = new HashMap>();
BufferedReader reader = new BufferedReader(new FileReader("file.txt"));
// With var — type inferred from the right-hand side
var scores = new HashMap>(); // Map>
var reader = new BufferedReader(new FileReader("file.txt")); // BufferedReader
// Works in for-loops
for (var entry : scores.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue());
}
// Works with try-with-resources
try (var in = new FileInputStream("data.bin")) { ... }
// CANNOT be used:
// var x; // no initialiser — type unknown
// var x = null; // null has no type
// private var name; // instance/static fields
// public var foo() { } // return types
// void bar(var x) { } // method parameters
// var x = { 1, 2, 3 }; // array initialiser without explicit type In Java 11, var was extended to lambda parameters so you can add annotations: (var x, var y) -> x + y is equivalent to (x, y) -> x + y but allows (@NotNull var x, @NotNull var y) -> x + y.
Switch expressions (JEP 361, finalised in Java 14) make switch a value-producing expression rather than only a control-flow statement. They use the arrow (->) syntax and require exhaustiveness.
| Aspect | Switch Statement | Switch Expression |
|---|---|---|
| Produces a value | No | Yes — must be exhaustive |
| Syntax | case X: ... break; | case X -> result; or case X -> { ... yield result; } |
| Fall-through | Possible (bug-prone) | Not possible with arrows |
| Exhaustiveness | Not checked | Compiler-enforced (default required unless exhaustive) |
| Multiple labels | No | case A, B, C -> ... (comma-separated) |
// Switch STATEMENT (old style) — fall-through risk
int numLetters;
switch (day) {
case MONDAY: case FRIDAY: case SUNDAY:
numLetters = 6; break;
case TUESDAY:
numLetters = 7; break;
default:
numLetters = 8; break;
}
// Switch EXPRESSION (Java 14+) — exhaustive, no fall-through
int numLetters = switch (day) {
case MONDAY, FRIDAY, SUNDAY -> 6;
case TUESDAY -> 7;
case THURSDAY, SATURDAY -> 8;
case WEDNESDAY -> 9;
}; // no default — DayOfWeek is exhaustive
// Multi-line arm with yield
int result = switch (x) {
case 1 -> 10;
case 2 -> {
int y = x * x;
System.out.println("Computing...");
yield y + 5; // yield produces the value from a block arm
}
default -> 0;
};
JEP 394 (finalised in Java 16) extends the instanceof operator with a type pattern that both tests the type and binds a typed local variable in a single expression, eliminating the explicit cast that always followed a traditional instanceof check.
// Traditional — test + cast (redundant, error-prone)
if (obj instanceof String) {
String s = (String) obj; // redundant cast
System.out.println(s.length());
}
// Pattern matching instanceof (Java 16+)
if (obj instanceof String s) {
System.out.println(s.length()); // s is String, no cast needed
}
// The pattern variable is in scope only where the test is true
if (!(obj instanceof String s)) {
return; // s is NOT in scope here (obj didn't match)
}
// s IS in scope here — guard pattern
System.out.println(s.toUpperCase());
// Combine with &&
if (obj instanceof String s && s.length() > 3) {
System.out.println(s.trim()); // s in scope because && is short-circuit
}
// In equals() — cleaner implementation
@Override
public boolean equals(Object o) {
return o instanceof Point p
&& this.x == p.x
&& this.y == p.y;
}The scoping rule is precise: the pattern variable is in scope in the part of the expression where the type test is guaranteed to have been true. It is not in scope in the else branch or on the false side of ||. This is called flow scoping and the compiler enforces it statically.
The Stream API has been incrementally enhanced since Java 9. Knowing which methods are available and when they arrived is frequently tested.
| Method | Since | Description |
|---|---|---|
| takeWhile(Predicate) | 9 | Takes elements while predicate is true, then stops |
| dropWhile(Predicate) | 9 | Drops elements while predicate is true, then takes rest |
| iterate(seed, hasNext, next) | 9 | Finite iterate — 3-arg form with a terminating predicate |
| ofNullable(T) | 9 | Stream of 0 or 1 elements (empty if null) |
| Stream.of(T...) | 9 | Already existed; ofNullable is the new addition |
| Collectors.teeing(d1, d2, merger) | 12 | Collect into two downstreams, merge results |
| toList() | 16 | Unmodifiable List directly from stream (vs Collectors.toList()) |
| mapMulti(BiConsumer) | 16 | Flexible flat-map alternative |
| Stream.gather(Gatherer) | 22 preview | Custom intermediate operations (post-21) |
// takeWhile / dropWhile — Java 9
List.of(1, 2, 3, 4, 5, 1, 2)
.stream()
.takeWhile(n -> n < 4) // [1, 2, 3] — stops at first failure
.toList();
// 3-argument iterate — finite sequence
Stream.iterate(1, n -> n <= 100, n -> n * 2) // 1 2 4 8 ... 64
.toList();
// ofNullable — avoids NPE in flatMap chains
Stream.ofNullable(maybeNull) // empty stream if null, else stream of one
.map(String::toUpperCase)
.findFirst();
// Collectors.teeing — Java 12
record Stats(long count, double sum) {}
Stats s = numbers.stream().collect(
Collectors.teeing(
Collectors.counting(),
Collectors.summingDouble(Double::doubleValue),
Stats::new
));
// toList() — Java 16
List names = people.stream().map(Person::name).toList();
// returns an UNMODIFIABLE list — names.add("x") throws UnsupportedOperationException
Optional<T> (Java 8) is a container for a value that may or may not be present. It is designed to be a return type that makes the absence of a value explicit in the API contract, not a replacement for null in all contexts.
| Method | Since | Purpose |
|---|---|---|
| of(T) / ofNullable(T) / empty() | 8 | Factory methods |
| isPresent() / isEmpty() | 8 / 11 | isEmpty() added Java 11 |
| get() / orElse(T) / orElseGet(Supplier) | 8 | Extract value (orElseGet lazier) |
| orElseThrow(Supplier) | 8 | Throw custom exception if empty |
| map() / flatMap() / filter() | 8 | Transform the value |
| ifPresent(Consumer) / ifPresentOrElse() | 8 / 9 | ifPresentOrElse added Java 9 |
| or(Supplier<Optional>) | 9 | Return this if present, else the supplied Optional |
| stream() | 9 | Returns Stream of 0 or 1 elements |
// Correct use — as a return type
Optional findById(long id) {
return Optional.ofNullable(repository.get(id));
}
// Chaining without get()
findById(42)
.filter(u -> u.isActive())
.map(User::email)
.ifPresent(email -> sendNotification(email));
// or() — Java 9: fallback to another Optional
Optional user = findById(42)
.or(() -> findByEmail("alice@example.com"));
// ifPresentOrElse — Java 9
findById(42).ifPresentOrElse(
user -> System.out.println("Found: " + user),
() -> System.out.println("Not found")
);
// stream() — Java 9: use Optional in flatMap on a stream of Optionals
List> opts = List.of(Optional.of("a"), Optional.empty(), Optional.of("b"));
List present = opts.stream()
.flatMap(Optional::stream) // ["a", "b"]
.toList();
// ANTI-PATTERNS:
// opt.isPresent() ? opt.get() : default -- use orElse(default)
// Optional> -- use flatMap
// Optional as a field -- use null or an explicit state enum
// Optional as a parameter -- use method overloading
CompletableFuture<T> (Java 8) provides composable asynchronous computation pipelines. It lets you chain transformations (thenApply), combinations (thenCombine), and error handlers (exceptionally) without blocking threads.
// Basic async computation
CompletableFuture future = CompletableFuture
.supplyAsync(() -> fetchUserName(userId)) // runs in ForkJoinPool
.thenApply(String::toUpperCase) // transform result
.thenApply(name -> "Hello, " + name);
System.out.println(future.join()); // blocks calling thread until done
// Combining two independent futures
CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture orderFuture = CompletableFuture.supplyAsync(() -> fetchOrder(id));
CompletableFuture result = userFuture
.thenCombine(orderFuture,
(user, order) -> user.name() + " ordered " + order.item());
// Error handling
future.exceptionally(ex -> "Default value on error: " + ex.getMessage())
.thenAccept(System.out::println);
// Run all, collect results
List> futures = ids.stream()
.map(id -> CompletableFuture.supplyAsync(() -> fetch(id)))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.thenApply(v -> futures.stream().map(CompletableFuture::join).toList())
.join();
// With virtual threads (Java 21): use simple blocking code instead
// StructuredTaskScope is the recommended replacement for CompletableFuture
// when running on virtual threads — simpler and handles cancellation With Java 21 virtual threads, the async/reactive style of CompletableFuture is less necessary for I/O-bound work — you can write simple blocking code on virtual threads and achieve the same throughput. However, CompletableFuture remains useful for CPU-bound parallel pipelines and for code that must integrate with existing async APIs.
The volatile keyword provides two guarantees in the Java Memory Model (JMM): visibility and ordering.
- Visibility: A write to a volatile variable is immediately flushed to main memory, and a read of a volatile variable always reads from main memory — not from a CPU cache. This prevents a thread from seeing a stale cached value written by another thread.
- Ordering: volatile establishes a happens-before relationship. All writes performed before a volatile write are visible to any thread that subsequently reads that volatile variable.
// Without volatile — Thread B may never see the update from Thread A
class BadFlag {
boolean running = true; // may be cached in Thread A's register
void stop() { running = false; }
void work() { while (running) { /* spin */ } }
}
// With volatile — Thread B sees the write immediately
class GoodFlag {
volatile boolean running = true;
void stop() { running = false; } // flushed to main memory
void work() { while (running) { /* spin */ } } // reads main memory
}
// volatile is NOT atomic for compound operations
volatile int counter = 0;
counter++; // NOT atomic: read + increment + write — use AtomicInteger
// Double-checked locking with volatile (Java 5+ safe)
class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // first check — no lock
synchronized (Singleton.class) {
if (instance == null) { // second check — locked
instance = new Singleton(); // volatile ensures ordering
}
}
}
return instance;
}
}volatile is weaker than synchronized (no mutual exclusion) but cheaper. Use it for single-variable state flags and simple publish/read patterns. For compound operations (increment, compare-and-swap), use java.util.concurrent.atomic.AtomicInteger and friends.
Java 9 (JEP 269) introduced convenient static factory methods on List, Set, and Map for creating small, immutable collections without the verbosity of Arrays.asList() or Collections.unmodifiableList().
// Java 9+ — concise and immutable
List list = List.of("a", "b", "c"); // immutable
Set set = Set.of(1, 2, 3); // immutable, no dups
Map map = Map.of("one", 1, "two", 2); // up to 10 pairs
Map bigMap = Map.ofEntries( // more than 10 pairs
Map.entry("a", 1),
Map.entry("b", 2)
);
// Copying (Java 10+)
List copy = List.copyOf(mutableList); // immutable copy
Set sCopy = Set.copyOf(existingSet);
// Java 9 vs Arrays.asList
// Arrays.asList: fixed-size but MUTABLE (set() works, add() throws)
// List.of: IMMUTABLE (set(), add(), remove() all throw)
list.add("d"); // UnsupportedOperationException
list.set(0, "z"); // UnsupportedOperationException
// Null not permitted in List.of / Set.of / Map.of
List.of("a", null, "c"); // NullPointerException at creation
// Set.of does not allow duplicates
Set.of(1, 2, 2); // IllegalArgumentException These factory-created collections are optimised for memory: small sets and maps use compact array-based implementations rather than hash tables when they have few elements. The iteration order of Set.of and Map.of is intentionally unspecified and may vary between JVM runs.
A functional interface is an interface with exactly one abstract method (SAM — Single Abstract Method). The @FunctionalInterface annotation is optional but recommended — it causes the compiler to enforce the single-abstract-method rule. Lambdas and method references provide implementations for functional interfaces without requiring an anonymous class.
| Interface | Signature | Purpose |
|---|---|---|
| Function | R apply(T t) | Transform T to R |
| Consumer | void accept(T t) | Consume T, no return |
| Supplier | T get() | Produce T, no input |
| Predicate | boolean test(T t) | Test T — returns boolean |
| BiFunction | R apply(T t, U u) | Two inputs, one output |
| UnaryOperator | T apply(T t) | Function where T-in = T-out |
| BinaryOperator | T apply(T t1, T t2) | Two T inputs, one T output |
| Runnable | void run() | No input, no output |
| Callable | V call() | No input, returns V, throws Exception |
// Lambda syntax
Function length = s -> s.length();
Function length2 = String::length; // method reference
// Composing functions
Function toUpper = String::toUpperCase;
Function len = String::length;
Function upperLen = toUpper.andThen(len);
System.out.println(upperLen.apply("hello")); // 5
// Predicate composition
Predicate notBlank = Predicate.not(String::isBlank);
Predicate shortWord = s -> s.length() < 5;
Predicate both = notBlank.and(shortWord);
// Custom functional interface
@FunctionalInterface
interface ThrowingSupplier {
T get() throws Exception; // can declare checked exceptions
}
// Method reference types
String::toUpperCase // instance method — unbound
System.out::println // instance method — bound (specific object)
String::new // constructor reference
Integer::parseInt // static method reference
Collectors define how a terminal collect() operation assembles the stream elements. java.util.stream.Collectors provides ~40 factory methods; the most frequently used in interviews are:
import static java.util.stream.Collectors.*;
// Grouping
Map> byCity =
people.stream().collect(groupingBy(Person::city));
// Grouping with downstream collector
Map countByCity =
people.stream().collect(groupingBy(Person::city, counting()));
Map avgAgeByCity =
people.stream().collect(groupingBy(Person::city, averagingInt(Person::age)));
// Partitioning — splits into true/false map
Map> adultMap =
people.stream().collect(partitioningBy(p -> p.age() >= 18));
// Joining
String csv = names.stream().collect(joining(", ", "[", "]"));
// e.g. "[Alice, Bob, Carol]"
// toMap
Map byId = people.stream()
.collect(toMap(Person::id, p -> p,
(existing, duplicate) -> existing)); // merge fn for duplicates
// teeing (Java 12) — two simultaneous collectors
record MinMax(int min, int max) {}
MinMax mm = IntStream.of(3, 1, 4, 1, 5, 9)
.boxed()
.collect(teeing(
minBy(Comparator.naturalOrder()),
maxBy(Comparator.naturalOrder()),
(min, max) -> new MinMax(min.get(), max.get())
));
// toUnmodifiableList/Set/Map — Java 10
List immutable = stream.collect(toUnmodifiableList());
JEP 445 (preview in Java 21) removes much of the ceremony required to write a simple Java program. It targets learning, scripting, and small utilities — not production application structure.
// Traditional Hello World — requires class declaration, static, String[] args
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
// Java 21 Preview — unnamed class, instance main, no args
void main() {
System.out.println("Hello, World!");
}
// The file is still named Main.java (or any name)
// Compile: javac --enable-preview --release 21 Main.java
// Run: java --enable-preview Main
// Top-level methods and fields are allowed
String greeting = "Hello";
void main() {
System.out.println(greeting + ", World!");
greet("Alice");
}
void greet(String name) {
System.out.println(greeting + ", " + name + "!");
}
// Main method signature flexibility:
// void main() -- new (no args, instance)
// static void main() -- no args, static
// static void main(String[]) -- traditionalThe launch protocol in JEP 445 checks for a valid main method in this order: first static void main(String[]) (traditional), then static void main(), then void main(String[]), then void main(). This ensures backward compatibility — existing programs are unaffected.
JEP 358 (Java 14) enabled helpful NullPointerException messages that precisely identify which variable or expression was null, rather than just the line number and a generic message. This was one of the most immediately useful quality-of-life improvements in recent Java history.
// Before Java 14: vague NPE
// Exception in thread "main" java.lang.NullPointerException
// at com.example.Main.main(Main.java:12)
// Java 14+: helpful NPE message
// NullPointerException: Cannot invoke "String.length()" because
// "" is null
// Detailed example
a.b.c.d = 42;
// Cannot assign field "d" because "a.b.c" is null
a[5].b = 42;
// Cannot assign field "b" because "a[5]" is null
a.b = foo();
// Cannot invoke "Main.foo()" because "a" is null
// Enabled by default since Java 15
// (In Java 14 it was enabled with -XX:+ShowCodeDetailsInExceptionMessages)
// Complements with Objects.requireNonNull for fail-fast validation
String name = Objects.requireNonNull(input, "input must not be null");
// Objects.requireNonNullElse — Java 9
String display = Objects.requireNonNullElse(name, "Unknown");
// Objects.requireNonNullElseGet — lazy supplier
String display2 = Objects.requireNonNullElseGet(name, () -> computeDefault()); The JVM analyses the bytecode at the point of the NPE and constructs a sentence describing which specific reference in a complex expression was null. This works for field access, method invocations, array accesses, and unboxing operations. It dramatically reduces debugging time for nested null-checks.
Type erasure means generic type parameters are removed at compile time, leaving only the raw type at runtime. This has direct implications for instanceof checks.
// Cannot check parameterised generic types at runtime
List<String> strings = List.of("a", "b");
if (strings instanceof List<String>) { } // compile error - cannot check List <String>
if (strings instanceof List<?>) { } // OK - wildcard is fine
if (strings instanceof List) { } // OK - raw type is fine (with warning)
// Generic type parameter in pattern ÂÂ unchecked warning
// The JVM can only check 'instanceof List', not 'instanceof List'
// Java 21 allows wildcard in pattern
static void process(Object obj) {
if (obj instanceof List<?> list) {
list.forEach(System.out::println); // OK ÂÂ each element is Object
}
}
// Workaround: check elements individually
static boolean isListOfStrings(Object obj) {
if (obj instanceof List<?> list) {
return list.stream().allMatch(e -> e instanceof String);
}
return false;
}
// getClass() also suffers from erasure
List<String> ls = new ArrayList<>();
List<Integer> li = new ArrayList<>();
System.out.println(ls.getClass() == li.getClass()); // true ÂÂ both ArrayList
Type erasure is the root cause. Java generics are a compile-time mechanism; at runtime both List<String> and List<Integer> are simply List. Pattern matching cannot change this ÂÂ instanceof List<String> would require the JVM to inspect every element, which is why it is disallowed. Project Valhalla (future Java) aims to address this with reified generics via value types.
synchronized is Java's built-in intrinsic lock mechanism. ReentrantLock (in java.util.concurrent.locks) provides the same mutual-exclusion guarantee but with more capabilities at the cost of more verbose code.
| Feature | synchronized | ReentrantLock |
|---|---|---|
| Lock acquisition | Implicit — enters on block entry | Explicit — must call lock() |
| Unlock | Automatic on block exit | Explicit — must call unlock() in finally |
| Try to acquire | Not possible | tryLock() / tryLock(timeout, unit) |
| Interruptible lock | Not possible | lockInterruptibly() |
| Fairness | Non-fair (JVM decides) | Can be fair (new ReentrantLock(true)) |
| Condition variables | One per monitor (wait/notify) | Multiple Condition objects per lock |
| Virtual thread pinning | Pins virtual thread to carrier | Does NOT pin — preferred for VTs |
// synchronized — simple cases
synchronized (this) {
// critical section
}
// ReentrantLock — more control, required for virtual-thread-safe code
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();
void produce(T item) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) notFull.await();
queue.add(item);
notEmpty.signal();
} finally {
lock.unlock(); // always in finally to prevent lock leaks
}
}
// tryLock — non-blocking attempt
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try { /* critical section */ }
finally { lock.unlock(); }
} else {
System.out.println("Could not acquire lock in time");
}The critical Java 21 consideration: synchronized pins a virtual thread to its carrier OS thread for the duration of the synchronized block, preventing the scheduler from parking the virtual thread and assigning the carrier to another task. ReentrantLock does not pin — the virtual thread can still be parked inside a lock. For high-concurrency virtual-thread applications, replacing hot synchronized blocks with ReentrantLock is a meaningful performance improvement.
The java.time package (Java 8, JSR 310) replaces the problematic java.util.Date and java.util.Calendar classes. All classes are immutable and thread-safe by design.
| Class | Represents | Example |
|---|---|---|
| LocalDate | Date without time or timezone | 2024-03-15 |
| LocalTime | Time without date or timezone | 14:30:00.000 |
| LocalDateTime | Date + time, no timezone | 2024-03-15T14:30:00 |
| ZonedDateTime | Date + time + timezone | 2024-03-15T14:30:00+05:30[Asia/Kolkata] |
| OffsetDateTime | Date + time + fixed UTC offset | 2024-03-15T14:30:00+05:30 |
| Instant | Moment on UTC timeline (nanosecond precision) | 2024-03-15T09:00:00Z |
| Duration | Amount of time in hours/minutes/seconds | PT2H30M |
| Period | Amount of time in years/months/days | P1Y2M3D |
| ZoneId | A time zone ID | "Europe/London" |
// LocalDate operations
LocalDate today = LocalDate.now();
LocalDate birthday = LocalDate.of(1990, Month.JUNE, 15);
LocalDate nextMonth = today.plusMonths(1);
long days = ChronoUnit.DAYS.between(birthday, today);
// ZonedDateTime — essential for cross-timezone work
ZonedDateTime nyNow = ZonedDateTime.now(ZoneId.of("America/New_York"));
ZonedDateTime tokyoNow = nyNow.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));
// Instant — machine time, for logging and persistence
Instant start = Instant.now();
// ... do work ...
Duration elapsed = Duration.between(start, Instant.now());
System.out.println("Elapsed: " + elapsed.toMillis() + "ms");
// Formatting / parsing
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
String formatted = today.atTime(14, 30).format(fmt);
LocalDateTime parsed = LocalDateTime.parse("25/12/2024 09:00", fmt);
// Converting legacy Date to Instant
Instant i = new Date().toInstant();
LocalDate ld = i.atZone(ZoneId.systemDefault()).toLocalDate();
Java 21 ships four major garbage collectors, each optimised for different trade-off points on the throughput-vs-latency spectrum.
| GC | Flag | Pause target | Throughput | Best for |
|---|---|---|---|---|
| Serial GC | -XX:+UseSerialGC | Not optimised — STW | Low | Single-core, small heap (<100 MB) |
| Parallel GC | -XX:+UseParallelGC | Seconds (STW) | Highest | Batch, throughput-first, CPU-heavy |
| G1 GC (default) | -XX:+UseG1GC | <200 ms target | High | General-purpose, heaps 1–32 GB |
| ZGC (Generational) | -XX:+UseZGC | <1 ms | Good | Low-latency, very large heaps (>32 GB) |
| Shenandoah | -XX:+UseShenandoahGC | <10 ms | Good | Low-latency alternative to ZGC |
// G1 (default since Java 9) — good general purpose starting point
// java -Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 MyApp
// ZGC — for large heaps needing sub-millisecond pauses
// java -Xms16g -Xmx32g -XX:+UseZGC MyApp
// In Java 21, ZGC is generational by default
// Parallel GC — batch jobs, maximum throughput
// java -Xms8g -Xmx8g -XX:+UseParallelGC -XX:ParallelGCThreads=8 MyApp
// GC logging (useful in all environments)
// -Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20m
// Key GC metrics to monitor:
// - GC pause duration and frequency
// - Allocation rate (Eden fill rate)
// - Promotion rate (objects escaping young gen)
// - Heap occupancy after GC
// - Metaspace usageFor Java 21 virtual-thread applications: ZGC (Generational) or G1 are the best choices. Virtual threads create many short-lived objects (parked continuations), and G1/ZGC handle high young-generation allocation rates well. Avoid Parallel GC for latency-sensitive applications — its stop-the-world pauses can be seconds long on large heaps.
JPMS (Project Jigsaw, JEP 261, Java 9) introduces the concept of modules — named, versioned groups of packages with explicit dependency declarations and access control. It solves the classpath hell problem (no visibility into what JARs exist or what they expose) and improves security by allowing packages to be completely non-accessible to other modules.
// module-info.java — at the root of the source tree
module com.example.myapp {
// Explicit dependencies
requires java.sql;
requires com.example.utils;
requires transitive com.example.api; // transitive: callers get it too
requires static com.example.opt; // optional at runtime
// Explicit exports — only these packages are readable by other modules
exports com.example.myapp.api;
exports com.example.myapp.model to com.example.web; // qualified export
// Opens for reflection (e.g., for Spring, Hibernate, Jackson)
opens com.example.myapp.model; // to all modules
opens com.example.myapp.impl to spring.core; // to Spring only
// Service provider / consumer
uses com.example.api.Plugin; // consumes
provides com.example.api.Plugin // provides
with com.example.myapp.impl.MyPlugin;
}
// Compile
// javac --module-source-path src -d out -m com.example.myapp
// Run
// java --module-path out -m com.example.myapp/com.example.myapp.Main
// Reflect without opens in module — use --add-opens at JVM startup
// java --add-opens java.base/java.lang=com.example.myapp ...Practical reality: most enterprise applications still use the unnamed module (plain classpath) because migrating large codebases to named modules is complex, and many libraries do not provide module-info.java yet. However, libraries and frameworks are increasingly module-aware, and applications targeting the JVM directly (command-line tools, serverless jars) benefit greatly from JPMS for smaller deployable images via jlink.
JEP 430 introduces String Templates as a preview feature in Java 21. They provide type-safe string interpolation via template processors — a safer alternative to string concatenation and String.format() that prevents injection vulnerabilities by separating the template structure from the values.
// Traditional approaches — error-prone
String name = "Alice";
int age = 30;
String s1 = "Hello " + name + ", you are " + age + " years old.";
String s2 = String.format("Hello %s, you are %d years old.", name, age);
String s3 = "Hello %s, you are %d years old.".formatted(name, age);
// String Template (Java 21 Preview) — requires --enable-preview
String s4 = STR."Hello \{name}, you are \{age} years old.";
// Embedded expressions are evaluated and inserted
// Expressions can be any Java expression
String s5 = STR."The sum of 3+4 is \{3+4}.";
String s6 = STR."First name: \{name.split(" ")[0]}";
// Multi-line template with STR
String json = STR."""
{
"name": "\{name}",
"age": \{age}
}
""";
// FMT processor — formatted output like printf
String formatted = FMT."Total: %10.2f\{total} USD";
// Custom template processor — safe SQL (not yet in preview)
// PreparedStatement ps = SQL."SELECT * FROM users WHERE id = \{userId}";
// The SQL processor parameterises the value — injection-safeThe key safety property: unlike simple string interpolation in other languages, String Templates allow custom processors (STR, FMT, or user-defined) to control how embedded values are combined with the template. A SQL processor can automatically use prepared statement parameters; a JSON processor can escape values correctly — the template structure and values are never naively concatenated.
These three methods have formal contracts that must be maintained for code to work correctly with collections, sorting, and data structures.
// equals() contract:
// 1. Reflexive: x.equals(x) == true
// 2. Symmetric: x.equals(y) == y.equals(x)
// 3. Transitive: x.equals(y) && y.equals(z) => x.equals(z)
// 4. Consistent: multiple calls return same result (no random state)
// 5. Null: x.equals(null) == false (never throws NPE)
// hashCode() contract:
// 1. Consistent: same object => same hashCode across calls
// 2. Equality: x.equals(y) MUST imply x.hashCode() == y.hashCode()
// 3. Collision: x.hashCode() == y.hashCode() does NOT imply x.equals(y)
// Records auto-generate correct equals() and hashCode()
record Point(int x, int y) {} // perfect equals and hashCode built-in
// Manual implementation (old style)
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
// Comparable — natural ordering for TreeSet, TreeMap, Collections.sort
record Employee(String name, int salary) implements Comparable {
@Override
public int compareTo(Employee other) {
return Integer.compare(this.salary, other.salary); // ascending salary
}
}
// Comparator — external ordering (does not require modifying the class)
Comparator byNameThenSalary =
Comparator.comparing(Employee::name)
.thenComparingInt(Employee::salary); The most common bug: implementing equals() without updating hashCode(). Two objects that are equal will then have different hash codes, causing them to land in different hash buckets — a HashMap will store both, and HashSet will contain both, breaking uniqueness silently.
Exception handling design is a common interview discussion topic. Java distinguishes checked exceptions (must be declared or caught — compiler enforces), unchecked (RuntimeException subclasses), and errors (JVM-level, not catchable in normal code).
// 1. Prefer specific exceptions over general ones
// Bad: catch (Exception e) { ... }
// Good: catch (IOException | ParseException e) { ... }
// 2. Try-with-resources for AutoCloseable resources
try (var conn = ds.getConnection();
var stmt = conn.prepareStatement(sql)) {
// Both closed automatically in reverse order
} catch (SQLException e) {
throw new DataAccessException("Query failed", e); // preserve cause
}
// 3. Wrap exceptions to maintain abstraction layers
// A service layer should not leak SQLException to the web layer
public User findUser(long id) {
try {
return repository.findById(id);
} catch (SQLException e) {
throw new ServiceException("User lookup failed for id=" + id, e);
}
}
// 4. Never swallow exceptions silently
// Bad:
try { riskyOp(); } catch (Exception e) { /* ignore */ }
// Good: at minimum, log
try { riskyOp(); } catch (Exception e) { log.error("Failed", e); throw e; }
// 5. Checked vs unchecked — modern advice
// Use checked: caller CAN and SHOULD recover (FileNotFoundException)
// Use unchecked: programming error or unrecoverable (NullPointer, IllegalArgument)
// 6. Custom exceptions — extend RuntimeException for most domain errors
public class UserNotFoundException extends RuntimeException {
private final long userId;
public UserNotFoundException(long userId) {
super("User not found: " + userId);
this.userId = userId;
}
public long getUserId() { return userId; }
}Multi-catch (catch (IOException | ParseException e)) was added in Java 7 and reduces boilerplate when handling multiple exception types with identical recovery logic. The caught variable is implicitly final in a multi-catch.
An immutable object's state cannot change after construction. Immutable objects are inherently thread-safe, can be freely shared without copying, make better HashMap keys, and simplify reasoning about code because no method can have hidden side effects on them.
// Building an immutable class correctly
public final class Money { // 1. final class — no subclassing
private final BigDecimal amount; // 2. private final fields
private final Currency currency;
private final List notes; // 3. defensive copy of mutable input
public Money(BigDecimal amount, Currency currency, List notes) {
this.amount = Objects.requireNonNull(amount);
this.currency = Objects.requireNonNull(currency);
// Defensive COPY — don't store caller's mutable list
this.notes = List.copyOf(notes); // unmodifiable copy
}
public BigDecimal getAmount() { return amount; }
public Currency getCurrency() { return currency; }
public List getNotes() { return notes; } // safe — unmodifiable
// Return new instance for 'mutations'
public Money add(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException("Currency mismatch");
return new Money(this.amount.add(other.amount), this.currency, notes);
}
}
// Records are immutable by default (final, final fields, no setters)
record Point(int x, int y) {}
// Java 9+ — unmodifiable collection factories
List immList = List.of("a", "b", "c"); // unmodifiable
Map immMap = Map.of("k", 1); // unmodifiable
// Collections.unmodifiable* wraps but the underlying can still be mutated
var inner = new ArrayList<>(List.of("a"));
var wrapped = Collections.unmodifiableList(inner);
inner.add("b"); // wrapped now also reflects the change! The subtle pitfall: Collections.unmodifiableList(list) creates a view — not a copy. If the backing list is mutated through the original reference, the view reflects the change. List.copyOf() (Java 10) creates a true immutable snapshot.
Java 8 added default and static methods to interfaces, fundamentally changing the relationship between interfaces and abstract classes.
interface Validator {
// Abstract method — implementors must provide
boolean isValid(T value);
// Default method — provided implementation, can be overridden
default Validator and(Validator other) {
return value -> this.isValid(value) && other.isValid(value);
}
default Validator or(Validator other) {
return value -> this.isValid(value) || other.isValid(value);
}
default Validator negate() {
return value -> !this.isValid(value);
}
// Static factory method — belongs to the interface, not instances
static Validator of(Validator validator) {
return Objects.requireNonNull(validator);
}
}
// Usage
Validator notBlank = s -> !s.isBlank();
Validator notTooLong = s -> s.length() <= 100;
Validator combined = notBlank.and(notTooLong);
System.out.println(combined.isValid("hello")); // true
System.out.println(combined.isValid("")); // false
// Diamond problem resolution
interface A { default String hello() { return "A"; } }
interface B extends A { default String hello() { return "B"; } }
class C implements A, B {
// Must override — most-specific interface wins (B),
// but compiler forces explicit resolution
@Override public String hello() { return B.super.hello(); }
} Default methods enable interface evolution: adding a new method to an interface in a library no longer breaks all existing implementors, because the default provides backward compatibility. This is how Java 8 added methods like Collection.forEach(), List.sort(), and Map.getOrDefault() without breaking existing code.
Understanding HashMap internals — hashing, buckets, collisions, resizing, and treeification — is one of the most commonly asked Java interview topics at mid-to-senior level.
// Internal structure:
// - Node[] table — array of buckets (initially null, lazily allocated)
// - Default capacity: 16, load factor: 0.75
// - When size > capacity * loadFactor → resize (double capacity)
// put(key, value) steps:
// 1. Compute hash: (h = key.hashCode()) ^ (h >>> 16) — spread high bits
// 2. Bucket index: hash & (capacity - 1)
// 3. If bucket empty: insert new Node
// 4. If bucket occupied (collision):
// - Walk linked list: if key equals an existing node, update value
// - Else append to linked list
// - If list length >= TREEIFY_THRESHOLD (8) AND capacity >= 64:
// convert to balanced Red-Black tree (O(log n) instead of O(n))
// Performance characteristics
// Best case (no collisions): O(1) get, put
// Worst case (all in one bucket, pre-Java 8): O(n)
// Worst case (all in one bucket, Java 8+): O(log n) after treeification
// Why the spread: hashCode() of strings/integers often has poor high-bit distribution
// The XOR spread: (h) ^ (h >>> 16) mixes high and low 16 bits
// Thread safety
// HashMap is NOT thread-safe
// ConcurrentHashMap: segments replaced by per-bin locking in Java 8
// Hashtable: fully synchronized (legacy, slow)
// Collections.synchronizedMap: wraps HashMap, one lock for all ops
Map map = new HashMap<>(32, 0.75f); // pre-size to avoid rehash The load factor trade-off: a lower load factor (e.g., 0.5) means fewer collisions but more memory wasted on empty buckets. A higher load factor (e.g., 0.9) uses memory efficiently but increases collision probability, degrading performance toward O(log n) or O(n). The default 0.75 is the empirically best trade-off for general use.
Both virtual threads (Java 21) and reactive frameworks solve the same underlying problem: how to serve many concurrent I/O-bound requests without blocking OS threads, which are expensive. They solve it very differently.
| Aspect | Reactive (Reactor/RxJava) | Virtual Threads (Java 21) |
|---|---|---|
| Programming style | Functional/async chains (flatMap, subscribe) | Imperative blocking code |
| Debugging | Complex — callbacks and stack traces are fragmented | Simple — full stack traces, works with standard debugger |
| Library compatibility | Requires reactive-aware libs (R2DBC, WebFlux) | Works with any blocking library (JDBC, HttpClient) |
| Error handling | onError / catch in chains | Normal try/catch |
| Learning curve | High — new mental model | Low — reads like sequential code |
| Context propagation | Manual (reactor Context) | ThreadLocal (or ScopedValue) |
| CPU-bound work | No advantage | No advantage — still needs ForkJoinPool |
// Reactive style (Spring WebFlux)
Mono result = userRepository.findById(id) // Mono
.flatMap(user -> orderRepository.findByUser(user.id())) // Mono
.map(order -> order.summary())
.onErrorReturn("default response");
// Virtual thread style (Spring Boot 3.2+ with Tomcat virtual threads)
String result;
try {
User user = userRepository.findById(id); // blocking — parks VT
Order order = orderRepository.findByUser(user.id()); // same
result = order.summary();
} catch (Exception e) {
result = "default response";
}
// Spring Boot 3.2 — enable virtual threads for Tomcat:
// spring.threads.virtual.enabled=true
// All @RequestMapping handlers run on virtual threads automatically The consensus in the Java community for Java 21+: prefer virtual threads for new applications — the code is simpler, debuggable, and compatible with the existing JDBC/HTTP ecosystem. Reactive programming remains justified when you need fine-grained back-pressure, stream operators for event pipelines, or when integrating with reactive middleware that does not have a blocking API.
The java.net.http.HttpClient (JEP 321, finalised in Java 11) is a modern, non-blocking HTTP client that supports HTTP/1.1 and HTTP/2, WebSockets, synchronous and asynchronous request modes, and streaming. It replaces the antiquated HttpURLConnection.
import java.net.http.*;
import java.net.URI;
import java.time.Duration;
// Build a reusable client (expensive to create — use as singleton)
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
// Synchronous GET
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users/1"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse response =
client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode()); // 200
System.out.println(response.body()); // JSON string
// Asynchronous POST with JSON body
HttpRequest postRequest = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("""
{"name":"Alice","age":30}
"""))
.build();
client.sendAsync(postRequest, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(System.out::println)
.join(); // wait for demo purposes
// With virtual threads (Java 21): use send() (blocking) — no need for sendAsync()
// The virtual thread parks during the network wait — OS thread is free
Java enums are full-fledged classes that happen to have a fixed number of instances. This makes them far more powerful than C-style enums and eliminates entire categories of bugs that arise from using int constants.
public enum Planet {
MERCURY(3.303e+23, 2.4397e6),
VENUS (4.869e+24, 6.0518e6),
EARTH (5.976e+24, 6.37814e6);
private final double mass; // kg
private final double radius; // meters
Planet(double mass, double radius) { // constructor
this.mass = mass;
this.radius = radius;
}
static final double G = 6.67300E-11;
double surfaceGravity() { return G * mass / (radius * radius); }
double surfaceWeight(double otherMass) { return otherMass * surfaceGravity(); }
}
double earthWeight = 75.0;
double mass = earthWeight / Planet.EARTH.surfaceGravity();
for (Planet p : Planet.values()) {
System.out.printf("Weight on %s = %6.2f%n", p, p.surfaceWeight(mass));
}
// Abstract methods — each constant provides its own implementation
enum Operation {
PLUS("+ ") { @Override double apply(double x, double y) { return x + y; } },
MINUS("-") { @Override double apply(double x, double y) { return x - y; } };
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
abstract double apply(double x, double y);
@Override public String toString() { return symbol; }
}
// Enum with switch (Java 21 — pattern matching works on enums)
int priority = switch (severity) {
case CRITICAL -> 1;
case HIGH -> 2;
case MEDIUM -> 3;
case LOW -> 4;
};
// EnumSet and EnumMap — fast, memory-efficient specialisations
EnumSet workdays = EnumSet.range(MONDAY, FRIDAY);
EnumMap weights = new EnumMap<>(Planet.class);
The java.util.concurrent.atomic package provides lock-free, thread-safe single-variable operations using CPU-level Compare-And-Swap (CAS) instructions. They are faster than synchronized blocks for single-variable updates because they avoid locking entirely.
// AtomicInteger — counter safe for use from multiple threads
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // returns new value: 1
counter.getAndIncrement(); // returns old value: 1, new value: 2
counter.addAndGet(5); // atomically add 5
counter.compareAndSet(7, 0); // only updates if current == 7
// AtomicLong, AtomicBoolean, AtomicReference work the same way
// AtomicReference — CAS on an object reference
AtomicReference name = new AtomicReference<>("initial");
boolean updated = name.compareAndSet("initial", "updated"); // true
// LongAdder — better than AtomicLong under HIGH contention
LongAdder hitCount = new LongAdder();
hitCount.increment(); // thread-safe, very fast under contention
long total = hitCount.sum(); // reads the aggregate
// LongAdder splits the counter across multiple cells to reduce contention
// AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray
AtomicIntegerArray arr = new AtomicIntegerArray(10);
arr.compareAndSet(5, 0, 99); // CAS on index 5
// VarHandle (Java 9) — generalized CAS on any field
// More powerful but more verbose than atomic classes LongAdder vs AtomicLong: at low contention they perform similarly. Under high contention (many threads incrementing simultaneously), LongAdder is significantly faster because it distributes the counter across multiple cells and only aggregates in sum(). Use LongAdder for pure counters; use AtomicLong when you need atomic read-modify-write with a precise current value (e.g., compareAndSet patterns).
Reflection allows programs to inspect and manipulate classes, methods, fields, constructors, and annotations at runtime — without knowing them at compile time. It powers frameworks like Spring (dependency injection), Hibernate (ORM), JUnit (test discovery), and Jackson (JSON serialisation).
import java.lang.reflect.*;
// Inspect a class at runtime
Class> clazz = Class.forName("com.example.User");
// or: User.class or user.getClass()
// Fields
for (Field f : clazz.getDeclaredFields()) {
f.setAccessible(true); // bypass private access
System.out.println(f.getName() + " : " + f.getType().getSimpleName());
}
// Methods
Method method = clazz.getDeclaredMethod("setName", String.class);
method.setAccessible(true);
method.invoke(userInstance, "Alice"); // calls userInstance.setName("Alice")
// Constructor — create an instance dynamically
Constructor> ctor = clazz.getDeclaredConstructor(String.class, int.class);
ctor.setAccessible(true);
Object user = ctor.newInstance("Alice", 30);
// Annotations — reading custom metadata
@Retention(RetentionPolicy.RUNTIME) // must be RUNTIME to read via reflection
@Target(ElementType.METHOD)
@interface Timed { String value() default ""; }
@Timed("fetchUser")
void fetchUser() { /* ... */ }
Method m = SomeClass.class.getMethod("fetchUser");
if (m.isAnnotationPresent(Timed.class)) {
Timed t = m.getAnnotation(Timed.class);
System.out.println("Timer label: " + t.value());
}Performance implications: Reflection bypasses JIT optimisations (inlining, escape analysis) and incurs overhead from security checks, type conversions, and dynamic dispatch. setAccessible(true) in Java 9+ requires the module to be opened. Java 21 provides MethodHandles as a faster alternative for repeated reflective access — they can be specialised and inlined by the JIT after a warm-up period.
JEP 443 (preview in Java 21) introduces the underscore _ as a special token meaning 'I don't care about this value'. It can be used as an unnamed variable in catch blocks, try-with-resources, enhanced for loops, and lambda parameters — and as an unnamed pattern component in record patterns.
// --- Unnamed variable in catch ---
// Before: must name the exception even if you don't use it
try { processFile(); } catch (IOException e) { throw new RuntimeException(e); }
// After (Java 21 Preview):
try { processFile(); } catch (IOException _) { throw new RuntimeException("failed"); }
// --- Unnamed variable in enhanced for ---
int count = 0;
for (var _ : list) count++; // iterate just for the count, don't need the element
// --- Unnamed pattern component in record pattern ---
record Point(int x, int y) {}
record Line(Point start, Point end) {}
// Only care about the x of start, ignore everything else
if (shape instanceof Line(Point(int x, _), _)) {
System.out.println("Line starts at x=" + x);
}
// --- Unnamed lambda parameter ---
list.forEach(_ -> count++);
// Multiple unnamed variables in same scope (all named _ — allowed)
try {
int _ = Integer.parseInt(s1);
int _ = Integer.parseInt(s2); // two _ variables, both unnamed
} catch (NumberFormatException _) {
System.out.println("Parse failed");
}Before JEP 443, Java required naming every variable even if it was intentionally unused, leading to suppressed warnings (@SuppressWarnings("unused")) and distracting variable names like ignored or dummy. The underscore communicates intent clearly: this value exists but is deliberately not examined.
Both APIs manage concurrent asynchronous work, but they have different designs, guarantees, and ideal use cases. Java 21 introduces StructuredTaskScope as the preferred model when running on virtual threads.
| Aspect | CompletableFuture | StructuredTaskScope |
|---|---|---|
| API style | Callback/continuation chain | Imperative — fork, then join |
| Cancellation | Manual — no automatic cleanup | Automatic — scope closes all tasks on exit |
| Error propagation | Manual — must handle in chain | throwIfFailed() re-throws cleanly |
| Thread model | ForkJoinPool (platform threads) or custom executor | Virtual threads (one per fork) |
| Structure | Unstructured — tasks can outlive scope | Structured — subtasks never outlive scope |
| Stack traces | Fragmented across continuations | Full linear traces per virtual thread |
| Availability | Java 8+ | Java 21 (preview) |
// CompletableFuture — unstructured, manual cancellation
CompletableFuture userF = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture orderF = CompletableFuture.supplyAsync(() -> fetchOrder(id));
// If fetchUser throws, fetchOrder may continue running — thread leak possible
String result = userF.thenCombine(orderF,
(u, o) -> u.name() + " / " + o.total()).join();
// StructuredTaskScope — structured, automatic cleanup
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var userTask = scope.fork(() -> fetchUser(id));
var orderTask = scope.fork(() -> fetchOrder(id));
scope.join().throwIfFailed(); // if either fails, closes both
String result2 = userTask.get().name() + " / " + orderTask.get().total();
} // scope.close() cancels any still-running tasks
// Choose CompletableFuture when:
// - Integrating with existing CF-based APIs
// - You need complex transformation pipelines on the result
// - You cannot use preview features
// Choose StructuredTaskScope when:
// - You want automatic cancellation and no thread leaks
// - Code runs on virtual threads (Java 21)
// - You want structured, readable concurrent code
Java 21 continues the cleanup of APIs that were deprecated in earlier releases. Understanding what was removed helps interviewers assess whether a candidate's Java knowledge is current.
| API/Feature | Deprecated | Removed | Replacement |
|---|---|---|---|
| Security Manager | Java 17 | Java 21 (JEP 411 warning) | OS-level security / security frameworks |
| Applet API | Java 9 | Java 23 target; Java 17 deprecated for removal | Web technologies |
| Thread.stop/suspend/resume | Java 1.2 | Not yet; flagged deprecated for removal | Cooperative interruption |
| finalize() | Java 9 | Java 18 (JEP 421 deprecated) | Cleaner API / try-with-resources |
| RMI Activation | Java 15 | Java 17 | Direct RMI or gRPC |
| Experimental AOT/JIT (Graal) | N/A | Java 17 | GraalVM as separate product |
| CMS Garbage Collector | Java 9 | Java 14 | G1, ZGC, Shenandoah |
// finalize() — use Cleaner instead (Java 9+)
// Old pattern:
class OldResource {
@Override protected void finalize() { /* cleanup */ } // unreliable, deprecated
}
// Modern pattern:
class ManagedResource implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
private final Cleaner.Cleanable cleanable;
ManagedResource() {
cleanable = CLEANER.register(this, new CleanupAction());
}
@Override public void close() { cleanable.clean(); }
static class CleanupAction implements Runnable {
@Override public void run() { /* release native resource */ }
}
}
// SecurityManager — Java 17 deprecated, Java 21 warns, will be removed
// If your code calls System.setSecurityManager() → update now
// Migration: use OS-level security, module system, or third-party frameworksThe finalize() deprecation is particularly important: it was unreliable (not guaranteed to run, can be called on a different thread, delays GC), and caused memory leaks. The Cleaner API provides a reliable, predictable cleanup mechanism without the pitfalls of finalisation.
Understanding key JVM flags distinguishes senior engineers from juniors. Here are the flags that matter most for Java 21 production deployments.
| Flag | Category | Purpose |
|---|---|---|
| -Xms / -Xmx | Memory | Initial / max heap size. Set equal to avoid resizing pauses |
| -XX:+UseZGC | GC | Enable ZGC (Generational by default in Java 21) |
| -XX:MaxGCPauseMillis=N | GC | G1 pause target (best effort) |
| -XX:+UseStringDeduplication | GC | G1: deduplicate identical String objects |
| -Xlog:gc*:file=gc.log | Logging | GC logging to file |
| -XX:+HeapDumpOnOutOfMemoryError | Diagnostics | Auto heap dump on OOM |
| -XX:HeapDumpPath=/path | Diagnostics | Location for heap dump |
| -XX:+ExitOnOutOfMemoryError | Reliability | Exit JVM on OOM instead of limping on |
| -Djdk.virtualThreadScheduler.parallelism=N | Loom | Number of carrier threads for VTs |
| --enable-preview | Language | Enable preview features (e.g., String Templates, unnamed classes) |
| -XX:+TieredCompilation | JIT | Multi-tier JIT (default on — rarely need to disable) |
| --add-opens module/package=ALL-UNNAMED | Modules | Open module package to classpath (for reflection) |
# Production example: Java 21 virtual-thread microservice with ZGC
java \
-Xms512m -Xmx2g \
-XX:+UseZGC \
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=20m \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/app/heap.hprof \
-XX:+ExitOnOutOfMemoryError \
-Djava.util.concurrent.ForkJoinPool.common.parallelism=1 \
-jar myapp.jar
# Carrier thread count for virtual threads (default = number of CPU cores)
# -Djdk.virtualThreadScheduler.parallelism=16
# Diagnose pinning (virtual thread pinned to carrier too long)
# -Djdk.tracePinnedThreads=full
Migrating from Java 8/11/17 to Java 21 requires attention to removed APIs, module system access restrictions, and the opportunity to adopt new features incrementally.
// Step 1: Compile with Java 21 — fix deprecation warnings
// javac --release 21 -Xlint:deprecation src/**/*.java
// Step 2: Run with Java 21 — look for startup warnings
// WARNING: Illegal reflective access ... (module access)
// Fix: add --add-opens flags until migrated properly
// Step 3: Scan for removed APIs
// - SecurityManager: remove System.setSecurityManager() calls
// - Thread.stop() / suspend() / resume(): use interrupt/flags
// - sun.* / com.sun.* internal APIs: use public replacements
// - Applet: remove entirely
// Step 4: Update dependencies
// Many libraries had Java 9+ compatibility issues that are now fixed
// Spring Boot 3.x requires Java 17+ and supports Java 21 natively
// Hibernate 6.x is Jakarta EE and Java 17+ compatible
// Jackson 2.15+ has module-info.java
// Step 5: Enable virtual threads (Spring Boot 3.2)
// spring.threads.virtual.enabled=true
// Then: remove thread pool sizing, increase load test concurrency
// Step 6: Replace synchronized with ReentrantLock in hot paths
// Use: jcmd Thread.print to see pinned virtual threads
// Step 7: Adopt new language features incrementally
// Records -> DTO classes first
// Pattern matching instanceof -> existing instanceof+cast
// Text blocks -> multi-line strings (SQL, JSON, HTML)
// Switch expressions -> switch statements returning a value
// Step 8: Update GC selection
// G1 is still default and excellent
// Consider ZGC if latency percentiles matter and heap > 4 GB | Pitfall | Symptom | Fix |
|---|---|---|
| sun.misc.Unsafe usage | InaccessibleObjectException | Use VarHandle or Cleaner API |
| Reflective access to internals | InaccessibleObjectException | --add-opens or update library |
| Split packages across modules | Module resolution failure | Merge JARs or use classpath |
| Thread.stop() removal | NoSuchMethodError | Cooperative interrupt flag |
| synchronized + virtual threads | Carrier thread pinning | Replace with ReentrantLock |
| High ThreadLocal usage | Memory pressure at high VT count | Switch to ScopedValue |
Older versions of Spring MVC scaled using a rigid "one-request-per-thread" blocking architecture tied to expensive platform (OS) threads. Spring 7 builds on top of modern Java baselines to offer native, out-of-the-box optimization for Virtual Threads. This allows applications to process millions of concurrent web requests using lightweight, ephemeral threads without switching entirely to complex, reactive programming architectures.
