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

FeatureStringStringBuilderStringBuffer
MutabilityImmutableMutableMutable
Thread-safeYes (immutable)❌ No✅ Yes (synchronized)
PerformanceSlow for concatenationFastSlower than StringBuilder
Use caseConstants, keysSingle-threaded string buildingMulti-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 + into StringBuilder or invokedynamic calls. But in loops, explicit StringBuilder is 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!)

  1. If a.equals(b) is true, then a.hashCode() == b.hashCode() MUST be true.
  2. If a.hashCode() != b.hashCode(), then a.equals(b) MUST be false.
  3. If a.hashCode() == b.hashCode(), a.equals(b) may or may not be true (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 override equals(). This is the #1 source of subtle bugs with HashMap and HashSet — 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] finally does NOT execute in two edge cases:

  1. System.exit() is called in the try/catch block.
  2. 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

ConceptTypePurpose
finalKeywordPrevent modification/inheritance
finallyBlockGuaranteed 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 (intInteger).
  • Unboxing: wrapper → primitive (Integerint).
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 are Object[]

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

FeatureAbstract ClassInterface
Multiple inheritance❌ Single extends only✅ Multiple implements
Constructors✅ Yes❌ No
Instance fields✅ Yes (any access modifier)Only public static final constants
Method typesAbstract + concreteAbstract + default + static + private (Java 9+)
Access modifiersAny (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., VehicleCar, Truck).
  • Interface: When unrelated classes need a shared capability (e.g., Comparable, Serializable, Flyable).

[!TIP] Since Java 8+, interfaces with default methods 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, or private methods.
  • Must use @Override annotation (not required but strongly recommended).

Overloading vs Overriding

FeatureOverloadingOverriding
WhereSame classSubclass
Method nameSameSame
ParametersMust differMust be identical
Return typeCan differSame or covariant
Resolved atCompile timeRuntime
Polymorphism typeStaticDynamic
@OverrideN/AYes

[!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"
CouplingTight — child depends on parent's implLoose — depends on interface
FlexibilityFixed at compile timeSwap at runtime
EncapsulationBreaks (subclass sees parent internals)Preserves
Multiple typesSingle inheritance onlyCompose 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

  1. Hide state. Fields private; never expose mutable internals.
  2. Defensive copies for mutable inputs/outputs (or use immutable types).
  3. Validate at boundaries. Reject bad input in constructor/method.
  4. Behavior over setters. placeOrder() not setOrders() — captures invariants.
  5. Minimal API. Only expose what callers need.

Why It Matters

  • Refactor safely. Change orders from List to LinkedHashSet without 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 private enforces both. Modules (module-info.java, JPMS) extend it across packages.

Q: Explain the four kinds of nested classes in Java.

Answer:

TypeStatic?Holds outer ref?Use case
Static nestedyesnoLogical grouping; helper attached to enclosing class
Inner (member)noyesTightly bound to outer instance
Localnoyes (if in instance method)One-method-only helper
AnonymousnoyesOne-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 Handler holding Activity in 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

LambdaAnonymous class
this refers toenclosing classthe anonymous instance
Compiled toinvokedynamic / synthetic methodnew .class file
Can hold statenoyes (instance fields)
Multiple methodsno (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]
ImplementationBacked ByAccessInsert/DeleteUse Case
ArrayListDynamic arrayO(1) randomO(n) middleDefault choice, random access
LinkedListDoubly-linked listO(n)O(1) at head/tailFrequent insert/remove, queues
CopyOnWriteArrayListArray (copy on write)O(1)O(n) copiesMulti-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
ImplementationOrdered?Sorted?PerformanceUse Case
HashSetO(1) add/containsDefault choice
LinkedHashSet✅ Insertion orderO(1)When order matters
TreeSet✅ Sorted orderO(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
ImplementationOrdered?Sorted?Thread-safe?Use Case
HashMapDefault choice
LinkedHashMap✅ Insertion orderLRU cache, ordered iteration
TreeMap✅ Sorted by keySorted keys, range queries
ConcurrentHashMapMulti-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);
  1. Compute hash: hash = key.hashCode() → apply a bit-spreading function to reduce collisions.
  2. Find bucket index: index = hash & (table.length - 1) (bitwise AND, equivalent to hash % capacity).
  3. Check bucket:
    • If bucket is empty → create a new Node and 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.
  4. 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");
  1. Compute hash("Alice").
  2. Find bucket: index = hash & (table.length - 1).
  3. Traverse the bucket (linked list or tree) comparing with .equals().
  4. Return the value if found, null otherwise.

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 sizeStructureLookup
≤ 8 nodesLinked listO(n)
> 8 nodesRed-black treeO(log n)
≤ 6 nodes (after deletion)Untreeified back to listO(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

FeatureHashtablesynchronizedMapConcurrentHashMap
LockingEntire mapEntire mapPer-bucket / CAS
Concurrent reads❌ Blocked❌ Blocked✅ Lock-free
Concurrent writes❌ Sequential❌ Sequential✅ Parallel (different buckets)
Null keys/values✅ (if wrapped map allows)
Iteration safetyFail-fast (throws CME)Fail-fast (manual sync needed)Weakly consistent (no CME)
PerformancePoorPoorExcellent

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.synchronizedMap is if you need a thread-safe TreeMap or LinkedHashMap (since ConcurrentHashMap doesn't support ordering). For all other cases, always use ConcurrentHashMap.

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

FeatureComparableComparator
Packagejava.langjava.util
MethodcompareTo(T other)compare(T a, T b)
Modifies class?✅ Yes (class implements it)❌ No (external)
# of orderings1 (natural order)Unlimited
UsageCollections.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 Comparable for the one obvious natural ordering (e.g., alphabetical for String, numeric for Integer). Use Comparator for 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.

OpArrayListLinkedList
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 allO(1)
add(i, e) (middle)O(n) — shiftO(n) — walk + insert
remove(i)O(n) — shiftO(n) — walk
iterator.remove()O(n) — shift remainingO(1)
Memory per element4–8 bytes (array slot)~40 bytes (node + 2 refs)
Cache localityexcellent (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 ArrayDeque doesn't fit (rare).

Choose ArrayList When

  • Random access by index.
  • Iteration-heavy.
  • Default choice unless profiling proves otherwise.

Better Alternatives

  • Need a deque? → ArrayDeque beats LinkedList.
  • Need fixed-size? → Arrays.asList(...) or List.of(...).
  • Need thread-safe? → CopyOnWriteArrayList (read-heavy) or wrap with Collections.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

FeatureThreadRunnableCallable
TypeClassFunctional InterfaceFunctional Interface
Methodrun()run()call()
Return value❌ void❌ void✅ Returns V
Checked exceptions❌ No❌ No✅ Yes
Result handlingN/AN/AVia Future<V>
Use with Executor❌ Not recommendedsubmit(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 Thread directly in modern Java. Use Runnable or Callable with an ExecutorService. 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:

  1. Mutual exclusion — only one thread enters the critical section.
  2. 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

FeaturesynchronizedvolatileAtomic
Atomicity✅ Yes (whole block)❌ No✅ Yes (single operation)
Visibility✅ Yes✅ Yes✅ Yes
Blocking✅ Yes (acquires lock)❌ No❌ No (CAS spin)
PerformanceSlowest (lock contention)FastFast (no locks)
Use caseComplex multi-step operationsSimple flagsCounters, accumulators

[!CAUTION] volatile does NOT make count++ thread-safe! count++ is actually three operations: read, increment, write. Between the read and write, another thread can intervene. Use AtomicInteger.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:

PolicyBehavior
AbortPolicy (default)Throws RejectedExecutionException
CallerRunsPolicyThe calling thread runs the task (backpressure)
DiscardPolicySilently discards the task
DiscardOldestPolicyDiscards 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 use ThreadPoolExecutor with 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

FeatureFutureCompletableFuture
Blockingget() blocks❌ Callbacks are non-blocking
ChainingthenApply, thenCompose
CombiningthenCombine, allOf, anyOf
Error handlingTry-catch around get()exceptionally(), handle()
Manual completioncomplete(), completeExceptionally()

[!TIP] Think of CompletableFuture as Java's equivalent of JavaScript Promise. 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):

  1. Mutual exclusion — resources can't be shared.
  2. Hold and wait — thread holds one lock while waiting for another.
  3. No preemption — locks can't be forcibly taken away.
  4. 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

ProblemThreads Blocked?Making Progress?Solution
Deadlock✅ Yes❌ NoConsistent lock ordering, timeout
Livelock❌ No (active)❌ No (repeating same actions)Random backoff, priority
Starvation❌ Partially❌ One thread starvedFair locks, priority adjustment

[!TIP] In production, use jstack <pid> or Thread.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:

LockReentrantTry/TimeoutFairRead/Write splitCondition vars
synchronizedyesnononoone (wait/notify)
ReentrantLockyesyesconfigurablenomultiple
ReentrantReadWriteLockyesyesconfigurableyesyes
StampedLocknoyesnoyes (+ 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] StampedLock is not reentrant. Calling lock recursively → deadlock. No Condition support.

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 → ReentrantReadWriteLock or StampedLock.
  • Highest throughput, willing to handle complexity, no reentrancy → StampedLock.
  • Avoid all of above → use java.util.concurrent data structures (ConcurrentHashMap, etc).

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

CollectorTypePauseBest For
Serial GCSingle-threadedLong STWSmall apps, single-core
Parallel GC (default < Java 9)Multi-threadedMedium STWBatch processing, throughput
G1 GC (default Java 9+)Region-basedShort STWGeneral purpose, balanced
ZGC (Java 15+)Concurrent< 1ms STWUltra-low latency
Shenandoah (Java 12+)Concurrent< 1ms STWLow 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)
ClassLoaderLoads FromExamples
Bootstrap$JAVA_HOME/lib (core classes)java.lang.String, java.util.*
Extension/Platform$JAVA_HOME/lib/extSecurity, crypto extensions
Application/SystemClasspath (-cp, CLASSPATH)Your application classes
CustomAnywhere you definePlugin systems, hot-reloading

Parent-Delegation Model

When a class needs to be loaded:

  1. The current ClassLoader delegates to its parent first.
  2. If the parent can't find it, the current ClassLoader tries.
  3. 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:

ExceptionCause
ClassNotFoundExceptionClass not found at runtime (e.g., Class.forName("Missing"))
NoClassDefFoundErrorClass 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)

TierWhoProfilingSpeed
0Interpreteryesslowest
1C1 (no profiling)nofast compile, fast run
2C1 (limited profiling)partial
3C1 (full profiling)full
4C2 / Graaluses tier-3 profileslowest compile, fastest run

Hot path: 0 → 3 → 4. Cold quick wins: 0 → 1.

Triggers

  • Invocation count + back-edge (loop) count crosses thresholds (-XX:CompileThreshold=10000 for 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: 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)

InterfaceMethodSignatureUse Case
Predicate<T>test(T)T → booleanFiltering, conditions
Function<T,R>apply(T)T → RTransformation
Consumer<T>accept(T)T → voidSide effects (logging, saving)
Supplier<T>get()() → TFactory, lazy evaluation
BiFunction<T,U,R>apply(T,U)(T,U) → RTwo-arg transformation
UnaryOperator<T>apply(T)T → TSame-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

ConceptDetail
Lazy evaluationIntermediate operations are NOT executed until a terminal operation is called
Short-circuitOperations like findFirst(), limit(), anyMatch() stop early
Stateless vs Statefulfilter/map are stateless (per-element); sorted/distinct are stateful (need all elements)
One-time useA 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 absenceUse as method parameter
Use map/flatMap/orElse chainsUse isPresent() + get()
Use orElseThrow() for required valuesUse Optional.get() without checking
Use Optional.ofNullable() for nullable valuesUse Optional.of() with nullable values

[!TIP] Think of Optional as a single-element Stream. It supports map, flatMap, filter, and ifPresent — 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

  • toMap with duplicate keys → IllegalStateException. Always pass merger.
  • Collectors.toList() returns mutable list pre-16. Don't rely on immutability.
  • groupingBy returns regular HashMap — no order guarantees. Use LinkedHashMap for insertion order.
  • null values not allowed in toMap (uses Map.merge). Pre-filter or use groupingBy.

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

  1. Source split into chunks (Spliterator).
  2. Each chunk processed on a pool thread.
  3. 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

AspectConstructorSetterField
Immutabilityfinal 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 depsFails fast (detected)Can mask issuesCan mask issues

[!TIP] Since Spring 4.3, if a class has only one constructor, @Autowired is 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.

ScopeInstancesLifecycleUse Case
singleton (default)1 per ApplicationContextApp startup → shutdownStateless services, repositories
prototypeNew instance per injection/requestCreated on demand, NOT destroyed by SpringStateful objects, builders
request1 per HTTP requestRequest start → endRequest-scoped data
session1 per HTTP sessionSession start → invalidationUser session data
application1 per ServletContextApp startup → shutdownGlobal 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, or ObjectFactory to 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

PropagationBehavior
REQUIRED (default)Join existing TX, or create a new one if none exists
REQUIRES_NEWAlways create a new TX (suspend current if exists)
MANDATORYMust run inside an existing TX (throws if none)
SUPPORTSRun in TX if one exists, otherwise run without
NOT_SUPPORTEDAlways run without TX (suspend current if exists)
NEVERMust 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:

  1. Move the transactional method to a separate service.
  2. Inject self reference: @Autowired private OrderService self; then call self.createOrder().
  3. Use AspectJ mode (compile-time weaving) instead of proxy.

[!IMPORTANT] The two most critical interview points: (1) @Transactional only rolls back unchecked exceptions by default — use rollbackFor = Exception.class for 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-web
  • jackson-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

StarterBrings
spring-boot-starter-webMVC, Tomcat, Jackson
spring-boot-starter-webfluxWebFlux + Netty
spring-boot-starter-data-jpaHibernate, Spring Data JPA
spring-boot-starter-data-redisLettuce + Spring Data Redis
spring-boot-starter-securitySpring Security
spring-boot-starter-actuatorHealth/metrics endpoints
spring-boot-starter-testJUnit 5, AssertJ, Mockito, Spring Test
spring-boot-starter-validationJakarta Bean Validation

Custom Starter (Library Authors)

  1. Module: acme-spring-boot-starter (just dependency aggregator).
  2. Module: acme-spring-boot-autoconfigure (the actual @AutoConfiguration classes).
  3. Register classes in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports.
  4. Provide @ConfigurationProperties for 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 @Bean of 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:

AnnotationFires when
@ConditionalOnClassclass present on classpath
@ConditionalOnMissingClassclass absent
@ConditionalOnBeanbean of type already in context
@ConditionalOnMissingBeanbean of type not yet in context
@ConditionalOnPropertyconfig property matches
@ConditionalOnWebApplicationservlet/reactive web app
@ConditionalOnExpressionSpEL evaluates true
@ConditionalOnResourceresource exists

Order Matters

  • @AutoConfigureBefore / @AutoConfigureAfter / @AutoConfigureOrder.
  • User @Configuration classes 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 @ConditionalOnMissingBean in a custom starter → user can't override.
  • Component scan misses package: @SpringBootApplication scans 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 @Bean definitions, gated on classpath + properties + existing beans. Boot looks under META-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)

  1. Command-line args
  2. SPRING_APPLICATION_JSON
  3. application-{profile}.properties/yml (external)
  4. application.properties/yml (external)
  5. application-{profile}.properties/yml (classpath)
  6. application.properties/yml (classpath)
  7. @PropertySource
  8. 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

EndpointUse
/actuator/healthLiveness/readiness probes (k8s)
/actuator/infoBuild info, git commit
/actuator/metricsCounters, gauges, timers (Micrometer)
/actuator/prometheusPrometheus-format metrics
/actuator/envAll resolved properties (sensitive!)
/actuator/configprops@ConfigurationProperties beans
/actuator/beansBean graph
/actuator/mappingsURL → handler mappings
/actuator/loggersView / change log levels at runtime
/actuator/threaddumpLive thread dump
/actuator/heapdumpDownload .hprof
/actuator/httpexchangesRecent HTTP requests
/actuator/scheduledtasks@Scheduled registry
/actuator/shutdownGraceful 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/prometheus into Prometheus scrape.
  • k8s probes → liveness/readiness groups.
  • Build info via spring-boot-maven-plugin build-info goal → 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 @ResponseBody to return raw data.
  • @RestController = @Controller + @ResponseBody on 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

SourceAnnotationExample
URL path variable@PathVariable/users/{id}
Query string@RequestParam?page=1&size=20
Header@RequestHeaderAuthorization
Cookie@CookieValue
JSON body@RequestBodyPOST body
Form (x-www-form-urlencoded)@RequestParam per field
File upload@RequestPart / MultipartFile
Whole requestHttpServletRequest

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 on TaskExecutor, 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:

  1. try/catch in handler — local, ugly, boilerplate.
  2. @ExceptionHandler in controller — per-controller.
  3. @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 + orElseThrow pattern is common.
  • Order of advice: @Order controls precedence when multiple @ControllerAdvice could handle the same exception.
  • Async exceptions: @ExceptionHandler doesn't catch errors from inside @Async methods — handle there or via AsyncUncaughtExceptionHandler.

Best Practice Checklist

  • One global @RestControllerAdvice.
  • ProblemDetail for response shape.
  • Domain exceptions → mapped statuses, never raw 500.
  • Validation handled separately with field-level breakdown.
  • Catch-all Exception.class last → 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

RelationDefault
@OneToOneEAGER
@ManyToOneEAGER
@OneToManyLAZY
@ManyToManyLAZY

[!IMPORTANT] Make all @*ToOne LAZY (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 to true. 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);.
  • @Transactional on private/self-call — proxy bypassed, no transaction. See @Transactional deep-dive.
  • Cascade ALL on @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

TypeUse
AuthenticationThe principal + credentials + authorities (roles)
AuthenticationManagerValidates credentials, returns authenticated Authentication
AuthenticationProviderSpecific strategy (DAO, LDAP, JWT, ...)
UserDetailsServiceLoads user by username (DAO-based auth)
SecurityContextHolderThreadLocal 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") vs hasAuthority("ROLE_ADMIN")hasRole auto-prefixes ROLE_. Authority strings either include ROLE_ or not — pick one convention.
  • Forgetting @EnableMethodSecurity@PreAuthorize silently does nothing.
  • permitAll() in URL config but @PreAuthorize denies — both layers run; deny wins.
  • SecurityContextHolder + thread pools — child threads don't inherit context unless you use DelegatingSecurityContextExecutor.
  • 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

AnnotationEffect
@CacheableLookup cache; on miss, run method, store result
@CachePutAlways run method, store result (refresh)
@CacheEvictRemove entry (or all)
@CachingCompose 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) { ... }
  • condition evaluated before call → skip caching when false.
  • unless evaluated 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, prefer CompletableFuture.

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 SimpleAsyncTaskExecutorcreates a new thread per call. Disaster under load. Always configure explicitly.

3. Exception propagation

  • void return → exception swallowed unless AsyncUncaughtExceptionHandler set.
  • Future return → exception delivered via Future.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

TypeAnnotationScope
Unitnone (pure JUnit/Mockito)One class, fastest
Slice@WebMvcTest, @DataJpaTest, etc.One layer, mocks rest
Integration@SpringBootTestFull context, slower
E2E@SpringBootTest(webEnvironment=RANDOM_PORT) + TestcontainersReal 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

SliceLoads
@JsonTestJackson + JSON test utilities
@RestClientTestRestTemplate / RestClient + MockRestServiceServer
@WebFluxTestWebFlux equivalent of @WebMvcTest
@DataMongoTest, @DataRedisTest, @DataR2dbcTestPer-store slices
@WebServiceClientTestSOAP 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 — uses server.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/@SpyBean in 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

@TestPropertySourceOverride properties for one test class
@DirtiesContextForce context reload (slow, use sparingly)
@TransactionalWrap each test in tx + rollback (works with @SpringBootTest)
@SqlRun SQL scripts before/after tests
@WithMockUserInject a fake authenticated user (Spring Security)
@Tag("slow")Group tests for selective runs

Common Pitfalls

  • Context caching — Spring caches contexts by config. Different @TestPropertySource / @MockBean combos = new context. @DirtiesContext defeats caching → slow build.
  • @MockBean invalidates cache — every unique combination spawns a fresh context. Centralize mocks in shared test classes.
  • @Transactional with 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 port or TestRestTemplate.
  • 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

TermMeaning
AspectA class encapsulating a concern (@Aspect)
Join pointA point where advice can run (Spring AOP: only method calls)
AdviceCode that runs at a join point (@Before, @After, @Around, ...)
PointcutExpression matching join points
WeavingLinking 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 inner to 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 AOPAspectJ
WeavingRuntime (proxy)Compile time / load time
Join pointsMethod calls onlyConstructors, fields, blocks, more
PerformanceSlight runtime costNear-native
Self-call problemYesNo
SetupJust annotationsRequires 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

  • @TransactionalTransactionAspectSupport.
  • @AsyncAnnotationAsyncExecutionInterceptor.
  • @CacheableCacheInterceptor.
  • @PreAuthorizeMethodSecurityInterceptor.

All proxy-based. All affected by self-invocation. Same rules.

Common Pitfalls

  • Self-invocation (above).
  • Aspect on private / static / final method → won't work (CGLIB can't subclass).
  • Aspect on a non-Spring-managed object (new instead of injected) → no proxy → no advice.
  • Two aspects with same priority → undefined order.
  • Forgetting @EnableAspectJAutoProxy (Boot enables it automatically).

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

ProblemIssue
SecurityDeserialization of untrusted data can execute arbitrary code (deserialization attacks)
VersioningAny class change can break existing serialized data
PerformanceJava serialization is slow and produces verbose output
InheritanceSuperclass must also be Serializable, or have a no-arg constructor

Modern Alternatives

AlternativeFormatSpeedUse Case
JacksonJSONFastREST APIs, config
GsonJSONFastSimple JSON mapping
Protocol BuffersBinaryVery fastgRPC, microservices
AvroBinaryFastKafka, data pipelines
Java RecordsN/AN/AImmutable 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 the Serializable contract.

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

PatternProblem It SolvesReal-World Example
SingletonNeed exactly one shared instanceRuntime.getRuntime(), Spring beans (default scope)
FactoryObject creation depends on conditionsCalendar.getInstance(), LoggerFactory.getLogger()
BuilderComplex object with many optional paramsStringBuilder, HttpRequest.newBuilder(), Lombok @Builder

[!TIP] In modern Java, Lombok's @Builder generates 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, @Autowired field injection, AOP proxies.
  • Hibernate / JPA — entity field access, lazy proxies.
  • Jackson / Gson — serialize/deserialize without manual mappings.
  • JUnit / TestNG — discover @Test methods.
  • 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, Constructor lookups.
  • Catch and wrap checked exceptions sensibly.
  • Prefer MethodHandle over Method.invoke in 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

CategoryExamples
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 @Inherited and assuming subclasses pick up the annotation (only works on TYPE-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.