Skip to content
QSWEQB

Java fundamentals

The answers a Java interviewer works through before the hard questions start: language semantics, collections, concurrency, the JVM and Spring behaviour. Seventy-five items, seventeen of them worked through with code or a diagram.

75 questions

Go deeper on Java & Spring

Language and semantics

Is Java pass-by-value or pass-by-reference?

Always pass-by-value, with no exceptions. The confusion comes from object arguments, where the value being copied is the reference rather than the object, so the method receives its own reference pointing at the same object on the heap. That is why a method can mutate an object you passed in and the caller sees the change, but cannot make your variable point at a different object — reassigning the parameter only changes the method's copy of the reference. The classic demonstration is a swap(a, b) method, which cannot work in Java no matter how it is written, because the two references it swaps are local to the method and discarded when it returns. If you need a method to replace an object, it has to return the new one.

What is the difference between `==` and `equals`?

== compares the values held in the variables, which for objects means the references, so it asks whether two names point at the same object in memory. equals is an ordinary method the class defines, so it asks whatever that class decided equality means — usually a comparison of contents. For primitives there is no equals and == compares the values directly, which is why it behaves "as expected" there and surprises people on objects. The practical rule is that == is correct for primitives, for enum constants, and for deliberate identity checks, and wrong for everything else. Comparing two strings with == occasionally works because of the string pool, which makes it worse than if it never worked at all.

Why is `String` immutable?

Because immutability makes strings safe to share, and the runtime relies heavily on that sharing. The string pool can hand the same instance to many callers, the hash code can be computed once and cached, and a string used as a map key or in a security check cannot be altered underneath you afterwards by whoever else holds a reference. If strings were mutable, a validated file path could be changed between the check and the open, which is precisely the kind of bug immutability removes by construction. The cost is that every apparent modification allocates a new object, which is why building a string inside a loop with + is quadratic and wants a StringBuilder instead.

What does `final` mean on a variable, a method and a class?

On a variable it means the binding cannot be reassigned after it is set, which is emphatically not the same as the object being immutable — a final List can still have items added to it all day, because the reference is fixed and the object is not. On a method it means no subclass may override it, which lets the compiler and the JIT reason about the call site. On a class it means no subclass at all. The third is how String guarantees its own immutability: without final, someone could extend it with a mutable subclass and every assumption about sharing would collapse. final on a local variable is also what allows it to be captured by a lambda or an anonymous class.

What is autoboxing, and where does it bite?

Autoboxing is the compiler silently converting between a primitive and its wrapper type, so Integer x = 5 and int y = someInteger both compile. It makes generics usable with numbers, since a List<int> is not possible. It bites in three specific places. An unboxed null throws a NullPointerException from a line containing no visible method call, which is one of the more confusing stack traces to read. == on two wrappers compares references rather than values. And boxing inside a hot loop allocates an object per iteration, so a Map<Integer, Integer> counter in a tight loop generates far more garbage than the arithmetic suggests.

Why does `Integer` comparison with `==` sometimes work?

Because the JVM caches small Integer instances — conventionally those from -128 to 127 — so two boxed values in that range are frequently the same object and == happens to return true. Outside the range each boxing creates a new object, so the same comparison returns false. This makes it a genuinely nasty bug: code tested with small values passes, and fails in production the first time an identifier exceeds 127. It is a caching detail rather than a language rule, and the cache size for Integer can even be tuned with a JVM flag, which means relying on it is unsafe in both directions. Compare with equals, or unbox deliberately to int.

What is the difference between an interface and an abstract class?

An abstract class can hold instance state and constructors, and a class may extend only one of them; an interface holds no instance state and a class may implement as many as it likes. Since Java 8 interfaces can carry default method implementations, so the old "interfaces have no behaviour" distinction no longer holds and the real difference is state plus single inheritance. Choose an abstract class when subclasses genuinely share fields and construction logic, and an interface when you are describing a capability that unrelated types might have. Default methods exist mainly so an interface can gain a method without breaking every existing implementation, which is how Collection acquired stream().

What does `static` actually mean?

It means the member belongs to the class rather than to any instance, so it exists exactly once in the process regardless of how many objects you create, and it can be used without one. A static method therefore cannot reference instance fields, because there is no instance to take them from — which is why main cannot touch anything non-static directly. Static final constants are uncontroversial. Static mutable state is where the trouble lives: it is shared across every thread and every test in the same JVM, it makes a class impossible to construct twice with different configuration, and it is the usual cause of tests that pass individually and fail when run together.

What is type erasure?

Generic type arguments exist for the compiler and are removed from the bytecode, so List<String> and List<Integer> are the same class at run time. The compiler checks your usage and inserts casts where needed, which is why generics give you compile-time safety and nothing at all afterwards. The consequences are concrete: you cannot write new T[10], you cannot test instanceof List<String>, and you cannot overload two methods that differ only by type argument because they erase to the same signature. Erasure was chosen for backward compatibility, so that generic and pre-generic code could interoperate on the same JVM, and the cost is a type system that is more honest at compile time than at run time.

Show me how a mutable static field breaks a test suite.

The failure is that state written by one test is still there for the next, so the suite passes in the order it was written and fails when the runner parallelises or reorders it.

public final class RateLimiter {
    // One instance for the whole JVM, so one instance for the whole test run.
    private static final Map<String, Integer> COUNTS = new HashMap<>();

    public static boolean allow(String user, int max) {
        int used = COUNTS.merge(user, 1, Integer::sum);
        return used <= max;
    }
}

class RateLimiterTest {
    @Test void allowsUpToTheLimit() {
        assertTrue(RateLimiter.allow("alice", 2));
        assertTrue(RateLimiter.allow("alice", 2));   // passes
    }

    @Test void rejectsPastTheLimit() {
        // Intends to start from zero. Actually starts from whatever the other
        // test left behind, so this passes for the wrong reason - or fails,
        // depending on which test ran first.
        assertTrue(RateLimiter.allow("alice", 2));
        assertTrue(RateLimiter.allow("alice", 2));
        assertFalse(RateLimiter.allow("alice", 2));
    }
}

The usual response is a @BeforeEach that clears the map, which works and treats the symptom: the class is still impossible to construct twice, still shared across threads, and still unusable if two parts of the application want different limits. The structural fix is to make it an ordinary object with the map as an instance field, constructed by whoever needs it and injected where it is used. Then each test builds its own, no reset hook is required, and the class can be configured per caller — which is the same argument as the one against getInstance(), arriving through a test failure rather than through design.

When do you use `extends` and when `super` in a generic bound?

Use ? extends T when you only read from the structure, because every element is guaranteed to be at least a T even if the actual list is of a subtype. Use ? super T when you only write into it, because whatever the actual type is, it can certainly accept a T. The mnemonic is producer-extends, consumer-super. The reason you cannot have both is that a read-write wildcard would let you insert the wrong subtype: a List<? extends Number> might really be a List<Integer>, so adding a Double has to be forbidden. Collections.copy, which reads from one list and writes to another, is the canonical example that needs one of each.

Show me what type erasure actually removes.

The compiler checks your generic usage and then discards the type arguments, so the bytecode has no idea List<String> was ever different from List<Integer>.

