Java Interview Prep
A comprehensive collection of Java interview questions ranging from core fundamentals to advanced topics like concurrency, JVM internals, and Spring Boot.
Topics covered:
- Core basics (JDK vs JRE, String pool, exceptions, autoboxing, pass-by-value, static)
- OOP principles (inheritance, polymorphism, abstract vs interface, composition, encapsulation, nested classes)
- Collections framework (List, Set, Map, HashMap internals, ArrayList vs LinkedList, fail-fast)
- Concurrency & multithreading (synchronized, volatile, executors, CompletableFuture, ThreadLocal, Locks)
- JVM internals (memory model, garbage collection, class loading, JIT compiler)
- Streams & functional Java (lambdas, Stream API, Optional, Collectors, parallel streams)
- Spring Boot essentials (IoC/DI, beans, transactions, starters, auto-config, profiles, actuator, REST, exception handling, JPA, security, caching, async/scheduled, testing, AOP)
- Advanced (generics, serialization, design patterns, reflection, annotations)
Q: What is the difference between JDK, JRE, and JVM?
Answer:
This is the most common Java interview opener. These three form a layered architecture.
JVM (Java Virtual Machine)
The JVM is an abstract machine that provides the runtime environment to execute Java bytecode. It does NOT understand Java source code — only .class bytecode files.
Responsibilities:
- Loading bytecode (via ClassLoader)
- Verifying bytecode (bytecode verifier)
- Executing bytecode (interpreter + JIT compiler)
- Managing memory (heap, stack, garbage collection)
Key point: The JVM is what makes Java platform-independent. The same .class file runs on any OS that has a JVM implementation (Windows, macOS, Linux).
JRE (Java Runtime Environment)
The JRE = JVM + standard class libraries (java.lang, java.util, java.io, etc.). It's everything you need to run a Java application, but you cannot compile code with it.
JDK (Java Development Kit)
The JDK = JRE + development tools (javac compiler, javadoc, jdb debugger, jconsole, etc.). It's what developers install to develop and compile Java applications.
The Relationship
┌───────────────────────────────────┐
│ JDK │
│ ┌─────────────────────────────┐ │
│ │ JRE │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ JVM │ │ │
│ │ │ (bytecode execution) │ │ │
│ │ └───────────────────────┘ │ │
│ │ + Standard Libraries │ │
│ │ (rt.jar, java.*, etc.) │ │
│ └─────────────────────────────┘ │
│ + Development Tools │
│ (javac, javadoc, jar, jdb) │
└───────────────────────────────────┘
[!NOTE] Since Java 11, Oracle no longer ships a separate JRE. The JDK is the only downloadable package, and you can create custom minimal runtimes using
jlink.
Q: How does the String Pool work? Why are Strings immutable?
Answer:
String Pool (String Intern Pool)
The String Pool is a special memory region inside the heap (moved from PermGen to heap in Java 7) where Java caches string literals to save memory.
String a = "hello"; // Created in the String Pool
String b = "hello"; // Reuses the SAME object from the pool
String c = new String("hello"); // Creates a NEW object on the heap (outside pool)
System.out.println(a == b); // true (same reference in pool)
System.out.println(a == c); // false (different objects)
System.out.println(a.equals(c)); // true (same content)
You can explicitly add a string to the pool:
String d = c.intern(); // Returns the pooled reference
System.out.println(a == d); // true
Why Are Strings Immutable?
1. String Pool Requires It If strings were mutable, changing one reference would corrupt every other reference pointing to the same pooled object.
2. Thread Safety Immutable objects are inherently thread-safe. Multiple threads can share the same string without synchronization.
3. Security Strings are used for class loading, network connections, file paths, database URLs. If a string could be modified after creation, it would be a massive security hole.
4. hashCode Caching
Since a String's content never changes, its hashCode() is computed once and cached. This makes Strings extremely efficient as HashMap keys.
// String class internally:
public final class String {
private final char[] value; // final → cannot be reassigned
private int hash; // cached hashCode
}
Common Follow-Up: StringBuilder vs StringBuffer
| Feature | String | StringBuilder | StringBuffer |
|---|---|---|---|
| Mutability | Immutable | Mutable | Mutable |
| Thread-safe | Yes (immutable) | ❌ No | ✅ Yes (synchronized) |
| Performance | Slow for concatenation | Fast | Slower than StringBuilder |
| Use case | Constants, keys | Single-threaded string building | Multi-threaded string building |
// ❌ Bad: Creates a new String object each iteration
String result = "";
for (int i = 0; i < 1000; i++) {
result += i; // O(n²) — each += creates a new String
}
// ✅ Good: Modifies in-place
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append(i); // O(n) — appends to the same buffer
}
[!TIP] In modern Java (9+), the JIT compiler often optimizes string concatenation with
+intoStringBuilderorinvokedynamiccalls. But in loops, explicitStringBuilderis still the right approach.
Q: What is the contract between ==, .equals(), and hashCode()?
Answer:
== (Reference Equality)
Compares memory addresses. Returns true only if both variables point to the exact same object on the heap.
String a = new String("hello");
String b = new String("hello");
System.out.println(a == b); // false — different objects
.equals() (Content Equality)
Compares the logical content of two objects. The default implementation in Object uses ==, so you must override it in your classes.
System.out.println(a.equals(b)); // true — String overrides equals()
hashCode()
Returns an integer hash used by hash-based collections (HashMap, HashSet). Must be consistent with equals().
The Contract (Critical!)
- If
a.equals(b)istrue, thena.hashCode() == b.hashCode()MUST betrue. - If
a.hashCode() != b.hashCode(), thena.equals(b)MUST befalse. - If
a.hashCode() == b.hashCode(),a.equals(b)may or may not betrue(hash collisions are allowed).
What Happens If You Break the Contract?
// ❌ BROKEN: overrides equals() but NOT hashCode()
public class Employee {
private int id;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Employee)) return false;
Employee e = (Employee) o;
return id == e.id && Objects.equals(name, e.name);
}
// hashCode NOT overridden — uses default Object.hashCode() (memory address)
}
Employee e1 = new Employee(1, "Alice");
Employee e2 = new Employee(1, "Alice");
e1.equals(e2); // true ✅
Set<Employee> set = new HashSet<>();
set.add(e1);
set.contains(e2); // false! 💥 — different hashCode → looks in wrong bucket
Correct Implementation
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Employee e)) return false;
return id == e.id && Objects.equals(name, e.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name);
}
[!CAUTION] Always override
hashCode()when you overrideequals(). This is the #1 source of subtle bugs withHashMapandHashSet— objects that are logically equal but have different hash codes end up in different buckets and are treated as different entries.
Q: How does Exception Handling work in Java? Checked vs Unchecked?
Answer:
Exception Hierarchy
Throwable
/ \
Error Exception
/ \
Checked Exceptions RuntimeException (Unchecked)
(IOException, (NullPointerException,
SQLException) IllegalArgumentException,
ArrayIndexOutOfBoundsException)
Checked Exceptions
Checked at compile time. The compiler forces you to either catch them or declare them with throws. They represent recoverable conditions.
// Must handle or declare
public void readFile() throws IOException {
FileReader reader = new FileReader("data.txt"); // IOException is checked
}
Examples: IOException, SQLException, ClassNotFoundException
Unchecked Exceptions (RuntimeException)
NOT checked at compile time. They represent programming bugs that shouldn't be caught with a catch block — they should be fixed in the code.
String s = null;
s.length(); // NullPointerException — unchecked, no compile error
Examples: NullPointerException, ArrayIndexOutOfBoundsException, ClassCastException, IllegalArgumentException
Errors
Represent unrecoverable JVM-level problems. You should NOT catch these.
Examples: OutOfMemoryError, StackOverflowError, VirtualMachineError
try-with-resources (Java 7+)
Automatically closes resources that implement AutoCloseable:
// ❌ Old style: verbose, error-prone
BufferedReader reader = null;
try {
reader = new BufferedReader(new FileReader("file.txt"));
String line = reader.readLine();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (reader != null) {
try { reader.close(); } catch (IOException e) { /* swallowed */ }
}
}
// ✅ try-with-resources: auto-closes, clean
try (BufferedReader reader = new BufferedReader(new FileReader("file.txt"))) {
String line = reader.readLine();
} catch (IOException e) {
e.printStackTrace();
}
// reader.close() is called automatically, even if an exception occurs
Multi-catch (Java 7+)
try {
// ...
} catch (IOException | SQLException e) {
log.error("Failed", e);
}
[!TIP] In interviews, a strong take is: "I prefer unchecked exceptions for application-level errors with clear documentation, and checked exceptions only at API boundaries where the caller genuinely needs to handle the failure mode." This shows you understand the ongoing debate in the Java community about checked vs unchecked exception design.
Q: What is the difference between final, finally, and finalize?
Answer:
Despite the similar names, these are completely unrelated concepts.
final — A Keyword for Immutability/Restriction
1. final variable — Value cannot be changed after assignment (constant).
final int MAX = 100;
MAX = 200; // ❌ Compilation error
2. final method — Cannot be overridden by subclasses.
public class Parent {
public final void doWork() { /* cannot be overridden */ }
}
3. final class — Cannot be extended (no subclasses).
public final class String { /* no one can extend String */ }
4. final with references — The reference can't change, but the object it points to CAN.
final List<String> list = new ArrayList<>();
list.add("hello"); // ✅ Modifying the object is fine
list = new ArrayList<>(); // ❌ Reassigning the reference is not
finally — Exception Handling Block
A block that always executes after a try-catch, whether an exception occurred or not. Used for cleanup (closing resources, releasing locks).
try {
riskyOperation();
} catch (Exception e) {
log.error("Failed", e);
} finally {
connection.close(); // Always runs, even if exception is thrown
}
[!WARNING]
finallydoes NOT execute in two edge cases:
System.exit()is called in the try/catch block.- The JVM crashes or the thread is killed.
finalize() — Garbage Collection Hook (DEPRECATED)
A method called by the garbage collector before destroying an object. It was intended for cleanup of native resources.
@Override
protected void finalize() throws Throwable {
// Cleanup before GC — DON'T USE THIS
super.finalize();
}
Why it's deprecated (Java 9+):
- No guarantee when (or if) it will be called.
- Causes significant GC performance overhead.
- Objects can be "resurrected" in
finalize(), creating bugs. - Not a replacement for proper resource management.
Use instead: try-with-resources + AutoCloseable, or Cleaner (Java 9+).
Summary
| Concept | Type | Purpose |
|---|---|---|
final | Keyword | Prevent modification/inheritance |
finally | Block | Guaranteed cleanup after try-catch |
finalize() | Method (deprecated) | GC hook before object destruction |
Q: Explain autoboxing, unboxing, and the Integer cache.
Answer:
Autoboxing & Unboxing
- Autoboxing: automatic conversion of primitive → wrapper (
int→Integer). - Unboxing: wrapper → primitive (
Integer→int).
Integer boxed = 10; // autoboxing: Integer.valueOf(10)
int unboxed = boxed; // unboxing: boxed.intValue()
List<Integer> nums = new ArrayList<>();
nums.add(5); // autoboxing — primitive can't go in generic
int first = nums.get(0); // unboxing
The Integer Cache (-128 to 127)
Integer.valueOf(int) caches values in [-128, 127]. Same reference returned for cached values.
Integer a = 100;
Integer b = 100;
System.out.println(a == b); // true — same cached reference
Integer c = 200;
Integer d = 200;
System.out.println(c == d); // false — new objects, outside cache
System.out.println(c.equals(d)); // true — always use .equals() for wrappers
Cache upper bound configurable via
-XX:AutoBoxCacheMax=N.
Pitfalls
1. NullPointerException on unboxing
Integer x = null;
int y = x; // 💥 NPE — unboxing null
2. Performance — boxing in tight loops
Long sum = 0L; // ❌ Long, not long
for (long i = 0; i < 1_000_000; i++) {
sum += i; // boxes/unboxes every iteration
}
Use long primitive → 10x+ faster.
3. == vs .equals() on wrappers
Integer a = 1000, b = 1000;
if (a == b) { ... } // ❌ reference compare — false outside cache
if (a.equals(b)) { ... } // ✅ value compare
4. Conditional expression unboxing
Integer i = null;
int x = true ? i : 0; // 💥 NPE — ternary unboxes Integer
When Boxing Happens
- Generics:
List<Integer>,Map<String, Long> - Object params:
Object o = 5; - Collection ops:
set.contains(42) - Reflection:
method.invoke(obj, 1)— args areObject[]
Best Practices
- Primitives in hot paths.
- Wrappers only when nullability or generics needed.
- Always
.equals()for wrapper comparison. - Watch ternary + null returns for NPE.
Q: Is Java pass-by-value or pass-by-reference?
Answer:
Java is strictly pass-by-value. Always — even for objects.
In Java, everything is pass-by-value. No exceptions. What changes is what the value represents:
- For primitives (
int,double, etc.), the value is the actual data. - For objects, the value is a reference to the object.
So when you pass an object to a method, Java copies the reference (not the object itself). That means:
- You can mutate the object’s internal state inside the method.
- You cannot change which object the caller’s variable refers to.
Example
class Test {
int value;
}
void modify(Test obj) {
obj.value = 10; // affects original object
}
void reassign(Test obj) {
obj = new Test(); // does NOT affect original reference
}
Key takeaway
Java is pass-by-value; for objects, the value passed is a copy of the reference.
Primitives — Copy of Value
void increment(int x) { x++; }
int a = 5;
increment(a);
System.out.println(a); // 5 — caller unaffected
Objects — Copy of Reference
class Box { int val; }
void mutate(Box b) { b.val = 99; } // mutate via the copied reference
void reassign(Box b) { b = new Box(); } // reassign the local copy
Box box = new Box();
box.val = 1;
mutate(box);
System.out.println(box.val); // 99 — same object, mutated
reassign(box);
System.out.println(box.val); // 99 — local b reassigned, caller's reference unchanged
Mental Model
- Variable stores a value (primitive) or a reference (object handle).
- Method call copies that value/reference into a new local variable.
- Mutating object state through the copied reference is visible (same object).
- Reassigning the parameter to a new object is not visible (caller still holds original reference).
Why "pass-by-reference" Would Look Different
True pass-by-reference (C++ &, C# ref): reassigning the parameter would change the caller's variable.
void swap(Integer a, Integer b) {
Integer tmp = a; a = b; b = tmp;
}
Integer x = 1, y = 2;
swap(x, y);
// x=1, y=2 — Java can't swap. Pass-by-reference languages can.
String Trap
void change(String s) { s = "world"; }
String s = "hello";
change(s);
System.out.println(s); // hello — String is immutable + reference reassigned locally
Interview-Killer Phrasing
"Java is pass-by-value. For object types, the value of the reference is passed by value — a copy of the reference, not the object."
Q: What does the static keyword do? Where can it be used?
Answer:
static = belongs to the class, not to instances. One copy shared across all instances. Loaded when the class is loaded.
1. Static Variables (Class Variables)
class Counter {
static int count = 0; // shared across all instances
int id; // per instance
Counter() { id = ++count; }
}
new Counter(); // count=1
new Counter(); // count=2 — shared
2. Static Methods
class MathUtil {
static int square(int x) { return x * x; }
}
MathUtil.square(5); // call without instance
Rules:
- Cannot access instance fields/methods (no
this). - Cannot be overridden — hidden (resolved at compile time, not polymorphic).
- Can be called via instance, but discouraged:
obj.staticMethod().
3. Static Block
class Config {
static final Map<String, String> MAP;
static {
MAP = new HashMap<>();
MAP.put("env", "prod");
}
}
Runs once at class load. Multiple blocks run top-to-bottom.
4. Static Nested Class
class Outer {
static class Nested {
// does NOT hold reference to Outer instance
}
}
Outer.Nested n = new Outer.Nested();
vs. inner class (non-static) which holds implicit Outer.this reference.
5. Static Imports
import static java.lang.Math.PI;
import static java.lang.Math.sqrt;
double r = sqrt(PI); // no Math. prefix
Static Method Hiding vs Overriding
class Parent { static void hi() { System.out.println("parent"); } }
class Child extends Parent { static void hi() { System.out.println("child"); } }
Parent p = new Child();
p.hi(); // "parent" — static binding (hidden, not overridden)
Compare with instance methods — would print "child" (dynamic dispatch).
Common Pitfalls
- Mutable static state = global state = thread-safety nightmare.
- Static + Spring = bypass DI; static fields not injected by default.
- Memory leaks: static collections keep references alive for class lifetime.
- Test isolation: static state leaks across tests.
When to Use
- Constants (
public static final). - Pure utility functions (
Math.max,Collections.sort). - Factory methods (
List.of,Optional.of). - Singletons (carefully).
When to Avoid
- Anything stateful that's not a constant.
- Anything you want to mock in tests.
- Replace with dependency injection where possible.
Q: What is the difference between Abstract Class and Interface?
Answer:
This distinction has evolved significantly across Java versions. The modern answer is more nuanced than the textbook version.
Abstract Class
A class that cannot be instantiated and may contain both abstract (unimplemented) and concrete (implemented) methods. Represents an "is-a" relationship.
public abstract class Animal {
protected String name;
public Animal(String name) { this.name = name; } // ✅ Can have constructors
public abstract void makeSound(); // Must be implemented by subclasses
public void breathe() { // Concrete method — inherited as-is
System.out.println(name + " is breathing");
}
}
public class Dog extends Animal {
public Dog(String name) { super(name); }
@Override
public void makeSound() { System.out.println("Woof!"); }
}
Interface
A contract that defines what a class can do, without specifying how. Represents a "can-do" / "has-a-capability" relationship.
public interface Flyable {
void fly(); // implicitly public abstract
default void land() { // Default method (Java 8+)
System.out.println("Landing...");
}
static boolean canFly(Animal a) { // Static method (Java 8+)
return a instanceof Flyable;
}
}
public class Bird extends Animal implements Flyable {
public Bird(String name) { super(name); }
@Override public void makeSound() { System.out.println("Tweet!"); }
@Override public void fly() { System.out.println(name + " is flying"); }
}
Key Differences
| Feature | Abstract Class | Interface |
|---|---|---|
| Multiple inheritance | ❌ Single extends only | ✅ Multiple implements |
| Constructors | ✅ Yes | ❌ No |
| Instance fields | ✅ Yes (any access modifier) | Only public static final constants |
| Method types | Abstract + concrete | Abstract + default + static + private (Java 9+) |
| Access modifiers | Any (private, protected, etc.) | Methods are implicitly public |
| State | ✅ Can maintain state (fields) | ❌ No instance state |
When to Use Which?
- Abstract class: When subclasses share common state (fields) and behavior, and there's a clear "is-a" hierarchy (e.g.,
Vehicle→Car,Truck). - Interface: When unrelated classes need a shared capability (e.g.,
Comparable,Serializable,Flyable).
[!TIP] Since Java 8+, interfaces with
defaultmethods blurred the line significantly. The modern rule of thumb: prefer interfaces for defining contracts, and use abstract classes only when you need constructors, mutable instance fields, or non-public methods.
Q: Explain Polymorphism. What is the difference between Method Overloading and Overriding?
Answer:
Polymorphism = "many forms." It allows a single interface to represent different underlying types.
Compile-Time Polymorphism (Method Overloading)
Same method name, different parameter lists in the same class. Resolved at compile time by the compiler based on the method signature.
public class Calculator {
public int add(int a, int b) { return a + b; }
public double add(double a, double b) { return a + b; }
public int add(int a, int b, int c) { return a + b + c; }
}
Rules:
- Must differ in parameter count, type, or order.
- Return type alone is NOT sufficient to overload.
- Access modifiers can differ.
Runtime Polymorphism (Method Overriding)
Subclass provides a specific implementation of a method already defined in its parent class. Resolved at runtime via dynamic dispatch based on the actual object type.
public class Shape {
public double area() { return 0; }
}
public class Circle extends Shape {
private double radius;
@Override
public double area() { return Math.PI * radius * radius; }
}
public class Rectangle extends Shape {
private double width, height;
@Override
public double area() { return width * height; }
}
// Runtime polymorphism in action:
Shape shape = new Circle(5); // Reference type: Shape, Object type: Circle
shape.area(); // Calls Circle.area() — resolved at RUNTIME
Rules:
- Same method signature (name + parameters).
- Return type must be the same or a covariant (subclass) return type.
- Access modifier must be the same or less restrictive.
- Cannot override
static,final, orprivatemethods. - Must use
@Overrideannotation (not required but strongly recommended).
Overloading vs Overriding
| Feature | Overloading | Overriding |
|---|---|---|
| Where | Same class | Subclass |
| Method name | Same | Same |
| Parameters | Must differ | Must be identical |
| Return type | Can differ | Same or covariant |
| Resolved at | Compile time | Runtime |
| Polymorphism type | Static | Dynamic |
@Override | N/A | Yes |
[!IMPORTANT] The most common interview trick question: "Can you override a static method?" No. Static methods belong to the class, not the instance. You can hide a static method (by defining one with the same signature in a subclass), but this is method hiding, not overriding — there's no dynamic dispatch.
Q: Explain the SOLID Principles with Java examples.
Answer:
SOLID is a set of five design principles for writing maintainable, scalable object-oriented code.
S — Single Responsibility Principle
A class should have only one reason to change. It should do one thing and do it well.
// ❌ Violates SRP: handles both user logic AND email sending
public class UserService {
public void createUser(User user) { /* save to DB */ }
public void sendWelcomeEmail(User user) { /* send email */ }
}
// ✅ Follows SRP: each class has one responsibility
public class UserService {
public void createUser(User user) { /* save to DB */ }
}
public class EmailService {
public void sendWelcomeEmail(User user) { /* send email */ }
}
O — Open/Closed Principle
Classes should be open for extension, closed for modification. Add new behavior without changing existing code.
// ❌ Must modify this class every time a new shape is added
public double calculateArea(Shape shape) {
if (shape instanceof Circle) return Math.PI * ((Circle) shape).radius * ((Circle) shape).radius;
if (shape instanceof Rectangle) return ((Rectangle) shape).w * ((Rectangle) shape).h;
}
// ✅ Add new shapes by extending, not modifying
public abstract class Shape {
public abstract double area();
}
public class Circle extends Shape {
@Override public double area() { return Math.PI * radius * radius; }
}
// Adding a new shape doesn't touch existing code
L — Liskov Substitution Principle
Subtypes must be substitutable for their base types without breaking the program.
// ❌ Violates LSP: Square changes Rectangle's expected behavior
public class Rectangle {
public void setWidth(int w) { this.width = w; }
public void setHeight(int h) { this.height = h; }
}
public class Square extends Rectangle {
@Override public void setWidth(int w) { this.width = w; this.height = w; } // Surprise!
}
// Code expecting Rectangle behavior breaks:
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(10);
assert r.area() == 50; // FAILS! Square made it 100
I — Interface Segregation Principle
Clients should not be forced to depend on methods they don't use. Prefer small, focused interfaces.
// ❌ Fat interface: forces all implementations to handle everything
public interface Worker {
void work();
void eat();
void sleep();
}
// ✅ Segregated: each interface is focused
public interface Workable { void work(); }
public interface Feedable { void eat(); }
public class Robot implements Workable {
@Override public void work() { /* ... */ }
// Robot doesn't need eat() or sleep()
}
D — Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions.
// ❌ High-level depends directly on low-level
public class OrderService {
private MySQLOrderRepository repo = new MySQLOrderRepository(); // Tight coupling
}
// ✅ Both depend on abstraction
public interface OrderRepository { void save(Order order); }
public class OrderService {
private final OrderRepository repo; // Depends on interface
public OrderService(OrderRepository repo) { this.repo = repo; } // DI
}
[!TIP] In interviews, don't just list the principles — give examples of violations and their consequences. This shows you've actually used them in practice rather than memorizing definitions.
Q: Composition vs Inheritance — when to use which?
Answer:
Rule of thumb: "Favor composition over inheritance" (Effective Java, Item 18).
Inheritance (extends) | Composition (has-a) | |
|---|---|---|
| Relationship | "is-a" | "has-a" |
| Coupling | Tight — child depends on parent's impl | Loose — depends on interface |
| Flexibility | Fixed at compile time | Swap at runtime |
| Encapsulation | Breaks (subclass sees parent internals) | Preserves |
| Multiple types | Single inheritance only | Compose any number |
Inheritance Example (When It Goes Wrong)
// Classic broken example from Effective Java
class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c); // 💥 HashSet.addAll calls add() internally
}
}
// addCount double-counts because addAll → add → addCount++
Subclass broke when parent's internal call chain changed. Fragile base class problem.
Composition Fix
class InstrumentedSet<E> implements Set<E> {
private final Set<E> delegate; // composed, not extended
private int addCount = 0;
InstrumentedSet(Set<E> delegate) { this.delegate = delegate; }
@Override public boolean add(E e) {
addCount++;
return delegate.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return delegate.addAll(c); // delegate's internal calls don't hit our add()
}
// ... forward other Set methods
}
Works regardless of which Set impl is wrapped (HashSet, TreeSet, etc).
When Inheritance IS Right
- True "is-a" relationship (
Dog extends Animal). - Designed-for-extension classes (e.g.,
AbstractList). - Within your own controlled hierarchy.
- Template Method pattern.
When Composition Wins
- Reusing behavior across unrelated types.
- Need to swap implementations.
- Avoiding deep hierarchies.
- Multiple "behaviors" needed (Java has no multiple inheritance).
Strategy Pattern (Composition Done Right)
class PaymentService {
private final PaymentGateway gateway; // injected, swappable
PaymentService(PaymentGateway g) { this.gateway = g; }
void pay(Order o) { gateway.charge(o); }
}
// Swap Stripe ↔ PayPal without touching PaymentService
Liskov Substitution Test
If subclass can't fully replace parent without breaking behavior → don't inherit.
class Square extends Rectangle { ... } // ❌ violates LSP
// Setting width changes height — surprises code that expects Rectangle
Q: What is encapsulation? Why does it matter beyond "private fields + getters/setters"?
Answer:
Encapsulation = bundling state + behavior + hiding internal representation behind a stable interface. The point isn't "use private" — it's decouple callers from implementation so internals can change.
Naive "Encapsulation" (Anti-pattern)
class User {
private String name;
private List<Order> orders;
public String getName() { return name; }
public void setName(String n) { this.name = n; }
public List<Order> getOrders() { return orders; } // ❌ leaks mutable internal list
public void setOrders(List<Order> o) { this.orders = o; }
}
Caller can mutate getOrders().clear() — bypasses class. This is a public field with extra steps.
Real Encapsulation
public final class User {
private final String name;
private final List<Order> orders = new ArrayList<>();
public User(String name) { this.name = Objects.requireNonNull(name); }
public String name() { return name; }
public List<Order> orders() {
return Collections.unmodifiableList(orders); // defensive
}
public void placeOrder(Order o) { // behavior, not setter
if (orders.size() >= 100) throw new IllegalStateException("max orders");
orders.add(o);
}
}
Principles
- Hide state. Fields private; never expose mutable internals.
- Defensive copies for mutable inputs/outputs (or use immutable types).
- Validate at boundaries. Reject bad input in constructor/method.
- Behavior over setters.
placeOrder()notsetOrders()— captures invariants. - Minimal API. Only expose what callers need.
Why It Matters
- Refactor safely. Change
ordersfromListtoLinkedHashSetwithout breaking callers. - Invariants hold. "Max 100 orders" enforced — caller can't break it.
- Concurrency. Internal state can be made thread-safe in one place.
- Testability. Behavior methods describe domain rules; tests assert behavior, not field values.
Java 16+ Records
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount);
if (amount.signum() < 0) throw new IllegalArgumentException("negative");
}
}
Compact, immutable, validated. Fits encapsulation goals for value types.
Encapsulation vs Information Hiding
- Encapsulation: bundling state + behavior in one unit.
- Information hiding: design decisions hidden behind interfaces.
- Java's
privateenforces both. Modules (module-info.java, JPMS) extend it across packages.
Q: Explain the four kinds of nested classes in Java.
Answer:
| Type | Static? | Holds outer ref? | Use case |
|---|---|---|---|
| Static nested | yes | no | Logical grouping; helper attached to enclosing class |
| Inner (member) | no | yes | Tightly bound to outer instance |
| Local | no | yes (if in instance method) | One-method-only helper |
| Anonymous | no | yes | One-shot interface/abstract impl |
1. Static Nested
class Outer {
static class Builder {
Outer build() { return new Outer(); }
}
}
Outer o = new Outer.Builder().build();
No outer instance needed. Same as a top-level class but namespaced.
2. Inner (Member) Class
class Outer {
private int x = 10;
class Inner {
int read() { return x; } // implicit Outer.this reference
}
}
Outer.Inner i = new Outer().new Inner(); // needs outer instance
[!WARNING] Inner classes hold a hidden reference to the outer instance — common cause of memory leaks (e.g., non-static
HandlerholdingActivityin Android, or non-static inner classes referenced by long-lived collections).
3. Local Class (Inside Method)
void process(List<String> items) {
class LengthFilter {
boolean keep(String s) { return s.length() > 3; }
}
LengthFilter f = new LengthFilter();
items.removeIf(s -> !f.keep(s));
}
Captures effectively final local variables.
4. Anonymous Class
Runnable r = new Runnable() {
@Override public void run() { System.out.println("hi"); }
};
Mostly replaced by lambdas (Java 8+) for single-method interfaces:
Runnable r = () -> System.out.println("hi");
Lambdas vs Anonymous Classes
| Lambda | Anonymous class | |
|---|---|---|
this refers to | enclosing class | the anonymous instance |
| Compiled to | invokedynamic / synthetic method | new .class file |
| Can hold state | no | yes (instance fields) |
| Multiple methods | no (single abstract method only) | yes |
Effectively Final Capture
void demo() {
int x = 10;
Runnable r = () -> System.out.println(x); // ✅ x not reassigned
// x = 20; ← would break the lambda
}
Memory Leak Example
class Repository {
private List<Listener> listeners = new ArrayList<>();
void register() {
listeners.add(new Listener() { // anonymous → holds Repository.this
public void onEvent() { ... }
});
}
}
// Listener pinned in `listeners` → Repository instance never GC'd if list outlives it.
Fix: make it static nested, or store no reference, or use weak refs.
Q: What is the difference between List, Set, and Map?
Answer:
These are the three core interfaces of the Java Collections Framework.
List — Ordered, Duplicates Allowed
An ordered collection (sequence). Elements are indexed by position and maintain insertion order. Duplicates are allowed.
List<String> list = new ArrayList<>();
list.add("Alice");
list.add("Bob");
list.add("Alice"); // ✅ Duplicates allowed
list.get(0); // "Alice" — indexed access
// [Alice, Bob, Alice]
| Implementation | Backed By | Access | Insert/Delete | Use Case |
|---|---|---|---|---|
ArrayList | Dynamic array | O(1) random | O(n) middle | Default choice, random access |
LinkedList | Doubly-linked list | O(n) | O(1) at head/tail | Frequent insert/remove, queues |
CopyOnWriteArrayList | Array (copy on write) | O(1) | O(n) copies | Multi-threaded reads, rare writes |
Set — No Duplicates
An unordered collection (by default) that does not allow duplicate elements.
Set<String> set = new HashSet<>();
set.add("Alice");
set.add("Bob");
set.add("Alice"); // ❌ Ignored — already exists
// [Bob, Alice] — no guaranteed order
| Implementation | Ordered? | Sorted? | Performance | Use Case |
|---|---|---|---|---|
HashSet | ❌ | ❌ | O(1) add/contains | Default choice |
LinkedHashSet | ✅ Insertion order | ❌ | O(1) | When order matters |
TreeSet | ✅ Sorted order | ✅ | O(log n) | Sorted, range queries |
Map — Key-Value Pairs
Stores key-value pairs. Keys must be unique; values can be duplicated.
Map<String, Integer> map = new HashMap<>();
map.put("Alice", 90);
map.put("Bob", 85);
map.put("Alice", 95); // Overwrites previous value for "Alice"
map.get("Alice"); // 95
| Implementation | Ordered? | Sorted? | Thread-safe? | Use Case |
|---|---|---|---|---|
HashMap | ❌ | ❌ | ❌ | Default choice |
LinkedHashMap | ✅ Insertion order | ❌ | ❌ | LRU cache, ordered iteration |
TreeMap | ✅ Sorted by key | ✅ | ❌ | Sorted keys, range queries |
ConcurrentHashMap | ❌ | ❌ | ✅ | Multi-threaded access |
Quick Decision Guide
Need indexed access? → List (ArrayList)
Need uniqueness? → Set (HashSet)
Need key-value lookup? → Map (HashMap)
Need ordered + unique? → LinkedHashSet or TreeSet
Need sorted + key-value? → TreeMap
Need thread-safe map? → ConcurrentHashMap
Q: How does HashMap work internally in Java?
Answer:
This is one of the most asked Java interview questions. Understanding HashMap internals demonstrates deep knowledge of data structures.
Structure (Java 8+)
A HashMap is internally an array of buckets (called Node<K,V>[] table). Each bucket can hold a linked list or a red-black tree of entries.
HashMap table (initial capacity = 16):
Index: [0] [1] [2] [3] [4] [5] ... [15]
│
Node("Alice", 90)
│
Node("Bob", 85) ← collision: same bucket
put() Operation — Step by Step
map.put("Alice", 90);
- Compute hash:
hash = key.hashCode()→ apply a bit-spreading function to reduce collisions. - Find bucket index:
index = hash & (table.length - 1)(bitwise AND, equivalent tohash % capacity). - Check bucket:
- If bucket is empty → create a new
Nodeand place it there. - If bucket is occupied → traverse the linked list:
- If a key with
.equals()match is found → replace the value. - If no match → append a new node at the end.
- If a key with
- If bucket is empty → create a new
- Treeify: If a bucket's linked list exceeds 8 nodes (and table size ≥ 64), convert it to a red-black tree for O(log n) lookup instead of O(n).
get() Operation
map.get("Alice");
- Compute
hash("Alice"). - Find bucket:
index = hash & (table.length - 1). - Traverse the bucket (linked list or tree) comparing with
.equals(). - Return the value if found,
nullotherwise.
Resizing (Rehashing)
When the number of entries exceeds capacity × loadFactor (default: 16 × 0.75 = 12), the table doubles in size (16 → 32) and ALL entries are rehashed into new bucket positions.
new HashMap<>(initialCapacity, loadFactor);
// Default: capacity=16, loadFactor=0.75
Why Load Factor Matters
- Low load factor (0.5): More empty buckets → fewer collisions → faster lookups → more memory.
- High load factor (1.0): More collisions → slower lookups → less memory.
- Default (0.75): Good balance between time and space.
Java 8+ Optimization: Treeification
| Bucket size | Structure | Lookup |
|---|---|---|
| ≤ 8 nodes | Linked list | O(n) |
| > 8 nodes | Red-black tree | O(log n) |
| ≤ 6 nodes (after deletion) | Untreeified back to list | O(n) |
[!CAUTION] Mutable keys break HashMap. If you use a mutable object as a HashMap key and modify it after insertion, its
hashCode()changes, but the entry stays in the old bucket. It becomes unreachable — a silent memory leak. Always use immutable objects (String, Integer, records) as keys.
Q: What is the difference between ConcurrentHashMap, Hashtable, and Collections.synchronizedMap?
Answer:
All three provide thread-safe Map implementations, but with very different performance characteristics.
Hashtable (Legacy — Don't Use)
The original thread-safe Map. Every method is synchronized, meaning the entire map is locked for every operation.
Hashtable<String, Integer> table = new Hashtable<>();
table.put("key", 1); // Locks the ENTIRE table
Problems: Only one thread can access the map at a time → massive bottleneck. Also, null keys/values are not allowed.
Collections.synchronizedMap()
A wrapper that adds synchronized to every method of any Map. Same locking strategy as Hashtable.
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
Problem: Still uses a single lock for the entire map. Must also manually synchronize iterations:
synchronized (map) { // Manual sync required!
for (Map.Entry<String, Integer> e : map.entrySet()) { ... }
}
ConcurrentHashMap (Use This!)
Uses fine-grained locking (segment-level in Java 7, node-level + CAS in Java 8+). Multiple threads can read and write to different segments simultaneously.
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("key", 1); // Only locks the specific bucket, not the whole map
Performance Comparison
| Feature | Hashtable | synchronizedMap | ConcurrentHashMap |
|---|---|---|---|
| Locking | Entire map | Entire map | Per-bucket / CAS |
| Concurrent reads | ❌ Blocked | ❌ Blocked | ✅ Lock-free |
| Concurrent writes | ❌ Sequential | ❌ Sequential | ✅ Parallel (different buckets) |
| Null keys/values | ❌ | ✅ (if wrapped map allows) | ❌ |
| Iteration safety | Fail-fast (throws CME) | Fail-fast (manual sync needed) | Weakly consistent (no CME) |
| Performance | Poor | Poor | Excellent |
ConcurrentHashMap Atomic Operations
// Atomic compute-if-absent (avoids check-then-act race)
map.computeIfAbsent("counter", k -> 0);
// Atomic merge
map.merge("counter", 1, Integer::sum);
// Atomic replace
map.replace("key", oldValue, newValue);
[!TIP] The only reason to use
Collections.synchronizedMapis if you need a thread-safeTreeMaporLinkedHashMap(sinceConcurrentHashMapdoesn't support ordering). For all other cases, always useConcurrentHashMap.
Q: What is the difference between Comparable and Comparator?
Answer:
Both are used for sorting objects, but they serve different purposes.
Comparable — Natural Ordering (Built-In)
The class itself implements Comparable<T> and defines its natural ordering via compareTo(). There is only one natural ordering per class.
public class Employee implements Comparable<Employee> {
private int id;
private String name;
@Override
public int compareTo(Employee other) {
return Integer.compare(this.id, other.id); // Natural order: by ID
}
}
List<Employee> employees = new ArrayList<>();
Collections.sort(employees); // Uses compareTo() — sorts by ID
Comparator — Custom Ordering (External)
A separate functional interface that defines a custom ordering without modifying the class. You can have multiple comparators for different sort criteria.
// Sort by name
Comparator<Employee> byName = Comparator.comparing(Employee::getName);
// Sort by salary descending, then by name ascending
Comparator<Employee> bySalaryDesc = Comparator.comparing(Employee::getSalary).reversed()
.thenComparing(Employee::getName);
employees.sort(byName);
employees.sort(bySalaryDesc);
Key Differences
| Feature | Comparable | Comparator |
|---|---|---|
| Package | java.lang | java.util |
| Method | compareTo(T other) | compare(T a, T b) |
| Modifies class? | ✅ Yes (class implements it) | ❌ No (external) |
| # of orderings | 1 (natural order) | Unlimited |
| Usage | Collections.sort(list) | Collections.sort(list, comparator) |
| Functional interface? | ❌ | ✅ (can use lambdas) |
Modern Java Comparator Utilities
// Null-safe comparisons
Comparator.nullsFirst(Comparator.comparing(Employee::getName))
// Chaining
Comparator.comparing(Employee::getDepartment)
.thenComparing(Employee::getSalary)
.reversed()
// Lambda shorthand
employees.sort((a, b) -> a.getName().compareTo(b.getName()));
[!TIP] Rule of thumb: Implement
Comparablefor the one obvious natural ordering (e.g., alphabetical for String, numeric for Integer). UseComparatorfor any alternative orderings (e.g., sort employees by salary, by department, etc.).
Q: ArrayList vs LinkedList — when to use which?
Answer:
Short answer: use ArrayList 99% of the time. LinkedList rarely wins in practice despite textbook claims.
| Op | ArrayList | LinkedList |
|---|---|---|
get(i) | O(1) | O(n) — walk from head/tail |
add(e) (end) | Amortized O(1) | O(1) |
add(0, e) (front) | O(n) — shift all | O(1) |
add(i, e) (middle) | O(n) — shift | O(n) — walk + insert |
remove(i) | O(n) — shift | O(n) — walk |
iterator.remove() | O(n) — shift remaining | O(1) |
| Memory per element | 4–8 bytes (array slot) | ~40 bytes (node + 2 refs) |
| Cache locality | excellent (contiguous) | poor (scattered nodes) |
Why ArrayList Usually Wins Even For "LinkedList Cases"
Modern CPUs love contiguous memory. ArrayList shifts cost less than LinkedList pointer chasing because of cache lines + branch prediction. Bjarne Stroustrup's famous talk: linked lists lose for sizes < 100k even on insertion-heavy workloads.
Internals
ArrayList:
Object[] elementData; // backing array
int size;
// add(): resize when full → Arrays.copyOf with 1.5x growth
LinkedList:
class Node<E> {
E item;
Node<E> next;
Node<E> prev; // doubly-linked
}
Implements both List and Deque.
Choose LinkedList When
- Heavy use as a queue/deque (
addFirst,removeFirst,pollLast). - Frequent insertions/removals via iterator (
O(1)). - Need a deque but
ArrayDequedoesn't fit (rare).
Choose ArrayList When
- Random access by index.
- Iteration-heavy.
- Default choice unless profiling proves otherwise.
Better Alternatives
- Need a deque? →
ArrayDequebeatsLinkedList. - Need fixed-size? →
Arrays.asList(...)orList.of(...). - Need thread-safe? →
CopyOnWriteArrayList(read-heavy) or wrap withCollections.synchronizedList.
Common Pitfall
List<Integer> list = new LinkedList<>();
for (int i = 0; i < list.size(); i++) {
list.get(i); // O(n) per call → O(n²) total
}
// Use iterator or for-each instead → O(n)
Capacity Hint
new ArrayList<>(10_000); // pre-size if you know the count
// Avoids ~14 resize+copy cycles when growing from 10 → 10,000
Q: Explain fail-fast vs fail-safe iterators. What is ConcurrentModificationException?
Answer:
Fail-Fast
Detect concurrent modification → throw ConcurrentModificationException immediately.
List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
for (int x : list) {
if (x == 2) list.remove(Integer.valueOf(x)); // 💥 CME on next iteration
}
How it works: collection has modCount. Iterator captures expectedModCount at creation. On every next(), checks modCount == expectedModCount. Mismatch → CME.
// ArrayList.Itr#next()
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
[!IMPORTANT] CME is not a guarantee — it's best-effort. Don't rely on it for thread safety. It's a debugging aid for incorrect single-threaded code.
Fail-Safe
Iterate over a snapshot or copy → no CME, but you may see stale data.
List<Integer> list = new CopyOnWriteArrayList<>(List.of(1, 2, 3));
for (int x : list) {
if (x == 2) list.remove(Integer.valueOf(x)); // ✅ no CME
// iterator sees the old snapshot — won't see the removal
}
Fail-Fast Collections
ArrayList,LinkedList,HashMap,HashSet,TreeMap,TreeSet,Vector(mostly),Hashtable(mostly).
Fail-Safe Collections
CopyOnWriteArrayList,CopyOnWriteArraySet— full snapshot on write.ConcurrentHashMap— weakly consistent iterator (sees some, not all, concurrent updates without CME).
Correct Removal During Iteration
// ❌ Wrong
for (String s : list) {
if (s.startsWith("x")) list.remove(s); // CME
}
// ✅ Iterator.remove()
Iterator<String> it = list.iterator();
while (it.hasNext()) {
if (it.next().startsWith("x")) it.remove();
}
// ✅ Java 8+
list.removeIf(s -> s.startsWith("x"));
Why iterator.remove() Works
It updates expectedModCount along with modCount. Other methods (list.remove()) bump modCount only.
Map Iteration Pitfall
Map<String, Integer> map = new HashMap<>();
for (var e : map.entrySet()) {
map.put("new", 1); // 💥 CME
}
Use:
map.entrySet().removeIf(e -> e.getValue() < 0);
// or replaceAll for value updates
map.replaceAll((k, v) -> v * 2);
Weakly Consistent (ConcurrentHashMap)
- No CME ever.
- Iterator may reflect updates after creation, may not.
- Safe across threads, but iteration is not a snapshot.
Q: What is the difference between Thread, Runnable, and Callable?
Answer:
Thread (Class)
The most basic way to create a thread. Extend the Thread class and override run().
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("Running in: " + Thread.currentThread().getName());
}
}
MyThread t = new MyThread();
t.start(); // start(), NOT run() — run() doesn't create a new thread
Downside: Java doesn't support multiple inheritance. If your class already extends something, you can't extend Thread.
Runnable (Interface) — Preferred
A functional interface with a single run() method. Decouples the task from the thread.
Runnable task = () -> System.out.println("Running in: " + Thread.currentThread().getName());
Thread t = new Thread(task);
t.start();
// Or with ExecutorService (better):
ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(task);
Callable (Interface) — Returns a Result
Like Runnable, but the call() method returns a value and can throw checked exceptions.
Callable<Integer> task = () -> {
Thread.sleep(1000);
return 42; // Returns a result
};
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(task);
Integer result = future.get(); // Blocks until result is ready → 42
Key Differences
| Feature | Thread | Runnable | Callable |
|---|---|---|---|
| Type | Class | Functional Interface | Functional Interface |
| Method | run() | run() | call() |
| Return value | ❌ void | ❌ void | ✅ Returns V |
| Checked exceptions | ❌ No | ❌ No | ✅ Yes |
| Result handling | N/A | N/A | Via Future<V> |
| Use with Executor | ❌ Not recommended | ✅ submit(runnable) | ✅ submit(callable) |
When to Use What?
Simple fire-and-forget task? → Runnable + ExecutorService
Need a result from the task? → Callable + Future
Need exception propagation? → Callable
Extending Thread directly? → Almost never. Use Runnable.
[!IMPORTANT] Never extend
Threaddirectly in modern Java. UseRunnableorCallablewith anExecutorService. Direct thread management (creating, starting, joining threads manually) is error-prone and doesn't scale. Thread pools handle lifecycle, reuse, and scheduling for you.
Q: What are synchronized, volatile, and Atomic classes?
Answer:
These are Java's three primary mechanisms for thread safety.
synchronized — Mutual Exclusion (Locking)
Ensures only one thread can execute a block of code at a time by acquiring a monitor lock.
// Synchronized method — locks on `this`
public synchronized void increment() {
count++;
}
// Synchronized block — locks on a specific object
public void increment() {
synchronized (this) {
count++;
}
}
// Static synchronized — locks on the Class object
public static synchronized void staticMethod() { }
What synchronized guarantees:
- Mutual exclusion — only one thread enters the critical section.
- Visibility — changes made inside the block are visible to other threads when the lock is released.
volatile — Visibility (No Locking)
Ensures that reads and writes to a variable go directly to main memory, not a thread-local CPU cache. It guarantees visibility but NOT atomicity.
private volatile boolean running = true;
// Thread 1
public void run() {
while (running) { // Always reads from main memory
doWork();
}
}
// Thread 2
public void stop() {
running = false; // Written to main memory immediately
}
When to use volatile:
- Simple flags (boolean stop/running flags).
- A variable written by one thread and read by many.
- NOT suitable for compound operations like
count++(read + modify + write is not atomic).
Atomic Classes — Lock-Free Thread Safety
The java.util.concurrent.atomic package provides classes that use CAS (Compare-And-Swap) CPU instructions for lock-free atomic operations.
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // Atomic: read + increment + write
counter.compareAndSet(5, 10); // Atomic: if value is 5, set to 10
counter.addAndGet(3); // Atomic: add 3 and return new value
Comparison
| Feature | synchronized | volatile | Atomic |
|---|---|---|---|
| Atomicity | ✅ Yes (whole block) | ❌ No | ✅ Yes (single operation) |
| Visibility | ✅ Yes | ✅ Yes | ✅ Yes |
| Blocking | ✅ Yes (acquires lock) | ❌ No | ❌ No (CAS spin) |
| Performance | Slowest (lock contention) | Fast | Fast (no locks) |
| Use case | Complex multi-step operations | Simple flags | Counters, accumulators |
[!CAUTION]
volatiledoes NOT makecount++thread-safe!count++is actually three operations: read, increment, write. Between the read and write, another thread can intervene. UseAtomicInteger.incrementAndGet()instead.
Q: How does ExecutorService and Thread Pools work?
Answer:
Why Thread Pools?
Creating a new Thread for every task is expensive (OS thread creation, memory allocation). Thread pools reuse a fixed set of threads to execute many tasks.
ExecutorService
The ExecutorService interface is the standard API for managing thread pools in Java.
// Fixed-size pool: always 4 threads
ExecutorService pool = Executors.newFixedThreadPool(4);
// Cached pool: creates threads on-demand, reuses idle ones
ExecutorService pool = Executors.newCachedThreadPool();
// Single thread: tasks execute sequentially in one thread
ExecutorService pool = Executors.newSingleThreadExecutor();
// Scheduled: run tasks after a delay or periodically
ScheduledExecutorService pool = Executors.newScheduledThreadPool(2);
Submitting Tasks
ExecutorService pool = Executors.newFixedThreadPool(4);
// Fire-and-forget (Runnable)
pool.submit(() -> System.out.println("Task 1"));
// Get a result (Callable)
Future<Integer> future = pool.submit(() -> computeExpensiveThing());
Integer result = future.get(); // Blocks until done
// Shutdown
pool.shutdown(); // Finish current tasks, reject new ones
pool.shutdownNow(); // Interrupt running tasks immediately
pool.awaitTermination(5, TimeUnit.SECONDS); // Wait for completion
ThreadPoolExecutor (Full Control)
The Executors factory methods are shortcuts. For production, configure ThreadPoolExecutor directly:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // corePoolSize: minimum threads kept alive
8, // maxPoolSize: maximum threads allowed
60, TimeUnit.SECONDS, // keepAliveTime for idle threads above core
new ArrayBlockingQueue<>(100), // work queue capacity
new ThreadPoolExecutor.CallerRunsPolicy() // rejection policy
);
Rejection Policies
When both the thread pool and work queue are full:
| Policy | Behavior |
|---|---|
AbortPolicy (default) | Throws RejectedExecutionException |
CallerRunsPolicy | The calling thread runs the task (backpressure) |
DiscardPolicy | Silently discards the task |
DiscardOldestPolicy | Discards the oldest queued task |
[!WARNING] Avoid
Executors.newCachedThreadPool()in production unless you're sure about your workload. It creates an unbounded number of threads. A sudden traffic spike can spawn thousands of threads, exhausting memory and crashing the JVM. Always useThreadPoolExecutorwith explicit bounds.
Q: What is CompletableFuture and how does it compare to Future?
Answer:
The Problem with Future
Future is blocking. future.get() blocks the calling thread until the result is ready. There's no way to chain tasks, combine results, or handle errors without blocking.
Future<Integer> future = executor.submit(() -> expensiveComputation());
Integer result = future.get(); // 😴 Thread is BLOCKED here, doing nothing
CompletableFuture — Non-Blocking, Composable
CompletableFuture (Java 8+) is a powerful asynchronous programming API that supports non-blocking callbacks, chaining, combining, and error handling.
CompletableFuture.supplyAsync(() -> fetchUserFromDB(userId)) // Async task
.thenApply(user -> enrichWithProfile(user)) // Transform result
.thenAccept(user -> sendWelcomeEmail(user)) // Consume result
.exceptionally(ex -> { log.error("Failed", ex); return null; }); // Handle errors
// No blocking! Everything runs asynchronously.
Key Operations
1. Creating:
CompletableFuture.supplyAsync(() -> "result"); // Returns a value
CompletableFuture.runAsync(() -> doSomething()); // Void (no return)
2. Transforming (thenApply = map):
CompletableFuture<String> name =
CompletableFuture.supplyAsync(() -> getUser(1))
.thenApply(User::getName);
3. Consuming (thenAccept):
future.thenAccept(result -> System.out.println("Got: " + result));
4. Chaining (thenCompose = flatMap):
CompletableFuture<Order> order =
getUserAsync(1)
.thenCompose(user -> getOrdersAsync(user.getId())); // Returns another CF
5. Combining two futures:
CompletableFuture<String> userFuture = getUserAsync();
CompletableFuture<List<Order>> ordersFuture = getOrdersAsync();
CompletableFuture<String> combined = userFuture.thenCombine(ordersFuture,
(user, orders) -> user.getName() + " has " + orders.size() + " orders");
6. Waiting for all / any:
CompletableFuture.allOf(future1, future2, future3).join(); // Wait for ALL
CompletableFuture.anyOf(future1, future2, future3).join(); // Wait for FIRST
Error Handling
CompletableFuture.supplyAsync(() -> riskyOperation())
.thenApply(result -> process(result))
.exceptionally(ex -> {
log.error("Failed", ex);
return fallbackValue; // Recover with a default
})
.handle((result, ex) -> { // Access both result and exception
if (ex != null) return "error";
return result;
});
Future vs CompletableFuture
| Feature | Future | CompletableFuture |
|---|---|---|
| Blocking | ✅ get() blocks | ❌ Callbacks are non-blocking |
| Chaining | ❌ | ✅ thenApply, thenCompose |
| Combining | ❌ | ✅ thenCombine, allOf, anyOf |
| Error handling | Try-catch around get() | exceptionally(), handle() |
| Manual completion | ❌ | ✅ complete(), completeExceptionally() |
[!TIP] Think of
CompletableFutureas Java's equivalent of JavaScriptPromise.thenApply=.then(),thenCompose=.then()that returns another Promise,exceptionally=.catch().
Q: What is a Deadlock? What about Livelock and Starvation?
Answer:
Deadlock
Two or more threads are permanently blocked, each waiting for a lock held by the other.
Object lockA = new Object();
Object lockB = new Object();
// Thread 1: acquires lockA, then waits for lockB
new Thread(() -> {
synchronized (lockA) {
Thread.sleep(100);
synchronized (lockB) { System.out.println("Thread 1"); }
}
}).start();
// Thread 2: acquires lockB, then waits for lockA
new Thread(() -> {
synchronized (lockB) {
Thread.sleep(100);
synchronized (lockA) { System.out.println("Thread 2"); }
}
}).start();
// 💀 DEADLOCK: Thread 1 holds lockA, waits for lockB.
// Thread 2 holds lockB, waits for lockA.
// Neither can proceed.
Four conditions for deadlock (ALL must be present):
- Mutual exclusion — resources can't be shared.
- Hold and wait — thread holds one lock while waiting for another.
- No preemption — locks can't be forcibly taken away.
- Circular wait — A waits for B, B waits for A.
Prevention — consistent lock ordering:
// ✅ Both threads acquire locks in the SAME order
synchronized (lockA) {
synchronized (lockB) { /* safe */ }
}
Livelock
Threads are not blocked — they keep actively responding to each other but make no progress. Like two people in a hallway both stepping aside in the same direction.
Thread 1: "You go first" → releases lock
Thread 2: "No, you go first" → releases lock
Thread 1: "No really, you go first" → releases lock
... forever
Solution: Add randomized backoff or priority.
Starvation
A thread is perpetually denied access to a resource because other higher-priority threads monopolize it.
Example: Using synchronized with no fairness guarantee. High-priority threads keep acquiring the lock, and a low-priority thread never gets its turn.
Solution: Use ReentrantLock(true) with fair ordering:
ReentrantLock lock = new ReentrantLock(true); // Fair lock: FIFO ordering
Summary
| Problem | Threads Blocked? | Making Progress? | Solution |
|---|---|---|---|
| Deadlock | ✅ Yes | ❌ No | Consistent lock ordering, timeout |
| Livelock | ❌ No (active) | ❌ No (repeating same actions) | Random backoff, priority |
| Starvation | ❌ Partially | ❌ One thread starved | Fair locks, priority adjustment |
[!TIP] In production, use
jstack <pid>orThread.getAllStackTraces()to detect deadlocks. JVM thread dumps show "Found one Java-level deadlock" with the exact threads and locks involved.
Q: What is ThreadLocal? Why is it dangerous in thread pools?
Answer:
ThreadLocal<T> = per-thread variable. Each thread gets its own independent copy. Reads/writes never see other threads' values.
Mental Model
Internally each Thread has a ThreadLocalMap. ThreadLocal is the key, value is per-thread.
Common Use Cases
- Per-request context: user/tenant/correlation ID propagated through call stack.
- Non-thread-safe utilities:
SimpleDateFormat(notoriously not thread-safe). - Transaction/session context (Spring uses ThreadLocal heavily).
private static final ThreadLocal<SimpleDateFormat> FMT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
String today() { return FMT.get().format(new Date()); }
Set / Get / Remove
ThreadLocal<String> userId = new ThreadLocal<>();
userId.set("u-123");
userId.get(); // "u-123" — only on this thread
userId.remove(); // clear
The Thread Pool Problem
Thread pools reuse threads. If you set() on a pooled thread and don't remove(), the next task on that thread inherits the stale value.
ExecutorService pool = Executors.newFixedThreadPool(4);
ThreadLocal<String> tenant = new ThreadLocal<>();
pool.submit(() -> {
tenant.set("acme");
doWork();
// forgot tenant.remove() ⚠️
});
pool.submit(() -> {
System.out.println(tenant.get()); // "acme" — leaked from previous task!
});
Memory Leak
ThreadLocalMap keys are weak references to the ThreadLocal object. Values are strong references. If the ThreadLocal becomes unreachable but the thread lives on (pool!), the value stays in the map forever until the slot is cleared lazily.
Always Use Try/Finally
public Object handle(Request r) {
tenant.set(r.tenantId());
try {
return processor.run(r);
} finally {
tenant.remove(); // critical for pooled threads
}
}
Spring's MDC / RequestContextHolder
Spring/SLF4J's MDC and RequestContextHolder use ThreadLocal under the hood. Filter clears them after each request — that's why filters wrap chain calls in try/finally.
InheritableThreadLocal
Child thread inherits parent's value at thread creation. Doesn't propagate to thread pool tasks (pool threads created at startup, not per-task).
Modern Alternative: ScopedValue (Java 21+)
final static ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "u-123").run(() -> {
// USER.get() == "u-123" only inside this run
});
// outside the run() — USER not bound
Immutable, no leak risk, designed for virtual threads.
Virtual Threads + ThreadLocal
Virtual threads are cheap (millions). Each carries its own ThreadLocalMap → memory pressure. Prefer ScopedValue in virtual-thread code.
Q: synchronized vs ReentrantLock vs ReadWriteLock vs StampedLock?
Answer:
| Lock | Reentrant | Try/Timeout | Fair | Read/Write split | Condition vars |
|---|---|---|---|---|---|
synchronized | yes | no | no | no | one (wait/notify) |
ReentrantLock | yes | yes | configurable | no | multiple |
ReentrantReadWriteLock | yes | yes | configurable | yes | yes |
StampedLock | no | yes | no | yes (+ optimistic) | no |
synchronized (Intrinsic Lock)
synchronized (lock) {
// critical section
}
- Built into JVM, automatic release on exception/return.
- No timeout, no interruptibility, no try-lock.
- JVM optimizes (biased locking until JDK 15, lock coarsening, escape analysis).
ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
public void op() {
lock.lock();
try {
// critical section
} finally {
lock.unlock(); // ⚠️ must be in finally
}
}
Capabilities:
lock.tryLock() // non-blocking attempt
lock.tryLock(500, TimeUnit.MILLISECONDS) // bounded wait
lock.lockInterruptibly() // respond to interrupt
new ReentrantLock(true) // fair (FIFO) — slower
Conditions (Multiple Wait Queues)
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
void put(E e) throws InterruptedException {
lock.lock();
try {
while (queue.isFull()) notFull.await();
queue.add(e);
notEmpty.signal();
} finally { lock.unlock(); }
}
With synchronized you only get one wait set per object.
ReentrantReadWriteLock
Many readers OR one writer. Use when reads dominate writes.
ReadWriteLock rw = new ReentrantReadWriteLock();
V get(K k) {
rw.readLock().lock();
try { return map.get(k); }
finally { rw.readLock().unlock(); }
}
void put(K k, V v) {
rw.writeLock().lock();
try { map.put(k, v); }
finally { rw.writeLock().unlock(); }
}
[!WARNING] Read locks don't block other reads but block writers. Heavy read traffic can starve writers (use fair mode if needed).
StampedLock (Java 8+)
Adds optimistic read — no lock acquisition for reads if uncontended.
StampedLock sl = new StampedLock();
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // no lock
double cx = x, cy = y;
if (!sl.validate(stamp)) { // writer happened during read?
stamp = sl.readLock(); // fall back to real read lock
try { cx = x; cy = y; }
finally { sl.unlockRead(stamp); }
}
return Math.sqrt(cx*cx + cy*cy);
}
[!IMPORTANT]
StampedLockis not reentrant. Calling lock recursively → deadlock. NoConditionsupport.
Reentrancy
Same thread re-acquiring the lock it already holds:
synchronized void a() { b(); }
synchronized void b() { /* same lock — OK */ }
All Java intrinsic + ReentrantLock support this. StampedLock does not.
When to Pick What
- Default →
synchronized. Simple, optimized, can't forget to unlock. - Need timeout / interruptibility / fairness / multiple conditions →
ReentrantLock. - Read-heavy data structure →
ReentrantReadWriteLockorStampedLock. - Highest throughput, willing to handle complexity, no reentrancy →
StampedLock. - Avoid all of above → use
java.util.concurrentdata structures (ConcurrentHashMap, etc).
Q: What are Virtual Threads (Project Loom), and when should you use them?
Answer:
Virtual threads (GA in Java 21) are lightweight threads scheduled by the JVM instead of the OS. They look like Thread and behave like Thread but cost orders of magnitude less. The point is to let you write simple blocking code at the throughput of an async framework.
The Problem They Solve
Classic Java concurrency forces an unhappy trade:
- Thread-per-request, blocking I/O — readable, debuggable. But OS threads cost ~1–2 MB stack and a handful of microseconds to context-switch. A box runs out at ~few thousand threads.
- Async / reactive — scales to millions of concurrent operations. But: callback hell, stack traces lose context, debugging is misery, thread-locals don't work.
Virtual threads keep the thread-per-request mental model and remove the cost.
How They Work
A virtual thread is a Java object scheduled onto a small pool of carrier threads (real OS threads, by default ForkJoinPool of size = CPU cores).
┌──────────────────────────────────────────────────┐
│ 10,000 virtual threads (Java heap objects) │
└─────────────────────┬────────────────────────────┘
│ scheduled onto
▼
┌──────────────────────────────────────────────────┐
│ ~N carrier threads (OS threads, N ≈ #CPU) │
└──────────────────────────────────────────────────┘
When a virtual thread hits a blocking call (e.g. socket.read()), the JVM parks it — saves its continuation off the carrier — and lets the carrier run another virtual thread. When I/O completes, the virtual thread is rescheduled. The OS thread never blocked.
Creating Them
// Direct
Thread.startVirtualThread(() -> handle(req));
// Or a builder
Thread.ofVirtual().name("handler-", 0).start(() -> ...);
// Executor — the recommended way
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (var req : requests) {
exec.submit(() -> handle(req));
}
} // try-with-resources joins all tasks
There is no pool of virtual threads. You make one per task. Cheap.
When Virtual Threads Help
They help when threads spend most of their time waiting on I/O — HTTP calls, DB queries, message brokers. A typical microservice handling requests via Servlet/Spring MVC fits perfectly.
Before (Tomcat, 200-thread pool):
200 in-flight requests max, queue afterwards. p99 spikes under load.
After (virtual threads):
10,000+ in-flight requests, no queueing.
Spring Boot 3.2+: spring.threads.virtual.enabled=true. Tomcat will hand each request to a virtual thread.
When They Don't Help
- CPU-bound work. A virtual thread holds the carrier the whole time it's computing. Don't run a million Fibonacci tasks expecting magic — you're still limited by physical cores.
- Code with native pinning (see "Pinning" below).
- Replacing a thread pool that's already right-sized for the work. Pools sized for parallelism (not concurrency) are still correct.
Pinning — the Main Gotcha
A virtual thread is pinned (can't unmount from its carrier) when:
- It's inside a
synchronizedblock. - It's executing a JNI/native call.
Pinned virtual threads block the carrier just like an OS thread. If many requests pin at the same time, you've recreated the bottleneck you tried to escape.
Diagnose:
-Djdk.tracePinnedThreads=full
Fix: replace synchronized with ReentrantLock, which is pin-aware in JDK 21. JDK 24+ removes the synchronized-pinning limitation (JEP 491).
ThreadLocals — Behavior
Thread-locals still work, but they're a gotcha at scale:
- With 200 OS threads, 200 copies of each thread-local. Bounded.
- With 1,000,000 virtual threads, 1,000,000 copies. Memory pressure.
The replacement: ScopedValue (preview).
private static final ScopedValue<User> USER = ScopedValue.newInstance();
ScopedValue.where(USER, currentUser).run(() -> handle(req));
ScopedValue is immutable and scoped to a call tree; no leak through pools.
Virtual Threads vs Reactive Streams
| Aspect | Virtual threads | Reactive (Project Reactor, RxJava) |
|---|---|---|
| Style | Imperative, blocking | Async, functional |
| Stack trace | Full, useful | Often opaque |
| Backpressure | None built-in — manage with semaphores | First-class |
| Library compatibility | Any blocking JDBC, HTTP client works | Needs reactive client |
| Best for | I/O-heavy services | Streaming, fan-out, backpressure-critical |
For most CRUD services: virtual threads + blocking JDBC > reactive.
Common Mistakes
| Mistake | Reality |
|---|---|
| Pooling virtual threads | Pointless — they're cheap. Use newVirtualThreadPerTaskExecutor |
synchronized (lock) { jdbc.call() } | Pins the carrier across I/O. Use ReentrantLock |
| Using virtual threads for CPU work | No throughput gain; possibly worse due to scheduler overhead |
| Reading old advice about heap usage from a million Threads | Pre-Loom limits don't apply — virtual threads aren't OS threads |
[!NOTE] Virtual threads aren't faster than OS threads at the unit level — they're cheaper at scale. You don't get them to handle one request faster; you get them to handle ten thousand at once.
Interview Follow-ups
- "Difference between virtual threads and green threads?" — Conceptually similar (user-mode scheduled threads). Implementation: continuations on top of
ForkJoinPool, full JVM integration, transparent yield on JDK blocking calls. Green threads in 1.x Java had no preemption and no multi-CPU support. - "What is a continuation?" — The freeze-frame of a paused virtual thread (stack frames + locals + program counter). Exposed via
jdk.internal.vm.Continuationfor advanced use. - "How do they interact with Structured Concurrency?" — JEP 462 /
StructuredTaskScopeprovides try-with-resources style fan-out/fan-in across virtual threads. Same scope = same lifetime = automatic cancellation propagation.
Q: Explain the JVM Memory Model.
Answer:
JVM Memory Areas
┌──────────────────────────────────────────┐
│ JVM Memory │
│ │
│ ┌──────────────────────────────────┐ │
│ │ HEAP │ │
│ │ ┌────────────┐ ┌───────────┐ │ │
│ │ │ Young Gen │ │ Old Gen │ │ │
│ │ │ ┌────────┐ │ │(Tenured) │ │ │
│ │ │ │ Eden │ │ │ │ │ │
│ │ │ ├────────┤ │ │ Long- │ │ │
│ │ │ │ S0 │ │ │ lived │ │ │
│ │ │ │ S1 │ │ │ objects │ │ │
│ │ │ └────────┘ │ │ │ │ │
│ │ └────────────┘ └───────────┘ │ │
│ └──────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌─────────────────────┐ │
│ │ Stack │ │ Metaspace │ │
│ │ (per │ │ (class metadata, │ │
│ │ thread) │ │ method bytecode) │ │
│ └──────────┘ └─────────────────────┘ │
└──────────────────────────────────────────┘
Heap (Shared Across All Threads)
Where objects live. Divided into generations for GC efficiency:
- Young Generation: Newly created objects. Most objects die here (short-lived).
- Eden: Objects are initially allocated here.
- Survivor Spaces (S0, S1): Objects that survive minor GC move here.
- Old Generation (Tenured): Objects that survive multiple minor GC cycles are promoted here. Full GC cleans this area.
Stack (Per Thread)
Each thread has its own stack storing:
- Stack frames: One per method call, containing local variables, method parameters, and return addresses.
- Primitive values and object references (not the objects themselves).
- Fixed size: too many frames =
StackOverflowError.
Metaspace (Java 8+, replaces PermGen)
Stores class metadata, method bytecode, constant pool, and static variables. Uses native memory (not heap), so it auto-grows (configurable with -XX:MaxMetaspaceSize).
Other Areas
- PC Register: Per thread, tracks the current bytecode instruction.
- Native Method Stack: For JNI native method calls.
Key JVM Flags
java -Xms512m # Initial heap size
-Xmx2g # Maximum heap size
-Xss1m # Thread stack size
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
-XX:+PrintGCDetails # Log GC activity
[!IMPORTANT] Stack vs Heap: Primitives and references are stored on the stack (fast, per-thread). Objects are stored on the heap (shared, GC-managed). This distinction is fundamental to understanding memory management, thread safety, and performance tuning.
Q: How does Garbage Collection work in Java?
Answer:
Garbage Collection (GC) automatically frees heap memory by reclaiming objects that are no longer reachable.
How Objects Become Eligible for GC
An object is eligible when no live thread can reach it through any chain of references.
Object a = new Object(); // Object created, referenced by 'a'
a = null; // Reference removed → object is now unreachable → GC eligible
GC Process: Generational Collection
1. Minor GC (Young Generation)
- New objects are allocated in Eden.
- When Eden fills up, a minor GC runs.
- Live objects are copied to a Survivor space (S0 or S1).
- Dead objects are discarded (Eden is cleared).
- Objects that survive multiple minor GCs are promoted to Old Gen.
2. Major GC / Full GC (Old Generation)
- Triggered when Old Gen fills up.
- Much slower than minor GC (scans the entire heap).
- "Stop the world" — all application threads are paused.
GC Algorithms
| Collector | Type | Pause | Best For |
|---|---|---|---|
| Serial GC | Single-threaded | Long STW | Small apps, single-core |
| Parallel GC (default < Java 9) | Multi-threaded | Medium STW | Batch processing, throughput |
| G1 GC (default Java 9+) | Region-based | Short STW | General purpose, balanced |
| ZGC (Java 15+) | Concurrent | < 1ms STW | Ultra-low latency |
| Shenandoah (Java 12+) | Concurrent | < 1ms STW | Low latency, Red Hat |
G1 GC (Garbage-First)
The default collector since Java 9. Divides the heap into equal-sized regions and prioritizes collecting regions with the most garbage first.
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 MyApp
ZGC (Z Garbage Collector)
Designed for sub-millisecond pauses regardless of heap size (supports up to 16 TB heaps).
java -XX:+UseZGC MyApp
GC Tuning Tips
# Enable GC logging (Java 9+)
java -Xlog:gc*:file=gc.log:time,uptime,level,tags
# Set target pause time for G1
java -XX:MaxGCPauseMillis=100
# Monitor with jstat
jstat -gc <pid> 1000 # GC stats every 1 second
[!TIP] In interviews, mentioning G1 GC (default, balanced) and ZGC (ultra-low latency) shows you understand modern Java. The key insight: "GC is a trade-off between throughput (total work done) and latency (pause duration). G1 balances both; ZGC minimizes latency at some throughput cost."
Q: How does ClassLoading work in Java?
Answer:
Class loading is the process of finding, loading, and initializing .class files into the JVM.
The Three Phases
1. Loading — The ClassLoader reads the .class bytecode file and creates a Class<?> object in memory.
2. Linking
- Verification: Bytecode is checked for correctness and security.
- Preparation: Static fields are allocated and set to default values (0, null).
- Resolution: Symbolic references (class names in bytecode) are resolved to actual memory addresses.
3. Initialization — Static initializers and static blocks are executed. This happens only when the class is first used.
ClassLoader Hierarchy (Delegation Model)
Bootstrap ClassLoader (C/C++)
↑ delegates to parent first
Application ClassLoader
↑
Extension ClassLoader
↑
Custom ClassLoader (your code)
| ClassLoader | Loads From | Examples |
|---|---|---|
| Bootstrap | $JAVA_HOME/lib (core classes) | java.lang.String, java.util.* |
| Extension/Platform | $JAVA_HOME/lib/ext | Security, crypto extensions |
| Application/System | Classpath (-cp, CLASSPATH) | Your application classes |
| Custom | Anywhere you define | Plugin systems, hot-reloading |
Parent-Delegation Model
When a class needs to be loaded:
- The current ClassLoader delegates to its parent first.
- If the parent can't find it, the current ClassLoader tries.
- If no one can find it →
ClassNotFoundException.
Why? Prevents duplicate class loading and ensures core classes (java.lang.String) are always loaded by the Bootstrap ClassLoader, preventing tampering.
Common Interview Scenarios
// These are loaded by DIFFERENT classloaders:
String.class.getClassLoader(); // null (Bootstrap — implemented in native code)
MyApp.class.getClassLoader(); // AppClassLoader
ClassNotFoundException vs NoClassDefFoundError:
| Exception | Cause |
|---|---|
ClassNotFoundException | Class not found at runtime (e.g., Class.forName("Missing")) |
NoClassDefFoundError | Class was available at compile time but missing at runtime |
[!NOTE] Understanding class loading is essential for debugging issues in application servers (Tomcat, Spring Boot), OSGi frameworks, and anywhere with multiple classloaders. "Class X cannot be cast to Class X" errors typically mean the same class was loaded by two different classloaders.
Q: How does the JIT compiler work? What are C1/C2 and tiered compilation?
Answer:
JVM starts by interpreting bytecode. Hot methods are then JIT-compiled to native code. JIT = Just-In-Time.
Why Not AOT-compile Everything?
- Startup latency.
- Profile-guided optimization needs runtime data (which branches taken, types seen).
- AOT can't speculate; JIT can (and de-optimize on guess wrong).
Two Compilers
- C1 (client): fast compile, modest optimization.
- C2 (server): slow compile, aggressive optimization (inlining, escape analysis, vectorization).
Tiered Compilation (Default Since JDK 8)
| Tier | Who | Profiling | Speed |
|---|---|---|---|
| 0 | Interpreter | yes | slowest |
| 1 | C1 (no profiling) | no | fast compile, fast run |
| 2 | C1 (limited profiling) | partial | |
| 3 | C1 (full profiling) | full | |
| 4 | C2 / Graal | uses tier-3 profile | slowest compile, fastest run |
Hot path: 0 → 3 → 4. Cold quick wins: 0 → 1.
Triggers
- Invocation count + back-edge (loop) count crosses thresholds (
-XX:CompileThreshold=10000for non-tiered). - "Hot" = called often or contains hot loop.
Key Optimizations
- Inlining — replace method call with body. Enables further optimization.
- Escape analysis — if object never escapes a method, allocate on stack or scalar-replace.
- Lock elision — remove locks on objects proven thread-local.
- Loop unrolling + vectorization (SIMD).
- Branch prediction hints from profile.
- Devirtualization — turn virtual call into direct/inlined call when only one impl observed.
De-optimization
JIT speculates ("only seen ArrayList here"). When assumption breaks ("now a LinkedList showed up"), JVM throws away compiled code, falls back to interpreter, recompiles with new info.
Useful Flags
-XX:+PrintCompilation # log JIT events
-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
-XX:CICompilerCount=4 # JIT compiler threads
-XX:+TieredCompilation # default on
-XX:TieredStopAtLevel=1 # disable C2 — faster startup, slower steady state
-XX:+UseCodeCacheFlushing
GraalVM
Drop-in replacement for C2, written in Java. Often faster on polyglot/Scala/Kotlin code.
-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler
AOT Options
jaotc(deprecated/removed) — AOT-compile classes.- GraalVM Native Image — full AOT, ~ms startup, lower peak throughput, no JIT speculation.
Code Cache
Compiled native code lives in code cache (separate from heap). Fills up → JIT stops → "CodeCache is full. Compiler has been disabled" log warning. Bump with -XX:ReservedCodeCacheSize=256m.
Warmup
Benchmarks must include warmup loop — first invocations run interpreted. Use JMH for microbenchmarks; it handles warmup correctly.
Interview Soundbite
"JIT means hot code becomes fast over time, cold code stays cheap. C1 prioritizes compile speed, C2 prioritizes runtime speed. Tiered compilation runs both — interpret first, C1 for warmup, C2 for steady state. Speculation enables aggressive optimization with de-opt as the safety net."
Q: How do you diagnose memory leaks in a JVM application?
Answer:
Java has garbage collection — but it also has memory leaks. The pattern is always the same: a long-lived object holds references to objects that should have been collected. Diagnosis is methodical, not magical.
The Symptom
heap usage over time:
┌─────────────────────────────────────────────────┐
│ ╱╲ ╱╲╱╲│
│ ╱╲ ╱╱ ╲╲╱ ╲│
│ ╱╲ ╱╱ ╲╲ ╱╱ │
│ ╱╲ ╱╱ ╲╲╱ ╲╲╱ │
│ ╱╲ ╱╱ ╲╲╱ │
│ ╱╲ ╱╱ ╲╲╱ │
│╱╱ ╲╱ │
└─────────────────────────────────────────────────┘
time → eventually: OOM
A healthy app's heap sawtooths around a stable mean. A leak shows the mean drifting upward over hours/days, ending in OutOfMemoryError: Java heap space.
Step 1: Confirm It's a Heap Leak
OutOfMemoryError has many flavors:
| Variant | Cause |
|---|---|
Java heap space | Heap full of reachable objects — actual leak or undersized heap |
GC overhead limit exceeded | GC running constantly but reclaiming <2% — same as above, earlier signal |
Metaspace | Class metadata leak (classloader leak in web containers) |
Direct buffer memory | ByteBuffer.allocateDirect not freed |
unable to create new native thread | Thread leak — every new Thread() without shutdown |
Requested array size exceeds VM limit | Single huge allocation (>2 GB) |
Step 2: Capture a Heap Dump
In production, on OOM:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdumps
Or live:
jcmd <pid> GC.heap_dump /tmp/dump.hprof
# Older:
jmap -dump:live,format=b,file=/tmp/dump.hprof <pid>
live runs a full GC first, dropping unreachable objects — what remains is what's leaking.
Step 3: Analyze with Eclipse MAT or VisualVM
Eclipse MAT (Memory Analyzer Tool) is the workhorse:
- Open
.hprof. - Click Leak Suspects report — automated heuristic.
- Inspect the Dominator Tree — objects that, if removed, free the most memory.
- For a specific suspect, run Path to GC Roots → exclude weak/soft/phantom references.
The GC root path tells you which long-lived object is keeping your leaked instances alive.
java.lang.Thread (GC root)
└── ConcurrentHashMap (the static cache)
└── String "tenant-42-key"
└── Order [12,345 instances]
The cache is the leak.
Common Leak Patterns
1. Unbounded static collection.
public class Cache {
private static final Map<String, Object> CACHE = new HashMap<>();
public static void put(String k, Object v) { CACHE.put(k, v); }
// Never evicts. Lives forever.
}
Fix: LinkedHashMap access-ordered with removeEldestEntry, or Caffeine/Guava Cache with size or time bounds.
2. Listener / observer not removed.
eventBus.register(this); // every web request creates a new listener
// no unregister → eventBus pins this object forever
Fix: pair every register with unregister; use weak references; scope listeners properly.
3. ThreadLocal in a pooled thread.
public static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
CTX.set(new UserContext(...));
// missing CTX.remove() → reusable Tomcat thread holds the context forever
In container thread pools, a ThreadLocal value can survive across requests. Always CTX.remove() in a finally block.
4. ClassLoader leak in web container.
A redeploy creates a new classloader for the webapp, but a static reference from a JDK-loaded class (e.g., a Driver registered in DriverManager, a Logger, a ThreadLocal) keeps the old classloader alive. Metaspace climbs after each redeploy.
Fix: cleanup hooks in ServletContextListener.contextDestroyed, deregister drivers, shutdown executors, clear ThreadLocals.
5. Caches keyed by mutable objects.
Map<Order, BigDecimal> totals = new HashMap<>();
// Order's hashCode based on a field that changes — entry can never be retrieved or evicted
6. Strings interned excessively.
String.intern() puts strings in the StringTable, which lives in the heap (and metaspace pre-Java 8). Calling it on user-supplied data → leak.
Step 4: Reproduce in Test
Once a suspect is identified, write a reproducer:
@Test
void cacheDoesNotLeak() {
for (int i = 0; i < 1_000_000; i++) {
cache.put("k" + i, new byte[1024]);
}
System.gc();
long heap = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
assertThat(heap).isLessThan(100 * 1024 * 1024);
}
A failing test is the difference between "we think we fixed it" and "we know."
Native Memory Tracking
-XX:NativeMemoryTracking=summary then:
jcmd <pid> VM.native_memory baseline
# run for a while
jcmd <pid> VM.native_memory summary.diff
Shows growth across Java Heap, Metaspace, Code Cache, Thread stacks, GC structures, Direct buffers. Useful when heap looks fine but RSS keeps growing.
MAT Useful Queries
Dominator tree filtered:
SELECT * FROM java.util.HashMap WHERE @retainedHeapSize > 100000000SELECT * FROM java.lang.Thread WHERE @retainedHeapSize > 100000000
Finding all ClassLoaders:
- Histogram → group by class → filter
ClassLoader.
Async Profiler for Allocations
Heap dumps show what's there now. To find who's allocating:
asprof -e alloc -d 60 -f alloc.html <pid>
Generates a flame graph by allocation site. Pairs well with heap dumps: dump shows the leak, profiler shows the code path producing leaked objects.
Common Mistakes
| Mistake | Better |
|---|---|
| "GC will clean it up" | GC reclaims unreachable. Reachable garbage stays |
Looking at Runtime.totalMemory() for leak detection | Use GC logs, post-GC heap-after numbers |
Reading heap dump without live flag | Includes ephemeral garbage; harder to find the real culprit |
Increasing -Xmx instead of finding the leak | Postpones the OOM, doesn't fix it |
| Assuming WeakReference fixes any leak | Only useful when GC-reachability really is the right policy |
[!NOTE] A leak is reachable-but-useless memory. The fix is always to break the reference: clear the cache, remove the listener, drop the ThreadLocal, dispose the classloader.
Interview Follow-ups
- "Difference between strong, soft, weak, phantom references?" — Strong: normal, GC won't reclaim. Soft: GC reclaims under memory pressure. Weak: GC reclaims on next cycle. Phantom: notified-only, used for cleanup hooks.
- "How does G1 vs ZGC change diagnosis?" — Doesn't change leak diagnosis. ZGC has lower pause times but doesn't reclaim reachable objects either.
- "What's the difference between live heap and committed heap?" — Live = objects currently reachable. Committed = OS-allocated heap memory. RSS can exceed committed due to off-heap, code cache, threads, metaspace.
Q: How do you choose and tune a JVM garbage collector (G1, ZGC, Parallel)?
Answer:
Modern JVMs ship four production GCs: G1 (default), ZGC, Shenandoah, Parallel. Each makes different tradeoffs between pause time, throughput, and memory overhead. Picking one without understanding the tradeoff is how you ship "weird latency spikes."
The Four GCs
| GC | Goal | Pause | Throughput | Heap size sweet spot |
|---|---|---|---|---|
| Serial | Tiny heaps | Stop-the-world | Low | < 100 MB |
| Parallel | Throughput | Long stop-the-world | Highest | Anything; batch jobs |
| G1 | Balanced | < 200 ms (target) | Good | 1 GB – 32 GB |
| ZGC | Low pause | < 1 ms | Slightly lower | 1 GB – TB |
| Shenandoah | Low pause | < 10 ms | Similar to ZGC | OpenJDK distros |
Default since Java 9: G1.
What a GC Actually Does
Heap = Young Generation (Eden + 2 Survivor spaces) + Old Generation
Allocate → Eden
Eden full → MINOR GC: copy live objects from Eden + Survivor-from to Survivor-to
After N survival cycles → promote to Old
Old fills up → MAJOR GC: collect Old (often more expensive)
GCs differ in how they walk the heap, when they pause, and how concurrent they are with the mutator (your code).
G1 (Garbage-First)
Heap is split into ~2000 regions of equal size. G1 collects regions with the most garbage first (hence the name).
[E][E][O][O][H][O][S][E][E][O][O][O] ... 2048 regions
E = Eden S = Survivor O = Old H = Humongous (≥ region size)
Workflow:
- Young GC: short pause, collect Eden + Survivors.
- Concurrent marking: scan reachability in background.
- Mixed GC: collect Old regions selected from the marking phase.
Tuning knobs:
-Xms4g -Xmx4g # set both equal in containers
-XX:+UseG1GC # default in modern JDKs
-XX:MaxGCPauseMillis=200 # target pause goal (G1 sizes regions to hit it)
-XX:G1HeapRegionSize=16m # explicit region size
-XX:InitiatingHeapOccupancyPercent=45 # start concurrent marking at 45% Old
-XX:G1NewSizePercent=20 # min young gen %
-XX:G1MaxNewSizePercent=40 # max young gen %
Hits most workloads' needs. Tune MaxGCPauseMillis and let G1 figure out the rest.
ZGC
Concurrent collector. Almost all work happens while application runs. Pause times stay sub-millisecond regardless of heap size.
-XX:+UseZGC
-Xmx16g
-XX:+ZGenerational # Java 21+: split young/old like G1, big throughput win
Use ZGC when:
- p99 latency matters more than throughput (trading, gaming, APIs with strict SLAs).
- Heaps are large (10s of GB to TB).
- You're willing to give up ~5–15% throughput for tiny pauses.
ZGC uses colored pointers (Linux x64 with 5-level page tables) and load barriers to relocate objects concurrently. Magic, but the overhead is per-load.
Parallel GC
The throughput champion. Stop-the-world for both young and full collections, but parallelizes within each pause.
-XX:+UseParallelGC
-XX:ParallelGCThreads=8
Use for:
- Batch jobs where total wall time matters more than any individual pause.
- Map-reduce-style workloads.
Choosing in 30 Seconds
heap > 32 GB? → ZGC
latency SLA < 50 ms p99? → ZGC or Shenandoah
batch throughput max? → Parallel
default web service? → G1
Sizing the Heap
-Xmx should be set, not left to defaults. Common rule:
heap ≈ container_memory × 0.75
The other 25%: native memory (Metaspace, code cache, threads, direct buffers, off-heap caches), and Linux page cache.
For containers:
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=75.0
Modern JDKs auto-detect cgroup limits — verify with -XshowSettings:vm.
-Xms = -Xmx
Set initial and max heap equal in long-running services. Why:
- Growing the heap is a stop-the-world operation in G1.
- Heap returns RAM to the OS lazily; not setting
Xmswon't make your container "use less." - Surprises in monitoring (heap grows = looks like a leak).
Useful Flags
-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=10,filesize=10M
# Heap dump on OOM
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdumps
# Crash on OOM (let K8s restart)
-XX:+ExitOnOutOfMemoryError
# More descriptive errors
-XX:+ShowCodeDetailsInExceptionMessages
# String dedup (saves memory in apps with lots of identical strings)
-XX:+UseStringDeduplication
Reading GC Logs
[12.345s][info][gc] GC(42) Pause Young (Normal) (G1 Evacuation Pause) 1024M->256M(2048M) 45.123ms
Decode:
Pause Young (Normal): minor collection.1024M->256M(2048M): heap before → after (capacity).45.123ms: pause time.
Frequent young GCs with high before→after ratio? Big allocation rate; consider raising young gen.
Full GCs frequent? Old gen pressure; consider larger heap, ZGC, or fixing a memory leak.
Tools
# Live GC stats
jstat -gcutil <pid> 1000
# Class histogram (top alloc classes)
jcmd <pid> GC.class_histogram | head -50
# Trigger heap dump
jcmd <pid> GC.heap_dump /tmp/dump.hprof
For deeper analysis: GCViewer, GCEasy, or load gc.log into Grafana via gc_log_exporter.
Common Mistakes
| Mistake | Reality |
|---|---|
Setting only -Xmx, not -Xms | Heap grows over time; metrics look like a leak; pause spikes during growth |
| ZGC for batch job | Throughput penalty for no benefit |
| Default GC for 64 GB heap | G1 struggles past ~32 GB; switch to ZGC |
| Tweaking 10+ flags without measuring | More knobs = more bugs. Start with Xms/Xmx + MaxGCPauseMillis |
-Xmx equal to container memory | OOM-killed by kernel; leave 20–30% headroom |
| Disabling GC ("GC is the problem") | The allocations are the problem. Profile with async-profiler -e alloc |
When To Care
GC tuning matters when:
- p99/p99.9 latency is bad and
gc.pause.maxis significant. - Allocation rate is huge (multi-GB/s).
- Heap is large enough that G1's mixed GC stalls.
GC tuning does not fix:
- Memory leaks (reachable garbage).
- CPU-bound code paths.
- Excessive allocation patterns (fix the code, not the GC).
[!NOTE] If GC takes >5% of CPU time and causes user-visible latency, tune. Otherwise leave it alone. The JVM defaults are excellent for most workloads.
Interview Follow-ups
- "What's
ZGenerational?" — Java 21 made ZGC generational (young + old). Massive throughput improvement at no pause cost. Almost always set it. - "Why does G1 have humongous regions?" — Objects ≥ 50% of region size allocate directly into "humongous" regions. Avoids excessive copying.
- "What's Epsilon GC?" — A no-op GC. Allocates until OOM. Useful for measuring application allocation rate without GC noise, or for short-lived jobs.
Q: What are Lambda Expressions and Functional Interfaces?
Answer:
Functional Interface
An interface with exactly one abstract method. Annotated with @FunctionalInterface (optional but recommended).
@FunctionalInterface
public interface Predicate<T> {
boolean test(T t); // Single abstract method
// Can have default and static methods
}
Lambda Expressions (Java 8+)
A concise way to implement a functional interface without boilerplate anonymous classes.
// ❌ Old way: Anonymous class (verbose)
Predicate<String> isLong = new Predicate<String>() {
@Override
public boolean test(String s) {
return s.length() > 5;
}
};
// ✅ Lambda: same thing, cleaner
Predicate<String> isLong = s -> s.length() > 5;
Built-in Functional Interfaces (java.util.function)
| Interface | Method | Signature | Use Case |
|---|---|---|---|
Predicate<T> | test(T) | T → boolean | Filtering, conditions |
Function<T,R> | apply(T) | T → R | Transformation |
Consumer<T> | accept(T) | T → void | Side effects (logging, saving) |
Supplier<T> | get() | () → T | Factory, lazy evaluation |
BiFunction<T,U,R> | apply(T,U) | (T,U) → R | Two-arg transformation |
UnaryOperator<T> | apply(T) | T → T | Same-type transformation |
Method References
Shorthand for lambdas that call an existing method:
// Lambda → Method Reference
s -> s.toUpperCase() → String::toUpperCase
s -> System.out.println(s) → System.out::println
s -> Integer.parseInt(s) → Integer::parseInt
() -> new ArrayList<>() → ArrayList::new
Effectively Final
Lambdas can capture local variables, but they must be effectively final (not modified after initialization):
int multiplier = 3; // effectively final — never reassigned
Function<Integer, Integer> multiply = x -> x * multiplier; // ✅
int counter = 0;
Runnable task = () -> counter++; // ❌ Compilation error! counter is modified
[!TIP] Think of lambdas as data rather than code. You're passing behavior as a parameter — the foundation of functional programming in Java. This enables powerful patterns like strategy pattern without a dozen classes.
Q: How does the Stream API work?
Answer:
The Stream API (Java 8+) provides a declarative, functional way to process collections — focusing on what to do instead of how.
Creating Streams
List<String> names = List.of("Alice", "Bob", "Charlie", "David");
names.stream() // From collection
Stream.of("a", "b", "c") // From values
IntStream.range(1, 10) // Primitive stream
Files.lines(Path.of("f")) // From file
Intermediate Operations (Lazy, Return a Stream)
names.stream()
.filter(n -> n.length() > 3) // Predicate: keep if true
.map(String::toUpperCase) // Transform each element
.sorted() // Natural ordering
.distinct() // Remove duplicates
.limit(10) // Take first N
.peek(System.out::println) // Debug: inspect without modifying
Terminal Operations (Trigger Execution, Return a Result)
.collect(Collectors.toList()) // Collect into a List
.collect(Collectors.toSet()) // Collect into a Set
.collect(Collectors.joining(", ")) // Join as String
.collect(Collectors.groupingBy(fn)) // Group into Map
.forEach(System.out::println) // Side-effect per element
.count() // Count elements
.findFirst() // Optional<T>
.reduce(0, Integer::sum) // Reduce to single value
.toArray(String[]::new) // To array
Real-World Example
// Get the names of the top 3 highest-paid employees in the Engineering department
List<String> topPaid = employees.stream()
.filter(e -> "Engineering".equals(e.getDepartment()))
.sorted(Comparator.comparing(Employee::getSalary).reversed())
.limit(3)
.map(Employee::getName)
.collect(Collectors.toList());
Parallel Streams
list.parallelStream()
.filter(...)
.map(...)
.collect(Collectors.toList());
[!CAUTION] Parallel streams are not always faster. They use the common ForkJoinPool and add overhead for splitting, threading, and merging. Use them only for CPU-intensive operations on large datasets. For I/O-bound tasks or small collections, sequential streams are faster.
Key Concepts
| Concept | Detail |
|---|---|
| Lazy evaluation | Intermediate operations are NOT executed until a terminal operation is called |
| Short-circuit | Operations like findFirst(), limit(), anyMatch() stop early |
| Stateless vs Stateful | filter/map are stateless (per-element); sorted/distinct are stateful (need all elements) |
| One-time use | A stream can only be consumed ONCE. Reuse requires creating a new stream |
Q: What is Optional and how should you use it?
Answer:
Optional<T> (Java 8+) is a container that may or may not contain a non-null value. It's designed to eliminate NullPointerException by making nullability explicit in the API.
The Problem
// ❌ NullPointerException waiting to happen
User user = userRepository.findById(id); // Could return null
String city = user.getAddress().getCity(); // 💥 NPE if user or address is null
Using Optional
// ✅ Explicit nullability
Optional<User> user = userRepository.findById(id);
// Safe access
String city = user
.map(User::getAddress)
.map(Address::getCity)
.orElse("Unknown");
Creating Optionals
Optional<String> present = Optional.of("hello"); // Must be non-null
Optional<String> nullable = Optional.ofNullable(value); // May be null
Optional<String> empty = Optional.empty(); // Explicitly empty
Consuming Optionals
// Get with default value
String name = optional.orElse("default");
// Get with lazy default (only computed if empty)
String name = optional.orElseGet(() -> expensiveDefault());
// Throw if empty
String name = optional.orElseThrow(() -> new NotFoundException("Not found"));
// Execute if present
optional.ifPresent(value -> System.out.println(value));
// Java 9+: if-present-else
optional.ifPresentOrElse(
value -> System.out.println("Found: " + value),
() -> System.out.println("Not found")
);
Transforming Optionals
// map: transform the value if present
Optional<String> upper = optional.map(String::toUpperCase);
// flatMap: when the transformation itself returns Optional
Optional<String> city = userOpt.flatMap(User::getAddress) // getAddress returns Optional<Address>
.map(Address::getCity);
// filter: keep value only if predicate matches
Optional<User> admin = userOpt.filter(u -> u.getRole() == Role.ADMIN);
Anti-Patterns (Don't Do This!)
// ❌ Using Optional as a glorified null check — defeats the purpose
if (optional.isPresent()) {
return optional.get();
}
// ❌ Optional as a method parameter — confusing API
public void process(Optional<String> name) { } // Bad
// ❌ Optional as a field — not serializable, adds overhead
private Optional<String> name; // Bad
// ❌ Optional.of() with a nullable value — NPE!
Optional.of(null); // 💥 NullPointerException
Best Practices
| ✅ Do | ❌ Don't |
|---|---|
| Use as return type to signal possible absence | Use as method parameter |
Use map/flatMap/orElse chains | Use isPresent() + get() |
Use orElseThrow() for required values | Use Optional.get() without checking |
Use Optional.ofNullable() for nullable values | Use Optional.of() with nullable values |
[!TIP] Think of
Optionalas a single-element Stream. It supportsmap,flatMap,filter, andifPresent— the same functional operations. If you're comfortable with Streams, Optional follows the same patterns.
Q: Explain Collectors. Common patterns + what groupingBy actually does.
Answer:
Collector<T, A, R> = mutable reduction strategy: how to accumulate stream elements into a result container.
Built-in factory: java.util.stream.Collectors.
Common Recipes
To collection
list.stream().collect(Collectors.toList()); // mutable, post-Java 16: prefer .toList()
list.stream().toList(); // unmodifiable, Java 16+
list.stream().collect(Collectors.toUnmodifiableList());
list.stream().collect(Collectors.toSet());
list.stream().collect(Collectors.toCollection(TreeSet::new));
To map
users.stream().collect(Collectors.toMap(User::id, Function.identity()));
// duplicate-key merge
.collect(Collectors.toMap(User::email, u -> u, (a, b) -> a));
// pick map type
.collect(Collectors.toMap(User::id, u -> u, (a,b) -> a, LinkedHashMap::new));
Joining strings
names.stream().collect(Collectors.joining(", ", "[", "]"));
// → "[alice, bob, carol]"
Counting / summing / averaging
orders.stream().collect(Collectors.counting());
orders.stream().collect(Collectors.summingDouble(Order::amount));
orders.stream().collect(Collectors.averagingInt(Order::quantity));
orders.stream().collect(Collectors.summarizingDouble(Order::amount));
// → DoubleSummaryStatistics{count, sum, min, avg, max}
groupingBy (The Big One)
Map<Status, List<Order>> byStatus =
orders.stream().collect(Collectors.groupingBy(Order::status));
Two- and three-arg forms take a downstream collector:
// count per status
Map<Status, Long> counts =
orders.stream().collect(Collectors.groupingBy(Order::status, Collectors.counting()));
// sum amount per customer
Map<Long, Double> totals =
orders.stream().collect(Collectors.groupingBy(
Order::customerId,
Collectors.summingDouble(Order::amount)));
// nested grouping
Map<Status, Map<Long, List<Order>>> nested =
orders.stream().collect(Collectors.groupingBy(
Order::status,
Collectors.groupingBy(Order::customerId)));
// pick map type
Collectors.groupingBy(Order::status, TreeMap::new, Collectors.toList());
partitioningBy
Special-case groupingBy with a predicate → always returns map with true/false keys (both keys present even if one is empty).
Map<Boolean, List<User>> adultsAndMinors =
users.stream().collect(Collectors.partitioningBy(u -> u.age() >= 18));
mapping, filtering, flatMapping
Apply transform inside the downstream:
Map<Status, List<String>> idsByStatus =
orders.stream().collect(Collectors.groupingBy(
Order::status,
Collectors.mapping(Order::id, Collectors.toList())));
Map<Status, List<Order>> highValuePerStatus =
orders.stream().collect(Collectors.groupingBy(
Order::status,
Collectors.filtering(o -> o.amount() > 1000, Collectors.toList())));
reducing
Lower-level than the typed sum/avg variants:
Optional<Order> biggest =
orders.stream().collect(Collectors.reducing(BinaryOperator.maxBy(Comparator.comparing(Order::amount))));
Custom Collector
Collector<Order, ?, BigDecimal> totalCollector = Collector.of(
() -> new BigDecimal[]{ BigDecimal.ZERO }, // supplier
(a, o) -> a[0] = a[0].add(o.amount()), // accumulator
(a, b) -> { a[0] = a[0].add(b[0]); return a; }, // combiner
a -> a[0] // finisher
);
Pitfalls
toMapwith duplicate keys →IllegalStateException. Always pass merger.Collectors.toList()returns mutable list pre-16. Don't rely on immutability.groupingByreturns regularHashMap— no order guarantees. UseLinkedHashMapfor insertion order.nullvalues not allowed intoMap(usesMap.merge). Pre-filter or usegroupingBy.
Q: How do parallel streams work? When should you avoid them?
Answer:
stream().parallel() or parallelStream() splits work across the common ForkJoinPool (ForkJoinPool.commonPool()).
How
- Source split into chunks (
Spliterator). - Each chunk processed on a pool thread.
- Results combined (depends on terminal op).
list.parallelStream()
.filter(x -> x > 0)
.mapToInt(Integer::intValue)
.sum();
Common Pool — Important Caveats
- Default size =
Runtime.getRuntime().availableProcessors() - 1. - Shared across all parallel streams in the JVM — one slow task blocks others.
- Configure:
-Djava.util.concurrent.ForkJoinPool.common.parallelism=8.
When Parallel Streams Help
- Large dataset (rough rule: > 10k elements).
- CPU-bound work per element (heavy compute, not I/O).
- Stateless lambdas (no shared mutable state).
- Splittable source (
ArrayList, arrays,IntStream.range) — splits cheaply. - Associative reduction (
sum,max,min).
When To Avoid
1. I/O or blocking work
urls.parallelStream().map(this::httpGet); // ❌ blocks pool threads → starves the JVM
Use CompletableFuture with a dedicated Executor, or virtual threads.
2. Small datasets Overhead of split + merge > savings.
3. Order-sensitive work
list.parallelStream().forEach(System.out::println); // unordered
list.parallelStream().forEachOrdered(System.out::println); // ordered, kills parallelism gain
4. Stateful or shared-mutable lambdas
List<Integer> result = new ArrayList<>();
list.parallelStream().forEach(result::add); // 💥 race — ArrayList not thread-safe
// Correct: collect()
5. LinkedList / Stream.iterate Bad splitters → poor parallelism.
6. Unsplittable sources
Files.lines(path).parallel() — IO bounded, hard to split.
Cost Model
Useful = N * cost_per_element >> Splitting + merging + thread coordination overhead
Custom Pool (Workaround)
Run parallel stream in your own pool:
ForkJoinPool pool = new ForkJoinPool(16);
pool.submit(() -> list.parallelStream().map(...).collect(...)).get();
pool.shutdown();
Reduction: Identity Must Be a True Identity
int sum = list.parallelStream().reduce(0, Integer::sum); // ✅ 0 + x = x
String s = list.parallelStream().reduce("", String::concat); // ✅
int prod = list.parallelStream().reduce(1, (a,b) -> a*b); // ✅ 1 * x = x
int bad = list.parallelStream().reduce(1, Integer::sum); // ❌ wrong identity
Performance Reality
Parallel streams rarely scale linearly. Measure with JMH. Often a for loop or sequential stream is faster on real workloads.
Decision Tree
Is work CPU-bound? → no → don't parallelize
Are elements > ~10k? → no → don't parallelize
Is operation associative? → no → don't parallelize
Is source efficiently splittable? → no → don't parallelize
Will it share the common pool with other work? → yes → use custom executor
Q: What is Inversion of Control (IoC) and Dependency Injection (DI)?
Answer:
Inversion of Control (IoC)
IoC is a design principle where the framework controls the flow of the program and the creation of objects, instead of the application code. The "control is inverted" — you don't call the framework, the framework calls you.
Dependency Injection (DI)
DI is the most common implementation of IoC. Instead of a class creating its own dependencies, they are injected from the outside by the Spring container.
Without DI (Tight Coupling)
// ❌ OrderService creates its own dependency — hard to test, hard to swap
public class OrderService {
private final OrderRepository repo = new MySQLOrderRepository(); // Hardcoded
public void createOrder(Order order) {
repo.save(order);
}
}
With DI (Loose Coupling)
// ✅ Dependency is injected — testable, swappable
@Service
public class OrderService {
private final OrderRepository repo; // Interface, not implementation
@Autowired // Spring injects the concrete implementation
public OrderService(OrderRepository repo) {
this.repo = repo;
}
public void createOrder(Order order) {
repo.save(order);
}
}
Types of Injection
1. Constructor Injection (Preferred)
@Service
public class OrderService {
private final OrderRepository repo;
public OrderService(OrderRepository repo) { // @Autowired optional for single constructor
this.repo = repo;
}
}
2. Setter Injection
@Service
public class OrderService {
private OrderRepository repo;
@Autowired
public void setRepo(OrderRepository repo) { this.repo = repo; }
}
3. Field Injection (Avoid)
@Service
public class OrderService {
@Autowired // ❌ Makes testing hard, hides dependencies
private OrderRepository repo;
}
Why Constructor Injection is Best
| Aspect | Constructor | Setter | Field |
|---|---|---|---|
| Immutability | ✅ final fields | ❌ Mutable | ❌ Mutable |
| Required deps | ✅ Enforced at compile time | ❌ Can be null | ❌ Can be null |
| Testability | ✅ Easy (just pass mocks) | ⚠️ Need setter | ❌ Need reflection |
| Circular deps | Fails fast (detected) | Can mask issues | Can mask issues |
[!TIP] Since Spring 4.3, if a class has only one constructor,
@Autowiredis optional. This makes constructor injection even cleaner and framework-agnostic.
Q: What are Bean Scopes and the Bean Lifecycle in Spring?
Answer:
Bean Scopes
A bean's scope defines how many instances Spring creates and how long they live.
| Scope | Instances | Lifecycle | Use Case |
|---|---|---|---|
singleton (default) | 1 per ApplicationContext | App startup → shutdown | Stateless services, repositories |
prototype | New instance per injection/request | Created on demand, NOT destroyed by Spring | Stateful objects, builders |
request | 1 per HTTP request | Request start → end | Request-scoped data |
session | 1 per HTTP session | Session start → invalidation | User session data |
application | 1 per ServletContext | App startup → shutdown | Global web-app state |
@Component
@Scope("prototype")
public class ShoppingCart { /* new instance per injection */ }
Bean Lifecycle
Bean Lifecycle
1. Instantiation → Constructor called
2. Populate Props → Dependencies injected (@Autowired)
3. BeanNameAware → setBeanName() if interface implemented
4. BeanFactoryAware → setBeanFactory()
5. Pre-Init → @PostConstruct / BeanPostProcessor.postProcessBeforeInitialization()
6. Init → InitializingBean.afterPropertiesSet() / custom init-method
7. Post-Init → BeanPostProcessor.postProcessAfterInitialization()
8. ═══ Bean is READY to use ═══
9. Pre-Destroy → @PreDestroy
10. Destroy → DisposableBean.destroy() / custom destroy-method
Practical Example
@Component
public class DatabaseConnectionPool {
private HikariDataSource dataSource;
@Autowired
public DatabaseConnectionPool(DataSourceProperties props) {
// Step 1-2: Constructor + injection
}
@PostConstruct // Step 5: Called after all dependencies are injected
public void init() {
this.dataSource = createPool();
log.info("Connection pool initialized with {} connections", poolSize);
}
@PreDestroy // Step 9: Called before bean is destroyed (app shutdown)
public void cleanup() {
dataSource.close();
log.info("Connection pool closed gracefully");
}
}
Singleton Gotcha with Prototype
@Component // Singleton by default
public class OrderService {
@Autowired
private ShoppingCart cart; // Prototype-scoped
// ❌ PROBLEM: Same cart instance is used for ALL requests!
// The prototype bean is injected ONCE into the singleton.
}
// ✅ Fix: Use ObjectProvider or @Lookup
@Component
public class OrderService {
@Autowired
private ObjectProvider<ShoppingCart> cartProvider;
public void process() {
ShoppingCart cart = cartProvider.getObject(); // New instance each time
}
}
[!CAUTION] Injecting a prototype-scoped bean into a singleton is a common mistake. The prototype is created once during singleton initialization and reused forever. Use
ObjectProvider,@Lookup, orObjectFactoryto get a new prototype instance each time.
Q: How does @Transactional work in Spring?
Answer:
@Transactional is Spring's declarative transaction management annotation. It wraps a method in a database transaction — if the method succeeds, the transaction commits; if it throws an exception, the transaction rolls back.
How It Works Under the Hood
Spring creates a proxy around the annotated bean. The proxy intercepts method calls, begins a transaction before the method, and commits/rolls back after.
Client → [Proxy: begin TX] → [Actual Method] → [Proxy: commit TX] → Return
│
throws exception?
│
[Proxy: rollback TX] → Propagate exception
Basic Usage
@Service
public class OrderService {
@Transactional
public void createOrder(Order order) {
orderRepository.save(order); // DB write 1
paymentService.processPayment(order); // DB write 2
inventoryService.deductStock(order); // DB write 3
// If ANYTHING throws → ALL 3 writes are rolled back
}
}
Rollback Rules
// Default: rolls back on unchecked exceptions (RuntimeException) ONLY
@Transactional
public void process() { throw new RuntimeException(); } // ✅ Rolls back
@Transactional
public void process() throws IOException { throw new IOException(); } // ❌ Does NOT rollback!
// Explicit: roll back on checked exceptions too
@Transactional(rollbackFor = Exception.class)
public void process() throws IOException { throw new IOException(); } // ✅ Rolls back
Propagation Levels
| Propagation | Behavior |
|---|---|
REQUIRED (default) | Join existing TX, or create a new one if none exists |
REQUIRES_NEW | Always create a new TX (suspend current if exists) |
MANDATORY | Must run inside an existing TX (throws if none) |
SUPPORTS | Run in TX if one exists, otherwise run without |
NOT_SUPPORTED | Always run without TX (suspend current if exists) |
NEVER | Must NOT run in a TX (throws if one exists) |
The Self-Invocation Trap (Most Common Bug!)
@Service
public class OrderService {
public void processOrder(Order order) {
createOrder(order); // ❌ Calling @Transactional method from same class!
}
@Transactional
public void createOrder(Order order) {
// This is NOT transactional when called from processOrder()!
// The proxy is bypassed because it's an internal method call.
}
}
Why? Spring's proxy only intercepts calls that come through the proxy (from outside the class). Internal method calls bypass the proxy entirely.
Fix options:
- Move the transactional method to a separate service.
- Inject
selfreference:@Autowired private OrderService self;then callself.createOrder(). - Use AspectJ mode (compile-time weaving) instead of proxy.
[!IMPORTANT] The two most critical interview points: (1)
@Transactionalonly rolls back unchecked exceptions by default — userollbackFor = Exception.classfor checked exceptions, and (2) self-invocation bypasses the proxy — the annotation is silently ignored on internal calls.
Q: What are Spring Boot starters? How do they work under the hood?
Answer:
A starter is a curated dependency descriptor — a single Maven/Gradle artifact that pulls in a coherent set of libraries for a use case (web, JPA, security, etc.).
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
That single line brings:
spring-webmvc,spring-webjackson-databind(JSON)tomcat-embed-core(embedded server)spring-boot-starter-json,-tomcat,-validation- compatible versions tested together via the BOM (
spring-boot-dependencies).
Why Starters Exist
Pre-Boot Spring meant manual version juggling: which spring-webmvc works with which jackson with which validator-api? Starters solve this — pick a Boot version → all transitive versions known-good.
How Auto-Configuration Hooks In
Each starter brings classes annotated with @AutoConfiguration (Boot 2.7+) or @Configuration + listed in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. Boot scans these on startup.
@AutoConfiguration
@ConditionalOnClass(DataSource.class)
@ConditionalOnMissingBean(DataSource.class)
public class DataSourceAutoConfiguration { ... }
@ConditionalOnX annotations gate configuration:
@ConditionalOnClass— class on classpath?@ConditionalOnMissingBean— user hasn't defined their own?@ConditionalOnProperty— config flag set?
Common Starters
| Starter | Brings |
|---|---|
spring-boot-starter-web | MVC, Tomcat, Jackson |
spring-boot-starter-webflux | WebFlux + Netty |
spring-boot-starter-data-jpa | Hibernate, Spring Data JPA |
spring-boot-starter-data-redis | Lettuce + Spring Data Redis |
spring-boot-starter-security | Spring Security |
spring-boot-starter-actuator | Health/metrics endpoints |
spring-boot-starter-test | JUnit 5, AssertJ, Mockito, Spring Test |
spring-boot-starter-validation | Jakarta Bean Validation |
Custom Starter (Library Authors)
- Module:
acme-spring-boot-starter(just dependency aggregator). - Module:
acme-spring-boot-autoconfigure(the actual@AutoConfigurationclasses). - Register classes in
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. - Provide
@ConfigurationPropertiesfor tunables.
@AutoConfiguration
@ConditionalOnClass(AcmeClient.class)
@EnableConfigurationProperties(AcmeProperties.class)
public class AcmeAutoConfiguration {
@Bean
@ConditionalOnMissingBean
AcmeClient acmeClient(AcmeProperties p) {
return AcmeClient.builder().url(p.url()).build();
}
}
Override / Disable
- Add your own
@Beanof the same type → Boot's auto-config backs off (@ConditionalOnMissingBean). - Disable explicitly:
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) - Property:
spring.autoconfigure.exclude=....
Key Takeaway
Starter = "pull dependencies" + "trigger auto-config". You stop writing infrastructure beans; convention does it. Override anywhere via @Bean or properties.
Q: How does Spring Boot auto-configuration work? How do you debug it?
Answer:
Auto-configuration = Boot inspects the classpath + your config + existing beans, then conditionally registers default beans.
The Entry Point
@SpringBootApplication
public class App { public static void main(String[] a) { SpringApplication.run(App.class, a); } }
@SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan.
@EnableAutoConfiguration
Triggers AutoConfigurationImportSelector, which loads class names from:
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
(Pre-2.7 used META-INF/spring.factories.)
Each class is @AutoConfiguration annotated — a @Configuration evaluated only if its conditions hold.
Conditions
@AutoConfiguration
@ConditionalOnClass({DataSource.class, EmbeddedDatabaseType.class})
@ConditionalOnMissingBean(DataSource.class)
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
@Bean @ConditionalOnProperty(name = "spring.datasource.url")
DataSource dataSource(DataSourceProperties p) { ... }
}
Common conditions:
| Annotation | Fires when |
|---|---|
@ConditionalOnClass | class present on classpath |
@ConditionalOnMissingClass | class absent |
@ConditionalOnBean | bean of type already in context |
@ConditionalOnMissingBean | bean of type not yet in context |
@ConditionalOnProperty | config property matches |
@ConditionalOnWebApplication | servlet/reactive web app |
@ConditionalOnExpression | SpEL evaluates true |
@ConditionalOnResource | resource exists |
Order Matters
@AutoConfigureBefore/@AutoConfigureAfter/@AutoConfigureOrder.- User
@Configurationclasses process before auto-config → user beans win via@ConditionalOnMissingBean.
Debugging — --debug Mode
Run with --debug or debug=true:
=========================
AUTO-CONFIGURATION REPORT
=========================
Positive matches:
-----------------
DataSourceAutoConfiguration matched:
- @ConditionalOnClass found required class 'javax.sql.DataSource'
...
Negative matches:
-----------------
GsonAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find required class 'com.google.gson.Gson'
Exclusions:
-----------
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
Unconditional classes:
----------------------
...
Actuator /actuator/conditions
Same data as a live JSON endpoint when actuator is enabled.
Override / Disable
Disable specific auto-configs
@SpringBootApplication(exclude = { SecurityAutoConfiguration.class })
// or via property
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
Override a bean
@Bean
public DataSource dataSource() { return myCustomDataSource(); }
// Boot's @ConditionalOnMissingBean → its DataSource doesn't register
@ConfigurationProperties Binding
Tunables for auto-configs come from application.yml:
spring:
datasource:
url: jdbc:postgresql://db/app
username: app
hikari:
maximum-pool-size: 20
@ConfigurationProperties(prefix = "spring.datasource")
public class DataSourceProperties { ... }
Common Gotchas
- Bean conflicts: forgot
@ConditionalOnMissingBeanin a custom starter → user can't override. - Component scan misses package:
@SpringBootApplicationscans from its own package down. Move it up the package tree if needed. - Test slices (
@WebMvcTest,@DataJpaTest) load subset of auto-config. Some beans missing in tests but present in prod. - Order surprises: two auto-configs both register a bean of same type → first wins, others skip due to
@ConditionalOnMissingBean.
Interview Soundbite
"Auto-configuration = conditional
@Beandefinitions, gated on classpath + properties + existing beans. Boot looks underMETA-INF/spring/...AutoConfiguration.imports, evaluates each class's conditions, registers what fits. User beans always win because they're processed first and conditions check@ConditionalOnMissingBean."
Q: How do Spring profiles work? How do you handle environment-specific config?
Answer:
Profiles = named groups of beans + properties. Activate per-environment (dev/staging/prod) without code changes.
Activate Profiles
Multiple ways, increasing priority:
# property
spring.profiles.active=prod
# env var
export SPRING_PROFILES_ACTIVE=prod
# CLI
java -jar app.jar --spring.profiles.active=prod
# JVM
-Dspring.profiles.active=prod
Multiple: dev,debug,local.
Profile-Specific Properties
Boot automatically loads:
application.yml # base — always loaded
application-dev.yml # only when "dev" active
application-prod.yml # only when "prod" active
Profile properties override base.
Profile-Specific Beans
@Configuration
@Profile("prod")
public class ProdMailConfig {
@Bean MailSender mailSender() { return new SesMailSender(); }
}
@Configuration
@Profile("!prod") // any non-prod
public class DevMailConfig {
@Bean MailSender mailSender() { return new ConsoleMailSender(); }
}
Method-level too:
@Bean @Profile("prod") DataSource prodDs() { ... }
@Bean @Profile({"dev","test"}) DataSource devDs() { ... }
YAML Multi-Document
Single file, multiple profiles (Boot 2.4+):
spring:
application.name: my-app
---
spring:
config.activate.on-profile: dev
server:
port: 8080
---
spring:
config.activate.on-profile: prod
server:
port: 80
Profile Groups (Boot 2.4+)
spring:
profiles:
group:
production: prod, monitoring, audit
Activate production → all three flip on.
Default Profile
If none active, default profile is. application-default.yml loads. Override:
spring.profiles.default=local
Conditional Beans Beyond Profiles
For finer control:
@ConditionalOnProperty(name = "feature.payments.v2", havingValue = "true")
@Bean PaymentClient v2Client() { ... }
Programmatic Activation
SpringApplication app = new SpringApplication(App.class);
app.setAdditionalProfiles("prod");
app.run(args);
Tests
@SpringBootTest
@ActiveProfiles({"test", "h2"})
class OrderServiceTest { ... }
Common Patterns
1. External secrets per env
# application.yml
db:
url: ${DB_URL}
password: ${DB_PASSWORD}
Profile decides which env vars are set in deployment manifest.
2. Mock vs real integrations in dev
@Profile("local") @Service class FakePaymentClient implements PaymentClient {...}
@Profile("!local") @Service class StripePaymentClient implements PaymentClient {...}
3. Cloud config + profiles
Spring Cloud Config server can serve profile-specific files (app-prod.yml) from git.
Pitfalls
- Forgot to activate → bean missing →
NoSuchBeanDefinitionException. - Multiple profile files but typo in profile name → silently uses defaults.
- Tests inheriting prod profile → hitting real services. Always set
@ActiveProfiles("test"). - Property precedence: command-line > env vars > application-{profile}.yml > application.yml. Knowing this matters when debugging "why is this value not what I set".
Profile-Aware Property Sources Order (highest precedence first)
- Command-line args
SPRING_APPLICATION_JSONapplication-{profile}.properties/yml(external)application.properties/yml(external)application-{profile}.properties/yml(classpath)application.properties/yml(classpath)@PropertySource- Default properties
Profile-specific always wins over base at the same level.
Q: What is Spring Boot Actuator? What endpoints matter in production?
Answer:
Actuator = production-ready monitoring/management endpoints over HTTP (or JMX): health, metrics, info, env, mappings, etc.
Add It
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
Default exposed: /actuator/health and /actuator/info over HTTP. Everything else: JMX-only by default, must opt in.
Expose Endpoints
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,loggers
# exclude: env # hide sensitive ones if include=*
endpoint:
health:
show-details: when_authorized # never | always | when_authorized
Key Endpoints
| Endpoint | Use |
|---|---|
/actuator/health | Liveness/readiness probes (k8s) |
/actuator/info | Build info, git commit |
/actuator/metrics | Counters, gauges, timers (Micrometer) |
/actuator/prometheus | Prometheus-format metrics |
/actuator/env | All resolved properties (sensitive!) |
/actuator/configprops | @ConfigurationProperties beans |
/actuator/beans | Bean graph |
/actuator/mappings | URL → handler mappings |
/actuator/loggers | View / change log levels at runtime |
/actuator/threaddump | Live thread dump |
/actuator/heapdump | Download .hprof |
/actuator/httpexchanges | Recent HTTP requests |
/actuator/scheduledtasks | @Scheduled registry |
/actuator/shutdown | Graceful shutdown (disabled by default) |
Health
{
"status": "UP",
"components": {
"db": {"status":"UP","details":{"database":"PostgreSQL","validationQuery":"isValid()"}},
"diskSpace": {"status":"UP"},
"redis": {"status":"UP"}
}
}
Custom indicator:
@Component
public class StripeHealthIndicator implements HealthIndicator {
@Override
public Health health() {
try {
stripe.ping();
return Health.up().build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}
Liveness vs Readiness (k8s)
Boot exposes both groups under /actuator/health:
/actuator/health/liveness— is the app alive?/actuator/health/readiness— should it receive traffic?
management:
endpoint:
health:
probes:
enabled: true
Metrics (Micrometer)
Boot wires Micrometer in. Auto-registers JVM, system, HTTP, JDBC, JPA, Tomcat metrics.
Custom:
@RestController
class OrderController {
private final Counter ordersPlaced;
OrderController(MeterRegistry r) {
this.ordersPlaced = Counter.builder("orders.placed")
.tag("region", "us-east").register(r);
}
@PostMapping("/orders")
void place(@RequestBody Order o) {
ordersPlaced.increment();
...
}
}
// Timer
@Timed(value = "orders.process.time", percentiles = {0.5, 0.95, 0.99})
public void process(Order o) { ... }
Backends: Prometheus, Datadog, CloudWatch, New Relic, Graphite — add the right micrometer-registry-* dependency.
Securing Actuator
Sensitive endpoints (env, heapdump, loggers, beans) leak config + memory. Lock them:
@Bean
SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception {
return http
.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(a -> a
.requestMatchers(EndpointRequest.to("health", "info")).permitAll()
.anyRequest().hasRole("ADMIN"))
.httpBasic(withDefaults())
.build();
}
Or run actuator on a separate port internal-only:
management:
server:
port: 9090
address: 127.0.0.1
Production Recipe
- Expose:
health,info,metrics,prometheus. - Lock everything else behind auth or internal port.
- Wire
/actuator/prometheusinto Prometheus scrape. - k8s probes → liveness/readiness groups.
- Build info via
spring-boot-maven-pluginbuild-infogoal → shows in/actuator/info.
Useful info Contributors
- Git commit (
spring-boot-starter-actuator+git-commit-id-plugin). - Build time/version (Maven plugin
build-info). - Custom: implement
InfoContributor.
Q: @Controller vs @RestController. How do request mappings, validation, and content negotiation work?
Answer:
@Controller vs @RestController
@Controller— returns view names (Thymeleaf, JSP). Methods must@ResponseBodyto return raw data.@RestController=@Controller+@ResponseBodyon every method. JSON/XML by default.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping("/{id}")
Order get(@PathVariable Long id) { ... }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
Order create(@RequestBody @Valid CreateOrderRequest req) { ... }
@PutMapping("/{id}")
Order update(@PathVariable Long id, @RequestBody @Valid Order o) { ... }
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
void delete(@PathVariable Long id) { ... }
}
Mapping Annotations
@RequestMapping(value="/x", method=GET) // generic
@GetMapping("/x") // shortcut
@PostMapping @PutMapping @DeleteMapping @PatchMapping
Parameters
| Source | Annotation | Example |
|---|---|---|
| URL path variable | @PathVariable | /users/{id} |
| Query string | @RequestParam | ?page=1&size=20 |
| Header | @RequestHeader | Authorization |
| Cookie | @CookieValue | |
| JSON body | @RequestBody | POST body |
Form (x-www-form-urlencoded) | @RequestParam per field | |
| File upload | @RequestPart / MultipartFile | |
| Whole request | HttpServletRequest |
Validation
Add spring-boot-starter-validation. Use Jakarta Bean Validation:
public record CreateOrderRequest(
@NotBlank String customerEmail,
@Min(1) int quantity,
@Size(max = 500) String notes
) {}
@PostMapping
Order create(@RequestBody @Valid CreateOrderRequest req) { ... }
// Invalid → 400 with MethodArgumentNotValidException
For path/query params:
@GetMapping("/orders")
List<Order> list(
@RequestParam @Min(0) int page,
@RequestParam @Max(100) int size) { ... }
// requires @Validated on the controller class
Response Status & Headers
@PostMapping
ResponseEntity<Order> create(@RequestBody @Valid CreateOrderRequest req) {
Order o = service.create(req);
return ResponseEntity
.created(URI.create("/api/orders/" + o.id()))
.header("X-Trace-Id", traceId())
.body(o);
}
Content Negotiation
Spring picks HttpMessageConverter based on Accept header + produces attribute.
@GetMapping(value="/{id}", produces={"application/json","application/xml"})
Order get(@PathVariable Long id) { ... }
Add Jackson XML / YAML modules to support those.
consumes (Body Type Restriction)
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
Wrong Content-Type → 415.
Common Annotations
@CrossOrigin— CORS at controller level (or use a global CORS config).@ModelAttribute— bind form fields to a POJO.@SessionAttribute,@RequestAttribute.
Error Handling
Per-controller:
@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
ProblemDetail handleNotFound(OrderNotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
Global → see @ControllerAdvice (separate question).
ResponseEntity vs Direct Return
- Direct return — cleaner when status is always the same (
@ResponseStatus). ResponseEntity— when you need to vary status, headers, or body shape.
Async Responses
Callable<T>— runs onTaskExecutor, frees the servlet thread.DeferredResult<T>— completed from another thread.CompletableFuture<T>— wraps async pipelines.ResponseBodyEmitter/SseEmitter— server-sent events / streams.
WebFlux Variant
Same annotations, but methods return Mono<T> / Flux<T> and run reactively.
@RestController
class OrderControllerR {
@GetMapping("/{id}")
Mono<Order> get(@PathVariable Long id) { return repo.findById(id); }
}
Test
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockBean OrderService service;
@Test
void createOrder() throws Exception {
when(service.create(any())).thenReturn(new Order(1L));
mvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"customerEmail":"a@b.com","quantity":2}"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1));
}
}
Q: How do you handle exceptions globally in Spring Boot? @ControllerAdvice, ProblemDetail, RFC 7807.
Answer:
Three layers, each broader scope:
try/catchin handler — local, ugly, boilerplate.@ExceptionHandlerin controller — per-controller.@ControllerAdvice— global across all (or selected) controllers.
@ExceptionHandler (Per-Controller)
@RestController
class OrderController {
@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
ErrorResponse notFound(OrderNotFoundException e) {
return new ErrorResponse(e.getMessage());
}
}
@ControllerAdvice / @RestControllerAdvice (Global)
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ProblemDetail> notFound(EntityNotFoundException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
pd.setType(URI.create("https://api.acme.com/errors/not-found"));
pd.setProperty("timestamp", Instant.now());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(pd);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> validation(MethodArgumentNotValidException e) {
Map<String, String> errors = e.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage, (a,b)->a));
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
pd.setProperty("errors", errors);
return ResponseEntity.badRequest().body(pd);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> fallback(Exception e) {
log.error("Unhandled exception", e);
return ResponseEntity.internalServerError()
.body(ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Internal error"));
}
}
@RestControllerAdvice = @ControllerAdvice + @ResponseBody.
ProblemDetail (Spring 6+, Boot 3+)
RFC 7807 standard error format.
{
"type": "https://api.acme.com/errors/not-found",
"title": "Not Found",
"status": 404,
"detail": "Order 42 not found",
"instance": "/api/orders/42",
"timestamp": "2026-04-26T10:00:00Z"
}
Enable RFC 7807 default behavior:
spring:
mvc:
problemdetails:
enabled: true
Built-in Spring exceptions (404, 405, 415, etc.) auto-respond with ProblemDetail.
ResponseStatusException (Quick Throw)
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "order " + id + " not found");
Custom Exception Hierarchy
public abstract class AppException extends RuntimeException {
private final HttpStatus status;
private final String code;
protected AppException(HttpStatus status, String code, String msg) {
super(msg);
this.status = status;
this.code = code;
}
// getters
}
public class OrderNotFoundException extends AppException {
public OrderNotFoundException(long id) {
super(HttpStatus.NOT_FOUND, "ORDER_NOT_FOUND", "order " + id);
}
}
@ExceptionHandler(AppException.class)
public ResponseEntity<ProblemDetail> handle(AppException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(e.getStatus(), e.getMessage());
pd.setProperty("code", e.getCode());
return ResponseEntity.status(e.getStatus()).body(pd);
}
Scope @ControllerAdvice
@RestControllerAdvice(basePackages = "com.acme.api.public")
@RestControllerAdvice(annotations = RestController.class)
@RestControllerAdvice(assignableTypes = {OrderController.class, UserController.class})
ResponseEntityExceptionHandler Base
For full control over Spring's built-in exceptions, extend it:
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders h, HttpStatusCode s, WebRequest r) {
// your custom shape
}
}
Validation Errors — Field-Level Messages
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ProblemDetail> handleValidation(MethodArgumentNotValidException e) {
List<Map<String, String>> errors = e.getBindingResult().getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "message", f.getDefaultMessage()))
.toList();
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation failed");
pd.setProperty("errors", errors);
return ResponseEntity.badRequest().body(pd);
}
Constraint Violations on Path/Query Params
Different exception type:
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ProblemDetail> constraintViolation(ConstraintViolationException e) {
...
}
Pitfalls
- Logging duplication: don't log + rethrow + log again. Pick one layer.
- Exposing internals: never serialize
e.getMessage()blindly — may leak DB schema, paths. - 404 vs 200 with empty body: pick a convention.
Optional<T>controllers +orElseThrowpattern is common. - Order of advice:
@Ordercontrols precedence when multiple@ControllerAdvicecould handle the same exception. - Async exceptions:
@ExceptionHandlerdoesn't catch errors from inside@Asyncmethods — handle there or viaAsyncUncaughtExceptionHandler.
Best Practice Checklist
- One global
@RestControllerAdvice. ProblemDetailfor response shape.- Domain exceptions → mapped statuses, never raw 500.
- Validation handled separately with field-level breakdown.
- Catch-all
Exception.classlast → log full stack, return generic 500.
Q: How does Spring Data JPA work? Repositories, queries, N+1, fetch types.
Answer:
Spring Data JPA = repository abstraction over JPA (Hibernate by default). Define an interface, get a working DAO at runtime via dynamic proxy.
Repository Hierarchy
Repository<T, ID> (marker)
└─ CrudRepository (save, findById, delete, count)
└─ PagingAndSortingRepository
└─ JpaRepository (flush, batch, findAll w/ Sort, getReferenceById)
Define
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerId(Long customerId);
Optional<Order> findByIdAndStatus(Long id, OrderStatus status);
long countByStatus(OrderStatus status);
boolean existsByEmail(String email);
}
No implementation. Spring generates one.
Query Derivation (From Method Names)
findBy / readBy / queryBy / countBy / existsBy ...
findByXAndY findByXOrY findByXBetween findByXIn findByXLike findByXNotNull
findByXOrderByYDesc findFirst10ByXOrderByCreatedAtDesc
@Query (JPQL/HQL)
@Query("select o from Order o where o.customer.email = :email and o.status = :status")
List<Order> activeFor(@Param("email") String email, @Param("status") OrderStatus status);
@Query(value = "select * from orders where total > ?1", nativeQuery = true)
List<Order> highValue(BigDecimal threshold);
Modifying
@Modifying
@Transactional
@Query("update Order o set o.status = :s where o.id = :id")
int updateStatus(@Param("id") Long id, @Param("s") OrderStatus s);
@Modifying required for UPDATE/DELETE/INSERT JPQL.
Pagination & Sort
Page<Order> page = repo.findByStatus(OrderStatus.PAID,
PageRequest.of(0, 20, Sort.by("createdAt").descending()));
page.getContent(); // current page
page.getTotalPages(); // → triggers a count query
Use Slice<T> instead of Page<T> to skip the count query — cheaper for infinite scroll.
N+1 Problem (The Big One)
@Entity
class Order {
@ManyToOne(fetch = FetchType.LAZY) Customer customer;
}
orders.forEach(o -> System.out.println(o.getCustomer().getName()));
// 1 query for orders + N queries (one per order's customer) → N+1
Fixes:
1. JOIN FETCH
@Query("select o from Order o join fetch o.customer where o.status = :s")
List<Order> findWithCustomer(@Param("s") OrderStatus s);
2. @EntityGraph
@EntityGraph(attributePaths = {"customer", "items"})
List<Order> findByStatus(OrderStatus s);
3. Batch fetching (Hibernate)
@BatchSize(size = 50)
@OneToMany ... List<Item> items;
4. DTO projection — best when you only need a subset
public interface OrderSummary {
Long getId();
String getCustomerName();
BigDecimal getTotal();
}
@Query("select o.id as id, o.customer.name as customerName, o.total as total from Order o")
List<OrderSummary> summaries();
FetchType Default Recap
| Relation | Default |
|---|---|
@OneToOne | EAGER |
@ManyToOne | EAGER |
@OneToMany | LAZY |
@ManyToMany | LAZY |
[!IMPORTANT] Make all
@*ToOneLAZY (fetch = FetchType.LAZY). Eager loads cascade — one entity ends up loading half the schema.
Lazy Init Outside Transaction
Order o = repo.findById(1L).get(); // tx ends here
o.getItems().size(); // 💥 LazyInitializationException
Fix: keep transaction open (@Transactional), use JOIN FETCH, or @EntityGraph.
getReferenceById vs findById
Order o = repo.findById(1L).orElseThrow(); // SELECT now
Order ref = repo.getReferenceById(1L); // proxy, no SELECT until access
Useful when assigning @ManyToOne relations without loading the parent:
order.setCustomer(customerRepo.getReferenceById(customerId));
Custom Repository (Beyond Generated Methods)
public interface OrderRepositoryCustom {
List<Order> search(OrderSearchCriteria c);
}
public class OrderRepositoryImpl implements OrderRepositoryCustom {
@PersistenceContext EntityManager em;
public List<Order> search(OrderSearchCriteria c) { /* CriteriaBuilder */ }
}
public interface OrderRepository extends JpaRepository<Order, Long>, OrderRepositoryCustom { }
Specifications (Dynamic Queries)
public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {}
Specification<Order> spec = Specification
.where(OrderSpecs.statusEq(PAID))
.and(OrderSpecs.totalGt(100));
repo.findAll(spec, PageRequest.of(0, 20));
Auditing
@EnableJpaAuditing
public class JpaConfig { }
@Entity
@EntityListeners(AuditingEntityListener.class)
class Order {
@CreatedDate Instant createdAt;
@LastModifiedDate Instant updatedAt;
@CreatedBy String createdBy;
}
Common Pitfalls
- Open Session In View (
spring.jpa.open-in-view) — defaults totrue. Hides N+1 by keeping session alive across the view layer. Disable it in production APIs. - Bidirectional
toString()→ infinite loop. Exclude collections. save()returns the managed entity — assign back:order = repo.save(order);.@Transactionalon private/self-call — proxy bypassed, no transaction. See@Transactionaldeep-dive.- Cascade
ALLon@ManyToMany— deleting one side wipes the other. Avoid.
Q: How does Spring Security work? Filter chain, authentication, authorization, JWT.
Answer:
Spring Security = a chain of servlet filters that intercept every HTTP request. Each filter does one thing (auth, CSRF, logout, etc.).
The Filter Chain
Request → SecurityContextPersistenceFilter
→ LogoutFilter
→ UsernamePasswordAuthenticationFilter (form login)
→ BearerTokenAuthenticationFilter (oauth2 resource server)
→ BasicAuthenticationFilter
→ ExceptionTranslationFilter
→ AuthorizationFilter
→ DispatcherServlet → Controller
Each filter can short-circuit (return 401/403) or pass through.
Modern Configuration (Spring Security 6 / Boot 3)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable()) // disable for stateless API
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("ORDERS_READ")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(withDefaults()))
.exceptionHandling(e -> e
.authenticationEntryPoint((req, res, ex) -> res.sendError(401))
.accessDeniedHandler((req, res, ex) -> res.sendError(403)))
.build();
}
@Bean
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
}
Authentication Pieces
| Type | Use |
|---|---|
Authentication | The principal + credentials + authorities (roles) |
AuthenticationManager | Validates credentials, returns authenticated Authentication |
AuthenticationProvider | Specific strategy (DAO, LDAP, JWT, ...) |
UserDetailsService | Loads user by username (DAO-based auth) |
SecurityContextHolder | ThreadLocal for the current Authentication |
Username/Password Auth
@Service
public class DbUserDetailsService implements UserDetailsService {
private final UserRepo repo;
@Override
public UserDetails loadUserByUsername(String username) {
var u = repo.findByEmail(username).orElseThrow(() -> new UsernameNotFoundException(username));
return User.withUsername(u.email())
.password(u.passwordHash())
.authorities(u.roles().stream().map(r -> "ROLE_" + r).toArray(String[]::new))
.build();
}
}
Stateless JWT (Resource Server)
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.acme.com/realms/app
# auto-discovers jwks-uri from /.well-known/openid-configuration
Boot auto-wires JWT decoder + filter. Just protect routes.
Custom claim → authority:
@Bean
JwtAuthenticationConverter jwtAuthConverter() {
JwtGrantedAuthoritiesConverter g = new JwtGrantedAuthoritiesConverter();
g.setAuthoritiesClaimName("permissions");
g.setAuthorityPrefix("");
JwtAuthenticationConverter c = new JwtAuthenticationConverter();
c.setJwtGrantedAuthoritiesConverter(g);
return c;
}
Method-Level Security
@Configuration
@EnableMethodSecurity // unlocks @PreAuthorize, @PostAuthorize, @Secured
public class MethodSecurityConfig { }
@PreAuthorize("hasAuthority('ORDERS_WRITE')")
public Order create(CreateOrderRequest r) { ... }
@PreAuthorize("#userId == authentication.name") // SpEL — current user matches arg
public User get(String userId) { ... }
@PostAuthorize("returnObject.ownerId == authentication.name")
public Document load(Long id) { ... }
Get Current User
SecurityContextHolder.getContext().getAuthentication().getName();
// Or inject into controller
@GetMapping("/me")
User me(@AuthenticationPrincipal Jwt jwt) {
return service.findByEmail(jwt.getSubject());
}
Common Patterns
1. CORS for SPA
.cors(c -> c.configurationSource(req -> {
var cfg = new CorsConfiguration();
cfg.setAllowedOrigins(List.of("https://app.acme.com"));
cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE"));
cfg.setAllowCredentials(true);
return cfg;
}))
2. Multiple filter chains (e.g., public API + admin)
@Bean @Order(1)
SecurityFilterChain admin(HttpSecurity http) throws Exception {
return http.securityMatcher("/admin/**")...build();
}
@Bean @Order(2)
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http.securityMatcher("/api/**")...build();
}
3. Password encoding
Always BCrypt or Argon2. Never plaintext. Use DelegatingPasswordEncoder to support migrations.
CSRF
- Stateless API + token auth (JWT) → disable CSRF.
- Session-based browser app → keep CSRF on. Spring Security uses cookie + header double-submit.
Common Pitfalls
hasRole("ADMIN")vshasAuthority("ROLE_ADMIN")—hasRoleauto-prefixesROLE_. Authority strings either includeROLE_or not — pick one convention.- Forgetting
@EnableMethodSecurity—@PreAuthorizesilently does nothing. permitAll()in URL config but@PreAuthorizedenies — both layers run; deny wins.SecurityContextHolder+ thread pools — child threads don't inherit context unless you useDelegatingSecurityContextExecutor.- CORS configured on Spring MVC but not Security — preflight blocked by Security filter before MVC sees it.
Test
@WebMvcTest(OrderController.class)
class OrderControllerSecurityTest {
@Autowired MockMvc mvc;
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDelete() throws Exception {
mvc.perform(delete("/api/orders/1")).andExpect(status().isNoContent());
}
@Test
void anonGetsUnauthorized() throws Exception {
mvc.perform(get("/api/orders/1")).andExpect(status().isUnauthorized());
}
}
Q: How does Spring's @Cacheable work? Caveats around proxies, keys, and invalidation.
Answer:
Spring Cache abstraction = annotations (@Cacheable, @CacheEvict, @CachePut) backed by a pluggable provider (Caffeine, Redis, Ehcache, Hazelcast).
Enable
@EnableCaching
@Configuration class CacheConfig { }
Default provider: ConcurrentMapCacheManager (heap, no eviction). Real apps use Caffeine or Redis.
Annotations
| Annotation | Effect |
|---|---|
@Cacheable | Lookup cache; on miss, run method, store result |
@CachePut | Always run method, store result (refresh) |
@CacheEvict | Remove entry (or all) |
@Caching | Compose multiple |
Examples
@Cacheable(value = "products", key = "#id")
public Product findById(Long id) { return repo.findById(id).orElseThrow(); }
@CachePut(value = "products", key = "#p.id")
public Product update(Product p) { return repo.save(p); }
@CacheEvict(value = "products", key = "#id")
public void delete(Long id) { repo.deleteById(id); }
@CacheEvict(value = "products", allEntries = true)
public void clearAll() { }
Key Generation
- Default:
SimpleKeyGenerator— combines all params. - SpEL via
key:@Cacheable(value="users", key="#email.toLowerCase()") @Cacheable(value="orders", key="#root.methodName + '-' + #status + '-' + #page") - Custom: implement
KeyGenerator.
condition and unless
@Cacheable(value="products", key="#id", condition="#id > 0", unless="#result == null")
public Product find(long id) { ... }
conditionevaluated before call → skip caching when false.unlessevaluated after call → skip storing when true.
Caffeine Setup
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
spring:
cache:
type: caffeine
cache-names: products, users
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m,recordStats
Programmatic per-cache config:
@Bean
CacheManager cacheManager() {
CaffeineCacheManager m = new CaffeineCacheManager("products", "users");
m.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats());
return m;
}
Redis Setup
spring:
cache:
type: redis
redis:
time-to-live: 600000 # ms
cache-null-values: false
@Bean
RedisCacheManager cacheManager(RedisConnectionFactory cf) {
return RedisCacheManager.builder(cf)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())))
.withCacheConfiguration("products", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1)))
.build();
}
Self-Invocation Trap (BIG One)
Spring caching is proxy-based. Internal calls bypass the proxy.
@Service
class ProductService {
@Cacheable("products")
public Product find(Long id) { ... }
public Product findAndCount(Long id) {
return find(id); // ❌ same-class call → no proxy → no cache!
}
}
Fixes:
- Move method to a different bean.
- Self-inject:
@Lazy @Autowired ProductService self; public Product findAndCount(Long id) { return self.find(id); } - Use
AopContext.currentProxy()(requires@EnableCaching(exposeProxy = true)).
Other Pitfalls
1. Caching null
Default behavior: null cached. Often you don't want that:
@Cacheable(value="users", key="#id", unless="#result == null")
Or for Redis, set cache-null-values: false.
2. Mutable returned objects Caller mutates → next cache hit returns mutated. Use immutable types or defensive copies.
3. Cache stampede / thundering herd
Many concurrent misses → all hit DB. Caffeine handles this by default (AsyncCache or sync loading). Redis caches don't — implement single-flight or use Caffeine in front of Redis.
4. Stale data
TTL too long → stale; too short → cache useless. Combine TTL with explicit @CacheEvict on writes.
5. Multi-arg key surprises
@Cacheable("orders")
List<Order> list(int page, int size, Sort sort) { ... }
// SimpleKey(page, size, sort) — sort.toString() may not be deterministic across instances
Define an explicit key SpEL.
@CacheEvict(beforeInvocation = true)
Default: evict after method returns. If method throws, cache remains. Set beforeInvocation = true to evict regardless.
@Caching (Compose)
@Caching(evict = {
@CacheEvict(value="orders", key="#id"),
@CacheEvict(value="orderSummaries", allEntries=true)
})
public void delete(Long id) { ... }
When NOT to Cache
- Highly write-heavy data — cache invalidation cost dwarfs read savings.
- Per-user data with low repeat rate.
- Anything sensitive without thinking through eviction on auth/role changes.
Q: How do @Async and @Scheduled work in Spring? Common gotchas.
Answer:
Both are proxy-based annotations. Spring intercepts the call and dispatches to a TaskExecutor (@Async) or TaskScheduler (@Scheduled).
Enable
@EnableAsync
@EnableScheduling
@Configuration class AsyncConfig { }
@Async Basics
@Service
class NotificationService {
@Async
public void send(Notification n) { httpClient.post(n); }
@Async
public CompletableFuture<Report> generate(Long userId) {
Report r = build(userId);
return CompletableFuture.completedFuture(r);
}
}
Caller returns immediately. Method runs on the configured executor.
Return types:
void— fire-and-forget.Future<T>/CompletableFuture<T>— caller can.get()/ chain.ListenableFuture<T>— deprecated, preferCompletableFuture.
Configure Executor
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(8);
ex.setMaxPoolSize(32);
ex.setQueueCapacity(500);
ex.setThreadNamePrefix("async-");
ex.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
ex.initialize();
return ex;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, m, params) -> log.error("Async error in {}", m.getName(), ex);
}
}
Multiple executors:
@Bean("ioExecutor") Executor ioExecutor() { ... }
@Bean("cpuExecutor") Executor cpuExecutor() { ... }
@Async("ioExecutor")
public void download(URL u) { ... }
@Async Pitfalls
1. Self-invocation — same as @Cacheable. Calling this.async() from within the same class bypasses the proxy → runs synchronously.
2. Default executor
Pre-Boot 3, default was SimpleAsyncTaskExecutor — creates a new thread per call. Disaster under load. Always configure explicitly.
3. Exception propagation
voidreturn → exception swallowed unlessAsyncUncaughtExceptionHandlerset.Futurereturn → exception delivered viaFuture.get().
4. Transactions
@Async runs in a different thread → loses transaction context from caller. Annotate the async method itself with @Transactional if needed (start a fresh tx).
5. Security context
ThreadLocal SecurityContext doesn't propagate. Use:
@Bean
TaskDecorator securityDecorator() {
return runnable -> {
SecurityContext ctx = SecurityContextHolder.getContext();
return () -> {
try {
SecurityContextHolder.setContext(ctx);
runnable.run();
} finally {
SecurityContextHolder.clearContext();
}
};
};
}
// Wire into ThreadPoolTaskExecutor#setTaskDecorator
@Scheduled Basics
@Component
class CleanupJob {
@Scheduled(fixedRate = 60_000) // every 60s, regardless of duration
void cleanup() { ... }
@Scheduled(fixedDelay = 60_000) // 60s after previous run finishes
void poll() { ... }
@Scheduled(initialDelay = 5000, fixedRate = 30_000)
void warmup() { ... }
@Scheduled(cron = "0 0 2 * * *", zone = "UTC") // 2am UTC daily
void nightly() { ... }
}
Cron Format (Spring Style)
sec min hour day-of-month month day-of-week
0 0 2 * * * → 2am every day
0 */5 * * * * → every 5 min
0 0 9 * * MON-FRI → 9am weekdays
Spring also supports macros: @hourly, @daily, @weekly.
Default Scheduler
Single-threaded! Long task blocks others. Configure:
@Bean
TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler s = new ThreadPoolTaskScheduler();
s.setPoolSize(10);
s.setThreadNamePrefix("sched-");
s.initialize();
return s;
}
Or via property:
spring.task.scheduling.pool.size: 10
@Scheduled Pitfalls
1. Multi-instance deployment Every instance fires the job. For a "run once cluster-wide" semantic, use:
- DB-backed lock (
ShedLock— most common solution). - Quartz with JDBC store.
- Leader election (e.g., via Kubernetes lease).
@Scheduled(cron = "0 0 * * * *")
@SchedulerLock(name = "hourlyJob", lockAtLeastFor = "30s", lockAtMostFor = "10m")
void hourly() { ... }
2. Method must be void and parameterless (unless dynamic via SchedulingConfigurer).
3. Exceptions — uncaught exception kills next iteration of fixedRate jobs in some setups. Wrap in try/catch + log.
4. Time zones — server time vs UTC vs business time zone. Always specify zone in cron.
Dynamic Schedules
@Configuration
@EnableScheduling
public class DynamicSchedule implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
registrar.addTriggerTask(
() -> doWork(),
ctx -> {
String cron = config.getCronExpression(); // re-read each time
return new CronTrigger(cron).nextExecution(ctx);
});
}
}
@Async + @Scheduled Combined
@Async
@Scheduled(fixedRate = 30_000)
public void refresh() { ... }
Decouples scheduling tick from work — scheduler thread isn't blocked.
Modern Alternative — Virtual Threads
Boot 3.2+ on Java 21:
spring.threads.virtual.enabled: true
Auto-uses virtual threads for @Async, request handling, and many other places. Reduces need to tune pool sizes.
Q: How do you test Spring Boot apps? Slices, @SpringBootTest, @MockBean, Testcontainers.
Answer:
Spring Boot offers test slices (load minimal context for layer under test) and full-context tests (load entire app). Pick the smallest scope that exercises what you're testing.
Test Pyramid in Boot
| Type | Annotation | Scope |
|---|---|---|
| Unit | none (pure JUnit/Mockito) | One class, fastest |
| Slice | @WebMvcTest, @DataJpaTest, etc. | One layer, mocks rest |
| Integration | @SpringBootTest | Full context, slower |
| E2E | @SpringBootTest(webEnvironment=RANDOM_PORT) + Testcontainers | Real server + DB |
Unit Test (No Spring)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository repo;
@Mock PaymentClient pay;
@InjectMocks OrderService service;
@Test
void createsOrder() {
when(repo.save(any())).thenAnswer(i -> i.getArgument(0));
Order o = service.create(new CreateOrderRequest(...));
assertThat(o.id()).isNotNull();
verify(pay).charge(any());
}
}
Fastest. No Spring overhead. Use whenever possible.
@WebMvcTest (Controller Slice)
Loads only MVC infrastructure (controllers, filters, advice). Other beans must be mocked.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockBean OrderService service; // service mocked, controller real
@Test
void getOrder() throws Exception {
when(service.find(1L)).thenReturn(new Order(1L, "PAID"));
mvc.perform(get("/api/orders/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PAID"));
}
}
@DataJpaTest (Repository Slice)
- Configures in-memory DB (H2) by default.
- Wraps each test in transaction + rollback.
- Loads only JPA components.
@DataJpaTest
class OrderRepositoryTest {
@Autowired OrderRepository repo;
@Autowired TestEntityManager em;
@Test
void findsByStatus() {
em.persist(new Order("PAID"));
em.persist(new Order("REFUNDED"));
assertThat(repo.findByStatus("PAID")).hasSize(1);
}
}
To use the real DB:
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
Other Slices
| Slice | Loads |
|---|---|
@JsonTest | Jackson + JSON test utilities |
@RestClientTest | RestTemplate / RestClient + MockRestServiceServer |
@WebFluxTest | WebFlux equivalent of @WebMvcTest |
@DataMongoTest, @DataRedisTest, @DataR2dbcTest | Per-store slices |
@WebServiceClientTest | SOAP client |
@SpringBootTest (Full Context)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class OrderApiIT {
@Autowired TestRestTemplate http;
@Test
void createOrder() {
var resp = http.postForEntity("/api/orders", new CreateOrderRequest(...), Order.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
}
WebEnvironment options:
MOCK(default) —MockMvc-style, no real server.RANDOM_PORT— real Tomcat on random port.DEFINED_PORT— usesserver.port.NONE— no servlet env.
@MockBean and @SpyBean
Replace a bean in the context with a Mockito mock/spy.
@SpringBootTest
class CheckoutTest {
@MockBean PaymentGateway gateway; // replaces real bean
@Test
void doesntCallProduction() {
when(gateway.charge(any())).thenReturn(Receipt.ok());
...
}
}
[!IMPORTANT] Boot 3.4+ deprecated
@MockBean/@SpyBeanin favor of@MockitoBean/@MockitoSpyBean.
Testcontainers (Real DB / Kafka / Redis)
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class OrderApiIT {
@Container
static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", pg::getJdbcUrl);
r.add("spring.datasource.username", pg::getUsername);
r.add("spring.datasource.password", pg::getPassword);
}
// tests here use a real Postgres
}
Boot 3.1+ has built-in Testcontainers support via @ServiceConnection:
@Container @ServiceConnection
static PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:16");
// Boot auto-wires datasource, no @DynamicPropertySource needed
Test Configuration
@TestConfiguration
class TestConfig {
@Bean ClockProvider fixedClock() { return () -> Clock.fixed(...); }
}
Use @Import(TestConfig.class) or place in src/test/java.
Useful Annotations
@TestPropertySource | Override properties for one test class |
@DirtiesContext | Force context reload (slow, use sparingly) |
@Transactional | Wrap each test in tx + rollback (works with @SpringBootTest) |
@Sql | Run SQL scripts before/after tests |
@WithMockUser | Inject a fake authenticated user (Spring Security) |
@Tag("slow") | Group tests for selective runs |
Common Pitfalls
- Context caching — Spring caches contexts by config. Different
@TestPropertySource/@MockBeancombos = new context.@DirtiesContextdefeats caching → slow build. @MockBeaninvalidates cache — every unique combination spawns a fresh context. Centralize mocks in shared test classes.@Transactionalwith REST — request runs in a different thread; rollback applies to the test's own thread. For HTTP tests, use Testcontainers + manual cleanup.- Random port — get via
@LocalServerPort int portorTestRestTemplate. - Slow tests due to full context — most tests should be unit or slice tests.
Recipe
- Service logic → unit test with mocks.
- Controller serialization →
@WebMvcTest. - Repository queries →
@DataJpaTest(or with Testcontainers for Postgres-specific SQL). - Full app smoke →
@SpringBootTest+ Testcontainers, kept few in number.
Q: What is AOP in Spring? How does it work, and why is the proxy detail important?
Answer:
AOP = Aspect-Oriented Programming. Cross-cutting concerns (logging, transactions, security, caching, metrics) extracted from business code into aspects that wrap target methods.
Spring's AOP is built on proxies, not bytecode weaving (unlike AspectJ).
Core Vocabulary
| Term | Meaning |
|---|---|
| Aspect | A class encapsulating a concern (@Aspect) |
| Join point | A point where advice can run (Spring AOP: only method calls) |
| Advice | Code that runs at a join point (@Before, @After, @Around, ...) |
| Pointcut | Expression matching join points |
| Weaving | Linking aspects to target — Spring does it via runtime proxies |
Example
@Aspect
@Component
public class TimingAspect {
@Around("execution(public * com.acme.service..*(..))")
public Object time(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
try {
return pjp.proceed();
} finally {
long us = (System.nanoTime() - start) / 1000;
log.info("{} took {}us", pjp.getSignature().toShortString(), us);
}
}
}
Advice Types
@Before("execution(* OrderService.create(..))")
void log(JoinPoint jp) { log.info("calling {}", jp.getSignature()); }
@AfterReturning(pointcut = "execution(* OrderService.create(..))", returning = "result")
void onReturn(Order result) { log.info("returned {}", result); }
@AfterThrowing(pointcut = "...", throwing = "ex")
void onThrow(Exception ex) { log.error("failed", ex); }
@After("...") // finally
void always() { }
@Around("...")
Object around(ProceedingJoinPoint pjp) throws Throwable { ... }
Pointcut Designators (Spring AOP Subset)
execution(public * com.acme..*Service.*(..)) // method execution
within(com.acme.service..*) // any method in package
@annotation(Loggable) // methods annotated @Loggable
@within(org.springframework.stereotype.Service) // methods in @Service classes
@target(...) args(...) this(...) target(...) bean(orderService)
Reusable Pointcut
@Aspect @Component
public class Pointcuts {
@Pointcut("execution(* com.acme.service..*(..))")
void service() {}
}
@Around("com.acme.aspects.Pointcuts.service()")
public Object x(ProceedingJoinPoint p) { ... }
Custom Annotation Pattern (Common)
@Target(METHOD) @Retention(RUNTIME)
public @interface RateLimit { int perSecond(); }
@Aspect @Component
public class RateLimitAspect {
@Around("@annotation(rateLimit)")
public Object check(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
if (!limiter.tryAcquire(rateLimit.perSecond())) {
throw new TooManyRequestsException();
}
return pjp.proceed();
}
}
@Service
public class ApiService {
@RateLimit(perSecond = 10)
public void call() { ... }
}
How Proxies Work
- Bean has interface → JDK dynamic proxy (interface-based).
- No interface → CGLIB subclass proxy (cglib-style bytecode subclass).
- Spring 5+ default for unsuited classes: CGLIB. Force with
@EnableAspectJAutoProxy(proxyTargetClass = true).
The proxy intercepts external method calls, runs advice, delegates to the target.
The Self-Invocation Trap
@Service
class UserService {
@Transactional public void outer() { inner(); } // ❌ self-call
@Transactional public void inner() { ... }
}
outer calls this.inner(), not the proxy. Inner's @Transactional (or any aspect annotation) is ignored. Same applies to @Async, @Cacheable, @PreAuthorize, custom aspects.
Fixes:
- Move
innerto another bean. - Self-inject:
@Autowired @Lazy UserService self; public void outer() { self.inner(); } - Expose proxy:
@EnableAspectJAutoProxy(exposeProxy = true)then((UserService) AopContext.currentProxy()).inner();.
Spring AOP vs AspectJ
| Spring AOP | AspectJ | |
|---|---|---|
| Weaving | Runtime (proxy) | Compile time / load time |
| Join points | Method calls only | Constructors, fields, blocks, more |
| Performance | Slight runtime cost | Near-native |
| Self-call problem | Yes | No |
| Setup | Just annotations | Requires AspectJ compiler / agent |
For field access or constructor interception → AspectJ load-time weaving.
Order of Aspects
@Aspect @Component @Order(1) class FirstAspect {}
@Aspect @Component @Order(2) class SecondAspect {}
Lower @Order = wraps outermost = runs first on entry, last on exit.
Real Examples in Spring Itself
@Transactional—TransactionAspectSupport.@Async—AnnotationAsyncExecutionInterceptor.@Cacheable—CacheInterceptor.@PreAuthorize—MethodSecurityInterceptor.
All proxy-based. All affected by self-invocation. Same rules.
Common Pitfalls
- Self-invocation (above).
- Aspect on
private/static/finalmethod → won't work (CGLIB can't subclass). - Aspect on a non-Spring-managed object (
newinstead of injected) → no proxy → no advice. - Two aspects with same priority → undefined order.
- Forgetting
@EnableAspectJAutoProxy(Boot enables it automatically).
Q: Optimistic vs Pessimistic locking in JPA — when to use which?
Answer:
Both locks solve lost updates (two transactions read the same row, both write back, one overwrites the other). They differ in when they detect conflict and how much they hold off other readers/writers.
The Lost-Update Problem
T1: SELECT balance FROM account WHERE id=1 --> 100
T2: SELECT balance FROM account WHERE id=1 --> 100
T1: UPDATE account SET balance = 100 - 30 WHERE id=1
T2: UPDATE account SET balance = 100 - 50 WHERE id=1
^^ overwrites T1's update, balance is 50, not 20
Both committed. Neither saw the other. Money disappears.
Optimistic Locking (Version Column)
Assumes conflicts are rare. Detects them at commit time and fails fast.
@Entity
class Account {
@Id Long id;
BigDecimal balance;
@Version
long version;
}
JPA-generated SQL on update:
UPDATE account
SET balance = ?, version = version + 1
WHERE id = ? AND version = ?
If version doesn't match → 0 rows affected → JPA throws OptimisticLockException (Spring: ObjectOptimisticLockingFailureException).
Caller retries:
@Retryable(retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 3, backoff = @Backoff(50))
public void debit(Long id, BigDecimal amt) {
var a = repo.findById(id).orElseThrow();
a.setBalance(a.getBalance().subtract(amt));
repo.save(a); // version check happens on flush
}
Pessimistic Locking (Database Row Lock)
Assumes conflicts are common. Locks rows in the DB so others wait.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Account findForUpdate(@Param("id") Long id);
Generated SQL:
SELECT * FROM account WHERE id = ? FOR UPDATE
Other transactions doing SELECT ... FOR UPDATE on the same row block until this transaction commits/rolls back.
JPA lock modes:
| Mode | SQL | Use |
|---|---|---|
PESSIMISTIC_READ | FOR SHARE (Postgres) / LOCK IN SHARE MODE (MySQL) | Read but prevent others' writes |
PESSIMISTIC_WRITE | FOR UPDATE | Exclusive — most common |
PESSIMISTIC_FORCE_INCREMENT | FOR UPDATE + bump @Version | Force a version change even on read |
Timeout to avoid hanging forever:
@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")
Choosing Between Them
| Aspect | Optimistic | Pessimistic |
|---|---|---|
| Conflict assumption | Rare | Common |
| Cost on happy path | None | Lock overhead, contention |
| Failure mode | Exception → retry | Wait → possible deadlock |
| Long transactions | Fine | Dangerous (holds locks) |
| Cross-system (HTTP + DB) | Best fit (DB lock doesn't span an HTTP request) | Doesn't fit |
| Reporting / analytical queries on same row | Doesn't block | Blocks |
Use optimistic when:
- Read-modify-write across HTTP requests (the typical web app).
- Reads >> writes, conflicts are rare.
- Edits go through a UI and you want "someone else changed this — refresh?" semantics.
Use pessimistic when:
- Short-lived service-internal transactions with high contention (inventory, seat booking, ledger postings).
- You'd rather queue than retry.
- Logic between read and write is complex enough that retry is wasteful.
Worked Example: Inventory Decrement
Pessimistic:
@Transactional
public void reserve(Long sku, int qty) {
Item item = repo.findForUpdate(sku); // FOR UPDATE
if (item.getStock() < qty) throw new OutOfStock();
item.setStock(item.getStock() - qty);
}
Optimistic (more concurrent throughput, can spurious-fail under load):
@Retryable(retryFor = OptimisticLockException.class, maxAttempts = 5)
@Transactional
public void reserve(Long sku, int qty) {
Item item = repo.findById(sku).orElseThrow();
if (item.getStock() < qty) throw new OutOfStock();
item.setStock(item.getStock() - qty); // version-checked update
}
Under high contention, optimistic burns CPU on retries. Under low contention, pessimistic introduces unnecessary blocking. Measure.
Pitfalls
| Pitfall | Fix |
|---|---|
@Version on a Hibernate-managed dirty entity bypassed via native UPDATE | Always go through the entity for writes, or manually bump version |
| Pessimistic lock held across remote calls | Never hold a DB row lock during an HTTP/gRPC call |
| Deadlocks from inconsistent lock order | Always lock rows in the same order (e.g., by primary key) |
| Retry storms after optimistic failure | Add backoff with jitter; cap retries; surface to user |
Forgetting that findForUpdate outside @Transactional is a no-op | The lock needs an open transaction |
Hybrid: Skip Locked (Worker Queues)
SELECT * FROM jobs
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED
Multiple workers grab different rows without blocking each other. Available via:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHint(name = "jakarta.persistence.lock.timeout", value = "-2") // SKIP_LOCKED hint depends on dialect
Postgres/MySQL support; useful for outbox patterns and job queues.
[!NOTE] Neither lock helps if you write directly with native SQL that bypasses Hibernate. Mixing modes is fine — but be explicit about it in code review.
Interview Follow-ups
- "What is the default isolation level?" —
READ_COMMITTEDfor most JDBC drivers.REPEATABLE_READfor MySQL. Neither prevents lost updates without@VersionorFOR UPDATE. - "Does
SERIALIZABLEmake this unnecessary?" — Theoretically yes (in Postgres, via SSI), but with retry storms on conflict. Most teams stay at READ_COMMITTED and use explicit locking. - "
@Lockvs@Transactional(isolation=...)?" — Different layers. Isolation is the policy; lock is the mechanism on a specific query.
Q: How do you build idempotent REST APIs with the Idempotency-Key pattern?
Answer:
A request is idempotent if making it twice has the same effect as making it once. For unsafe HTTP methods (POST, PATCH) that's not free — you have to design for it. The standard pattern, adopted by Stripe and now formalized as IETF draft draft-ietf-httpapi-idempotency-key-header, uses an Idempotency-Key header.
Why It Matters
Network failures don't tell you whether your write succeeded. The client retries, the server processes a second POST /payments → double-charge. The fix can't live in the client (timeouts aren't reliable) or in the network (retries are necessary). It has to live in the server.
Client Server
│ POST /payments ─────────►
│ charge OK
│ ◄ ── ── ── X (TCP reset)
│
│ retry POST /payments ────►
│ charge AGAIN <-- bug
The Contract
POST /payments
Idempotency-Key: 8a4b8c3e-...
Content-Type: application/json
{ "amount": 100, "currency": "USD" }
Server promise:
- If this key was never seen, perform the operation, store the response, return it.
- If this key was seen with the same request body, return the stored response — do not perform the operation again.
- If this key was seen with a different body, return
422 Unprocessable Entity(key reuse with conflict). - If a request with this key is in flight, return
409 Conflictor wait.
Data Model
CREATE TABLE idempotency_records (
key VARCHAR(64) PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL,
status_code SMALLINT NOT NULL,
response_body TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL
);
keyis the client-supplied header.request_hashis SHA-256 over the canonicalized request payload (used to detect (3)).expires_atlets you GC keys after 24h–7d (per Stripe convention).
Spring Implementation Sketch
@Component
@RequiredArgsConstructor
public class IdempotencyFilter extends OncePerRequestFilter {
private final IdempotencyStore store;
private final ObjectMapper mapper;
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse resp,
FilterChain chain) throws IOException, ServletException {
if (!isUnsafeMethod(req)) { chain.doFilter(req, resp); return; }
String key = req.getHeader("Idempotency-Key");
if (key == null) { chain.doFilter(req, resp); return; }
var cached = new ContentCachingRequestWrapper(req);
byte[] body = cached.getContentAsByteArray();
String hash = sha256(body);
Optional<IdempotencyRecord> existing = store.find(key);
if (existing.isPresent()) {
var rec = existing.get();
if (!rec.requestHash().equals(hash)) {
resp.setStatus(422);
resp.getWriter().write("{\"error\":\"idempotency key reused with different payload\"}");
return;
}
resp.setStatus(rec.statusCode());
resp.getWriter().write(rec.responseBody());
return;
}
// First time: capture response, then store
var respWrapper = new ContentCachingResponseWrapper(resp);
chain.doFilter(cached, respWrapper);
if (respWrapper.getStatus() < 500) {
store.save(new IdempotencyRecord(
key, hash, respWrapper.getStatus(),
new String(respWrapper.getContentAsByteArray())));
}
respWrapper.copyBodyToResponse();
}
}
Concurrent Requests with the Same Key
A naïve check-then-write race lets two requests both pass the "not present" check. Two defenses:
1. Insert-first lock row.
INSERT INTO idempotency_records(key, status_code, response_body, ...)
VALUES (?, 0, '', ...)
ON CONFLICT DO NOTHING
RETURNING key;
- If you got a row, you own the operation.
- If
INSERT ... DO NOTHINGreturned nothing, someone else is processing — return409or poll the row.
2. Distributed lock (Redis SETNX).
SET idempotency:KEY processing NX EX 60
Lighter weight but adds another dependency.
Scope of Idempotency
| Scope | Example |
|---|---|
| Per resource | POST /accounts/{id}/payments — key valid only for that account |
| Per tenant | Key namespaced by tenant_id |
| Global | Rarely sensible |
Always include the authenticated user/account in the key namespace, otherwise one user's key collides with another's.
Response Replay vs Re-execution
Two interpretations:
- Strict idempotency: replay the stored response bytes, even if the underlying resource has since changed. (Stripe does this.)
- Effect idempotency: re-execute the operation, rely on uniqueness constraints to no-op. (Simpler, but the response can differ.)
Strict is what clients expect.
Common Mistakes
| Mistake | Fix |
|---|---|
| Storing response before operation commits | Use the same DB transaction for business write + idempotency record |
Idempotency middleware around 5xx responses | Don't cache server errors — let client retry |
| Treating GET as needing idempotency | GET is already idempotent by definition |
| No expiry on idempotency records | Table grows forever; set TTL (Stripe: 24h) |
| Hashing raw body bytes including timestamps | Canonicalize JSON first (sort keys, normalize whitespace) |
[!NOTE] Idempotency-Key is for at-least-once delivery semantics over an at-most-once business operation. It doesn't replace transactional outbox or saga patterns — it's the request-layer half of those.
Interview Follow-ups
- "How is this different from
@Transactional?" —@Transactionalmakes DB writes atomic. Idempotency-Key makes the whole HTTP request replayable. They're orthogonal; you typically use both. - "Why not let clients use a unique business ID instead?" — They should — for the business object — but you still need an envelope key for the HTTP retry, because the business write might or might not have happened on the first attempt.
- "Where do you put the idempotency store?" — Same DB as the business data, so a single transaction covers both. Redis is faster but introduces a two-phase-commit problem.
Q: What is a circuit breaker, and how do you use Resilience4j in Spring Boot?
Answer:
A circuit breaker is a pattern that stops calling a failing dependency to give it time to recover and to prevent cascading failure. Resilience4j is the modern Java implementation (Hystrix has been in maintenance since 2018).
The Failure It Prevents
Service A ──► Service B (slow / down)
Without breaker:
- A's threads block waiting on B.
- Connection pool exhausts.
- A starts failing requests it could otherwise serve.
- Cascade: any upstream of A starts to fail.
A circuit breaker stops calling B after a threshold of failures, returning a fallback immediately. A's resources stay healthy. B gets a chance to recover.
The State Machine
┌────────────┐ failure rate > threshold ┌───────────┐
│ CLOSED │ ─────────────────────────► │ OPEN │
│ (calls B) │ │ (fails │
│ │ ◄───────────────────── │ fast) │
└────────────┘ success in HALF_OPEN └─────┬─────┘
│ wait duration
▼
┌───────────────┐
│ HALF_OPEN │
│ (probe calls) │
└───────────────┘
- CLOSED: normal operation; track success/failure metrics in a sliding window.
- OPEN: requests fail fast (don't even try B). After a wait, transition to half-open.
- HALF_OPEN: allow N probe calls. If they succeed, close. If they fail, open again.
Resilience4j Setup
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
</dependency>
resilience4j:
circuitbreaker:
instances:
paymentsApi:
sliding-window-type: COUNT_BASED
sliding-window-size: 50
minimum-number-of-calls: 20
failure-rate-threshold: 50 # percent
slow-call-rate-threshold: 60
slow-call-duration-threshold: 2s
wait-duration-in-open-state: 30s
permitted-number-of-calls-in-half-open-state: 5
automatic-transition-from-open-to-half-open-enabled: true
Annotate the call:
@Service
class PaymentClient {
@CircuitBreaker(name = "paymentsApi", fallbackMethod = "fallback")
public PaymentResp charge(ChargeReq req) {
return restClient.post().uri("/charges").body(req).retrieve().body(PaymentResp.class);
}
PaymentResp fallback(ChargeReq req, Throwable t) {
return PaymentResp.queued(req); // or throw a domain exception
}
}
The fallback must have the same signature plus a trailing Throwable parameter (or specific exception type).
Combine with Other Patterns
A circuit breaker alone is rarely enough. Stack:
@Retry(name = "paymentsApi")
@CircuitBreaker(name = "paymentsApi", fallbackMethod = "fallback")
@Bulkhead(name = "paymentsApi")
@TimeLimiter(name = "paymentsApi") // for CompletableFuture-returning methods
public CompletableFuture<PaymentResp> charge(ChargeReq req) { ... }
Order matters (outermost to innermost as decorator):
Retry → CircuitBreaker → RateLimiter → Bulkhead → TimeLimiter
- TimeLimiter: bounds individual call duration (slow B doesn't tie up a thread).
- Bulkhead: bounds concurrent calls — isolates B's failure from A's other dependencies.
- RateLimiter: caps QPS to be a good citizen.
- Retry: handles transient failures with backoff.
- CircuitBreaker: handles sustained failure.
Choosing Thresholds
| Setting | Typical | Why |
|---|---|---|
minimum-number-of-calls | 20–50 | Avoid flipping on a tiny sample |
failure-rate-threshold | 50% | Half the calls failing = something's wrong |
slow-call-duration-threshold | p99 of healthy traffic × 2 | Don't conflate slow with broken |
wait-duration-in-open-state | 30s | Long enough for dependency to recover, short enough to retry quickly |
permitted-number-of-calls-in-half-open-state | 5–10 | Probe load |
What Counts as a Failure
By default: any thrown exception. Customize:
record-exceptions:
- org.springframework.web.client.RestClientException
- java.io.IOException
ignore-exceptions:
- com.example.ValidationException # 4xx is the client's fault, don't open breaker
Critical: do not let a 400-class response open the breaker. That's the caller's bug, not the dependency's.
Observability
Resilience4j publishes Micrometer metrics:
resilience4j.circuitbreaker.state{name="paymentsApi", state="open"} 1
resilience4j.circuitbreaker.calls{name="paymentsApi", kind="successful"} 12345
resilience4j.circuitbreaker.failure.rate{name="paymentsApi"} 0.42
Alert on:
state == openfor more than a brief flap.failure.rate > threshold * 0.8(about to open).
Also enable health indicator:
management.health.circuitbreakers.enabled: true
Common Mistakes
| Mistake | Fix |
|---|---|
| Catching the exception inside the annotated method | Breaker never sees the failure — let it propagate |
| Fallback that also calls the dependency | Fallback must be cheap and local |
Same name for unrelated dependencies | One bad dependency opens the breaker for both |
| Counting 404 as a failure | Use ignore-exceptions for expected client-side errors |
Wrapping a @Cacheable method directly | Cache-hit short-circuits the breaker; usually fine but be aware |
[!NOTE] A circuit breaker is not a load shedder. If you're overloading B, breakers will hide it temporarily but not solve it. Pair with rate limiters and capacity planning.
Interview Follow-ups
- "Why not just retry?" — Retries multiply load on a struggling dependency. A breaker stops calling at all.
- "Difference between bulkhead and circuit breaker?" — Bulkhead limits concurrent calls to isolate failure domains. Breaker decides whether to call at all based on recent failure rate. Complementary.
- "How is this related to a sliding-window rate limiter?" — Same data structure (sliding window), different decision: rate limiter caps QPS; breaker caps failure rate.
Q: How do you tune HikariCP, and why is connection pool sizing critical?
Answer:
HikariCP is Spring Boot's default JDBC pool. Misconfiguring it is the most common cause of production database incidents: connection leaks, exhausted pools, slow startup, and "too many connections" errors at the DB. The rules are counterintuitive — more connections usually means worse performance.
Why You Need a Pool
Without pool:
Each request → open TCP + TLS + auth handshake → run query → close
Cost: 50–200 ms per connection on Postgres/MySQL
Throughput: limited by handshake, not query
With pool:
Long-lived connections kept warm
Request borrows one, returns it
Cost amortized to near zero
Key Settings
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 10
connection-timeout: 3000 # ms to wait for a connection
idle-timeout: 600000 # ms before idle conn is closed (10m)
max-lifetime: 1800000 # ms max conn age (30m)
leak-detection-threshold: 30000 # warn if borrow > 30s
validation-timeout: 5000
connection-init-sql: "SELECT 1"
The Sizing Counterintuition
"If 10 connections handle 1000 req/s, 100 connections should handle 10000 req/s."
Wrong. Beyond a small number, more connections slow you down because:
- Each DB connection has a thread/process on the DB side.
- More DB threads → more context switching, more lock contention, more cache misses.
- Postgres in particular: each backend is a process, ~5–10 MB RAM each.
Empirical guidance from HikariCP authors:
connections = ((core_count * 2) + effective_spindle_count)
For a modern SSD-backed DB with 8 cores: 8*2 + 1 ≈ 17. Use 10–20, not 100.
Why Bigger Pools Hurt
Consider a DB with 200 max connections. App pool = 200:
- Connection-borrow latency: zero (always one free).
- Query latency: high — every query fights 199 others for CPU, locks, buffer cache.
App pool = 20:
- Connection-borrow latency: low, occasional brief wait.
- Query latency: low — only 20 queries in flight, DB happy.
- Throughput: higher despite the queueing.
This is Little's Law at work. Latency × throughput = concurrent work. Lower the concurrent work, and latency drops faster than throughput rises.
Per-Instance Sizing in Microservices
Each app instance has its own pool. If you have:
- DB max_connections = 200.
- 10 app instances.
- 20 admin/reporting connections reserved.
Per-instance pool = (200 - 20) / 10 = 18 → round to 15 for safety.
A common production failure: autoscaling from 10 → 50 pods, each with maximum-pool-size: 20 → 1000 connection requests at a 200-conn DB. Pods fail health checks waiting on connection-timeout.
Critical Settings Explained
maximum-pool-size: hard cap. Default 10. Don't blindly increase.
minimum-idle: HikariCP authors recommend setting this equal to max for a fixed-size pool, avoiding the cost of ramping up under load.
connection-timeout: how long a thread waits for a connection before throwing SQLException. Default 30s — usually too long. Use 1–5s in user-facing services so callers fail fast and load balancers can shed.
max-lifetime: rotates connections every N ms. Critical for:
- Picking up DB failover events (old conn pointing at dead replica).
- Working around stateful proxies (RDS Proxy, PgBouncer transaction pool).
- Set to less than your DB's idle timeout (typically 30 min vs DB's 60 min).
idle-timeout: closes idle connections after this duration. Set 0 to disable for fixed-size pools.
leak-detection-threshold: prints a stack trace if a borrowed connection isn't returned in N ms. Enable this in production — 30s catches most leaks without false positives.
Connection Leaks
// ❌ Leak: connection never closed on exception
public void process() throws SQLException {
Connection c = ds.getConnection();
PreparedStatement s = c.prepareStatement("SELECT ...");
if (someCondition) throw new RuntimeException(); // c leaked!
s.executeQuery();
c.close();
}
// ✅ try-with-resources guarantees cleanup
public void process() throws SQLException {
try (Connection c = ds.getConnection();
PreparedStatement s = c.prepareStatement("SELECT ...")) {
s.executeQuery();
}
}
Spring's JdbcTemplate, JPA, and TransactionTemplate handle this for you. Raw JDBC requires discipline.
@Transactional and Pool Behavior
@Transactional
public void slow() {
Order o = repo.findById(1L).orElseThrow();
callExternalAPI(o); // ← holds DB conn during HTTP call
repo.save(o);
}
The connection is borrowed at transaction start and held until commit/rollback. A 30-second external call → 30-second connection occupancy → pool saturates under load.
Fix:
public void slow() {
Order o = readTx(() -> repo.findById(1L).orElseThrow());
callExternalAPI(o); // no DB conn during HTTP call
writeTx(() -> repo.save(o));
}
Never hold a transaction across a remote call.
Read-Only Pool Pattern
Separate read traffic to a replica with a second DataSource:
@Bean @Primary
DataSource primary() { return hikari(primaryProps()); }
@Bean
DataSource replica() { return hikari(replicaProps()); }
Cuts load on the primary and keeps writes isolated from analytical reads.
Observing the Pool
HikariCP exposes Micrometer metrics out of the box:
hikaricp.connections.active # in-use right now
hikaricp.connections.idle # waiting in pool
hikaricp.connections.pending # threads waiting to borrow (>0 is a smell)
hikaricp.connections.timeout # cumulative borrow timeouts
hikaricp.connections.usage # histogram of how long borrowed
Alert on:
pending > 0for sustained periods.active ≈ maxconsistently.timeoutrate > 0.
Common Mistakes
| Mistake | Fix |
|---|---|
maximum-pool-size: 100 "for safety" | 10–20 is right for most workloads |
Holding @Transactional across HTTP/RPC | Split the transaction, use compensations |
| Different pools sharing one DB max_connections, oversubscribed | Plan pool budget org-wide; reserve admin slots |
connection-timeout: 30000 user-facing | Cut to 1–5 s; fail fast |
Forgot to close raw JDBC Connection/Statement/ResultSet | Use try-with-resources |
max-lifetime > DB idle timeout | Pool hands out dead connections; set < DB limit |
[!NOTE] If a problem looks like "we need more connections," it usually means a query is slow, a transaction is too long, or pool is misconfigured. More connections is rarely the cure.
Interview Follow-ups
- "Why does HikariCP outperform DBCP/c3p0?" — Smaller code, no synchronization on the hot path, FastList instead of ArrayList for the connection bag, careful CAS-based state transitions.
- "What's PgBouncer and how does it interact?" — A connection multiplexer in front of Postgres. Two modes: session (1:1 like a real pool) and transaction (connection released to next client per transaction — incompatible with prepared statements and
SET LOCAL). - "How would you tune for serverless functions?" — Each instance is short-lived; either use a single connection per instance and rely on the DB-side proxy (RDS Proxy, Neon, PlanetScale) or pre-warm a small pool of 1–2 connections.
Q: Kafka with Spring Boot — semantics, ordering, error handling.
Answer:
Spring Kafka wraps the official Kafka client with template/listener abstractions. The wrapper is thin; almost every production bug is rooted in misunderstanding Kafka semantics (acks, idempotence, transactions, rebalances), not the Spring layer.
Producer Side
spring:
kafka:
bootstrap-servers: kafka:9092
producer:
acks: all
retries: 2147483647
properties:
enable.idempotence: true
max.in.flight.requests.per.connection: 5
delivery.timeout.ms: 120000
linger.ms: 5
compression.type: lz4
# Transactions (optional):
transactional.id: ${HOSTNAME}-producer
Send:
@Component
class Publisher {
private final KafkaTemplate<String, Order> tmpl;
public void publish(Order o) {
tmpl.send("orders", o.id(), o)
.whenComplete((res, ex) -> {
if (ex != null) log.error("send failed", ex);
});
}
}
Key choice matters: same key → same partition → ordering preserved per key. Null key → load-balanced (sticky batching).
Exactly-Once Producer (Idempotent + Transactions)
enable.idempotence=true deduplicates retries within a producer session. transactional.id makes writes atomic across topics and the offset commit:
@Transactional("kafkaTransactionManager")
public void process(Order o) {
template.send("audit", o);
template.send("invoices", new Invoice(o));
// both written atomically with the consumer's offset commit
}
For exactly-once end to end, the consumer must set isolation.level=read_committed to skip aborted records.
Consumer Side
spring:
kafka:
consumer:
group-id: orders-app
enable-auto-commit: false # always false in production
auto-offset-reset: earliest
isolation-level: read_committed
max-poll-records: 500
listener:
type: SINGLE # or BATCH
ack-mode: MANUAL_IMMEDIATE # or RECORD / BATCH
concurrency: 3 # parallel consumers within app
Listener:
@KafkaListener(topics = "orders", containerFactory = "orderListenerFactory")
public void onMessage(ConsumerRecord<String, Order> rec, Acknowledgment ack) {
try {
process(rec.value());
ack.acknowledge();
} catch (RetryableException e) {
// Spring's container will retry per ErrorHandler config
throw e;
} catch (NonRetryableException e) {
// send to DLT and ack
dlt.send(rec);
ack.acknowledge();
}
}
Ordering and Concurrency
A consumer group reads each partition in order. Across partitions, no order. Inside a consumer instance, Spring's listener container can run concurrency = N threads, each consuming one or more partitions.
If you need strict per-key ordering: ensure producer keys by the entity ID, and concurrency doesn't exceed partition count (extras are idle). Don't @Async or thread-pool inside the listener — that breaks per-partition order.
Manual Offset Management
ack-mode options:
RECORD # ack after each message
BATCH # ack after the whole poll batch
MANUAL # ack when you call ack.acknowledge() — committed on next poll
MANUAL_IMMEDIATE # ack and commit synchronously NOW (slower but safest)
TIME / COUNT # ack every N ms or N records
Default BATCH is fast but risks re-processing the whole batch on crash. MANUAL_IMMEDIATE per-record is slow but bounds re-processing to the in-flight record.
Retry + DLT (Dead Letter Topic)
Spring Kafka has built-in non-blocking retry:
@RetryableTopic(
attempts = "4",
backoff = @Backoff(delay = 5000, multiplier = 4.0),
autoCreateTopics = "true",
dltStrategy = DltStrategy.FAIL_ON_ERROR,
include = { TransientException.class }
)
@KafkaListener(topics = "orders")
public void consume(Order o) { ... }
@DltHandler
public void dlt(Order o,
@Header(KafkaHeaders.EXCEPTION_MESSAGE) String err,
@Header(KafkaHeaders.ORIGINAL_TOPIC) String orig) {
alertOps(o, err, orig);
}
Creates orders-retry-0, orders-retry-1, ..., orders-dlt. Failed records are routed to retry topics that delay-and-replay rather than blocking the main topic.
Backpressure
Kafka has no broker-side backpressure. Your consumer must:
- Limit
max.poll.recordsto what one poll cycle can process. - Process synchronously (don't dump to an unbounded
ExecutorService). - Monitor
records-lag-maxand alert when it grows.
If your processing is genuinely slow:
- Add partitions to allow more parallel consumers.
- Or shift the slow work to a downstream worker pool with its own queue/topic.
Rebalance Pitfalls
When a consumer joins/leaves, partitions reassign. By default with cooperative-sticky, only affected partitions revoke — but uncommitted offsets are lost across rebalance. Always commit before yielding partitions:
@KafkaListener(topics = "orders")
public void onMessage(@Payload Order o,
Acknowledgment ack,
ConsumerRebalanceListener... ) {
// process and ack
}
Spring exposes a RebalanceListener you can implement to flush state on partition revoke.
Common Mistakes
| Mistake | Reality |
|---|---|
enable.auto.commit: true | Offsets advance even on failure — silent data loss |
acks=1 for critical writes | One-broker durability — leader loss = data loss |
concurrency > partitions | Extras sit idle |
Async processing inside @KafkaListener | Breaks ordering + ack semantics |
| Catching all exceptions and acking | Failures become silent — design a DLT path |
Sending to Kafka inside a @Transactional JPA method | Two unrelated transaction managers — use ChainedKafkaTransactionManager or outbox pattern |
Transactional Outbox Pattern
Mixing DB commits and Kafka sends safely requires either:
- Chained transaction manager (DB + Kafka, two-phase commit-ish).
- Transactional outbox: write a row to
outboxtable in the same DB transaction, separate poller (Debezium CDC or app-side polling) republishes to Kafka.
Outbox is the recommended production pattern — it survives crashes between DB commit and Kafka send.
Observability
Micrometer-instrumented out of the box. Watch:
spring.kafka.listener.* timer
kafka.consumer.records-lag-max
kafka.consumer.records-consumed-total
kafka.producer.record-send-total
kafka.producer.record-error-total
Trace context: enable Spring Cloud Sleuth / Micrometer Tracing and Kafka client interceptors to propagate trace IDs in headers.
[!NOTE] Idiomatic Spring Kafka is thin. Almost every gotcha is Kafka behavior, not Spring sugar. Read the consumer config doc once, top to bottom.
Interview Follow-ups
- "How is Kafka's transactional producer different from a DB transaction?" — Atomic across topic partitions + offset commits. Doesn't span outside Kafka.
- "Why is
max.in.flight=5safe with idempotence?" — Producer sequence numbers let brokers reorder retries correctly. Without idempotence, in-flight > 1 + retries can reorder messages. - "What is
ChainedKafkaTransactionManager?" — Spring's coordinator that opens DB + Kafka transactions and synchronizes commit/rollback. Not a true XA — best-effort with known failure modes.
Q: How do you instrument a Spring Boot app with Micrometer (metrics, traces, logs)?
Answer:
Observability has three pillars: metrics, traces, logs. Spring Boot ships Micrometer for metrics + tracing and supports any backend (Prometheus, Datadog, New Relic, Tempo, etc.) via a façade pattern similar to SLF4J's role for logging.
The Stack
Application code
│
▼
Micrometer API (vendor-neutral)
│
▼
Registry (Prometheus / OTLP / Datadog / ...)
│
▼
Backend (Grafana, Jaeger, Tempo, DD, ...)
You write meterRegistry.counter(...) once. The backend is swappable via dependency.
Setup
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
metrics:
tags:
application: ${spring.application.name}
env: ${ENV:dev}
tracing:
sampling:
probability: 0.1 # sample 10%
otlp:
tracing:
endpoint: http://otel-collector:4318/v1/traces
Built-In Metrics
Spring Boot auto-instruments:
- HTTP server:
http.server.requests(timer per URI/method/status). - HTTP client (
RestClient,WebClient):http.client.requests. - JDBC pool:
hikaricp.connections.*. - JVM:
jvm.memory.used,jvm.gc.pause,jvm.threads.live. - Kafka: producer/consumer metrics via
KafkaClientMetrics. - Tomcat/Jetty: request rate, thread pool stats.
Scrape from Prometheus:
GET /actuator/prometheus
Custom Metrics
Use the right type:
| Type | Use | Example |
|---|---|---|
Counter | Monotonic event counts | orders.placed |
Gauge | Snapshot value | queue.size |
Timer | Duration histograms | payment.processing |
DistributionSummary | Non-time distributions | request.size.bytes |
@Service
class PaymentService {
private final Counter placed;
private final Timer processing;
PaymentService(MeterRegistry r) {
placed = Counter.builder("payments.placed")
.description("Payments accepted")
.tag("region", "us-east-1")
.register(r);
processing = Timer.builder("payment.processing")
.publishPercentileHistogram()
.register(r);
}
public void pay(Payment p) {
processing.record(() -> {
placed.increment();
// ...
});
}
}
Cardinality — The Production Killer
Never tag with high-cardinality values:
counter.tag("user_id", userId) // ❌ explodes — one series per user
counter.tag("trace_id", traceId) // ❌ same
counter.tag("path", request.uri()) // ❌ /orders/123, /orders/124, ...
counter.tag("region", "us-east-1") // ✅ bounded
counter.tag("status", "200") // ✅
counter.tag("path", "/orders/{id}") // ✅ templated
Spring's HTTP timer uses the templated URI automatically. If you build URIs manually with IDs, you'll explode cardinality.
Histograms / Percentiles
Timer.builder("http.api")
.publishPercentileHistogram() // export histogram buckets
.serviceLevelObjectives(
Duration.ofMillis(100),
Duration.ofMillis(500),
Duration.ofSeconds(1))
.register(registry);
publishPercentileHistogram is the right answer in 95% of cases — Prometheus computes percentiles across instances. Don't use publishPercentiles (client-side estimate; can't aggregate).
Distributed Tracing
Spring Boot 3 + Micrometer Tracing replaces Sleuth. Auto-propagates traceparent headers via:
RestClient/WebClientRestTemplateKafkaTemplate/@KafkaListener- Reactor schedulers
@Async
Manual span:
@Service
class Quoter {
private final ObservationRegistry observations;
String quote(String sku) {
return Observation.createNotStarted("quote.lookup", observations)
.lowCardinalityKeyValue("sku.kind", classify(sku))
.observe(() -> doExpensiveLookup(sku));
}
}
Observation produces both a metric (a timer) and a span automatically — the unified API.
Logging Correlation
With Micrometer Tracing on the classpath, MDC automatically gets traceId and spanId. Pattern:
logging:
pattern:
level: "%5p [${spring.application.name},%X{traceId:-},%X{spanId:-}]"
Log line:
2026-05-18 12:00:00 INFO [orders,a1b2c3d4...e9,1234] OrderService - order placed
Now any log line links to its trace.
Health Checks
management:
endpoint:
health:
show-details: when-authorized
probes:
enabled: true # K8s liveness + readiness endpoints
Endpoints:
GET /actuator/health/liveness # is the app alive?
GET /actuator/health/readiness # can it serve traffic?
Custom indicator:
@Component
class KafkaHealth implements HealthIndicator {
public Health health() {
return reachable() ? Health.up().build() : Health.down().withDetail("err", "...").build();
}
}
Readiness should fail if downstreams (DB, queue) are not ready. Liveness should only fail if the app is truly broken (stuck thread, deadlock) — never on transient downstream issues, or K8s will restart-loop the pod.
What to Alert On (SLOs)
- Availability:
sum(rate(http_server_requests_seconds_count{status=~"5.."}[5m])) / sum(rate(http_server_requests_seconds_count[5m])) - Latency: p99 of
http_server_requests_seconds - Saturation:
hikaricp_connections_pending,jvm_threads_live > N,kafka_consumer_records_lag_max
Pick a few SLIs; alert on burn rate, not raw thresholds (Google SRE workbook).
Common Mistakes
| Mistake | Fix |
|---|---|
Tag.of("user", userId) | Cardinality explosion |
| 100% trace sampling in prod | Sampling overhead crushes throughput |
| Liveness probe = readiness probe | Liveness should rarely fail |
| Logging full payloads at INFO | Cost + PII risk; sample or DEBUG |
| Custom timer with no histogram | Can't aggregate percentiles cross-instance |
| Forgetting to register meters as singletons | Per-request meters silently leak |
[!NOTE] If you can't answer "what's our error rate right now?" in 30 seconds, you don't have observability. Defaults + a handful of custom timers cover 80% of operational questions.
Interview Follow-ups
- "Difference between Micrometer Tracing and OpenTelemetry?" — Micrometer Tracing is the API; OTel is one of its implementations. You can also bridge to Brave (Zipkin).
- "Why histograms over percentiles?" — Percentiles can't be averaged or combined across instances. Histograms can (
histogram_quantile). - "How do you trace through Kafka?" —
traceparentpropagated in Kafka record headers via Spring'sKafkaTemplateinterceptor + listener container.
Q: How do Generics work in Java? What is Type Erasure?
Answer:
Generics
Generics enable type-safe, parameterized classes, interfaces, and methods. They catch type errors at compile time instead of runtime.
// Without generics: runtime ClassCastException risk
List list = new ArrayList();
list.add("hello");
Integer x = (Integer) list.get(0); // 💥 ClassCastException at RUNTIME
// With generics: compile-time safety
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(42); // ❌ Compilation error — caught EARLY
String x = list.get(0); // No cast needed
Type Erasure
Java generics are a compile-time feature only. The compiler uses generic type information for type checking, then erases all generic types and replaces them with their bounds (or Object).
// What you write:
List<String> strings = new ArrayList<>();
List<Integer> ints = new ArrayList<>();
// After type erasure (what the JVM sees):
List strings = new ArrayList(); // Just "List" — type info is GONE
List ints = new ArrayList();
// At runtime:
strings.getClass() == ints.getClass(); // true! Both are just ArrayList
Consequences of Type Erasure
// ❌ Cannot do these at runtime:
if (obj instanceof List<String>) { } // Compilation error
new T(); // Cannot instantiate type parameter
T[] array = new T[10]; // Cannot create generic array
// ❌ Cannot overload with different generic types:
void process(List<String> list) { }
void process(List<Integer> list) { } // Compilation error — same erasure!
Bounded Type Parameters
// Upper bound: T must be Comparable or its subtype
public <T extends Comparable<T>> T findMax(List<T> list) {
return list.stream().max(Comparator.naturalOrder()).orElseThrow();
}
// Multiple bounds
public <T extends Serializable & Comparable<T>> void process(T item) { }
Wildcards
// Upper-bounded: read-only (producer)
void printAll(List<? extends Number> numbers) {
for (Number n : numbers) { System.out.println(n); }
// numbers.add(42); ❌ Cannot add — compiler doesn't know the exact type
}
// Lower-bounded: write-only (consumer)
void addIntegers(List<? super Integer> list) {
list.add(42); // ✅ Can add Integer or subtypes
// Integer x = list.get(0); ❌ Can only read as Object
}
// Unbounded: completely read-only
void countElements(List<?> list) {
System.out.println(list.size());
}
PECS: Producer Extends, Consumer Super
The mnemonic for remembering wildcard usage:
? extends T→ Read from the collection (it produces items).? super T→ Write to the collection (it consumes items).
[!TIP] In interviews, type erasure is the key insight. "Generics provide compile-time safety but are erased at runtime. This means you can't do runtime type checks on generic types or create generic arrays — it's all synthetic compiler enforcement."
Q: How does Serialization and Deserialization work in Java?
Answer:
Serialization converts a Java object into a byte stream (for storage or network transfer). Deserialization reconstructs the object from that byte stream.
Basic Serialization
A class must implement java.io.Serializable (a marker interface with no methods):
public class Employee implements Serializable {
private static final long serialVersionUID = 1L; // Version control
private String name;
private int salary;
private transient String password; // NOT serialized
}
// Serialize
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("emp.ser"))) {
oos.writeObject(new Employee("Alice", 90000, "secret"));
}
// Deserialize
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("emp.ser"))) {
Employee emp = (Employee) ois.readObject();
// emp.password is null (was transient)
}
Key Concepts
serialVersionUID
A version identifier. If the class changes (add/remove fields) and the UID doesn't match the serialized data, deserialization throws InvalidClassException. Always declare it explicitly.
transient
Fields marked transient are excluded from serialization. Used for sensitive data, derived fields, or non-serializable references.
static fields
Static fields belong to the class, not the instance — they are NOT serialized.
Custom Serialization
public class Employee implements Serializable {
private String name;
private transient String encryptedPassword;
private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject();
oos.writeObject(encrypt(encryptedPassword)); // Custom logic
}
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject();
encryptedPassword = decrypt((String) ois.readObject());
}
}
Serialization Problems
| Problem | Issue |
|---|---|
| Security | Deserialization of untrusted data can execute arbitrary code (deserialization attacks) |
| Versioning | Any class change can break existing serialized data |
| Performance | Java serialization is slow and produces verbose output |
| Inheritance | Superclass must also be Serializable, or have a no-arg constructor |
Modern Alternatives
| Alternative | Format | Speed | Use Case |
|---|---|---|---|
| Jackson | JSON | Fast | REST APIs, config |
| Gson | JSON | Fast | Simple JSON mapping |
| Protocol Buffers | Binary | Very fast | gRPC, microservices |
| Avro | Binary | Fast | Kafka, data pipelines |
| Java Records | N/A | N/A | Immutable data carriers (Java 16+) |
[!CAUTION] Java's built-in serialization (
ObjectOutputStream) is considered a security risk by Oracle itself. It has been the source of many critical CVEs. For new projects, use JSON (Jackson) or Protocol Buffers instead. Java serialization is mainly relevant for understanding legacy systems and theSerializablecontract.
Q: Explain the Singleton, Factory, and Builder design patterns.
Answer:
1. Singleton — One Instance, Global Access
Ensures a class has exactly one instance and provides a global point of access.
Thread-Safe Singleton (Bill Pugh idiom):
public class DatabaseConnection {
private DatabaseConnection() {} // Private constructor
private static class Holder {
private static final DatabaseConnection INSTANCE = new DatabaseConnection();
}
public static DatabaseConnection getInstance() {
return Holder.INSTANCE; // Lazy, thread-safe (class loading guarantees)
}
}
Enum Singleton (simplest, recommended by Effective Java):
public enum DatabaseConnection {
INSTANCE;
public void query(String sql) { /* ... */ }
}
// Usage: DatabaseConnection.INSTANCE.query("SELECT 1");
When to use: Configuration managers, connection pools, caches, logging.
2. Factory — Delegate Object Creation
Encapsulates object creation logic, returning instances of a common interface without exposing the concrete class.
public interface Notification {
void send(String message);
}
public class EmailNotification implements Notification {
@Override public void send(String msg) { /* send email */ }
}
public class SmsNotification implements Notification {
@Override public void send(String msg) { /* send SMS */ }
}
// Factory
public class NotificationFactory {
public static Notification create(String type) {
return switch (type) {
case "email" -> new EmailNotification();
case "sms" -> new SmsNotification();
default -> throw new IllegalArgumentException("Unknown type: " + type);
};
}
}
// Usage
Notification n = NotificationFactory.create("email");
n.send("Hello!");
When to use: When the exact class to instantiate depends on runtime conditions (config, user input, environment).
3. Builder — Complex Object Construction
Separates the construction of a complex object from its representation. Avoids telescoping constructors.
// ❌ Telescoping constructor hell
new User("Alice", "alice@mail.com", 25, "NYC", "Engineer", true, false);
// What is true? What is false? Unreadable.
// ✅ Builder pattern
public class User {
private final String name;
private final String email;
private final int age;
private final String city;
private User(Builder builder) {
this.name = builder.name;
this.email = builder.email;
this.age = builder.age;
this.city = builder.city;
}
public static class Builder {
private final String name; // Required
private final String email; // Required
private int age; // Optional
private String city; // Optional
public Builder(String name, String email) {
this.name = name;
this.email = email;
}
public Builder age(int age) { this.age = age; return this; }
public Builder city(String city) { this.city = city; return this; }
public User build() { return new User(this); }
}
}
// Usage: clean and readable
User user = new User.Builder("Alice", "alice@mail.com")
.age(25)
.city("NYC")
.build();
Summary
| Pattern | Problem It Solves | Real-World Example |
|---|---|---|
| Singleton | Need exactly one shared instance | Runtime.getRuntime(), Spring beans (default scope) |
| Factory | Object creation depends on conditions | Calendar.getInstance(), LoggerFactory.getLogger() |
| Builder | Complex object with many optional params | StringBuilder, HttpRequest.newBuilder(), Lombok @Builder |
[!TIP] In modern Java, Lombok's
@Buildergenerates the Builder pattern automatically. And in Spring, most "singletons" are managed by the IoC container rather than the traditional pattern — so you rarely need to implement Singleton yourself.
Q: How does Java reflection work? When to use it, what are the costs?
Answer:
Reflection = inspect + manipulate classes/methods/fields at runtime. The class metadata in the JVM is exposed via java.lang.reflect.
Basic Operations
Class<?> c = Class.forName("com.acme.User");
// or User.class, or user.getClass()
// Inspect
c.getDeclaredFields();
c.getDeclaredMethods();
c.getDeclaredConstructors();
c.getInterfaces();
c.getSuperclass();
c.isAnnotationPresent(Entity.class);
// Instantiate
Constructor<?> ctor = c.getDeclaredConstructor(String.class, int.class);
Object instance = ctor.newInstance("alice", 30);
// Invoke method
Method m = c.getDeclaredMethod("greet", String.class);
m.setAccessible(true); // bypass private
Object result = m.invoke(instance, "world");
// Read/write field
Field f = c.getDeclaredField("name");
f.setAccessible(true);
f.set(instance, "bob");
String name = (String) f.get(instance);
Annotation Reading
for (Method m : c.getDeclaredMethods()) {
if (m.isAnnotationPresent(MyAnnotation.class)) {
MyAnnotation ann = m.getAnnotation(MyAnnotation.class);
System.out.println(ann.value());
}
}
Where Reflection Powers The Java Ecosystem
- Spring — bean instantiation, DI,
@Autowiredfield injection, AOP proxies. - Hibernate / JPA — entity field access, lazy proxies.
- Jackson / Gson — serialize/deserialize without manual mappings.
- JUnit / TestNG — discover
@Testmethods. - Mockito — mock generation.
- Logging frameworks, ORM, IoC, validators (Bean Validation), serializers, deserializers, ...
Costs
1. Performance
Reflection is slower than direct calls. JIT can optimize repeated reflective calls (caching MethodAccessor), but not as well as direct invocation.
Rough rule of thumb (varies, measure for your case):
- Direct call: ~1ns
- Cached
Method.invoke: ~10-50ns - Uncached: 100s of ns to µs
Avoid in tight loops. Cache Method/Field references.
2. No compile-time safety Method names are strings → typos blow up at runtime, not compile time.
3. Strong encapsulation (Java 9+)
JPMS modules + --illegal-access controls block deep reflection on JDK internals. Setting setAccessible(true) on private members of other modules requires the module to opens the package.
Add-Opens=java.base/java.lang=ALL-UNNAMED # JAR manifest, e.g., for older libs
4. Security
Bypassing private violates encapsulation contracts. Avoid in production code.
Modern Alternatives
1. MethodHandle (Java 7+)
Faster than reflection. JIT-friendly.
MethodHandles.Lookup lookup = MethodHandles.lookup();
MethodHandle mh = lookup.findVirtual(User.class, "greet", MethodType.methodType(String.class, String.class));
String s = (String) mh.invokeExact(user, "world");
2. VarHandle (Java 9+)
For fields. Replaces sun.misc.Unsafe for atomic operations.
3. Annotation processors / code generation Lombok, MapStruct, Dagger: generate code at compile time → no runtime reflection cost.
4. Records + pattern matching Reduces need for reflective deconstruction.
Real Reflection Examples
Generic factory
public static <T> T newInstance(Class<T> c) {
try { return c.getDeclaredConstructor().newInstance(); }
catch (Exception e) { throw new RuntimeException(e); }
}
Find all fields with annotation
List<Field> idFields = Arrays.stream(c.getDeclaredFields())
.filter(f -> f.isAnnotationPresent(Id.class))
.toList();
Dynamic proxy (no aspect framework needed)
@SuppressWarnings("unchecked")
public static <T> T loggingProxy(T target, Class<T> iface) {
return (T) Proxy.newProxyInstance(
iface.getClassLoader(),
new Class<?>[]{ iface },
(proxy, method, args) -> {
System.out.println("calling " + method.getName());
return method.invoke(target, args);
});
}
Generic Type Erasure + Reflection
Generic types erased at runtime, but declared types preserved on fields, methods, classes:
Field f = User.class.getDeclaredField("orders"); // List<Order> orders;
ParameterizedType pt = (ParameterizedType) f.getGenericType();
Class<?> actualType = (Class<?>) pt.getActualTypeArguments()[0]; // Order.class
Best Practices
- Cache
Method,Field,Constructorlookups. - Catch and wrap checked exceptions sensibly.
- Prefer
MethodHandleoverMethod.invokein hot paths. - Prefer compile-time generation (annotation processors) over runtime reflection.
- Don't use reflection to break encapsulation in your own code — it's for frameworks.
Q: How do annotations work in Java? Retention, targets, and writing your own.
Answer:
Annotations = metadata on classes/methods/fields/parameters/etc. They're declarative — the compiler, framework, or runtime decides what to do with them.
Built-In Categories
- Marker — no members.
@Override,@Deprecated. - Single-value — one element.
@SuppressWarnings("unchecked"). - Multi-value — multiple elements.
@RequestMapping(path="/x", method=POST). - Repeating — same annotation multiple times (Java 8+).
- Type annotations — on uses of types, not just declarations (Java 8+).
List<@NotNull String>.
Retention (When Annotation Is Available)
@Retention(RetentionPolicy.SOURCE) // discarded by compiler (e.g., @Override)
@Retention(RetentionPolicy.CLASS) // in .class file but not at runtime (default)
@Retention(RetentionPolicy.RUNTIME) // accessible via reflection
Target (Where It Can Be Applied)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD,
ElementType.PARAMETER, ElementType.CONSTRUCTOR,
ElementType.LOCAL_VARIABLE, ElementType.ANNOTATION_TYPE,
ElementType.PACKAGE, ElementType.TYPE_PARAMETER, ElementType.TYPE_USE,
ElementType.MODULE, ElementType.RECORD_COMPONENT})
Custom Annotation Skeleton
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented // appears in Javadoc
@Inherited // subclasses inherit (only ElementType.TYPE)
public @interface Auditable {
String action() default "";
String[] tags() default {};
boolean async() default false;
}
Usage:
@Auditable(action = "ORDER_CREATE", tags = {"orders","write"})
public Order create(...) { ... }
Element Types Allowed
- Primitives,
String,Class, enum, annotation, arrays of those. - Not arbitrary objects.
Reading at Runtime
Method m = OrderService.class.getMethod("create", ...);
if (m.isAnnotationPresent(Auditable.class)) {
Auditable a = m.getAnnotation(Auditable.class);
System.out.println(a.action());
}
Repeating Annotations (Java 8+)
@Repeatable(Schedules.class)
public @interface Schedule { String cron(); }
public @interface Schedules { Schedule[] value(); }
@Schedule(cron="0 0 * * * *")
@Schedule(cron="0 0 12 * * *")
public void run() { }
Type Annotations (Java 8+)
public @NonNull String greet(@NonNull String name) { ... }
List<@NonNull String> names;
String s = (@NonNull String) obj;
Used by tools like Checker Framework for null-safety.
Meta-Annotations (Annotations on Annotations)
You build composite annotations:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Auditable
public @interface DomainOperation { }
Spring follows this pattern — @RestController, @Service, @Repository are all meta-annotated with @Component.
Spring's Common Annotation Categories
| Category | Examples |
|---|---|
| Stereotype | @Component, @Service, @Repository, @Controller |
| Wiring | @Autowired, @Qualifier, @Value, @Lazy |
| Config | @Configuration, @Bean, @Profile, @ConditionalOn* |
| Web | @RestController, @GetMapping, @RequestBody, @PathVariable |
| Data/Tx | @Transactional, @Entity, @Id, @Query |
| AOP | @Aspect, @Before, @Around |
| Validation | @NotNull, @Size, @Valid, @Validated |
Annotation Processors (Compile-Time)
javax.annotation.processing API. Read SOURCE / CLASS-retention annotations during compilation and generate code/reports.
Famous users:
- Lombok — generates getters, setters,
equals,hashCode, etc. - MapStruct — generates DTO ↔ entity mappers.
- Dagger — DI graph code generation.
- AutoValue — value-class generation.
- Hibernate Metamodel — type-safe Criteria API metamodel.
Custom processor:
@SupportedAnnotationTypes("com.acme.GenerateBuilder")
@SupportedSourceVersion(SourceVersion.RELEASE_17)
public class BuilderProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment env) {
for (Element e : env.getElementsAnnotatedWith(GenerateBuilder.class)) {
// generate code via Filer
}
return true;
}
}
When To Write A Custom Annotation
- Marker for cross-cutting behavior + a Spring aspect / interceptor (
@RateLimit,@Audited). - Configuration switch read by a runtime framework you control.
- Compile-time generation (annotation processor).
When NOT To
- Hiding logic that should be obvious in code.
- Replacing what a method or interface would express better.
- "Magic" that obscures program flow without clear benefit.
Default Methods on Annotations? Defaults Yes; Methods No
Annotations are interfaces under the hood, but you can only declare element methods (no body, no default behavior beyond default values).
Pitfalls
- Forgetting
@Retention(RUNTIME)→ annotation invisible at runtime. - Forgetting to
@Inheritedand assuming subclasses pick up the annotation (only works onTYPE-level). - Annotation present but no processor / aspect to act on it → silent no-op.
- Putting heavy logic (string parsing, regex compilation) in annotation users — cache the parsed form.
Q: What are records, sealed classes, and pattern matching in modern Java?
Answer:
These three Java 16–21 features together push Java toward a more algebraic-data-type style: small immutable data carriers (records), closed type hierarchies (sealed), and exhaustive structural deconstruction (pattern matching). Used together they replace a lot of boilerplate visitor/equals/hashCode/instanceof code.
Records (Java 16)
A record is a class whose entire purpose is to be a transparent carrier of values.
public record Point(int x, int y) {}
The compiler generates:
- A canonical constructor.
finalfieldsx,y.- Accessors
x(),y()(nogetprefix). equals,hashCode,toStringbased on the components.
Override behavior selectively:
public record Range(int lo, int hi) {
// Compact constructor — validate without re-declaring parameters.
public Range {
if (lo > hi) throw new IllegalArgumentException();
}
}
Records can implement interfaces and declare static methods, but cannot extend a class (they implicitly extend java.lang.Record).
When NOT a record:
- Mutable state required.
- Inheritance from a base class.
- Identity matters (you want reference equality).
Sealed Classes (Java 17)
sealed restricts which types can extend/implement a type. The hierarchy is closed and known at compile time.
public sealed interface Shape permits Circle, Square, Triangle {}
public record Circle(double radius) implements Shape {}
public record Square(double side) implements Shape {}
public record Triangle(double a, double b, double c) implements Shape {}
Permitted subtypes must be declared final, sealed, or non-sealed. Records are implicitly final, so they fit naturally.
Why it matters: the compiler now knows the full set of subtypes. Combined with pattern matching, you get exhaustiveness checking.
Pattern Matching for instanceof (Java 16)
// Old
if (obj instanceof String) {
String s = (String) obj;
return s.length();
}
// New — binding variable
if (obj instanceof String s) {
return s.length();
}
Pattern Matching for switch (Java 21)
double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Square s -> s.side() * s.side();
case Triangle t -> heron(t.a(), t.b(), t.c());
// No default — compiler verifies exhaustiveness because Shape is sealed.
};
}
If you add a fourth permitted subtype, this method fails to compile until you handle it. That's the safety win: the type system enforces case coverage.
Record Deconstruction Patterns (Java 21)
You can destructure inside instanceof/switch:
record Pair(int x, int y) {}
if (obj instanceof Pair(int x, int y)) {
return x + y;
}
return switch (shape) {
case Circle(double r) -> Math.PI * r * r;
case Square(double s) -> s * s;
case Triangle(double a, double b, double c) -> heron(a, b, c);
};
Nested patterns:
record Line(Point a, Point b) {}
case Line(Point(int x1, int y1), Point(int x2, int y2)) ->
Math.hypot(x2 - x1, y2 - y1);
Guards (when)
return switch (shape) {
case Circle c when c.radius() == 0 -> 0;
case Circle c -> Math.PI * c.radius() * c.radius();
...
};
Order matters: more specific guards first.
Putting It Together
sealed interface Json permits JNull, JBool, JNum, JStr, JArr, JObj {}
record JNull() implements Json {}
record JBool(boolean v) implements Json {}
record JNum(double v) implements Json {}
record JStr(String v) implements Json {}
record JArr(List<Json> v) implements Json {}
record JObj(Map<String, Json> v) implements Json {}
String stringify(Json j) {
return switch (j) {
case JNull n -> "null";
case JBool(boolean v) -> Boolean.toString(v);
case JNum(double v) -> Double.toString(v);
case JStr(String v) -> "\"" + v + "\"";
case JArr(List<Json> v) -> v.stream().map(this::stringify).collect(joining(",", "[", "]"));
case JObj(Map<String,Json> v) ->
v.entrySet().stream()
.map(e -> "\"" + e.getKey() + "\":" + stringify(e.getValue()))
.collect(joining(",", "{", "}"));
};
}
No visitor pattern, no class-explosion, exhaustively type-checked.
Common Mistakes
| Mistake | Reality |
|---|---|
| Treating records as Lombok replacements universally | Lombok generates getters/setters; records are immutable. Use only where immutability fits |
| Long parameter lists in record components | A record with 12 components is a smell; group sub-records |
| Sealing a public API hierarchy then needing to add a type | Sealed is a commitment. Use only when the set is genuinely closed |
Forgetting that default in switch breaks exhaustiveness checking | Omit default for sealed switches — let the compiler do its job |
[!NOTE] These features are most valuable together: sealed defines the algebra; records carry data; pattern matching consumes them. Used piecemeal they look like ceremony; used together they replace whole patterns.
Interview Follow-ups
- "Can a record be mutable?" — Components are final. But components can be mutable types (
record R(List<String> items)) — best to wrap inList.copyOfin a compact constructor. - "Difference between sealed and final?" —
final= no subtypes.sealed= a known, enumerated set of subtypes.non-sealedopens that branch back up. - "Will pattern matching support arrays/Maps?" — Array patterns are previewed; map patterns aren't standardized yet (JEP under discussion).
Q: What is GraalVM Native Image, and when should you use it?
Answer:
GraalVM Native Image AOT-compiles a Java application into a standalone native binary. Startup goes from seconds to milliseconds, memory drops 5–10×, but you lose JIT peak throughput and reflection-driven libraries need extra configuration.
What It Produces
javac + JIT (HotSpot):
app.jar + JDK + JIT warmup → fast peak throughput, slow startup, big RAM
native-image:
app.jar → app (one ELF/Mach-O binary) → fast startup, small RAM, lower peak
Output is a single executable with:
- All reachable JDK + library + app classes compiled to native code.
- Pre-initialized heap snapshot.
- No JVM, no class loading at runtime.
Closed-World Assumption
GraalVM analyzes the entire program at build time. Everything reachable must be statically discoverable. Code paths reached only via reflection, JNI, proxies, resource loading, or serialization need explicit registration.
Source files + classpath
│
▼ static analysis (points-to)
reachable methods, classes, fields
│
▼ AOT compile (Graal)
native binary
The trade: smaller, faster-starting binary; less runtime flexibility.
Building It
# Install GraalVM (with native-image component)
sdk install java 21.0.2-graal
# From a Spring Boot project:
./mvnw -Pnative native:compile
# Run:
./target/app
Generic:
native-image -jar app.jar -o app \
--no-fallback \
-O3 \
--gc=G1
Startup and Memory Comparison
Typical Spring Boot REST service:
| Metric | JVM | Native Image |
|---|---|---|
| Startup time | 2.5 s | 40 ms |
| First-request latency | varies (JIT warmup) | constant |
| RSS (idle) | 250 MB | 35 MB |
| Peak throughput | 100% | 70–90% |
| Image size | 50 MB JAR + JRE | 80–120 MB binary |
| Build time | 10 s | 90 s — 5 min |
Where Native Image Wins
- Serverless functions (AWS Lambda, GCP Cloud Functions). Cold start dominates user-perceived latency.
- Containers with autoscaling. New pods ready in 100 ms, not 5 s.
- CLI tools written in Java. No JVM startup tax.
- Memory-constrained environments. 10× less RSS = more pods per node.
Where It Loses
- Long-running CPU-bound services where JIT eventually beats AOT.
- Apps heavy on reflection/proxies without good framework support.
- Build-time CI cost matters (5-minute native build vs 30-second JAR build).
Reflection Configuration
Class.forName("com.example.Plugin"); // reflective load
Graal's analyzer doesn't see this. You declare it in reflect-config.json:
[
{
"name": "com.example.Plugin",
"allDeclaredConstructors": true,
"allPublicMethods": true
}
]
Generate automatically by running the app on the tracing agent first:
java -agentlib:native-image-agent=config-output-dir=./meta -jar app.jar
# Exercise all code paths via tests
# Outputs reflect-config.json, resource-config.json, jni-config.json, proxy-config.json
Spring AOT plugin, Quarkus, and Micronaut do this automatically — that's their main value proposition.
Frameworks Ranked by Native Support
| Framework | Native Story |
|---|---|
| Quarkus | Built for native from day 1. Best UX. |
| Micronaut | AOT-by-design, no runtime reflection. Excellent. |
| Spring Boot 3+ | First-class via spring-aot. Works well; not all starters supported equally. |
| Helidon SE | Native-first. Good. |
| Plain Spring (pre-3.0) / Hibernate | Possible but painful. |
Build-Time vs Run-Time Initialization
By default, class <clinit> blocks run at image build time and the resulting state is captured in the image heap. This is what makes startup fast — but it can break things:
// Calls happen at BUILD time on the build machine
class Config {
static final String HOSTNAME = InetAddress.getLocalHost().getHostName();
}
Now every deployed instance reports the build machine's hostname. Mark it for runtime init:
native-image --initialize-at-run-time=com.example.Config ...
Conversely, force init at build time for performance:
--initialize-at-build-time=com.example.Static
Profile-Guided Optimization (PGO)
GraalVM Enterprise (and community 23+) supports PGO:
# 1. Build instrumented binary
native-image --pgo-instrument -jar app.jar
./app # exercise workload
# default.iprof generated
# 2. Build optimized binary using the profile
native-image --pgo=default.iprof -jar app.jar
PGO recovers most of the peak-throughput gap to JIT.
Garbage Collection
Native Image ships with:
- Serial GC (default): single-threaded, low memory overhead, good for serverless/short jobs.
- G1: production-grade concurrent GC (Linux x64 only in community).
- Epsilon: no-op GC for short-lived programs.
native-image --gc=G1 -jar app.jar
Foreign Code (JNI, JFR, Agents)
- JNI: works, but each native call is configured in
jni-config.json. - JFR (Flight Recorder): supported in newer versions; profile production binaries.
- Java agents: build-time only; runtime instrumentation isn't possible.
Build Image Size Optimization
native-image \
--no-fallback \
-O3 \
-H:Optimize=3 \
--enable-preview \
-H:+ReportExceptionStackTraces \
-H:IncludeResources='.*\.(properties|yaml|xml|json|sql)' \
-jar app.jar
UPX further compresses (cost: startup +20 ms decompressing).
Common Mistakes
| Mistake | Fix |
|---|---|
| Reflection used by a library; "ClassNotFoundException at runtime" | Run tracing agent, commit generated config |
| Static block reads env at build time | --initialize-at-run-time= |
| Bloated binary with all resources included | Tighten the IncludeResources regex |
Class.forName on user-supplied strings | Doesn't work; refactor or maintain a closed list |
| Logging slow at startup | Pre-initialize logger at build time |
[!NOTE] Native Image is not a free upgrade — it's a different deployment model. Treat the AOT build as a separate artifact with its own test suite. CI: run integration tests against the native binary, not just the JVM build.
Interview Follow-ups
- "Why is reflection a problem?" — Static reachability analysis can't see "open this class by string name at runtime."
- "How does this differ from
jlink?" —jlinkproduces a custom JRE (still JVM-based).native-imageproduces a JVM-less binary. - "What's
Substrate VM?" — The runtime inside a native image — a small VM with its own GC, scheduler, exception handling. About 10 MB of code.
Q: Switch expressions and pattern matching for switch — what changed and why?
Answer:
Java's switch evolved from a statement that fell through by default into a typed expression with arrow labels, exhaustiveness checking, and pattern matching. Today's switch replaces visitor pattern, instanceof ladders, and lookup Maps for many uses.
Recap: Classic switch Statement
String label;
switch (day) {
case MONDAY:
case TUESDAY:
case WEDNESDAY:
case THURSDAY:
case FRIDAY:
label = "weekday";
break;
case SATURDAY:
case SUNDAY:
label = "weekend";
break;
default:
throw new IllegalArgumentException();
}
Problems:
breakis mandatory or fall-through bites.- Two-step assignment (declare, then assign in each branch).
- No exhaustiveness check from the compiler.
- Can't easily return a value.
Switch Expression (Java 14+)
String label = switch (day) {
case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "weekday";
case SATURDAY, SUNDAY -> "weekend";
};
Key changes:
- Expression — returns a value, assignable.
- Arrow form — no fall-through, no
break. - Comma-separated multiple labels.
- Exhaustive over
enum→ nodefaultneeded.
Block Body with yield
If a branch needs more than one statement:
int score = switch (grade) {
case "A" -> 90;
case "B" -> 80;
case "C" -> {
log.info("scraping by");
yield 70;
}
default -> throw new IllegalArgumentException();
};
yield is the "return from this branch" keyword inside a block.
Pattern Matching for switch (Java 21)
Match by type, deconstruct, and bind in one step.
sealed interface Shape permits Circle, Square, Triangle {}
record Circle(double r) implements Shape {}
record Square(double s) implements Shape {}
record Triangle(double a, double b, double c) implements Shape {}
double area(Shape shape) {
return switch (shape) {
case Circle(double r) -> Math.PI * r * r;
case Square(double s) -> s * s;
case Triangle(double a, double b, double c) -> heron(a, b, c);
};
}
Compiler enforces exhaustiveness because Shape is sealed. Add a fourth permits type? area fails to compile.
Guards
return switch (shape) {
case Circle c when c.r() == 0 -> 0;
case Circle c -> Math.PI * c.r() * c.r();
...
};
Order matters — first matching label wins.
null in Switch
Historically, switch(null) threw NPE. Now you can match it:
return switch (obj) {
case null -> "no value";
case String s -> "string: " + s;
case Integer i -> "int: " + i;
default -> "other";
};
Without case null, NPE still throws — backwards compatibility.
Combining: Records + Sealed + Switch Patterns
The full Java algebraic style:
sealed interface Result<T> permits Ok, Err {}
record Ok<T>(T value) implements Result<T> {}
record Err<T>(Throwable e) implements Result<T> {}
<T, R> R fold(Result<T> r, Function<T, R> onOk, Function<Throwable, R> onErr) {
return switch (r) {
case Ok<T>(T v) -> onOk.apply(v);
case Err<T>(var e) -> onErr.apply(e);
};
}
Reads cleanly; compiler catches missing cases.
When NOT to Use Switch Expressions
- Side-effect heavy branches. A
switchexpression yielding a value with side effects is OK but reads awkwardly; consider a statement form. - More than ~5 cases with complex logic per case. Method dispatch (polymorphism) often reads better.
Migration Tips
Existing statement → expression:
// Before
switch (op) {
case PLUS:
result = a + b;
break;
case MINUS:
result = a - b;
break;
default:
throw new IllegalArgumentException();
}
// After
result = switch (op) {
case PLUS -> a + b;
case MINUS -> a - b;
};
Statement form with arrows is allowed too — same fall-through avoidance, just no value:
switch (event) {
case Click c -> handleClick(c);
case KeyPress k -> handleKey(k);
}
Common Mistakes
| Mistake | Reality |
|---|---|
Mixing case X: and case X -> in one switch | Compile error — must use one style |
| Expecting fall-through with arrows | Doesn't happen; each branch is isolated |
Missing default on non-sealed input | Compile error — switch must be exhaustive |
Using yield in arrow-single-expression form | Wrong; yield is only for block form |
default after sealed-exhaustive cases | Permitted but unnecessary; remove to let compiler catch new variants |
Performance
Switch expressions over enums compile to tableswitch/lookupswitch bytecode — same as classic switch. Pattern switches over types compile to a chain of instanceof checks plus invokedynamic; modern JIT optimizes well. Don't reach for a Map<K, Function> for performance — switch is at least as fast for the common case.
[!NOTE] Adopt switch expressions in any new code. They eliminate a whole class of bugs (fall-through, missing default) and read better. Pattern matching is the right answer whenever you'd otherwise write a chain of
instanceof.
Interview Follow-ups
- "How does pattern matching handle generics?" — Type-erasure aware.
case Box<String> b -> ...is rejected becauseBox<String>andBox<Integer>look the same at runtime. Use unchecked casts or design around it. - "What is
recorddeconstruction?" — Pulls out components by position:case Point(int x, int y)bindsxandy. Works only onrecords. - "Can switch be used in a lambda?" — Yes, anywhere an expression is allowed. Lambda body can be a switch expression directly.