The Four Pillars of OOP β Through Sarah's Coffee Shop
Almost every Java interview opens here. The trick is not to recite definitions β interviewers have heard "encapsulation is hiding data" five hundred times. They want to know if you can recognize these pillars in code.
1. Encapsulation β hide the wires, expose the buttons
Encapsulation means bundling data (fields) and behavior (methods) inside a class, and exposing only what the outside world needs. Private fields, public methods. Think of Sarah's coffee machine β customers push brew(), they don't poke at internalGrinderRPM.
public class CoffeeMachine { private int waterMl; // hidden state private int beansGrams; private int grinderRpm = 1200; public Coffee brew(String type) { if (waterMl < 200) throw new IllegalStateException("Refill water"); // internals hidden β customer just gets a Coffee back return new Coffee(type); } public void refillWater(int ml) { this.waterMl += ml; } }
2. Inheritance β the family resemblance
Inheritance lets a child class reuse fields and methods from a parent. Sarah's shop sells different drinks, but every drink has a price, a name, and a way to "serve." Instead of repeating those in Coffee, Tea, and Smoothie, we put them in a parent Drink.
abstract class Drink { protected String name; protected double price; public abstract void prepare(); // each child decides how public void serve() { // shared behavior System.out.println("Serving " + name + " for βΉ" + price); } } class Coffee extends Drink { public void prepare() { System.out.println("Brewing espresso..."); } } class Tea extends Drink { public void prepare() { System.out.println("Steeping leaves..."); } }
Stack is not an ArrayList β that's why java.util.Stack extending Vector is widely considered a design mistake.3. Polymorphism β one call, many forms
Polymorphism means a single reference can point to different types and call the right method automatically. Sarah's barista holds a list of Drink β they call prepare() on each, and each drink does its own thing. The barista doesn't need a giant if/else chain.
List<Drink> orders = List.of(new Coffee(), new Tea(), new Coffee()); for (Drink d : orders) { d.prepare(); // resolves to Coffee.prepare() or Tea.prepare() at RUNTIME d.serve(); }
There are two flavors:
- Compile-time (overloading) β same method name, different parameter list. The compiler picks based on arguments. Example:
System.out.println(int)vsprintln(String). - Runtime (overriding) β child class redefines a parent method. The JVM picks based on the actual object type. This is what gives us "one call, many forms."
4. Abstraction β show what, hide how
Abstraction is the cousin of encapsulation. Encapsulation hides data; abstraction hides implementation details. You write to an interface ("what should happen"), not a concrete class ("how it happens").
interface PaymentGateway { PaymentResult pay(double amount); } class Razorpay implements PaymentGateway { /* HTTP calls to RP */ } class Stripe implements PaymentGateway { /* HTTP calls to Stripe */ } // Caller doesn't care which gateway: PaymentGateway gw = pickCheapestGateway(); gw.pay(499.0);
String, the String Pool, and Why "hi" == "hi" is True
== and it works in tests but fails in production. He's just stumbled into the most-asked Java interview topic: how Strings live in memory.The String Pool β a shared bookshelf
Java keeps a special area of memory called the String Pool (or "string intern table") inside the heap. When you write a literal like "hello", the JVM checks: is this exact text already on the shelf? If yes, hand back the existing reference. If no, place it on the shelf and hand back the new reference.
String a = "hello"; // goes into the pool String b = "hello"; // reuses the pool reference String c = new String("hello"); // FORCES a new object on the heap System.out.println(a == b); // true β same pool reference System.out.println(a == c); // false β c is a fresh object System.out.println(a.equals(c)); // true β same characters System.out.println(a == c.intern()); // true β intern() puts c into the pool
new String(...)), they get a different physical book β even though the words inside are identical.Why is String immutable?
Once a String is created, you can't change its characters. s.toUpperCase() returns a new String β the original is untouched. Why did Java's designers pick this?
- Pool safety. If two variables share
"hello"from the pool and one could mutate it, the other would see the change. Chaos. - Thread safety. Immutable objects are inherently safe to share across threads β no locks needed.
- HashMap key safety. A String's
hashCode()is computed once and cached. If the contents could change, the map would lose the entry. - Security. File paths, class names, URLs β all passed as Strings. If they could be mutated after a security check, an attacker could pass
"safe.txt", get past the check, then change it to"/etc/passwd".
String vs StringBuilder vs StringBuffer
| Class | Mutable? | Thread-safe? | Use when |
|---|---|---|---|
String | No | Yes (immutable) | Most cases β short text, keys, return values |
StringBuilder | Yes | No | Building text in a single thread (loops, parsers) |
StringBuffer | Yes | Yes (synchronized) | Legacy β almost never the right choice today |
+ creates a new String every iteration β O(nΒ²) garbage. Use StringBuilder for loops. The compiler converts a single a + b + c expression into a StringBuilder under the hood, but it can't do that across loop iterations.== sometimes "works" by accident. Always use .equals() for content comparison; == only tells you "same object reference."The equals/hashCode Contract β A Pact, Not a Suggestion
contains() for the same person β and gets false. She forgot to override hashCode(). The set is using the default identity hash, so the "same" employee maps to two different buckets.The contract in plain English
- If
a.equals(b)is true, thena.hashCode() == b.hashCode()MUST be true. - If hash codes are equal, equals may or may not be true (collisions are allowed).
- Both methods must be deterministic β same input, same output, every call.
- equals must be reflexive (
a.equals(a)), symmetric (a.equals(b) == b.equals(a)), and transitive (a=b, b=c β a=c).
hashCode() as your house's pin code and equals() as your house number. The post office (HashMap) uses the pin code to deliver to the right neighborhood, then the house number to find the exact door. If two houses claim to be "the same address" (equals) but live in different pin codes (hashCode), the post office will look in the wrong neighborhood and never find the second one.The right way to implement them
public class Employee { private final String id; private final String email; @Override public boolean equals(Object o) { if (this == o) return true; // shortcut if (!(o instanceof Employee e)) return false; return Objects.equals(id, e.id) && Objects.equals(email, e.email); } @Override public int hashCode() { return Objects.hash(id, email); // must use SAME fields as equals } }
What breaks if you violate the contract?
- HashSet/HashMap fails to find your object. You add it, you can't find it. Memory leak: the set grows forever with "duplicates" that aren't really duplicates.
- Two equal objects in different buckets. Iterating the map shows both β looks like a bug from the outside.
- Caches break silently. Spring's
@Cacheable, Guava caches, anything keyed by your object β all return stale or missing data.
== vs .equals() β and the Integer Cache Trap
This question is so common that interviewers expect a thorough answer with the famous "Integer cache" twist. If you can explain that, you're showing you know the JVM, not just the syntax.
The simple rule
==compares references for objects (same object in memory?), and values for primitives..equals()compares logical equality based on the class's contract.
The Integer cache
To save memory, the JVM pre-creates Integer objects for the range -128 to 127 and reuses them whenever you call Integer.valueOf(x) (which autoboxing also calls). Outside that range, every call creates a fresh object.
Integer a = 127; Integer b = 127; System.out.println(a == b); // true β both pulled from cache Integer c = 128; Integer d = 128; System.out.println(c == d); // false β two new objects System.out.println(c.equals(d)); // true β same value
== from C/JavaScript get burned. Always use .equals() for object equality β even for Integer, Long, String, Date, and any wrapper type.== answers "are these the same object?" β almost never the question you actually want. .equals() answers "are these logically the same?" β that's the question 99% of the time.The Collections Framework β A Tour Through the Toolbox
List. Autocomplete shows ArrayList, LinkedList, CopyOnWriteArrayList, Stack, Vector, and ten more. He freezes. Which one? When? Why are there so many?The Collections Framework is huge but organized. Three main interfaces sit at the top: List, Set, and Map. Everything else is a specialization.
The mental map
| Interface | What it represents | Common implementations |
|---|---|---|
List | Ordered, allows duplicates | ArrayList, LinkedList, CopyOnWriteArrayList |
Set | No duplicates | HashSet, LinkedHashSet, TreeSet |
Queue / Deque | FIFO / double-ended | ArrayDeque, LinkedList, PriorityQueue |
Map | Keyβvalue pairs | HashMap, LinkedHashMap, TreeMap, ConcurrentHashMap |
How to choose, in 30 seconds
- Need order + duplicates + index access? β
ArrayList. Default choice. - Need uniqueness, don't care about order? β
HashSet. - Need uniqueness in insertion order? β
LinkedHashSet. - Need uniqueness in sorted order? β
TreeSet. - Need keyβvalue lookup? β
HashMap(single-thread) orConcurrentHashMap(multi-thread). - Need sorted keys? β
TreeMap. - Need a stack/queue? β
ArrayDeque(faster than Stack/LinkedList).
Big-O cheat sheet
| Operation | ArrayList | LinkedList | HashMap | TreeMap |
|---|---|---|---|---|
| get / contains | O(1) by index, O(n) by value | O(n) | O(1) avg | O(log n) |
| add at end | O(1) amortized | O(1) | O(1) | O(log n) |
| add at middle | O(n) | O(1) if you have node ref, else O(n) | β | β |
| remove | O(n) | O(1) at ends, O(n) in middle | O(1) | O(log n) |
ArrayList, and wrap with Collections.synchronizedList() only if you actually need thread safety. Better: use a concurrent collection.HashMap Internals β The Most-Asked Question in Java
The structure β an array of buckets
Internally, a HashMap is a Node[] (called the "table") where each slot is called a bucket. Default initial size: 16. Each bucket either holds null, a single Node, a linked list of Nodes (when there are collisions), or a red-black tree (when collisions get bad).
static class Node<K, V> { final int hash; final K key; V value; Node<K, V> next; // linked list pointer for collisions }
Put, step by step
- Compute
key.hashCode(). - Apply a "spreading" function:
hash = h ^ (h >>> 16). This mixes the high bits into the low bits, so even bad hashCodes spread well. - Compute bucket index:
index = (n - 1) & hashwherenis the table size (always a power of 2, so this is equivalent tohash % nbut faster). - If the bucket is empty β place the new Node.
- If occupied β walk the chain. If a Node's key
.equals()the new key β replace the value. Otherwise β append. - If the chain length exceeds 8 AND the table size is β₯ 64 β convert that bucket to a red-black tree (treeification). Lookups in that bucket go from O(n) to O(log n).
- If
size > capacity * loadFactorβ resize. Default load factor is 0.75, so a 16-bucket table resizes when it hits 12 entries. The new table is double the size, and every entry is rehashed into it.
Why load factor 0.75?
It's a balance. Lower load factor (e.g., 0.5) β fewer collisions but wasted memory. Higher (e.g., 0.9) β less memory but more collisions, slower lookups. 0.75 is the sweet spot picked from empirical testing.
The treeification fix (Java 8)
Pre-Java 8, a bucket with bad hashCodes (or worse, a malicious attacker) could degrade to O(n) β a denial-of-service vector. Java 8 added the tree conversion at threshold 8 β guarantees O(log n) worst case for any single bucket.
HashMap is NOT thread-safe
Concurrent puts during resize can cause infinite loops (pre-Java 8, due to entry rotation) or lost data. Use ConcurrentHashMap for multi-threaded access β it locks individual bucket segments (Java 8+: per-bucket CAS), so reads are lock-free and writes only block on the same bucket.
get() goes from O(1) to O(log n) at best. Always test hashCode() for distribution on real data.ArrayList vs LinkedList β and Why You Probably Want ArrayList
Textbooks teach: "Use LinkedList for frequent inserts in the middle." Reality: even then, ArrayList usually wins. Let's see why.
Internally
- ArrayList β backed by a contiguous
Object[]. When full, it grows by 50% (Java 8+) and copies into a new array. - LinkedList β doubly-linked list of Nodes. Each Node holds a value plus two pointers (prev, next).
The real-world performance story
Modern CPUs love contiguous memory. ArrayList is a flat array β CPU cache prefetches the next elements for free. LinkedList is scattered Nodes across the heap β every .next is a potential cache miss, often 100x slower than a cache hit.
When does LinkedList actually win?
Almost never in practice. The textbook answer says "frequent insertions in the middle" β but to insert in the middle, you first need to find the position, which is O(n) for LinkedList anyway (walking the chain). The only true win is when you already hold a Node reference and want O(1) insert/remove there β and that's a rare API need.
Real winning use case: Deque operations from both ends. But even there, ArrayDeque is usually faster.
Exceptions β Checked, Unchecked, and Why People Argue About Them
throws IOException. The compiler refuses to compile. She gets annoyed: "Why can't Java just trust me?" She's just met checked exceptions.The hierarchy
Every error inherits from Throwable. Below it are two branches:
- Error β JVM problems you can't recover from (
OutOfMemoryError,StackOverflowError). Don't catch these. - Exception β application problems. Two sub-categories:
- Checked (everything that extends
Exceptionbut NOTRuntimeException) β compiler forces you to catch or declare. Examples:IOException,SQLException. - Unchecked (extends
RuntimeException) β compiler doesn't force anything. Examples:NullPointerException,IllegalArgumentException.
- Checked (everything that extends
try-with-resources (Java 7+)
Anything implementing AutoCloseable can go in a try-with-resources block β Java auto-closes it, in reverse order, even if an exception is thrown.
try (BufferedReader r = Files.newBufferedReader(path); PreparedStatement ps = conn.prepareStatement(sql)) { // use r and ps } // ps.close() then r.close() β even if exception thrown
Common mistakes
- Catching
ExceptionorThrowableat the top. Hides bugs. Catch the narrowest type that's actually meaningful. - Empty catch blocks. "Just don't crash" β and now your team spends 3 hours debugging silent corruption. At minimum log it.
- Wrapping and re-throwing without the cause.
throw new RuntimeException("failed")loses the stack trace. Always pass the original:throw new RuntimeException("failed", e). - Returning from finally. Swallows exceptions silently. Don't.
map can't accept a function that throws IOException. This forced workaround patterns. Many modern Java libraries (Spring, Guava) lean unchecked for this reason.Generics & Type Erasure β What Happens to <T> at Runtime?
List<String> and List<Integer> and runs list1.getClass() == list2.getClass(). It returns true. He's just discovered that at runtime, both are just List β the type parameter has been erased.What is type erasure?
Java generics are a compile-time feature only. The compiler uses <T> to type-check your code and insert casts, but the bytecode that ships to the JVM has no record of T. Wherever you wrote T, the bytecode says Object (or the upper bound, like Number for <T extends Number>).
// What you write: List<String> names = new ArrayList<>(); names.add("Sarah"); String first = names.get(0); // What the JVM actually runs (after erasure): List names = new ArrayList(); names.add("Sarah"); String first = (String) names.get(0); // compiler-inserted cast
The consequences
- You can't do
new T()β at runtime there is noTto instantiate. - You can't do
obj instanceof List<String>β onlyinstanceof List. The runtime can't see the type parameter. - You can't have arrays of generic types β
new T[10]won't compile. (Arrays know their element type at runtime; generics don't.) - Bridge methods β when a generic class is overridden, the compiler may add invisible methods to keep the JVM's method dispatch happy.
Wildcards β ? extends vs ? super (PECS)
Mnemonic: PECS β Producer Extends, Consumer Super.
List<? extends Number>β you can read Numbers out (it's a producer), but you can't add anything (compiler can't know if it's a List of Integer or Double).List<? super Integer>β you can add Integers (it's a consumer), but reading gives you Object (compiler can't know the upper bound).
Immutability and the Three Faces of final
What does final mean?
finalvariable β value can be assigned once. (For objects, it means the reference can't change β the object's internals can still mutate.)finalmethod β cannot be overridden by subclasses.finalclass β cannot be extended.String,Integer,LocalDateare all final.
final List<String> names = new ArrayList<>() does NOT make the list immutable. You can still names.add("..."). The reference is final; the list contents are not. For a truly read-only list, use List.copyOf(names) or Collections.unmodifiableList(names).How to build a truly immutable class
- Mark the class
final(so no one can subclass and add mutability). - Mark all fields
private final. - No setters. Initialize everything in the constructor.
- If a field is itself a mutable object (e.g., a Date or List), defensive copy on the way in (in the constructor) and on the way out (in the getter).
public final class Order { private final String id; private final List<String> items; public Order(String id, List<String> items) { this.id = id; this.items = List.copyOf(items); // defensive copy + immutable } public List<String> getItems() { return items; // already unmodifiable, safe to return } }
Why immutability matters
- Thread safety for free. No locks needed; the object can never be in an inconsistent state.
- Safe to use as a HashMap key. hashCode never changes mid-lookup.
- Easier to reason about. No "who mutated this?" debugging sessions.
- Cacheable. Compute once, reuse forever β
Stringcaches its hashCode.
record Order(String id, List<String> items) {}. They auto-generate constructor, getters, equals, hashCode, toString. Defensive copy still requires a compact constructor, though.Threads β The Basics, Told Through a Restaurant Kitchen
Thread vs Process
- Process β an independent program with its own memory space. Two processes can't see each other's variables.
- Thread β a unit of work inside a process. All threads in the same process share the heap (objects, static fields), but each has its own stack (local variables).
Three ways to start a thread
// 1. Extend Thread (rarely the right choice) class Worker extends Thread { public void run() { System.out.println("running"); } } new Worker().start(); // 2. Implement Runnable (preferred β you can still extend something else) Runnable task = () -> System.out.println("running"); new Thread(task).start(); // 3. Submit to an Executor (the modern way β see section 13) ExecutorService pool = Executors.newFixedThreadPool(4); pool.submit(task);
thread.run() directly. That just runs the code on the current thread synchronously. start() is what tells the JVM to actually create a new thread.Thread lifecycle
- NEW β created but not started.
- RUNNABLE β eligible to run (the OS scheduler picks when).
- BLOCKED β waiting for a monitor lock (e.g., entering a
synchronizedblock held by another thread). - WAITING / TIMED_WAITING β waiting for another thread (
Object.wait(),Thread.join(),Thread.sleep()). - TERMINATED β finished or threw an uncaught exception.
synchronized and volatile β The Two Keywords Every Java Dev Must Know
The problem they solve
Modern CPUs have multiple cores, each with its own cache. When thread A on Core 1 writes to a variable, that write may sit in Core 1's cache for a while before reaching main memory. Thread B on Core 2 reading the same variable might see a stale value. Worse, the compiler and CPU can reorder instructions for performance, breaking your assumptions about what runs first. synchronized and volatile are how Java tells the JVM "stop being clever here."
synchronized β mutual exclusion + memory visibility
Wraps a block in a monitor lock. Only one thread can hold the lock at a time; others block. Critically, entering and exiting a synchronized block also flushes the thread's CPU caches to/from main memory.
class Counter { private int count = 0; // Method-level β locks on `this` public synchronized void increment() { count++; } // Block-level β lock on a specific object (more flexible) private final Object lock = new Object(); public void incrementSafe() { synchronized (lock) { count++; } } }
volatile β visibility, NOT mutual exclusion
Marks a variable so every read goes to main memory and every write is flushed immediately. No locking. Threads always see the latest value, but multiple threads can still race on it.
class Worker implements Runnable { private volatile boolean running = true; public void run() { while (running) { /* work */ } } public void stop() { running = false; } // other thread sees this immediately }
volatile is like saying "always read the whiteboard, never trust your memory." synchronized is "lock the whiteboard room β only one cook in at a time, and when they leave, everyone else's notes are updated."When to use which
| Need | Use |
|---|---|
| Read-only flag updated from another thread | volatile |
Read-modify-write (count++, list.add) | synchronized or AtomicXxx |
| Compound action across multiple fields | synchronized |
| Single counter / single reference, lock-free | AtomicInteger / AtomicReference |
volatile on count++ does NOT make it thread-safe. count++ is read-modify-write β three operations. Two threads can both read 5, both write 6, and you've lost an increment. Use AtomicInteger.incrementAndGet().Executors and Thread Pools β Don't Hire a New Cook for Every Order
new Thread(task).start() for every request does β creating a thread costs ~1 MB of memory and milliseconds of OS overhead. ExecutorService is the staffing agency that maintains a pool of standing-by baristas.The four common pools
| Factory method | Behavior | Use case |
|---|---|---|
newFixedThreadPool(n) | n threads, unbounded queue | Steady load, known concurrency |
newCachedThreadPool() | Unbounded threads, threads die after 60s idle | Many short-lived tasks, bursty |
newSingleThreadExecutor() | 1 thread, sequential execution | Order-dependent tasks (logger, sequencer) |
newScheduledThreadPool(n) | Delayed/periodic tasks | Cron-style jobs |
Submit and wait
ExecutorService pool = Executors.newFixedThreadPool(4); // Submit returns a Future β the task's "claim ticket" Future<String> future = pool.submit(() -> { Thread.sleep(1000); return "done"; }); String result = future.get(); // blocks until the task finishes // Modern: CompletableFuture β chainable, non-blocking CompletableFuture.supplyAsync(() -> fetchUser(42), pool) .thenApply(user -> user.getName()) .thenAccept(name -> System.out.println(name)) .exceptionally(ex -> { ex.printStackTrace(); return null; }); pool.shutdown(); // always shutdown β else JVM won't exit
Executors.newCachedThreadPool() can create unlimited threads β if your tasks block (e.g., on slow I/O), you can run out of memory. Prefer new ThreadPoolExecutor(...) with explicit bounded queue + rejection policy in production.Virtual Threads (Java 21+)
Lightweight threads managed by the JVM, not the OS. Cost: ~few KB. You can spin up millions. Perfect for I/O-bound work where each task spends most of its time waiting on a network call. The "one thread per request" model is back β but cheap.
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 10_000; i++) { exec.submit(() -> callSlowApi()); } } // AutoCloseable β waits for all tasks
Beyond synchronized β Locks, Atomics, and Concurrent Collections
ReentrantLock β synchronized with superpowers
synchronized is simple but rigid. ReentrantLock gives you tryLock (non-blocking attempt), interruptible lock, fair ordering, and multiple condition variables.
Lock lock = new ReentrantLock(); // Try to acquire for 500ms β give up if it can't if (lock.tryLock(500, TimeUnit.MILLISECONDS)) { try { // critical section } finally { lock.unlock(); // MUST be in finally β else lock leaks forever } }
ReadWriteLock β many readers, one writer
If your data is read 100x more often than written, full mutual exclusion is wasteful. ReentrantReadWriteLock lets unlimited readers in concurrently, but writers get exclusive access.
Atomics β lock-free counters
AtomicInteger, AtomicLong, AtomicReference use CPU-level CAS (compare-and-swap) instructions. No locks, no blocking β just retry-on-conflict at the hardware level.
AtomicInteger count = new AtomicInteger(); count.incrementAndGet(); // thread-safe ++ without synchronized count.compareAndSet(5, 10); // "if value is 5, set to 10" atomically
Concurrent collections
| Collection | What's special |
|---|---|
ConcurrentHashMap | Per-bucket locks. Reads are lock-free. Writes only contend on the same bucket. |
CopyOnWriteArrayList | Every write creates a new copy. Reads are lock-free and very fast. Use only when reads dominate writes massively. |
BlockingQueue (ArrayBlockingQueue, LinkedBlockingQueue) | Producer-consumer pattern. put() blocks if full, take() blocks if empty. |
ConcurrentLinkedQueue | Lock-free FIFO queue (Michael-Scott algorithm). |
ConcurrentHashMap as a parking garage with separate gates per row. Pre-Java 8 it had ~16 gates (segments). Java 8 onwards, every row has its own little gate (CAS). Two cars heading to different rows never wait.ConcurrentHashMap over Collections.synchronizedMap() β the latter wraps every operation in a single lock, which kills concurrency.JVM Memory Model β Where Does Your Object Actually Live?
Person p = new Person("Sarah"). He's been told "objects go on the heap, primitives on the stack." But which stack? Where in the heap? And what is this Metaspace thing? Let's open the JVM and look inside.The five memory areas
- Heap β shared by all threads. All objects (everything created with
new) live here. Subdivided into Young Gen (Eden + two Survivor spaces) and Old Gen. - Stack β one per thread. Holds method frames: each frame contains local variables and the return address. Primitives and object references (NOT the objects themselves) live here.
- Metaspace (Java 8+; replaced PermGen) β class metadata, method bytecode, runtime constant pool. Native memory, grows dynamically.
- PC Register β one per thread. Holds the address of the current bytecode instruction.
- Native Method Stack β for JNI / native calls.
Stack vs Heap β a concrete example
void checkOut() { int total = 100; // primitive β on this thread's STACK String name = "Sarah"; // reference on STACK, "Sarah" String on HEAP (in pool) Order order = new Order(42, name); // reference on STACK, Order object on HEAP } // stack frame discarded β heap objects live until GC
Young vs Old generation
The heap has two main zones:
- Young Generation β where new objects are born (specifically in Eden). Most objects die young (the "weak generational hypothesis"). Young GC is fast and frequent.
- Old Generation β objects that survive several Young GC cycles get promoted here. Long-lived objects (caches, singletons). Old GC is slower but rarer.
StackOverflowError = stack ran out (usually unbounded recursion). OutOfMemoryError: Java heap space = heap is full. Different problems, different fixes β increase -Xss for stack, -Xmx for heap.Garbage Collection β Java's Janitor
The GC's job is to find objects no one is using anymore and reclaim their memory. The how and when has evolved dramatically β knowing modern GCs (G1, ZGC, Shenandoah) is a strong signal in interviews.
What does "no one is using" mean?
The GC walks from a set of GC roots (live thread stacks, static fields, JNI references) and marks every object it can reach. Anything not reached is unreachable β garbage β freed.
Generational hypothesis β the key insight
Empirically, most objects die young. A request handler creates 100 short-lived objects, returns, and they're all garbage. Why scan the whole heap when 99% of garbage is in the young area? Modern GCs split the heap into Young + Old and run different algorithms on each.
GC algorithms β the modern lineup
| GC | Pause time | Best for |
|---|---|---|
| Serial | Stops the world. Single-threaded. | Tiny apps, embedded |
| Parallel (Throughput) | Stops the world. Multi-threaded. | Batch jobs β max throughput, pauses ok |
| G1 (default since Java 9) | Tries to hit a target pause (e.g., 200ms). Region-based. | Most server apps with multi-GB heap |
| ZGC / Shenandoah | <10ms pauses, even on 100GB+ heaps | Latency-critical, large heap apps |
Stop-the-world (STW)
For some GC phases, all application threads must pause. This is "stop-the-world." It's why a 16 GB heap full of long-lived objects can cause noticeable lag spikes. Modern GCs (G1, ZGC) minimize STW pauses by doing most work concurrently with the application.
When you can't be GC'd
Common causes of memory leaks in Java (yes, leaks exist despite GC):
- Static collections that grow forever β a static
HashMapthat you never evict from. - Unclosed listeners / callbacks β registered but never deregistered. The framework keeps a strong reference to your object.
- ThreadLocals not removed β a thread in a pool retains its ThreadLocal entry across requests.
- Caches without size limits β use
WeakHashMapor a real cache library (Caffeine).
ClassLoaders β Who Brings Your Classes In?
java -cp myapp.jar com.example.Main, who actually loads Main.class into memory? It's not magic β it's a chain of ClassLoaders, each with its own job and its own search path.The classic three-tier hierarchy
- Bootstrap ClassLoader β written in C++, part of the JVM itself. Loads the core JDK classes (
java.lang.*,java.util.*). Pre-Java 9 these came fromrt.jar; post-Java 9 from JRT modules. - Platform (Extension) ClassLoader β loads JDK extension modules. A child of bootstrap.
- Application (System) ClassLoader β loads classes from your
-cpclasspath. A child of platform. This is the one that loads your code.
The delegation model
When asked to load class X, a ClassLoader first asks its parent ("can you load X?"). Only if the parent can't does it try locally. This walks all the way up to bootstrap before any child tries.
java.lang.String.Why does this matter?
- Class identity = (class name, ClassLoader). Two different ClassLoaders can load the same class name and the JVM treats them as different types. Cast between them β ClassCastException.
- Hot reloading β frameworks like Spring DevTools, Tomcat, and IDEs use multiple ClassLoaders so they can swap class versions without restarting the JVM.
- Plugin systems β each plugin gets its own ClassLoader, isolated from others.
Streams & Functional Java β Pipelines, Lazy Evaluation, the Whole Story
What is a stream?
A Stream is a sequence of elements supporting declarative operations like filter, map, reduce. It's NOT a data structure β it doesn't store anything. It's a pipeline that lazily processes elements from a source.
List<Order> orders = /* ... */; Map<String, Double> topCustomers = orders.stream() .filter(o -> o.getDate().isAfter(LocalDate.now().minusDays(7))) .collect(Collectors.groupingBy(Order::getCustomer, Collectors.summingDouble(Order::getAmount)));
Three pieces of every stream
- Source β collection, array, I/O channel, generator. Where elements come from.
- Intermediate operations β
filter,map,flatMap,sorted,distinct. Lazy β they describe work, don't do it. - Terminal operation β
collect,forEach,reduce,count. Triggers the actual computation.
Lazy evaluation β the key superpower
Intermediate ops don't run until a terminal op pulls. This means short-circuiting: findFirst() only processes elements until it finds one. limit(10) stops after ten. Streams over infinite sources (Stream.iterate, Stream.generate) work because of laziness.
Parallel streams
Add .parallel() and the JVM splits the work across the common ForkJoinPool. Sounds magical β and is dangerous if abused.
list.add) is a race condition. Rule: parallel only for CPU-heavy, stateless, large-N work.Functional interfaces β the building blocks
| Interface | Signature | When to use |
|---|---|---|
Function<T, R> | R apply(T) | Transform: map |
Predicate<T> | boolean test(T) | Filter |
Consumer<T> | void accept(T) | Side effect: forEach |
Supplier<T> | T get() | Lazy value, factory |
BiFunction<T, U, R> | R apply(T, U) | Two-arg transform: reduce accumulator |
Optional β Use It Right or Don't Use It
Optional was added in Java 8 to express "this might be absent." The community immediately misused it everywhere. Here's how to use it the way Brian Goetz (Java's chief language architect) recommends.
What it's for
Optional exists to make "no value" explicit in return types. A method returning Optional<User> tells the caller, "I might not find one β handle that case."
public Optional<User> findById(String id) { return Optional.ofNullable(userMap.get(id)); } // Caller is forced to handle absence: String name = findById("u1") .map(User::getName) .orElse("Unknown");
What it's NOT for
- Fields β don't make
Optional<Address> addressa field. Use null directly, or split into two classes. Optional doesn't serialize well and adds memory overhead. - Method parameters β overloads or just allowing null are simpler.
- Collection elements β
List<Optional<User>>is silly. An empty list is the absence. - Direct .get() without isPresent β defeats the entire purpose. If you're going to call
.get()blindly, you've replaced NullPointerException with NoSuchElementException for no benefit.
Optional is NOT a substitute for null everywhere. It's a tool for one specific signaling problem: "this query might return nothing." Use it surgically.HashMap vs ConcurrentHashMap β A Single-Threaded Notebook vs a Shared Whiteboard
HashMap because "it's faster". Two weeks later, under load, the API starts returning random NPEs and once even hangs an entire JVM thread at 100% CPU. The bug is one line β the wrong Map.The fundamental difference
HashMap is single-threaded by design. If two threads write to it at the same time, you can corrupt the internal bucket array β pre-Java 8 this could even create a circular linked list during resize, sending one thread into a 100% CPU infinite loop. ConcurrentHashMap is purpose-built for concurrent access β multiple threads can read and write at the same time without locks blocking each other (in most cases).
How ConcurrentHashMap achieves concurrency
It does not slap a single lock around the whole map (that's what Collections.synchronizedMap() does, and it's terrible for throughput). Instead:
- Java 7 (segment-based): the map was split into 16 "segments". Each segment had its own lock. Two threads writing to different segments never blocked each other.
- Java 8+ (bucket-level CAS): the segments were removed. Each bucket can be updated atomically using compare-and-swap (CAS). When buckets collide on a write, only that one bucket synchronizes briefly. Reads are fully lock-free thanks to
volatilefields on the Node.
Side-by-side comparison
| Aspect | HashMap | ConcurrentHashMap |
|---|---|---|
| Thread-safe | No | Yes |
| Null keys/values | One null key, many null values allowed | No nulls allowed (anywhere) |
| Iterator behavior | Fail-fast (throws ConcurrentModificationException) | Fail-safe (weakly consistent β never throws CME) |
| Performance (single-threaded) | Faster (no synchronization overhead) | Slightly slower |
| Performance (multi-threaded) | Unsafe β corruption guaranteed | Excellent β bucket-level locking |
| Internals | Plain Node[] | Node[] + CAS + volatile + synchronized blocks per bucket |
Why no nulls in ConcurrentHashMap?
With concurrent access, map.get(key) returning null would be ambiguous: did the key not exist, or did someone just put(key, null)? In a single-threaded HashMap you can follow up with containsKey, but in a concurrent map another thread might mutate between the two calls. So Doug Lea (the author) just banned nulls β disambiguation by design.
The "atomic operations" superpower
ConcurrentHashMap exposes operations like putIfAbsent, compute, computeIfAbsent, merge that are atomic β no other thread can sneak in between the read and the write. Always prefer these over a manual get-then-put sequence:
// Two threads can both see "absent" and both put their value if (!map.containsKey(key)) { map.put(key, expensiveCompute()); }
map.computeIfAbsent(key, k -> expensiveCompute());
computeIfAbsent, do NOT mutate the same map (e.g., put another key) β the bucket is locked and you'll either deadlock or break invariants. Keep the lambda short and side-effect-free.When to use which
- HashMap β local variables, single-threaded code, request-scoped data, anything that won't escape a thread.
- ConcurrentHashMap β caches, shared registries, counters, anything multiple threads see.
- Collections.synchronizedMap(new HashMap<>()) β almost never. It's a single coarse lock; ConcurrentHashMap beats it on every benchmark.
OutOfMemoryError β What Causes It and How to Debug It in Production
java.lang.OutOfMemoryError: Java heap space. The team thinks "just bump the heap to 4GB". Karan knows that's a band-aid β something is leaking. Here's how he chases it down.OOM is not one error β it's six
The full message after the colon tells you which memory area ran out. They have very different causes:
| Variant | What it means | Likely cause |
|---|---|---|
Java heap space | The Old Gen + Young Gen are full and GC can't reclaim enough | Memory leak (objects pinned by some root), or undersized heap for actual workload |
GC overhead limit exceeded | JVM spent >98% of recent time in GC and reclaimed <2% | Heap is too small AND there's a leak β the JVM is thrashing GC trying to survive |
Metaspace | Class metadata area is full (Java 8+ replacement for PermGen) | Loading too many classes β common in apps that hot-deploy or use code-generation libraries (CGLib, Groovy, etc.) |
Direct buffer memory | Native memory used by NIO ByteBuffers is exhausted | Leaking DirectByteBuffers β common in Netty / Kafka client misuse |
unable to create new native thread | OS refused to create another OS thread | Thread leak β usually unbounded thread pools or leaked new Thread() calls |
Requested array size exceeds VM limit | Tried to allocate an array bigger than ~Integer.MAX_VALUE | Reading a giant file/blob into a single byte[] |
Common root causes for "Java heap space"
- Static collections that grow forever β
private static final Map<String, User> CACHE = new HashMap<>();with no eviction. Classic. - ThreadLocal leaks β values set on a thread pool thread, never removed. The thread lives forever, the value lives with it.
- Listeners and callbacks not deregistered β every page registers a listener; the page closes; the listener still holds the page in memory.
- Loading too much from the database β
userRepo.findAll()returning 5 million rows. JPA hydrates every one into an object. - Caches without bounds β Guava/Caffeine caches with no
maximumSize. - Inner classes capturing outer-class references β common in Android-style code; less common but possible in Java.
The debugging workflow β what Karan actually does
- Add JVM flags before the next crash so you have evidence:
JVM flags every prod app should have
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/heapdump.hprof -Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags -XX:+ExitOnOutOfMemoryError // let k8s restart you cleanly - Reproduce or wait for the next crash. Now you have a heap dump (a snapshot of every object in memory at the moment of OOM).
- Open the heap dump in Eclipse MAT (Memory Analyzer Tool) β or VisualVM, or IntelliJ's profiler. MAT is the gold standard.
- Run "Leak Suspects Report". MAT will rank the biggest retained-size objects and tell you "Object X is keeping 1.4 GB alive via the path Y β Z". This single feature solves 80% of leaks.
- Look at the dominator tree β the chain of references that holds the suspect object alive. The first non-framework class in that chain is usually your culprit.
- Cross-check with GC logs. If the Old Gen graph monotonically grows and full GCs reclaim less and less β that's a leak. If it sawtooths normally and just hits the ceiling under load β your heap is just too small.
Live diagnostics (no heap dump needed)
jcmd <pid> GC.heap_infoβ current heap usage by regionjcmd <pid> GC.class_histogramβ count and total size of every loaded class's instances. The class with 5 million instances ofbyte[]is your suspect.jstat -gcutil <pid> 1000β once-per-second GC stats; watch the Old Gen %jmap -dump:live,format=b,file=heap.hprof <pid>β manually trigger a heap dump while the app is running
Fixes by category
- Cache leak β put a bounded eviction policy on the cache (Caffeine +
.maximumSize()+.expireAfterAccess()). - ThreadLocal leak β always pair
set()withremove()in a finally block, or use a try-with-resources wrapper. - Loading too much from DB β add pagination, use streaming (JPA
Stream<Entity>with@QueryHints), or process in chunks. - Genuine workload growth β bump
-Xmx, but only after you've ruled out a leak.
-Xmx as a "fix" without finding the leak is the most common mistake. The leak still grows, just slower. The next OOM hits at 3 AM on the weekend instead of Tuesday afternoon.Making a Class Thread-Safe β A Real Scenario, Not a Lecture
ProductInventory that tracks stock per SKU. Last Black Friday, two customers managed to buy the very last unit of a phone β they both saw "1 in stock" and both checkouts succeeded. Sales is furious. Sneha's job: make this class thread-safe.What "thread-safe" really means
A class is thread-safe if its public behavior remains correct when multiple threads use it concurrently β without callers needing to add their own synchronization. Three things can go wrong without thread-safety:
- Race conditions β two threads see the same "before" state and both apply changes, one update is lost.
- Memory visibility β thread A writes a field, thread B never sees the new value because it's cached in a CPU register.
- Compound operations β "check then act" or "read-modify-write" sequences that are atomic individually but not as a unit.
The buggy starting point
class ProductInventory { private final Map<String, Integer> stock = new HashMap<>(); public boolean tryReserve(String sku, int qty) { Integer available = stock.get(sku); // (1) read if (available == null || available < qty) return false; stock.put(sku, available - qty); // (2) write β both threads do this with the same "available" return true; } }
Two threads can both pass step (1) seeing available = 1, then both execute step (2) writing 0 β and both tryReserve calls return true. Two phones sold; one phone in inventory. That's the bug.
Five ways to fix it β pick by trade-off
Option 1 β synchronized method (simplest)
public synchronized boolean tryReserve(String sku, int qty) { Integer available = stock.get(sku); if (available == null || available < qty) return false; stock.put(sku, available - qty); return true; }
One lock for the whole object. Reservations on different SKUs serialize unnecessarily β fine for low traffic, painful at Black Friday scale.
Option 2 β Per-key locking (better concurrency)
private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>(); public boolean tryReserve(String sku, int qty) { return stock.computeIfPresent(sku, (k, current) -> { if (current < qty) return current; // no change β put back same value return current - qty; }) != null; // note: the boolean we want is "did the value actually change" β // real code would use AtomicReference to capture that. Sketch shown for clarity. }
The lambda runs under a per-bucket lock. Two threads on different SKUs proceed in parallel.
Option 3 β Atomic counters (lock-free)
private final ConcurrentHashMap<String, AtomicInteger> stock = new ConcurrentHashMap<>(); public boolean tryReserve(String sku, int qty) { AtomicInteger counter = stock.get(sku); if (counter == null) return false; while (true) { int current = counter.get(); if (current < qty) return false; if (counter.compareAndSet(current, current - qty)) return true; // CAS failed β another thread changed it; loop and retry } }
Hardware-level CAS, no kernel locks. Beats synchronized for hot single counters under high contention.
Option 4 β Immutability (no locks needed at all)
If ProductInventory is read-mostly (e.g., a config snapshot), make it immutable and replace the whole reference atomically:
private final AtomicReference<Map<String, Integer>> ref = new AtomicReference<>(Map.of()); public void replaceAll(Map<String, Integer> next) { ref.set(Map.copyOf(next)); // immutable snapshot }
Option 5 β Use a thread-safe library (Caffeine, Hazelcast, Redis)
For real e-commerce inventory, in-memory state isn't enough β you need persistence + cross-node coordination. Move state to Redis with DECRBY (atomic decrement) or use a distributed lock.
The thread-safety checklist
- Identify shared mutable state β fields read/written by multiple threads.
- Make it immutable if possible β final fields, defensive copies, no setters.
- If it must be mutable, pick the right primitive:
synchronized,ReentrantLock,Atomic*,ConcurrentHashMap,volatile(visibility only). - Identify compound operations ("check-then-act", "read-modify-write") and make each one atomic.
- Avoid leaking
thisfrom a constructor β partially-constructed objects can be seen by other threads. - Document the policy β annotate with
@ThreadSafe/@NotThreadSafe(jcip-annotations) so callers know.
StringBuffer is thread-safe per method, but if (sb.length() > 0) sb.append(x) still has a race between the two calls. Document the unit of atomicity.HashMap with ConcurrentHashMap and used computeIfPresent so the check-then-decrement happens under one bucket lock. Black Friday next year: zero oversells. The pattern: shared state β atomic operation β bounded contention.The Complete Lifecycle of a Spring Bean β From Definition to Destruction
UserService and annotated it @Service. By the time the first HTTP request hits, that bean is wired up, configured, validated, and ready. What happened in the milliseconds between main() starting and the first request arriving? That's the bean lifecycle.The 10-step ritual
Spring follows a strict sequence whenever it brings a bean to life. The same sequence runs for every bean β from your @Service to a third-party library's DataSource:
- Bean Definition β Spring scans
@Component/@Configurationand registers aBeanDefinition(a recipe β class name, scope, dependencies). No object exists yet. - Instantiation β When something requests the bean (or eager init kicks in), Spring calls the constructor (or factory method). Now an object exists in memory.
- Populate Properties β Spring resolves
@Autowireddependencies and injects them into fields/setters. (Constructor-injected deps were already supplied in step 2.) - Aware interfaces β If the bean implements
BeanNameAware,BeanFactoryAware,ApplicationContextAware, Spring calls those setters now. - BeanPostProcessor.postProcessBeforeInitialization β every registered
BeanPostProcessorgets a crack at the bean. This is where AOP proxies are wrapped,@Autowiredvalidation happens, etc. - InitializingBean.afterPropertiesSet() β if the bean implements this interface, Spring calls it. (Generally avoid β couples to Spring.)
- @PostConstruct / custom init-method β your custom initialization runs. This is the "after wiring is done, do my setup" hook.
- BeanPostProcessor.postProcessAfterInitialization β second pass for post-processors. This is where Spring AOP wraps the bean in a proxy (e.g., for
@Transactional) β that's why injected versions of your bean are CGLib subclasses. - Bean is in use β methods get called by the rest of the app.
- Destruction β On context close:
@PreDestroyβDisposableBean.destroy()β customdestroy-method. Singleton beans only β prototype scope is never destroyed by Spring.
Diagram of the flow
BeanDefinition
β
new MyBean(deps) // constructor
β
setX(...) / @Autowired // field/setter injection
β
setBeanName / setApplicationContext // Aware callbacks
β
postProcessBeforeInitialization (every BeanPostProcessor)
β
afterPropertiesSet() // InitializingBean
β
@PostConstruct method
β
postProcessAfterInitialization // AOP proxies happen here
β
[ READY β bean is used by the app ]
β
@PreDestroy
β
destroy() // DisposableBean
Concrete example
@Service public class UserService implements InitializingBean, DisposableBean { private final UserRepository repo; public UserService(UserRepository repo) { // step 2 + 3: ctor + DI this.repo = repo; } @PostConstruct public void init() { // step 7 // warm cache, validate config, etc. } @Override public void afterPropertiesSet() { } // step 6 @PreDestroy public void cleanup() { // step 10 // flush queues, close clients } @Override public void destroy() { } // also step 10 }
Bean scopes change the rules
- Singleton (default) β one instance per Spring container. Created at startup (eager) unless
@Lazy. - Prototype β new instance every time it's requested. Spring does NOT call destroy callbacks on prototypes β you own the cleanup.
- Request / Session β Spring MVC scopes; one instance per HTTP request or session.
@Lookup or ObjectProvider to get a fresh prototype on each call.Why the proxy step matters
When a bean has @Transactional, @Async, or @Cacheable, what gets injected elsewhere is not your class β it's a CGLib subclass (or JDK dynamic proxy if you implement an interface). The proxy intercepts every method call to add transaction/cache/async behavior, then delegates to your real instance. This is why calling @Transactional methods from within the same class (this.method()) bypasses the proxy and the transaction never starts.
How Dependency Injection Actually Works in Spring
@Autowired UserRepository repo; and the field magically has a working repository. There's no new UserRepository() anywhere in her code. She wonders β who actually builds these objects, and how do they find each other?The core idea β invert the dependency direction
In hand-rolled Java, your UserService says "I need a UserRepository, let me new one up." That couples UserService to the concrete UserRepository class. Dependency injection inverts this: UserService just declares what it needs (in its constructor or a field), and someone else β Spring β supplies the dependency. UserService doesn't know or care which implementation it gets.
UserService) asks for "a tomato" β they don't go to the farm, the supplier does. If the supplier swaps brands (in-memory repo β JPA repo), the chef's recipe doesn't change.The mechanism β three layers
Spring DI rests on three pieces of machinery: the BeanDefinition registry (the catalog of all known beans), the ApplicationContext (the runtime container), and BeanFactory (the lower-level interface that actually builds and wires beans).
Startup walkthrough β what happens in SpringApplication.run()
- Component Scan. Spring scans the packages under your main class for
@Component/@Service/@Repository/@Controller/@Configurationβ ASM-based bytecode reading, not actual class loading. For each annotated class, it creates aBeanDefinition: name, class, scope, autowire mode, dependencies. - Configuration classes processed. Methods annotated
@Beaninside@Configurationclasses also produceBeanDefinitions β the method itself becomes the factory. - BeanDefinitionRegistryPostProcessor β runs (e.g., to add more bean definitions dynamically β Spring Boot's auto-configuration uses this).
- BeanFactoryPostProcessor β gets a chance to modify bean definitions (e.g., resolve
${property}placeholders). - Eager singleton creation. Spring iterates all singleton bean definitions and instantiates each one. For each:
- Resolve constructor β pick the one with the most-satisfiable parameters.
- For each constructor parameter, recursively get-or-create that bean (this is where DI happens).
- Call
newon the constructor with those resolved dependencies. - Field/setter inject any remaining
@Autowireddeps. - Run init callbacks, post-processors, AOP wrapping (see the bean lifecycle in section 24).
- Cache the singleton. Future requests for the same bean return the cached instance β that's why singletons are singletons.
Resolution rules β how Spring picks which bean to inject
Given a @Autowired UserRepository repo, Spring looks at all beans assignable to UserRepository:
- If exactly one match β inject it.
- If multiple matches β look for
@Primary; if present, use that one. - Still ambiguous β look for
@Qualifier("name")on the injection point. - Still ambiguous β fall back to matching by parameter/field name (e.g.,
repotries to match a bean namedrepo). - Still nothing β throw
NoUniqueBeanDefinitionException.
Three injection styles
@Service public class UserService { private final UserRepository repo; public UserService(UserRepository repo) { // @Autowired implicit on single ctor (Spring 4.3+) this.repo = repo; } }
@Autowired @Setter private EmailService email;
@Autowired private UserRepository repo; // hides dependencies, not final, hard to test, can't detect circular deps cleanly
Constructor injection is preferred because: (1) the field can be final, (2) the dependency is visible in the type signature, (3) you can construct the class in tests with plain new (no Spring needed), (4) circular dependencies fail at startup instead of silently breaking later.
How does field injection work without setters?
Reflection. Spring uses Field.setAccessible(true) + Field.set(bean, dependency). That's why private final fields cannot be field-injected β final can only be set in the constructor, and reflection-setting it is undefined behavior in modern JVMs.
Circular dependency handling
If A needs B via constructor and B needs A via constructor β unsolvable, startup fails. If at least one side uses setter/field injection, Spring uses a three-level cache to expose a half-constructed reference: the partially-built A is added to the "early singleton" cache so B can grab a reference to it before A's setters have run. It works, but it's a code smell β fix the circular design.
What's @ComponentScan doing under the hood?
It uses Spring's ClassPathScanningCandidateComponentProvider which:
- Resolves the base package to a filesystem/JAR path.
- Walks all
.classfiles using ASM to read the bytecode without loading the class. - Checks for stereotype annotations (
@Component,@Service, etc.). - Produces
BeanDefinitions for matches.
ASM-based scanning is why component scan is fast β it never invokes the class loader for non-component classes.
@Autowired on a static field doesn't work. Static fields belong to the class, not the bean instance β Spring has nowhere to inject. Wrap statics in a non-static getter or refactor to instance fields.What Actually Happens When You Use @Transactional
@Transactional public void transfer(...) and assumes "Spring will roll back if anything goes wrong." It's mostly true. Then a runtime bug eats $40 of test money silently β the rollback didn't fire because someone wrapped the call in a try-catch. He learns the hard way that @Transactional is not magic β it's a proxy with very specific rules.The 10,000-foot view
When Spring sees @Transactional on a bean's method, it wraps the bean in a proxy at startup (CGLib subclass, or JDK dynamic proxy if the bean implements an interface). The proxy intercepts every public method call. If the called method is annotated, the proxy:
- Asks the
PlatformTransactionManagerto begin a transaction (or join an existing one β see propagation). - Invokes your real method.
- If the method returns normally β commit.
- If the method throws an unchecked exception (RuntimeException, Error) β rollback.
- If the method throws a checked exception β by default, COMMIT (yes, really β rule 5 of @Transactional gotchas).
The proxy in action β diagram
caller -> UserService$$EnhancerByCGLIB.transfer() // the proxy β beginTransaction() β super.transfer() // real method β commit() | rollback() β return to caller
The four rollback rules everyone gets wrong
Rule 1 β Default rollback is unchecked exceptions only
@Transactional rolls back on RuntimeException and Error. It commits on checked exceptions like IOException. To change this:
@Transactional(rollbackFor = Exception.class) public void transfer(...) throws IOException { ... }
Rule 2 β Self-invocation bypasses the proxy
public void orderFlow() { this.saveOrder(); // direct call β proxy NOT involved β @Transactional ignored! } @Transactional public void saveOrder() { ... }
The proxy intercepts external calls. this.foo() is a direct method call on the underlying object β it never goes through the proxy. Fix: split into two beans, or use AopContext.currentProxy(), or self-inject.
Rule 3 β Catching the exception kills the rollback
@Transactional public void transfer(...) { try { debit(from); credit(to); } catch (RuntimeException e) { // swallowed β proxy never sees the exception β COMMIT log.error("transfer failed", e); } }
The proxy decides commit/rollback based on whether the method throws. If you catch and swallow, the method "returns normally" and gets committed.
Rule 4 β Only public methods are proxied (CGLib)
@Transactional on private/package-private methods is silently ignored. JDK dynamic proxies require an interface; CGLib subclasses can only override non-final, non-private methods.
Propagation β what happens when @Transactional calls @Transactional
| Propagation | Behavior |
|---|---|
REQUIRED (default) | Join the caller's transaction; start one if none exists |
REQUIRES_NEW | Suspend the caller's transaction, start a fresh independent one |
NESTED | Use a SAVEPOINT inside the caller's transaction β partial rollback possible |
SUPPORTS | Use the caller's transaction if there is one; otherwise run non-transactionally |
MANDATORY | Throw if there's no caller transaction |
NEVER | Throw if there IS a caller transaction |
NOT_SUPPORTED | Suspend any caller transaction; run without one |
Common use: auditing β @Transactional(propagation = REQUIRES_NEW) on the audit-log save so the audit row commits even if the parent business transaction rolls back.
Isolation level β what reads can you trust?
READ_UNCOMMITTEDβ see uncommitted changes from other tx (dirty reads). Almost never used.READ_COMMITTED(Postgres default) β only see committed data, but two reads in the same tx can see different values (non-repeatable read).REPEATABLE_READ(MySQL default) β same row returns same value within the tx. Phantoms still possible (new rows matching your WHERE).SERIALIZABLEβ full isolation, as if transactions ran one after another. Safest, slowest.
How does the transaction "follow" the thread?
Spring stores the active Connection + transaction state in a ThreadLocal. JdbcTemplate / Hibernate / JPA query methods inside the transactional method look up the thread-bound connection, so all queries share the same transaction. This is why @Transactional + a new thread (e.g., @Async or CompletableFuture.runAsync) is broken β the thread-local doesn't follow.
@Transactional on the controller layer. Transactions should match the boundary of a unit of business work β that's the service layer. Controller-level transactions hold a DB connection for the entire HTTP request including request parsing and response serialization, wasting connection-pool capacity.@Component vs @Service vs @Repository β Same Engine, Different Labels
@Component on a class and @Service on another. Functionally they look identical β both make a bean. So why does Spring offer multiple annotations? It's a mix of intent-signaling and one quietly-magical exception around @Repository.The short answer
@Service and @Repository are both meta-annotated with @Component. From Spring's perspective, all three register a bean. The differences are: (1) intent β what role this class plays in the architecture, and (2) one functional difference β @Repository triggers JDBC/JPA exception translation.
Side-by-side
| Annotation | Layer | Functional behavior |
|---|---|---|
@Component | Generic β any Spring-managed bean | Just registers a bean |
@Service | Service / business logic layer | Same as @Component (no extra behavior) |
@Repository | Persistence / DAO layer | Same as @Component + DataAccessException translation |
@Controller / @RestController | Web / MVC layer | Same as @Component + Spring MVC handler-mapping picks them up |
The functional difference β exception translation
JPA throws PersistenceException, JDBC throws SQLException with vendor-specific error codes ("23505" for Postgres unique-violation, "1062" for MySQL). If your service layer catches these directly, your business code becomes coupled to the database product.
When you mark a class @Repository, Spring's PersistenceExceptionTranslationPostProcessor wraps it in a proxy that converts these low-level exceptions into Spring's vendor-neutral DataAccessException hierarchy:
DuplicateKeyExceptionβ for any unique-violation, regardless of DBDataIntegrityViolationExceptionβ generic constraint violationOptimisticLockingFailureExceptionβ version mismatch on updateQueryTimeoutExceptionβ DB query timeout
Your service code can now catch (DuplicateKeyException e) portably across MySQL, Postgres, Oracle.
@Service public class UserService { public void register(User u) { try { userRepo.save(u); } catch (DuplicateKeyException e) { // portable Spring exception throw new EmailAlreadyTakenException(); } } }
Why bother with intent annotations at all?
- Readability β a reader sees
@Serviceand immediately knows "this is business logic, not a controller, not a DAO". Self-documenting layering. - Tooling β IDEs, static analyzers, and AOP pointcuts can target a layer (
execution(* (@Service *).*(..))). - Future hooks β Spring may add behavior to
@Servicein future versions.@Repositoryalready has its hook;@Serviceis reserved for similar purposes.
What about @Configuration?
Also a meta-@Component. The extra behavior: @Bean methods inside an @Configuration class are intercepted by a CGLib proxy so that calling another @Bean method returns the cached singleton instead of running the method twice. Without @Configuration (e.g., with the lighter proxyBeanMethods=false), each call would create a new instance.
@Service on a DAO doesn't break anything functionally β but you lose the exception translation benefit. Use the right annotation for the layer.@Repository is the only one with a real functional bonus (exception translation). Use the others to communicate layering intent β your future self reading the code will thank you.Global Exception Handling in Spring Boot β One Place, Every Error
@ControllerAdvice.The mechanism β @ControllerAdvice + @ExceptionHandler
@ControllerAdvice is a special @Component that Spring MVC consults for every controller in the application. Inside it, methods annotated @ExceptionHandler(SomeException.class) are called whenever a controller throws that exception type, and their return value becomes the HTTP response β same way as a normal controller method.
A complete error-handling setup
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(UserNotFoundException.class) public ResponseEntity<ApiError> handleNotFound(UserNotFoundException ex) { return ResponseEntity.status(404) .body(new ApiError("USER_NOT_FOUND", ex.getMessage())); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex) { List<String> errors = ex.getBindingResult().getFieldErrors().stream() .map(f -> f.getField() + ": " + f.getDefaultMessage()) .toList(); return ResponseEntity.badRequest() .body(new ApiError("VALIDATION_FAILED", errors)); } @ExceptionHandler(DataIntegrityViolationException.class) public ResponseEntity<ApiError> handleDuplicate(DataIntegrityViolationException ex) { return ResponseEntity.status(409) .body(new ApiError("DUPLICATE_RESOURCE", "Already exists")); } @ExceptionHandler(Exception.class) // catch-all β must be last public ResponseEntity<ApiError> handleAll(Exception ex) { log.error("unhandled", ex); // log full stack server-side return ResponseEntity.status(500) .body(new ApiError("INTERNAL_ERROR", "Something went wrong")); } } public record ApiError(String code, Object detail, Instant timestamp) { public ApiError(String code, Object detail) { this(code, detail, Instant.now()); } }
The handler hierarchy β most specific wins
If handleNotFound matches UserNotFoundException and a generic handleAll matches Exception, Spring picks the most specific handler β your specific one. Order doesn't matter; type specificity does.
RFC 7807 β the standard "Problem Details" format
For consistency across services, follow the IETF Problem Details standard:
{
"type": "https://api.example.com/errors/user-not-found",
"title": "User not found",
"status": 404,
"detail": "No user with id=42",
"instance": "/api/users/42",
"timestamp": "2026-05-10T09:23:14Z"
}
Spring 6 / Boot 3 has built-in support β extend ResponseEntityExceptionHandler and Spring will automatically produce ProblemDetail bodies for the standard exceptions.
Validation errors β a special case
@Valid on controller params triggers MethodArgumentNotValidException when validation fails. Catch it in your advice and return field-level error messages so the frontend can highlight which fields are wrong.
What to log vs what to return
- Log server-side β full stack trace, request ID, user ID, request body. This is for your debugging.
- Return to client β error code, human-readable detail, request ID (so users can quote it to support). Never return stack traces or SQL fragments β that's an info leak attack vector.
Errors thrown outside controllers
@ControllerAdvice catches exceptions thrown during request handling. Exceptions in @Async methods, scheduled jobs, or filters are NOT caught β handle those with their own mechanisms (AsyncUncaughtExceptionHandler, custom error filter chain, etc.).
@ControllerAdvice. If your auth filter throws, the global handler doesn't see it. Add a fallback error filter or use Spring Security's AuthenticationEntryPoint for those cases.@RestControllerAdvice class with one @ExceptionHandler per error type, returning a consistent ApiError shape β that's all global exception handling needs. Bonus: log the full stack server-side, never leak it to the client.JPA Lazy vs Eager Loading β and the N+1 Bug Behind Half of All "Why Is It Slow?" Tickets
The two strategies
| Strategy | Behavior | Default for |
|---|---|---|
FetchType.EAGER | Load the related entity immediately, in the same query (or an extra one) when the parent is loaded | @OneToOne, @ManyToOne |
FetchType.LAZY | Don't load the related entity. Replace it with a proxy. The first method call on that proxy triggers a SQL query. | @OneToMany, @ManyToMany |
How lazy loading actually works
When you load an Order with a lazy List<OrderItem> items, Hibernate doesn't query the items table. Instead, it puts a PersistentBag proxy in the field. The proxy holds a reference to the session. On the first order.getItems().size() call, the proxy hits the DB to fetch the actual items.
The N+1 problem β what bit Pooja
List<Order> orders = orderRepo.findAll(); // 1 query: SELECT * FROM orders for (Order o : orders) { System.out.println(o.getItems().size()); // N queries: SELECT * FROM items WHERE order_id = ? } // Total: 1 + N queries β devastating for N = 1000
Five ways to avoid N+1
Fix 1 β JOIN FETCH in JPQL (most common)
@Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.userId = :uid") List<Order> findOrdersWithItems(@Param("uid") Long uid);
Fix 2 β Entity Graphs
@EntityGraph(attributePaths = {"items", "items.product"}) List<Order> findByUserId(Long userId);
Fix 3 β Batch Size
Annotate the collection: @BatchSize(size = 50). Now Hibernate will fetch 50 orders' items in a single WHERE order_id IN (?, ?, ...) instead of one query per order. Reduces N+1 to N/50 + 1 β a 50x improvement.
Fix 4 β Projections / DTOs
If you only need a few fields, skip the entity entirely. Project directly into a DTO:
@Query("SELECT new com.foo.OrderSummary(o.id, o.total, count(i)) " + "FROM Order o LEFT JOIN o.items i GROUP BY o.id") List<OrderSummary> summaries();
Fix 5 β Second-level cache
For read-heavy reference data (countries, product categories), enable Hibernate's L2 cache so subsequent loads of the same entity hit Redis/Ehcache instead of the DB.
The other extreme β eager-loading horror
Eager @OneToMany sounds tempting ("just load it all once"), but if the parent entity has 5 lazy collections, every parent load fires 5+ queries even when you don't need them. Worse, eager OneToMany forces a JOIN that returns a Cartesian product β loading 100 orders Γ 10 items each = 1000-row result set, deduped by Hibernate in memory. Default to LAZY everywhere; opt into eager fetching per query.
The "LazyInitializationException" trap
Lazy loading needs an open session. If you fetch an order in the service layer (closes the transaction), then access order.getItems() in the controller layer (no session), Hibernate throws LazyInitializationException. Fixes:
- Initialize what you need inside the transaction (
order.getItems().size()forces fetch). - Use JOIN FETCH in your repository so the data is loaded in one go.
- Avoid OSIV (Open Session In View) β Spring's default. It hides the problem by extending the session through the controller, but encourages N+1 queries during JSON serialization.
How to detect N+1 before production
- Enable SQL logging in tests:
spring.jpa.show-sql=true+logging.level.org.hibernate.SQL=DEBUG. - Use Hypersistence Utils or Datasource Proxy to count queries in tests and fail builds where count > expected.
- Add a load test that exercises list endpoints with realistic dataset sizes.
@EntityGraph β query count dropped from 51 to 1, latency from 4s to 90ms.Concurrent Updates to the Same Database Row β Optimistic vs Pessimistic Locking
read β modify β write without any locking guarantees.The lost update β what we're protecting against
Thread A: balance = SELECT balance FROM wallet WHERE id=1; // reads 500 Thread B: balance = SELECT balance FROM wallet WHERE id=1; // reads 500 Thread A: UPDATE wallet SET balance = 100 WHERE id=1; // 500-400=100 Thread B: UPDATE wallet SET balance = 100 WHERE id=1; // 500-400=100 (B's version of "100") // Final: 100. Should have been -300 (overdraft) or B's request rejected.
Five strategies, ordered by overhead
Strategy 1 β Atomic single-statement update (best when it fits)
Skip the read entirely. Let the database do the math:
UPDATE wallet
SET balance = balance - 400
WHERE id = 1 AND balance >= 400;
-- check rowsAffected; if 0, insufficient funds
Atomic at the DB level (most engines acquire a row lock for the duration of the UPDATE). No application-level coordination needed. Best for simple counters, balances, stock decrements.
Strategy 2 β Optimistic Locking (version number)
Add a version column. Every update bumps it; updates fail if the version doesn't match what you read. JPA does this automatically with @Version:
@Entity class Wallet { @Id Long id; int balance; @Version Long version; } // Hibernate emits: UPDATE wallet SET balance=?, version=version+1 WHERE id=? AND version=?; // rowsAffected = 0 β throw OptimisticLockException β retry the whole transaction
Pros: no locks held; fast under low contention. Cons: you must implement retry logic (typical: retry 3 times with backoff). Good when conflicts are rare (most wallet operations don't collide).
Strategy 3 β Pessimistic Locking (SELECT FOR UPDATE)
Tell the DB "I'm going to update this row β block any other reader/writer until I'm done":
BEGIN; SELECT balance FROM wallet WHERE id=1 FOR UPDATE; -- row-level write lock -- now no one else can read-for-update or write this row until I COMMIT UPDATE wallet SET balance = balance - 400 WHERE id=1; COMMIT;
JPA equivalent: @Lock(LockModeType.PESSIMISTIC_WRITE) on the repository method.
Pros: simple to reason about; no retry needed. Cons: serializes access; risk of deadlocks if two transactions lock different rows in different orders; holds a connection longer.
Strategy 4 β Distributed Locks (Redis / ZooKeeper)
When the lock needs to span multiple services or non-DB resources, use a distributed lock manager β Redis with SET NX EX (Redlock), or ZooKeeper ephemeral nodes:
RLock lock = redisson.getLock("wallet:1"); if (lock.tryLock(5, 30, TimeUnit.SECONDS)) { try { // critical section β across services } finally { lock.unlock(); } }
Strategy 5 β Idempotency keys (request-level)
For payments / API calls: clients include an Idempotency-Key header. The server stores the key + result. If the same key arrives twice (retries, double-clicks), serve the cached result β never apply the operation twice. Doesn't prevent concurrent requests but prevents duplicate effects.
How to choose
| Use case | Strategy |
|---|---|
| Counter / balance update | Atomic single-statement (Strategy 1) |
| Conflicts are rare, retries are cheap | Optimistic locking (Strategy 2) |
| Conflicts are frequent OR retries expensive | Pessimistic locking (Strategy 3) |
| Lock spans multiple services | Distributed lock (Strategy 4) |
| Client retries with same intent | Idempotency keys (Strategy 5) |
Isolation level matters
Even with optimistic locking, READ_COMMITTED (Postgres default) lets the lost update happen if you don't have a version check. REPEATABLE_READ (MySQL InnoDB default) detects the conflict and aborts one transaction. Know your DB's default and pick a strategy that matches.
UPDATE wallet SET balance = balance - ? WHERE id = ? AND balance >= ?. Atomic, no race, no retry logic, no extra schema. When the math is simple, let the database do it.ACID Properties β Through a Banking Transfer
The four letters
A β Atomicity ("all or nothing")
Either every statement in the transaction succeeds, or none do. A power cut between the debit and the credit must not leave the bank with vanished money.
BEGIN; UPDATE accounts SET balance = balance - 1000 WHERE id = 'A'; -- step 1 -- π₯ server crashes here UPDATE accounts SET balance = balance + 1000 WHERE id = 'B'; -- never runs COMMIT; -- After recovery: A is back to original balance. The crash rolled back step 1.
How it's achieved: the database writes every change to a write-ahead log (WAL) first. On crash recovery, transactions that didn't reach COMMIT are rolled back from the log.
C β Consistency ("the rules are never broken")
The transaction takes the database from one valid state to another. Constraints (foreign keys, check constraints, unique indexes) are never violated by a committed transaction. In our banking case: the total money in the bank before = total after.
CHECK (balance >= 0) -- can't go negative total_money = SUM(balance) -- invariant: 1000 leaves A, 1000 enters B
How it's achieved: partly the DB (constraints, triggers) and partly your application logic. Consistency is the only ACID property that requires application cooperation.
I β Isolation ("transactions don't see each other's mess")
If Sahil's transfer is running and Anil's balance check reads Account A in the middle, Anil shouldn't see "money has left A but not yet arrived at B." From Anil's perspective, the transfer either hasn't happened or has fully happened.
| Level | Allows |
|---|---|
| READ_UNCOMMITTED | Dirty reads (sees uncommitted writes from other tx) |
| READ_COMMITTED (Postgres default) | Non-repeatable reads possible |
| REPEATABLE_READ (MySQL default) | Phantom reads possible |
| SERIALIZABLE | Full isolation β slowest |
How it's achieved: row-level locks, multi-version concurrency control (MVCC β each transaction sees a snapshot of the DB at its start time).
D β Durability ("once it's committed, it's permanent")
Once COMMIT returns success, the change must survive crash, power loss, OS reboot. If Anil sees "transfer successful" then yanks the power cord, the money is still moved when the server comes back.
How it's achieved: the WAL is fsync()'d to disk before COMMIT returns. The database may also replicate to a standby before acking the commit (synchronous replication) for stronger durability.
The full transfer in code
@Transactional(isolation = Isolation.REPEATABLE_READ) public void transfer(String from, String to, BigDecimal amount) { int debited = jdbc.update( "UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?", amount, from, amount); if (debited == 0) throw new InsufficientFundsException(); // C β invariant jdbc.update("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, to); } // A β both updates atomic via the surrounding transaction // I β REPEATABLE_READ ensures consistent view // D β DB fsyncs WAL before commit returns
How ACID is implemented under the hood
- Write-Ahead Log (WAL). Every change is appended to a sequential log before being applied to the data files. On crash, WAL is replayed to recover.
- Locks + MVCC. Locks for writers; MVCC snapshot for readers β readers never block writers.
- Two-phase commit (2PC). For distributed transactions across multiple databases β phase 1: prepare (everyone vote yes), phase 2: commit. Slow, blocking; modern systems prefer Sagas instead.
The CAP / BASE counterpoint
Not every system needs strict ACID. NoSQL stores like Cassandra or DynamoDB give up isolation/consistency for availability and horizontal scale. They offer BASE β Basically Available, Soft state, Eventual consistency. The trade-off: faster, scalable, but you must handle stale reads and conflicts in your app code.
@Transactional + READ_COMMITTED prevents lost updates. It doesn't β see section 30 on concurrent updates. ACID isolation prevents seeing partial data, NOT all anomalies.How to Optimize a Slow SQL Query β A Step-by-Step Playbook
Step 1 β Run EXPLAIN (or EXPLAIN ANALYZE)
Never optimize blind. Get the query plan first:
EXPLAIN (ANALYZE, BUFFERS) SELECT o.id, u.name FROM orders o JOIN users u ON o.user_id = u.id WHERE o.created_at >= NOW() - INTERVAL '7 days' ORDER BY o.created_at DESC;
Look for: Seq Scan on a large table (probably needs an index), Nested Loop on big sets (might want a Hash or Merge Join), high actual time on a node, rows estimated vs actual diverging by 10x+ (stale stats β run ANALYZE).
Step 2 β Index the columns in WHERE, JOIN, and ORDER BY
The single most common fix. Indexes turn O(N) sequential scans into O(log N) lookups.
CREATE INDEX idx_orders_created_at ON orders(created_at DESC);
-- DESC matches the ORDER BY direction β no extra sort
- B-tree β default; great for equality, range, and prefix-match LIKE.
- Hash β equality only; rarely worth it.
- GIN β full-text search, JSONB queries, array contains.
- BRIN β block-range; for huge time-series tables where data is naturally ordered.
Step 3 β Composite indexes for multi-column filters
If you query WHERE user_id = ? AND status = 'PAID', a single composite index is far better than two separate indexes:
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- Used for: WHERE user_id = ?
-- Used for: WHERE user_id = ? AND status = ?
-- NOT used for: WHERE status = ? (left column missing)
Order matters: put the most selective column (the one with the most distinct values, used in the most queries) first.
Step 4 β Avoid SELECT *
Selecting only needed columns lets the DB use covering indexes β if all columns the query needs are in the index, it never touches the table:
CREATE INDEX idx_orders_covering ON orders(user_id, status) INCLUDE (total);
-- SELECT total FROM orders WHERE user_id = ? AND status = 'PAID' β never touches the heap
Step 5 β Avoid functions on indexed columns
WHERE LOWER(email) = 'a@b.com'; WHERE DATE(created_at) = '2026-05-10';
CREATE INDEX idx_email_lower ON users(LOWER(email));
-- or rewrite predicate:
WHERE created_at >= '2026-05-10' AND created_at < '2026-05-11';
Step 6 β Pagination β keyset over offset
LIMIT 20 OFFSET 100000 makes the DB scan and discard 100,000 rows. Switch to keyset pagination:
SELECT * FROM orders WHERE created_at < :lastSeenCreatedAt ORDER BY created_at DESC LIMIT 20;
Step 7 β Reduce JOIN cost
- Filter before joining (CTE / subquery) when one side is huge.
- Make sure both sides of a join are indexed on the join key.
- Question the join β do you actually need data from that table, or just an EXISTS check?
Step 8 β Cache the result
If the data changes rarely, cache the query result in Redis with a TTL. A 10ms Redis hit beats a 200ms DB query every time.
Step 9 β Update statistics / rebuild stale indexes
The query planner uses table statistics. If they're stale, plans get bad. Postgres: ANALYZE orders;. MySQL: ANALYZE TABLE orders;. Rebuild bloated indexes occasionally β REINDEX (Postgres) or OPTIMIZE TABLE (MySQL).
Step 10 β Last resort: denormalize / materialized view / read replica
- Materialized view β pre-computed query result, refreshed on a schedule.
- Denormalized columns β store
order_counton the user row, updated on insert/delete. Trades write speed for read speed. - Read replica β offload heavy analytical queries to a follower DB.
The senior-engineer checklist
- EXPLAIN ANALYZE first β never guess
- Index the WHERE / JOIN / ORDER BY columns
- Composite indexes for multi-column predicates (selective column first)
- Drop SELECT *, prefer covering indexes
- Avoid functions on indexed columns
- Keyset pagination, not OFFSET
- Rewrite N+1 query patterns
- Cache for read-heavy, slow-changing data
- Refresh stats; rebuild bloated indexes
- Denormalize / materialize / replicate when needed
orders.created_at; adding it dropped runtime to 35ms. Always start with the plan, never with a guess.Designing Pagination & Sorting in a REST API
Two pagination styles β pick deliberately
Style 1 β Offset pagination (page + size)
GET /api/products?page=3&size=20&sort=createdAt,desc
// Backend translates to:
SELECT * FROM products ORDER BY created_at DESC LIMIT 20 OFFSET 60;
Pros: users can jump to any page; total page count is easy to compute. Cons: OFFSET 100000 is slow (DB scans 100k rows just to skip them); pages become inconsistent under inserts (if a row is added on page 2 while you're viewing page 3, the next item shifts and you can see duplicates or skip rows).
Style 2 β Keyset / Cursor pagination
GET /api/products?cursor=eyJjcmVhdGVkQXQiOiIyMDI2LTA1LTEwVDA5OjAwOjAwWiIsImlkIjo0Mn0&size=20
// Backend decodes cursor β continues from a specific point
SELECT * FROM products
WHERE (created_at, id) < ('2026-05-10T09:00:00Z', 42)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Pros: always fast (uses index on created_at); stable under inserts. Cons: can't jump to "page 47" β only forward / backward. Cursor must be opaque (base64-encoded JSON) so clients don't depend on its structure.
Why two columns in the cursor? If two rows share created_at (same millisecond), use id as a tiebreaker β otherwise you can skip or duplicate rows at the boundary.
Response shape β include the metadata
{
"data": [ ... 20 products ... ],
"pagination": {
"next_cursor": "eyJ...",
"prev_cursor": "eyK...",
"has_more": true
}
}
{
"data": [ ... 20 products ... ],
"page": 3,
"size": 20,
"total_elements": 5421,
"total_pages": 272
}
Sorting β keep it explicit, validate aggressively
Accept a list of sort fields with directions. Whitelist allowed fields β never inject the user's input directly into SQL or JPQL.
private static final Set<String> SORTABLE = Set.of("createdAt", "price", "name"); @GetMapping("/products") public Page<Product> list(@RequestParam int page, @RequestParam int size, @RequestParam String sort) { // "createdAt,desc" β Sort.by(...) String[] parts = sort.split(","); if (!SORTABLE.contains(parts[0])) throw new BadRequestException(); return repo.findAll(PageRequest.of(page, size, Sort.by(Sort.Direction.fromString(parts[1]), parts[0]))); }
Spring Data Pageable β built-in support
Spring auto-binds a Pageable from query params: ?page=0&size=20&sort=price,desc&sort=name,asc. Your repository method just declares Page<Product> findAll(Pageable p) and you get a paginated result with total count for free.
Default and max sizes
Always cap size. A client requesting size=100000 can DoS your DB. Reasonable defaults: page 0, size 20, max 100. Validate at the controller level.
Filtering β make it composable
Beyond pagination, filters need a contract. Options:
- Per-field query params β simple but limited:
?status=PAID&minPrice=100 - RSQL / Spring Specification β
?filter=status==PAID;price=ge=100 - POST with filter body β for complex queries, accept JSON in request body
Caching pagination responses
Page 1 is often hit hardest. Cache the response keyed by query params + cursor. Invalidate on writes to the entity. For high-cardinality queries (every request unique), don't bother.
id) makes pagination silently skip / duplicate rows at page boundaries. Always include id as a final sort key.JWT Authentication β Step by Step, With What Each Step Protects
What's in a JWT
A JWT is three base64url strings separated by dots: header.payload.signature.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 // header (base64) β {"alg":"HS256","typ":"JWT"} .eyJzdWIiOiI0MiIsImV4cCI6MTcxNTM0NTk5OX0 // payload (base64) β {"sub":"42","exp":1715345999} .MEUCIQDxxxxx... // signature β HMAC or RSA over (header + "." + payload)
- Header β algorithm + token type. Don't trust this; pin the algorithm server-side.
- Payload (claims) β user id (
sub), expiry (exp), roles, anything you want. Not encrypted β anyone can read it. Don't put secrets here. - Signature β proof that the server signed it. Anyone who tampers with header or payload invalidates the signature.
The seven-step flow
- Login. Client POSTs username/password to
/auth/login. - Server verifies credentials. Looks up user, BCrypt-checks the password.
- Server signs two tokens.
- Access token β short-lived (15 min), carries user id + roles, used on every API call.
- Refresh token β long-lived (7-30 days), used only to get a new access token. Stored in an HttpOnly secure cookie or in a server-side allowlist.
- Client stores tokens. Web: HttpOnly cookies (XSS-safe) or memory + refresh-via-cookie. Mobile: Keychain (iOS) / EncryptedSharedPreferences (Android).
- Client sends access token on every request.
Header
GET /api/orders Authorization: Bearer eyJhbGc...
- Server validates the token on every request.
- Split into header / payload / signature.
- Recompute signature using the server's secret (HS256) or public key (RS256). Compare. If mismatch β 401.
- Check
exp. If past β 401. - Check
iss,audmatch expected values. - Optionally check a denylist (for revocation β see below).
- Extract
sub+ roles β set Spring SecurityAuthenticationβ continue to controller.
- Token expires. Client gets 401, sends refresh token to
/auth/refresh, gets a fresh access token. If refresh token also expired β user must log in again.
Spring Security flow β where the code lives
public class JwtAuthFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) { String header = req.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { String token = header.substring(7); try { Claims claims = jwtParser.parseClaimsJws(token).getBody(); Authentication auth = new UsernamePasswordAuthenticationToken( claims.getSubject(), null, rolesFrom(claims)); SecurityContextHolder.getContext().setAuthentication(auth); } catch (JwtException e) { /* 401 */ } } chain.doFilter(req, res); } }
Algorithm choice β HS256 vs RS256
- HS256 (HMAC-SHA256) β symmetric. The same secret signs and verifies. Simple if all your services share one trusted boundary.
- RS256 (RSA) β asymmetric. Auth server signs with private key, every API server verifies with the public key. Use this when verifiers and issuers are separate teams or services.
The hardest part β revocation
JWTs are stateless, which means once issued, they're valid until exp. If a user logs out or you ban them, you can't really "revoke" a JWT β the signature is still valid. Three approaches:
- Short-lived access tokens (15 min) β accept that revocation takes β€15 min to propagate.
- Server-side denylist β store revoked token IDs (jti claim) in Redis with TTL = remaining token lifetime. Every request checks the list. Reintroduces the round-trip you were trying to avoid, but only for the denylist.
- Refresh-token rotation β every refresh issues a new refresh token AND invalidates the old one server-side. If a stolen refresh token is used, the attacker and the legitimate user race; one will get logged out, alerting you to the breach.
The dangerous failure modes
- "alg: none" attack β naive libraries trust the header's
algfield. If it saysnone, no signature check is done. Always pinalgserver-side. - HS256 with a public-key secret β leaked, attacker can forge tokens. Rotate your secret regularly.
- JWT in localStorage + XSS β XSS reads the token, attacker has full session. Prefer HttpOnly cookies on web.
- No expiry / very long expiry β stolen token is gold forever. Always set
exp. - Sensitive data in payload β claims are base64, not encrypted. Don't put PII or PAN data in there.
Your API is Slow Under Load β How to Find the Bottleneck
The hierarchy of suspects β fastest to find first
Latency under load is almost always one of these, in this rough order of frequency: (1) database, (2) downstream HTTP call, (3) thread pool / connection pool exhaustion, (4) GC pauses, (5) hot lock contention, (6) actual CPU-bound code.
Step 1 β Look at the metrics dashboard first
Before SSH-ing into pods, check Grafana. Within 60 seconds you should know:
- p50, p95, p99 latency for the slow endpoint β is it tail latency or median?
- Throughput (RPS) β did traffic spike?
- Error rate β are timeouts contributing?
- CPU / memory / GC on the JVM pods
- DB connection pool active vs max β is it pegged?
- Downstream service latency β did a dependency get slow?
Step 2 β Distributed tracing tells you which span is slow
OpenTelemetry / Jaeger / Zipkin / Datadog APM. Pick one slow request, look at the flame chart of spans:
- HTTP handler total: 8s
- JPA query "findOrders": 7.6s β the smoking gun
- Redis "get user-cache:42": 3ms
- HTTP call to "payment-service": 80ms
The widest span is the bottleneck. No tracing? Add structured logs with timing around suspicious calls.
Step 3 β Database investigation
If the DB is the suspect:
- Slow query log β most DBs log queries above a threshold. Postgres:
log_min_duration_statement = 500ms. Find queries that recently got slow. - EXPLAIN ANALYZE β see section 32 for the full playbook.
- Active queries β Postgres:
SELECT * FROM pg_stat_activity WHERE state = 'active' ORDER BY query_start;. Long-running queries blocking the pool? - Locks β
pg_locksfor blocked transactions; deadlocks can cascade through a whole connection pool. - Connection pool saturation β HikariCP active = max means every thread is waiting. Either increase the pool, fix the slow queries holding connections, or shorten transactions.
Step 4 β Thread / connection pool exhaustion
Symptoms: latency suddenly spikes from 50ms to seconds; throughput plateaus despite more load. Causes:
- Tomcat threads (
server.tomcat.threads.maxdefault 200) all stuck on a slow downstream β new requests queue. - HikariCP DB pool size too small for traffic β threads wait for a connection.
- Custom executor with unbounded queue β requests pile up forever, p99 climbs to infinity.
Diagnose: thread dump (jstack <pid> or kill -3). Look at "what are the 200 Tomcat threads doing right now?" β usually they're all parked on the same downstream call.
Step 5 β GC pauses
If p99 has periodic 1-2 second spikes, suspect Stop-The-World GC. Enable GC logs (-Xlog:gc*), graph pause times. Fixes:
- Switch to G1GC or ZGC if you're on Parallel.
- Bump heap if Old Gen is constantly near full.
- Look for memory leaks (section 22) β they cause Full GCs that take seconds.
Step 6 β Lock contention
Symptoms: CPU isn't pegged but throughput plateaus. Cause: synchronized on a hot path. Diagnose with async-profiler in lock mode β produces a flame graph showing where threads spent time blocked. Fix: replace with ConcurrentHashMap, atomic, or finer-grained locks.
Step 7 β CPU profiling (last resort)
If nothing above is the cause, the code itself is slow. Run async-profiler on a live pod for 30s, get a CPU flame graph. The widest tower at the top = the hottest method. Common findings: regex compiled per request, unbounded loop, JSON serialization overhead, log statements in tight loops.
The diagnostic toolkit β every backend engineer should have these handy
| Tool | Use it for |
|---|---|
| Grafana / Prometheus dashboards | Service-level metrics, RPS, latency percentiles |
| Distributed tracing (Jaeger, Datadog APM) | Per-request span breakdown |
| Slow query log + EXPLAIN ANALYZE | DB query optimization |
jstack / thread dump | Thread pool exhaustion, deadlocks |
jcmd ... GC.heap_info + GC log | Heap usage, GC pauses |
async-profiler | CPU and lock-contention flame graphs |
| Connection pool metrics (HikariCP MBeans) | Pool saturation |
Load testing to reproduce
If you can't reproduce in prod, run k6 or JMeter against staging with realistic traffic. Ramp up RPS and watch where latency starts hockey-sticking. That's your saturation point.
REST vs Messaging (Kafka) β When to Use Which
The fundamental difference β synchronous vs asynchronous
REST is a synchronous, point-to-point request/response between two services. Caller waits, gets a response, knows the result. Tightly coupled β caller must know the URL of the callee.
Kafka is asynchronous, broker-mediated, pub/sub. Producer writes an event to a topic and moves on. Any number of consumers (now or in the future) can read it. Loosely coupled β producer doesn't know who consumes.
Side-by-side
| Aspect | REST | Kafka |
|---|---|---|
| Communication | Synchronous request-response | Asynchronous event publish/subscribe |
| Coupling | Tight β caller knows callee's URL | Loose β producer doesn't know consumers |
| Backpressure | Caller blocks; can cascade failures | Events buffer in topic; consumers process at their pace |
| Durability | None β failed call is lost (unless caller retries) | Events persisted to disk; consumed even if consumer was down |
| Replay | Not possible | Yes β rewind offset, reprocess history |
| Latency | Lowest (usually milliseconds) | Higher (tens of ms β broker adds a hop) |
| Fan-out to N consumers | N HTTP calls | Free β one publish, N subscribers |
| Ordering | Per request (irrelevant) | Per partition (within a topic) |
When REST is right
- You need an immediate answer. "Is this user allowed to log in?" "What's the current price of SKU-42?" The caller can't proceed without the response.
- The operation is a query. Reading data from another service.
- Sync transactional flow. "Reserve seat β confirm payment β email confirmation" β and the user is waiting on the page.
- Simple integrations. One client, one server, low traffic β Kafka would be overkill.
When Kafka is right
- Fan-out. "Order placed" β email service, analytics, fraud detection, inventory, recommendations all need to know. With REST, the order-service makes 5 HTTP calls and is coupled to all 5. With Kafka, it publishes once and doesn't care who listens.
- The producer doesn't need a response. "Track this user click" β fire and forget.
- Decoupled scaling. The producer wants to ingest 100k events/sec; the consumer can only process 10k/sec; let the topic buffer the rest.
- Durability requirement. If the email service is down for 2 hours, you don't want to lose those order events β Kafka holds them safely until consumers catch up.
- Replay / reprocessing. A bug in the analytics consumer? Rewind the offset and re-process the last 24 hours.
- Audit / event-sourcing. The full history of every state change is the source of truth β Kafka topics with retention = forever.
Vivek's order β email decision
Email is fan-out (other services also care about "order placed"), the user isn't waiting for the email to arrive, and email-service downtime shouldn't fail order placement. Kafka wins.
But the order placement itself? "User clicks Place Order β API returns success" β the API needs an immediate response, the order must be confirmed before the page shows "Thank you". REST/synchronous DB write wins for the placement.
The hybrid pattern β outbox
The classic problem: how do you atomically (a) save the order to the DB and (b) publish to Kafka? If the publish fails after the DB commit, you've lost the event. If the DB rolls back after publishing, you've published a phantom event.
The Transactional Outbox pattern: in the same DB transaction, write the order AND write an "event row" to an outbox table. A separate background worker (or Debezium) reads the outbox and publishes to Kafka, then deletes/marks the row. Atomic by virtue of the DB transaction.
What Kafka costs you
- Eventual consistency. Producer commits at T=0, consumer processes at T=200ms. The world is briefly inconsistent. Embrace this or pick REST.
- Operational complexity. Kafka cluster, ZooKeeper / KRaft, monitoring, partitions, consumer-group lag β vs a single HTTP call.
- Schema evolution headaches. If a producer changes the event format, every consumer breaks. Use Avro/Protobuf with a Schema Registry to enforce backward compatibility.
- Idempotency on consumers. Kafka delivers at-least-once by default. Same event might arrive twice. Consumers must be idempotent (e.g., check "already processed?" before applying).
How to Dockerize a Spring Boot Application β The Right Way
The naive Dockerfile (works, but bad)
FROM openjdk:17 COPY target/app.jar /app.jar ENTRYPOINT ["java", "-jar", "/app.jar"]
Problems: (1) the base image is the full OpenJDK distribution (~600MB). (2) Every code change invalidates the cache and re-uploads the entire fat JAR. (3) The container runs as root β security risk.
Production Dockerfile β multi-stage, layered, non-root
# --- Stage 1: build with full JDK --- FROM eclipse-temurin:17-jdk-alpine AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B # cached unless pom.xml changes COPY src src RUN mvn package -DskipTests -B # Extract Spring Boot's layered jar (layers change at different rates) RUN java -Djarmode=layertools -jar target/app.jar extract --destination extracted # --- Stage 2: runtime image β JRE only --- FROM eclipse-temurin:17-jre-alpine WORKDIR /app # Run as non-root user RUN addgroup -S spring && adduser -S spring -G spring USER spring:spring # Copy each layer separately β Docker caches layers individually COPY --from=builder /build/extracted/dependencies/ ./ COPY --from=builder /build/extracted/spring-boot-loader/ ./ COPY --from=builder /build/extracted/snapshot-dependencies/ ./ COPY --from=builder /build/extracted/application/ ./ # your code β changes most EXPOSE 8080 ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
Why multi-stage matters
The build stage has Maven, the JDK, source code, test fixtures β gigabytes of stuff you don't need at runtime. The final runtime image only has the JRE + your compiled JAR layers. Final image: 200MB instead of 1GB+.
Why layered JARs matter (Spring Boot 2.3+)
A fat JAR rebuilt for a one-line code change re-uploads 60MB. With layered JARs, only the application layer (your BOOT-INF/classes) changes β typically 1MB. Pull/push of new images becomes nearly instant.
Three runtime essentials
1. Run as non-root
If your container is exploited, the attacker shouldn't have root inside it. Add USER spring:spring.
2. Tune the JVM for containers
Java 11+ honors container CPU/memory limits automatically (UseContainerSupport on by default). But explicitly set max heap as a percentage of container memory:
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.JarLauncher"]
Why 75%? Reserves 25% for direct buffers, metaspace, thread stacks β the heap is not the only memory the JVM uses.
3. Health checks
Add Spring Boot Actuator. Then in Docker / Kubernetes:
livenessProbe: { httpGet: { path: /actuator/health/liveness, port: 8080 } }
readinessProbe: { httpGet: { path: /actuator/health/readiness, port: 8080 } }
The .dockerignore β don't ship junk
target/ .idea/ .git/ *.iml .vscode/ node_modules/ .env
Even simpler β Buildpacks
Spring Boot ships built-in support: ./mvnw spring-boot:build-image uses Cloud Native Buildpacks to produce an optimized image β no Dockerfile to maintain, layered automatically, secure base image, sensible JVM defaults. Great for teams that don't want to babysit Dockerfiles.
Configuration via environment variables
Spring Boot maps env vars to properties: SPRING_DATASOURCE_URL β spring.datasource.url. Bake nothing environment-specific into the image. Same image runs in dev/staging/prod, configured by env.
Image security checklist
- Use a specific base-image tag (
eclipse-temurin:17.0.10_7-jre-alpine) β neverlatest. - Scan with Trivy / Snyk in CI; fail builds on Critical CVEs.
- Run as non-root.
- Minimize layers β fewer surface area, smaller images.
- Don't bake secrets into the image. Inject at runtime via env or a secrets manager.
application.properties with prod DB credentials inside the image is a leak waiting to happen. Anyone who pulls the image gets the secrets. Always read secrets from env vars / Vault / AWS Secrets Manager at startup.When Two Microservices Fail to Talk β Fault Tolerance Patterns
The patterns, in the order you should reach for them
1. Timeouts β the absolute baseline
Every outbound call MUST have a timeout. The default in many HTTP clients is "infinite" β that's how you get the cascade Aditya saw.
WebClient.builder() .clientConnector(new ReactorClientHttpConnector( HttpClient.create().responseTimeout(Duration.ofSeconds(2)))) .build();
Pick timeouts based on the dependency's p99 + headroom. Payment service p99 of 500ms? Set 1s timeout, not 30s.
2. Retries β for transient failures only
Network blips, brief 503s, momentary timeouts β retry once or twice with exponential backoff. Never retry non-idempotent calls without an idempotency key β you'll double-charge customers.
@Retry(name = "paymentService", fallbackMethod = "fallback") public PaymentResult charge(Order o) { ... } # application.yml resilience4j.retry.instances.paymentService: maxAttempts: 3 waitDuration: 200ms exponentialBackoffMultiplier: 2 retryExceptions: [java.io.IOException, org.springframework.web.client.HttpServerErrorException]
3. Circuit Breaker β stop calling a sick service
If payment-service is failing 50% of calls in the last 30 seconds, calling it more just makes things worse. Circuit breakers track recent failure rate; once it crosses a threshold, the breaker "opens" and calls fail fast (without even trying) for a cool-down period. After cool-down, it allows a few "half-open" trial calls; if they succeed, it closes back to normal.
@CircuitBreaker(name = "paymentService", fallbackMethod = "fallback") public PaymentResult charge(Order o) { ... } public PaymentResult fallback(Order o, Throwable t) { return PaymentResult.queuedForRetry(o); }
Three states: CLOSED (normal) β OPEN (fail fast) β HALF_OPEN (trial) β CLOSED.
4. Bulkhead β isolate resource pools
Picture a ship: if one compartment floods, watertight bulkheads stop it from sinking the whole vessel. Same principle: dedicate a thread pool / connection pool per downstream so one slow dependency can't starve the others. Resilience4j's @Bulkhead caps concurrent calls per backend.
5. Fallback β graceful degradation
When the call fails, return something useful instead of an error:
- Cached previous response.
- Default / safe value ("you have 0 unread notifications" instead of a spinner forever).
- Queue the request for later processing ("we'll email you when it's done").
6. Idempotency β make retries safe
If a call might be retried (by you, by the load balancer, by a queue), the receiver must handle duplicate requests safely. Pattern: client sends an Idempotency-Key header; receiver stores key + result; on duplicate, returns the cached result. Used by Stripe, PayPal, every serious payment API.
7. Async messaging β the strongest decoupling
If the call doesn't need an immediate response, switch from REST to Kafka. The producer publishes; the consumer processes when it can. The producer never blocks on consumer health. See section 36 for when this is the right move.
The cascade-prevention checklist
| Pattern | Prevents |
|---|---|
| Timeout | Threads pinned forever on unresponsive callee |
| Retry with backoff | Transient blips becoming user-visible failures |
| Circuit breaker | Hammering an already-broken dependency |
| Bulkhead | One slow callee starving threads needed for healthy ones |
| Fallback | Hard failure when graceful degradation suffices |
| Idempotency keys | Retries causing double-charges / duplicate side effects |
| Async / event-driven | Synchronous coupling where it isn't needed |
Service-mesh alternatives (Istio, Linkerd)
Modern infra moves these patterns out of code into a sidecar proxy. The mesh adds timeouts, retries, circuit breakers, mTLS at the network layer β your app stays focused on business logic. Trade-off: more infra to operate.
Don't forget the outbound side too
If you're a service being called, advertise your contract: documented SLOs, idempotency support, rate limits. Make it easy for callers to be resilient against you.
Observability is non-negotiable
None of these patterns are useful without metrics. You need to see: circuit-breaker state per dependency, retry counts, fallback invocations, downstream latencies. Wire Resilience4j to Micrometer β Prometheus β Grafana.
The Tricky Gotchas β Questions That Separate Mid from Senior
These are the questions where interviewers smile when you nail them β they reveal whether you've actually shipped Java in production or just memorized a textbook.
1. finally runs even after return
int tricky() { try { return 1; } finally { System.out.println("finally"); // PRINTS "finally" // return 2; // BAD β would override return 1 } } // Output: "finally", returns 1.
2. Autoboxing in collections
list.remove(2) on a List<Integer> β does it remove the element at index 2 or the element with value 2?
Index 2! Because List has both remove(int index) and remove(Object o), and the primitive int matches the index version. To remove by value: list.remove(Integer.valueOf(2)).
3. Static method "overriding"
Static methods cannot be overridden β only hidden. A child class declaring static foo() doesn't override the parent's static foo(); it shadows it. Calls resolve at compile time based on the reference type, not the runtime object.
4. String.intern() moves a string into the pool
Useful for deduplication when reading millions of strings from a file. But: don't intern user input β the pool is a permanent area (well, GC'd in modern JVMs but expensive), and an attacker filling it with garbage = denial of service.
5. The diamond problem with default methods
Java 8 allowed interfaces to have default methods. What if a class implements two interfaces with the same default method? You MUST override and pick: InterfaceA.super.method();
6. Constructor of an inner class secretly captures the outer
A non-static inner class holds an implicit reference to its enclosing instance. If the inner is long-lived (e.g., stored in a static map or sent to an executor), the outer can't be GC'd β memory leak. Solution: use a static nested class when no enclosing reference is needed.
7. HashMap iteration order is not insertion order
Insertion order is not preserved in HashMap. If you need it, use LinkedHashMap. If you need sorted order, use TreeMap.
8. SimpleDateFormat is NOT thread-safe
The classic 2009-era bug. Sharing a SimpleDateFormat across threads silently corrupts dates. Use DateTimeFormatter (Java 8+) β it's immutable and thread-safe.
9. equals on arrays compares references, not contents
arr1.equals(arr2) is arr1 == arr2. Use Arrays.equals(arr1, arr2) for element-wise comparison, Arrays.deepEquals for nested arrays.
10. Integer i = null; int x = i; throws NullPointerException
Auto-unboxing a null wrapper throws NPE β a classic source of "where did this NPE come from?" debugging. Always null-check wrappers before auto-unboxing.