List<String> names = new ArrayList<>();
List<Integer> ids  = new ArrayList<>();

names.getClass() == ids.getClass();      // true - both are just ArrayList

// So none of these compile, and each failure follows from erasure:
// if (names instanceof List<String>) {}   no type argument survives to test
// T[] array = new T[10];                  no runtime type to allocate
// void f(List<String> a) {}
// void f(List<Integer> a) {}              same erased signature

You can also demonstrate the hole it leaves, which is the part that makes the concept concrete:

List<String> strings = new ArrayList<>();
List raw = strings;                       // unchecked, compiles with a warning
raw.add(42);                              // no check happens here at all

// The failure surfaces somewhere else entirely, at the implicit cast:
String first = strings.get(0);            // ClassCastException

The exception is thrown at the get, in code that is completely correct, by a cast the compiler inserted. That is why an unchecked warning is worth treating as an error rather than suppressing it — the damage is done at the insertion point and reported at the retrieval point, which can be a different class entirely.

Erasure was chosen for backward compatibility, so that generic and pre-generic code could run on the same JVM and against the same libraries. The price is a type system that is stronger at compile time than at run time, and the standard workaround where a type genuinely must survive is to pass a Class<T> token explicitly.

Objects, equality and immutability

What is the contract between `equals` and `hashCode`?

If two objects are equals, they must return the same hash code. The reverse is not required — unequal objects may legitimately collide — because a hash code is a bucket hint rather than an identity. Breaking the contract means a hash-based collection stores an object in the bucket chosen by one hash and then looks for it in the bucket chosen by another, so a key you definitely inserted cannot be found and the map appears to contain a duplicate. The rule in practice is to override both or neither, derive both from the same fields, and use only fields that do not change while the object is in a collection.

What happens if you mutate an object after using it as a `HashMap` key?

The entry stays in the bucket chosen by the hash code at insertion time, while every subsequent lookup computes the new hash code and searches a different bucket. The entry becomes unreachable through the map while still occupying memory and still being returned by iteration, so map.get(key) returns null for a key that map.containsValue can plainly see. Nothing throws and nothing warns. This is the concrete reason map keys should be immutable, and why records, strings and enums make good keys while a mutable entity with a generated equals over all its fields makes a very bad one.

What does `Comparable` give you that `Comparator` does not?

Comparable is implemented by the class itself and defines its single natural ordering — the one Collections.sort, TreeMap and TreeSet use when you give them nothing else. Comparator is a separate object, so you can define as many orderings as you like, including for a type you do not own and cannot modify. Use Comparable when there is an obvious canonical order, such as a date or a version, and a Comparator for everything situational. One contract detail matters: a natural ordering that disagrees with equals will make TreeSet behave differently from HashSet for the same elements, because the tree decides membership by comparison rather than by equality.

What makes a class properly immutable?

Four things together. Make the class final so nobody can subclass it and reintroduce mutability; make every field private final; set all of them in the constructor and provide no setters; and — the step most often missed — defensively copy any mutable field on the way in and on the way out. Without that last one the class is not immutable at all: if you store the caller's List directly, they still hold a reference and can change your internals afterwards, and if you return it, so can everyone else. LocalDate and String are immutable in this full sense, which is what makes them safe to share between threads with no synchronisation at all.

What is a record, and when is it the wrong choice?

A record is a concise final class holding immutable data, with the constructor, accessors, equals, hashCode and toString all generated from the header. It is the right answer for values: a coordinate, a money amount, a parsed request body, a key in a map. It is the wrong answer for anything with identity or a lifecycle. A JPA entity is the standard mistake — an entity is mutable by design, its equality is defined by its identifier rather than by all its fields, and JPA needs a no-argument constructor and non-final fields to work at all. The test is whether two instances with identical contents are genuinely the same thing.

Show me an `equals` and `hashCode` pair that is actually correct.

Most hand-written pairs are wrong in one of three ways: they use a field that mutates, they check getClass() when the class is extensible, or they compute a hash from fields equals does not use.

public final class Isbn {
    private final String value;   // immutable, so the hash never changes

    public Isbn(String value) { this.value = Objects.requireNonNull(value); }

    @Override public boolean equals(Object other) {
        if (this == other) return true;                 // cheap identity path
        if (!(other instanceof Isbn isbn)) return false; // covers null too
        return value.equals(isbn.value);
    }

    // Derived from exactly the fields equals compares, and no others.
    @Override public int hashCode() { return value.hashCode(); }
}

instanceof rather than getClass() because the class is final, so the two are equivalent here and instanceof also handles the null case in one step. If the class were extensible the choice becomes a real one: getClass() makes a subclass never equal to its parent, which breaks substitutability, while instanceof can make equality asymmetric if a subclass adds fields — which is why value types are usually made final and the question avoided.

The reason this matters beyond tidiness is what a hash-based collection does when the contract is broken. HashMap locates a bucket from the hash and only then compares with equals. If two equal objects hash differently they land in different buckets, so a key you inserted cannot be found and the map appears to hold a duplicate — with no exception anywhere.

flowchart TD
    A[map.get key] --> B[compute hashCode]
    B --> C[pick bucket from the low bits]
    C --> D[walk the chain comparing with equals]
    D --> E{match found}
    E -- Yes --> F[return the value]
    E -- No --> G[return null, though the entry exists elsewhere]

The branch to notice is the last one: a broken contract does not fail loudly, it returns null from the wrong bucket. A record generates both methods from the same components, which is why it is the right default for a value type.

Why is `clone` generally avoided?

Because Object.clone produces a shallow copy, so every nested mutable object is shared between the original and the copy — which is almost never what "clone" was meant to convey. The mechanism is also awkward: it relies on implementing a marker interface with no methods, calling a protected method, and casting the result, and forgetting any step fails at run time rather than at compile time. A copy constructor or a static factory says the same thing explicitly, lets you decide how deep the copy goes, works with final fields, and can be seen in the class's public API. For value types, a record with a with-style factory is simpler still.

Collections

How does a `HashMap` find a value?

It computes the key's hashCode, applies a spreading function that mixes the high bits into the low ones, and uses the low bits of the result to index into an array of buckets. It then walks that bucket comparing keys with equals. The spreading step exists because the index is taken with a bitmask rather than a modulo, so without it two keys differing only in their high bits would always collide. Buckets that grow past a threshold — and only once the table itself is reasonably large — convert from a linked list into a balanced tree, so a pathological set of colliding keys degrades to logarithmic lookup rather than linear. That treeification was added specifically to blunt hash-collision denial-of-service attacks.

What happens when a `HashMap` resizes?

Once the entry count exceeds capacity multiplied by the load factor, the table doubles in size and every existing entry is redistributed, which is an O(n) operation touching everything inserted so far. Because capacity is always a power of two and the index is a bitmask, an entry either stays at its current index or moves to that index plus the old capacity, so a bucket can be split in two without recomputing any hash codes. The practical consequence is that a map you know will hold ten thousand entries should be constructed with an explicit initial capacity, because left at the default it will resize roughly ten times on the way there.

Walk me through a `HashMap` resize.

The map keeps a threshold equal to capacity times the load factor, 0.75 by default. Crossing it doubles the table and redistributes every entry, which is O(n) over everything inserted so far.

capacity 4, threshold 3          after resize: capacity 8, threshold 6
index  entries                   index  entries
  0    []                          0    []
  1    [ K1 ] -> [ K5 ]            1    [ K1 ]
  2    [ K2 ]                      2    [ K2 ]
  3    []                          3    []
                                   4    []
                                   5    [ K5 ]      <- 1 + oldCapacity
                                   6    []
                                   7    []

K5 moved from index 1 to index 5, which is its old index plus the old capacity. That is not a coincidence: capacity is always a power of two and the index is taken with a bitmask, so doubling the table exposes exactly one more bit of the hash. An entry either has that bit clear and stays put, or has it set and moves by exactly the old capacity. So a bucket splits cleanly into two without a single hash being recomputed.

// Resizes about ten times on the way to 10,000, touching every entry each time.
Map<String, User> lazy = new HashMap<>();

// Sized once. Dividing by the load factor stops the threshold being hit
// at exactly the expected element count.
Map<String, User> sized = new HashMap<>((int) (10_000 / 0.75f) + 1);

The practical consequence is the second line: if you know roughly how many entries a map will hold, say so at construction. The cost of not doing it is not one slow operation but a series of them, each touching everything inserted before it, and it lands unpredictably in the middle of whatever request happens to cross the threshold.

When would you use a `LinkedHashMap`?

When iteration order matters and you still want constant-time lookup. It maintains a doubly linked list through its entries, so iterating gives insertion order rather than the effectively random order of a HashMap — which matters whenever the output is read by a human or compared in a test. Constructed in access-order mode it moves an entry to the end whenever it is read, which makes it the standard way to build a simple LRU cache: override its removeEldestEntry hook to evict once the map passes a size. The cost over a plain HashMap is two extra references per entry and slightly slower writes.

`ArrayList` or `LinkedList`?

Almost always ArrayList. It stores elements in a contiguous array, so indexing is constant time and iteration is fast because the processor's cache prefetches exactly that access pattern. LinkedList gives constant-time insertion and removal only if you already hold the node, which in practice means iterating to it first — so removing from the middle by index is linear in both, and the array usually wins anyway because the traversal is cache-friendly. The one real LinkedList use is as a Deque, adding and removing at both ends, and ArrayDeque is generally faster even for that. Adding at the end of an ArrayList is amortised constant despite the occasional resize.

Why is `ArrayList` faster than `LinkedList` even for traversal?

Both are linear to traverse, so the difference is not the complexity class — it is memory layout, and the processor's cache is the reason.

ArrayList: one contiguous block. Fetching element 0 pulls 1..15 into cache too.

  [ e0 | e1 | e2 | e3 | e4 | e5 | e6 | e7 | ... ]
  └──────────── one cache line ────────────┘

LinkedList: a node per element, allocated whenever, wherever.

  [prev|e0|next] ──▶ [prev|e1|next] ──▶ [prev|e2|next]
       0x4a20             0x9f08             0x2c74
  each hop is a pointer chase to an unrelated address

Traversing the array walks memory in the order the prefetcher expects, so most accesses are already in cache. Traversing the list jumps to an address the processor could not have predicted, so each hop risks a cache miss costing far more than the comparison you came to do. The list also stores two extra references and an object header per element, so it occupies several times the memory for the same data.

// Where LinkedList genuinely wins: you already hold the position.
Iterator<Order> it = list.iterator();
while (it.hasNext()) {
    if (it.next().isCancelled()) it.remove();   // O(1) per removal
}

// ArrayList's remove shifts everything after it, so this is O(n) each time.
// But removeIf compacts in a single pass, which is usually faster anyway.
list.removeIf(Order::isCancelled);

The honest summary is that LinkedList wins in a narrower band than its reputation suggests: you need to be inserting or removing at a position you already hold, often, in a large list. For queue and deque use, ArrayDeque is faster than LinkedList for the same reason — it is backed by an array. The default should be ArrayList and the burden of proof sits with anything else.

What is the difference between `HashSet` and `TreeSet`?

HashSet is a HashMap with a dummy value behind every key, so membership tests and insertion are constant time on average and iteration order is unspecified and may change as the set grows. TreeSet keeps its elements in a balanced tree ordered by their natural ordering or a supplied comparator, so operations are logarithmic, iteration is in sorted order, and you get range queries such as headSet and subSet for free. Choose by whether you need ordering, because you pay for it whichever way you go. One trap: TreeSet decides membership by comparison, so an element whose compareTo returns zero is treated as a duplicate even if equals disagrees.

What is a fail-fast iterator?

An iterator that throws ConcurrentModificationException when it detects the collection was structurally modified after the iterator was created. It works by comparing a modification counter captured at creation with the collection's current one, which makes it a best-effort bug detector rather than a guarantee — it may miss a modification, and it can fire on a single thread when you remove inside an enhanced for loop. That single-threaded case is the one most people actually hit. The supported ways to remove while iterating are the iterator's own remove, the collection's removeIf, or building a new collection; a CopyOnWriteArrayList iterates a snapshot instead and never throws.

How does `ConcurrentHashMap` differ from a synchronised map?

Collections.synchronizedMap wraps every method in a single lock, so all access serialises through one monitor regardless of which keys are involved, and throughput collapses under contention. ConcurrentHashMap locks per bin, so threads touching different keys usually do not contend at all, and reads are generally lock-free. The more important difference is compound operations: with the wrapper, "put if absent" is a check followed by a put and another thread can interleave between them, and no amount of external synchronisation on the returned map is convenient to get right. ConcurrentHashMap provides putIfAbsent, computeIfAbsent and merge as single atomic operations, which is usually the actual reason to reach for it.

Show me the safe ways to remove while iterating.

Removing from a collection during an enhanced for loop throws ConcurrentModificationException, on a single thread, with no concurrency involved — the name is about the collection being modified concurrently with the iteration rather than by another thread.

List<Order> orders = new ArrayList<>(all);

// Throws. The loop's hidden iterator sees the modification counter change.
for (Order o : orders) {
    if (o.isCancelled()) orders.remove(o);
}

// Works: the iterator performs the removal, so it updates its own expectation.
Iterator<Order> it = orders.iterator();
while (it.hasNext()) {
    if (it.next().isCancelled()) it.remove();
}

// Clearer, and the right answer in modern code.
orders.removeIf(Order::isCancelled);

There is a case that makes this genuinely dangerous rather than merely annoying: removing the second-to-last element happens to leave the iterator's cursor equal to the size, so hasNext returns false, the loop exits early and no exception is thrown at all. The list is silently missing a removal. That is why "it worked when I tested it" is not evidence here — fail-fast detection is best-effort, not a guarantee.

Two related shapes are worth knowing. Collections.unmodifiableList returns a read-only view, so the underlying list can still change beneath you and an iterator over the view will still throw. And CopyOnWriteArrayList iterates a snapshot taken when the iterator was created, so it never throws and never sees changes made after iteration began — which is the right trade for a listener registry read constantly and written rarely, and the wrong one for a large list written often, since every write copies the array.

Why can `List.of` not be modified?

Because the factory methods added in Java 9 return genuinely immutable collections that throw UnsupportedOperationException from every mutating method. That differs from Arrays.asList, which returns a fixed-size view backed by the original array — there, set works and writes through to the array, while add and remove throw. It also differs from Collections.unmodifiableList, which is a read-only view of a list somebody else can still change underneath you. The immutable factories additionally reject null elements and reject duplicate keys in Map.of, both of which surprise code migrating from the older idioms and both of which are deliberate.

Exceptions and control flow

Checked or unchecked — how do you choose?

The intended distinction is that a checked exception represents a condition a caller can reasonably recover from, such as a missing file, while an unchecked one represents a programming error, such as an invalid argument. In practice most modern Java favours unchecked exceptions, because a checked exception propagates into every method signature in the call chain, forcing intermediate code to declare or wrap something it cannot do anything about. That pressure is what produces the empty catch block, which is worse than either option because it discards the failure silently. Spring, and most libraries written since, translate checked exceptions into unchecked ones for exactly this reason.

What does `finally` guarantee?

That the block runs whether the try completed normally, threw, or returned — which is why it is the correct place for cleanup that must happen on every path. The exceptions are process death, System.exit, and a few pathological cases such as an infinite loop in the try. The behaviour worth knowing is what happens on conflict: a return or a throw inside finally discards whatever was in flight, so an exception propagating out of the try disappears entirely and takes its stack trace with it. That is one of the more expensive ways to lose a production diagnosis, and it is why returning from a finally block should be treated as a bug rather than a style choice.

What is try-with-resources for?

It closes anything implementing AutoCloseable when the block exits, in reverse order of acquisition, without a finally block. Beyond the brevity it handles a case that hand-written cleanup almost always gets wrong: if the body throws and the close also throws, the close exception is attached to the original as a suppressed exception rather than replacing it. Written by hand, the second exception overwrites the first, so you lose the failure that actually mattered and debug the cleanup instead. It also guarantees closure even when an earlier resource in the same statement fails to open, which is fiddly to reproduce manually with two or three resources.

Show me how an exception gets lost.

Three mechanisms, and all three are silent.

// 1. Returning from finally discards whatever was propagating.
int parse(String s) {
    try {
        return Integer.parseInt(s);      // throws on bad input
    } finally {
        return -1;                       // the exception vanishes here
    }
}

// 2. Catching and rethrowing without the cause.
try {
    repository.save(order);
} catch (SQLException e) {
    throw new ServiceException("could not save order");   // cause dropped
}

// 3. Cleanup that throws, replacing the real failure.
Connection conn = open();
try {
    conn.execute(sql);                   // throws: the real problem
} finally {
    conn.close();                        // also throws: this is what you see
}

The first is the worst because there is no trace at all. The second is the most common: the stack trace you get points at your service layer and says nothing about the constraint violation underneath, so debugging starts from zero. Pass the cause — new ServiceException("could not save order", e) — and the chain survives.

The third is what try-with-resources exists to fix. When both the body and the close throw, the language attaches the close exception to the original as a suppressed exception rather than replacing it, so you keep the failure that mattered and can still see the cleanup problem underneath it.

try (Connection conn = open()) {
    conn.execute(sql);
}
// Body exception propagates; close exception is retrievable
// from getSuppressed() rather than overwriting it.

The general rule that covers all three: an exception should only ever be replaced by one that references it. If your catch block does not pass the original along, you are choosing to discard the only evidence of what went wrong.

Why is catching `Exception` discouraged?

Because it catches far more than you intended, including runtime exceptions that signal bugs, so a null dereference and the network timeout you were handling become indistinguishable and both get the same recovery. It also silently absorbs exceptions added to a callee's signature later, so the code stops propagating something new without anyone noticing. And it tends to encourage logging plus continuing, which leaves the program running in a state its author never considered. Catch the narrowest type you can actually handle, let the rest travel, and reserve a broad catch for a top-level boundary — a request handler or a worker loop — where the job is to log with context and fail that one unit of work.

What is the difference between an `Error` and an `Exception`?

Both extend Throwable, but an Error signals a condition the application is not expected to recover from: out of memory, stack overflow, a class that failed to link. An Exception signals something a program might reasonably handle. The practical significance is that catching Throwable sweeps up both, which is almost always wrong — an OutOfMemoryError caught and logged usually produces a process that limps along corrupting things rather than dying cleanly, and the log line itself may fail to allocate. The one defensible place to catch an Error is a top-level handler whose only job is to record what happened and shut down.

Concurrency

What does `volatile` guarantee?

Visibility and ordering, but not atomicity. A write to a volatile field is guaranteed to be visible to any thread that subsequently reads it, and the compiler, JVM and processor may not reorder other reads and writes across it. Without it, a thread can cache a value in a register indefinitely, which is why a plain boolean running flag can leave a loop spinning forever after another thread sets it to false. What volatile does not do is make count++ atomic, because that is a read, an add and a write, and two threads can interleave between them. Use it for flags and for safe publication of an immutable object; use an atomic or a lock for anything read-modify-write.

Show me a visibility bug that is not a race.

The loop below can spin forever after stop() returns, on a perfectly correct JVM, with no data race in the usual sense — one thread writes and one reads, and the write is simply never observed.

class Worker implements Runnable {
    private boolean running = true;        // no volatile

    public void run() {
        while (running) {                  // hoisted out of the loop by the JIT
            // busy work with no synchronisation and no I/O
        }
    }

    public void stop() { running = false; } // may never be seen by run()
}

Nothing establishes a happens-before relationship between the write in stop and the read in run, so the compiler, the JVM and the processor are all entitled to keep the value in a register. The JIT is the usual culprit in practice: once it compiles the loop it can prove nothing inside changes running, so it hoists the read out entirely and the loop becomes while (true). That is why the bug frequently appears only after the code has been running for a few seconds — the interpreter re-reads the field, and the compiled version does not.

sequenceDiagram
    participant T1 as Worker thread
    participant Mem as Main memory
    participant T2 as Caller
    T1->>Mem: read running, true
    T1->>T1: JIT hoists the read, loops on a register
    T2->>Mem: write running = false
    Note over T1: never re-reads, so never sees it

The fix is one keyword — private volatile boolean running — which forces the read to go to memory and forbids the reordering. synchronized on both methods would also work, and an AtomicBoolean would too. What does not work is adding a sleep in the loop, which merely makes the bug intermittent by giving the interpreter more chances to run, and is how this gets "fixed" and shipped.

What does `synchronized` do beyond mutual exclusion?

It also establishes a happens-before relationship, so everything a thread did before releasing a monitor is guaranteed visible to the next thread that acquires the same monitor. That second guarantee is the one people forget, and it is why removing a lock that looked redundant can produce a visibility bug rather than a race — the mutual exclusion was not the only thing it was buying. It also means the memory effects are tied to the same lock object: synchronising two methods on different monitors gives you neither exclusion nor visibility between them. synchronized is reentrant, so a thread already holding the monitor can acquire it again, which is what allows one synchronised method to call another.

What is a race condition, precisely?

A dependence on the order in which two threads interleave, where at least one of them is writing. The canonical shape is check-then-act: the balance is sufficient so debit it, the file does not exist so create it, the key is absent so put it. The gap between the check and the act is where another thread gets in, and crucially no individual operation needs to be broken for the whole to be wrong — making each of containsKey and put thread-safe does not make the pair atomic. The fix is either to make the compound operation itself atomic, as computeIfAbsent does, or to hold a lock across both halves.

Show me a check-then-act race and three ways to fix it.

The gap between deciding and doing is where another thread gets in, and each individual operation being thread-safe does not help.

// Broken. Two threads can both find the key absent and both compute.
Map<String, Session> sessions = new ConcurrentHashMap<>();

Session get(String id) {
    Session s = sessions.get(id);        // check
    if (s == null) {
        s = new Session(id);             // another thread interleaves here
        sessions.put(id, s);             // act - one of the two is discarded
    }
    return s;
}

The consequence is not just a wasted allocation: two callers hold different Session objects for the same id, so anything written to one is invisible through the other. It is also load-dependent, so it passes every test and appears at peak.

// 1. One atomic operation. The map holds the lock across both halves.
Session get(String id) {
    return sessions.computeIfAbsent(id, Session::new);
}

// 2. A lock around the compound operation, if the state is not in one map.
synchronized Session get(String id) { /* check and act, uninterrupted */ }

// 3. Compare-and-set, when a wasted construction is acceptable.
Session get(String id) {
    Session created = new Session(id);
    Session existing = sessions.putIfAbsent(id, created);
    return existing != null ? existing : created;
}

The first is the right default. Note the caveat that makes it non-obvious: the mapping function runs while the bin is locked, so it must be short and must not touch the same map — a computeIfAbsent whose function inserts another key into the same map can deadlock, which is a documented restriction rather than a bug.

The third is worth knowing for the case where construction is cheap and you would rather not hold anything: it always allocates, and then throws one away. Choosing between them is a question about the cost of the wasted work, not about correctness — all three are correct.

What four conditions produce a deadlock?

Resources held exclusively, a holder able to request more while still holding, resources that cannot be forcibly taken away, and a circular chain of threads each waiting on the next. All four must hold simultaneously, so breaking any one prevents it. In practice you break the cycle, by acquiring locks in a globally consistent order — for example, always locking the account with the lower identifier first when transferring between two. The database version is exactly the same problem: two transactions updating the same two rows in opposite orders, fixed by the same ordering discipline expressed in SQL. Timeouts are the fallback when ordering is impossible because the lock set is not known upfront.

When would you use an `AtomicInteger` over a lock?

When the operation is a single read-modify-write against one value, such as a counter, a sequence, or a compare-and-set on a state flag. The atomics use a hardware compare-and-swap instruction rather than a monitor, so there is no blocking, no context switch, and no risk of deadlock — under contention they retry in a loop instead of parking the thread. The limit is that atomicity covers exactly one variable: as soon as two fields must change together, or a decision spans two values, you are back to a lock. LongAdder is worth knowing as the higher-throughput alternative when many threads increment and reads are rare.

What does an `ExecutorService` give you over creating threads?

It separates the work from the thread that runs it, which lets you bound how many threads exist, reuse them across tasks, queue what does not fit, and shut everything down in an orderly way. Creating a thread per task is expensive — each carries a stack measured in hundreds of kilobytes — and it is unbounded, so a traffic spike becomes thousands of threads and the machine spends its time context switching rather than working. An executor also gives you a Future per task, so failures surface as exceptions on get rather than vanishing into an uncaught handler. The shutdown sequence matters: shutdown then awaitTermination then shutdownNow is the conventional drain.

What happens when a thread pool's queue is full?

The pool applies its rejection policy, and the default aborts by throwing RejectedExecutionException. The alternatives run the task on the calling thread, which throttles the submitter by making it do the work; discard the task silently, which is almost never acceptable; or discard the oldest queued task. Choosing deliberately matters because the common alternative — an unbounded queue — does not avoid the problem, it moves it: the queue grows until the heap is exhausted, and an out-of-memory error is a worse failure than a rejection you could have handled. A bounded queue plus caller-runs is the usual shape when you want backpressure rather than loss.

What is a `CompletableFuture` for?

Composing asynchronous work without blocking a thread. Instead of calling get and occupying a thread while you wait, you describe what should happen when the result arrives, combine several results, and attach error handling to the chain. That matters because blocking a pooled thread on I/O is how a thread pool deadlocks itself — every worker waiting on a task that needs a worker to progress. The trap is that the default execution happens on the common fork-join pool, which is shared process-wide and sized to your core count, so one slow blocking operation on it interferes with unrelated parallel work. Supply your own executor for anything that blocks.

What are virtual threads, and what problem do they solve?

Virtual threads, standard since JDK 21, are scheduled by the JVM onto a small pool of platform threads and park rather than blocking one when they wait on I/O. That means ordinary blocking code — the readable kind — scales to hundreds of thousands of concurrent operations without being rewritten in an asynchronous style, which was previously the only way to get there. What they do not do is make unsynchronised shared mutable state safe; the memory model is unchanged. They also move the bottleneck rather than removing it: with threads no longer scarce, the connection pool and the downstream service become the limit, so a naive migration can flood a database that a thread pool used to protect.

Show me how a thread pool deadlocks itself.

A pool with a bounded number of workers deadlocks when a task submitted to it waits for another task on the same pool. Every worker ends up blocked on work that needs a worker.

ExecutorService pool = Executors.newFixedThreadPool(4);

Future<Report> build(List<String> ids) {
    return pool.submit(() -> {
        List<Future<Section>> parts = ids.stream()
            .map(id -> pool.submit(() -> loadSection(id)))   // same pool
            .toList();
        // This thread now blocks, holding one of the four, waiting for tasks
        // that are queued behind it and can never be scheduled.
        return new Report(parts.stream().map(Futures::getUnchecked).toList());
    });
}

Four concurrent calls to build occupy all four workers, each waiting on subtasks sitting in the queue behind them. Nothing throws, CPU usage is zero, and the symptom is a service that stops responding while looking perfectly healthy — no errors, no exceptions, memory flat.

stateDiagram-v2
    [*] --> Queued
    Queued --> Running: a worker is free
    Running --> Blocked: awaits a subtask
    Blocked --> Running: subtask completes
    Blocked --> Deadlocked: every worker is blocked, queue never drains

The transition into Deadlocked needs nobody to do anything wrong; it needs only enough concurrent callers to occupy every worker. The diagnosis is a thread dump showing all pool threads parked in Future.get.

Three fixes, in order of preference. Do not block inside a pooled task — compose with CompletableFuture and thenCombine so nothing waits. Use a separate pool for the nested work, so the two cannot starve each other. Or use virtual threads, where blocking parks the virtual thread rather than occupying a platform one, which removes the class of problem entirely.

What is a `ThreadLocal`, and what is the risk?

It gives each thread its own independent copy of a value, which is how request-scoped context such as a trace id or a security principal is carried without threading a parameter through every method signature. The risk appears in any pooled environment. A thread returns to the pool still holding the value, so the next request served by that thread sees the previous request's data — a security problem when the value is a user identity. The same retention prevents the object being collected, so a ThreadLocal holding anything large is a leak for the life of the pool. Always clear it in a finally, and prefer explicit parameters where the call depth is small.

Show me what virtual threads change, and what they do not.

They change the cost of blocking, so the readable style scales. They change nothing about the memory model.

// Platform threads: each one is an OS thread with a stack measured in
// hundreds of kilobytes. Ten thousand of these is not a plan.
ExecutorService pool = Executors.newFixedThreadPool(200);

// Virtual threads: one per task, scheduled by the JVM onto a small carrier
// pool. A blocked virtual thread parks and releases its carrier.
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    for (String id : ids) {
        exec.submit(() -> {
            var order = http.get("/orders/" + id);   // blocks - and that is fine
            return enrich(order);
        });
    }
}

The blocking call is the point. On a platform thread it occupies an OS thread for the whole wait; on a virtual thread the JVM unmounts it, runs something else on the carrier, and remounts it when the response arrives. So ordinary sequential code reaches concurrency levels that previously required a callback or reactive style.

What does not change:

// Still a race. Virtual or platform, this is a read, an add and a write.
private int count;
void increment() { count++; }

Two practical consequences. Thread pooling becomes an anti-pattern for virtual threads — they are cheap enough to create per task, and pooling them reintroduces the limit you were removing. And the bottleneck moves rather than disappearing: with threads no longer scarce, the connection pool and the downstream service become the constraint, so a naive migration can send ten thousand concurrent queries at a database that a two-hundred-thread pool used to protect. The old pool was doing two jobs, and only one of them was managing threads.

Worth knowing too that a virtual thread blocked inside a synchronized block could historically pin its carrier, which is why ReentrantLock was the recommended alternative in code designed for them.

The JVM

What lives on the heap and what lives on the stack?

The heap holds objects, is shared by every thread, and is what the garbage collector manages. Each thread has its own stack made of frames, one per method call, holding the parameters, local variables and the references those locals contain. So the object lives on the heap and the reference to it lives on the stack — which is why deep recursion produces a StackOverflowError while the heap is nearly empty, and why an infinite loop allocating objects produces an OutOfMemoryError with a shallow stack. Stack frames are reclaimed automatically when a method returns, which is why local variables cost nothing to clean up.

What is the difference between the heap and metaspace?

The heap holds your objects and is bounded by -Xmx. Metaspace holds class metadata — the structures describing loaded classes and methods — and lives in native memory outside the heap, growing as classes are loaded rather than being fixed at start-up. The practical consequence is that "my heap is full" and "my container was killed for exceeding its memory limit" are different problems with different fixes, and conflating them wastes a day. A metaspace leak typically comes from repeatedly loading classes, which is why it shows up in application servers with frequent redeploys and in code generating proxies without bound. Thread stacks, the JIT code cache and direct byte buffers are also outside the heap.

What does the garbage collector actually reclaim?

Objects that are unreachable from any garbage-collection root — a live thread's stack, a static field, an active JNI reference. Reachability, not usefulness, is the criterion, which is the whole reason a Java program can leak: the collector cannot free something you are still pointing at, however certain you are that you are finished with it. So a Java memory leak always resolves to a reference you forgot: a cache with no eviction policy, a listener registry nobody unregisters from, a static map keyed by something unbounded, a ThreadLocal on a pooled thread. Reference counting is not used, so cycles between two dead objects are collected without difficulty.

Why does a Java service get faster after a few minutes?

Because bytecode begins interpreted and is compiled to native code only once the JVM decides a method is hot, through tiered compilation using two compilers of increasing aggressiveness. The optimising compiler also makes speculative assumptions from observed behaviour — this branch is never taken, this call site always resolves to one implementation — and deoptimises when reality later disagrees. This is why a benchmark with no warm-up phase measures the interpreter rather than the code you shipped, why performance can change several minutes into a load test, and why a bug can appear only under sustained traffic: the machine code executing then is not the code that ran during your test.

How do you tell a leak from a heap the collector has simply let grow?

Read the troughs, not the peaks. A sawtooth with a flat floor is the collector working; a sawtooth whose floor rises is retention.

flowchart TD
    A[Heap graph rising] --> B{Do the post-collection lows also rise}
    B -- No --> C[Normal: the collector lets the heap grow before collecting]
    B -- Yes --> D[Something is retained]
    D --> E[Two heap dumps, minutes apart]
    E --> F[Compare what grew, by live objects]
    F --> G[Trace the retention path back to a root]

The step people skip is the second dump. A single snapshot tells you what is on the heap, which is usually unsurprising — lots of strings and byte arrays. The diagnosis is the difference between two, filtered to what is still live.

# Two snapshots, five minutes apart, on a running JVM.
jcmd <pid> GC.heap_dump /tmp/a.hprof
sleep 300
jcmd <pid> GC.heap_dump /tmp/b.hprof

# Cheaper first look: live histogram, no dump to open.
jcmd <pid> GC.class_histogram | head -20

Then look for a retention path: which chain of references keeps the growing objects reachable. That path names the bug, because a Java leak is always something you are still pointing at. The recurring culprits are a cache with no eviction, a static collection that only grows, a listener registry nobody unregisters from, and a ThreadLocal never cleared on a pooled thread.

One thing to expect and not be alarmed by: resident process size often stays high after the live set shrinks, because the runtime returns memory to the operating system lazily. A large process with a small live heap is not a leak, which is why the profile is more trustworthy than the process table.

What is a memory leak in a garbage-collected language?

An object that is no longer needed but is still reachable, so the collector is correct to keep it. The mechanisms are a short list and worth recognising by shape: a collection that only ever grows, a long-lived object holding a reference to a short-lived one, a ThreadLocal never cleared on a pooled thread, a listener or callback registered and never removed, and a sub-view such as a sliced buffer keeping a much larger backing array alive. The symptom is a heap whose troughs rise between collections rather than a sawtooth with a flat floor, and the diagnosis is two heap dumps taken minutes apart, compared for what grew.

What does a `NoClassDefFoundError` mean?

That a class was present when the code was compiled but is not available, or failed to initialise, at run time. It differs from ClassNotFoundException, which comes from an explicit reflective lookup such as Class.forName and is a checked exception. The usual causes are a dependency missing from the runtime classpath, or two versions of a library present where the one that loaded first lacks the member being called. A less obvious cause is a static initialiser that threw: the class is then marked erroneous, and every later attempt to use it reports NoClassDefFoundError rather than the original exception, so the real error is only in the first stack trace.

Where does a JVM's memory actually go?

The heap is the part everyone sizes and frequently a minority of the total, which is why a container gets killed while the heap looks fine.

container limit  1024 MB
├─ heap                  -Xmx512m          512 MB   sized, monitored, dumped
├─ metaspace             class metadata    ~120 MB  grows as classes load
├─ thread stacks         200 x ~1 MB       ~200 MB  one per platform thread
├─ code cache            JIT output         ~60 MB  grows with hot methods
├─ GC structures         card tables etc    ~40 MB  proportional to heap
└─ direct byte buffers   NIO, Netty        ~80 MB   off-heap, not in -Xmx
                                          --------
                                          ~1012 MB  -> next allocation is fatal

Nothing here is leaking. The heap is obeying -Xmx exactly, and the process is still killed, because everything below the first row is invisible to that flag. The thread stack row is the one that surprises people: a service that creates a platform thread per connection is buying roughly a megabyte each, outside the heap, and it does not appear in any heap dump.

# What the JVM thinks it is using, broken down. The one command worth knowing.
java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory summary

The guidance that follows is to leave -Xmx alone in a container. Modern JVMs read the cgroup limit and size the heap as a fraction of it, so setting the flag by hand usually means overriding a correct decision with a guess. If you do set it, budget for everything above it — a heap at seventy-five per cent of the container limit is optimistic for anything with many threads or heavy NIO.

What is the difference between `-Xmx` and a container memory limit?

-Xmx bounds the Java heap only, while the container limit bounds everything the process uses — heap plus metaspace, thread stacks, the JIT code cache, direct byte buffers and the runtime's own structures. Total process memory is therefore meaningfully larger than the heap, often by hundreds of megabytes, so setting -Xmx equal to the container limit invites the kernel to kill the process while the JVM believes it has headroom. Modern JVMs read the container's limit and size the heap as a fraction of it by default, which is why explicitly setting -Xmx in a container is often a way to make things worse rather than better.

Modern Java

What does `Optional` actually improve?

It makes absence explicit in a return type, so a caller has to make a decision rather than discovering a null at run time in a stack trace with no context. Used well it also composes: map, filter and orElseGet let you express a chain of maybe-present steps without nested null checks. It is intended for return values specifically. Using it for fields adds an allocation per instance and breaks serialisation, and using it for parameters means callers must wrap arguments, which is worse than an overload. And calling get without checking reproduces precisely the failure it was introduced to prevent, which is why Java 10 added orElseThrow() as the preferred alternative with an honest name. get was never renamed and is not deprecated, which is exactly why the lint rule matters.

When is a stream clearer than a loop, and when not?

A stream is clearer when the operation is genuinely a pipeline of transformations ending in a collection or a reduction, because the intent lives in the operators rather than in index arithmetic and accumulator variables. A loop is clearer when you need to exit early, when you need the index, when you are mutating something outside the loop, or when the pipeline needs more than about three stages to express. Debugging also favours the loop: a breakpoint in a lambda gives you far less context than one in a loop body, and a stack trace through stream internals is long and mostly irrelevant. Neither is a rule, and mixing side effects into a stream is where the real damage happens.

Why can a parallel stream be slower?

Because splitting the source, scheduling the pieces and merging the results all cost something, and for a small collection or a cheap per-element operation that overhead exceeds any saving. Sources that split badly make it worse — a LinkedList or a stream from an iterator cannot be divided evenly, unlike an array. The more damaging problem is that parallel streams use the shared common fork-join pool by default, so a blocking operation inside one starves every other parallel stream in the process, including ones in unrelated libraries. It suits large, CPU-bound, independent, side-effect-free work over an efficiently splittable source, and very little else.

Show me a sealed hierarchy with an exhaustive switch.

Sealing closes the set of subtypes, which lets the compiler prove a pattern match covers every case — turning a missed case from a run-time surprise into a compilation failure.

public sealed interface Payment permits Card, Transfer, Voucher {}

public record Card(String last4, Money amount)      implements Payment {}
public record Transfer(String iban, Money amount)   implements Payment {}
public record Voucher(String code, Money amount)    implements Payment {}

static String describe(Payment payment) {
    // No default branch. The compiler checks the three cases are exhaustive,
    // and adding a fourth permitted type breaks the build here.
    return switch (payment) {
        case Card c     -> "card ending " + c.last4();
        case Transfer t -> "transfer to " + t.iban();
        case Voucher v  -> "voucher " + v.code();
    };
}

The value is the absence of the default branch. Without sealing you must write one, and it can only throw — so a new subtype compiles cleanly, ships, and fails at run time on the first payment of that kind. With sealing, adding Voucher to the permitted list breaks every incomplete switch in the codebase at compile time, which is exactly where you want to find them.

Record patterns take it further, destructuring in the same expression:

return switch (payment) {
    case Card(String last4, Money amount) when amount.isOver(500)
        -> "large card payment ending " + last4;
    case Card(String last4, var ignored) -> "card ending " + last4;
    case Transfer(String iban, var ignored) -> "transfer to " + iban;
    case Voucher v -> "voucher " + v.code();
};

Two details worth carrying. permits can be omitted when the subtypes are in the same file, which is the compact form for a small hierarchy. And every permitted subtype must itself be final, sealed or explicitly non-sealed, so the compiler always knows where the hierarchy stops — a non-sealed subtype reopens that branch deliberately, and costs you exhaustiveness below it.

What do sealed types buy you?

They let a class or interface declare exactly which types may extend or implement it, so the hierarchy is closed and known at compile time. The compiler can then verify that a pattern-matching switch covers every permitted case, which turns "somebody added a subtype and forgot to handle it" from a run-time surprise into a compilation failure. That is the real payoff: it makes an algebraic data type expressible in Java, so a domain model of a few known shapes — a payment that is Card, Transfer or Voucher — can be matched exhaustively. Without sealing, a switch over subtypes always needs a default branch that exists only to throw.

What is the difference between `var` and dynamic typing?

None of the typing changes. var is local variable type inference: the compiler determines a single static type from the initialiser, and the variable has that type for the rest of its life, so assigning something else is still a compilation error. It only removes the repetition of writing the type twice on one line. The judgement is about readability rather than safety — used where the initialiser makes the type obvious, as in var orders = new ArrayList<Order>(), it reads better; used where the right-hand side is a method call with a non-obvious return type, it hides information the reader needs and makes the code worse.

Show me `Optional` used well and used badly.

Used well it removes a nested null check and makes the absent case a decision. Used badly it is a null check with an allocation.

// Badly: this is `if (x != null)` with extra steps and an object per call.
Optional<User> maybe = repository.findById(id);
if (maybe.isPresent()) {
    return maybe.get().getEmail();
}
return "unknown";

// Well: the chain expresses "if there is one, take its email, otherwise this".
return repository.findById(id)
    .map(User::getEmail)
    .orElse("unknown");

// Better still when the fallback is expensive - orElse always evaluates
// its argument, orElseGet only evaluates it when needed.
return repository.findById(id)
    .map(User::getEmail)
    .orElseGet(() -> lookupFromDirectory(id));   // not called when present

That orElse against orElseGet distinction is the one that bites in production: orElse(expensiveCall()) runs the expensive call on every invocation, present or not, because it is an argument rather than a lambda.

Where it does not belong:

public class Order {
    private Optional<Discount> discount;   // an allocation per instance,
}                                          // and it breaks serialisation

void apply(Optional<Discount> discount) {} // caller must wrap; use an overload

Optional was introduced for return types, and the guidance from the people who added it has been consistent on that. Fields and parameters gain nothing: a field can simply be null with the class controlling access, and a parameter is better expressed as two methods. Using it as a collection element is worse still, since an empty collection already expresses absence.

The last trap is get(). Calling it without checking reproduces exactly the NullPointerException the type was introduced to prevent, which is why it was effectively renamed to orElseThrow() — same behaviour, honest name.

Spring and persistence

How does dependency injection work in Spring?

The container constructs your objects, works out what each one requires from its constructor signature, and supplies those instances rather than the class creating them itself. That inverts the direction of control: the class declares what it needs and receives it, so the dependency is visible in the constructor and substitutable in a test without any framework. Constructor injection is preferred over field injection for three concrete reasons — the requirement is explicit rather than hidden behind an annotation, the field can be final, and the object cannot exist in a half-constructed state. Field injection also makes the class impossible to instantiate in a plain unit test without reflection.

Why did `@Transactional` do nothing on this method?

Almost always because the method was called from within the same object. The annotation works by wrapping the bean in a proxy: callers go through the proxy, which opens a transaction and then delegates. An internal call goes straight to the method on this and never passes through the proxy, so no transaction starts and nothing warns you. The same mechanism explains a @Cacheable, @Async or @Retryable method that quietly does not apply. The usual fixes are to move the annotated method into a separate bean, or to inject the bean into itself so the call goes through the proxy — the first being much the cleaner.

Show me the self-invocation problem with `@Transactional`.

Spring implements the annotation with a proxy. Calls that arrive through the proxy get a transaction; calls that go straight to this do not, and nothing warns you.

@Service
public class OrderService {

    @Transactional
    public void placeAll(List<Order> orders) {
        for (Order order : orders) {
            place(order);   // direct call on `this` - bypasses the proxy
        }
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void place(Order order) {
        repository.save(order);   // silently joins the outer transaction
    }
}

The intent was one transaction per order, so a single bad order rolls back alone. What happens is one transaction for the whole loop, so the first failure rolls back every order placed before it.

sequenceDiagram
    participant C as Caller
    participant P as Spring proxy
    participant S as OrderService instance
    C->>P: calls placeAll
    P->>P: begins a transaction
    P->>S: placeAll
    S->>S: self call to place, bypassing the proxy
    S-->>P: returns with no new transaction
    P->>P: commits or rolls back everything together

The arrow to look at is the self-call: it never leaves the object, so the proxy has no opportunity to act. The same mechanism explains a @Cacheable, @Async, @Retryable or @PreAuthorize method that quietly does nothing when called internally.

Three fixes, best first. Move place into a separate bean and inject it, so the call crosses a proxy boundary — this is usually also the better design, since the two methods have different transactional responsibilities. Inject the bean into itself and call through that reference, which works and reads oddly. Or use AopContext.currentProxy(), which works and couples your code to Spring's internals. There is also a quieter variant of the same trap: a private method is never transactional, whatever you annotate it with. Package-visible and protected methods do work, but only under class-based CGLIB proxies and only since Spring 6.0; under an interface-based proxy the method must be public. So the rule is proxy-dependent rather than absolute, and worth stating that way.

What is the difference between a singleton bean and a singleton?

A singleton bean is created once per application context and injected wherever it is needed, so the lifetime guarantee is identical to the classic pattern while the global access point is gone. That difference is what matters: dependencies stay visible in constructors, a test can supply a different instance without touching static state, and a second context in the same process gets its own — which is what makes parallel integration tests possible. The classic singleton's getInstance() provides none of that. The related hazard is the captive dependency: inject a request-scoped bean into a singleton and the singleton captures the first request's instance forever.

What is the N+1 select problem?

One query fetches a list of entities and then a further query fires per entity to load a lazy association, so fetching twenty orders with their customers costs twenty-one round trips rather than one. It is the most common performance defect in a JPA application, and it scales with data rather than with code, so it passes every test against a small fixture and collapses in production. The fixes are a join fetch, an entity graph, or a batch size setting that loads associations in groups. The way to notice it is to have SQL logging on while you develop, because the count of statements per request is the symptom and nothing else reveals it.

Show me an N+1 and the three ways out.

One query for the parents, then one per parent for a lazy association. Twenty orders become twenty-one round trips, and it scales with data rather than with code, so it passes every test against a small fixture.

// The query that looks innocent.
List<Order> orders = repository.findByStatus(PLACED);   // 1 select

for (Order order : orders) {
    // Each access initialises the proxy: one select per order.
    System.out.println(order.getCustomer().getName());  // N selects
}
-- What the log shows. This is the symptom, and nothing else reveals it.
select * from orders where status = 'PLACED';
select * from customers where id = 41;
select * from customers where id = 87;
select * from customers where id = 12;
-- ... seventeen more

The three fixes differ in where the join happens and what they cost.

// 1. Join fetch: one query, one round trip. Best for a single association;
//    fetching two collections this way multiplies rows.
@Query("select o from Order o join fetch o.customer where o.status = :status")
List<Order> findByStatusWithCustomer(@Param("status") Status status);

// 2. Entity graph: same effect, declared rather than written into JPQL,
//    so the query stays reusable with and without the fetch.
@EntityGraph(attributePaths = "customer")
List<Order> findByStatus(Status status);

// 3. Batch size: still several queries, but N/size rather than N. The right
//    answer for collections, where a join fetch would explode the result set.
@BatchSize(size = 50)
private List<OrderLine> lines;

Two caveats worth knowing. Join-fetching two separate collections in one query produces a cartesian product — a hundred rows for ten lines and ten payments — which is slower than the N+1 it replaced, and Hibernate will refuse it outright for two bags. And a Pageable combined with a join fetch on a collection cannot paginate in the database, so it fetches everything and paginates in memory, usually with a warning in the log that people learn to ignore.

The habit that prevents all of this is having SQL logging on while you develop. The count of statements per request is the only thing that makes an N+1 visible before production.

What does the persistence context do?

It tracks every entity it loaded, keeps a snapshot of the original field values, and at flush time compares current values against that snapshot to generate the necessary UPDATE statements. That is why changing a field on a loaded entity is written without you calling save, and why a stray normalisation such as trim() on a tracked entity ends up in the database. It also acts as an identity map, so the same row returns the same object within one context. The cost is a duplicate of every entity in memory plus a comparison pass over all of them, which is why a read-only query over fifty thousand rows should disable tracking.

What is the difference between lazy and eager loading?

Eager loading fetches an association at the same time as its parent; lazy defers it until the association is first touched. Lazy is the right default for collections, because eager loading everything turns one query into a join across half the schema. Its characteristic failure is that touching the association after the transaction has closed throws a lazy-initialisation exception, since the persistence context that would have loaded it no longer exists. Fixing that by marking everything eager trades a clear error for a silent performance problem, which is worse; the correct fix is to fetch what you need in the query, or to map to a DTO inside the transaction.

What does `@SpringBootApplication` actually enable?

Three annotations in one: component scanning from the current package downwards, auto-configuration, and marking the class itself as a source of bean definitions. Auto-configuration is the significant one — it inspects the classpath and the beans you have already defined, and conditionally supplies hundreds of defaults, which is why adding a dependency changes application behaviour without you writing any code. That is convenient and occasionally mysterious, so the thing worth knowing is that the decisions are recorded: the auto-configuration report, enabled with debug logging, lists every configuration that was applied, every one that was skipped, and the condition that decided it.