Java — Interview Questions

Read notes

Answered the way you would say it in the room — first person, with an example every time. Tap a question to reveal the answer. Levels: Basic, Intermediate, Advanced.

Part 1 — Java Basics

1. What Java Is: JDK, JRE & JVM

BasicWhat's the difference between the JDK, the JRE and the JVM?

So they're three nested layers. The JVM is the engine that actually runs bytecode. The JRE is the JVM plus the standard class library, which is everything you need to run a Java program. The JDK is the JRE plus developer tools like javac, javap, jshell and jlink, which is what you need to build one.

ContainsWho needs it
JVMClass loader, bytecode interpreter, JIT compiler, garbage collectorNobody installs it alone; it lives inside the JRE/JDK
JREJVM + core libraries (java.lang, java.util, …) + the java launcherAnyone running Java apps
JDKJRE + javac, jar, javap, jdb, jshell, jlink, jcmdDevelopers and build servers

One thing I'd add for modern Java: since Java 11, Oracle and most vendors stopped shipping a separate JRE download. You install a JDK, and if you want a small runtime for production you use jlink to build a custom image with only the modules your app needs. That's what we did for our Docker images, and it cut the base image size a lot.

So in practice I just say "install JDK 21" and let jlink or a slim base image handle the runtime side.

Interviewers look for: the nesting (JDK contains JRE contains JVM) and bonus points for knowing the standalone JRE went away after Java 8.

BasicIs Java compiled or interpreted?

So honestly, both. Java source is compiled ahead of time by javac into bytecode, and then at runtime the JVM first interprets that bytecode and JIT-compiles the hot parts into native machine code.

Let me show you with a quick example. Here's a tiny program and the bytecode javac produced for it, which I got from javap -c:

public class Hello {
    public static void main(String[] args) {
        int a = 2, b = 3;
        System.out.println("Sum: " + (a + b));
    }
}

// javap -c Hello  (main method)
0: iconst_2
1: istore_1
2: iconst_3
3: istore_2
4: getstatic     java/lang/System.out
7: iload_1
8: iload_2
9: iadd
10: invokedynamic makeConcatWithConstants
15: invokevirtual java/io/PrintStream.println
18: return

That bytecode is not x86 or ARM. It's instructions for a stack machine. When I run java Hello, the JVM starts by interpreting those instructions one by one. It also counts how often each method runs, and once a method gets hot, the JIT compiler (C1 first, then C2 for really hot code in HotSpot) turns it into optimized native code.

So the accurate answer is "compiled to bytecode, then interpreted and JIT-compiled at runtime." That's also why Java services get faster after warm-up.

BasicWhy is Java platform independent, but the JVM platform dependent?

So the .class file is the portable part. Bytecode is the same on every OS, so I compile once and the same JAR runs on Windows, Linux or macOS. The JVM is the part that has to talk to the real hardware and OS, so there's a different JVM build for each platform.

Let me put it as a picture:

Hello.java --javac--> Hello.class (bytecode, same everywhere)
                          |
        +-----------------+-----------------+
        |                 |                 |
  JVM for Windows   JVM for Linux     JVM for macOS/ARM
   (x86-64 code)    (x86-64 code)     (AArch64 code)

The JVM has to allocate memory, create native threads, open files and emit real machine instructions through the JIT. All of that is OS- and CPU-specific, so the JVM itself is a native program. It absorbs the platform differences so my bytecode doesn't have to.

That's what "write once, run anywhere" really means: the bytecode is portable because someone already ported the JVM. The caveat I'd mention is that your code can still break portability, for example hard-coding "\\" as a path separator or calling native libraries through JNI.

Interviewers look for: that you place portability in the bytecode, not in the JVM, and can name what makes the JVM native (memory, threads, I/O, JIT output).

IntermediateIs Java a 100% object-oriented language?

So no, not strictly. Java is strongly object-oriented, but it has eight primitive types that are not objects, and it has static members that belong to a class rather than to any object.

Let me show you with a quick example.

int count = 5;             // primitive: no methods, no null, lives directly in the variable
Integer boxed = count;     // object wrapper, created by autoboxing
// count.toString();       // does not compile: primitives have no methods
boxed.toString();          // fine

Math.max(3, 7);            // static method: no object involved at all

The primitives were a deliberate performance choice. An int[] of a million elements is one block of 4 MB. An Integer[] is a million references pointing at a million separate objects with headers, which is far more memory and much worse for the CPU cache.

Purely OO languages like Smalltalk make everything an object. Java chose speed for numbers. Project Valhalla is working on value classes that would narrow the gap, but that's not in Java 21.

So in an interview I'd say "object-oriented, but not pure, because of primitives and statics," and explain that it's a performance trade-off.

IntermediateWhat are LTS releases, and what are the big things in Java 8, 11, 17 and 21?

So Java ships a new version every six months, and every two years one of them is a Long-Term Support release that vendors patch for many years. Companies mostly run LTS versions: 8, 11, 17 and 21.

VersionHeadline features
8 (2014)Lambdas, Streams, Optional, default methods, java.time
11 (2018)var in lambdas (var itself came in 10), HttpClient, String.isBlank/strip/repeat/lines, run a single file with java File.java, no separate JRE
17 (2021)Records (16), sealed classes, text blocks (15), switch expressions (14), pattern matching for instanceof (16)
21 (2023)Virtual threads, pattern matching for switch, record patterns, sequenced collections

In my last project we moved a Spring service from 11 to 17 mainly for records and switch expressions, which removed a lot of boilerplate DTO code. Then 21 was attractive for virtual threads, because our service was mostly blocking I/O against other services.

So in practice I target the latest LTS my framework supports, which today is 21, and I read the release notes for removed APIs before upgrading.

Interviewers look for: knowing that var is Java 10, records 16, and that virtual threads are final in 21, not preview.

AdvancedIs there only one JVM? What's the difference between the JVM specification and HotSpot?

So "the JVM" is actually a specification: a document that defines the class file format, the bytecode instructions and how they must behave. HotSpot is Oracle's implementation of that spec, and it's the one inside OpenJDK builds from Oracle, Temurin, Corretto and most others.

There are other implementations that all run the same bytecode:

  • HotSpot: the default. Interpreter plus C1 and C2 JIT compilers, and GCs like G1 (default since 9), ZGC and Shenandoah.
  • Eclipse OpenJ9: from IBM, tuned for lower memory footprint and fast startup.
  • GraalVM: a JIT written in Java, plus native-image, which compiles ahead of time into a standalone binary that starts in milliseconds.

The spec leaves a lot open on purpose. It doesn't say how garbage collection works, how objects are laid out in memory, or when to JIT-compile. That's why two JVMs can run the same JAR with very different memory and startup profiles.

In my last team we tried GraalVM native image for a small CLI tool because JVM startup was about a second and native image got it to tens of milliseconds. The trade-off was that reflection-heavy libraries needed extra configuration.

So in practice I use HotSpot for long-running services, where the JIT wins, and consider native image only where startup time matters.

2. How a Java Program Runs

BasicWhy is main declared public static void? What happens if I remove static?

So each keyword exists because of how the JVM calls it. public because the launcher is outside the class and must be able to see it. static because no object exists yet when the program starts, so the JVM needs to call it on the class itself. void because the program's exit code isn't a return value; you use System.exit(code) for that. And String[] args carries the command-line arguments.

Let me show you what happens if I drop static:

public class NoStatic {
    public void main(String[] args) { System.out.println("hi"); }
}
$ javac NoStatic.java     // compiles fine
$ java NoStatic
Error: Main method is not static in class NoStatic, please define the main method as:
   public static void main(String[] args)

It compiles, because it's just an ordinary method called main. The error comes at launch, when the JVM looks for the entry point and can't find a static one.

One update for Java 21: JEP 445 added instance main methods and unnamed classes as a preview feature. With --enable-preview, the same file actually prints "hi". That's aimed at beginners, and it's still preview in 21, so in real code I always write the full public static void main(String[] args).

Interviewers look for: the reason behind each keyword, and that the failure is at runtime, not compile time.

BasicWalk me through what happens when you type java Hello.

So at a high level: the launcher starts a JVM, the class loader finds and loads Hello.class, the JVM verifies and links it, runs its static initializers, then calls main on the main thread, interpreting bytecode and JIT-compiling hot methods as it goes.

Let me break it into steps:

  1. Start the JVM: the java launcher creates the heap, starts GC and JIT threads.
  2. Load: the application class loader searches the classpath for Hello.class and reads the bytes. Classes like String come from the bootstrap loader.
  3. Link: verify the bytecode is safe (no stack overflows in the operand stack, no bad casts), prepare static fields with default values, and resolve symbolic references, often lazily.
  4. Initialize: run static field initializers and static {} blocks, once per class.
  5. Execute: call main(String[]). Each method call gets a stack frame; objects go on the heap.
  6. Exit: when all non-daemon threads finish, or someone calls System.exit, shutdown hooks run and the JVM exits.

A detail I like to mention: classes are loaded lazily. If main never touches a class, it's never loaded or initialized. That's why a missing dependency can blow up only when a particular code path runs, sometimes days after deployment.

So in practice when a service fails with NoClassDefFoundError in production, I think "that code path just ran for the first time."

IntermediateCan you overload main? Can a program run without a main method?

So yes, you can overload main like any other method, but the JVM only ever calls the public static void main(String[]) version. The others are just normal methods you call yourself.

Let me show you with a quick example.

public class MainOverload {
    public static void main(String[] args) {
        System.out.println("JVM called main(String[])");
        main(42);
    }
    public static void main(int x) {
        System.out.println("overloaded main(int): " + x);
    }
}
// JVM called main(String[])
// overloaded main(int): 42

Also valid: String... args instead of String[] args, final on the parameter, and any parameter name. Changing the return type or making it private is not.

Can it run without main? Back in Java 6 and earlier there was a trick of putting code in a static block and calling System.exit before the JVM looked for main. Since Java 7 the launcher checks for main first, so that no longer works. The real exceptions today are frameworks and containers: a servlet or a JUnit test has no main of its own, because the container's main loads and calls your class.

Interviewers look for: "overloading is allowed but only the String[] signature is the entry point," and knowing the static-block trick is dead since Java 7.

IntermediateWhat's the difference between ClassNotFoundException and NoClassDefFoundError?

So ClassNotFoundException is a checked exception you get when your code asks to load a class by name at runtime and it isn't there. NoClassDefFoundError is an error you get when a class was present at compile time, the JVM tries to load it implicitly, and it's missing or failed to initialize.

// ClassNotFoundException: dynamic loading by name
Class.forName("com.mysql.cj.jdbc.Driver");   // driver JAR not on classpath

// NoClassDefFoundError: a normal reference that compiled fine
Report r = new Report();   // Report.class was deleted from the deployed JAR,
                           // or Report's static initializer threw earlier
ClassNotFoundExceptionNoClassDefFoundError
TypeChecked exceptionError (a LinkageError)
TriggerClass.forName, loadClassImplicit load by new, method call, field access
Typical causeMissing JAR, typo in class nameClasspath differs between build and runtime, or static init failed

The sneaky case is the second cause. In one project a class had a static block that read a config file. The first access threw ExceptionInInitializerError, and every access after that threw NoClassDefFoundError, which sent us hunting for a missing JAR that wasn't missing. The real error was in the logs from the first failure.

So in practice when I see NoClassDefFoundError, I search the logs for an earlier ExceptionInInitializerError before I touch the classpath.

IntermediateWhat are class loaders, and what is parent delegation?

So class loaders are the objects that find class bytes and turn them into Class objects. Since Java 9 there are three built-in ones: bootstrap (core java.base classes, written in native code), platform (other JDK modules like java.sql), and application (your classpath). Parent delegation means a loader first asks its parent to load a class, and only tries itself if the parent can't.

System.out.println(String.class.getClassLoader());                        // null (bootstrap)
System.out.println(java.sql.Connection.class.getClassLoader().getName()); // platform
System.out.println(Loaders.class.getClassLoader().getName());             // app
System.out.println(Loaders.class.getClassLoader().getParent().getName()); // platform

The bootstrap loader shows up as null because it isn't a Java object.

Delegation gives two guarantees. Security: if I put my own java/lang/String.class on the classpath, the app loader asks bootstrap first, which loads the real one, so I can't replace core classes. Consistency: a class is identified by its name plus its loader, so delegation stops the same class being loaded twice and causing "cannot cast Foo to Foo" errors.

So in practice this matters when I work with app servers, plugin systems or hot reload in Spring DevTools, which deliberately use child-first loaders and can produce exactly that cast error.

Interviewers look for: "a class is unique by name plus loader," which explains the weird ClassCastException between identical names.

AdvancedPredict the output: static blocks, instance blocks and constructors with inheritance.
static class Parent {
    static { System.out.println("1. Parent static block"); }
    { System.out.println("3. Parent instance block"); }
    Parent() { System.out.println("4. Parent constructor"); }
}
static class Child extends Parent {
    static { System.out.println("2. Child static block"); }
    { System.out.println("5. Child instance block"); }
    Child() { System.out.println("6. Child constructor"); }
}

new Child();
System.out.println("--- second object ---");
new Child();

So it prints the numbers in order, 1 to 6, and then for the second object only 3 to 6.

1. Parent static block
2. Child static block
3. Parent instance block
4. Parent constructor
5. Child instance block
6. Child constructor
--- second object ---
3. Parent instance block
4. Parent constructor
5. Child instance block
6. Child constructor

Here's what's going on. Before Child can be initialized, its superclass must be, so the static blocks run parent first, and only once per class, ever. Then for each new Child(), the Child constructor's first action is an implicit super(). The parent's instance initializers run, then the parent constructor body, and only then does control come back to run the child's instance initializers and constructor body. Instance blocks are effectively copied into the start of each constructor, right after the super() call.

So in practice this is why I never call an overridable method from a constructor: the parent constructor would run the child's override before the child's fields are initialized.

Interviewers look for: statics once and parent-first, and the implicit super() explaining the instance order.

AdvancedWhat is the JIT compiler, and why is a Java service slow right after startup?

So the JIT, just-in-time compiler, watches which methods run a lot and compiles them from bytecode into optimized native code while the program runs. Right after startup almost everything is still being interpreted, and classes are still being loaded, so the first requests are slower. That's the warm-up period.

HotSpot uses tiered compilation:

  • Tier 0: interpreter, collecting profile data like which branches are taken and which types show up at a call site.
  • C1: quick compile with light optimizations once a method is warm.
  • C2: heavy optimization for hot methods, using the profile: inlining, escape analysis to put objects on the stack, loop unrolling, and removing virtual call overhead when only one implementation has been seen.

Because C2 optimizes based on what it observed, it can also deoptimize. If it assumed only one subclass existed and a new one gets loaded, it throws that compiled code away and goes back to the interpreter.

In my last project, after each deploy the p99 latency spiked for about a minute. We fixed it by sending synthetic warm-up traffic before putting the pod behind the load balancer. Other options are CDS archives for faster class loading, or GraalVM native image when startup really matters.

So in practice I never benchmark Java with a single timed loop in main. I use JMH, which handles warm-up properly.

3. Types, Variables & Wrappers

BasicWhat are the primitive types, and how are they different from wrapper classes?

So Java has eight primitives that hold raw values, and each has a wrapper class that is a real object. Primitives are fast and can't be null. Wrappers can be null, can go into collections and generics, and have utility methods.

PrimitiveSizeDefaultWrapper
byte8 bits0Byte
short16 bits0Short
int32 bits0Integer
long64 bits0LLong
float32 bits0.0fFloat
double64 bits0.0Double
char16 bits (UTF-16 unit)'\u0000'Character
booleanJVM-dependentfalseBoolean

The key practical differences:

  • Generics only take objects, so it's List<Integer>, never List<int>.
  • Wrappers can be null, which is useful for "no value yet" in a database entity, but dangerous when unboxed.
  • == on wrappers compares references, not values.
  • Wrappers cost memory: an Integer is about 16 bytes on a 64-bit JVM versus 4 for an int.

So in practice I use primitives for local math and fields that always have a value, and wrappers only where I need null or generics.

Interviewers look for: the sizes, that defaults apply only to fields and array elements (local variables must be assigned before use), and a hint at the == trap.

BasicWhy does byte b = 10; b = b + 1; fail to compile, but b += 1 works?

So in Java, arithmetic on byte, short and char is always promoted to int first. b + 1 is an int, and assigning an int to a byte is a narrowing conversion that needs an explicit cast. Compound assignment like += has an implicit cast built in.

byte b = 10;
// b = b + 1;       // error: possible lossy conversion from int to byte
b = (byte) (b + 1); // fine with a cast
b += 1;             // fine: means b = (byte)(b + 1)
// b is 12

byte big = (byte) 200;
// big is -56: only the low 8 bits are kept

int max = Integer.MAX_VALUE;
max + 1;            // -2147483648: silent overflow, no exception

long wrong = 1_000_000 * 1_000_000;   // -727379968 (int overflow happens first)
long right = 1_000_000L * 1_000_000;  // 1000000000000

That last pair is the one I've actually seen in production. Someone computed a timeout in microseconds with two int literals, stored it in a long, and got a negative number. The multiplication happens in int before it's widened to long. Making one operand 1_000_000L fixes it.

So in practice when I multiply values that could be large I use long from the start, or Math.multiplyExact, which throws on overflow instead of wrapping silently.

IntermediatePredict the output: Integer a = 127, b = 127 and Integer c = 128, d = 128.
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b);          // true
System.out.println(c == d);          // false
System.out.println(c.equals(d));     // true
System.out.println(c.intValue() == d); // true

Long l = 127L;
System.out.println(l.equals(127));   // false
System.out.println(l.equals(127L));  // true

So a == b is true but c == d is false. Autoboxing calls Integer.valueOf, and valueOf returns cached objects for values from -128 to 127. So both 127s are literally the same object. 128 is outside the cache, so each boxing creates a new object, and == compares references.

When one side is a primitive, like c.intValue() == d, Java unboxes the other side and compares values, so that's true.

The Long one catches people too. 127 is an int, so it boxes to Integer, and Long.equals returns false for anything that isn't a Long. Same values, different types.

The cache exists for Byte, Short, Integer, Long and Character (0 to 127). The upper bound for Integer can be raised with -XX:AutoBoxCacheMax, which is exactly why you must never depend on it.

I've seen this bug in real code: comparing two Long IDs with == worked in every test, because test IDs were small, and failed in production once IDs passed 127.

So in practice I always compare wrappers with equals or Objects.equals, never ==.

Interviewers look for: the exact range -128 to 127, that it comes from valueOf, and the "works in tests, fails in prod" story.

IntermediateHow can autoboxing cause a NullPointerException?

So unboxing a wrapper is really a method call like intValue(). If the wrapper is null, that call throws an NPE, and the line often doesn't look like it dereferences anything.

Let me show you the two ways I've seen it happen.

Map<String, Integer> stock = new HashMap<>();
int qty = stock.get("apple");
// NullPointerException: Cannot invoke "java.lang.Integer.intValue()"
// because the return value of "java.util.Map.get(Object)" is null

Integer none = null;
Integer r = true ? none : 0;   // NPE, even though r is an Integer!

The first one is the classic: get returns null for a missing key, and assigning to an int unboxes it. The helpful message is from Java 14's helpful NullPointerExceptions, which names exactly what was null.

The ternary is sneakier. When one branch is Integer and the other is the primitive 0, the whole expression's type becomes int. So Java unboxes none to produce an int, crashes, and would have re-boxed it afterwards.

The fixes:

int qty = stock.getOrDefault("apple", 0);   // 0
Integer r = true ? none : Integer.valueOf(0); // both branches Integer, no unboxing

So in practice I use getOrDefault or merge for counters, keep entity fields as wrappers only when null has a meaning, and watch mixed wrapper/primitive ternaries in code review.

IntermediateWhat is var, and where can't you use it?

So var, added in Java 10, is local variable type inference. The compiler figures out the static type from the initializer. It's still fully statically typed; it just saves me writing the type twice.

var list = new ArrayList<String>();   // ArrayList<String>
list.add("x");
// list.add(42);                       // compile error: still typed

var map = new HashMap<String, List<Integer>>();   // saves a lot of noise

for (var entry : map.entrySet()) { }  // fine in loops
try (var in = new FileInputStream("a.txt")) { }  // fine in try-with-resources

// var x;                 // error: needs an initializer
// var n = null;          // error: can't infer from null
// var f = () -> 1;       // error: lambda needs a target type
// var arr = {1, 2};      // error: array initializer needs a type
// private var field;     // error: only for local variables

It's not allowed for fields, method parameters or return types. Java 11 did allow var for lambda parameters, mainly so you can put annotations on them.

One trap: var list = new ArrayList<>(); with the diamond infers ArrayList<Object>, which is probably not what you wanted.

So in practice I use var when the right-hand side makes the type obvious, like a constructor call, and I write the type out when it comes from a method whose return type a reader can't see.

Interviewers look for: that var is not dynamic typing, and that it's local-only.

AdvancedWhy is 0.1 + 0.2 not equal to 0.3, and how do you handle money in Java?

So double is binary floating point, and 0.1 has no exact binary representation, the same way 1/3 has none in decimal. The small errors add up, so the sum is slightly off.

System.out.println(0.1 + 0.2);          // 0.30000000000000004
System.out.println(0.1 + 0.2 == 0.3);   // false

new BigDecimal(0.1);
// 0.1000000000000000055511151231257827021181583404541015625
BigDecimal.valueOf(0.1);                // 0.1
new BigDecimal("0.1").add(new BigDecimal("0.2"));   // 0.3

new BigDecimal("2.0").equals(new BigDecimal("2.00"));    // false (scale differs)
new BigDecimal("2.0").compareTo(new BigDecimal("2.00")); // 0

For money I use BigDecimal, or I store amounts as a long of the smallest unit, like cents. Three traps I watch for with BigDecimal:

  • new BigDecimal(0.1) captures the binary error. Use the String constructor or BigDecimal.valueOf.
  • equals checks scale too, so 2.0 and 2.00 aren't equal. Use compareTo for value comparison. That also means a HashSet<BigDecimal> can hold both.
  • divide throws ArithmeticException for non-terminating results like 1/3 unless you pass a scale and RoundingMode.

For comparing doubles in non-money code, like geometry, I compare with a tolerance: Math.abs(a - b) < 1e-9.

So in practice: double for measurements and science, BigDecimal or long cents for anything a customer will see on a bill.

4. Operators & Control Flow

BasicPredict the output of these char and String expressions.
char c = 'a';
System.out.println(c + 1);          // 98
System.out.println((char) (c + 1)); // b
c++;
System.out.println(c);              // b
System.out.println("" + 'a' + 1);   // a1
System.out.println('a' + 1 + "");   // 98
System.out.println('a' + 'b');      // 195
System.out.println("x" + 1 + 2);    // x12
System.out.println(1 + 2 + "x");    // 3x
System.out.println('7' - '0');      // 7

So the rule is that a char is really a 16-bit unsigned number, and + is evaluated left to right. If neither operand is a String, it's numeric addition, and char gets promoted to int. As soon as one side is a String, it becomes concatenation from that point on.

So c + 1 is 97 + 1 = 98, printed as a number. Casting back to char gives b. c++ stays a char because increment has an implicit cast, like +=. 'a' + 'b' is 97 + 98 = 195, not "ab".

For the string ones, "" + 'a' is already a String, so the 1 is appended. But 'a' + 1 runs first as math, giving 98, and only then becomes a String.

'7' - '0' is the trick I use all the time in parsing problems to turn a digit character into its value. Same idea with ch - 'a' for a letter's index in a 26-slot count array.

Interviewers look for: left-to-right evaluation and "numeric until a String appears."

BasicWhat's the difference between & and &&?

So && is the logical AND that short-circuits: if the left side is false, the right side is never evaluated. & on booleans always evaluates both sides, and on integers it's bitwise AND. Same story for || and |.

String s = null;

if (s != null && s.length() > 3) { }   // safe: length() never called

if (s != null & s.length() > 3) { }    // NullPointerException:
                                        // both sides run

int flags = 0b1010;
int masked = flags & 0b0010;            // 2: bitwise on ints

Short-circuiting is what makes the null-guard idiom work. I rely on it constantly: list != null && !list.isEmpty(), or i < arr.length && arr[i] == target, where the order of the two conditions is what prevents the exception.

The only time I'd use the non-short-circuit version on booleans is when the right side has a side effect I always want to happen, and honestly I'd rewrite that as two statements because it's easy to misread.

So in practice I use && and || for conditions and keep &, |, ^ for bit manipulation.

IntermediateWhat does i = i++ leave in i? And what is j++ + ++j?
int i = 5;
i = i++;
System.out.println(i);         // 5

int j = 1;
int k = j++ + ++j;
System.out.println(j + " " + k); // 3 4

So i stays 5. Here's exactly what happens: i++ saves the old value, 5, as the result of the expression, then increments i to 6. Then the assignment writes the saved 5 back into i, overwriting the 6. The increment happened, it just got thrown away.

For the second one, Java guarantees left-to-right evaluation of operands, unlike C where this is undefined behavior. j++ yields 1 and makes j 2. Then ++j makes j 3 and yields 3. So k is 1 + 3 = 4, and j ends at 3.

The one-line rule I use: postfix returns the old value, prefix returns the new value, and both change the variable.

So in practice I'd never write either of these in real code. If I see i = i++ in a review, it's a bug: the author meant i++. I keep increments on their own line or in the for-loop header.

Interviewers look for: that Java defines left-to-right evaluation, so the answer is deterministic, and that you'd flag it in code review.

IntermediateHow does switch on a String work? Any gotchas?

So switching on String has worked since Java 7. Under the hood the compiler calls hashCode() on the string, jumps on that number, and then calls equals() to confirm, because two different strings can share a hash.

Let me show you with a quick example, including the classic fall-through bug.

static String oldStyle(String day) {
    String type;
    switch (day) {
        case "SAT":
        case "SUN":
            type = "weekend";
            break;
        case "MON":
            type = "start";      // forgot break!
        default:
            type = "weekday";
    }
    return type;
}
oldStyle("MON");   // "weekday": fell through into default
oldStyle(null);    // NullPointerException

// javap -c shows: String.hashCode, lookupswitch, then String.equals per case
"FB".hashCode();   // 2236
"Ea".hashCode();   // 2236 (collision, which is why equals is still needed)

The gotchas I'd list:

  • Null: the hashCode() call throws NPE. I guard it or, in Java 21, add case null.
  • Fall-through: with colon-style cases, a missing break runs the next case.
  • Case-sensitive: "mon" won't match "MON", so I normalize first.

So in practice I use the arrow form from Java 14, which has no fall-through, and for a fixed set of values I prefer an enum so the compiler catches typos.

IntermediateWhat changed with switch in Java 14 and 21?

So Java 14 made switch an expression with arrow labels: it returns a value, has no fall-through, and multiple labels go on one line. Java 21 added pattern matching for switch: you can switch on type, deconstruct records, add when guards, and handle null directly.

// Java 14: switch expression
String type = switch (day) {
    case "SAT", "SUN" -> "weekend";
    case "MON" -> "start";
    default -> "weekday";
};

// Java 21: patterns, guards, null
static String describe(Object o) {
    return switch (o) {
        case null -> "null!";
        case Integer n when n > 100 -> "big int " + n;
        case Integer n -> "int " + n;
        case String s -> "string of length " + s.length();
        default -> "something else";
    };
}
// describe(500) -> "big int 500", describe(5) -> "int 5"
// describe("hey") -> "string of length 3", describe(null) -> "null!"

// Java 21 with a sealed hierarchy: no default needed
sealed interface Shape permits Circle, Square {}
record Circle(double r) implements Shape {}
record Square(double side) implements Shape {}

static double area(Shape s) {
    return switch (s) {
        case Circle c -> Math.PI * c.r() * c.r();
        case Square sq -> sq.side() * sq.side();
    };
}   // area(new Square(3)) -> 9.0

The best part is exhaustiveness. With a sealed interface, if someone adds a Triangle, every switch without a case for it stops compiling. With the old if-else and instanceof chain, that was a silent runtime bug.

Order matters: the guarded Integer n when n > 100 must come before plain Integer n, or the compiler reports it as dominated.

So in practice on Java 21 I use switch expressions by default and reach for patterns plus sealed types instead of the visitor pattern.

Interviewers look for: the version numbers (14 for expressions, 21 for patterns) and exhaustiveness with sealed types.

AdvancedWhat do -7 / 2 and -7 % 2 give, and why is Math.abs(Integer.MIN_VALUE) negative?
System.out.println(-7 / 2);                 // -3
System.out.println(-7 % 2);                 // -1
System.out.println(Math.floorMod(-7, 2));   // 1
System.out.println(Math.abs(Integer.MIN_VALUE)); // -2147483648
System.out.println(10 / 3.0);               // 3.3333333333333335
System.out.println(1 / 0.0);                // Infinity
System.out.println(1 / 0);                  // ArithmeticException: / by zero

So integer division in Java truncates toward zero, so -3.5 becomes -3, not -4. And % takes the sign of the dividend, so -7 % 2 is -1. That breaks the common check n % 2 == 1 for odd numbers when n is negative. I write n % 2 != 0 instead, or use Math.floorMod, which always returns a non-negative result for a positive divisor. That matters for things like circular buffer indices and hash bucket indices.

The Math.abs one is two's complement. int goes from -2,147,483,648 to 2,147,483,647, so the positive version of MIN_VALUE doesn't fit, and it overflows back to itself. I've seen this bug in sharding code: Math.abs(key.hashCode()) % n returned a negative shard index for the one key whose hash was MIN_VALUE. The fix is Math.floorMod(key.hashCode(), n).

And division by zero behaves differently by type: integers throw, floating point gives Infinity or NaN.

So in practice for any index computed from a hash I use floorMod, and I use Math.absExact (Java 15) when I want an exception instead of a silent wrong answer.

AdvancedHow do labeled break and continue work? Predict the output.
outer:
for (int r = 0; r < 3; r++) {
    for (int q = 0; q < 3; q++) {
        if (q == 2) continue outer;
        if (r == 2) break outer;
        System.out.print(r + "" + q + " ");
    }
}
// 00 01 10 11

So a label names a loop, and break label or continue label acts on that outer loop instead of the innermost one. Plain break only exits the inner loop.

Walking through it: for r = 0, it prints 00 and 01, and when q hits 2, continue outer skips the rest of the inner loop and moves to the next r. Same for r = 1, printing 10 and 11. When r = 2, the very first inner iteration hits break outer and leaves both loops. Notice the r + "" + q: without the empty string it would add the ints.

Where I actually use this: searching a 2D grid and stopping as soon as I find a target, without a found flag checked in two places.

search:
for (int r = 0; r < grid.length; r++)
    for (int c = 0; c < grid[r].length; c++)
        if (grid[r][c] == target) { row = r; col = c; break search; }

So in practice labels are fine for that one case, but if I need more than one, I extract the loops into a method and use return, which reads better. Java has no goto; it's a reserved word that does nothing.

5. Strings

BasicWhy is String immutable in Java?

So once a String is created, its characters can never change. Every method that looks like it modifies a string, like toUpperCase or replace, returns a new one. Java did this for four reasons: pooling, hashing, security and thread safety.

String s = "hello";
s.toUpperCase();        // result discarded
System.out.println(s);  // hello
s = s.toUpperCase();    // now s points to a new object
System.out.println(s);  // HELLO

The reasons I'd give:

  • String pool: the JVM can let every "hello" literal in the program share one object only because nobody can change it.
  • Hash keys: String caches its hashCode on first use. If a key could change after going into a HashMap, it would sit in the wrong bucket and never be found again.
  • Security: class names, file paths, URLs and database URLs are strings. If a string could change after a security check, you'd have a time-of-check to time-of-use hole.
  • Thread safety: immutable objects can be shared across threads with no locking.

The class is also final, so no one can subclass it and add mutable behavior.

The cost is that every change is a copy, which is why building strings in a loop needs StringBuilder.

So in practice immutability is why I can use strings freely as map keys and share them across threads without thinking about it.

Interviewers look for: at least three reasons, with hashing and the pool first, plus the cost side.

BasicWhat's the difference between == and equals() for Strings?

So == compares references: are these the same object in memory? equals() compares content: do they have the same characters? For strings I almost always want equals.

String a = "java";
String b = "java";
String c = new String("java");

System.out.println(a == b);        // true  (same pooled literal)
System.out.println(a == c);        // false (different objects)
System.out.println(a.equals(c));   // true  (same characters)

String x = null;
System.out.println("java".equals(x));        // false, no NPE
// x.equals("java");                         // NPE
System.out.println(Objects.equals(x, null)); // true, null-safe both sides
System.out.println("Java".equalsIgnoreCase("JAVA")); // true

The dangerous part is that == seems to work, because literals are pooled, so a == b is true. It breaks the moment a string comes from user input, a file, a database or substring, because those create new objects. So code that passed every unit test with literals fails in production.

For null safety I either put the constant first, "ADMIN".equals(role), or use Objects.equals(a, b) when both could be null.

So in practice == on strings is a code review flag for me. The only legitimate use is comparing against something I know is interned, and I can't remember the last time that was worth it.

BasicWhat is the String pool, and how many objects does new String("x") create?

So the String pool is a table inside the JVM, on the heap since Java 7, that holds one shared copy of each string literal. When I write "x", the JVM reuses the pooled object if it exists. new String("x") creates up to two objects: the literal "x" in the pool, if it isn't there already, and a brand new String on the heap that is never pooled.

String a = "java";              // pooled
String c = new String("java");  // new heap object with its own identity, never pooled
a == c;            // false
a == c.intern();   // true: intern() returns the pooled copy

So the standard answer to "how many objects" is: two if "x" hasn't appeared before, one if the literal is already in the pool. Strictly speaking the literal gets pooled when the class is loaded and the constant is resolved, not when the line runs.

Let me picture it:

Stack         Heap
a  ---------> [String pool] "java"
c  ---------> String "java"   (a separate object outside the pool)

There's almost never a reason to write new String("literal"). It just wastes an object. intern() can save memory if you have millions of duplicate strings, like country codes loaded from a file, but these days G1's string deduplication (-XX:+UseStringDeduplication) does something similar automatically.

So in practice I use literals, never new String, and compare with equals.

Interviewers look for: "one or two objects depending on whether the literal is already pooled," and that the pool moved from PermGen to the heap in Java 7.

BasicStringBuilder vs StringBuffer: what's the difference?

So both are mutable character buffers with the same API. StringBuffer is from Java 1.0 and every method is synchronized. StringBuilder came in Java 5 as the unsynchronized version, which is faster, and it's what you should use almost all the time.

StringStringBuilderStringBuffer
MutableNoYesYes
Thread-safeYes (immutable)NoYes (synchronized)
Since1.051.0
Use forValues, keys, constantsBuilding strings in one threadLegacy APIs
StringBuilder x = new StringBuilder("abc");
StringBuilder y = new StringBuilder("abc");
x.equals(y);                          // false! not overridden, compares references
x.toString().equals(y.toString());    // true
x.compareTo(y) == 0;                  // true (Comparable since Java 11)

That equals result surprises people. Neither builder class overrides equals or hashCode, so never use them as map keys.

Why is StringBuffer rarely the answer even with threads? Because a builder is almost always a local variable inside one method, so it's never shared. And if two threads really append to the same buffer, per-method locking still doesn't make a sequence of appends atomic, so you'd need your own lock anyway.

So in practice I use StringBuilder everywhere and only see StringBuffer in old APIs like Matcher.appendReplacement overloads.

IntermediateWhat's wrong with concatenating strings with += in a loop?
String s = "";
for (int i = 0; i < 100_000; i++) s += 'x';

So each += creates a brand new String and copies every character built so far. The first iteration copies 1 char, the next 2, and so on, so the total work is about n²/2 character copies. That's O(n²) for what should be O(n).

Let me show you with a quick measurement I ran on Java 21:

// same 100,000-char result both ways
String s = "";
for (int i = 0; i < n; i++) s += 'x';       // about 1800 ms

StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) sb.append('x'); // about 9 ms
String r = sb.toString();

A StringBuilder keeps one growable array and doubles its capacity when full, so each append is amortized O(1).

A follow-up I often get is "doesn't the compiler optimize +?" It does, but only within a single expression. "Hello " + name + "!" on one line is fine; since Java 9 it compiles to one invokedynamic call to StringConcatFactory, which sizes the result exactly. The compiler does not hoist a builder out of a loop, so the loop still creates a new string every iteration.

Other good options: String.join(",", list), Collectors.joining(",") for streams, and new StringBuilder(expectedSize) when I know the size, to avoid resizing.

So in practice: + for one-liners, StringBuilder or joining for loops.

Interviewers look for: the O(n²) reasoning, and knowing that single-expression concatenation is already optimized.

IntermediatePredict the output: compile-time constants and ==.
String a = "java";

String ab = "ja" + "va";
System.out.println(a == ab);       // true

String part = "ja";
String built = part + "va";
System.out.println(a == built);    // false

final String fpart = "ja";
String built2 = fpart + "va";
System.out.println(a == built2);   // true

So the difference is whether the compiler can compute the value. "ja" + "va" is two literals, a compile-time constant, so javac folds it into the single literal "java" in the class file, which gets the pooled object. a == ab is true.

With part + "va", part is an ordinary variable, so the concatenation happens at runtime and produces a new object that isn't pooled. False.

The third case is the tricky one. final String fpart = "ja" is a constant variable: final, and initialized with a constant expression. The compiler substitutes its value, so fpart + "va" is folded again and is true. If fpart were final but assigned from a method call, like getPrefix(), it would be false again.

So in practice this question is really testing whether I know == checks identity and that identity depends on compiler details. That's exactly why I never use == for string content.

AdvancedWhy is char[] preferred over String for passwords? And what are compact strings?

So for passwords: a String is immutable and may be pooled, so I can't wipe it. It stays in memory until the GC reclaims it, which could be a long time, and it can show up in a heap dump or get logged by accident. A char[] I can overwrite with zeros the moment I'm done.

char[] password = console.readPassword("Password: ");
try {
    authenticate(password);
} finally {
    Arrays.fill(password, '\0');   // wipe it
}

That's why Console.readPassword, JPasswordField.getPassword and KeyStore APIs use char[]. A bonus: printing a char[] by accident via toString shows [C@1b6d3586, not the secret. I'd be honest that this is defense in depth, not a guarantee, because the JVM may have copied the data during GC.

Compact strings came in Java 9. Before that, a String was backed by a char[], 2 bytes per character. Now it's a byte[] plus a coder flag: if every character fits in Latin-1, it uses 1 byte per character, otherwise UTF-16 with 2 bytes. Since most strings in typical apps (JSON keys, IDs, log messages) are ASCII, this cut string memory roughly in half with zero code changes.

So in practice I use char[] for secrets I handle directly, and compact strings are a good example of why upgrading the JDK can reduce heap usage for free.

Interviewers look for: "I can clear an array but not a String," and the Java 9 byte[] plus coder layout.

6. Arrays

BasicWhat are the key properties of arrays in Java? length vs length() vs size()?

So a Java array is a fixed-size object holding elements of one type in contiguous memory. Once created, its size never changes. It knows its length, it's bounds-checked on every access, and its elements get default values.

boolean[] flags = new boolean[2];   // [false, false]
String[] names = new String[2];     // [null, null]
double[] ds = new double[1];        // [0.0]

int[] arr = new int[3];
// arr[3] = 1;   // ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3

int[][] jag = new int[3][];          // jagged: rows created separately
jag[1] = new int[4];
jag[1].length;   // 4
jag[2];          // null (row never created)

And the three "length" spellings people mix up:

ThingSyntaxWhy
Arrayarr.lengthA final field, no parentheses
Strings.length()A method
Collectionlist.size()A method on the interface

Arrays are objects, so they live on the heap even when they hold primitives, and a variable of type int[] is a reference. A 2D array is really an array of references to row arrays, which is why rows can have different lengths.

So in practice I use arrays for fixed-size, performance-sensitive data like DP tables or counts, and ArrayList when the size changes.

BasicHow do you compare and print two arrays correctly?

So arrays don't override equals, hashCode or toString. They inherit them from Object, which means identity comparison and an unreadable default string. I use the static helpers in java.util.Arrays instead.

int[] a = {1, 2, 3};
int[] b = {1, 2, 3};
System.out.println(a == b);                 // false
System.out.println(a.equals(b));            // false (same as ==)
System.out.println(Arrays.equals(a, b));    // true
System.out.println(a);                      // [I@2a139a55 (type code + hash)
System.out.println(Arrays.toString(a));     // [1, 2, 3]

int[][] m1 = {{1, 2}, {3}};
int[][] m2 = {{1, 2}, {3}};
System.out.println(Arrays.equals(m1, m2));      // false
System.out.println(Arrays.deepEquals(m1, m2));  // true
System.out.println(Arrays.deepToString(m1));    // [[1, 2], [3]]

The 2D case is the trap. Arrays.equals compares element by element with equals, and the elements are row arrays, which again compare by identity. deepEquals and deepToString recurse into the nested arrays.

The same problem hits hashing: an int[] as a HashMap key or in a HashSet uses identity, so two arrays with the same content are different keys. For a "seen" set of states I convert to a List, a String key, or use Arrays.hashCode inside a wrapper record.

So in practice I never call equals or toString directly on an array.

Interviewers look for: knowing deepEquals exists for nested arrays, and the HashSet-of-arrays bug.

IntermediateWhat are the pitfalls of Arrays.asList?

So Arrays.asList returns a fixed-size list that is a view over the original array. You can set elements, but add and remove throw, and changes flow both ways between the array and the list. And with a primitive array, it doesn't do what people expect at all.

List<String> fixed = Arrays.asList("a", "b", "c");
fixed.set(0, "z");            // fine: [z, b, c]
fixed.add("d");               // UnsupportedOperationException

String[] backing = {"x", "y"};
List<String> view = Arrays.asList(backing);
backing[0] = "CHANGED";
System.out.println(view);     // [CHANGED, y]  (it's a view)

int[] nums = {1, 2, 3};
List<int[]> oops = Arrays.asList(nums);
oops.size();                  // 1 !  a list containing one int[]

List<Integer> ok = Arrays.stream(nums).boxed().toList();  // [1, 2, 3]

The primitive case happens because generics can't hold int, so the varargs T... can't be int.... The whole int[] becomes the single T.

Compare with List.of (Java 9): fully immutable, so even set throws, and it rejects null elements.

In one project someone returned Arrays.asList(...) from a repository method and a caller later did result.add(...). It compiled, since the return type was List, and crashed at runtime.

So in practice when I need a list I can grow, I write new ArrayList<>(Arrays.asList(...)), and for constants I use List.of.

IntermediateWhat is array covariance, and why does it cause ArrayStoreException?

So arrays in Java are covariant: because String is a subtype of Object, String[] is treated as a subtype of Object[]. That lets bad code compile, so the JVM has to check every store into a reference array at runtime.

Object[] objs = new String[2];   // compiles: covariance
objs[0] = 42;                    // compiles: 42 is an Object
// runtime: ArrayStoreException: java.lang.Integer

List<Object> list = new ArrayList<String>();   // does NOT compile: generics are invariant

The array knows its real element type at runtime, String, so when I try to put an Integer in, the JVM throws. That's a type error that slipped past the compiler.

Generics fixed this by being invariant: List<String> is not a List<Object>, so the mistake is caught at compile time. When I do need flexibility, I use wildcards: List<? extends Object> lets me read but not add, which is safe.

Covariance exists because arrays predate generics. Before Java 5, something like Arrays.sort(Object[]) needed to accept a String[], and covariance was how they did it.

So in practice I prefer List over arrays in APIs, partly for this reason, and I think of arrays as "covariant and checked at runtime" versus generics as "invariant and checked at compile time."

Interviewers look for: the contrast with generics invariance, which is the setup for PECS and wildcards.

IntermediateHow do you copy an array? Is clone() deep or shallow?

So there are four ways: clone(), Arrays.copyOf, Arrays.copyOfRange and System.arraycopy. All of them are shallow: they copy the elements, and if the elements are references, like row arrays or objects, both arrays end up pointing at the same things.

int[][] grid = {{1, 2}, {3, 4}};
int[][] shallow = grid.clone();
shallow[0][0] = 99;
System.out.println(grid[0][0]);   // 99: rows are shared!

int[][] deep = new int[grid.length][];
for (int i = 0; i < grid.length; i++) deep[i] = grid[i].clone();
deep[1][1] = -1;
System.out.println(grid[1][1]);   // 4: original untouched

int[] a = {1, 2, 3};
int[] grown = Arrays.copyOf(a, 5);   // [1, 2, 3, 0, 0], pads with defaults

System.arraycopy(src, srcPos, dest, destPos, length); // fastest, into an existing array

For a 1D array of primitives, shallow and deep are the same thing, so clone() is perfect. The problem is 2D arrays and arrays of mutable objects. I've hit this in backtracking problems: I "saved" a board with board.clone(), kept mutating the board, and all my saved snapshots changed with it.

System.arraycopy also handles overlapping ranges correctly, which is what ArrayList uses internally to shift elements on insert and remove.

So in practice: clone() or copyOf for 1D primitives, a per-row clone for 2D, and a real copy constructor for arrays of mutable objects.

AdvancedWhy can't you write new T[n] in a generic class?

So because of type erasure. Generics are a compile-time feature, and at runtime T is erased to Object (or its bound). But arrays need to know their real element type at runtime to do the store check we just talked about. The JVM has no idea what T is, so it can't create a T[].

class Box<T> {
    // T[] items = new T[10];            // compile error: generic array creation

    // Option 1: what ArrayList does internally
    private Object[] items = new Object[10];
    @SuppressWarnings("unchecked")
    T get(int i) { return (T) items[i]; }

    // Option 2: pass the type in when you need a real T[]
    T[] toArray(IntFunction<T[]> factory, int n) { return factory.apply(n); }
}

// caller: box.toArray(String[]::new, 3) gives a real String[]

The unchecked cast in option 1 is safe as long as the Object[] never escapes the class, because only Ts go in. The trap is returning it as (T[]) items: the caller gets an Object[] pretending to be a String[], and assigning it to a String[] variable throws ClassCastException.

That's why Collection.toArray has an overload taking IntFunction<T[]> (Java 11), like list.toArray(String[]::new). The caller supplies the real array type.

So in practice inside a generic class I store an Object[] or just use a List<T>, and when I must hand out a typed array I take a generator or a Class<T> from the caller.

Interviewers look for: connecting erasure (generics are compile-time) with arrays being reified (their type is checked at runtime).

7. Methods & Pass-by-Value

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

So Java is always pass-by-value. For primitives, the value is the number itself. For objects, the value that gets copied is the reference. The method gets its own copy of that pointer, so it can change the object it points to, but it can never make the caller's variable point somewhere else.

Let me show you with the classic swap example.

static void swap(Dog x, Dog y) { Dog tmp = x; x = y; y = tmp; }

Dog a = new Dog("Rex"), b = new Dog("Fido");
swap(a, b);
System.out.println(a + " " + b);   // Rex Fido  (nothing swapped)

Inside swap, x and y are copies of the references in a and b. Swapping them just swaps two local variables that disappear when the method returns. a and b in the caller still point where they did. In a true pass-by-reference language, like C++ with & parameters, this swap would work.

caller:  a --> [Dog Rex]  <-- x  (copy of a's reference)
         b --> [Dog Fido] <-- y  (copy of b's reference)
swap:    x and y exchange arrows; a and b never change

If I actually need a swap, I swap slots inside something both sides share, like an array:

static void swap(int[] arr, int i, int j) { int t = arr[i]; arr[i] = arr[j]; arr[j] = t; }
// arr = {1, 2} becomes {2, 1}

So in practice I say "pass-by-value, where the value of an object variable is a reference," which is precise and settles the argument.

Interviewers look for: the swap example as proof, and not saying "objects are passed by reference."

BasicPredict the output: modifying vs reassigning a parameter.
static void rename(Dog d)   { d.name = "Max"; }
static void replace(Dog d)  { d = new Dog("Buddy"); d.name = "Charlie"; }
static void increment(int n) { n++; }
static void append(StringBuilder sb)   { sb.append(" world"); }
static void reassign(StringBuilder sb) { sb = new StringBuilder("gone"); }
static void change(String s) { s = s + "!"; }

Dog a = new Dog("Rex");
rename(a);   System.out.println(a);   // Max
replace(a);  System.out.println(a);   // Max
int n = 5; increment(n); System.out.println(n);   // 5
StringBuilder sb = new StringBuilder("hello");
append(sb); reassign(sb); System.out.println(sb); // hello world
String s = "hi"; change(s); System.out.println(s); // hi

So the rule I apply: going through the reference to mutate the object is visible to the caller; reassigning the parameter is not.

rename follows the copied reference to the same Dog and changes its field, so the caller sees "Max". replace points its local d at a brand new Dog, and everything after that line touches the new object, so the caller's Dog stays "Max". increment gets a copy of 5. append mutates the shared builder, reassign only changes its local copy.

change with a String is the one people get wrong. It looks like modification, but strings are immutable, so s + "!" creates a new object and assigns it to the local s. The caller's string can't change, ever.

So in practice when a method needs to "return" a changed value, I return it, like s = addBang(s), rather than hoping a parameter change leaks out.

IntermediateHow does Java pick an overloaded method? Predict the output.
static void f(long x)    { System.out.println("long"); }
static void f(Integer x) { System.out.println("Integer"); }
static void f(int... x)  { System.out.println("varargs"); }

f(5);                      // long

static void g(Object o) { System.out.println("Object"); }
static void g(String s) { System.out.println("String"); }

g(null);                   // String
Object o = "text";
g(o);                      // Object

So the compiler resolves overloads in three phases, and stops at the first phase that finds a match:

  1. Exact match or widening of primitives, and subtype for references. No boxing, no varargs.
  2. Boxing and unboxing allowed.
  3. Varargs allowed.

For f(5), phase 1 already finds f(long) through widening int to long, so Integer and varargs are never considered. This order exists for backward compatibility: code written before Java 5 added boxing and varargs had to keep calling the same method.

Within a phase, if several methods match, the compiler picks the most specific one. null fits both Object and String, and String is more specific, so "String". If I added a g(Integer), then g(null) would be ambiguous and fail to compile, because neither String nor Integer is more specific than the other.

And g(o) prints "Object" because overloading is chosen at compile time from the declared type. The runtime type being String doesn't matter. That's the key contrast with overriding, which is chosen at runtime.

Interviewers look for: the order "widening, then boxing, then varargs," and "overloading is compile time, based on the static type."

IntermediateMore overloading traps: char, long, byte and Integer arguments.
static void h(char c)   { System.out.println("char"); }
static void h(int i)    { System.out.println("int"); }
static void h(double d) { System.out.println("double"); }

h('a');          // char
h(5L);           // double
byte b = 1;
h(b);            // int

static void k(Object o) { System.out.println("k Object"); }
static void k(long l)   { System.out.println("k long"); }

Integer boxed = 7;
k(boxed);        // k Object
k(7);            // k long

So each one follows the same phase rules. h('a') is an exact match. h(5L) has no long version, and long can't narrow to int, but it can widen to double, so "double". byte can widen to int or double, but it can't widen to char, because char is unsigned. Of the two, int is more specific, so "int".

k(boxed) is the interesting one. You might expect unboxing to long. But in phase 1, an Integer is already a subtype of Object with no conversion, so phase 1 succeeds and unboxing is never tried. k(7) is the reverse: phase 1 finds long by widening, which beats boxing 7 to reach Object.

A real-world version of this is List<Integer>.remove. list.remove(1) calls remove(int index), not remove(Object), so it removes the element at index 1, not the value 1. To remove the value I write list.remove(Integer.valueOf(1)).

So in practice I avoid overloads that differ only by numeric type or primitive versus wrapper. Distinct method names are clearer.

IntermediateCan you overload by return type alone? What are the rules for varargs?

So no. The compiler identifies a method by its name and parameter types, the signature. The return type isn't part of it, so two methods that differ only by return type won't compile, because at a call like calc(5); where the result is ignored, the compiler couldn't pick one.

int  calc(int x) { return x; }
// long calc(int x) { return x; }   // error: method calc(int) is already defined

static void v(int... xs) { System.out.println("v got " + xs.length); }
v();                   // v got 0
v(1, 2, 3);            // v got 3
v(new int[]{4, 5});    // v got 2  (an array can be passed directly)

Varargs rules I keep in mind:

  • Only one varargs parameter, and it must be last: format(String fmt, Object... args).
  • Inside the method it's just an array, and with no arguments it's an empty array, never null.
  • Each call allocates a new array, which is why APIs like List.of have fixed overloads for up to ten arguments before falling back to varargs.
  • Varargs is the last phase of overload resolution, so a fixed-arity method always wins if it matches.
  • Generic varargs like T... create a generic array, which gives a heap pollution warning; @SafeVarargs is how you promise the method is safe.

So in practice I use varargs for convenience APIs like logging and builders, and avoid overloading a varargs method with fixed-arity ones of similar types, because the resolution gets hard to predict.

Interviewers look for: "signature is name plus parameter types," and that throws clauses and return types don't count either.

AdvancedWhat happens on a StackOverflowError, and does Java optimize tail recursion?

So each method call pushes a frame onto the thread's stack with its local variables, parameters and return address. Deep or infinite recursion fills that fixed-size stack, and the JVM throws StackOverflowError. And no, HotSpot does not do tail call optimization, so even a tail-recursive method uses one frame per call.

static int depth = 0;
static void down() { depth++; down(); }

try { down(); }
catch (StackOverflowError e) { /* depth reaches thousands of frames, then this */ }

// tail-recursive, but still O(n) stack in Java
static long sum(int n, long acc) { return n == 0 ? acc : sum(n - 1, acc + n); }

// the fix: turn it into a loop
static long sumLoop(int n) { long acc = 0; while (n > 0) acc += n--; return acc; }

The exact depth depends on frame size and the stack size, which is typically 512 KB to 1 MB per thread by default and set with -Xss. It's an Error, not an Exception, so I shouldn't catch it in normal code; it signals a design problem.

In my last project a recursive tree walker over a category hierarchy worked fine until a customer imported data with a cycle, and it overflowed. The fix was an explicit Deque as a stack plus a visited set.

So in practice for recursion that could go deep, like DFS on a large graph or a long linked list, I use an explicit stack or a loop. Raising -Xss or running the recursion in a new Thread(null, task, "worker", 256L << 20) with a bigger stack works, but I treat it as a last resort.

Part 2 — Object-Oriented Programming

8. Classes & Objects

BasicWhat are the four pillars of OOP? Give me a real example of each, not the textbook definitions.

So the four pillars are encapsulation, abstraction, inheritance and polymorphism. Encapsulation hides an object's state behind methods. Abstraction exposes only what a caller needs. Inheritance reuses and specialises behaviour. Polymorphism lets one call do different things depending on the real object.

Let me show you with a quick example from a checkout service, where all four show up in about twenty lines.

interface PaymentMethod { String pay(long cents); }             // abstraction

static abstract class Card implements PaymentMethod {          // inheritance
    private final String last4;                                 // encapsulation
    Card(String number) { this.last4 = number.substring(number.length() - 4); }
    protected String masked() { return "**** " + last4; }
}
static class CreditCard extends Card {
    CreditCard(String n) { super(n); }
    public String pay(long cents) { return "Charged " + cents + " to credit " + masked(); }
}
static class Upi implements PaymentMethod {
    public String pay(long cents) { return "UPI request for " + cents; }
}

List<PaymentMethod> methods = List.of(new CreditCard("4111111111111111"), new Upi());
for (PaymentMethod m : methods) System.out.println(m.pay(2500));  // polymorphism
// Charged 2500 to credit **** 1111
// UPI request for 2500

Walking through it: the card number never leaves Card. Only the last four digits are stored, and they're private, so that's encapsulation. The checkout code only knows PaymentMethod.pay. It has no idea about card networks or UPI handles, so that's abstraction. CreditCard reuses the masking logic from Card through inheritance. And the loop calls pay on each element and the JVM picks the right implementation at runtime. That's polymorphism.

So in practice the pillar that matters most day to day is abstraction through interfaces. It's what lets me add a new payment method without touching the checkout loop.

Interviewers look for: one connected example instead of four memorised definitions, and a sense of which pillar actually earns its keep in real code.

BasicWhat's the difference between a class and an object? What actually happens when I write new?

So a class is the blueprint: it declares fields and methods and lives once in the JVM as loaded metadata. An object is one instance of that blueprint on the heap, with its own copy of the instance fields. One class, many objects.

When I write new Order("A1"), a few things happen in order:

  1. The JVM makes sure Order is loaded, linked and initialised. Static blocks run here, but only the first time.
  2. It allocates memory on the heap for all instance fields, including inherited ones, and sets them to defaults: 0, false, null.
  3. It runs the constructor chain. The Object constructor runs first, then each parent down the hierarchy, and for each class the field initialisers and instance blocks run before its constructor body.
  4. It returns the reference, which I store in a variable on the stack.
Order a = new Order("A1");
Order b = new Order("A1");
Order c = a;
a == b;   // false  two separate objects on the heap
a == c;   // true   two references to the same object

A point people miss: the variable a isn't the object. It's a reference to it. That's why c = a doesn't copy anything. It just adds a second arrow to the same object.

So in practice, when a bug looks like "I changed one object and another one changed too", I check whether they're really two references to one object.

BasicWhen does Java give you a default constructor? What happens if you add a parameterised one?

So the compiler adds a no-arg constructor only when the class declares no constructors at all. As soon as I write any constructor, the default one is gone, and new User() stops compiling.

Let me show you with a quick example.

static class User {
    String name;
    User(String name) { this.name = name; }
}

User u = new User();
// error: constructor User in class User cannot be applied to given types;
//   required: String

The default constructor has the same access as the class and its only job is to call super(). That second part causes a related error with inheritance. If a parent class has only a parameterised constructor, a subclass that doesn't call super(...) explicitly won't compile, because the implicit super() has nothing to call.

static class Animal { Animal(String name) {} }
static class Dog extends Animal {
    Dog() { System.out.println("dog"); }   // implicit super() -> compile error
}

This matters in real projects with frameworks. JPA entities and Jackson (without extra config) need a no-arg constructor. I've seen deserialisation break the moment someone added a convenience constructor to a DTO, because the default one silently disappeared.

So in practice, if a framework needs a no-arg constructor, I write it explicitly, often protected, so it can't vanish by accident.

IntermediateWhat is constructor chaining? What are the rules for this() and super()?

So constructor chaining is one constructor calling another. this(...) calls a constructor in the same class, and super(...) calls one in the parent. The rule in Java 21 is that either call must be the very first statement, and you can have only one of them, never both.

Let me show you with a quick example, and predict the output.

static class Pizza {
    final String size; final String crust; final boolean cheese;
    Pizza()                   { this("medium");              System.out.println("Pizza()"); }
    Pizza(String size)        { this(size, "thin");          System.out.println("Pizza(size)"); }
    Pizza(String size, String crust) { this(size, crust, true); System.out.println("Pizza(size, crust)"); }
    Pizza(String size, String crust, boolean cheese) {
        this.size = size; this.crust = crust; this.cheese = cheese;
        System.out.println("Pizza(all) -> " + size + ", " + crust + ", " + cheese);
    }
}
new Pizza();
// Pizza(all) -> medium, thin, true
// Pizza(size, crust)
// Pizza(size)
// Pizza()

The calls go down the chain, then the bodies unwind back up, so the most complete constructor prints first. The pattern is called telescoping: every short constructor fills in a default and forwards, and all the real assignment happens in one place.

Why only one call, and why first? Because the object must be fully built from the parent down before my code touches it. If this() and super() were both allowed, the parent would be initialised twice. If there's neither, the compiler inserts super() for me. Java 22 started previewing "statements before super()" for argument validation, but in Java 21 the first-statement rule is strict.

So in practice I keep one "main" constructor that does the work and chain the rest into it. Beyond three or four parameters I switch to a builder.

Interviewers look for: the correct reverse order of the print statements, and knowing the compiler inserts super() when you don't write it.

IntermediatePredict the output: static blocks, instance blocks and constructors in a parent and child class.

So the rule is: static initialisation happens once per class, parent before child, when the class is first used. Then for every object, each class in the hierarchy runs its instance initialisers and then its constructor body, from the top of the hierarchy down.

Let me show you with a quick example.

static class Parent {
    static { System.out.println("1. Parent static block"); }
    { System.out.println("3. Parent instance block"); }
    Parent() { System.out.println("4. Parent constructor"); }
}
static class Child extends Parent {
    static { System.out.println("2. Child static block"); }
    { System.out.println("5. Child instance block"); }
    Child() { System.out.println("6. Child constructor"); }
}

new Child();
System.out.println("--- second object ---");
new Child();
1. Parent static block
2. Child static block
3. Parent instance block
4. Parent constructor
5. Child instance block
6. Child constructor
--- second object ---
3. Parent instance block
4. Parent constructor
5. Child instance block
6. Child constructor

Here's how I walk through it. new Child() triggers class initialisation. Java initialises the superclass first, so both static blocks run, parent then child. Then the Child constructor starts, and its first hidden statement is super(). So Parent runs its instance block and constructor body. Control comes back to Child, and its instance block runs, then its constructor body. On the second object the static blocks don't run again, because a class is initialised only once.

One detail that often trips people up: instance blocks and field initialisers are copied by the compiler into each constructor, right after the super() call, in the order they appear in the source.

So in practice I avoid logic in static and instance blocks. It runs at surprising times, and a static block that throws gives you ExceptionInInitializerError and then NoClassDefFoundError on every later use of that class.

Interviewers look for: that static blocks don't repeat for the second object, and the "super() first, then my instance blocks, then my body" explanation.

IntermediateCan a constructor be private? Final? Static? Abstract?

So private: yes, and it's useful. Final, static and abstract: no, they're compile errors, and each one is forbidden for a clear reason.

Let me show you with a quick example of why private is useful.

static final class MathUtils {
    private MathUtils() { throw new AssertionError("no instances"); }  // utility class
    static int square(int x) { return x * x; }
}

static final class Temperature {
    private final double celsius;
    private Temperature(double c) { this.celsius = c; }
    static Temperature ofCelsius(double c)    { return new Temperature(c); }
    static Temperature ofFahrenheit(double f) { return new Temperature((f - 32) * 5 / 9); }
}
Temperature.ofFahrenheit(212);   // 100.0C

A private constructor is how I build singletons, utility classes that must never be instantiated, and static factory methods like ofFahrenheit. Static factories have a name, can cache instances, and can return a subtype.

ModifierAllowed?Why
private / protected / public / defaultYesControls who can create instances
finalNo: "modifier final not allowed here"Constructors aren't inherited, so there's nothing to stop overriding
staticNoA constructor exists to initialise an instance; static means "no instance"
abstractNoA constructor must have a body; abstract means "no body"
synchronizedNoNo other thread can see the object while it's being constructed, so there's nothing to lock

One catch with private constructors: they don't fully stop reflection. setAccessible(true) can still call them, which is why the utility class throws AssertionError and why an enum is the safest singleton.

So in practice I make constructors private whenever I want to control creation, and expose named factory methods instead.

AdvancedWhat's wrong with calling an overridable method from a constructor? Predict the output.

So the parent constructor runs before the child's fields are initialised. If the parent calls a method the child overrides, the child's version runs on a half-built object and sees default values, like null and 0.

Let me show you with a quick example.

static class Base {
    Base() { describe(); }
    void describe() { System.out.println("Base"); }
}
static class Derived extends Base {
    private String name = "Derived";
    private final int size = 42;     // compile-time constant
    private int count = 7;
    @Override void describe() { System.out.println(name + " " + size + " " + count); }
}

Derived d = new Derived();   // null 42 0
d.describe();                // Derived 42 7

Walking through it: new Derived() calls super() first. Base() calls describe(), and dynamic dispatch picks Derived.describe. But the Derived field initialisers haven't run yet, so name is null and count is 0. The interesting one is size: it's a final field with a constant initialiser, so the compiler inlines 42 directly into the method, and it prints correctly even though the field is technically unset. After construction finishes, everything prints normally.

In real code this shows up as a NullPointerException inside an overridden init() or template hook, which is very confusing to debug because the field "clearly" has a value.

So in practice constructors should call only private, final or static methods. If I need a hook, I use a factory method that constructs the object first and then calls the hook.

Interviewers look for: "fields of the subclass aren't initialised yet", and bonus points for spotting the constant-inlining detail on size.

9. Encapsulation & Access Modifiers

BasicWhat is encapsulation, and why does it matter?

So encapsulation means an object owns its state: the fields are private and the only way to change them is through methods that enforce the rules. The point isn't hiding for its own sake. It's protecting invariants, the things that must always be true.

Let me show you with a quick example.

// Not encapsulated: anyone can break it
public class Account { public double balance; }
account.balance = -500;          // nothing stops this

// Encapsulated: the rule lives in one place
public class Account {
    private long balanceCents;
    public void withdraw(long cents) {
        if (cents <= 0) throw new IllegalArgumentException("amount must be positive");
        if (cents > balanceCents) throw new IllegalStateException("insufficient funds");
        balanceCents -= cents;
    }
    public long balanceCents() { return balanceCents; }
}

With the first version, every caller in the codebase has to remember the rules, and one of them will forget. With the second, the rule is enforced once, and I can change the internal representation, say from double to long cents, without touching any caller.

So in practice I start every field as private, and I add methods that express behaviour (withdraw, deposit) instead of generic setters. A setter for every field is just a public field with extra steps.

Interviewers look for: "invariants" and "freedom to change the internals", not just "make fields private".

BasicExplain the four access modifiers. Who can see what?

So Java has four levels, from most to least restrictive: private, package-private (no keyword), protected, and public.

ModifierSame classSame packageSubclass, other packageEverywhere
privateYesNoNoNo
(default)YesYesNoNo
protectedYesYesYes, through inheritanceNo
publicYesYesYesYes

Two things people get wrong. First, protected is wider than default, not narrower, because it includes the whole package. Second, a top-level class can only be public or package-private. private and protected are allowed only on nested classes.

Since Java 9 there's also a level above public: modules. A public class in a package the module doesn't export is invisible to other modules. That's how the JDK hides its sun.* internals.

So in practice I use package-private a lot more than people expect. It's perfect for helper classes that only one package needs, and tests in the same package can still reach them.

IntermediateIs making fields private and adding getters and setters enough for encapsulation?

So no. If a getter returns a mutable object, like a List or a Date, the caller gets a reference to my internal state and can change it without going through any of my methods. The field is private, but the object inside it has escaped.

Let me show you with a quick example.

static class Team {
    private final List<String> members = new ArrayList<>();
    Team(String... names) { members.addAll(List.of(names)); }
    List<String> getMembers() { return members; }   // leaks the internal list
}
Team t = new Team("Asha", "Ben");
t.getMembers().clear();
System.out.println(t.getMembers().size());      // 0  -- emptied from outside

static class SafeTeam {
    private final List<String> members;
    SafeTeam(List<String> names) { this.members = List.copyOf(names); }
    List<String> getMembers() { return members; }   // already unmodifiable
}
new SafeTeam(List.of("Asha", "Ben")).getMembers().clear();
// throws UnsupportedOperationException

The fix works in both directions. On the way in I copy, so the caller can't keep a handle to my list and change it later. On the way out I return something unmodifiable: either a List.copyOf (Java 10) stored once, or Collections.unmodifiableList(members) as a read-only view.

In a project I worked on, a cached config object handed out its internal Map, and one request handler called put on it "temporarily". Every later request saw the change. It took a while to find because the field was private and final, so everyone assumed it was safe.

So in practice I treat any getter that returns a mutable type as a leak until I've made it defensive.

Interviewers look for: defensive copies on both the way in and the way out, and knowing that final on the field doesn't help here.

IntermediateHow exactly does protected work across packages? What's the catch?

So in another package, a subclass can use a protected member only through a reference of its own type, meaning this or another instance of the subclass. It cannot reach the protected member on an arbitrary instance of the parent class.

Let me show you with a quick example.

package com.shop.model;
public class Account { protected double balance = 100; }

package com.shop.app;
import com.shop.model.Account;
public class Savings extends Account {
    void addInterest()        { balance *= 1.05; }                      // OK: inherited, via this
    void peek(Savings other)  { System.out.println(other.balance); }    // OK: my own type
    void steal(Account any)   { System.out.println(any.balance); }      // compile error
}
// error: balance has protected access in Account

The reasoning is that protected means "for my subclasses to use in their own implementation", not "for any subclass to poke at any account". If steal compiled, any class could extend Account just to read the balance of every Account object it's handed, which would make protected almost the same as public.

And remember, in the same package none of this applies, because protected includes package access.

So in practice I use protected sparingly, mostly for template-method hooks in abstract classes. It's part of the class's API for subclasses, so I can't change it freely later.

IntermediateCan one object access another object's private fields?

So yes, as long as both objects are the same class. Java's private is per class, not per object. Code inside the class can read the private fields of any instance of that class.

Let me show you with a quick example.

static class Money {
    private final long cents;
    Money(long cents) { this.cents = cents; }
    boolean greaterThan(Money other) { return this.cents > other.cents; }  // other's private field
}
new Money(500).greaterThan(new Money(300));   // true

That's exactly how equals, compareTo and copy constructors are normally written: they read other.field directly, without needing getters.

A related rule: an outer class and its nested classes can read each other's private members too. Before Java 11 the compiler made that work by generating hidden "access$000" bridge methods. Since Java 11 (nestmates, JEP 181) the JVM understands the relationship directly, so no bridge methods are needed.

So in practice this is a feature, not a loophole. Encapsulation protects the class's invariants from other code, and code in the same class is already trusted to maintain them.

Interviewers look for: the words "per class, not per object", and ideally the equals example.

AdvancedCan an overriding method have a different access modifier?

So it can make access wider but never narrower. A protected method can be overridden as public, but a public method can't be overridden as protected, package-private or private.

Let me show you with a quick example.

static class A { public void run() {} }
static class B extends A { void run() {} }
// error: run() in B cannot override run() in A
//   attempting to assign weaker access privileges; was public

The reason is the Liskov substitution principle. Anywhere my code holds an A, it's allowed to call run(). If a B could hide run(), then passing a B where an A is expected would break a promise the type system already made. Widening is fine because it only makes more calls legal.

The classic real example is clone(). It's protected in Object, and classes that want callers to use it override it as public, usually with a covariant return type too.

The same "you can't break the parent's promise" logic explains two other rules: an override can't throw broader checked exceptions, and it can't change the return type except to a subtype.

So in practice I treat any override as a promise to be at least as usable as the parent, and I let @Override and the compiler catch it when I'm not.

10. Inheritance

BasicWhat is inheritance, and what does a subclass not inherit?

So inheritance is an IS-A relationship: a subclass gets the parent's fields and methods and can add or override behaviour. Dog extends Animal means every Dog can be used wherever an Animal is expected.

What a subclass doesn't inherit:

  • Constructors. They're never inherited. The subclass must call one through super(...), explicitly or implicitly.
  • Access to private members. Private fields are physically inside the subclass object, since the memory is allocated for them, but the subclass can't name them. It reaches them only through the parent's methods.
  • Package-private members, when the subclass is in a different package.
  • Static members belong to the class. They're accessible through the subclass name, but they aren't overridden, only hidden.
class Animal {
    private String name;
    protected Animal(String name) { this.name = name; }
    public String name() { return name; }
}
class Dog extends Animal {
    Dog(String name) { super(name); }         // must call the parent constructor
    String greet() { return "Woof, I am " + name(); }   // can't use this.name directly
}

Java supports single inheritance of classes (one extends) and multiple inheritance of types through interfaces. Every class ultimately extends Object.

So in practice I use inheritance only when the IS-A test is genuinely true and I control both classes. For code reuse alone, I prefer composition.

BasicWhy doesn't Java support multiple inheritance of classes?

So it's to avoid the diamond problem and the complexity that comes with it. If a class could extend two classes that both define the same method, or both carry state, the language would need rules for which one wins and how many copies of the shared ancestor's fields exist. Java's designers chose to keep that out.

Let me show you the problem it avoids.

//        Device (int batteryLevel; start())
//        /                      \
//   Camera extends Device    Phone extends Device
//     start() { ... }           start() { ... }
//        \                      /
//         SmartPhone extends Camera, Phone    // not legal Java
//
// new SmartPhone().start()  -> Camera's or Phone's?
// How many batteryLevel fields -- one or two?

C++ allows this and needs "virtual inheritance" to answer the second question. That's exactly the kind of complexity Java wanted to avoid.

What Java does allow is multiple inheritance of type through interfaces. A class can implement many interfaces. Before Java 8 that was trivially safe, because interfaces had no method bodies and no state. Since Java 8, interfaces can have default methods, so a mild diamond can happen with behaviour. But it still can't happen with state, because interfaces can't have instance fields. The compiler forces you to resolve it explicitly.

So in practice the answer is: multiple inheritance of state is banned, multiple inheritance of behaviour is allowed through default methods with explicit conflict resolution, and multiple inheritance of type has always been fine.

Interviewers look for: the three-way split of state, behaviour and type, and a lead-in to how default methods handle the diamond.

IntermediatePredict the output: a field and a method with the same name in parent and child.

So methods are polymorphic but fields are not. A method call is resolved from the real object's class at runtime. A field access is resolved from the reference's declared type at compile time. A child field with the same name doesn't override the parent's. It hides it, and the object ends up holding both.

Let me show you with a quick example.

static class Animal {
    String name = "animal";
    String sound() { return "..."; }
}
static class Dog extends Animal {
    String name = "dog";
    @Override String sound() { return "woof"; }
    void show() { System.out.println(name + " " + super.name + " " + super.sound()); }
}

Animal a = new Dog();
System.out.println(a.name);           // animal   (declared type decides)
System.out.println(a.sound());        // woof     (real object decides)
System.out.println(((Dog) a).name);   // dog
((Dog) a).show();                     // dog animal ...

Walking through it: a is declared as Animal, so a.name reads Animal's field. a.sound() goes through dynamic dispatch and finds Dog.sound. After the cast, the compiler sees a Dog, so it reads Dog's field. Inside show, super.name and super.sound() reach the parent's versions explicitly.

That's the super keyword's three jobs: call the parent constructor, call the parent's version of an overridden method, and reach a hidden field.

So in practice field hiding is always a bug waiting to happen. With private fields and accessor methods it can't occur, which is one more reason fields should be private.

IntermediateComposition vs inheritance. Why do people say "favour composition"?

So inheritance couples my class to the parent's implementation, not just its interface. If the parent calls its own overridable methods internally, my overrides get pulled into that in ways I can't see. Composition, where I hold the other object in a field and forward calls to it, depends only on its public contract.

Let me show you with a quick example. It's the classic one from Effective Java.

static class InstrumentedHashSet<E> extends HashSet<E> {
    int addCount = 0;
    @Override public boolean add(E e) { addCount++; return super.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) {
        addCount += c.size(); return super.addAll(c);
    }
}
var s = new InstrumentedHashSet<String>();
s.addAll(List.of("a", "b", "c"));
System.out.println(s.addCount);   // 6, not 3

static class InstrumentedSet<E> {           // composition
    private final Set<E> inner = new HashSet<>();
    int addCount = 0;
    boolean add(E e) { addCount++; return inner.add(e); }
    boolean addAll(Collection<? extends E> c) { addCount += c.size(); return inner.addAll(c); }
}
// same calls -> addCount is 3

Why 6? HashSet.addAll, inherited from AbstractCollection, loops and calls add for each element. That's my overridden add, so every element is counted twice. Nothing in my code shows that. It depends on an internal detail of the JDK that could change in any release.

The version with composition doesn't care how HashSet works internally. It can also wrap any Set, not just HashSet, if I take the set in the constructor.

InheritanceComposition
RelationshipIS-AHAS-A
CouplingTo the parent's implementationTo the public API only
Change at runtimeNo, fixed at compile timeYes, swap the field
Good forTrue subtypes, template methods in my own hierarchyReuse, decorators, strategies, wrapping third-party classes

So in practice I inherit only from classes designed for it, usually my own abstract classes, and I compose everything else.

Interviewers look for: the concrete "self-use" failure, not just the slogan. Being able to explain why it prints 6 is the whole answer.

IntermediateWhat happens if the parent class has no no-arg constructor?

So the subclass must call one of the parent's constructors explicitly with super(...). If it doesn't, the compiler inserts super(), finds no matching constructor, and fails.

static class Animal { Animal(String name) {} }

static class Dog extends Animal {
    Dog() { System.out.println("dog"); }
}
// error: constructor Animal in class Animal cannot be applied to given types;
//   required: String

static class Cat extends Animal {
    Cat() { super("cat"); }      // fixed
}

The same thing happens with no constructor at all in Dog: the default constructor also contains super(), so it fails too. The error points at the subclass even though the "cause" is the parent's design, which confuses people the first time.

I actually like this rule. A parent that requires a name is saying "an animal without a name is invalid", and the compiler forces every subclass to respect that.

So in practice, when I see this error, the fix is almost never to add a no-arg constructor to the parent. It's to pass the required values through super(...).

AdvancedHow do you stop a class from being extended? Where do sealed classes fit in?

So there are three levels. final stops all extension. A private constructor also stops it from outside, since a subclass can't call super(). And since Java 17, sealed allows extension only by a fixed list of classes I name.

static class MyString extends String {}
// error: cannot inherit from final String

sealed interface Shape permits Circle, Square, Rect {}
record Circle(double r) implements Shape {}         // records are implicitly final
record Square(double side) implements Shape {}
static non-sealed class Rect implements Shape { double w = 2, h = 3; }  // reopened on purpose

static double area(Shape s) {
    return switch (s) {                 // no default needed: compiler knows every case
        case Circle c -> Math.PI * c.r() * c.r();
        case Square q -> q.side() * q.side();
        case Rect r   -> r.w * r.h;
    };
}
area(new Square(4));   // 16.0

Each permitted subclass must pick one of final, sealed or non-sealed, so the hierarchy is deliberately closed or deliberately opened at every step.

The real payoff of sealed is the exhaustive switch (pattern matching for switch was finalised in Java 21). If I later add Triangle to the permits list, every switch without a Triangle case stops compiling. That's much better than a default branch that silently swallows the new type.

Why stop extension at all? Security and correctness. String is final so that nobody can pass a "String" that changes after it's been validated. And a class not designed for inheritance breaks in the way the HashSet counting example shows.

So in practice I make classes final by default, use sealed for domain types with a known set of variants (payment results, events, AST nodes), and open a class up only when I've designed it for extension.

Interviewers look for: the connection between sealed types and exhaustive switch in Java 21, not just the syntax.

11. Polymorphism

BasicWhat is polymorphism? Compile-time vs runtime polymorphism?

So polymorphism means one name, many behaviours. Compile-time polymorphism is method overloading: the compiler picks which method to call from the argument types. Runtime polymorphism is method overriding: the JVM picks the implementation from the actual object's class while the program runs.

Let me show you with a quick example.

// Compile-time: chosen by argument types
static int area(int side)          { return side * side; }
static int area(int w, int h)      { return w * h; }

// Runtime: chosen by the real object
interface Notifier { void send(String msg); }
class EmailNotifier implements Notifier { public void send(String m) { /* SMTP */ } }
class SmsNotifier   implements Notifier { public void send(String m) { /* SMS gateway */ } }

Notifier n = user.prefersSms() ? new SmsNotifier() : new EmailNotifier();
n.send("Your order shipped");   // which send() runs is decided at runtime

The runtime version is the one that matters for design. The code that calls n.send is written once against the interface, and new notifier types plug in without changing it. That's the open/closed principle in practice, and it's how Spring injects different beans behind the same interface.

Some people call overloading "not real polymorphism", because nothing is decided at runtime. It's just the compiler choosing between different method names that happen to look the same. I'd mention that, but still call it static polymorphism, since that's the standard interview terminology.

So in practice when I say "polymorphism" in a design discussion, I mean programming to an interface and letting dynamic dispatch pick the implementation.

BasicOverloading vs overriding: what are the differences?

So overloading is the same method name with a different parameter list, usually in the same class, resolved at compile time. Overriding is a subclass replacing an inherited method with the same signature, resolved at runtime.

OverloadingOverriding
WhereSame class, or inherited plus newSubclass of the declaring class
ParametersMust differ (number, type or order)Must be the same
Return typeAnything; doesn't count toward the signatureSame, or a subtype (covariant)
Access modifierAnythingSame or wider
Checked exceptionsAnythingSame, narrower, or none
ResolvedCompile time, from declared typesRuntime, from the object's class
static / private / finalCan be overloadedCannot be overridden
class Printer {
    void print(String s)           { }
    void print(String s, int copies) { }   // overload
}
class ColorPrinter extends Printer {
    @Override void print(String s) { }      // override
}

One trap: changing only the return type isn't an overload. int size() and long size() in the same class is a compile error, because the compiler couldn't tell which one a call like size(); means.

So in practice I always put @Override on overrides. If I get the signature slightly wrong, say equals(Point p) instead of equals(Object o), I've accidentally overloaded instead of overridden, and the annotation turns that silent bug into a compile error.

Interviewers look for: return type and exception rules, and the equals(Point) accidental-overload trap.

IntermediateCan you override a static method? A private method? Predict the output.

So no to both. A static method with the same signature in a subclass hides the parent's method, and the call is picked from the reference's declared type. A private method isn't visible to the subclass at all, so a same-named method in the child is a brand-new, unrelated method.

Let me show you with a quick example.

static class Parent {
    static String who() { return "Parent.static"; }
    private String secret() { return "Parent.private"; }
    String callSecret() { return secret(); }
    String hello() { return "Parent.hello"; }
}
static class Child extends Parent {
    static String who() { return "Child.static"; }        // hides
    private String secret() { return "Child.private"; }   // unrelated new method
    @Override String hello() { return "Child.hello"; }     // overrides
}

Parent p = new Child();
System.out.println(p.who());          // Parent.static
System.out.println(p.hello());        // Child.hello
System.out.println(p.callSecret());   // Parent.private

Walking through it: p.who() compiles to Parent.who(), because static calls are bound to the declared type. The object is ignored. p.hello() is a real override, so the JVM dispatches to Child. callSecret lives in Parent and calls Parent's private method directly. Private methods aren't virtual, so Child.secret never gets a chance.

If you put @Override on either the static or the private method in Child, the compiler rejects it. That's the quickest proof that it isn't overriding. Also, calling a static method through an instance, like p.who(), compiles but IDEs warn about it for exactly this reason: it looks polymorphic and isn't.

So in practice I always call static methods through the class name, and I treat a matching private method name in a subclass as a coincidence.

Interviewers look for: the word "hiding", and the explanation that static binding uses the reference type.

IntermediatePredict the output: overloading with null, widening, boxing and varargs.

So the compiler picks an overload in three phases. First it tries exact match and widening, with no boxing and no varargs. Then it allows boxing and unboxing. Only then does it consider varargs. Within a phase, the most specific type wins.

Let me show you with a quick example.

static void print(Object o)  { System.out.println("Object"); }
static void print(String s)  { System.out.println("String"); }
static void take(long x)     { System.out.println("long"); }
static void take(Integer x)  { System.out.println("Integer"); }
static void take(int... x)   { System.out.println("varargs"); }

print(null);          // String   -- most specific: String is a subtype of Object
take(5);              // long     -- widening (phase 1) beats boxing (phase 2)
Object o = "hello";
print(o);             // Object   -- decided by the declared type, not the value
char c = 'a';
take(c);              // long     -- char widens to long

Walking through it: for print(null), both overloads accept null, and String is more specific than Object, so it wins. For take(5), most people guess Integer, but widening int to long is found in phase 1, so boxing is never even considered. Varargs is always the last resort. For print(o), overloading is compile-time, so the declared type Object decides, even though the value is a String.

And if the two candidates are unrelated, like print(String) and print(StringBuilder), then print(null) doesn't compile: "reference to print is ambiguous".

A real bug from this: List<Integer> has both remove(int index) and remove(Object o). list.remove(1) removes the element at index 1, not the value 1. You need list.remove(Integer.valueOf(1)).

So in practice I avoid overloads that differ only by boxed vs primitive or by related types. Different names are clearer.

Interviewers look for: the order "widening, then boxing, then varargs", and the List.remove example as a real consequence.

IntermediateWhat are covariant return types?

So since Java 5, an overriding method can declare a return type that's a subtype of the parent's return type. The override still satisfies the parent's contract, since a Dog is an Animal, but callers who know they have the subclass get the more specific type without a cast.

Let me show you with a quick example.

static class Animal {
    Animal copy() { return new Animal(); }
}
static class Dog extends Animal {
    @Override Dog copy() { return new Dog(); }   // narrower return type
    void bark() { System.out.println("woof"); }
}

Dog d = new Dog();
d.copy().bark();     // woof -- no (Dog) cast needed

Before Java 5 the override had to return Animal, and every caller wrote ((Dog) d.copy()).bark(). The classic real use is clone(): Object.clone() returns Object, but an overriding public Order clone() can return Order. Fluent builders in a class hierarchy use it too, so SubBuilder.name(...) returns SubBuilder and the chain keeps going.

Under the hood, the compiler generates a synthetic bridge method in Dog with the old signature Animal copy() that calls the new one. That's how the JVM, which matches methods by exact descriptor, still finds the override.

It only works for reference types. You can't narrow long to int, because primitives don't have a subtype relationship.

So in practice I use it for clone, copy methods and builders, anywhere returning the more specific type saves callers a cast.

AdvancedWhat are the exception rules when overriding a method?

So an override may throw the same checked exceptions, narrower ones, or none at all. It may not add new or broader checked exceptions. Unchecked exceptions aren't restricted.

Let me show you with a quick example.

static class Reader1 { void read() throws IOException {} }
static class Ok1 extends Reader1 { @Override void read() throws FileNotFoundException {} } // narrower
static class Ok2 extends Reader1 { @Override void read() {} }                             // none
static class Ok3 extends Reader1 { @Override void read() throws IllegalStateException {} } // unchecked
static class Bad extends Reader1 { @Override void read() throws Exception {} }
// error: read() in Bad cannot override read() in Reader1
//   overridden method does not throw Exception

The reason is the same substitution argument as with access modifiers. Code written against Reader1 has a catch (IOException e) and nothing else. If a subclass could throw Exception, that caller would get a checked exception it was never forced to handle.

This rule has a practical consequence I've hit in real code. When I implement an interface like Runnable, whose run() declares no exceptions, I can't let a checked exception escape. I have to catch it or wrap it in an unchecked one. That's why Callable.call() declares throws Exception: it was designed for tasks that can fail with checked exceptions.

Constructors are the opposite case. They aren't overridden, so a subclass constructor must declare everything its super(...) call can throw, and it may add more.

So in practice when I design an interface method, I think carefully about the throws clause, because it caps what every implementation can ever throw.

AdvancedHow does the JVM actually decide which overridden method to call?

So it's done in two steps. At compile time, javac picks the method signature from the declared type, which also settles any overloading, and emits an invokevirtual (or invokeinterface) instruction for that signature. At runtime, the JVM looks at the actual object's class and finds the most specific implementation of that signature, usually through a per-class method table called the vtable.

Animal a = new Dog();
a.sound();
// javac:   invokevirtual Animal.sound()Ljava/lang/String;   (signature fixed now)
// runtime: a's class is Dog -> Dog's vtable slot for sound() -> Dog.sound()

Each class's vtable starts as a copy of its parent's, and overridden methods replace their slot. So a virtual call is basically "load the object's class pointer, index into the table, jump". That's cheap.

The JIT makes it even cheaper. If profiling shows a call site only ever sees one class, which is called monomorphic, HotSpot inlines the method with a quick type guard. With two classes (bimorphic), it inlines both. Only with many classes (megamorphic) does it fall back to the table lookup. So in hot code, "virtual calls are slow" is mostly a myth.

This also explains the earlier puzzles. Static and private methods use invokestatic and invokespecial, which have no runtime lookup, so they can't be overridden. Fields have no vtable at all, so they're resolved from the declared type.

So in practice I don't avoid interfaces for performance. The JIT removes most of the dispatch cost, and when I suspect a megamorphic hot spot, I measure with JMH first.

Interviewers look for: the compile-time/runtime split (signature chosen statically, implementation chosen dynamically) and some awareness of JIT inlining.

12. Abstract Classes vs Interfaces

BasicWhat's the difference between an abstract class and an interface in modern Java?

So an abstract class is a partially built class. It can have state, constructors and any kind of method, and a class can extend only one. An interface is a contract. Since Java 8 it can have default and static methods, and since Java 9 private methods, but it still can't have instance fields or constructors, and a class can implement as many as it wants.

Abstract classInterface
Instance fields (state)YesNo: only public static final constants
ConstructorsYesNo
Method bodiesYes, any accessdefault and static (8), private (9)
Method accessAnyPublic, except private helpers
How many per classOne (extends)Many (implements)
RelationshipIS-A, shared implementationCAN-DO, a capability
Can be a lambda targetNoYes, if it has one abstract method

The big thing default methods didn't change is state. An interface still can't hold per-object data. So if my shared code needs fields, like a logger name, a counter or cached config, it has to live in an abstract class.

So in practice: an interface for the type that callers depend on, and optionally an abstract class that implements it to share code between implementations. The JDK does exactly this with List and AbstractList.

Interviewers look for: an answer updated for Java 8/9, not the pre-2014 "interfaces have no method bodies" line, and "state" as the real remaining difference.

BasicCan an abstract class have a constructor? Can you instantiate one?

So yes, it can have a constructor, and no, you can't call new on it directly. The constructor runs when a subclass is created, through super(...), and its job is to initialise the abstract class's own fields.

Let me show you with a quick example.

static abstract class Report {
    private final String title;
    protected Report(String title) { this.title = title; System.out.println("Report ctor: " + title); }
    abstract String body();
    final String render() { return "== " + title + " ==\n" + body(); }   // template method
}
static class SalesReport extends Report {
    SalesReport() { super("Sales"); }
    String body() { return "Total: 1200"; }
}

new SalesReport().render();
// Report ctor: Sales
// == Sales ==
// Total: 1200

Report quick = new Report("Adhoc") { String body() { return "anonymous subclass"; } };
// Report ctor: Adhoc  -- this is an anonymous subclass, not an instance of Report itself

I make the constructor protected, since only subclasses can use it anyway. The new Report("Adhoc") { ... } line looks like it instantiates the abstract class, but it actually creates an anonymous subclass and instantiates that.

Also, an abstract class doesn't need any abstract methods. Marking a class abstract just means "don't instantiate me directly". And a class with even one abstract method must be declared abstract.

So in practice this is the template method pattern: the abstract class fixes the algorithm in a final method, holds the shared state, and subclasses fill in the steps.

IntermediateWhy were default methods added in Java 8?

So the main reason was interface evolution. Java 8 needed to add methods like stream() and forEach() to Collection and Iterable. Without default methods, every class in the world implementing Collection, including thousands in third-party libraries, would have stopped compiling.

Let me show you with a quick example.

// Simplified from the JDK
public interface Collection<E> extends Iterable<E> {
    // ...existing abstract methods...
    default Stream<E> stream() {
        return StreamSupport.stream(spliterator(), false);
    }
    default boolean removeIf(Predicate<? super E> filter) {
        boolean removed = false;
        for (Iterator<E> it = iterator(); it.hasNext(); ) {
            if (filter.test(it.next())) { it.remove(); removed = true; }
        }
        return removed;
    }
}

A default method is written purely in terms of the interface's other methods, like iterator() and spliterator(). So old implementations get it for free, and classes that can do better, like ArrayList with its own faster removeIf, override it.

The second reason is that it let the JDK add combinator methods to functional interfaces: Comparator.thenComparing, Predicate.and, Function.andThen. Those are what make lambdas pleasant to compose.

What default methods are not for is sneaking state or heavy logic into interfaces. They can't have fields, and if you find yourself writing complicated default methods, you probably want an abstract class.

So in practice I use default methods for backward-compatible API evolution and small convenience methods, and I keep them thin.

IntermediateWhat is the diamond problem with default methods, and how does Java resolve it?

So if a class implements two interfaces that both provide a default method with the same signature, the class doesn't compile until it overrides the method itself. Inside the override, it can call a specific parent with InterfaceName.super.method().

Let me show you with a quick example.

interface Camera { default String start() { return "camera on"; } }
interface Phone  { default String start() { return "phone on"; } }

static class SmartPhone implements Camera, Phone {}
// error: types Camera and Phone are incompatible;
//   class SmartPhone inherits unrelated defaults for start() from types Camera and Phone

static class SmartPhone implements Camera, Phone {
    @Override public String start() {
        return Camera.super.start() + " + " + Phone.super.start();
    }
}
new SmartPhone().start();   // camera on + phone on

There are two automatic rules before it ever gets to an error:

  1. Class wins. A method from a superclass always beats an interface default.
  2. More specific interface wins. If one interface extends the other, the sub-interface's default wins.
static class Device { public String start() { return "device on"; } }
static class Tablet extends Device implements Camera {}
new Tablet().start();       // device on   (class wins)

interface Base    { default String id() { return "Base"; } }
interface Special extends Base { default String id() { return "Special"; } }
static class Gadget implements Base, Special {}
new Gadget().id();          // Special     (more specific wins)

Only when neither rule settles it do you get the compile error. The key point is that Java never picks silently. Either there's a clear winner, or you decide.

So in practice this rarely bites, but when it does, I override the method and delegate explicitly to whichever parent I want, or combine them.

Interviewers look for: the exact X.super.method() syntax and the two rules, "class wins" and "more specific interface wins".

IntermediateAfter Java 8, when would you still choose an abstract class over an interface?

So I'd choose an abstract class when the shared code needs state, a constructor, non-public methods, or when I want to lock down a template method with final. Everywhere else I default to an interface, because a class can implement many interfaces but extend only one class.

My decision checklist:

I need...Use
A type that unrelated classes can adopt (Comparable, AutoCloseable)Interface
A lambda targetFunctional interface
Shared fields, like a repository or metrics referenceAbstract class
A constructor that enforces required valuesAbstract class
protected hooks or a final algorithm skeletonAbstract class
Both: a public contract plus shared codeInterface + abstract base class implementing it
public interface PaymentGateway { Receipt charge(Money amount); }  // what callers depend on

public abstract class BaseGateway implements PaymentGateway {    // what implementers reuse
    protected final HttpClient http;
    protected BaseGateway(HttpClient http) { this.http = http; }
    public final Receipt charge(Money amount) {                   // fixed flow
        validate(amount);
        return doCharge(amount);
    }
    protected abstract Receipt doCharge(Money amount);
    private void validate(Money m) { /* ... */ }
}

Callers and Spring wiring only see PaymentGateway. A new provider can extend BaseGateway for free plumbing, or implement the interface directly if it's completely different. It can also be mocked easily in tests.

So in practice the interface is the API and the abstract class is an optional implementation helper. I never make callers depend on the abstract class.

AdvancedWhat can an interface contain today? Explain static and private interface methods.

So a Java 21 interface can have abstract methods, default methods, static methods (Java 8), private and private static methods (Java 9), constants, and nested types. It still can't have instance fields, constructors, or protected members.

Let me show you with a quick example.

interface Validator {
    boolean validate(String s);                                   // abstract

    default Validator and(Validator other) {                      // Java 8
        return s -> validate(s) && other.validate(s);
    }
    static Validator notBlank() { return s -> !s.isBlank(); }     // Java 8 factory

    static Validator maxLen(int n) {
        return s -> log("checking " + s) && s.length() <= n;
    }
    private static boolean log(String m) {                         // Java 9 helper
        System.out.println(m); return true;
    }
}

Validator v = Validator.notBlank().and(Validator.maxLen(5));
v.validate("hello");   // prints "checking hello", returns true
v.validate("   ");     // returns false; short-circuits before maxLen runs

Static interface methods are for factories and utilities that belong with the type, like List.of, Comparator.comparing and Map.entry. Before Java 8 these lived in separate companion classes like Collections. One catch: static interface methods aren't inherited, so you must call Validator.notBlank(), never someImpl.notBlank().

Private methods exist so default methods can share code without making the helper part of the public API.

And since Java 17, interfaces can be sealed, which, combined with records, is how I model closed sets of alternatives.

So in practice a well-designed interface today often holds its contract, a few default combinators, and static factories, all in one place.

Interviewers look for: the correct version numbers (8 for default/static, 9 for private) and knowing static interface methods aren't inherited.

13. The Object Class: equals, hashCode, toString

BasicWhat methods does Object have, and which ones do you usually override?

So Object gives every class equals, hashCode, toString, getClass, clone, finalize, and the monitor methods wait, notify and notifyAll. The ones I override regularly are equals, hashCode and toString.

MethodDefault behaviourOverride?
equalsIdentity, same as ==For value objects
hashCodeIdentity-based numberAlways together with equals
toStringClassName@hexHashAlmost always; it's for logs and debugging
cloneProtected, shallow field copy, needs CloneableRarely; prefer copy constructors
finalizeDoes nothingNever; deprecated in Java 9, for removal since 18
getClassRuntime classCan't (it's final)
wait / notify / notifyAllLow-level thread coordinationCan't (final); prefer java.util.concurrent
System.out.println(new User());   // DefaultToString$User@ followed by a hex hash -- useless in a log

On finalize: it was meant for cleanup, but it runs at an unpredictable time, maybe never, and slows down garbage collection. For cleanup I use try-with-resources and AutoCloseable, or java.lang.ref.Cleaner (Java 9) as a safety net.

So in practice for data classes I don't write these by hand any more. A record generates equals, hashCode and toString correctly.

BasicWhat's the difference between == and equals()?

So == on references checks identity: are these the same object in memory? equals() checks logical equality: do these objects mean the same thing? It's whatever the class defines. For primitives, == just compares values.

String a = new String("java");
String b = new String("java");
a == b;          // false  two objects
a.equals(b);     // true   same characters

Integer x = 127, y = 127;
x == y;          // true   cached by Integer.valueOf (-128..127)
Integer p = 128, q = 128;
p == q;          // false  outside the cache: two objects
p.equals(q);     // true

The Integer case is the nasty one. Code that compares boxed IDs with == passes every test with small numbers and then fails in production once IDs pass 127. I've seen exactly this with Long user IDs coming out of a JPA entity.

If a class doesn't override equals, it inherits Object.equals, which is just ==. So equals is only "logical" if someone wrote it that way.

For a null-safe comparison I use Objects.equals(a, b). It returns true if both are null and doesn't throw if a is null.

So in practice: == for primitives, enums, and deliberate identity checks. equals for everything else.

Interviewers look for: the Integer cache trap as a real-world consequence, and Objects.equals for null safety.

IntermediateWhat is the equals/hashCode contract? What breaks if you override only one?

So the contract is: if two objects are equals, they must have the same hashCode. The reverse isn't required, since collisions are allowed. equals must also be reflexive, symmetric, transitive, consistent, and return false for null. Hash-based collections rely on this: they use hashCode to find the bucket first, and only then call equals inside that bucket.

Let me show you what breaks when you override only equals.

static class Point {                     // equals but NO hashCode
    final int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }
    @Override public boolean equals(Object o) {
        return o instanceof Point p && p.x == x && p.y == y;
    }
}
Set<Point> set = new HashSet<>();
set.add(new Point(1, 2));
new Point(1, 2).equals(new Point(1, 2));   // true
set.contains(new Point(1, 2));             // false
set.add(new Point(1, 2));
set.size();                                // 2  -- "duplicate" in a Set

Walking through it: the two points are equal, but each one uses the identity hash from Object, so they land in different buckets. The set never even calls equals. That's why contains says false and the set holds two "equal" elements. As a HashMap key, you'd put a value and then fail to get it back with an equal key.

The other way round, overriding only hashCode, is legal but pointless. Equal-looking objects land in the same bucket, but equals is still identity, so they're still treated as different. Adding two new HashOnly(1) objects to a set gives size 2.

@Override public int hashCode() { return Objects.hash(x, y); }   // the fix

So in practice I always override both together, generated from the same fields, and for plain data I use a record so I can't get it wrong.

Interviewers look for: "bucket first, then equals" as the mechanism, and the concrete symptom: contains false, duplicates in a Set, lost map entries.

IntermediateWrite a correct equals and hashCode. Should you use instanceof or getClass()?

So a correct equals checks identity, then type, then compares the significant fields, and hashCode uses exactly the same fields. On instanceof vs getClass(): instanceof is right for final classes, while getClass() is safer when subclasses may add fields, because instanceof can break symmetry.

Let me show you with a quick example.

static final class Employee {
    private final String id;
    private final String email;
    Employee(String id, String email) { this.id = id; this.email = email; }

    @Override public boolean equals(Object o) {
        if (this == o) return true;                          // fast path
        if (!(o instanceof Employee other)) return false;    // Java 16 pattern; handles null too
        return id.equals(other.id) && Objects.equals(email, other.email);
    }
    @Override public int hashCode() { return Objects.hash(id, email); }
    @Override public String toString() { return "Employee[id=" + id + ", email=" + email + "]"; }
}

The class is final, so instanceof is safe. Here's what goes wrong without that:

// Point.equals uses instanceof Point; ColorPoint extends Point and adds color
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, "red");
p.equals(cp);   // true   -- cp is an instanceof Point
cp.equals(p);   // false  -- p is not a ColorPoint
// symmetry broken: a HashSet may or may not treat them as duplicates

There's no way to extend an instantiable class, add a value field, and keep the equals contract with instanceof. getClass() != o.getClass() avoids the asymmetry, but then a ColorPoint is never equal to a plain Point, and it breaks Hibernate proxies, which are subclasses.

So in practice I make value classes final (or records) and use instanceof. For JPA entities I compare by ID only, handle proxies, and keep hashCode constant or ID-based. For everything else I let the IDE or a record generate it.

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

So the entry gets lost. The map stored it in a bucket based on the old hash. After the mutation, lookups compute the new hash and go to a different bucket, and a lookup with the old value finds the right bucket but fails equals. The entry is still there, taking memory, but it's unreachable by key.

Let me show you with a quick example.

static class Key {
    String name;
    Key(String n) { name = n; }
    @Override public boolean equals(Object o) { return o instanceof Key k && k.name.equals(name); }
    @Override public int hashCode() { return name.hashCode(); }
}

Map<Key, Integer> map = new HashMap<>();
Key k = new Key("alice");
map.put(k, 1);
k.name = "bob";

map.get(k);                  // null  -- looks in bob's bucket
map.get(new Key("alice"));   // null  -- right bucket, but equals fails ("bob" vs "alice")
map.size();                  // 1     -- the entry is stuck in there

This is a real way to get a memory leak. If the code keeps putting "new" keys and mutating them, the map grows forever and nothing can remove the old entries by key.

It's also the strongest argument for making keys immutable. String, Integer, enums and records with immutable components are all safe keys. The same rule applies to elements of a HashSet and to the fields a TreeMap comparator reads.

So in practice I only use immutable objects as keys. If a domain object is mutable, I key the map by its ID instead of the object.

Interviewers look for: that the entry still exists (size is 1) but is unreachable, and "keys must be immutable" as the fix.

IntermediateShallow copy vs deep copy: what's the difference, and how does clone() behave?

So a shallow copy creates a new object but copies references as they are, so nested objects are shared between the original and the copy. A deep copy also copies the nested mutable objects, so the two are fully independent. Object.clone() does a shallow copy.

Let me show you with a quick example.

static class Order implements Cloneable {
    String id;
    List<String> items = new ArrayList<>();
    Order(String id) { this.id = id; }

    @Override public Order clone() {                  // covariant return
        try { return (Order) super.clone(); }         // shallow
        catch (CloneNotSupportedException e) { throw new AssertionError(e); }
    }
    Order deepCopy() {
        Order o = new Order(id);
        o.items = new ArrayList<>(items);             // new list, same strings
        return o;
    }
}

Order original = new Order("A1");
original.items.add("pen");
Order shallow = original.clone();
Order deep = original.deepCopy();
shallow.items.add("book");

original.items;                    // [pen, book]  -- shared list changed
deep.items;                        // [pen]
original.items == shallow.items;   // true

Walking through it: super.clone() copies each field's value. For items, the value is a reference, so both orders point at one list. The deep copy creates a new list. The strings inside are still shared, but that's fine because strings are immutable. A deep copy only needs to go as far down as the mutable parts.

So in practice I don't use clone(). Cloneable is a marker interface with no clone method, super.clone() skips constructors, and it doesn't work with final fields that need new values. I use a copy constructor or a static copyOf factory, and immutable objects where possible, since those never need copying at all.

AdvancedWhat makes a good hashCode, and why does Objects.hash or 31 * h show up everywhere?

So a good hashCode is consistent with equals, cheap to compute, and spreads different objects across many values so buckets stay short. The 31 * h + field pattern mixes each field in, so that field order and every field affect the result.

@Override public int hashCode() {
    int h = id.hashCode();
    h = 31 * h + Objects.hashCode(email);   // null-safe
    h = 31 * h + Integer.hashCode(age);
    return h;
}
// Same idea as Objects.hash(id, email, age), without the varargs array

Why 31? It's an odd prime, so multiplying doesn't lose bits the way an even number would, and the JIT can compute 31 * h as (h << 5) - h. It's what String.hashCode uses.

What makes a bad one:

  • return 1; is legal, but every entry lands in one bucket. Since Java 8 HashMap turns long buckets into trees, so lookups become O(log n) instead of O(n), but that's still much slower than O(1).
  • Using a mutable field means the entry gets lost when that field changes.
  • Including a field that equals ignores breaks the contract: equal objects get different hashes.

Objects.hash is convenient but allocates a varargs array and boxes primitives on every call. On a hot path I write it out by hand, or cache it in a field for immutable objects, the way String does.

So in practice I generate it from exactly the fields used in equals, and for records I let the compiler do it.

Interviewers look for: "same fields as equals", awareness of the treeified-bucket fallback, and the cost of Objects.hash on hot paths.

14. Nested, Inner & Anonymous Classes

BasicWhat kinds of nested classes does Java have?

So there are four: static nested classes, inner classes (non-static members), local classes declared inside a method, and anonymous classes. The first question to ask about any of them is whether it holds a hidden reference to an instance of the outer class. Only the static nested class never does.

KindDeclaredHas outer instance?Typical use
Static nestedstatic class Node inside a classNoBuilders, map entries, helper types that belong with the outer class
Innerclass Itr inside a classYesIterators and views that need the outer object's data
LocalInside a method bodyYes, if in an instance methodRare; small helper used in one method
Anonymousnew Interface() { ... }Yes, if in an instance methodOne-off implementations; mostly replaced by lambdas
public class LinkedList<E> {
    private static class Node<E> { E item; Node<E> next; }   // static nested: no outer needed
    private class Itr implements Iterator<E> {               // inner: reads the list's head
        Node<E> cursor = head;
        public boolean hasNext() { return cursor != null; }
        public E next() { E e = cursor.item; cursor = cursor.next; return e; }
    }
    private Node<E> head;
}

The JDK's own collections follow exactly this split. HashMap.Node is static nested, while ArrayList's iterator is an inner class because it needs the list's array and modCount.

So in practice my default is static for any nested class. I drop the static only when the nested class genuinely needs the outer instance.

IntermediateStatic nested class vs inner class: how are they created, and what can each access?

So a static nested class is basically a top-level class that lives inside another for namespacing and access. You create it without any outer object. An inner class is tied to one outer instance, so you need that instance to create it, and it can read the outer object's fields directly, including private ones.

Let me show you with a quick example.

public class Nested {
    private int value = 10;
    static class StaticNested { int get() { return 1; } }
    class Inner { int get() { return value; } }        // reads Nested.this.value

    public static void main(String[] args) {
        StaticNested sn = new Nested.StaticNested();   // no outer object
        Nested outer = new Nested();
        Nested.Inner in = outer.new Inner();           // unusual syntax: outer.new
        System.out.println(sn.get() + " " + in.get()); // 1 10
    }
}

The outer.new Inner() syntax is the giveaway. The compiler passes outer into the inner class's constructor as a hidden parameter and stores it in a synthetic field. Inside Inner, value is really Nested.this.value.

A static nested class can still access the outer class's private static members, and the private instance members of any outer object you hand it. It just doesn't have an implicit one.

Before Java 16, inner classes couldn't declare static members. JEP 395, which delivered records, relaxed that, so now they can.

So in practice, if I see outer.new Inner() in a codebase, I check whether Inner really uses the outer instance. Usually it doesn't, and it should be static.

IntermediateHow can an inner class cause a memory leak?

So every inner class instance holds a hidden strong reference to its outer object. If the inner object outlives the outer one, for example because it's registered as a listener, submitted to a long-running executor, or stored in a static cache, it keeps the entire outer object and everything the outer object references alive.

Let me show you with a quick example, using reflection to show the hidden field.

public class InnerLeak {
    private final byte[] bigBuffer = new byte[50_000_000];
    class Task implements Runnable {
        public void run() { System.out.println("buffer " + bigBuffer.length); }
    }
    class Quiet implements Runnable {
        public void run() { System.out.println("quiet"); }
    }
    static class StaticTask implements Runnable {
        public void run() { System.out.println("static"); }
    }
}
// Declared fields, via reflection:
// Task: this$0/InnerLeak     <- hidden reference to the outer object
// Quiet:                     <- none: since JDK 18, javac drops it if unused
// StaticTask:                <- never has one

So if a Task gets stuck in a queue or a static list, it pins 50 MB through this$0, even though the code that created it is long gone. A heap dump shows exactly this chain: static list, then the Task, then this$0, then the huge outer object.

The classic real case is Android, where an inner Handler or AsyncTask held on to an Activity after rotation. On the server side, I've seen anonymous listeners registered on a singleton event bus from a request-scoped object and never unregistered. Each request leaked its whole object graph.

A modern detail: since JDK 18, javac doesn't emit this$0 for inner classes that never use the outer instance. That's the Quiet line. But you shouldn't rely on it, and anonymous classes or lambdas that do use outer fields still capture it.

So in practice I make nested classes static by default, pass in only the data they need, and always unregister listeners in a close() or lifecycle method.

Interviewers look for: the hidden this$0 reference, a concrete "outlives the outer object" scenario, and "make it static" as the fix.

IntermediateAnonymous class vs lambda: what's the difference? Predict the output.

So a lambda is not just shorter syntax for an anonymous class. An anonymous class creates a new class with its own this, and it can implement interfaces with several methods or extend classes. A lambda can only implement a functional interface, has no this of its own (this means the enclosing object), and is compiled with invokedynamic instead of a separate .class file.

Let me show you with a quick example.

public class ThisLambda {
    @Override public String toString() { return "outer"; }
    void demo() {
        Runnable anon = new Runnable() {
            @Override public void run() { System.out.println(this); }
            @Override public String toString() { return "anonymous"; }
        };
        Runnable lambda = () -> System.out.println(this);
        anon.run();     // anonymous
        lambda.run();   // outer
    }
}
Anonymous classLambda
thisThe anonymous instanceThe enclosing instance
TargetAny interface or classFunctional interface only
StateCan have fieldsNo fields
Compiled toOuter$1.classinvokedynamic + a private method
Captures outer thisIn instance context (javac may drop it if unused, JDK 18+)Only if it uses it
ShadowingCan redeclare outer variable namesCan't

Both can only capture local variables that are effectively final.

So in practice I use lambdas for functional interfaces and reach for an anonymous class only when I need state, several methods, or an abstract class. Those cases are rare now.

AdvancedWhy must local variables captured by a lambda or anonymous class be effectively final?

So a lambda doesn't capture the variable itself. It captures a copy of its value at the moment it's created. If Java allowed the local to change afterwards, the copy and the original would silently disagree. Requiring it to be effectively final guarantees they never can.

Let me show you with a quick example.

int count = 0;
Runnable r = () -> System.out.println(count);
count++;
// error: local variables referenced from a lambda expression
//        must be final or effectively final

Why a copy? Local variables live on the stack frame of the method, and that frame disappears when the method returns. But the lambda might run later, on another thread, long after the method is gone. So the value is copied into the lambda object. Once it's a copy, allowing writes would give confusing semantics and data races, since two threads would think they share a variable they don't actually share.

Fields are different. A lambda that uses this.count captures this, not a copy of the field, so it can read and write the field. That's also why it can be a thread-safety problem.

The workaround people use is the one-element array or an AtomicInteger:

final int[] counter = {0};
Runnable inc = () -> counter[0]++;   // the reference is final; the array contents aren't
inc.run(); inc.run();
counter[0];                          // 2

It compiles, but it's usually a sign I should use a stream reduction or a proper accumulator instead. With threads, it's a race condition waiting to happen unless I use AtomicInteger.

So in practice I treat the compile error as a hint that my code is mutating shared state from inside a lambda, and I restructure it.

Interviewers look for: "captures the value, not the variable", and why fields behave differently.

AdvancedImplement a Builder using a static nested class. Why static?

So a Builder is a mutable helper that collects the parameters and then creates an immutable object in one step. It's a static nested class because a builder must exist before the object it builds. An inner class would need an outer instance, and that instance doesn't exist yet.

Let me show you with a quick example.

static final class HttpRequest {
    private final String url; private final String method; private final int timeoutMs;
    private HttpRequest(Builder b) { url = b.url; method = b.method; timeoutMs = b.timeoutMs; }
    static Builder builder(String url) { return new Builder(url); }
    public String toString() { return method + " " + url + " (" + timeoutMs + "ms)"; }

    static final class Builder {
        private final String url;                 // required
        private String method = "GET";            // optional, with defaults
        private int timeoutMs = 5000;
        private Builder(String url) { this.url = url; }
        Builder method(String m) { method = m; return this; }
        Builder timeout(int ms) { timeoutMs = ms; return this; }
        HttpRequest build() { return new HttpRequest(this); }   // validate here
    }
}

HttpRequest.builder("/orders").method("POST").timeout(2000).build();
// POST /orders (2000ms)

A few details I'd point out. Required parameters go in builder(url), so you can't forget them. HttpRequest's constructor is private and takes the builder, which works because outer and nested classes can read each other's private fields. build() is the one place to validate the whole combination, for example "POST needs a body".

The alternative is the telescoping constructors I mentioned earlier, which become unreadable around four parameters. And new HttpRequest("/orders", "POST", 2000, true, false) gives no hint what true and false mean.

So in practice I use Lombok's @Builder or write one by hand for classes with many optional fields. For small value types, a record with a couple of static factories is usually enough.

15. Enums & Records

BasicWhat is an enum in Java, and what can it contain?

So an enum is a class with a fixed set of instances, created once when the class loads. Unlike C-style enums, a Java enum can have fields, constructors, methods, and even per-constant method bodies. The constructor is always private.

Let me show you with a quick example.

enum Plan {
    FREE(0, 1), PRO(999, 5), TEAM(2999, 50);

    private final int priceCents; private final int seats;
    Plan(int priceCents, int seats) { this.priceCents = priceCents; this.seats = seats; }
    int priceCents() { return priceCents; }
    boolean fits(int users) { return users <= seats; }
}

Plan.PRO.priceCents();              // 999
Plan.PRO.fits(8);                   // false
Plan.valueOf("TEAM").ordinal();     // 2
Plan.FREE == Plan.valueOf("FREE");  // true -- one instance per constant, so == is safe
Plan.valueOf("pro");                // IllegalArgumentException: No enum constant ...Plan.pro

String label = switch (Plan.PRO) {  // exhaustive switch expression (Java 14)
    case FREE -> "trial";
    case PRO, TEAM -> "paid";
};

Every enum extends java.lang.Enum, so it gets name(), ordinal(), compareTo(), values() and valueOf(), and it's automatically Comparable and Serializable. It can't extend another class, but it can implement interfaces.

For collections keyed by an enum, EnumMap and EnumSet are backed by an array and a bit vector, which makes them much faster than HashMap and HashSet.

So in practice I use an enum anywhere I'd otherwise have string or int constants, like statuses, plans or roles. The compiler then catches typos and missing switch cases for me.

IntermediateWhy is an enum the best way to implement a Singleton?

So an enum singleton is thread-safe, lazy enough, and it's the only version that's automatically safe against both serialisation and reflection creating a second instance. The JVM itself guarantees there's exactly one INSTANCE.

Let me show you with a quick example, comparing it with the classic private-constructor singleton.

enum Config {
    INSTANCE;
    private int hits;
    int hit() { return ++hits; }
}

static class ClassicSingleton implements Serializable {
    static final ClassicSingleton INSTANCE = new ClassicSingleton();
    private ClassicSingleton() {}
}

// Serialise and deserialise each one:
roundTrip(Config.INSTANCE) == Config.INSTANCE;                    // true
roundTrip(ClassicSingleton.INSTANCE) == ClassicSingleton.INSTANCE; // false -- a second instance!

// Reflection:
var c = ClassicSingleton.class.getDeclaredConstructor();
c.setAccessible(true);
c.newInstance() == ClassicSingleton.INSTANCE;                     // false -- another one
var ec = Config.class.getDeclaredConstructor(String.class, int.class);
ec.setAccessible(true);
ec.newInstance("HACK", 1);
// IllegalArgumentException: Cannot reflectively create enum objects

Walking through it: deserialisation normally creates a brand-new object, so the classic version needs a readResolve() method to return the real instance. Enums are serialised by name and resolved back to the existing constant, so there's nothing to add. For reflection, Constructor.newInstance has an explicit check that refuses enum classes.

The one limitation is that an enum can't extend a class. And if the singleton is expensive to create, it's created when the enum class initialises, which in practice is on first use anyway.

So in practice, in a Spring app I rarely write singletons at all, because the container manages single beans. When I do need a true JVM-wide singleton, I use an enum.

Interviewers look for: the serialisation and reflection points with evidence, not just "Effective Java says so".

IntermediateWhat is a record? What do you get for free, and what can't it do?

So a record (final in Java 16) is a transparent carrier for immutable data. From one line, the compiler generates private final fields, a canonical constructor, accessor methods named after the components, and equals, hashCode and toString based on all components.

Let me show you with a quick example.

record Money(long cents, String currency) {
    Money {                                          // compact constructor: validate / normalise
        if (cents < 0) throw new IllegalArgumentException("negative");
        currency = currency.toUpperCase();           // assigns the parameter; field set after
    }
    Money plus(Money o) { return new Money(cents + o.cents, currency); }
    static Money zero(String cur) { return new Money(0, cur); }
}

var a = new Money(500, "usd");
var b = new Money(500, "USD");
a;                       // Money[cents=500, currency=USD]
a.equals(b);             // true, and same hashCode
a.cents();               // accessor is cents(), not getCents()
new Money(-1, "usd");    // IllegalArgumentException: negative

What a record can't do: it can't extend a class (it implicitly extends java.lang.Record), it's implicitly final, and it can't declare extra instance fields. record Point(int x, int y) { int z; } fails with "field declaration must be static". It can implement interfaces and have static fields, static methods, instance methods and nested types.

A catch: records are only shallowly immutable.

record Leaky(List<String> items) {}
var list = new ArrayList<>(List.of("x"));
var leaky = new Leaky(list);
list.add("y");
leaky.items();   // [x, y]  -- changed from outside

record Team(String name, List<String> members) {
    Team { members = List.copyOf(members); }        // fix: defensive copy
}

So in practice I use records for DTOs, API responses, value objects, map keys, and multi-value returns. I always copy mutable components in the compact constructor.

Interviewers look for: the compact constructor, "shallow immutability" with the fix, and the no-extra-instance-fields rule.

IntermediateRecords vs Lombok vs a normal class: when do you use each?

So a record is a language feature for immutable data with value semantics. Lombok is an annotation processor that generates boilerplate for ordinary classes, including mutable ones. A hand-written class gives full control. I pick based on whether the thing is pure data, needs mutability or framework support, or has real behaviour.

recordLombok (@Value / @Data)Plain class
MutabilityImmutable (shallow)@Value immutable, @Data mutableYour choice
InheritanceCan't extend; finalCan extendCan extend
Extra instance fieldsNoYesYes
Accessorsname()getName()Whatever you write
BuilderHand-written@BuilderHand-written
DependencyNone, it's the languageBuild plugin + IDE plugin; hooks into javac internalsNone
JPA entityNo (needs no-arg ctor, mutability, proxies)Common, but avoid @Data on entitiesYes
Pattern matchingRecord patterns (Java 21)NoNo

A real example from a Spring Boot service: request and response DTOs became records, and Jackson has handled them natively since 2.12. JPA entities stayed plain classes with Lombok @Getter and @Setter, but not @Data, because its generated equals, hashCode and toString touch lazy relationships. That triggers extra queries and can even cause infinite recursion on bidirectional mappings.

So in practice my rule is: records for data by default, plain classes for entities and anything with identity or lifecycle, and Lombok only where it removes real noise on those plain classes.

IntermediateWhat are constant-specific methods in enums, and why is persisting ordinal() dangerous?

So each enum constant can have its own body that overrides an abstract method declared in the enum. That replaces a switch on the enum with polymorphism. ordinal() is just the declaration position, so if you store it, reordering or inserting a constant silently changes the meaning of every stored row.

Let me show you with a quick example.

enum Op {
    ADD("+") { int apply(int a, int b) { return a + b; } },
    MUL("*") { int apply(int a, int b) { return a * b; } };

    final String symbol;
    Op(String s) { symbol = s; }
    abstract int apply(int a, int b);
}
for (Op op : Op.values()) System.out.println("6 " + op.symbol + " 7 = " + op.apply(6, 7));
// 6 + 7 = 13
// 6 * 7 = 42

If I add SUB, the compiler forces me to implement apply. With a switch spread somewhere else in the codebase, I'd have to remember to find it.

On ordinals: JPA's @Enumerated defaults to ORDINAL. Someone adds PENDING at the top of OrderStatus, every existing 0 in the database now means PENDING instead of CREATED, and nothing fails. The data is just wrong. I've seen this in a code review and caught it before it shipped.

@Enumerated(EnumType.STRING)       // store "SHIPPED", not 2
private OrderStatus status;

So in practice I persist enums by name() or by an explicit code field, and I use ordinal() only inside things like EnumMap, which is what it was designed for.

Interviewers look for: the JPA EnumType.ORDINAL default as a concrete production risk.

AdvancedHow do records work with pattern matching in Java 21?

So Java 21 finalised record patterns and pattern matching for switch. A record pattern takes a record apart into its components right in the case label, and patterns can be nested. Combined with a sealed interface, the switch is checked for exhaustiveness, so no default is needed.

Let me show you with a quick example.

sealed interface Shape permits Circle, Rect {}
record Point(int x, int y) {}
record Circle(Point center, int r) implements Shape {}
record Rect(Point topLeft, Point bottomRight) implements Shape {}

static String describe(Shape s) {
    return switch (s) {
        case Circle(Point(var x, var y), var r) when r == 0 -> "dot at " + x + "," + y;
        case Circle(Point c, int r) -> "circle r=" + r + " at " + c;
        case Rect(Point(int x1, int y1), Point(int x2, int y2)) ->
            "rect " + (x2 - x1) + "x" + (y2 - y1);
    };
}
describe(new Circle(new Point(1, 2), 0));                    // dot at 1,2
describe(new Circle(new Point(0, 0), 5));                    // circle r=5 at Point[x=0, y=0]
describe(new Rect(new Point(0, 0), new Point(4, 3)));        // rect 4x3

Walking through it: the first case matches a circle, pulls out the nested point's coordinates, and uses a when guard. Case order matters, since the guarded case must come before the general Circle case, and the compiler rejects a case that an earlier one fully dominates. var works inside patterns.

This is Java's version of algebraic data types. Sealed interfaces give me "one of these", records give me "all of these fields", and the switch handles each shape of data. In a payments service, I modelled PaymentResult as Approved | Declined | NeedsReview this way, and adding a fourth outcome turned every unhandled switch into a compile error.

So in practice this is the modern alternative to the Visitor pattern for closed hierarchies. It's less code, and the logic stays in one readable place.

16. static, final & Immutability

BasicWhat's the difference between static and instance members?

So a static member belongs to the class: there's one copy, shared by every object, and it exists without any object. An instance member belongs to each object, with a separate copy per new. Static methods have no this, so they can't touch instance fields directly.

Let me show you with a quick example.

static class Visitor {
    static int total = 0;     // one copy for the class
    int mine = 0;             // one copy per object
    Visitor() { total++; mine++; }
}
new Visitor(); new Visitor(); Visitor c = new Visitor();
Visitor.total;   // 3
c.mine;          // 1

Visitor nothing = null;
nothing.total;   // 3 -- no NullPointerException: static access ignores the reference

That last line is a classic trick question. Static field access through an instance compiles to Visitor.total, so the null is never dereferenced.

And the most common compile error for beginners: calling an instance member from main.

public class StaticCtx {
    int count;
    public static void main(String[] args) { count++; }
}
// error: non-static variable count cannot be referenced from a static context

Static fields are also shared across threads, so a mutable static counter like total isn't thread-safe. Two threads doing total++ can lose updates.

So in practice I use static for constants, pure utility methods and factory methods. I avoid mutable static state, because it's global, hard to test, not thread-safe by default, and a common source of memory leaks when it's a growing collection.

BasicWhat does final mean on a variable, a method and a class?

So final means "can't be changed after this point", applied at three levels. A final variable can be assigned only once. A final method can't be overridden. A final class can't be extended.

OnMeaningExample
Local variable / parameterAssigned oncefinal int max = 10;
Instance fieldAssigned once, by the end of every constructorprivate final String id;
Static fieldAssigned once, in its declaration or a static blockstatic final int MAX;
MethodCan't be overriddenTemplate method in an abstract class
ClassCan't be extendedString, Integer, all records
static class Order {
    private final String id;              // "blank final": no initialiser
    Order(String id) { this.id = id; }
    Order() { this("GEN-1"); }            // still assigned exactly once via chaining
}
static final int MAX;
static { MAX = Integer.parseInt("100"); }
// new Order().id -> GEN-1, MAX -> 100

The compiler checks "definitely assigned exactly once". If one constructor path forgets to set id, or sets it twice, that's a compile error. That's a nice safety net for required fields.

Don't confuse it with finally, the block that always runs after try, or finalize(), the deprecated GC hook. They share a prefix and nothing else.

So in practice I make fields final by default, classes final unless designed for extension, and I don't bother marking every local variable final unless it helps readability.

IntermediateIf a reference is final, is the object immutable?

So no. final freezes the reference, not the object it points to. I can't make the variable point somewhere else, but I can freely change the object's contents through it.

Let me show you with a quick example.

final List<String> names = new ArrayList<>();
names.add("asha");            // fine: the object changes
names.add("ben");
System.out.println(names);    // [asha, ben]

names = new ArrayList<>();
// error: cannot assign a value to final variable names

This is the most common misunderstanding of final I see in code reviews: public static final List<String> ALLOWED = new ArrayList<>(); treated as a constant. Any code anywhere can call ALLOWED.add(...). A real constant needs both a final reference and an unmodifiable object:

public static final List<String> ALLOWED = List.of("admin", "editor");   // Java 9+

The same applies to arrays. static final int[] CODES can have its elements overwritten, which is why security guidelines say never to expose a public final array.

So in practice, "final" answers "can this variable be reassigned?" and "immutable" answers "can this object change?". For a real constant I need both.

Interviewers look for: the reference-vs-object distinction, and the public static final List "constant" trap.

IntermediateWhat's the difference between final and effectively final?

So a final variable is declared with the keyword. An effectively final variable is one without the keyword that's simply never reassigned after initialisation. Since Java 8 the compiler treats both the same for lambdas, anonymous and local classes: either kind can be captured.

Let me show you with a quick example.

String greeting = "hi";                        // no 'final', never reassigned
Runnable g = () -> System.out.println(greeting);
g.run();                                       // hi  -- effectively final, allowed

int count = 0;
Runnable r = () -> System.out.println(count);
count++;                                       // this line makes count NOT effectively final
// error: local variables referenced from a lambda expression must be final or effectively final

Notice that the error points at the lambda, even though the "problem" is the count++ after it. The compiler looks at the whole method: any reassignment anywhere, before or after the lambda, disqualifies the variable.

Before Java 8, anonymous classes required the explicit final keyword, so code was full of final parameters just to satisfy the compiler. Effectively final removed that noise without changing the rule.

A small detail: effectively final is also what try-with-resources accepts since Java 9. You can write try (existingStream) { ... } if the variable is effectively final.

So in practice I rarely write final on locals now. The compiler tracks it, and if a lambda needs a variable I'm reassigning, I copy it into a new variable first: var snapshot = count;.

IntermediateHow do you make a class immutable? Write one.

So there are five rules. Make the class final. Make all fields private final. Provide no setters. Copy mutable objects on the way in. Never hand out a mutable internal object on the way out. Changes are expressed as methods that return a new instance.

Let me show you with a quick example, deliberately using a mutable Date and a List to show the tricky parts.

public final class Booking {                                   // 1. can't be subclassed
    private final String guest;                                // 2. private final
    private final Date checkIn;
    private final List<String> rooms;

    public Booking(String guest, Date checkIn, List<String> rooms) {
        this.guest = Objects.requireNonNull(guest);
        this.checkIn = new Date(checkIn.getTime());            // 4. copy in
        this.rooms = List.copyOf(rooms);                       //    unmodifiable copy
    }
    public String guest() { return guest; }                    // 3. no setters
    public Date checkIn() { return new Date(checkIn.getTime()); }  // 5. copy out
    public List<String> rooms() { return rooms; }              //    already unmodifiable
    public Booking withGuest(String g) { return new Booking(g, checkIn, rooms); }  // "wither"
}

Date d = new Date(0);
List<String> rooms = new ArrayList<>(List.of("101"));
Booking b = new Booking("Asha", d, rooms);
d.setTime(999_999_999L);           // caller mutates its own Date
rooms.add("102");                  // and its own list
b.checkIn().setTime(123L);         // and the returned copy
b.checkIn().getTime() + " " + b.rooms();   // 0 [101]  -- Booking unaffected

Why final on the class? Otherwise a subclass could add mutable state or override guest() to return something different, and code that trusts Booking to be immutable would be wrong.

In modern code I'd use Instant or LocalDate instead of Date. They're immutable, so no copies are needed. And a record handles rules 1 to 3 for me, so the compact constructor only has to do the List.copyOf.

So in practice immutable objects are my default for value types: they're thread-safe without locks, safe as map keys, and easy to reason about.

Interviewers look for: defensive copying in both directions, and why the class itself must be final. Just "private final fields, no setters" isn't a complete answer.

IntermediateWhen exactly does a static block run? Predict the output.

So a static block runs once, when the class is initialised. That happens on first active use: creating an instance, calling a static method, or reading or writing a static field that isn't a compile-time constant. Just loading or referencing the class isn't enough.

Let me show you with a quick example.

static class Holder {
    static { System.out.println("Holder loaded"); }
    static final int CONSTANT = 10;     // compile-time constant
    static int counter = 5;
    static void hello() { System.out.println("hello"); }
}

System.out.println("start");
System.out.println(Holder.CONSTANT);
System.out.println(Holder.counter);
Holder.hello();
start
10
Holder loaded
5
hello

Walking through it: Holder.CONSTANT is a static final primitive with a constant value, so javac copies 10 straight into the calling code. The class is never touched, and the static block doesn't run. Holder.counter is a real field read, so it triggers initialisation, and "Holder loaded" prints just before 5. By the time we call hello(), the class is already initialised.

That constant inlining has a real-world side effect: if you change a public static final int in a library and don't recompile the code that uses it, callers keep the old value.

This lazy initialisation is what powers the initialisation-on-demand holder idiom, a thread-safe lazy singleton with no locks. The JVM guarantees class initialisation happens once, under a lock.

So in practice I keep static blocks tiny, and I remember that constants don't trigger them.

AdvancedWhy are immutable objects thread-safe? What does final guarantee in the Java Memory Model?

So immutable objects are thread-safe because there are no writes after construction, and data races need at least one write. But there's a subtle part: another thread must also see the fully constructed values, not defaults. The JMM's final field rule guarantees exactly that. Once a constructor finishes, any thread that sees a reference to the object sees the correct values of its final fields, even without synchronisation.

class Config {                 // NOT safe without final or proper publication
    int port;
    Config() { port = 8080; }
}
static Config shared;          // thread A: shared = new Config();
// thread B may see shared != null but shared.port == 0 (reordered writes)

final class Config {           // safe
    private final int port;
    Config() { port = 8080; }
}
// any thread that sees the reference sees port == 8080

Walking through the broken version: the JIT or CPU can reorder "write port" and "publish the reference", so thread B may get the reference before the field write is visible. With final, the JMM puts a freeze at the end of the constructor, so final fields are visible before the reference can be.

Two conditions for this guarantee. First, this must not escape during construction, for example by registering a listener in the constructor. Second, the guarantee covers the final field and everything reachable through it as of the end of the constructor, so later changes to a referenced mutable list are not covered.

That's why String is safe to share even through a data race, and why immutable objects don't need volatile or locks. Only the reference to the current snapshot does, if you swap it: I usually hold it in an AtomicReference or a volatile field.

So in practice my concurrency pattern of choice is immutable snapshots plus an atomic swap of the reference. It's much simpler than locking fine-grained mutable state.

Interviewers look for: safe publication and the final field freeze, plus the "don't let this escape the constructor" condition.

Part 3 — Core APIs

17. Exceptions

BasicWhat's the difference between checked and unchecked exceptions? When would you use each?

So a checked exception is anything that extends Exception but not RuntimeException, and the compiler forces the caller to either catch it or declare it with throws. An unchecked exception extends RuntimeException (or Error), and the compiler doesn't make you handle it.

Let me show you with a quick example.

// Checked: the caller must deal with it
String read(Path p) throws IOException {
    return Files.readString(p);
}

// Unchecked: signals a bug in the caller
void setAge(int age) {
    if (age < 0) throw new IllegalArgumentException("age must be >= 0: " + age);
    this.age = age;
}

The rule I follow is about whether the caller can realistically recover. A missing file, a network timeout, a failed payment: those are expected conditions a caller might retry or work around, so a checked exception documents that in the signature. A null argument, an index out of bounds, an illegal state: those are programming errors. No caller should "handle" them; the code should be fixed. Those are unchecked.

In practice, most modern code and frameworks lean unchecked. Spring wraps SQLException into its unchecked DataAccessException, and checked exceptions don't play well with lambdas, because Function can't throw them. So in practice I use checked exceptions sparingly, at boundaries where recovery is a real option, and unchecked everywhere else.

Interviewers look for: the "can the caller recover?" rule, not just the class hierarchy. Mentioning the lambda friction is a nice senior touch.

Basicfinal vs finally vs finalize?

So these three just share a spelling; they're unrelated. final is a keyword that stops reassignment, overriding or subclassing. finally is a block that runs after try/catch no matter how they exit. finalize() was a method on Object the GC might call before reclaiming an object, and it's deprecated.

Let me show you with a quick example.

final int max = 10;              // variable: can't be reassigned
final class Money { }            // class: can't be extended
public final void audit() { }    // method: can't be overridden

Connection c = pool.get();
try {
    use(c);
} finally {
    c.close();                   // runs on success, exception, or return
}

@Override
protected void finalize() { }    // deprecated since Java 9, marked for removal in 18

finalize was a bad idea from the start: there's no guarantee it ever runs, it runs on an unpredictable GC thread, it slows collection, and it can even resurrect the object. JEP 421 in Java 18 deprecated it for removal.

So in practice, for cleanup I use try-with-resources, and if I really need a safety net for native resources I use java.lang.ref.Cleaner (Java 9). I never write finalize.

Interviewers look for: knowing finalize is deprecated and naming the replacement: try-with-resources, and Cleaner as a backstop.

BasicWhat's the difference between Error and Exception?

So both extend Throwable, but an Error means the JVM itself is in trouble, like OutOfMemoryError or StackOverflowError, while an Exception is a problem in the application that code can reasonably catch.

Let me show you the hierarchy.

Throwable
├── Error                     (unchecked; don't catch)
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   └── NoClassDefFoundError
└── Exception                 (checked, unless a RuntimeException)
    ├── IOException, SQLException, InterruptedException ...
    └── RuntimeException      (unchecked)
        ├── NullPointerException
        ├── IllegalArgumentException
        └── IllegalStateException

The practical point is that I never write catch (Throwable t) or catch (Error e) in business code. If the heap is exhausted, there's usually nothing sensible to do, and catching it can leave the app half-working, which is worse than crashing and restarting. The one place catching Throwable makes sense is the very top of a thread or a framework boundary, where I log it and let the process or task die cleanly.

A classic interview trap: StackOverflowError from infinite recursion is an Error, not an exception, so a catch (Exception e) won't see it.

So in practice I catch specific exceptions, and treat Errors as "restart the JVM and investigate".

IntermediateDoes a finally block always run?

So almost always. It runs whether the try completes normally, throws, returns, or hits break/continue. But there are a few cases where it doesn't: System.exit(), the JVM crashing or being killed, and the thread never actually leaving the try.

Let me show you the most common one.

public static void main(String[] args) {
    try {
        System.out.println("in try");
        System.exit(0);
    } finally {
        System.out.println("finally");
    }
}
// prints only: in try

When I ran this, "finally" never printed. System.exit starts JVM shutdown; the current thread never returns from the call, so the finally is never reached. Shutdown hooks do run, which is where you'd put last-chance cleanup.

The full list I'd give:

  • System.exit() or Runtime.halt() in the try or catch.
  • kill -9, power loss, or a native JVM crash.
  • An infinite loop or a deadlock inside the try, since it never exits.
  • A daemon thread still in its try when the JVM exits.

So in practice I rely on finally (or try-with-resources) for releasing locks and connections, but never for anything that must survive a hard kill. That needs a transaction or an idempotent retry.

Interviewers look for: System.exit as the headline case, plus daemon threads. That shows you know how the JVM shuts down.

IntermediatePredict the output: return in both try and finally.
static int a() { try { return 1; } finally { return 2; } }

static int b() {
    int x = 1;
    try { return x; } finally { x = 99; }
}

static StringBuilder c() {
    StringBuilder sb = new StringBuilder("try");
    try { return sb; } finally { sb.append("+finally"); }
}

static int d() {
    try { throw new RuntimeException("boom"); } finally { return 3; }
}

So the outputs are 2, 1, try+finally, and 3. I ran all four to confirm.

Here's what happens. When the try hits return, the JVM evaluates the value and stashes it, then runs finally.

  • a(): finally has its own return 2, which replaces the stashed 1.
  • b(): the stashed value is the int 1. Changing x afterwards doesn't touch the copy, so it returns 1.
  • c(): the stashed value is a reference. Finally mutates the same object, so the caller sees the change.
  • d(): the worst one. The return in finally silently swallows the exception. "boom" is gone, no stack trace, nothing.

That last case is why javac -Xlint warns "finally clause cannot complete normally". So in practice I never return, throw, or break out of a finally. It should only clean up.

Interviewers look for: the "value is captured before finally runs" explanation, and spotting that d() loses the exception.

IntermediateHow does try-with-resources work, and what are suppressed exceptions?

So try-with-resources (Java 7) automatically calls close() on anything that implements AutoCloseable, in reverse order of opening, even if the body throws. And if the body throws and close throws too, the body's exception wins and the close exception is attached to it as a "suppressed" exception instead of being lost.

Let me show you with a quick example.

record Res(String name) implements AutoCloseable {
    Res { System.out.println("open " + name); }
    public void close() {
        System.out.println("close " + name);
        throw new IllegalStateException("close failed: " + name);
    }
}

try (Res a = new Res("A"); Res b = new Res("B")) {
    System.out.println("body");
    throw new RuntimeException("body failed");
} catch (RuntimeException e) {
    System.out.println("caught: " + e.getMessage());
    for (Throwable s : e.getSuppressed())
        System.out.println("suppressed: " + s.getMessage());
}
open A
open B
body
close B
close A
caught: body failed
suppressed: close failed: B
suppressed: close failed: A

Notice B closes before A, and both close failures are still reachable via getSuppressed(). With an old hand-written try/finally, a throwing close() would have replaced the real error, and I'd be debugging "close failed" when the actual problem was in the body.

Since Java 9 I can also use an effectively final variable declared outside: try (conn) { … }. So in practice every stream, connection, lock wrapper and executor (ExecutorService is AutoCloseable since Java 19) goes in a try-with-resources.

Interviewers look for: reverse close order and the suppressed-exception mechanism. Most candidates only say "it closes things".

AdvancedHow do you design custom exceptions? What are the best practices?

So I create a custom exception when the caller needs to react to that specific failure differently, or when I want to carry domain data like an order id. I name it after the problem, extend RuntimeException by default, always offer a constructor that takes a cause, and never lose the original exception.

Let me show you with a quick example.

public class OrderNotFoundException extends RuntimeException {
    private final long orderId;

    public OrderNotFoundException(long orderId, Throwable cause) {
        super("Order " + orderId + " not found", cause);
        this.orderId = orderId;
    }
    public long orderId() { return orderId; }
}

try {
    repo.load(id);
} catch (SQLException e) {
    throw new OrderNotFoundException(id, e);   // translate, keep the cause
}
// message: Order 42 not found | cause: java.sql.SQLException: no rows

The practices I stick to:

  • Chain the cause. throw new X("msg") inside a catch throws away the real stack trace. That's the number one mistake I see in code reviews.
  • Translate at layer boundaries. The service shouldn't leak SQLException to the controller.
  • Useful message with the ids involved, and no secrets or passwords.
  • Small hierarchy. One base like PaymentException with a few subclasses, not fifty classes.
  • Don't log and rethrow. Either handle it or throw it; doing both prints the same trace three times.
  • Don't use exceptions for control flow. Filling in a stack trace is expensive.

In my last project we mapped a small set of these to HTTP codes in one @ControllerAdvice, so OrderNotFoundException became a 404 without any try/catch in the controllers.

Interviewers look for: cause chaining and "log or rethrow, not both". Those two separate people who've debugged production from people who haven't.

18. Generics

BasicWhy do we need generics? What problem do they solve?

So generics (Java 5) move type errors from runtime to compile time. Before them, a List held Object, you cast everything coming out, and a wrong element only blew up with a ClassCastException far away from where it was added.

Let me show you with a quick example.

// Before Java 5
List names = new ArrayList();
names.add("Asha");
names.add(42);                     // compiles fine
String s = (String) names.get(1);  // ClassCastException at runtime

// With generics
List<String> names2 = new ArrayList<>();
names2.add("Asha");
names2.add(42);                    // compile error
String t = names2.get(0);          // no cast needed

So I get three things: type safety, no casts, and reusable code. I can write one Box<T> or one <T> T firstOrDefault(List<T>, T) and it works for any type while staying safe.

One limitation to mention up front: type parameters must be reference types, so it's List<Integer>, never List<int>, and you pay for boxing. That's why the streams API has IntStream.

So in practice I use generics everywhere I'd otherwise write Object, and I treat any unchecked-cast warning as a bug until proven otherwise.

IntermediateWhat is type erasure? What can't you do because of it?

So type erasure means generic type information exists only at compile time. The compiler checks the types, inserts casts, and then erases T to its bound (usually Object). At runtime a List<String> and a List<Integer> are the exact same class.

Let me show you with a quick example.

List<String> s = new ArrayList<>();
List<Integer> i = new ArrayList<>();
System.out.println(s.getClass() == i.getClass());  // true
System.out.println(s.getClass().getName());        // java.util.ArrayList

Java did it this way for backward compatibility: generic code in Java 5 had to run alongside pre-generics bytecode and libraries.

The consequences, which are the real interview answer:

  • No new T() and no new T[10]. The runtime doesn't know what T is.
  • No obj instanceof List<String>; only instanceof List<?>.
  • No overloading on type arguments: void f(List<String>) and void f(List<Integer>) is a "name clash, same erasure" compile error.
  • No primitives as type arguments.
  • A static field can't use the class's T, since there's only one class.

The standard workaround is to pass a Class<T> token, which is exactly why Jackson asks for readValue(json, User.class), and a TypeReference for List<User>. So in practice, when I need the type at runtime, I pass it in explicitly.

Interviewers look for: "compile-time only, for backward compatibility", plus two or three concrete consequences.

IntermediateWhat's the difference between List<Object>, List<?> and a raw List?

So List<Object> is a list that holds anything, but you can't assign a List<String> to it. List<?> is "a list of some specific type I don't know": you can pass any list in, read elements as Object, but you can't add anything except null. A raw List turns type checking off entirely.

Let me show you with a quick example.

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

List<Object> objs = names;   // compile error: generics are invariant
List<?> any = names;         // fine
any.add("x");                // compile error: unknown type
Object first = any.get(0);   // reading is fine

List raw = names;            // compiles, with only a warning
raw.add(42);                 // heap pollution: an Integer in a List<String>
String s = names.get(0);     // ClassCastException, far from the bug

When I ran the raw-type part, names.size() was 1 and the read threw ClassCastException. The compiler's hidden cast was the one that failed, on a line that looks perfectly safe.

The reason List<Object> can't accept a List<String>: if it could, I could then add an Integer through the List<Object> reference and corrupt the string list.

So in practice: List<?> for methods that only read, like printing or counting; List<Object> when I genuinely store mixed types; raw types never, except in legacy code.

Interviewers look for: the word "invariant", and the heap pollution example for raw types.

IntermediateExplain PECS. When do you use extends and when super?

So PECS stands for "Producer Extends, Consumer Super". If a parameter produces values that my method reads, I use ? extends T. If it consumes values that my method writes into it, I use ? super T. If it does both, I use plain T.

Let me show you with a quick example.

// Producer: I only read Numbers out of it
static double sum(List<? extends Number> nums) {
    double s = 0;
    for (Number n : nums) s += n.doubleValue();
    return s;
}

// Consumer: I only put Integers into it
static void fill(List<? super Integer> out) {
    for (int i = 1; i <= 3; i++) out.add(i);
}

sum(List.of(1, 2.5, 3L));        // 6.5, works for Integer, Double, Long
fill(new ArrayList<Number>());   // [1, 2, 3]
fill(new ArrayList<Object>());   // [1, 2, 3]

Why the restrictions? With ? extends Number the list might really be a List<Double>, so adding an Integer would be unsafe; I can only read. With ? super Integer it might be a List<Object>, so I can safely add Integers but only read back Object.

The JDK's own example is Collections.copy(List<? super T> dest, List<? extends T> src): source produces, destination consumes.

So in practice I apply PECS to public API parameters so callers aren't forced into exact types. I don't use wildcards in return types, because that pushes the pain onto every caller.

Interviewers look for: the Collections.copy signature, and "no wildcards in return types".

AdvancedWhy can't you create a generic array like new T[] or new List<String>[10]?

So it's because arrays and generics follow opposite rules. Arrays are covariant and reified: a String[] is an Object[], and the array checks every store at runtime. Generics are invariant and erased: the runtime has no idea what T is. Mixing the two would let the array's runtime check pass things it should reject.

Let me show you with a quick example.

// Arrays check at runtime
Object[] arr = new String[2];
arr[0] = 1;          // ArrayStoreException: java.lang.Integer

// If generic arrays were allowed:
List<String>[] lists = new List<String>[1];   // compile error, and here's why
Object[] o = lists;
o[0] = List.of(42);          // runtime check only sees "List", passes
String s = lists[0].get(0);  // ClassCastException, with no warning anywhere

The array can only check "is this a List?", because <String> was erased. So the type system would have a hole with no compiler warning. Java refuses to create such arrays to keep that guarantee.

Workarounds I actually use:

  • Just use List<List<String>>. Almost always the right answer.
  • Inside a generic class, (T[]) new Object[n] with @SuppressWarnings("unchecked"), kept private. That's what ArrayList does internally.
  • Array.newInstance(clazz, n) when I have a Class<T>.

So in practice I prefer lists over arrays in any generic code.

Interviewers look for: "covariant and reified vs invariant and erased", and the ArrayStoreException example.

AdvancedWhat does <T extends Comparable<? super T>> mean, and why not just <T extends Comparable<T>>?

So it means "T must be comparable to itself or to one of its supertypes". The ? super T part matters when the compareTo is inherited from a parent class instead of being declared for T itself.

Let me show you with a quick example.

static <T extends Comparable<? super T>> T max(List<? extends T> list) {
    T best = list.get(0);
    for (T t : list) if (t.compareTo(best) > 0) best = t;
    return best;
}

LocalDate d = max(List.of(LocalDate.of(2024, 1, 1), LocalDate.of(2025, 6, 1)));
// 2025-06-01

Here's the catch: LocalDate does not implement Comparable<LocalDate>. It implements Comparable<ChronoLocalDate>, its interface. With the stricter bound <T extends Comparable<T>>, this call wouldn't compile. With ? super T, it works, because ChronoLocalDate is a supertype of LocalDate.

The same thing happens in my own code: if Employee implements Comparable<Employee> and Manager extends Employee, then Manager is only Comparable<Employee>. Sorting a List<Manager> needs the super version.

It's also just PECS again: a Comparable consumes T values in compareTo, so it gets super. That's why Collections.sort is declared as <T extends Comparable<? super T>> void sort(List<T> list).

So in practice, whenever I write a generic utility that compares T, I copy that bound from the JDK.

19. The Collections Framework

BasicWalk me through the Collections hierarchy. When do you pick a List, Set, Queue or Map?

So there are two roots. Collection has List, Set and Queue under it, and Map is separate because it stores key-value pairs rather than single elements.

Let me show you how I choose.

InterfaceKey propertyDefault choiceOthers
ListOrdered by index, duplicates allowedArrayListLinkedList (rarely), CopyOnWriteArrayList
SetNo duplicatesHashSetLinkedHashSet (insertion order), TreeSet (sorted)
Queue / DequeProcess in orderArrayDequePriorityQueue (by priority), BlockingQueue (threads)
MapUnique keys to valuesHashMapLinkedHashMap, TreeMap, ConcurrentHashMap

My decision process is: do I need lookup by key? Map. Do I need uniqueness? Set. Do I need position or duplicates? List. Do I process items first-in-first-out or by priority? Queue. Then I pick the implementation by ordering needs: none, insertion order, or sorted.

Java 21 added SequencedCollection and SequencedMap (JEP 431), so List, Deque, LinkedHashSet and LinkedHashMap now share getFirst(), getLast() and reversed(). No more list.get(list.size() - 1).

So in practice I declare variables as the interface (List<Order> orders = new ArrayList<>()) so I can swap implementations later, and I use ArrayDeque instead of the legacy Stack class.

Interviewers look for: Map not being a Collection, ArrayDeque over Stack, and bonus points for Java 21 sequenced collections.

BasicArrayList vs LinkedList. Which would you use?

So ArrayList is backed by a resizable array, and LinkedList is a doubly linked list of nodes. In theory LinkedList wins on inserts in the middle; in practice ArrayList wins almost every real workload, so it's my default.

Let me show you the complexities.

OperationArrayListLinkedList
get(i)O(1)O(n), walks from the nearer end
add at endAmortised O(1)O(1)
add/remove at frontO(n), shifts elementsO(1)
add/remove in middleO(n) shiftO(n) to find the spot, then O(1)
Memory per elementOne referenceNode object with 3 references + header

The part people miss: inserting in the middle of a LinkedList is only O(1) once you're there, and getting there is O(n). Meanwhile ArrayList's "O(n) shift" is a single System.arraycopy over contiguous memory, which the CPU cache loves. LinkedList nodes are scattered around the heap, so every hop is a potential cache miss, plus much more GC pressure.

ArrayList starts at capacity 10 on the first add and grows by 1.5x, so if I know the size I pass it to the constructor to avoid repeated copies.

So in practice I use ArrayList for lists, and if I need fast adds and removes at both ends I use ArrayDeque, not LinkedList. I honestly can't remember the last time LinkedList was the right answer.

Interviewers look for: cache locality and "O(1) insert only after an O(n) walk". Big-O alone isn't enough here.

IntermediateHow does HashSet work internally?

So a HashSet is just a HashMap in disguise. Every element you add becomes a key in an internal HashMap, and the value is a shared dummy object called PRESENT. So all the uniqueness comes from HashMap's key rules: hashCode to find the bucket, equals to check for a duplicate.

Let me show you with a quick example.

// Simplified from the JDK source
public class HashSet<E> {
    private transient HashMap<E, Object> map;
    private static final Object PRESENT = new Object();

    public boolean add(E e) {
        return map.put(e, PRESENT) == null;   // null means the key was new
    }
}

Set<String> set = new HashSet<>();
set.add("a");   // true
set.add("a");   // false, already present
set.size();     // 1

That's why add returns a boolean: put returns the previous value, which is null only for a new key.

The same pattern repeats across the JDK: LinkedHashSet is backed by a LinkedHashMap, and TreeSet by a TreeMap. So a TreeSet uses compareTo for uniqueness, not equals, which can surprise people.

So in practice, everything I know about HashMap applies directly: O(1) average add and contains, one null allowed, no ordering, and elements must have correct equals and hashCode and must not be mutated while in the set.

Interviewers look for: "backed by a HashMap with a dummy value", and why add returns false for duplicates.

IntermediateHashMap vs Hashtable vs ConcurrentHashMap vs TreeMap vs LinkedHashMap?

So they differ on three axes: thread safety, ordering, and nulls. HashMap is the fast unordered default. LinkedHashMap keeps insertion (or access) order. TreeMap keeps keys sorted. ConcurrentHashMap is the thread-safe one. Hashtable is legacy and I'd never pick it for new code.

Let me show you with a table.

Orderget/putNull key / valueThread-safe
HashMapNoneO(1) avgOne null key, null valuesNo
LinkedHashMapInsertion or accessO(1) avgSame as HashMapNo
TreeMapSorted by keyO(log n)No null key (natural order); null values OKNo
HashtableNoneO(1) avgNeitherYes, one lock on every method
ConcurrentHashMapNoneO(1) avgNeitherYes, lock-free reads, per-bin locking
for (String k : List.of("banana", "apple", "cherry")) { lhm.put(k, 1); tm.put(k, 1); }
lhm.keySet();   // [banana, apple, cherry]  insertion order
tm.keySet();    // [apple, banana, cherry]  sorted

Why is Hashtable bad? Every method is synchronized on the whole table, so all threads queue up. ConcurrentHashMap (Java 8+) reads without locking, uses CAS to fill an empty bin, and only locks the single bin being written.

Why no nulls in ConcurrentHashMap? Because get(k) == null would be ambiguous: "absent" or "mapped to null"? In a single thread you could check containsKey, but between those two calls another thread could change things.

So in practice: HashMap by default, LinkedHashMap for predictable output or an LRU cache, TreeMap for range queries like floorKey and headMap, ConcurrentHashMap for shared state.

Interviewers look for: the reason CHM disallows nulls. It's the follow-up almost every time.

IntermediateWhat are the pitfalls of Arrays.asList?

So Arrays.asList doesn't copy anything. It returns a fixed-size view over the original array. That gives three traps: you can't add or remove, writes go straight through to the array (both ways), and passing a primitive array gives you a list with one element.

Let me show you with a quick example.

String[] arr = {"x", "y", "z"};
List<String> view = Arrays.asList(arr);

view.set(0, "CHANGED");
System.out.println(arr[0]);      // CHANGED   list writes into the array
arr[2] = "Z";
System.out.println(view);        // [CHANGED, y, Z]   and the other way round

view.add("w");                   // UnsupportedOperationException

int[] prim = {1, 2, 3};
Arrays.asList(prim).size();      // 1   it's a List<int[]>
Integer[] boxed = {1, 2, 3};
Arrays.asList(boxed).size();     // 3

The confusing part is that the returned class is also called ArrayList, but it's the private nested java.util.Arrays$ArrayList, not java.util.ArrayList. It supports set but inherits add and remove from AbstractList, which just throw.

The primitive case happens because the method is asList(T... a), and T can't be int, so the whole int[] becomes the single T.

So in practice, when I want a real mutable list I write new ArrayList<>(Arrays.asList(arr)), for a fixed list I use List.of, and for primitives Arrays.stream(prim).boxed().toList().

Interviewers look for: all three traps, especially the int[] one, which shows up in "what's wrong with this code" rounds.

IntermediateIs List.of immutable? How is it different from Collections.unmodifiableList?

So yes, List.of (Java 9) returns a truly immutable list: no add, remove or set, and it rejects nulls. Collections.unmodifiableList is different: it's a read-only view over another list, so if someone changes the original, you see the change.

Let me show you with a quick example.

List<String> fixed = List.of("a", "b");
fixed.set(0, "z");          // UnsupportedOperationException
List.of("a", null);         // NullPointerException
fixed.contains(null);       // NullPointerException too

List<String> src = new ArrayList<>(List.of("a"));
List<String> unmod = Collections.unmodifiableList(src);
List<String> copy = List.copyOf(src);
src.add("b");
System.out.println(unmod + " " + copy);   // [a, b] [a]

So the unmodifiable view changed under me, while List.copyOf (Java 10) took a real snapshot. The contains(null) throwing surprises people; I've seen it break code that was migrated from Arrays.asList.

One more nuance: "immutable" is shallow. List.of(sb) with a StringBuilder inside still lets you mutate the builder. And Stream.toList() (Java 16) also returns an unmodifiable list, though that one does allow nulls.

So in practice I return List.copyOf(internal) from getters to protect my class's state, use List.of for constants, and remember that neither accepts nulls.

Interviewers look for: view vs copy, and the null behaviour. Mentioning shallow immutability is a bonus.

AdvancedBuild an LRU cache using the Collections framework.

So the quickest correct answer is LinkedHashMap in access order mode, plus overriding removeEldestEntry. LinkedHashMap already keeps a doubly linked list through its entries, and in access-order mode every get moves that entry to the end, so the head is always the least recently used.

Let me show you with a quick example.

class LruCache<K, V> extends LinkedHashMap<K, V> {
    private final int capacity;

    LruCache(int capacity) {
        super(16, 0.75f, true);             // true = access order
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;           // called after every put
    }
}

LruCache<String, Integer> lru = new LruCache<>(2);
lru.put("a", 1);
lru.put("b", 2);
lru.get("a");          // "a" becomes most recent
lru.put("c", 3);       // evicts "b"
lru.keySet();          // [a, c]

Walking through it: after the two puts the order is a, b. The get("a") moves a to the end, giving b, a. Putting c makes the size 3, so removeEldestEntry returns true and b goes. Every operation is O(1).

If the interviewer says "without LinkedHashMap", I build the same thing by hand: a HashMap<K, Node> for lookup plus my own doubly linked list with dummy head and tail, moving a node to the tail on access and dropping from the head on overflow.

This one isn't thread-safe. For a shared cache in production I'd use Caffeine rather than wrapping this in synchronized.

Interviewers look for: the true access-order flag. Without it you've built a FIFO cache, not an LRU.

20. How HashMap Works Inside

BasicHow does HashMap work internally? Walk me through put and get.

So a HashMap is an array of buckets. On put it hashes the key, uses the hash to pick a bucket index, and stores a node with the key, value and hash there. On get it does the same hash, goes straight to that bucket, and uses equals to find the exact key. That's why it's O(1) on average.

Let me show you the steps for put(k, v).

// 1. Spread the hash so high bits affect the index
int h = k.hashCode();
int hash = h ^ (h >>> 16);

// 2. Pick a bucket (table length is always a power of two)
int index = (table.length - 1) & hash;     // same as hash % n, but faster

// 3. In that bucket:
//    empty                        -> place a new Node
//    a node with same hash and
//      equal key                  -> replace the value, return the old one
//    otherwise                    -> append to the bucket's list (or tree)

// 4. if (++size > threshold) resize();      // threshold = capacity * 0.75

The table is lazily created on the first put with capacity 16. A null key is allowed and always hashes to 0, so it lives in bucket 0.

Why the h ^ (h >>> 16) step? With a table of 16, only the low 4 bits of the hash choose the bucket. Keys whose hashCodes differ only in high bits would all collide. XOR-ing the top half into the bottom half mixes that information in cheaply.

So in practice HashMap is only as good as the key's hashCode and equals. With a good distribution each bucket holds zero or one node and both operations are constant time.

Interviewers look for: the order hashCode, then index, then equals. Candidates who mention the hash spreading and the power-of-two mask stand out.

IntermediateWhat happens on a collision?

So a collision is when two different keys land in the same bucket. HashMap handles it with chaining: the bucket holds a linked list of nodes, and a lookup walks that list comparing each node's hash first and then equals. Since Java 8, if one bucket gets too long it turns into a red-black tree.

Let me show you with a quick example.

"Aa".hashCode();   // 2112
"BB".hashCode();   // 2112   different strings, same hash

map.put("Aa", 1);
map.put("BB", 2);  // same bucket; "BB".equals("Aa") is false,
// so a second node is appended to the chain
map.get("BB");     // go to bucket, skip "Aa" (not equal), return 2

Two details that matter. First, on each node it compares the stored hash int before calling equals, because an int compare is much cheaper than a full equals on a long string. Second, Java 8 appends new nodes at the tail. Java 7 inserted at the head, and during a concurrent resize that could reverse links into a cycle, and get would spin at 100% CPU forever. That was a famous production bug when people shared a plain HashMap across threads.

The cost: a lookup becomes O(length of chain). With many collisions it degrades toward O(n), which is why Java 8 added treeification to cap it at O(log n).

So in practice collisions are normal and cheap in small numbers; what I worry about is a bad hashCode that sends everything to a few buckets.

IntermediateWhat is the load factor? When and how does a HashMap resize?

So the load factor (default 0.75) decides how full the table can get before it grows. The threshold is capacity times load factor. When the number of entries goes past the threshold, the table doubles and every entry is redistributed.

Let me show you with a quick example.

// Default: capacity 16, threshold 16 * 0.75 = 12
Map<String, Integer> m = new HashMap<>();
// after 12 entries  -> table length 16
// the 13th entry    -> table length 32, new threshold 24

I confirmed this by reading the internal table through reflection: 16 after the 12th put, 32 after the 13th.

The resize trick in Java 8 is neat. Because the size doubles, each entry either stays at the same index or moves to index + oldCapacity, decided by a single bit of its hash. So each bucket splits into a "low" list and a "high" list without recomputing anything, and the relative order is preserved.

Why 0.75? It's the trade-off between space and time. A lower factor means fewer collisions but more wasted slots; a higher one saves memory but lengthens chains.

Resizing is O(n) and allocates a new array, so if I know I'm loading 10,000 entries I pre-size. Since Java 19 there's HashMap.newHashMap(10_000), which does the capacity math for me. Before that, new HashMap<>(10_000) was a classic mistake, because 10,000 is the capacity, not the number of entries it holds before resizing.

Interviewers look for: the 12 then 13 numbers, and the "same index or index + oldCap" split.

IntermediateWhy is String a good HashMap key? What makes a good key in general?

So String is ideal because it's immutable, it overrides equals and hashCode consistently, and it caches its hash code. Once a String is in a bucket, nothing can ever change the hash that put it there.

Let me show you what the JDK does.

// Simplified from java.lang.String
private int hash;          // 0 until first computed
private boolean hashIsZero;

public int hashCode() {
    int h = hash;
    if (h == 0 && !hashIsZero) {
        h = computeHash();     // s[0]*31^(n-1) + ... + s[n-1]
        if (h == 0) hashIsZero = true; else hash = h;
    }
    return h;
}

So the O(length) hash is paid once per String object, then every later lookup is a field read. That matters a lot for maps keyed by long ids.

My checklist for any key class:

  • Immutable, or at least the fields used in hashCode never change.
  • equals and hashCode overridden together and based on the same fields.
  • Well-distributed hash, so keys spread across buckets.
  • Ideally Comparable, which helps when a bucket turns into a tree.

Records (Java 16) tick most of these for free: final fields, generated equals and hashCode. So in practice my composite keys are records like record OrderKey(long customerId, LocalDate day), as long as the components are immutable too.

Interviewers look for: immutability and the cached hash. Mentioning records as keys is a modern touch.

IntermediateWhat's wrong with this code? (mutable key)
class Point {
    int x, y;
    // equals and hashCode based on x and y
}

Map<Point, String> m = new HashMap<>();
Point p = new Point(1, 2);
m.put(p, "home");
p.x = 99;

m.get(p);                  // ?
m.get(new Point(1, 2));    // ?
m.containsKey(p);          // ?
m.size();                  // ?

So the key was mutated after it went into the map. The outputs are null, null, false, and 1. The entry is still in there, but I can't reach it with either the new or the old key.

Here's what happens. The node was stored in the bucket for hash(1, 2), and it kept that hash. Now get(p) computes hash(99, 2), goes to a different bucket, and finds nothing. get(new Point(1, 2)) goes to the right bucket and the stored hash matches, but equals compares against the mutated object, whose x is now 99, so it fails too. The entry is orphaned: it's a memory leak that also breaks containsKey and remove.

I've seen this in real code where an entity with a generated id was put into a HashSet before saving; the save set the id, the hashCode changed, and the set "lost" it.

The fixes: make the key immutable (a record with final fields), or base hashCode only on fields that never change. If I must change a key, I remove it, mutate, and put it back.

Interviewers look for: why the original key fails too. That's what proves you know the stored hash and the equals check both matter.

AdvancedWhen does a HashMap bucket turn into a tree? Why not always?

So since Java 8, a bucket's linked list is converted to a red-black tree when adding a node makes the chain longer than 8, which means on the 9th node, and the table has at least 64 slots. If the table is smaller than 64, HashMap resizes instead, because a long chain in a small table usually just means the table is too small.

Let me show you what I measured, with a key whose hashCode always returns 7 so every entry collides.

record Key(int id) implements Comparable<Key> {
    @Override public int hashCode() { return 7; }
    public int compareTo(Key o) { return Integer.compare(id, o.id); }
}

// new HashMap<>(64)
// entries 1..8  -> bucket is a plain Node chain
// entry 9       -> bucket becomes TreeNode

// new HashMap<>(16)
// entry 9       -> table resizes to 32, still a chain
// entry 10      -> table resizes to 64, still a chain
// entry 11      -> bucket becomes TreeNode

The constants are TREEIFY_THRESHOLD = 8, MIN_TREEIFY_CAPACITY = 64, and UNTREEIFY_THRESHOLD = 6: when a resize splits a tree bin down to 6 or fewer nodes, it goes back to a list.

Why not always use trees? A TreeNode is roughly twice the size of a Node, and for short chains a linear scan is faster. With a decent hash and load factor 0.75, bucket sizes follow a Poisson distribution, and the JDK comments put the chance of a chain reaching 8 at about 0.00000006. So treeification is really a defence against terrible hash codes, or against attackers deliberately sending colliding keys (hash-flooding a web server's parameter map), turning O(n) worst case into O(log n).

The tree orders nodes by hash, then by compareTo if the keys are Comparable, then by a tie-breaker. So Comparable keys get the full benefit.

Interviewers look for: the 64 capacity condition. Most people say only "8" and miss that a small table resizes instead.

AdvancedWhat happens if you override equals but not hashCode?

So hash-based collections break. Two objects that are "equal" get different identity hash codes, land in different buckets, and HashMap never even calls equals to compare them. You get duplicates in a Set and failed lookups in a Map.

Let me show you with a quick example.

class NoHash {
    int id;
    NoHash(int id) { this.id = id; }
    @Override public boolean equals(Object o) {
        return o instanceof NoHash n && n.id == id;
    }
    // no hashCode override
}

Set<NoHash> s = new HashSet<>();
s.add(new NoHash(1));
s.add(new NoHash(1));
s.size();                     // 2
s.contains(new NoHash(1));    // false

When I ran it, the set had two "equal" elements and contains returned false. That's because Object.hashCode is identity-based, so each instance gets its own value, and the lookup goes to a bucket where the stored object isn't.

The contract is one-directional: if a.equals(b), then a.hashCode() == b.hashCode() must hold. The reverse isn't required; collisions are allowed.

The opposite mistake, overriding hashCode but not equals, is also broken: objects land in the same bucket but equals falls back to ==, so you still get duplicates.

So in practice I never write these by hand. I use a record, or let the IDE generate both from the same fields, or Objects.hash(x, y). And I don't use Lombok's @Data on JPA entities, because a hashCode based on a mutable id causes exactly the mutable-key bug.

Interviewers look for: "HashMap checks the hash before equals, so equals never gets called". That's the mechanism, not just the rule.

21. Comparable, Comparator & Sorting

BasicComparable vs Comparator. When do you use which?

So Comparable defines a class's one natural ordering, implemented inside the class with compareTo. Comparator is an external strategy object with compare(a, b), so I can have as many orderings as I want without touching the class.

Let me show you with a quick example.

record Emp(String name, String dept, int salary) implements Comparable<Emp> {
    public int compareTo(Emp o) { return name.compareTo(o.name); }   // natural order
}

Collections.sort(emps);        // uses compareTo
// [Asha, Kabir, Meera, Ravi]

emps.sort(Comparator.comparing(Emp::salary));   // a different order, no class change
ComparableComparator
Packagejava.langjava.util
MethodcompareTo(T o)compare(T a, T b)
WhereInside the classOutside, as a lambda or object
How manyOne natural orderAny number
Used by defaultTreeMap, TreeSet, Collections.sort(list)When passed explicitly

I implement Comparable when there's one obvious order that everyone would agree on: numbers, dates, a version string. I use a Comparator for everything else, and whenever I can't modify the class, like a third-party DTO.

So in practice most of my sorting is Comparators built with Comparator.comparing, because business code usually sorts the same objects several different ways.

IntermediateSort employees by department, then salary descending, then name.

So I'd chain Comparator factory methods from Java 8: comparing for the first key, thenComparing for each tie-breaker, and reverseOrder() on just the salary key.

Let me show you with a quick example.

List<Emp> emps = new ArrayList<>(List.of(
        new Emp("Ravi", "Eng", 120), new Emp("Asha", "Ops", 90),
        new Emp("Meera", "Eng", 150), new Emp("Kabir", "Ops", 90)));

emps.sort(Comparator.comparing(Emp::dept)
    .thenComparing(Emp::salary, Comparator.reverseOrder())
    .thenComparing(Emp::name));

// [Eng:Meera, Eng:Ravi, Ops:Asha, Ops:Kabir]

Walking through it: Eng comes before Ops. Within Eng, Meera's 150 beats Ravi's 120 because salary is reversed. Within Ops, Asha and Kabir both earn 90, so the name tie-breaker puts Asha first.

A common mistake is writing .thenComparing(Emp::salary).reversed(). reversed() applies to the whole chain built so far, so it would flip the department order too. Passing reverseOrder() as the key comparator reverses only that key.

Other helpers I use: comparingInt to avoid boxing, and nullsFirst/nullsLast for fields that can be null:

list.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
// [b, null, a] -> [null, a, b]

So in practice I keep these comparators as constants, like Emp.BY_DEPT_THEN_PAY, so the same order is reused everywhere.

Interviewers look for: where reversed() applies. It's a common live-coding bug.

IntermediateWhat's wrong with this comparator: (a, b) -> a - b?

So it overflows. Subtracting two ints can wrap around past Integer.MAX_VALUE and flip the sign, so a very negative number can come out as "greater" than a positive one.

Let me show you with a quick example.

Comparator<Integer> bad = (a, b) -> a - b;
List<Integer> nums = new ArrayList<>(List.of(Integer.MIN_VALUE, 5, -3));

bad.compare(Integer.MIN_VALUE, 5);   // 2147483643  (positive!)
nums.sort(bad);
System.out.println(nums);            // [-3, 5, -2147483648]   wrong

nums.sort(Integer::compare);
System.out.println(nums);            // [-2147483648, -3, 5]   correct

MIN_VALUE - 5 should be hugely negative, but it wraps to 2147483643, so the comparator says MIN_VALUE is bigger than 5. The list ends up sorted wrongly, with no exception.

It's sneaky because it works in every unit test with small values, and then fails in production on ids, timestamps, or anything near the extremes. With a bigger list, an inconsistent comparator can even make TimSort throw "Comparison method violates its general contract!".

The fix is always Integer.compare(a, b), Long.compare, or Comparator.comparingInt(...). Same rule inside a compareTo.

So in practice I treat any subtraction inside compare as a code-review red flag.

Interviewers look for: naming overflow immediately and the Integer.compare fix.

AdvancedWhat does "consistent with equals" mean, and why does it matter for TreeSet?

So an ordering is consistent with equals when compare(a, b) == 0 exactly when a.equals(b). It matters because TreeSet and TreeMap never call equals. They decide "is this a duplicate?" purely by compareTo or the Comparator returning 0.

Let me show you with a quick example.

var a = new BigDecimal("1.0");
var b = new BigDecimal("1.00");
// a.equals(b)     -> false  (different scale)
// a.compareTo(b)  -> 0      (same value)

new HashSet<>(List.of(a, b)).size();   // 2
new TreeSet<>(List.of(a, b)).size();   // 1

Set<String> ci = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
ci.add("Java");
ci.add("JAVA");
System.out.println(ci);                // [Java]

Same two objects, different set sizes, depending on which method the collection trusts. Sometimes that's exactly what I want, like the case-insensitive set. But it bites when it's accidental.

The real bug I've seen: a TreeSet of employees with a comparator on salary only. Two different people with the same salary compared as 0, so the second one silently disappeared from the set, and a report was short by a few rows.

So in practice, when I write a comparator for a TreeSet or TreeMap, I always end the chain with a unique tie-breaker like the id, so that 0 really means "same element".

Interviewers look for: "TreeSet uses compareTo, not equals", and the BigDecimal example.

AdvancedWhich algorithm does Java use for sorting? Is it stable?

So it depends on what you're sorting. Arrays.sort on primitives uses Dual-Pivot Quicksort, which isn't stable. Sorting objects, whether Arrays.sort(Object[]), Collections.sort or List.sort, uses TimSort, which is stable and O(n log n) worst case.

Let me show you why stability matters.

// Already sorted by name: [Asha(Ops,90), Kabir(Ops,90), Meera(Eng,150), Ravi(Eng,120)]
emps.sort(Comparator.comparing(Emp::dept));
// Stable: within each dept, the name order survives
// [Meera, Ravi, Asha, Kabir]

Stability means equal elements keep their relative order. For objects that's important, because it lets you sort by one key after another and keep the earlier ordering. For primitives it doesn't matter: two equal ints are indistinguishable, so Java uses the faster, non-stable quicksort there.

TimSort is a hybrid of merge sort and insertion sort. It finds already-sorted "runs" in the data and merges them, so on nearly-sorted input it approaches O(n). It needs up to n/2 extra space. It also checks the comparator's contract, which is why a broken comparator can throw IllegalArgumentException: Comparison method violates its general contract!

Two extras: Arrays.parallelSort (Java 8) splits large arrays across the fork/join pool, and List.sort on an ArrayList sorts the backing array in place, while the default implementation copies to an array, sorts, and writes back.

So in practice I rely on stability for multi-key sorts, but I still prefer one explicit thenComparing chain because it's clearer.

22. Iterators, Fail-fast & Fail-safe

BasicWhat's the difference between Iterator, ListIterator and a for-each loop?

So a for-each loop over a collection is just syntax sugar: the compiler turns it into an Iterator with hasNext() and next(). Using the Iterator directly gives you one extra power, remove(). ListIterator, only for Lists, adds going backwards, the current index, and set and add while iterating.

Let me show you with a quick example.

List<String> li = new ArrayList<>(List.of("x", "y"));
for (ListIterator<String> it = li.listIterator(); it.hasNext(); ) {
    String v = it.next();
    it.set(v.toUpperCase());   // replace current element
    it.add(v + "!");           // insert after it
}
System.out.println(li);        // [X, x!, Y, y!]
for-eachIteratorListIterator
Works onArrays and any IterableAny CollectionLists only
Remove while loopingNo (CME)remove()remove()
Replace / insertNoNoset(), add()
DirectionForwardForwardBoth, with index

Any class that implements Iterable can be used in a for-each, which is how I make my own tree or range types loop-friendly.

So in practice I use for-each for reading, removeIf for filtering in place, and ListIterator only for the rare in-place edit or insert during a walk.

IntermediateFail-fast vs fail-safe iterators?

So a fail-fast iterator throws ConcurrentModificationException as soon as it notices the collection was structurally changed by anything other than itself. ArrayList, HashMap and most java.util collections work this way. A fail-safe iterator never throws: it either works on a snapshot (CopyOnWriteArrayList) or is "weakly consistent" (ConcurrentHashMap), tolerating changes during iteration.

Let me show you with a quick example.

// Fail-fast
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
for (Integer n : list) if (n % 2 == 0) list.remove(n);
// ConcurrentModificationException

// Snapshot: the loop sees the original 3 elements only
List<Integer> cow = new CopyOnWriteArrayList<>(List.of(1, 2, 3));
for (Integer n : cow) cow.add(n * 10);
System.out.println(cow);    // [1, 2, 3, 10, 20, 30]

// Weakly consistent: removing while iterating is fine
Map<String, Integer> chm = new ConcurrentHashMap<>(Map.of("a", 1, "b", 2));
for (String k : chm.keySet()) chm.remove(k);
System.out.println(chm);    // {}
Fail-fastSnapshot (COW)Weakly consistent (CHM)
Throws CMEYesNoNo
Sees later changesn/aNoMaybe
CostCheapCopies array on every writeCheap
iterator.remove()YesUnsupportedOperationExceptionYes

So in practice, CopyOnWriteArrayList is great for read-heavy lists like event listeners, and terrible for write-heavy ones. ConcurrentHashMap is my general-purpose choice when threads iterate and modify.

Interviewers look for: that "fail-safe" really means two different mechanisms, snapshot and weakly consistent.

IntermediateYou get a ConcurrentModificationException removing items in a loop. How do you fix it?

So the CME comes from calling list.remove() while a for-each's hidden iterator is walking the list. The fix is to remove through the iterator itself, or better, use removeIf (Java 8), which does it safely and in one pass.

Let me show you the options.

// Broken
for (Integer n : list) if (n % 2 == 0) list.remove(n);   // CME

// Fix 1: Iterator.remove()
for (Iterator<Integer> it = a.iterator(); it.hasNext(); )
    if (it.next() % 2 == 0) it.remove();
// [1, 3, 5]

// Fix 2: removeIf (my default)
b.removeIf(n -> n % 2 == 0);
// [1, 3, 5]

// Maps too
hm.entrySet().removeIf(e -> e.getValue() > 1);

// Fix 3: build a new list
List<Integer> odds = list.stream().filter(n -> n % 2 != 0).toList();

I prefer removeIf because on an ArrayList it's O(n): it marks what to remove and compacts the array once. Calling it.remove() in a loop on an ArrayList shifts the tail every time, which is O(n²) for many removals.

Another old trick is a backwards index loop, for (int i = list.size() - 1; i >= 0; i--), which works because removing at i doesn't shift the indexes you haven't visited yet.

And importantly: if the CME appears with multiple threads, none of these fixes help. That's a thread-safety bug, and the fix is a concurrent collection or proper locking.

Interviewers look for: removeIf, and recognising that a CME in multi-threaded code means a concurrency problem, not a loop problem.

AdvancedPredict the output: removing the second-to-last element in a for-each.
List<String> s = new ArrayList<>(List.of("a", "b", "c"));
for (String x : s) {
    if (x.equals("b")) s.remove(x);
}
System.out.println(s);

So surprisingly, this doesn't throw. It prints [a, c], and I verified that. But "c" is never visited by the loop, so if the condition also needed to check "c", it would silently be skipped.

Here's what happens inside ArrayList's iterator. It keeps a cursor (the next index), and hasNext() is simply cursor != size. The modification check lives in next(), not in hasNext().

  • After reading "a", cursor = 1. After reading "b", cursor = 2.
  • Removing "b" shrinks size from 3 to 2.
  • hasNext() checks 2 != 2, which is false, so the loop ends.
  • next() is never called again, so the modCount check never runs.

Remove "a" instead and you get a CME, because the loop continues and calls next().

The deeper lesson is that fail-fast is best effort. The Javadoc says explicitly that you can't rely on the exception for correctness. It exists to catch bugs, not to guarantee detection. So in practice I never modify a collection inside a for-each, even when it "works".

Interviewers look for: the cursor and size explanation, and the phrase "best effort".

AdvancedHow is fail-fast actually implemented?

So every fail-fast collection has an int field called modCount that it increments on every structural change, meaning an add or remove, not a set. When you create an iterator, it copies that value into expectedModCount. Every next() compares the two, and if they differ, it throws.

Let me show you the simplified source.

// Inside ArrayList
protected transient int modCount = 0;   // inherited from AbstractList

public boolean add(E e) { modCount++; /* ... */ }

private class Itr implements Iterator<E> {
    int cursor;
    int expectedModCount = modCount;

    public E next() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        // ...
    }

    public void remove() {
        ArrayList.this.remove(lastRet);
        expectedModCount = modCount;     // stays in sync, so no CME
    }
}

That last method explains why iterator.remove() is safe: it updates the collection and then resyncs its own expected count.

Why is it best effort? modCount isn't volatile, and there's no locking. Another thread's increment may not be visible, or two increments could race. So across threads you might get the exception, corrupted data, or nothing at all. It's a debugging aid for single-threaded misuse, not a concurrency control.

Also notice set(i, x) doesn't bump modCount, so replacing elements during a for-each is allowed. Same for HashMap.put on an existing key: updating a value isn't structural, adding a new key is.

So in practice, knowing this helps me read a CME stack trace: the throw is in next(), but the bug is whatever modified the collection before it.

23. Optional

BasicWhat is Optional and why was it added?

So Optional<T> (Java 8) is a container that holds either one value or nothing. Its purpose is to make "this method might not return a result" part of the method signature, so the caller is forced to think about the empty case instead of getting surprised by a NullPointerException.

Let me show you with a quick example.

// Before: the signature hides that null is possible
User findUser(String id);

// After: the signature says it
Optional<User> findUser(String id);

String city = findUser("1")
    .map(User::address)
    .map(Address::city)
    .orElse("unknown");      // "Pune"

findUser("2").map(User::address).map(Address::city).orElse("unknown");
// "unknown"

That chain replaces three nested null checks. If any step produces nothing, the rest are skipped and the default kicks in.

The ways to create one:

  • Optional.of(x): x must not be null, or it throws NPE immediately.
  • Optional.ofNullable(x): empty if x is null. This is the one for wrapping legacy code.
  • Optional.empty().

Brian Goetz has described it as intended for return types, for cases where "no result" is a legitimate answer. It wasn't designed as a general replacement for null everywhere.

So in practice I return Optional from finder methods like findById, which is exactly what Spring Data does, and I use plain values and validation everywhere else.

IntermediateorElse vs orElseGet. What's the difference?

So orElse(value) takes an already-computed value, which means its argument is always evaluated, even when the Optional has a value. orElseGet(supplier) takes a lambda that's only called when the Optional is empty.

Let me show you with a quick example.

static String expensiveDefault() {
    System.out.println("  computing default...");
    return "guest";
}

Optional<String> name = Optional.of("laeba");

name.orElse(expensiveDefault());          // prints "computing default..." then laeba
name.orElseGet(Opt::expensiveDefault);    // prints just laeba

When I ran it, orElse printed "computing default..." even though the value was present. That's just Java's normal rule: method arguments are evaluated before the call.

It sounds harmless until the default is a database query, a remote call, or something with side effects. I've seen repo.findByEmail(e).orElse(repo.save(new User(e))) in a code review; it inserted a new user on every call, even when one already existed, and then the unique constraint started failing.

The rule I follow: orElse for constants and cheap values like orElse("unknown") or orElse(0); orElseGet for anything that computes, allocates, or has side effects. For the "throw if missing" case, orElseThrow(() -> new NotFoundException(id)), or the no-arg orElseThrow() from Java 10.

Interviewers look for: "the argument is always evaluated", ideally with a side-effect example like the save bug.

Intermediatemap vs flatMap on Optional, and what else was added after Java 8?

So map takes a function that returns a plain value and wraps it for you. flatMap takes a function that already returns an Optional, and doesn't wrap it again. Use map with the wrong kind of function and you end up with Optional<Optional<T>>.

Let me show you with a quick example.

Optional<String> name = Optional.of("laeba");

name.map(n -> Optional.of(n.toUpperCase()));      // Optional[Optional[LAEBA]]
name.flatMap(n -> Optional.of(n.toUpperCase()));  // Optional[LAEBA]

// Java 9
findUser("2").ifPresentOrElse(
    u -> System.out.println(u.name()),
    () -> System.out.println("not found"));       // not found

findUser("2").or(() -> findUser("1"))             // fallback to another Optional
    .map(User::name);                            // Optional[Laeba]

ids.stream()
    .map(this::findUser)                         // Stream<Optional<User>>
    .flatMap(Optional::stream)                   // drop the empties
    .map(User::name)
    .toList();                                   // [Laeba]

// Java 10 / 11
findUser("2").orElseThrow();    // NoSuchElementException: No value present
findUser("2").isEmpty();        // true

The rule is the same as streams: if my function returns an Optional, like a repository lookup, I use flatMap.

The additions by version: Java 9 brought ifPresentOrElse, or and stream; Java 10 the no-arg orElseThrow(), which is the honest name for get(); Java 11 isEmpty(). Optional::stream is my favourite, because it cleanly turns "a list of lookups" into "the ones that were found".

So in practice I write chains of map, flatMap and filter, and finish with one terminal call.

IntermediateWhat are the common Optional anti-patterns?

So the main ones are: calling get() without checking, using isPresent() plus get() as a verbose null check, using Optional for fields and parameters, wrapping collections, and returning null from a method that returns Optional.

Let me show you the bad and good versions.

// 1. get() without a check: just an NPE with a different name
String n = findUser(id).get().name();
String n2 = findUser(id).map(User::name).orElseThrow(() -> new NotFound(id));

// 2. isPresent + get: a null check with extra steps
if (opt.isPresent()) { use(opt.get()); }
opt.ifPresent(this::use);

// 3. Optional field or parameter
class User { Optional<String> middleName; }        // not Serializable, extra object
void send(Optional<String> cc) { }                // caller can still pass null
// instead: a nullable field with an Optional getter, or overloads

// 4. Optional of a collection
Optional<List<Order>> orders();                     // two ways to say "none"
List<Order> orders();                               // return an empty list

// 5. Returning null from an Optional method
Optional<User> find() { return null; }              // defeats the whole point

A few reasons behind these. Optional isn't Serializable (it's a final class that doesn't implement it, so even instanceof Serializable won't compile), which breaks it as a JPA or DTO field. As a parameter it adds noise and a third state: present, empty, or null. And an empty list already means "nothing", so wrapping it just adds a second check.

Also avoid Optional<Integer> in hot code; OptionalInt skips the boxing.

So in practice: Optional as a return type, handled with map, orElse, orElseGet or orElseThrow, and almost never anywhere else.

Interviewers look for: "return types only" and at least three concrete anti-patterns with the fixes.

AdvancedRefactor this null-check pyramid using Optional. Would you always do that?
String getCity(Order order) {
    if (order != null) {
        Customer c = order.getCustomer();
        if (c != null) {
            Address a = c.getAddress();
            if (a != null && a.getCity() != null) {
                return a.getCity().toUpperCase();
            }
        }
    }
    return "UNKNOWN";
}

So I'd flatten it into one chain, where each map quietly turns a null result into an empty Optional and short-circuits the rest.

String getCity(Order order) {
    return Optional.ofNullable(order)
        .map(Order::getCustomer)
        .map(Customer::getAddress)
        .map(Address::getCity)
        .map(String::toUpperCase)
        .orElse("UNKNOWN");
}

This works because map uses ofNullable on the function's result: if getCustomer() returns null, the chain becomes empty and every later step is skipped. So the behaviour is identical to the pyramid, with one exit point.

Would I always do it? No. The Optional allocations are usually trivial and escape analysis often removes them, but in a tight loop I'd keep plain null checks. More importantly, if my own code has this pyramid, the real fix is upstream: make getCustomer() never return null, validate at construction, or return Optional<Address> from the getter that can legitimately be empty. The chain is great for data I don't control, like DTOs from another service.

And in Java 21 I sometimes prefer pattern matching when I need to branch on the types rather than just default.

So in practice I use the Optional chain at the edges of the system and design the core so that nulls don't reach it.

Interviewers look for: a correct chain, plus the judgement that fixing the design beats wrapping every null.

Part 4 — Functional Java

24. Lambdas & Functional Interfaces

BasicWhat is a functional interface?

So a functional interface is any interface with exactly one abstract method. That single method is what makes it a target for a lambda or a method reference: the lambda body simply becomes the implementation of that method. It came with Java 8, together with lambdas.

Let me show you with a quick example.

@FunctionalInterface
interface Validator {
    boolean validate(String s);                    // the one abstract method

    default Validator and(Validator other) {       // default: doesn't count
        return s -> validate(s) && other.validate(s);
    }
    static Validator notEmpty() {                  // static: doesn't count
        return s -> !s.isEmpty();
    }
    boolean equals(Object o);                      // from Object: doesn't count
}

Validator v = Validator.notEmpty().and(s -> s.length() <= 5);
v.validate("java");     // true
v.validate("kotlin");   // false

The rule is about abstract methods only. Default methods, static methods, and abstract methods that just redeclare something public from Object like equals or toString don't count. That's why Comparator is a functional interface even though it declares equals and has dozens of default methods.

The @FunctionalInterface annotation is optional. Any single-abstract-method interface works with lambdas, including old ones like Runnable. But I always add it on my own interfaces, because then the compiler fails the build if someone later adds a second abstract method and silently breaks every lambda that uses it.

Interviewers look for: "exactly one abstract method", and knowing that default, static and Object methods don't count.

BasicWhich built-in functional interfaces do you use most, and what are they for?

So java.util.function has around 40 interfaces, but they're all variations of four shapes: something that takes and returns (Function), something that tests (Predicate), something that only takes (Consumer) and something that only gives (Supplier).

InterfaceMethodShapeTypical use
Function<T,R>applyT → Rmap
Predicate<T>testT → booleanfilter
Consumer<T>acceptT → voidforEach
Supplier<T>get() → TorElseGet, lazy values
UnaryOperator<T>applyT → TreplaceAll
BiFunction<T,U,R>apply(T, U) → RMap.merge, compute
BinaryOperator<T>apply(T, T) → Treduce

Then there are primitive versions like IntPredicate, ToIntFunction and IntBinaryOperator. They exist purely to avoid boxing: a Function<Integer,Integer> boxes every value, while IntUnaryOperator works on raw ints. On a hot path over millions of elements that difference is measurable.

They also compose, which is handy:

Function<Integer, Integer> plus2  = x -> x + 2;
Function<Integer, Integer> times3 = x -> x * 3;

plus2.andThen(times3).apply(4);   // 18  -> (4 + 2) * 3
plus2.compose(times3).apply(4);   // 14  -> (4 * 3) + 2

Predicate<String> isLong = s -> s.length() > 3;
isLong.negate().test("hi");       // true

So in practice I only write my own functional interface when none of the built-ins fit, usually because I need a checked exception or a more meaningful name in a public API.

IntermediateWhat's the difference between a lambda and an anonymous class?

So they look interchangeable, but they're different in three ways that matter: what this means, how they're compiled, and what they can do. A lambda is just a function; an anonymous class is a real class with its own scope, fields and identity.

Let me show you the this difference, because it's the one people get wrong.

public class Demo {
    String name = "outer";

    void run() {
        Runnable anon = new Runnable() {
            String name = "anon";
            public void run() { System.out.println("anonymous: " + this.name); }
        };
        Runnable lambda = () -> System.out.println("lambda: " + this.name);
        anon.run();
        lambda.run();
    }
}
// anonymous: anon
// lambda: outer

Inside an anonymous class, this is the anonymous object. Inside a lambda, this is the enclosing instance, because a lambda doesn't introduce a new scope. For the same reason a lambda can't declare a local variable that shadows one from the enclosing method, while an anonymous class can.

LambdaAnonymous class
TargetFunctional interfaces onlyAny interface or class, any number of methods
thisEnclosing instanceThe anonymous object
StateNo fieldsCan have fields
Compilationinvokedynamic, no extra .class fileA separate Outer$1.class

On compilation: an anonymous class creates a new object every time the expression runs. A non-capturing lambda is linked once via invokedynamic and the JVM typically reuses a single instance, so it's cheaper.

So in practice I use lambdas by default, and switch to an anonymous class only when I need state, more than one method, or to extend an abstract class.

Interviewers look for: the this difference. Mentioning invokedynamic is a strong bonus.

IntermediateWhat does "effectively final" mean, and why can't a lambda modify a local variable?

So "effectively final" means a local variable that's never reassigned after it's initialised, even though it isn't marked final. Lambdas and anonymous classes (since Java 8) can only capture local variables that are final or effectively final.

Let me show you the error.

int count = 0;
List<String> names = List.of("a", "b", "c");
names.forEach(n -> count++);
// error: local variables referenced from a lambda expression
//        must be final or effectively final

The reason is how capture works. A lambda doesn't get a reference to the variable; it gets a copy of its value. Local variables live on the stack of the method, and the lambda can outlive that method, for example if it's handed to another thread or stored in a field. If Java allowed changing count, you'd have two copies drifting apart, and on top of that a data race if the lambda runs on another thread. Forcing the variable to be effectively final makes the copy indistinguishable from the original.

Note that this only applies to local variables. Fields and array elements can be changed, because the lambda captures a reference to the object, not the value.

int[] box = {0};
names.forEach(n -> box[0]++);   // compiles, box[0] == 3, but it's a hack

long count2 = names.stream().count();   // the right way: 3

So in practice, when I feel the urge to mutate a counter inside a lambda, I take it as a sign I should be using a stream reduction like count, sum or a collector instead. The array or AtomicInteger trick works, but it's not thread-safe with parallel streams unless it's the atomic one.

Interviewers look for: "the lambda captures a copy of the value", and knowing that fields are not restricted.

IntermediateWhere do lambdas help in real code, and when would you avoid them?

So lambdas shine when behaviour is the parameter: sorting with a comparator, filtering, callbacks, strategy objects, lazy defaults. I avoid them when the body grows beyond a few lines or when a stack trace needs to be readable.

Let me give you a real example. In my last project we had a pricing service with a big switch over discount types. We replaced it with a map of strategies:

Map<String, UnaryOperator<BigDecimal>> discounts = Map.of(
    "NONE",    p -> p,
    "TEN_OFF", p -> p.multiply(new BigDecimal("0.90")),
    "FLAT_50", p -> p.subtract(new BigDecimal("50")).max(BigDecimal.ZERO)
);

BigDecimal finalPrice = discounts.getOrDefault(code, p -> p).apply(price);

Adding a new discount became one line, and each strategy was trivially unit-testable.

Where I'm careful:

  • Long lambdas: if it's more than three or four lines I extract a named private method and pass a method reference. The name documents intent, and the stack trace shows a real method name instead of lambda$main$0.
  • Checked exceptions: the built-in interfaces don't declare any, so I/O inside a lambda gets ugly.
  • Debugging: stepping through a long chained pipeline in a debugger is harder than through a loop.

So in practice: small, pure, stateless lambdas everywhere; anything with real logic gets a name.

AdvancedHow do you handle checked exceptions inside a lambda?

So the problem is that Function.apply doesn't declare any checked exceptions, so a lambda passed to map can't throw IOException. There are three real options: catch inside the lambda, wrap it in an unchecked exception, or define your own functional interface that declares throws.

Let me show you the wrapper approach I usually use.

@FunctionalInterface
interface ThrowingFunction<T, R> {
    R apply(T t) throws Exception;
}

static <T, R> Function<T, R> unchecked(ThrowingFunction<T, R> f) {
    return t -> {
        try { return f.apply(t); }
        catch (Exception e) { throw new RuntimeException(e); }
    };
}

try {
    List.of("1", "2", "x").stream()
        .map(unchecked(s -> {
            if (s.equals("x")) throw new IOException("bad file " + s);
            return Integer.parseInt(s);
        }))
        .forEach(System.out::println);
} catch (RuntimeException e) {
    System.out.println("wrapped: " + e.getCause());
}
// 1
// 2
// wrapped: java.io.IOException: bad file x

For I/O specifically, I'd wrap in UncheckedIOException instead of a plain RuntimeException, so the caller can still catch it precisely. That's what Files.lines itself does.

The other choice is design-level: if a failure for one element shouldn't stop the others, I don't throw at all. I map each element to a result object, like a small record with either a value or an error, and partition the results afterwards.

So in practice: wrap and rethrow when one failure should abort everything, and turn failures into data when you want to process the whole batch and report errors at the end.

Interviewers look for: why it fails (the interface's signature), a clean wrapper, and preserving the cause.

25. The Streams API

BasicWhat is a stream, and how is it different from a collection?

So a collection is a data structure that stores elements. A stream, added in Java 8, doesn't store anything: it's a pipeline that describes a computation over a source, like a collection, an array, a file or a generator.

Let me show you with a quick example.

List<String> src = new ArrayList<>(List.of("b", "a"));
List<String> sorted = src.stream().sorted().toList();

System.out.println(src + " " + sorted);   // [b, a] [a, b]

The source list is untouched. The differences I'd list:

  • No storage: a stream pulls elements from its source as needed.
  • Functional: operations produce a new result; they don't modify the source.
  • Lazy: nothing runs until a terminal operation asks for a result.
  • Single use: once consumed, a stream can't be traversed again.
  • Possibly unbounded: Stream.iterate or Stream.generate can be infinite, which a collection never can.
  • Internal iteration: I say what to do; the library controls how it loops, which is what makes parallel() possible.

So in practice I think of collections as "data" and streams as "a query over data." I use a stream to transform, filter or aggregate, and a plain loop when I need early break with complex state or to mutate things as I go.

BasicWhat's the difference between intermediate and terminal operations?

So intermediate operations return another stream and are lazy: they just add a stage to the pipeline. A terminal operation produces a result or a side effect, and it's what actually triggers the whole pipeline to run.

Intermediate (lazy, return Stream)Terminal (eager, end the stream)
filter, map, flatMap, peekcollect, toList, reduce
distinct, sorted (stateful)forEach, count, min, max
limit, skip, takeWhile, dropWhilefindFirst, findAny, anyMatch, allMatch, noneMatch

Let me show you that intermediate operations really don't run on their own.

Stream<String> s = Stream.of("a", "bb", "ccc")
    .peek(x -> System.out.println("peek " + x))
    .filter(x -> x.length() > 1);
System.out.println("pipeline built");
// pipeline built        <- the only output; peek never ran

Two sub-categories are worth mentioning. Stateful intermediate operations like sorted and distinct need to see many or all elements before emitting, which matters for memory and for parallel performance. Short-circuiting operations like limit, findFirst and anyMatch can stop early, which is what lets you work with infinite streams.

So in practice: if a pipeline "does nothing", the first thing I check is whether it has a terminal operation.

Interviewers look for: the word "lazy", and that the terminal operation triggers execution.

IntermediatePredict the output. How does lazy evaluation actually process elements?

So streams don't run stage by stage over the whole list. Each element travels through the entire pipeline one at a time, vertically, and short-circuiting operations stop the pull as soon as they have enough.

List<String> result = Stream.of("one", "two", "three", "four")
    .peek(x -> System.out.println("peek: " + x))
    .filter(x -> { System.out.println("filter: " + x); return x.length() > 3; })
    .map(x -> { System.out.println("map: " + x); return x.toUpperCase(); })
    .limit(1)
    .toList();
System.out.println(result);

The output is:

peek: one
filter: one
peek: two
filter: two
peek: three
filter: three
map: three
[THREE]

Let me walk through it. "one" goes through peek, fails the filter, and is dropped. Same for "two". "three" passes the filter, gets mapped, and reaches limit(1), which now has what it needs, so the pipeline stops. "four" is never even looked at. map ran once, not four times.

A second trap: since Java 9, count() can skip the pipeline entirely if it can compute the size directly from the source.

long n = List.of(1, 2, 3).stream()
    .peek(x -> System.out.println("counted " + x))
    .count();
System.out.println("count = " + n);
// count = 3        <- peek never printed

So in practice I never put real logic in peek. It's a debugging aid, and whether it runs depends on what the rest of the pipeline needs.

Interviewers look for: element-by-element (vertical) processing, limit stopping the pull, and that peek is not guaranteed to run.

IntermediateWhat's the difference between map and flatMap?

So map is one-to-one: each element becomes exactly one new element. flatMap is one-to-many: each element becomes a stream of zero or more elements, and all those streams are flattened into one.

Let me show you with a quick example.

List<List<Integer>> nested = List.of(List.of(1, 2), List.of(3), List.of());

nested.stream().map(List::size).toList();        // [2, 1, 0]
nested.stream().flatMap(List::stream).toList();  // [1, 2, 3]

List<String> lines = List.of("hello world", "java streams");

lines.stream().map(l -> l.split(" ")).toList();  // List<String[]>, size 2
lines.stream()
     .flatMap(l -> Arrays.stream(l.split(" ")))
     .toList();                                   // [hello, world, java, streams]

The classic mistake is the third line. People want words and use map with split, and end up with a stream of arrays. Whenever my mapping function returns a collection, array or stream and I want the contents, that's the signal for flatMap.

The same idea exists on Optional: if the mapping function itself returns an Optional, map gives you Optional<Optional<T>>, and flatMap gives you Optional<T>.

So in practice: map to transform, flatMap to unpack. Real examples from my work are "all order lines across all orders" and "all roles across all users".

Interviewers look for: the split-into-words example and the Stream<String[]> trap.

IntermediateCan a stream be reused?

No. A stream can be consumed only once. After a terminal operation it's closed, and any further operation throws IllegalStateException.

Stream<Integer> nums = Stream.of(1, 2, 3);
System.out.println(nums.count());   // 3
nums.count();
// IllegalStateException: stream has already been operated upon or closed

It's by design. A stream can wrap a one-shot source like a file, a network socket or an iterator, and there's no general way to rewind those. So Java treats every stream as single-pass. The same exception also comes up if you call two intermediate operations on the same stream reference, since each one "uses" it.

If I need to run several pipelines over the same data, I keep the source and create a fresh stream each time, or use a Supplier:

Supplier<Stream<Integer>> nums2 = () -> Stream.of(1, 2, 3);
nums2.get().count();                     // 3
nums2.get().mapToInt(i -> i).sum();      // 6

If the source is expensive, like a big file or a database query, I'd rather do one pass and compute everything together, for example with Collectors.teeing (Java 12) or summaryStatistics(), than re-read it twice.

So in practice I never store a stream in a field or return it to be used twice. I store or return the collection, or a supplier of the stream.

IntermediatefindFirst vs findAny. When does the difference matter?

So findFirst returns the first element in encounter order. findAny is allowed to return any matching element. On a sequential stream they almost always give the same answer; the difference shows up with parallel streams.

Let me show you with a quick example.

List<Integer> list = IntStream.rangeClosed(1, 1000).boxed().toList();

list.stream().filter(x -> x % 7 == 0).findFirst();        // Optional[7], always

list.parallelStream().filter(x -> x % 7 == 0).findAny();  // some multiple of 7, not necessarily 7

When I ran the parallel findAny 200 times I got several different answers. Each worker thread handles a chunk of the list, and whichever thread finds a match first wins.

Why use findAny then? Because in parallel, findFirst has to coordinate: even if a thread working on the second half finds a match early, the pipeline has to wait to make sure nothing earlier matches. findAny can return as soon as any thread finds something.

Both return an Optional, because the stream might be empty or nothing might match.

So in practice: I use findFirst when order is part of the meaning, like "earliest failed payment", and findAny when I just need an example, like "is there any admin, give me one", especially on a parallel stream.

AdvancedWhat are the pitfalls of parallel streams?

So parallel() looks like a free speed-up, but it often isn't. It splits the work across the common ForkJoinPool, and it's only a win when the data is large, splits cheaply, the per-element work is CPU-heavy and independent, and there's no shared mutable state.

Let me show you the most common bug, which is shared mutable state.

List<Integer> unsafe = new ArrayList<>();
IntStream.range(0, 10_000).parallel().forEach(unsafe::add);
// size is often less than 10,000, or ArrayIndexOutOfBoundsException

List<Integer> safe = IntStream.range(0, 10_000).parallel().boxed().toList();
// always 10,000, in order

I ran the unsafe version 20 times and 18 runs were wrong. ArrayList isn't thread-safe. The fix is to let the stream build the result with a collector, never to add into an outside collection.

The other pitfalls I watch for:

  • Shared common pool: every parallel stream in the JVM uses the same ForkJoinPool.commonPool(), sized to CPU cores minus one. In one service we had a parallel stream doing blocking HTTP calls. It starved the pool, and unrelated parallel code across the app slowed down. Blocking I/O never belongs in a parallel stream.
  • Bad sources: ArrayList, arrays and IntStream.range split well. LinkedList, Stream.iterate and I/O-based streams split badly.
  • Order-dependent operations: findFirst, limit and sorted are expensive in parallel. forEach doesn't respect order; forEachOrdered does, but gives up much of the gain.
  • Small data: splitting and merging costs more than it saves for a few thousand cheap elements.
  • Non-associative reduce: reduce in parallel needs an associative function and a true identity value, or results change between runs.

So in practice I default to sequential, and only go parallel after measuring with a real benchmark, like JMH, on production-sized data.

Interviewers look for: shared mutable state, the common ForkJoinPool, and "measure first".

26. Collectors & Method References

BasicWhat are the four kinds of method references?

So a method reference is shorthand for a lambda that just calls one existing method. There are four kinds, and the difference is where the receiver object comes from.

Function<String, Integer> parse = Integer::parseInt;   // 1. static method
String prefix = "Hi ";
Function<String, String> greet = prefix::concat;       // 2. bound instance: a specific object
Function<String, Integer> len = String::length;        // 3. unbound instance: the argument is the receiver
Supplier<List<String>> mk = ArrayList::new;             // 4. constructor

parse.apply("42");    // 42
greet.apply("Ana");   // "Hi Ana"
len.apply("four");    // 4
mk.get();             // []

The one people mix up is 2 vs 3. With prefix::concat, the object is fixed when the reference is created, and the argument is passed to it. With String::length, there's no object yet: the first parameter becomes the receiver. So String::length is s -> s.length(), and a two-argument version like BiFunction<String,String,Boolean> eq = String::equalsIgnoreCase is (a, b) -> a.equalsIgnoreCase(b).

Array constructors work too: int[]::new is n -> new int[n], which is why you see toArray(String[]::new).

One subtle difference from a lambda: with a bound reference like obj::method, obj is evaluated immediately, so if it's null you get the NullPointerException when creating the reference, not when calling it.

So in practice I use a method reference when it's clearer than the lambda, and a lambda when the reference would hide what's happening.

Interviewers look for: the bound vs unbound distinction, explained with the implicit first parameter.

IntermediateWrite it: group employees by department and find the average salary.

So this is groupingBy with a downstream collector. The classifier picks the key, and the downstream collector decides what to do with each group's elements.

record Employee(String name, String dept, double salary) {}

List<Employee> emps = List.of(
    new Employee("Asha",  "Eng",   120_000),
    new Employee("Ravi",  "Eng",   100_000),
    new Employee("Meera", "Sales",  70_000),
    new Employee("John",  "Sales",  90_000),
    new Employee("Li",    "HR",     60_000),
    new Employee("Sam",   "Eng",   120_000));

Map<String, Double> avg = emps.stream()
    .collect(Collectors.groupingBy(
        Employee::dept,
        TreeMap::new,                                // sorted keys
        Collectors.averagingDouble(Employee::salary)));
// {Eng=113333.33333333333, HR=60000.0, Sales=80000.0}

I passed TreeMap::new so the output order is predictable; the plain two-argument version gives a HashMap with no guaranteed order.

Interviewers usually follow up with variations, and they're all just different downstream collectors:

// count per department
groupingBy(Employee::dept, counting())             // {Eng=3, HR=1, Sales=2}

// names per department
groupingBy(Employee::dept, mapping(Employee::name, toList()))
                                                   // {Eng=[Asha, Ravi, Sam], HR=[Li], Sales=[Meera, John]}

// highest paid per department
groupingBy(Employee::dept, maxBy(comparingDouble(Employee::salary)))
                                                   // Eng -> Asha, HR -> Li, Sales -> John

Note that maxBy returns Optional<Employee> per group. If I don't want that, I wrap it with collectingAndThen(maxBy(...), Optional::get), which is safe here because a group only exists if it has at least one element.

For real money I'd use BigDecimal, not double, but for this exercise double is what's expected.

Interviewers look for: fluency with downstream collectors, and awareness that counting() returns Long.

IntermediateWrite it: find the second highest salary.

So the key detail is distinct(). "Second highest salary" means the second highest distinct value, and if two people share the top salary, skipping one element without de-duplicating gives the wrong answer.

Optional<Double> second = emps.stream()
    .map(Employee::salary)
    .distinct()
    .sorted(Comparator.reverseOrder())
    .skip(1)
    .findFirst();
// Optional[100000.0]

// Without distinct() -- WRONG when there's a tie at the top:
// Asha and Sam both earn 120,000, so this returns Optional[120000.0]

Using the same employee list as before, Asha and Sam both earn 120,000, so the naive version returns 120,000 again. That's exactly the trap interviewers set.

I return an Optional because with fewer than two distinct salaries there is no answer, and I'd rather make the caller decide than return 0 or throw. The same pattern works on an int array:

int[] arr = {5, 9, 3, 9, 7};
Arrays.stream(arr).boxed()
    .distinct()
    .sorted(Comparator.reverseOrder())
    .skip(1)
    .findFirst()
    .orElse(-1);          // 7

If they ask for the employee record, not just the number, I'd find the salary this way and then filter employees with that salary, since several people could share it.

Complexity is O(n log n) because of the sort. If they push for O(n), I'd drop streams and do a single loop tracking the top two distinct values. So in practice I give the stream version first for readability and mention the loop as the optimisation.

IntermediateWrite it: count the frequency of each character in a string.

So I'd turn the string into a stream of characters and use groupingBy with counting(). The one detail is the map type: I use LinkedHashMap so the result keeps first-appearance order, which also makes follow-up questions easy.

String text = "programming";

Map<Character, Long> freq = text.chars()               // IntStream of code units
    .mapToObj(c -> (char) c)                             // box to Character
    .collect(Collectors.groupingBy(
        Function.identity(),
        LinkedHashMap::new,
        Collectors.counting()));
// {p=1, r=2, o=1, g=2, a=1, m=2, i=1, n=1}

Two things to point out. chars() gives an IntStream, so I cast back to char before boxing, otherwise the keys would be numbers. And counting() returns Long, not Integer, which trips people up when they declare Map<Character, Integer> and get a compile error.

Because the map is ordered, the common follow-ups are one line each:

// first non-repeating character
freq.entrySet().stream()
    .filter(e -> e.getValue() == 1)
    .map(Map.Entry::getKey)
    .findFirst();                          // Optional[p]

// most frequent character
freq.entrySet().stream()
    .max(Map.Entry.comparingByValue());    // r=2 (first of the ties)

The same groupingBy plus counting() pattern counts words: split on spaces and group by the word itself.

So in practice, in an interview I'd write the stream version, but if they ask for the fastest version on lowercase ASCII, I'd use an int[26] array, which avoids boxing entirely.

Interviewers look for: the cast from int to char, Long from counting, and choosing LinkedHashMap on purpose.

IntermediateWrite it: remove duplicates from a list while keeping the original order.

So there are two clean ways: stream().distinct() or a LinkedHashSet. Both keep the first occurrence of each element in its original position. The mistake to avoid is a plain HashSet, which removes duplicates but gives no ordering guarantee.

List<Integer> withDups = List.of(4, 1, 4, 2, 1, 3);

withDups.stream().distinct().toList();          // [4, 1, 2, 3]
new ArrayList<>(new LinkedHashSet<>(withDups)); // [4, 1, 2, 3]

Both rely on equals and hashCode, so for my own classes those must be implemented correctly. Records do that automatically.

The harder follow-up is "remove duplicates by a field", like keeping one employee per department. distinct() can't do that because it compares whole objects. I use toMap with a merge function that keeps the first, into a LinkedHashMap:

List<Employee> onePerDept = emps.stream()
    .collect(Collectors.toMap(
        Employee::dept,
        e -> e,
        (first, second) -> first,       // keep the first one seen
        LinkedHashMap::new))
    .values().stream().toList();
// Asha, Meera, Li

So in practice: distinct() when I'm already in a stream, LinkedHashSet when I just have a list, and toMap with a merge function when uniqueness is by key.

AdvancedWhat happens with Collectors.toMap when there's a duplicate key?

So the two-argument toMap throws IllegalStateException on a duplicate key. It refuses to guess which value you want. This is a very common production bug, because the code works in testing with clean data and fails the first time real data has a duplicate.

emps.stream().collect(Collectors.toMap(Employee::dept, Employee::name));
// IllegalStateException: Duplicate key Eng
//   (attempted merging values Asha and Ravi)

The fix is the three-argument version with a merge function, and optionally a fourth argument for the map type:

Map<String, String> merged = emps.stream()
    .collect(Collectors.toMap(
        Employee::dept,
        Employee::name,
        (a, b) -> a + "," + b,      // or (a, b) -> a to keep the first
        TreeMap::new));
// {Eng=Asha,Ravi,Sam, HR=Li, Sales=Meera,John}

The second trap is null values. toMap rejects a null value with a NullPointerException, even though HashMap itself allows null values. If a value can be null, I map it to a default or filter it out first.

In my last project we had a job that built a map of user email to user. It ran fine for months and then crashed because two accounts had been created with the same email. We fixed it by deciding the business rule explicitly, keeping the most recently active account, in the merge function, and adding a log line when a duplicate was merged.

So in practice, every time I write toMap I ask "can this key repeat?" If yes, I either write a merge function with a real rule, or switch to groupingBy if I actually want all the values.

Interviewers look for: the exception type, the merge function, and the null-value NPE as a bonus.

AdvancedStream.toList() vs Collectors.toList() vs toUnmodifiableList(). Are they the same?

No, and the differences matter when code is migrated. Collectors.toList() (Java 8) returns a list with no guarantees, which in practice is a mutable ArrayList. Stream.toList() (Java 16) returns an unmodifiable list that allows nulls. Collectors.toUnmodifiableList() (Java 10) returns an unmodifiable list that rejects nulls.

List<Integer> a = Stream.of(1, 2).collect(Collectors.toList());
a.add(3);                       // works: [1, 2, 3]

List<Integer> b = Stream.of(1, 2).toList();
b.add(3);                       // UnsupportedOperationException

Stream.of(1, null).toList();                                // [1, null]
Stream.of(1, null).collect(Collectors.toUnmodifiableList()); // NullPointerException
SinceMutableNulls
Collectors.toList()8Yes in practice (not guaranteed)Allowed
Collectors.toUnmodifiableList()10NoNPE
Stream.toList()16NoAllowed

The real risk: someone does a "cleanup" refactor replacing .collect(Collectors.toList()) with .toList() everywhere. It compiles, tests pass, and then some caller three layers away does list.add(...) or Collections.sort(list) and blows up at runtime.

A related one: partitioningBy always returns both true and false keys, even when one is empty, while groupingBy on a boolean only includes keys that actually occurred.

So in practice I use toList() for new code, since immutable results are safer, and when I need to mutate I say so explicitly with Collectors.toCollection(ArrayList::new).

27. Date & Time API

BasicWhy was java.util.Date replaced? LocalDate vs Date.

So java.util.Date was badly designed: it's mutable, not thread-safe, its name is misleading, and its API is full of traps. Java 8 added java.time, based on Joda-Time, where every type is immutable and thread-safe and each one represents exactly one concept.

Let me show you the traps.

new Date(2024, 1, 15);
// Fri Feb 15 00:00:00 UTC 3924
//   year is offset from 1900, month is 0-based

Date shared = new Date(0);
shared.setTime(86_400_000L);  // anyone holding this reference sees the change

LocalDate jan15 = LocalDate.of(2024, 1, 15);   // month is 1-based, year is real
LocalDate next  = jan15.plusMonths(1);          // 2024-02-15, a NEW object
jan15.plusDays(10);                             // result ignored...
System.out.println(jan15);                      // 2024-01-15, unchanged

The name is the other problem. Date isn't a date at all; it's an instant, a number of milliseconds since 1970 UTC, which prints itself in the JVM's default time zone. LocalDate really is just a date, with no time and no zone, like a birthday.

Mutability caused real bugs: a class exposing a Date field through a getter could be modified by any caller, so you needed defensive copies everywhere. And SimpleDateFormat isn't thread-safe, which produced corrupted dates when it was shared as a static field.

So in practice I only touch Date at the boundary with old libraries, converting with date.toInstant() and Date.from(instant), and use java.time everywhere else.

Interviewers look for: mutability and thread safety as the main reasons, and that Date is really an instant.

BasicLocalDateTime, ZonedDateTime, OffsetDateTime, Instant. Which one do you use when?

So they differ in how much context they carry. Instant is a point on the global timeline in UTC. The "Local" types have no zone at all. ZonedDateTime adds a real region with its daylight-saving rules, and OffsetDateTime adds only a fixed offset.

Instant inst = Instant.parse("2024-03-10T14:30:00Z");

inst.atZone(ZoneId.of("Asia/Kolkata"));      // 2024-03-10T20:00+05:30[Asia/Kolkata]
inst.atZone(ZoneId.of("America/New_York"));  // 2024-03-10T10:30-04:00[America/New_York]
inst.atOffset(ZoneOffset.ofHours(2));        // 2024-03-10T16:30+02:00
LocalDateTime.ofInstant(inst, ZoneId.of("Asia/Kolkata"));  // 2024-03-10T20:00  (zone dropped)
TypeRepresentsUse for
InstantMoment in UTCTimestamps: createdAt, event logs, storing in DB
LocalDateDate onlyBirthdays, holidays, due dates
LocalDateTimeDate + time, no zone"Meeting at 10:00" before you know where
ZonedDateTimeDate + time + regionScheduling and calendar math for real people
OffsetDateTimeDate + time + fixed offsetAPIs, JSON, JDBC TIMESTAMP WITH TIME ZONE

The mistake I see most is using LocalDateTime for timestamps. It has no zone, so "2024-03-10T20:00" could be any of 24-plus moments. If servers run in different zones, or during a DST change, the data becomes ambiguous.

So in practice: store and transmit Instant (or OffsetDateTime in UTC), and convert to ZonedDateTime only at the edge, when showing it to a user in their zone.

IntermediateHow do you handle time zones and daylight saving time correctly?

So the rules I follow are: store everything as UTC instants, keep the user's zone as a ZoneId like "Europe/London" rather than a fixed offset, and do calendar arithmetic in ZonedDateTime, which knows the DST rules.

Let me show you why "one day" and "24 hours" aren't the same thing. In New York, clocks jumped forward at 2 a.m. on 10 March 2024.

ZonedDateTime before = ZonedDateTime.of(2024, 3, 9, 12, 0, 0, 0,
                                        ZoneId.of("America/New_York"));

before.plusDays(1);    // 2024-03-10T12:00-04:00  same wall-clock time
before.plusHours(24);  // 2024-03-10T13:00-04:00  exactly 24 real hours
Duration.between(before, before.plusDays(1));   // PT23H  that "day" was 23 hours

ZonedDateTime.of(2024, 3, 10, 2, 30, 0, 0, ZoneId.of("America/New_York"));
// 2024-03-10T03:30-04:00  2:30 didn't exist, so it was shifted forward

So plusDays means calendar days, which is what a user expects for "remind me same time tomorrow". plusHours means physical time, which is what you want for "session expires in 24 hours".

In a previous project we had a daily report scheduled with a fixed offset of +05:30 for India, which was fine. Then we reused the same code for a UK customer with +00:00, and twice a year their report ran an hour off. Switching from ZoneOffset to ZoneId.of("Europe/London") fixed it, because a region ID tracks DST and a fixed offset doesn't.

So in practice: UTC in the database, ZoneId per user, and I never rely on the server's default zone. I set -Duser.timezone=UTC on servers so nothing depends on where the box happens to be.

Interviewers look for: "store UTC", region ID vs fixed offset, and a DST example.

IntermediateWhat's the difference between Duration and Period?

So Duration is an exact amount of time in seconds and nanoseconds, meant for machine time. Period is an amount in years, months and days, meant for human calendar time. The difference is that "one month" or even "one day" doesn't have a fixed length.

Period age = Period.between(LocalDate.of(1995, 8, 20), LocalDate.of(2024, 3, 10));
// P28Y6M19D  -> age.getYears() == 28

ChronoUnit.DAYS.between(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 3, 1));  // 60

Duration.ofMinutes(135);         // PT2H15M
Period.ofMonths(1).plusDays(3);  // P1M3D

LocalDate.of(2024, 1, 31).plusMonths(1);  // 2024-02-29  (clamped to month end)
LocalDate.of(2023, 1, 31).plusMonths(1);  // 2023-02-28

A few things I'd point out. To compute someone's age, Period.between(...).getYears() is correct; dividing days by 365 is not, because of leap years. And Period isn't normalised to a total: getDays() returns just the days part (19 above), not the total days. For total days I use ChronoUnit.DAYS.between.

Month arithmetic clamps to the end of the month, as you see with 31 January plus one month. That's usually right for billing dates, but I always mention it to product people because it surprises them.

So in practice: Duration for timeouts, SLAs and measuring elapsed time; Period for subscriptions, ages and "30 days from now" on a calendar.

IntermediateHow do you format and parse dates? Why is DateTimeFormatter better than SimpleDateFormat?

So DateTimeFormatter is immutable and thread-safe, so I can keep one as a static final constant and share it across threads. SimpleDateFormat keeps internal mutable state while parsing, so sharing one instance across threads gives wrong dates or random exceptions.

static final DateTimeFormatter F =
    DateTimeFormatter.ofPattern("dd MMM yyyy, HH:mm", Locale.ENGLISH);

LocalDateTime.of(2024, 3, 10, 9, 5).format(F);     // "10 Mar 2024, 09:05"
LocalDate.parse("10/03/2024", DateTimeFormatter.ofPattern("dd/MM/yyyy"));  // 2024-03-10
LocalDate.parse("2024-02-30");                     // DateTimeParseException

// The famous pattern bug: YYYY is week-based year, not calendar year
LocalDate d = LocalDate.of(2024, 12, 30);
d.format(DateTimeFormatter.ofPattern("YYYY-MM-dd"));  // 2025-12-30  wrong!
d.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));  // 2024-12-30

That YYYY bug is worth knowing. For the last few days of December, the week-based year is already next year, so the date prints a year ahead. It only shows up at year end, which is why it has caused real outages in production systems.

Other things I keep in mind: MM is month and mm is minutes; HH is 24-hour and hh is 12-hour. I always pass a Locale when using month names, otherwise the output depends on the server's language. And for APIs I prefer the ISO formats, which are the default for toString and parse.

So in practice: ISO-8601 for anything machine-to-machine, static final formatters with an explicit locale for display.

Interviewers look for: thread safety of the formatter, and bonus points for the YYYY vs yyyy trap.

AdvancedHow do you make code that depends on the current time testable?

So I never call LocalDate.now() or Instant.now() with no arguments inside business logic. I inject a java.time.Clock, and in tests I pass a fixed clock. Every now() method in java.time has an overload that takes a Clock.

class SubscriptionService {
    private final Clock clock;
    SubscriptionService(Clock clock) { this.clock = clock; }

    boolean isExpired(LocalDate expiry) {
        return LocalDate.now(clock).isAfter(expiry);
    }
}

// production
new SubscriptionService(Clock.systemUTC());

// test: freeze time at a tricky moment
Clock fixed = Clock.fixed(Instant.parse("2024-12-31T23:59:00Z"), ZoneOffset.UTC);
LocalDate.now(fixed);                                       // 2024-12-31
new SubscriptionService(fixed).isExpired(LocalDate.of(2024, 12, 30));   // true

Without this, tests around expiry, year end or DST are either impossible or flaky. I've seen a test suite that failed only between midnight and 1 a.m., because a test built "today" in one place and compared it with "today" computed a moment later in a different zone.

A fixed clock lets me test exact boundaries: one second before expiry, the moment of expiry, leap day, the DST gap. Clock.offset(base, Duration.ofDays(30)) lets me "jump forward" in a test. In Spring, I just declare a Clock bean and inject it.

So in practice, time is a dependency like any other: inject it, and never read it from a static call deep inside the logic.

Part 5 — Concurrency

28. Threads & Their Lifecycle

BasicWhat's the difference between a process and a thread?

So a process is a running program with its own memory space, managed by the OS. A thread is a path of execution inside a process. All threads of a process share its heap, but each thread has its own stack and program counter.

ProcessThread
MemoryOwn, isolated address spaceShares heap with other threads; own stack
Creation costHeavyMuch lighter (a platform thread still costs ~1 MB stack reserve)
CommunicationIPC: pipes, sockets, filesShared objects in memory
FailureA crash is isolatedAn OutOfMemoryError or corrupted state can affect every thread
In JavaOne JVM = one processThread, executors, virtual threads

In Java terms: when I start a JVM, that's one OS process. Inside it there's the main thread plus JVM threads for GC, JIT compilation and so on. Each Java platform thread maps one-to-one to an OS thread. Since Java 21, virtual threads are the exception: many virtual threads are scheduled on a small pool of OS threads.

The shared heap is both the benefit and the danger. Threads can communicate simply by reading and writing the same objects, which is fast, but that's also where race conditions and visibility problems come from.

So in practice, the reason we even talk about synchronized, volatile and concurrent collections is that threads share memory. Processes don't have that problem, but they pay for it in communication cost.

BasicHow do you create a thread? Runnable vs Callable.

So there are a few ways: extend Thread, pass a Runnable to a Thread, or, the way I actually do it, submit a Runnable or Callable to an ExecutorService. Since Java 21 there's also Thread.ofVirtual().

The difference between the two task types: Runnable.run() returns nothing and can't throw checked exceptions. Callable.call() returns a value and can throw any exception. You get the result or the exception back through a Future.

Thread t = new Thread(() -> System.out.println("hello"), "worker-1");
t.start();

Callable<Integer> task = () -> 6 * 7;
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {   // try-with-resources: Java 19+
    Future<Integer> f = pool.submit(task);
    System.out.println(f.get());            // 42

    Future<?> r = pool.submit(() -> { throw new IllegalStateException("boom"); });
    try { r.get(); }
    catch (ExecutionException e) {
        System.out.println(e.getCause());   // java.lang.IllegalStateException: boom
    }
}

Note that the exception thrown inside the task doesn't disappear; it's wrapped in ExecutionException and rethrown when I call get(). That's a classic bug: people submit a task and never call get(), so failures are silently swallowed.

I prefer implementing Runnable over extending Thread, because it separates the task from the mechanism that runs it, and Java has single inheritance.

So in practice I almost never create raw threads. I use an executor, Callable when I need a result, and CompletableFuture when I want to compose results.

Interviewers look for: return value, checked exceptions, and how Future delivers the exception.

BasicWhat's the difference between calling start() and run()?

So start() creates a new thread and that new thread calls run(). Calling run() directly is just a normal method call on the current thread; no new thread is created at all.

Let me show you with a quick example.

Thread t = new Thread(
    () -> System.out.println("running in " + Thread.currentThread().getName()),
    "worker-1");

t.run();     // running in main
t.start();   // running in worker-1
t.join();

t.start();   // IllegalThreadStateException

The first call prints main, which proves it ran on the calling thread. That's the bug interviewers want you to spot: code that calls run() looks like it's working, but everything happens sequentially, and there's no concurrency at all.

The last line matters too. A thread can be started only once. Once it has terminated, it can't be restarted; calling start() again throws IllegalThreadStateException. If I need to run the same work again, I create a new thread or, better, submit the task to an executor again.

So in practice: start() to launch, join() to wait for it, and never run() unless I actually want to execute the task inline, for example in a unit test.

IntermediateWhat are the thread states in Java?

So Thread.State has six values: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING and TERMINATED.

StateMeaningHow it gets there
NEWCreated, not startednew Thread(...)
RUNNABLERunning or ready to runstart(); also while doing blocking I/O
BLOCKEDWaiting to enter a synchronized blockAnother thread holds the monitor
WAITINGWaiting indefinitely for another threadwait(), join(), LockSupport.park()
TIMED_WAITINGWaiting with a timeoutsleep(ms), wait(ms), join(ms)
TERMINATEDFinishedrun() returned or threw

Let me show you a sequence I verified:

Object lock = new Object();
Thread s = new Thread(() -> {
    synchronized (lock) {
        try { lock.wait(); } catch (InterruptedException e) {}
    }
});
s.getState();        // NEW
s.start();
// after a moment:   WAITING        (inside wait())

synchronized (lock) {
    lock.notify();
    // s is now      BLOCKED        (woken, but needs the lock back, which main holds)
}
s.join();
s.getState();        // TERMINATED

Two subtleties interviewers like. First, there's no separate "RUNNING" state; a thread using the CPU and one waiting for a CPU are both RUNNABLE. Second, a thread blocked on socket I/O also shows as RUNNABLE, which surprises people reading thread dumps.

So in practice, when I read a thread dump during an incident, many threads BLOCKED on the same monitor means lock contention, and many WAITING on a pool's queue usually just means idle workers.

Interviewers look for: BLOCKED vs WAITING, and that a notified thread goes to BLOCKED before it can run again.

IntermediateWhat's the difference between sleep() and wait()?

So the key difference is the lock. Thread.sleep() pauses the current thread and keeps any locks it holds. wait() must be called while holding an object's monitor, and it releases that monitor while waiting, so other threads can get in and change the state.

sleep()wait()
Defined onThread (static)Object
LockKeeps itReleases it, reacquires before returning
Needs synchronized?NoYes, else IllegalMonitorStateException
Wakes up onTimeout or interruptnotify/notifyAll, timeout, interrupt, or spuriously
PurposePauseWait for a condition set by another thread

Let me show you the correct wait pattern:

class Mailbox {
    private String message;

    synchronized void put(String m) { message = m; notifyAll(); }

    synchronized String take() throws InterruptedException {
        while (message == null) wait();   // while, not if
        String m = message; message = null; return m;
    }
}

I always wait in a while loop, never an if. Threads can wake up spuriously, and with notifyAll another thread might grab the message first, so I must recheck the condition after waking.

The bug to avoid is sleeping inside a synchronized block to "wait for something": every other thread needing that lock stalls for the full sleep.

So in practice I rarely use wait/notify directly anymore. A BlockingQueue, CountDownLatch or CompletableFuture expresses the same thing more safely.

Interviewers look for: "wait releases the lock, sleep doesn't", and the while loop.

IntermediateHow do you stop a thread?

So you can't safely force a thread to stop. Thread.stop() was deprecated long ago because it kills the thread mid-operation and can leave shared objects half-updated; in Java 20+ it just throws UnsupportedOperationException. The correct way is cooperative: call interrupt(), and have the thread check its interrupted status and exit cleanly.

Thread worker = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            Thread.sleep(50);          // do a unit of work
        } catch (InterruptedException e) {
            // the flag was CLEARED when the exception was thrown
            System.out.println("interrupted during sleep, flag now: "
                + Thread.currentThread().isInterrupted());   // false
            Thread.currentThread().interrupt();              // restore it
        }
    }
    System.out.println("worker exiting cleanly");
});
worker.start();
Thread.sleep(200);
worker.interrupt();
worker.join();

What interrupt() does: it sets a flag. If the thread is blocked in sleep, wait, join or a blocking queue call, that call throws InterruptedException right away and the flag is cleared. That's the trap in the example: if I just caught the exception and continued, the loop condition would still see false and never exit. So I restore the flag.

The worst thing you can do is an empty catch (InterruptedException e) {}. It swallows the stop request, and things like executor.shutdownNow() stop working.

So in practice: in library code I either rethrow InterruptedException or restore the flag; with executors I call shutdownNow() or future.cancel(true), which interrupt the worker for me. For a simple loop a volatile boolean running flag also works, but it can't wake a thread out of sleep, so interrupt is better.

Interviewers look for: cooperative cancellation, the flag being cleared, and restoring it.

IntermediateWhat is a daemon thread?

So a daemon thread is a background thread that doesn't keep the JVM alive. The JVM exits when all non-daemon threads have finished, and any daemon threads still running are simply abandoned. The garbage collector threads are daemon threads.

Thread d = new Thread(() -> {
    while (true) {
        try { Thread.sleep(1000); } catch (InterruptedException e) { return; }
    }
});
d.setDaemon(true);       // must be called BEFORE start()
d.start();

d.isDaemon();                          // true
Thread.currentThread().isDaemon();     // false  (main is a user thread)
Thread.ofVirtual().unstarted(() -> {}).isDaemon();   // true  (virtual threads always are)
// main returns -> JVM exits, even though d loops forever

Some rules: setDaemon must be called before the thread starts, otherwise it throws IllegalThreadStateException. A new thread inherits the daemon status of the thread that created it. And virtual threads (Java 21) are always daemon.

The danger is that daemon threads are killed abruptly: no finally blocks run and no buffers are flushed. In one project, an audit logger ran on a daemon thread writing to a buffered file, and on shutdown the last few seconds of audit events were lost. We fixed it by making it a normal thread and stopping it from a shutdown hook, which flushed and closed the file.

So in practice, daemon threads are fine for things that are safe to lose, like a cache-refresh or metrics sampler, but never for work that must complete, like writes or sending messages.

29. synchronized, volatile & the Java Memory Model

BasicWhy is count++ not thread-safe?

So count++ looks like one operation but it's three: read the value, add one, write it back. If two threads interleave those steps, both read the same old value and one increment is lost. That's called a race condition, specifically a lost update.

Thread A: read count (5)
Thread B: read count (5)
Thread A: write 6
Thread B: write 6        <- one increment lost

Let me show you what happens with two threads each incrementing 100,000 times.

static int count = 0;
static volatile int volCount = 0;
static int syncCount = 0;
static final AtomicInteger atomic = new AtomicInteger();
static synchronized void inc() { syncCount++; }

// two threads, each running the loop 100,000 times:
count++;                  // plain:        164827  (different every run)
volCount++;               // volatile:     194303  (still wrong!)
inc();                    // synchronized: 200000
atomic.incrementAndGet(); // atomic:       200000

The interesting line is volatile. People assume it fixes this, but it doesn't. volatile guarantees each read sees the latest write, but it doesn't make read-modify-write atomic. Two threads can still both read 5 and both write 6.

The fixes are synchronized, which makes the three steps mutually exclusive, or AtomicInteger, which does it with a single hardware compare-and-swap instruction and no lock.

So in practice, for a simple counter I use AtomicInteger, or LongAdder if many threads hammer it; and when several fields must change together, I use a lock.

Interviewers look for: read-modify-write, and knowing that volatile alone doesn't fix it.

Basicsynchronized method vs synchronized block. Which do you prefer?

So both acquire a monitor lock, and both give mutual exclusion and visibility. The differences are which lock and how much code it covers. A synchronized instance method locks this for the whole method; a synchronized block lets me choose the lock object and lock only the critical section.

// Method: locks 'this' for everything, including work that doesn't need it
public synchronized void add(String item, int qty) { ... }

// Block: private lock, minimal critical section
class Inventory {
    private final Object lock = new Object();
    private final Map<String, Integer> stock = new HashMap<>();

    void add(String item, int qty) {
        String key = item.trim().toLowerCase();   // no lock needed
        synchronized (lock) {
            stock.merge(key, qty, Integer::sum);  // only the shared state
        }
    }
}

I prefer the block with a private final lock for two reasons. First, less time holding the lock means less contention. Second, and more important, this is public: any outside code can do synchronized (inventory) and interfere with my locking, or even cause a deadlock. Nobody outside can touch a private lock object.

Both are reentrant: a thread already holding the lock can enter another synchronized section on the same lock without deadlocking itself.

So in practice, for small classes where the whole method is the critical section, a synchronized method is fine and more readable. For anything shared widely or with expensive non-shared work, I use a block on a private lock.

IntermediateWhat's the difference between a class-level lock and an object-level lock?

So an object-level lock is the monitor of one instance: a synchronized instance method or synchronized (this). A class-level lock is the monitor of the Class object: a static synchronized method or synchronized (Counter.class). There's one class lock per class, but one object lock per instance, and the two are completely independent.

Let me show you with timings I measured. Each method sleeps 300 ms while holding its lock.

class Counter {
    synchronized void instanceMethod() throws InterruptedException { Thread.sleep(300); }
    static synchronized void staticMethod() throws InterruptedException { Thread.sleep(300); }
}

// thread 1: c1.instanceMethod()   thread 2: c2.instanceMethod()   -> ~300 ms (parallel)
// thread 1: Counter.staticMethod() thread 2: Counter.staticMethod() -> ~600 ms (serialised)
// thread 1: c1.instanceMethod()   thread 2: Counter.staticMethod() -> ~300 ms (parallel!)

The first case runs in parallel because the two threads lock different objects. The second is serialised because there's only one class lock. The third is the trick question: instance and static synchronized methods don't block each other, because they use different locks.

That third case causes real bugs. If an instance method and a static method both modify the same static field, it isn't protected at all, even though both methods say synchronized.

So in practice: protect instance state with an instance lock, protect static state with the class lock, and make sure every access to a given piece of state uses the same lock.

Interviewers look for: that static and instance synchronized methods don't block each other.

Intermediatevolatile vs synchronized vs atomic variables. When do you use each?

So they solve different parts of the problem. volatile gives visibility and ordering but not atomicity. synchronized gives visibility and mutual exclusion over any block of code. Atomic classes give lock-free atomicity for single-variable updates.

Let me show you the visibility problem that volatile fixes.

static boolean running = true;             // NOT volatile

Thread spinner = new Thread(() -> { long n = 0; while (running) n++; });
spinner.start();
Thread.sleep(500);
running = false;
spinner.join(2000);
spinner.isAlive();    // true -- it never saw the change

static volatile boolean vRunning = true;   // same code with volatile
// isAlive() -> false, it stops immediately

Without volatile, the JIT is allowed to hoist the read of running out of the loop, so the thread spins forever. On my machine it was still running after two seconds. With volatile, every read goes to main memory.

VisibilityAtomic compound opsBlocks?Use for
volatileYesNoNoFlags, publishing an immutable object
AtomicInteger etc.YesSingle variableNo (CAS)Counters, sequence numbers
synchronizedYesAny code blockYesInvariants across several fields

So in practice: volatile when one thread writes and others only read, like a shutdown flag; atomics for counters and single-variable updates; a lock whenever two or more fields must change together, like a balance and a transaction list.

Interviewers look for: visibility vs atomicity, and a correct use case for each.

IntermediateWhat is the happens-before relationship?

So happens-before is the core rule of the Java Memory Model. If action A happens-before action B, then everything A wrote is guaranteed to be visible to B, and they won't appear reordered. Without a happens-before edge between two threads, the JVM and CPU are allowed to reorder and cache, and one thread may see stale or partial values.

The main rules that create an edge:

  • Program order: within one thread, each action happens-before the next.
  • Monitor lock: unlocking a monitor happens-before every later lock of the same monitor.
  • volatile: a write to a volatile field happens-before every later read of that field.
  • Thread start and join: t.start() happens-before anything in t; everything in t happens-before t.join() returns.
  • Transitivity: if A → B and B → C, then A → C.

Let me show you how transitivity makes safe publication work.

static int data;                   // plain field
static volatile boolean ready;

// writer thread               // reader thread
data = 42;                     while (!ready) Thread.onSpinWait();
ready = true;                  System.out.println(data);   // guaranteed 42

data = 42 happens-before ready = true by program order. The volatile write happens-before the reader's volatile read. So by transitivity, the reader must see 42, even though data isn't volatile. If ready weren't volatile, the reader could legally see ready == true and data == 0.

The same rule is why things like ConcurrentHashMap.put, BlockingQueue.put and Future.get are safe hand-off points: their docs state the happens-before guarantee.

So in practice I don't reason about caches and CPU reordering. I just ask: "Is there a happens-before edge between this write and that read?" If not, it's a bug.

AdvancedExplain double-checked locking. Why does it need volatile?

So double-checked locking is a lazy singleton pattern that avoids taking the lock on every call: check without the lock, lock only if it looks null, then check again inside. It's broken without volatile, because another thread can see a non-null reference to an object whose constructor hasn't finished yet.

class Singleton {
    private static volatile Singleton instance;     // volatile is essential
    private Singleton() {}

    static Singleton getInstance() {
        Singleton local = instance;                 // one volatile read on the fast path
        if (local == null) {                        // 1st check, no lock
            synchronized (Singleton.class) {
                local = instance;
                if (local == null) {                // 2nd check, with lock
                    instance = local = new Singleton();
                }
            }
        }
        return local;
    }
}

Why volatile? new Singleton() is really three steps: allocate memory, run the constructor, assign the reference. Without a happens-before edge, the JIT or CPU may assign the reference before the constructor finishes. A second thread doing the first, unlocked check sees non-null, skips the lock, and uses a half-built object. volatile forbids that reordering and creates the happens-before edge. This has been correct since the memory model was fixed in Java 5.

The second check is needed because two threads can both pass the first check; the one that gets the lock second must not create another instance.

That said, I'd rarely write this in real code. The holder idiom is simpler and just as lazy, because the JVM guarantees class initialisation is thread-safe:

class Holder {
    private static class Lazy { static final Holder INSTANCE = new Holder(); }
    static Holder get() { return Lazy.INSTANCE; }
}

So in practice: an enum or the holder idiom for singletons, and double-checked locking only for lazy instance fields where those don't apply.

Interviewers look for: the reordering explanation (reference published before construction finishes), not just "add volatile".

AdvancedWhat's wrong with this code? synchronized (count) where count is an Integer.

So the bug is that the code locks on the object that it's changing. count++ on an Integer creates a new Integer object and reassigns the field, so different threads end up locking different objects, and there's no mutual exclusion at all.

static Integer count = 0;

Runnable r = () -> {
    for (int i = 0; i < 100_000; i++) {
        synchronized (count) { count++; }   // lock object changes every iteration
    }
};
// two threads -> count: 158064  (should be 200000)

// javac even warns:
// warning: [synchronization] attempt to synchronize on an instance of a value-based class

Let me walk through it. Thread A locks the Integer 5, increments, and the field now points to 6. Thread B, which had already read the field, is still locking 5, or it locks 6 while A is locking 7. The lock protects nothing.

There are related traps with the same root cause:

  • Locking on a String literal: synchronized ("LOCK"). Literals are interned, so any unrelated library that does the same shares your lock, which can cause mysterious contention or deadlocks.
  • Locking on a boxed small number: Integer.valueOf caches -128 to 127, so those objects are shared JVM-wide.
  • Locking on a non-final field: if it's ever reassigned, threads lock different objects.

So in practice my rule is: lock on a private final Object lock = new Object(), and nothing else. For this counter I'd just use an AtomicInteger.

30. Locks & Atomic Variables

BasicWhat are atomic variables and how does AtomicInteger work?

So atomic classes in java.util.concurrent.atomic, like AtomicInteger, AtomicLong, AtomicBoolean and AtomicReference, let you do read-modify-write operations on a single variable atomically without a lock. Under the hood they use a volatile field plus the CPU's compare-and-swap instruction.

AtomicInteger ai = new AtomicInteger(5);

ai.compareAndSet(5, 6);                    // true   was 5, now 6
ai.compareAndSet(5, 7);                    // false  it's 6, not 5; nothing changes
ai.get();                                  // 6
ai.updateAndGet(v -> v * 10);              // 60
ai.accumulateAndGet(3, Integer::sum);      // 63
ai.incrementAndGet();                      // 64

Conceptually, incrementAndGet is a retry loop: read the current value, compute value + 1, then CAS it in only if nobody changed it meanwhile; if someone did, read again and retry. On modern JVMs the increment actually compiles to a single atomic hardware instruction.

One thing about updateAndGet and accumulateAndGet (Java 8): the function may be called more than once if there's contention, so it must be side-effect free. Don't log or call a service inside it.

The limitation is that atomics only protect one variable. If I need to update a balance and a history list together, two atomics don't make that combination atomic. That's when I need a lock, or I put both fields into one immutable object and swap it with AtomicReference.

So in practice: atomics for counters, IDs, flags and one-time initialisation; locks for multi-field invariants.

IntermediateReentrantLock vs synchronized. When would you use ReentrantLock?

So both give mutual exclusion, reentrancy and the same visibility guarantees. synchronized is simpler and releases the lock automatically. ReentrantLock, from Java 5, is more flexible: you can try to lock with a timeout, lock interruptibly, choose fairness, and have multiple wait conditions. The cost is that you must unlock manually in a finally.

ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // critical section
} finally {
    lock.unlock();        // forget this and the lock is held forever
}

// Things synchronized can't do:
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) { ... }   // give up instead of blocking forever
lock.lockInterruptibly();                               // can be cancelled while waiting
new ReentrantLock(true);                                // fair: longest waiter goes first
Condition notEmpty = lock.newCondition();               // several wait-sets per lock

When I tested tryLock(100 ms) from a second thread while the first held the lock, it returned false after the timeout instead of hanging. That's the main reason I'd pick it: in a service calling out under a lock, I'd rather fail fast and return an error than have request threads pile up forever.

synchronizedReentrantLock
ReleaseAutomaticManual, in finally
Timeout / tryNotryLock
Interruptible waitNolockInterruptibly
FairnessNoOptional
ConditionsOne (wait/notify)Many (newCondition)

On virtual threads: before Java 24, blocking inside synchronized pinned the virtual thread to its carrier, so many Java 21 codebases switched hot locks to ReentrantLock. Java 24 removed most of that pinning.

So in practice I default to synchronized for simple cases, and use ReentrantLock when I need timeouts, interruptibility, fairness or multiple conditions.

Interviewers look for: unlock in finally, and concrete features like tryLock and Condition.

IntermediateWhat is CAS (compare-and-swap)? What is the ABA problem?

So CAS is a single atomic CPU instruction that says: "if the value at this memory location is still X, set it to Y, and tell me whether it worked." It's the foundation of all lock-free code in Java: atomics, ConcurrentHashMap, ConcurrentLinkedQueue and most of java.util.concurrent.

A lock-free update is a retry loop built on CAS:

// roughly what AtomicInteger does for incrementAndGet
int prev, next;
do {
    prev = value.get();
    next = prev + 1;
} while (!value.compareAndSet(prev, next));   // someone changed it? try again

Compared with a lock, no thread is ever blocked or put to sleep; a thread that loses the race just retries. Under low to moderate contention that's faster. Under very high contention, threads can waste CPU spinning and retrying.

The ABA problem: CAS only checks the value, not the history. If a value goes A → B → A while my thread is between its read and its CAS, the CAS succeeds even though the state changed underneath. For numbers it rarely matters, but for a lock-free stack built from node references it can corrupt the structure. The fix is to CAS on a value plus a version stamp:

AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
int stamp = ref.getStamp();              // I read "A" with stamp 0

ref.compareAndSet("A", "B", 0, 1);       // another thread: A -> B
ref.compareAndSet("B", "A", 1, 2);       // and back:       B -> A

ref.compareAndSet("A", "C", stamp, stamp + 1);   // false: value is A, but stamp is 2

So in practice I rarely write CAS loops by hand; I use atomics and concurrent collections that already do it correctly. But understanding CAS explains why they're fast and what their limits are.

Interviewers look for: the retry loop, "no blocking", and ABA with the stamped-reference fix.

IntermediateWhen would you use a ReadWriteLock or a StampedLock?

So a ReentrantReadWriteLock has two locks: many threads can hold the read lock at the same time, but the write lock is exclusive. It helps when reads vastly outnumber writes and each read takes some time, like a config cache or a lookup table that refreshes occasionally.

class Cache<K, V> {
    private final Map<K, V> map = new HashMap<>();
    private final ReadWriteLock rw = new ReentrantReadWriteLock();

    V get(K key) {
        rw.readLock().lock();
        try { return map.get(key); } finally { rw.readLock().unlock(); }
    }
    void put(K key, V value) {
        rw.writeLock().lock();
        try { map.put(key, value); } finally { rw.writeLock().unlock(); }
    }
}

Some caveats. You can downgrade (hold write, take read, release write) but you can't upgrade from read to write; trying deadlocks. And for very short reads like a single HashMap.get, the bookkeeping of the read lock can cost more than a plain lock. In fact, for this exact example I'd use a ConcurrentHashMap instead.

StampedLock (Java 8) adds an optimistic read: you read without locking at all, then validate that no write happened. If one did, you retry with a real read lock. That's very fast for read-heavy data like coordinates or small value objects. But it's not reentrant, doesn't support conditions, and is easy to misuse.

So in practice: ConcurrentHashMap or an immutable snapshot in a volatile field first; ReadWriteLock when reads are long and writes rare; StampedLock only after profiling shows lock contention on a read path.

AdvancedAtomicLong vs LongAdder. When is LongAdder better?

So AtomicLong is one variable that every thread CASes. Under heavy contention, most CAS attempts fail and retry, and the cache line bounces between CPU cores. LongAdder (Java 8) spreads the count across several internal cells, so threads mostly update different cells, and sum() adds them up when you read.

LongAdder adder = new LongAdder();
// two threads, 100,000 increments each:
adder.increment();
adder.sum();                // 200000

// typical use: per-key hit counters
ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
hits.computeIfAbsent("/home", k -> new LongAdder()).increment();

The trade-off:

  • Writes: LongAdder scales much better when many threads increment at the same time.
  • Reads: sum() is slower, and it's not an atomic snapshot: if others are writing during the sum, you get a value that may not have existed at any single instant.
  • API: LongAdder has no compareAndSet or incrementAndGet. You can't use it to generate unique IDs.
  • Memory: more cells, more memory.

In a metrics library at work we counted requests per endpoint with AtomicLong, and under load the profiler showed the counters themselves as a hotspot. Switching to LongAdder removed it, because we write constantly and read only when the metrics endpoint is scraped.

So in practice: LongAdder for statistics that are written often and read rarely; AtomicLong when I need the exact current value, CAS, or sequence numbers.

Interviewers look for: contention on a single variable, striping across cells, and the non-atomic sum().

AdvancedHow do you wait for a condition with ReentrantLock? Show Condition in use.

So a Condition is the ReentrantLock version of wait/notify. await() releases the lock and waits; signal() or signalAll() wakes waiters, which reacquire the lock before continuing. The advantage over wait/notify is that one lock can have several conditions, so I can wake only the threads that care.

class Account {
    private final Lock lock = new ReentrantLock();
    private final Condition funds = lock.newCondition();
    private int balance;

    void withdraw(int amt) throws InterruptedException {
        lock.lockInterruptibly();
        try {
            while (balance < amt) funds.await();   // releases the lock while waiting
            balance -= amt;
        } finally { lock.unlock(); }
    }

    void deposit(int amt) {
        lock.lock();
        try {
            balance += amt;
            funds.signalAll();
        } finally { lock.unlock(); }
    }
}
// withdraw(50) blocks on an empty account; deposit(80) wakes it; balance ends at 30

Let me walk through it. The withdrawing thread takes the lock, sees there isn't enough money, and awaits, which releases the lock. The depositing thread can then take the lock, add money and signal. The withdrawer wakes, reacquires the lock, rechecks the while condition, and proceeds.

The same rules as wait apply: always await in a loop because of spurious wake-ups, and only call await or signal while holding the lock, or you get IllegalMonitorStateException.

The classic use of two conditions is a bounded buffer: notFull for producers and notEmpty for consumers. That's exactly how ArrayBlockingQueue is implemented inside the JDK.

So in practice, unless I'm building a synchronizer myself, I use ArrayBlockingQueue or Semaphore directly, which already wrap this pattern correctly.

31. Executors & Thread Pools

BasicWhy use a thread pool instead of creating a new Thread for every task?

So a platform thread is expensive: it maps to an OS thread, reserves around 1 MB of stack, and takes real time to create and tear down. A pool creates a fixed set of threads once and reuses them, and it gives me a place to control how much work runs at the same time.

Let me show you the difference in code:

// Per-request thread: unbounded, no reuse, no back-pressure
new Thread(() -> handle(request)).start();

// Pool: 8 reusable workers, extra tasks wait in a queue
ExecutorService pool = Executors.newFixedThreadPool(8);
pool.submit(() -> handle(request));

With the first version, a traffic spike of 10,000 requests means 10,000 OS threads. The machine spends its time context-switching, memory runs out, and eventually I get OutOfMemoryError: unable to create native thread. With the pool, at most 8 run at once and the rest queue up.

The other benefits are separation of concerns (I submit tasks, the executor decides how they run), results and exceptions through Future, graceful shutdown, and metrics like active count and queue size.

So in practice I never call new Thread in application code. For CPU work I use a bounded pool. For blocking I/O on Java 21, I use virtual threads, which are cheap enough that pooling them is pointless.

BasicWhat's the difference between submit() and execute()?

So execute(Runnable) comes from the Executor interface and returns nothing. submit() comes from ExecutorService, accepts a Runnable or a Callable, and returns a Future. The difference that bites people is what happens to exceptions.

Let me show you with a quick example:

ExecutorService pool = Executors.newSingleThreadExecutor();

Future<?> f = pool.submit(() -> { throw new IllegalStateException("boom"); });
// nothing is printed: the exception is stored inside the Future
try { f.get(); }
catch (ExecutionException e) { System.out.println("get() -> " + e.getCause()); }
// get() -> java.lang.IllegalStateException: boom

pool.execute(() -> { throw new IllegalStateException("boom2"); });
// Exception in thread "pool-1-thread-1" java.lang.IllegalStateException: boom2
//     at ... (stack trace from the uncaught exception handler)

With submit, the task is wrapped in a FutureTask, which catches the exception and keeps it until someone calls get(). If nobody ever calls get(), the failure disappears silently. With execute, the exception escapes, the uncaught exception handler prints it, and the pool replaces that worker thread.

I've debugged exactly this: a nightly job "succeeded" for weeks while every task was failing, because we used submit and threw the futures away.

So in practice: fire-and-forget uses execute, or a try/catch with logging inside the task. If I need a result, I use submit and always call get().

Interviewers look for: "submit swallows exceptions until get()". It's the thing most candidates miss.

IntermediateExplain the ThreadPoolExecutor parameters. In what order does it use threads and the queue?

So the constructor takes core pool size, maximum pool size, keep-alive time, a work queue, a thread factory and a rejection handler. The part people get wrong is the order: it fills the core threads first, then the queue, and only creates extra threads up to the max once the queue is full.

Let me show you with a pool of core 2, max 4 and a queue of 2, where every task blocks so nothing finishes:

ThreadPoolExecutor pool = new ThreadPoolExecutor(
    2, 4, 30, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(2),
    new ThreadPoolExecutor.AbortPolicy());

// task 1: threads=1 queued=0
// task 2: threads=2 queued=0   core full
// task 3: threads=2 queued=1   goes to the queue
// task 4: threads=2 queued=2   queue full
// task 5: threads=3 queued=2   now an extra thread
// task 6: threads=4 queued=2   max reached
// task 7: rejected             RejectedExecutionException

The keep-alive time controls how long those extra threads sit idle before they're killed and the pool shrinks back to core size. The thread factory is where I set names like payment-worker-3, which makes thread dumps readable.

The consequence of this order is important. With an unbounded queue like LinkedBlockingQueue(), the queue is never full, so the pool never grows past core size. The max parameter does nothing.

So in practice I always use a bounded queue, name the threads, and pick the rejection policy deliberately.

Interviewers look for: core, then queue, then max, then reject. And "an unbounded queue makes maximumPoolSize useless".

IntermediateWhat happens when the queue is full and all threads are busy?

So the executor calls its RejectedExecutionHandler. There are four built-in policies, and choosing one is really a decision about how the system behaves under overload.

PolicyWhat it doesWhen I use it
AbortPolicy (default)Throws RejectedExecutionExceptionCaller can return 503 or retry later
CallerRunsPolicyRuns the task on the submitting threadNatural back-pressure: the producer slows down
DiscardPolicySilently drops the new taskRarely; only for truly optional work like metrics
DiscardOldestPolicyDrops the oldest queued task, retries the new oneWhen only the latest value matters, like price ticks

CallerRunsPolicy is my usual choice for internal pipelines. If a Kafka consumer thread submits work and the pool is saturated, the consumer thread runs the task itself, so it stops polling for a moment. That throttles intake without losing anything.

I can also write my own handler, for example to log, increment a "rejected" metric, and then throw. Rejection also happens after shutdown(), so seeing it in logs during a deploy is normal.

So in practice, rejection is a feature. It's the pool telling me I'm overloaded, and I'd rather see that than a queue quietly growing until the heap is gone.

IntermediateWhy can Executors.newFixedThreadPool() and newCachedThreadPool() be dangerous in production?

So both factory methods hide an unbounded resource. The fixed pool has an unbounded queue, and the cached pool has an unbounded number of threads. Under a traffic spike or a slow downstream, one fills the heap and the other fills the OS with threads.

Let me show you what they really create:

// newFixedThreadPool(10)
new ThreadPoolExecutor(10, 10, 0L, MILLISECONDS,
    new LinkedBlockingQueue<Runnable>());          // capacity Integer.MAX_VALUE

// newCachedThreadPool()
new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, SECONDS,
    new SynchronousQueue<Runnable>());             // no queue: every task needs a thread

In a previous project we used newFixedThreadPool(20) to push notifications. The SMS provider slowed to 5 seconds per call, the queue grew to a few million tasks, each holding a message payload, and the service died with OutOfMemoryError: Java heap space. Nothing was rejected, so no alarm fired until it crashed.

The cached pool fails differently: if tasks block, every new task creates a new thread, and you hit unable to create native thread or the box grinds to a halt from context switching.

So in practice I construct ThreadPoolExecutor directly with a bounded ArrayBlockingQueue, named threads and an explicit rejection policy, and I export queue size as a metric. Many teams' static analysis rules flag the Executors factories for exactly this reason.

Interviewers look for: the specific hidden bound (queue vs thread count), plus a real failure story. That's what separates people who've run this in production.

AdvancedHow do you shut down an ExecutorService correctly?

So there are two methods: shutdown() stops accepting new tasks but lets queued and running ones finish, and shutdownNow() interrupts running tasks and returns the ones still in the queue. The correct pattern combines both with a timeout.

void stop(ExecutorService pool) {
    pool.shutdown();                                   // no new tasks
    try {
        if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
            List<Runnable> dropped = pool.shutdownNow();   // interrupt workers
            log.warn("dropped {} tasks", dropped.size());
            pool.awaitTermination(10, TimeUnit.SECONDS);
        }
    } catch (InterruptedException e) {
        pool.shutdownNow();
        Thread.currentThread().interrupt();            // keep the interrupt flag
    }
}

// Java 19+: ExecutorService is AutoCloseable
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
    pool.submit(task);
}   // close() = shutdown + wait for all tasks

Two things people miss. First, shutdownNow() only interrupts. A task that ignores interruption, like a busy loop that never checks Thread.interrupted(), keeps running. Second, if I never shut down a pool of non-daemon threads, the JVM can't exit, which is why a main method sometimes "hangs" after finishing.

In Spring I don't do this by hand. I declare the executor as a bean and the container calls shutdown on context close.

So in practice: shutdown, await with a timeout, then shutdownNow, and write tasks that respond to interruption.

AdvancedHow do you decide the size of a thread pool?

So it depends on whether the tasks are CPU-bound or spend most of their time waiting. For pure CPU work, about the number of cores. For I/O-heavy work, more, according to how long threads wait compared to how long they compute.

int cores = Runtime.getRuntime().availableProcessors();   // container-aware since Java 10

// CPU-bound (hashing, image resizing):   cores  (or cores + 1)
// Blocking I/O:  threads = cores * (1 + waitTime / computeTime)
//   e.g. 8 cores, 90 ms waiting on DB per 10 ms of CPU
//   8 * (1 + 90/10) = 80 threads

The formula is a starting point, not an answer. The real limit is often downstream. If the database connection pool has 20 connections, 80 threads that all need a connection just means 60 of them wait on the pool. So I size against the scarcest resource.

Then I measure. I load-test, watch CPU, latency percentiles and queue depth, and adjust. If CPU is at 30% and the queue is growing, the pool is too small or something downstream is slow. If CPU is at 100% with poor latency, the pool is too big for CPU work.

I also keep separate pools for separate workloads, the bulkhead pattern, so a slow report job can't starve the checkout requests.

So in practice, on Java 21 the I/O case is mostly solved by virtual threads plus a semaphore on the downstream resource, and I only hand-size pools for CPU work.

Interviewers look for: CPU vs I/O, the wait/compute ratio, "bounded by the downstream resource", and "then measure".

32. CompletableFuture

BasicWhat's the difference between Future and CompletableFuture?

So Future, from Java 5, is a handle you can only poll or block on with get(). CompletableFuture, from Java 8, can be chained with callbacks, combined with other futures, completed manually, and it has proper error handling. It's the difference between waiting for a result and describing what to do when it arrives.

// Future: the only way forward is to block
Future<User> f = pool.submit(() -> userRepo.find(42));
User u = f.get();                       // this thread waits
String name = u.name().toUpperCase();

// CompletableFuture: a pipeline, nothing blocks
CompletableFuture<String> name =
    CompletableFuture.supplyAsync(() -> userRepo.find(42), pool)
        .thenApply(user -> user.name().toUpperCase())
        .exceptionally(ex -> "UNKNOWN");

With a plain Future I can't say "when this finishes, call that service", I can't wait for "all of these" or "the first of these" without writing loops, and I can't attach an error handler. I also can't complete it myself, which CompletableFuture.complete(value) allows, handy for adapting callback-based APIs.

It implements both Future and CompletionStage, so it still works anywhere a Future is expected.

So in practice, whenever I have to fan out to several services and merge the results, CompletableFuture is the tool. With virtual threads on Java 21, I sometimes just write plain blocking code instead, because it's easier to read and debug.

IntermediateWhat's the difference between thenApply and thenCompose?

So it's map versus flatMap. thenApply takes a function that returns a plain value. thenCompose takes a function that returns another CompletableFuture, and flattens it so I don't end up with a future inside a future.

CompletableFuture<User> findUser(long id) { ... }
CompletableFuture<Integer> fetchOrders(User u) { ... }   // also async

// thenApply with an async function: nested, awkward
CompletableFuture<CompletableFuture<Integer>> nested =
    findUser(42).thenApply(u -> fetchOrders(u));

// thenCompose: flat, chains the second call after the first
CompletableFuture<Integer> orders =
    findUser(42).thenCompose(u -> fetchOrders(u));
System.out.println(orders.join());          // 3

// thenApply is right for a plain transformation
CompletableFuture<String> upper =
    findUser(42).thenApply(u -> u.name().toUpperCase());
System.out.println(upper.join());           // ASHA

The rule I use: if the lambda returns a CompletableFuture, use thenCompose. If it returns a normal value, use thenApply. It's the same idea as Optional.map vs Optional.flatMap, or Stream.map vs flatMap.

And there's a third sibling: thenCombine runs two independent futures in parallel and merges them, whereas thenCompose is sequential because the second call needs the first result.

So in practice, seeing CompletableFuture<CompletableFuture<…>> in a code review is a signal that someone meant thenCompose.

Interviewers look for: the map/flatMap analogy, and knowing that compose is sequential while combine is parallel.

IntermediateHow do you handle exceptions in a CompletableFuture chain?

So there are three methods. exceptionally runs only on failure and returns a fallback value. handle always runs and gets both the value and the exception, so it can transform either. whenComplete always runs too, but it's for side effects like logging and doesn't change the result.

Let me show you with a quick example:

String r = CompletableFuture
    .supplyAsync(() -> { throw new IllegalStateException("db down"); })
    .thenApply(s -> s + "!")                          // skipped: previous stage failed
    .exceptionally(ex -> "fallback: " + ex.getCause().getMessage())
    .join();
System.out.println(r);                                // fallback: db down

CompletableFuture<Integer> failing = CompletableFuture.supplyAsync(() -> 10 / 0);
failing.handle((v, ex) -> ex == null ? v : 0).join();          // 0
failing.whenComplete((v, ex) -> log.error("failed", ex));      // still failed afterwards

try { failing.join(); }
catch (CompletionException e) { System.out.println(e.getCause()); }
// java.lang.ArithmeticException: / by zero

Two details matter. First, a failure skips every thenApply/thenAccept after it until it reaches a handler, like a catch block further down. Second, the exception I receive is usually wrapped in a CompletionException, so I call getCause() to reach the real one. That's why the example prints getCause().getMessage().

join() throws the unchecked CompletionException, while get() throws the checked ExecutionException. That's the only real difference between them.

So in practice I put one exceptionally or handle near the end of the chain, unwrap the cause, and log with whenComplete. The classic bug is a chain with no handler at all, where the error just vanishes.

Interviewers look for: the three methods and how they differ, "failures skip the stages after them", and unwrapping CompletionException.

IntermediateYou need data from three services for one page. How do you call them in parallel?

So I start all three calls immediately with supplyAsync on my own executor, then combine them. For two futures I use thenCombine. For N futures, allOf and then read each result with join(), which won't block because they're all done.

ExecutorService io = Executors.newVirtualThreadPerTaskExecutor();

CompletableFuture<Profile> profile = supplyAsync(() -> profileApi.get(id), io);
CompletableFuture<List<Order>> orders = supplyAsync(() -> orderApi.recent(id), io);
CompletableFuture<Integer> points  = supplyAsync(() -> loyaltyApi.points(id), io)
                                        .exceptionally(ex -> 0);    // optional data

CompletableFuture<Page> page = CompletableFuture
    .allOf(profile, orders, points)
    .thenApply(v -> new Page(profile.join(), orders.join(), points.join()));

// Two futures: thenCombine
price.thenCombine(stock, (p, s) -> p + " / " + s).join();   // "99.0 / in stock"

The total latency becomes roughly the slowest call rather than the sum. If the three calls take 100, 150 and 80 ms, the page takes about 150 ms instead of 330.

A few decisions I make explicitly: which failures are fatal and which get a default (loyalty points fall back to 0 here), and a timeout on each call so one slow service can't hold the page. There's also anyOf for "first one wins", which I've used to hit two replicas and take whichever answers first.

So in practice this fan-out pattern is the most common reason I reach for CompletableFuture at all.

IntermediateWhich thread runs the callbacks? What's the problem with the common pool?

So supplyAsync without an executor runs on ForkJoinPool.commonPool(). The non-async callbacks like thenApply run on whichever thread completes the previous stage, or on the calling thread if it's already complete. The ...Async variants always hand the callback to an executor.

// Risky: blocking HTTP call on the shared common pool
supplyAsync(() -> httpClient.send(req, ofString()));

// Better: my own executor for blocking work
supplyAsync(() -> httpClient.send(req, ofString()), ioExecutor)
    .thenApplyAsync(this::parse, cpuExecutor);

The common pool has only cores - 1 threads, and it's shared by the whole JVM, including parallel streams. If I run blocking I/O on it, a handful of slow calls can starve every other part of the application that uses it. On a 2-core container, that pool has a single thread.

The other surprise is that thenApply's thread isn't predictable. If the future is already complete when I attach the callback, it runs right there on my thread, which might be a request thread I didn't want to block with heavy work.

So in practice I always pass an executor to supplyAsync for anything that blocks, and I use the Async variants with an executor when I care where a stage runs.

Interviewers look for: "the common pool is shared and small, so never block on it".

AdvancedHow do you add a timeout to a CompletableFuture?

So since Java 9 there are two methods. orTimeout fails the future with a TimeoutException if it isn't done in time. completeOnTimeout completes it with a default value instead. Before Java 9, you needed a scheduled executor to do this by hand.

// Fall back to a cached value after 100 ms
String price = slowPriceCall()
    .completeOnTimeout("cached", 100, TimeUnit.MILLISECONDS)
    .join();                                     // "cached"

// Treat slowness as a failure
try {
    slowCall().orTimeout(100, TimeUnit.MILLISECONDS).join();
} catch (CompletionException e) {
    System.out.println(e.getCause().getClass().getSimpleName());   // TimeoutException
}

// Blocking alternative: bound only the wait, not the work
future.get(100, TimeUnit.MILLISECONDS);

The important thing to say is that a timeout completes the future, but it does not stop the underlying task. The thread keeps running the slow HTTP call until it returns. cancel(true) doesn't help either, because CompletableFuture ignores the interrupt flag. So I also set a timeout on the actual client, for example the HTTP client's request timeout or the JDBC query timeout, so the resource is really freed.

So in practice: completeOnTimeout for optional data, orTimeout plus a fallback for required data, and a client-level timeout underneath both.

33. Concurrent Collections

BasicWhy use ConcurrentHashMap instead of Hashtable or Collections.synchronizedMap?

So Hashtable and synchronizedMap put one lock around the whole map, so only one thread can read or write at a time. ConcurrentHashMap lets reads run with no lock at all and lets writes to different buckets run in parallel. Under load, that's a big throughput difference.

Hashtable / synchronizedMapConcurrentHashMap
LockingOne lock for everythingLock-free reads, per-bucket locks for writes
Iteration while modifyingFail-fast: ConcurrentModificationExceptionWeakly consistent, never throws
Atomic compound opsMust lock externallycompute, merge, putIfAbsent
null keys/valuesHashtable: no. synchronizedMap: yesNo

The iteration point is a real trap with synchronizedMap. Each method is synchronized, but iterating isn't a single method call, so I have to wrap the loop in synchronized (map) { … } myself or risk an exception.

The flip side is that size() on a ConcurrentHashMap is an estimate while other threads are writing, and iteration may or may not show updates that happen during the loop. For caches and counters that's fine.

So in practice Hashtable is legacy and I never use it. ConcurrentHashMap is the default for any map shared across threads.

IntermediateHow does ConcurrentHashMap work internally? What changed between Java 7 and Java 8?

So in Java 7 it was split into 16 segments by default, each one a small hash table with its own ReentrantLock. Writes locked one segment, so at most 16 writers could proceed at once. Java 8 removed segments: it's now a single Node[] table like HashMap, using CAS for empty buckets and synchronized on just the head node of a bucket otherwise.

Let me walk through a Java 8 put:

  • Empty bucket: insert the new node with a single CAS, no lock at all.
  • Bucket in use: synchronized (firstNode), then append or replace. Only that one bucket is locked.
  • Long bucket: past 8 nodes, it turns into a red-black tree, like HashMap.
  • Resize: several threads help move buckets. A moved bucket is marked with a ForwardingNode, so readers know to look in the new table.

Reads take no lock. The node's val and next fields are volatile, so a reader always sees a fully published node.

The size counter is also clever. Instead of one contended counter, it uses a baseCount plus an array of CounterCells, the same idea as LongAdder, so heavy concurrent puts don't all fight over one number.

So the net effect of Java 8 is lock granularity at the bucket level instead of 16 segments, less memory, and better concurrency. The concurrencyLevel constructor argument still exists but is now only a sizing hint.

Interviewers look for: segments in 7; CAS plus synchronized on the bucket head in 8; lock-free reads via volatile; treeification. Mentioning the LongAdder-style counter is a bonus.

IntermediateWhy doesn't ConcurrentHashMap allow null keys or values?

So it's about ambiguity. In a plain HashMap, get returning null can mean "no key" or "key mapped to null", and I can tell them apart with containsKey. In a concurrent map, another thread can change the map between those two calls, so the answer would be meaningless. Banning null removes the ambiguity.

Map<String, String> hm = new HashMap<>();
hm.put("a", null);
System.out.println(hm.get("a") + " " + hm.containsKey("a") + " " + hm.get("b"));
// null true null      -- same return value, two different meanings

Map<String, String> chm = new ConcurrentHashMap<>();
chm.put("a", null);    // NullPointerException
chm.get(null);         // NullPointerException

With the null ban, get returning null has exactly one meaning: absent, at that moment. That's what makes atomic methods like putIfAbsent, computeIfAbsent and merge reliable, because they all use null to mean "no mapping". Doug Lea, who wrote it, explained it this way, and he also considered nulls in maps a design mistake generally.

When I need to represent "known to be missing", for example to cache a negative lookup, I store a sentinel or Optional.empty() instead of null.

So in practice this also means migrating a HashMap to a ConcurrentHashMap can throw NPEs if the old code stored nulls, so I check for that first.

Interviewers look for: "you can't safely do get-then-containsKey in a concurrent map". Just saying "because it throws NPE" isn't enough.

IntermediateWhat's wrong with this counter, even though the map is a ConcurrentHashMap?
map.put("hits", map.get("hits") + 1);

So each call is thread-safe on its own, but the combination isn't atomic. Two threads can both read 5, both compute 6, and both write 6, so one increment is lost. A thread-safe collection doesn't make a sequence of calls thread-safe.

Let me show you by running both versions on 8 threads with 100,000 increments:

// racy
racy.put("hits", racy.get("hits") + 1);
// atomic
safe.merge("hits", 1, Integer::sum);

// racy=75935 safe=100000     (racy number changes every run)

The fix is to use the map's atomic compound methods, which lock the bucket for the whole read-modify-write:

  • merge(key, 1, Integer::sum) for counters.
  • computeIfAbsent(key, k -> new ArrayList<>()) for "get or create", instead of a get, null check and put.
  • compute(key, (k, v) -> …) for arbitrary updates.

For a very hot counter I go one step further and store a LongAdder as the value: map.computeIfAbsent(key, k -> new LongAdder()).increment(). That spreads contention across cells.

One caution: the function passed to compute runs while holding the bucket lock, so it must be short and must never modify the same map, or it can deadlock or throw IllegalStateException.

So in practice, whenever I see get followed by put on a shared map, I replace it with one atomic method.

IntermediateWhen would you use CopyOnWriteArrayList?

So I use it when reads vastly outnumber writes and I need to iterate safely without locking. Every write copies the whole backing array. Every iterator works on the snapshot it started with, so it never throws ConcurrentModificationException and never needs a lock.

List<String> plain = new ArrayList<>(List.of("a", "b", "c"));
for (String s : plain) if (s.equals("a")) plain.add("d");
// ConcurrentModificationException

List<String> cow = new CopyOnWriteArrayList<>(List.of("a", "b", "c"));
for (String s : cow) { if (s.equals("a")) cow.add("d"); System.out.print(s + " "); }
// a b c          (the iterator sees the old snapshot)
System.out.println(cow);
// [a, b, c, d]

The textbook use case is a list of listeners or observers. Listeners are registered a few times at startup, but events fire thousands of times a second, and each event iterates the list. A listener can even unregister itself during the callback without breaking the loop. I've also used it for a small set of routing rules reloaded every few minutes and read on every request.

It's a bad choice when writes are frequent or the list is big: adding to a 100,000-element list copies 100,000 references each time, so a loop of adds is O(n²). And iterators don't support remove().

So in practice: small list, rare writes, constant iteration. Otherwise I use a ConcurrentLinkedQueue or a normal list with a lock.

IntermediateWrite a producer-consumer using a BlockingQueue.

So a BlockingQueue does all the hard parts for me: put blocks when the queue is full, take blocks when it's empty, and all the locking and signalling is inside. I don't write any wait/notify. To stop the consumer cleanly I send a "poison pill".

public class ProducerConsumer {
    private static final String POISON = "__DONE__";

    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> queue = new ArrayBlockingQueue<>(2);   // bounded

        Thread producer = Thread.ofPlatform().start(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    queue.put("order-" + i);            // blocks when full
                    System.out.println("produced order-" + i);
                }
                queue.put(POISON);
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });

        Thread consumer = Thread.ofPlatform().start(() -> {
            try {
                while (true) {
                    String item = queue.take();         // blocks when empty
                    if (item.equals(POISON)) break;
                    Thread.sleep(50);                   // slow consumer
                    System.out.println("  consumed " + item);
                }
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });

        producer.join();
        consumer.join();
        System.out.println("done");
    }
}

When I run it, the producer races ahead by about two items, the capacity, and then has to wait for the slow consumer: "produced order-3", "consumed order-1", "produced order-4"… That's back-pressure. The bounded queue stops a fast producer from filling the heap.

With several consumers, I send one poison pill per consumer. I also restore the interrupt flag in the catch, rather than swallowing it.

So in practice this is the same pattern a ThreadPoolExecutor uses internally, and the same shape as a Kafka topic, just inside one JVM.

Interviewers look for: a bounded queue, put/take rather than offer/poll, a clean shutdown, and correct interrupt handling. If they then ask for a wait/notify version, use while, not if, around wait().

AdvancedCountDownLatch vs CyclicBarrier vs Semaphore: when do you use each?

So a CountDownLatch lets one or more threads wait until N events have happened, and it's single-use. A CyclicBarrier makes N threads wait for each other at a meeting point and resets for the next round. A Semaphore doesn't wait for events at all: it limits how many threads can be inside a section at once.

// Latch: main waits for 3 services to start
CountDownLatch ready = new CountDownLatch(3);
for (String s : new String[]{"db", "cache", "queue"})
    Thread.ofVirtual().start(() -> { System.out.println(s + " up"); ready.countDown(); });
ready.await();                      // "all services up, count=0"

// Barrier: 3 workers finish each round together; reusable
CyclicBarrier barrier = new CyclicBarrier(3, () -> System.out.println("-- round done --"));
// each worker: for (round 1..2) barrier.await();
// prints "-- round done --" twice

// Semaphore: at most 2 calls to a rate-limited API at a time
Semaphore permits = new Semaphore(2);
permits.acquire();
try { callApi(); } finally { permits.release(); }
// 10 tasks on virtual threads -> "max concurrent = 2"
CountDownLatchCyclicBarrierSemaphore
Who waitsWaiters, for other threadsThe parties, for each otherThreads, for a free permit
ReusableNoYes, resets each roundYes
Typical useStartup, test "go" signalParallel simulations in phasesConnection or rate limits

The latch's counting side and waiting side are different threads. With the barrier, the same threads count and wait. That's the clearest way to tell them apart.

So in practice I use Semaphore the most, especially with virtual threads, to cap how many of thousands of virtual threads hit a database at once. Latches show up mostly in tests. For anything more complex I'd look at Phaser.

34. Virtual Threads & Deadlocks

BasicWhat is a deadlock? What conditions cause one?

So a deadlock is when two or more threads each hold a lock the other needs, and each waits forever for the other to release it. Nothing crashes. The threads just stop, and the requests that depend on them hang.

Four conditions have to hold at the same time, the Coffman conditions:

  • Mutual exclusion: a lock can be held by only one thread.
  • Hold and wait: a thread holds one lock while waiting for another.
  • No preemption: a lock can't be taken away; the owner must release it.
  • Circular wait: T1 waits for T2, which waits for T1.

Break any one and deadlock is impossible. In Java the practical one to break is circular wait, by always acquiring locks in the same global order. The second practical one is hold-and-wait, broken with tryLock and a timeout, so a thread gives up its first lock if it can't get the second.

It's worth separating two neighbours. A livelock is threads that keep reacting to each other and retrying without progress, which can happen with naive tryLock retries. Starvation is a thread that never gets a turn because others always win.

So in practice, most deadlocks I've seen weren't two obvious synchronized blocks. They were a lock held while calling out to code that took another lock, like a listener callback or a synchronized DAO.

IntermediateWrite code that deadlocks, then fix it.

So the classic case is a bank transfer. Thread 1 transfers A to B and locks A then B. Thread 2 transfers B to A and locks B then A. Each gets its first lock and waits forever for the second.

// Deadlocks: lock order depends on the direction of the transfer
void transfer(Account from, Account to, long amount) {
    synchronized (from) {
        synchronized (to) {
            from.balance -= amount;
            to.balance += amount;
        }
    }
}
// thread 1: transfer(a, b, 1)   holds a, waits for b
// thread 2: transfer(b, a, 1)   holds b, waits for a

The fix is a global lock order. I always lock the account with the smaller id first, whatever the direction:

void transfer(Account from, Account to, long amount) {
    Account first  = from.id < to.id ? from : to;
    Account second = from.id < to.id ? to : from;
    synchronized (first) {
        synchronized (second) {
            from.balance -= amount;
            to.balance += amount;
        }
    }
}
// 10,000 transfers each way on 4 threads: a=1000 b=1000, no hang

The alternative fix, when there's no natural ordering, is ReentrantLock.tryLock with a timeout. If I can't get the second lock within 50 ms, I release the first and return false or retry after a random back-off. That breaks hold-and-wait. The random back-off matters, otherwise two threads can retry in lockstep and livelock.

So in practice: consistent ordering first, tryLock when ordering isn't possible, and above all, keep critical sections small and never call unknown code while holding a lock.

Interviewers look for: a deadlock you can explain line by line, and a fix that names which Coffman condition it breaks.

IntermediateHow do you detect a deadlock in a running application?

So the JVM detects Java-level deadlocks for me. I take a thread dump, and at the end it prints a "Found one Java-level deadlock" section naming the threads, the locks each one holds and the ones they're waiting for.

$ jcmd <pid> Thread.print        # or: jstack <pid>, or kill -3 <pid>

Found one Java-level deadlock:
=============================
"t1":
  waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
  which is held by "t2"
"t2":
  waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
  which is held by "t1"

I can also check from inside the application with ThreadMXBean, which is how I've built a health check that fails when threads are stuck:

ThreadMXBean mx = ManagementFactory.getThreadMXBean();
long[] ids = mx.findDeadlockedThreads();       // null if none
if (ids != null)
    for (ThreadInfo info : mx.getThreadInfo(ids))
        System.out.println(info.getThreadName() + " waits for " + info.getLockName()
            + " held by " + info.getLockOwnerName());
// t1 waits for java.lang.Object@119d7047 held by t2
// t2 waits for java.lang.Object@16b98e56 held by t1

The symptoms that make me look are requests hanging with low CPU and a thread pool whose active count is stuck at its max. I take two or three dumps a few seconds apart. If the same threads are BLOCKED on the same monitors every time, it's a deadlock or heavy contention.

So in practice I name threads through the thread factory, because "payment-worker-3 waits for a lock held by audit-writer-1" is solvable in minutes, and "pool-7-thread-3" is not.

IntermediateWhat are virtual threads, and how are they different from platform threads?

So virtual threads, final in Java 21, are threads managed by the JVM rather than the OS. A platform thread is a thin wrapper over an OS thread. A virtual thread is a small heap object that the JVM mounts onto a platform "carrier" thread only while it's running. When it blocks on I/O, it unmounts and the carrier runs another virtual thread.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));    // simulated blocking I/O
        return i;
    }));
}
// 10,000 tasks took ~1s
Platform threadVirtual thread
Backed byOne OS thread, for its whole lifeBorrows a carrier only while running
Stack~1 MB reserved up frontGrows on the heap, starts tiny
How manyThousandsMillions
PoolingYes, they're expensiveNever; one per task
DaemonConfigurableAlways daemon

The key point is that virtual threads aren't faster. A CPU-bound task runs at the same speed. They give scale for blocking workloads. I can write simple blocking code, one thread per request, and still handle tens of thousands of concurrent requests without reactive programming. In Spring Boot 3.2+ it's one property: spring.threads.virtual.enabled=true.

So in practice I use them for I/O-bound services, and I still cap access to scarce resources with a semaphore or connection pool, because a million virtual threads can very easily overwhelm a database.

Interviewers look for: "scale, not speed", "don't pool them", and knowing they were finalised in Java 21 (preview in 19 and 20).

AdvancedWhat is pinning with virtual threads, and how do you avoid it?

So pinning is when a virtual thread blocks but can't unmount from its carrier, so the carrier OS thread is blocked too. In Java 21 that happens in two cases: blocking inside a synchronized block or method, and blocking inside a native method. There are only as many carriers as cores by default, so a few pinned threads can stall the whole application.

static final Object LOCK = new Object();
static final ReentrantLock RLOCK = new ReentrantLock();

static void pinned() throws InterruptedException {
    synchronized (LOCK) { Thread.sleep(10); }          // pins the carrier
}
static void notPinned() throws InterruptedException {
    RLOCK.lock();
    try { Thread.sleep(10); } finally { RLOCK.unlock(); }   // unmounts normally
}

// java -Djdk.tracePinnedThreads=short ...
// VirtualThread[#20]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
//     Pinning.pinned(Pinning.java:7) <== monitors:1
// (nothing is reported for the ReentrantLock version)

To find it, I run with -Djdk.tracePinnedThreads=short in testing, or watch the JFR event jdk.VirtualThreadPinned in production. To fix it, I replace synchronized around blocking calls with a ReentrantLock, and I upgrade libraries. Older JDBC drivers and connection pools were full of synchronized, and most have since switched.

A short synchronized block that doesn't block, like incrementing a field, is fine. Pinning only matters when it's held across I/O or a sleep.

Worth mentioning: JDK 24 (JEP 491) reworked monitors so synchronized no longer pins. On 21, though, it's a real concern.

Interviewers look for: the two causes, the carrier being blocked, ReentrantLock as the fix, and the tracing flag. Knowing it's fixed in 24 shows you follow the platform.

AdvancedWhen should you NOT use virtual threads?

So virtual threads help when threads spend most of their time waiting. They don't help CPU-bound work, and a few older patterns work against them.

  • CPU-bound tasks: image processing or number crunching gains nothing, since only as many can run as there are cores. A fixed pool sized to cores, or parallel streams, is the right tool.
  • Heavy ThreadLocal use: caching an expensive object per thread made sense with 200 pooled threads. With a million short-lived virtual threads, each one gets its own copy and the cache is useless. Java 21 has ScopedValue in preview for passing context instead.
  • Pooling them: putting virtual threads in a fixed pool of 50 to limit concurrency brings back the old limits. I limit the resource with a Semaphore instead.
  • Pinning-heavy code: on Java 21, libraries that block inside synchronized can serialize everything onto a few carriers.
// Wrong way to limit DB load
ExecutorService limited = Executors.newFixedThreadPool(50, Thread.ofVirtual().factory());

// Right way: unlimited virtual threads, limited resource
Semaphore db = new Semaphore(50);
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    ex.submit(() -> { db.acquire(); try { return query(); } finally { db.release(); } });
}

So in practice: typical REST services that call databases and other services are the sweet spot. For batch computation I stay with platform thread pools.

Part 6 — The JVM Inside

35. JVM Memory: Heap, Stack & Metaspace

BasicWhat's the difference between the heap and the stack?

So every thread has its own stack, which holds one frame per method call with that call's local variables, parameters and partial results. The heap is shared by all threads and holds every object and array. The stack is freed automatically when a method returns. The heap is cleaned by the garbage collector.

void placeOrder() {
    int qty = 3;                          // stack: primitive value lives in the frame
    Order order = new Order("A-17", qty); // stack: the reference 'order'
                                          // heap:  the Order object itself
    process(order);                       // new frame pushed; same heap object shared
}                                         // frame popped; Order becomes garbage
                                          // if nothing else references it
StackHeap
HoldsFrames: locals, parameters, referencesObjects, arrays, instance fields
SharedNo, one per threadYes, all threads
Freed byMethod returnGarbage collector
Size flag-Xss (per thread)-Xms / -Xmx
When fullStackOverflowErrorOutOfMemoryError

Because locals live on a thread's own stack, they're naturally thread-safe. Anything on the heap that more than one thread can reach needs synchronization.

One nuance: the JIT's escape analysis can avoid heap-allocating an object that never leaves a method, by scalar-replacing its fields. But that's an optimization. The language model is still "objects live on the heap".

So in practice, when someone says "the object is on the stack", I'd correct it gently: the reference is on the stack, the object is on the heap.

BasicWhat is Metaspace, and what's stored there?

So Metaspace holds class metadata: the internal structure of each loaded class, its methods and bytecode, field descriptions, the runtime constant pool and so on. It replaced PermGen in Java 8, and the big change is that it lives in native memory, outside the heap, and grows automatically by default.

DataWhere it lives (Java 8+)
Class metadata, method bytecodeMetaspace (native memory)
JIT-compiled machine codeCode cache (native)
String pool (interned strings)Heap (moved there in Java 7)
Static variablesHeap, inside the Class object
Objects and arraysHeap

The PermGen problem was its fixed size. Apps that loaded many classes hit OutOfMemoryError: PermGen space and people had to tune -XX:MaxPermSize. With Metaspace, the limit is the machine's memory unless I set -XX:MaxMetaspaceSize, which I do in containers so a leak fails fast instead of taking the whole pod down.

Metaspace is only freed when a class's class loader becomes unreachable. That's why the classic Metaspace leak is redeploying a web app in Tomcat many times: if one old object keeps a reference to the old class loader, every class it loaded stays in memory.

So in practice, if I see OutOfMemoryError: Metaspace, I suspect a class loader leak or runaway dynamic class generation, like proxies or lambdas created in a loop by a library.

Interviewers look for: "replaced PermGen in Java 8, native memory", and that the string pool and statics are not in Metaspace.

IntermediateStackOverflowError vs OutOfMemoryError: what causes each?

So a StackOverflowError means one thread's stack ran out of room for new frames, almost always because of recursion that's too deep or never stops. An OutOfMemoryError means the JVM couldn't allocate memory in some area, most commonly the heap, even after garbage collection. Both are Errors, not Exceptions.

static int factorial(int n) { return n * factorial(n - 1); }  // no base case

try { factorial(5); }
catch (StackOverflowError e) { System.out.println("StackOverflowError"); }
System.out.println("thread still alive, carrying on");
// Printed after tens of thousands of frames (the exact depth varies by run and -Xss)

// java -Xmx64m
List<byte[]> hold = new ArrayList<>();
while (true) hold.add(new byte[1024 * 1024]);
// java.lang.OutOfMemoryError: Java heap space

Other common stack overflow causes are accidental ones: a toString() that prints a parent which prints its children which print the parent, or bidirectional JPA entities serialized to JSON. The fix is a base case, iteration instead of recursion, or breaking the cycle. Raising -Xss only helps with legitimately deep, finite recursion.

The OutOfMemoryError message tells me which area failed: Java heap space, Metaspace, GC overhead limit exceeded (the JVM spends almost all its time in GC and recovers almost nothing), unable to create native thread, or Direct buffer memory.

So in practice I never catch OutOfMemoryError to "carry on", because the JVM's state is unreliable afterwards. I let it die, with -XX:+HeapDumpOnOutOfMemoryError set, and the orchestrator restarts it.

IntermediateWhat are the main JVM memory flags, and how do they behave in containers?

So the essentials are -Xms for initial heap, -Xmx for maximum heap, -Xss for each thread's stack, and -XX:MaxMetaspaceSize. But in containers I usually prefer percentage flags so the heap follows the container's memory limit.

# Fixed sizes
java -Xms2g -Xmx2g -Xss512k -XX:MaxMetaspaceSize=256m -jar app.jar

# Container-friendly (Java 10+ reads cgroup limits)
java -XX:MaxRAMPercentage=75.0 -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/dumps -jar app.jar

# Check what the JVM actually picked
java -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|UseG1GC"

Without any flags, the default max heap is only 25% of the container's memory, which wastes most of a 2 GB pod. So I set MaxRAMPercentage to about 70–75%.

I don't set it to 100%, because the heap is not the JVM's only memory. There's Metaspace, the code cache, thread stacks (200 threads × 1 MB is 200 MB), direct buffers used by Netty or NIO, and GC bookkeeping. I've seen a pod get OOM-killed by Kubernetes with exit code 137 while the heap looked perfectly healthy, because heap plus everything else exceeded the limit. That kill leaves no Java error at all.

Setting -Xms equal to -Xmx avoids heap resizing pauses in long-running servers.

So in practice: percentage-based heap, room for native memory, a heap dump on OOM, and -XX:NativeMemoryTracking=summary when I need to find where non-heap memory went.

AdvancedHow is the heap organised into generations, and why?

So most collectors split the heap into a young generation and an old generation, based on the observation that most objects die young. Request objects, temporary strings and iterators live for milliseconds. Collecting the young generation often, and the old generation rarely, is much cheaper than scanning everything.

Young generation                      | Old generation
[ Eden ][ Survivor S0 ][ Survivor S1 ] | [ long-lived objects: caches, pools, singletons ]

1. new objects are allocated in Eden (fast: bump a pointer in a thread-local buffer)
2. Eden fills -> minor GC: live objects copied to a survivor space, Eden wiped
3. each survival increments the object's age
4. age reaches the tenuring threshold (up to 15) -> promoted to old gen
5. very large objects may go straight to old gen (G1: "humongous" regions)

A minor GC only has to find the few survivors and copy them. The dead objects cost nothing, because Eden is simply reset. That's why allocating many short-lived objects in Java is cheap.

The trick that makes this work is the card table. When an old object points to a young one, a write barrier records it, so a minor GC can treat those old-to-young references as roots without scanning the whole old generation.

G1 keeps the generational idea but divides the heap into many equal regions, each tagged as Eden, survivor, old or humongous, instead of fixed contiguous areas.

So in practice, trouble starts with premature promotion: medium-lived objects, like a big batch held for a few seconds, survive enough minor GCs to be promoted, fill the old generation, and trigger expensive old-gen collections.

36. Garbage Collection

BasicHow does the garbage collector decide what to collect?

So it uses reachability, not reference counting. It starts from a set of GC roots and follows every reference. Anything it can reach is live. Everything else is garbage, even objects that still point to each other.

The GC roots are:

  • Local variables and parameters in every thread's active stack frames.
  • Static fields of loaded classes.
  • Active threads themselves.
  • JNI references, and objects used as monitors in synchronized.
class Node { Node other; }

void demo() {
    Node a = new Node();
    Node b = new Node();
    a.other = b;
    b.other = a;       // a cycle
    a = null;
    b = null;          // no root reaches either one
}                      // both are collectable, despite pointing at each other

That cycle is exactly what reference counting gets wrong: each object still has a count of 1, so it would never be freed. Tracing from roots handles cycles naturally.

The basic algorithm is mark (walk from the roots and mark live objects), sweep (reclaim the rest), and often compact or copy (move survivors together to avoid fragmentation). Modern collectors do much of the marking concurrently while the application runs.

So in practice this is why a "memory leak" in Java means objects that are still reachable but no longer needed, like entries in a static map. The GC is doing its job correctly. It just can't know I've finished with them.

Interviewers look for: "GC roots and reachability", a list of what counts as a root, and "cycles are collected".

BasicCan you force garbage collection in Java?

So no. System.gc() and Runtime.getRuntime().gc() are only a request. The JVM is allowed to ignore them, and there's no guarantee of when or whether a collection happens or which objects get freed.

System.gc();       // "please consider collecting now"

// It can be disabled entirely:
java -XX:+DisableExplicitGC -jar app.jar

// For diagnostics, from outside the process:
jcmd <pid> GC.run

In HotSpot, by default System.gc() actually does trigger a full, stop-the-world collection, which is exactly why calling it in production code is a bad idea. I once found a library calling it after every batch export, and each call caused a pause of about 800 ms. Adding -XX:+DisableExplicitGC fixed the latency spikes. The exception is when System.gc() is used deliberately to free direct buffer memory, so I check for that before disabling it.

Setting a reference to null doesn't force anything either. It only makes the object eligible. For a local variable that's about to go out of scope, it's pointless.

So in practice the only places I call System.gc() are demos and tests, like checking that a WeakReference gets cleared. If memory is a problem, I tune the heap or fix what holds the references.

IntermediateWhat's the difference between a minor GC, a major GC and a full GC?

So a minor GC collects only the young generation. It's frequent and usually takes milliseconds. A major GC collects the old generation. A full GC collects the whole heap, young and old, usually with compaction and a long stop-the-world pause. The terms "major" and "full" are often used loosely, so I describe what was collected rather than rely on the name.

Minor (young)Full
ScopeEden + survivorsEntire heap (+ Metaspace cleanup)
FrequencyOften, seconds apartShould be rare or never
PauseShort, proportional to live young objectsLong, proportional to live heap
Triggered byEden fullOld gen full, promotion failure, Metaspace, System.gc()

With G1 there's a middle ground: mixed collections that collect the young generation plus a selection of old regions with the most garbage. G1 is designed so the old generation is cleaned incrementally this way, and a full GC is a fallback when it can't keep up.

java -Xlog:gc -jar app.jar
[12.345s][info][gc] GC(41) Pause Young (Normal) (G1 Evacuation Pause) 512M->96M(1024M) 6.123ms
[98.765s][info][gc] GC(87) Pause Full (G1 Compaction Pause) 1010M->900M(1024M) 1840.2ms

That second line is a red flag: a full GC that took almost 2 seconds and only freed 110 MB. The live data nearly fills the heap. It's either too small or leaking.

So in practice I treat frequent full GCs as a symptom to investigate, not a tuning parameter.

IntermediateExplain strong, soft, weak and phantom references.

So they're four levels of how strongly a reference keeps an object alive. A strong reference, a normal variable, prevents collection. A soft reference is cleared only when memory is tight. A weak reference is cleared at the next GC once nothing strong points to the object. A phantom reference never returns the object at all: it only tells me, through a queue, that the object has been collected.

Object strong = new Object();
WeakReference<Object> weakToStrong = new WeakReference<>(strong);
WeakReference<Object> weak = new WeakReference<>(new Object());
SoftReference<byte[]> soft = new SoftReference<>(new byte[1024]);
ReferenceQueue<Object> q = new ReferenceQueue<>();
PhantomReference<Object> phantom = new PhantomReference<>(new Object(), q);

phantom.get();                 // null, always
System.gc();
weakToStrong.get() != null;    // true  (still strongly reachable)
weak.get();                    // null  (only weakly reachable: cleared)
soft.get() != null;            // true  (memory is fine, kept)
q.remove(1000) == phantom;     // true  (enqueued after collection)
TypeCleared whenUsed for
StrongNever, while reachableEverything normal
SoftBefore an OOM, under memory pressureMemory-sensitive caches (with care)
WeakNext GCWeakHashMap, metadata attached to objects
PhantomAfter collection, via the queueCleanup of native resources (Cleaner)

Soft-reference caches sound great but behave unpredictably: they tend to be cleared all at once under pressure, causing a burst of cache misses exactly when the system is struggling. I prefer a size-bounded cache like Caffeine.

So in practice I use weak references through WeakHashMap or ThreadLocal internals, and phantom references through java.lang.ref.Cleaner (Java 9), which is the replacement for finalize().

Interviewers look for: the order of strength, one real use per type, and "Cleaner replaced finalize".

IntermediateWhat is a WeakHashMap, and when would you use it?

So a WeakHashMap holds its keys through weak references. When a key object is no longer strongly reachable anywhere else, the GC clears it and the map drops the whole entry on its next operation. It's useful for attaching extra data to objects I don't own, without keeping those objects alive.

Map<Object, String> cache = new WeakHashMap<>();
Object key = new Object();
cache.put(key, "metadata");
cache.put("literal", "never cleared");
System.out.println("before: " + cache.size());          // before: 2

key = null;                  // drop the only strong reference
System.gc();
System.out.println("after:  " + cache.size() + " " + cache.values());
// after:  1 [never cleared]

Notice the "literal" entry survives: string literals are held strongly by the string pool, so they never become weakly reachable. The same is true for small cached Integer values. That's the first pitfall.

The second pitfall is a value that references its own key. The map holds values strongly, so the value keeps the key alive and the entry is never removed. The third is that it compares keys with equals, which is surprising for a map that's really about identity.

It's also not thread-safe, so I'd wrap it with Collections.synchronizedMap when sharing it.

So in practice, I use it rarely and deliberately, for example per-class-loader or per-session metadata. It's not a general cache: it evicts by reachability, not by size or age, so for caching I use Caffeine with maximumSize and expireAfterWrite.

AdvancedG1 vs ZGC: how do they differ and how do you choose a collector?

So G1 is the default since Java 9. It balances throughput and pause times, targets a pause goal (200 ms by default) and does its evacuation, the copying, in stop-the-world pauses. ZGC (production since Java 15, generational in 21) does almost all its work concurrently, including moving objects, so pauses stay under a millisecond regardless of heap size, at the cost of some throughput and extra CPU.

CollectorPausesBest forFlag
SerialStop-the-world, single threadTiny heaps, 1-CPU containers-XX:+UseSerialGC
ParallelStop-the-world, many threadsBatch jobs: maximum throughput-XX:+UseParallelGC
G1 (default)Tens of ms, goal-drivenMost services, heaps of 4–32 GB-XX:MaxGCPauseMillis=200
ZGCUnder 1 msLatency-critical, very large heaps-XX:+UseZGC -XX:+ZGenerational
ShenandoahVery short, concurrentSame niche as ZGC (not in all builds)-XX:+UseShenandoahGC

ZGC's trick is coloured pointers and load barriers. Metadata bits live in each reference, and every time the application loads a reference, a small barrier checks whether the object has moved and fixes the pointer. That's how it relocates objects while the application is running.

In Java 21, ZGC's generational mode needs -XX:+ZGenerational. Later releases made it the default and removed the non-generational mode. Without generations, ZGC has to trace the whole heap every cycle, which costs more CPU.

So in practice I start with G1 and only tune the heap size and pause goal. I'd switch to ZGC when p99 latency is dominated by GC pauses, like a trading API or a large in-memory cache, and I measure with GC logs before and after. Throughput-only batch jobs are where Parallel GC still wins.

Interviewers look for: G1 is the default since 9; ZGC is concurrent with sub-millisecond pauses; the trade-off is throughput and CPU; and "measure before switching".

AdvancedWhat's wrong with finalize(), and what replaced it?

So finalize() was meant to run cleanup before an object is collected, but it's unreliable and harmful. There's no guarantee when it runs, or if it runs at all. It was deprecated in Java 9 and marked for removal in Java 18 (JEP 421).

The problems I'd list:

  • No timing guarantee: file handles or sockets can stay open for minutes, until some future GC. Under low memory pressure, maybe never.
  • Slower GC: finalizable objects need at least two GC cycles to be freed, and a single finalizer thread processes them. If it falls behind, the queue grows until OutOfMemoryError.
  • Resurrection: finalize can store this in a static field and bring the object back to life.
  • Exceptions ignored: anything thrown in it is silently swallowed.
// Replacement 1: deterministic cleanup
try (var in = Files.newInputStream(path)) {
    ...
}   // closed right here, always

// Replacement 2: safety net for native resources (Java 9)
private static final Cleaner CLEANER = Cleaner.create();
NativeBuffer(long address) {
    this.cleanable = CLEANER.register(this, () -> free(address)); // must not capture 'this'
}

Cleaner is built on phantom references, so the object can't be resurrected. The cleanup action must not reference the object itself, otherwise the object stays reachable forever.

So in practice: AutoCloseable with try-with-resources is the real answer. Cleaner is only a backstop in case someone forgets to call close().

37. Class Loading

BasicWhat is a class loader? What are the built-in ones?

So a class loader finds the bytecode for a class by name, and turns it into a Class object inside the JVM. Classes are loaded lazily, the first time they're needed, not all at startup. Since Java 9 there are three built-in loaders arranged in a parent chain.

System.out.println(String.class.getClassLoader());
// null        -> bootstrap loader (native, has no Java object)

System.out.println(java.sql.Connection.class.getClassLoader().getName());
// platform

ClassLoader app = Loaders.class.getClassLoader();
System.out.println(app.getName() + " -> parent " + app.getParent().getName()
    + " -> parent " + app.getParent().getParent());
// app -> parent platform -> parent null
LoaderLoads
BootstrapCore modules: java.base (java.lang, java.util…). Written in native code, shown as null.
Platform (was Extension before Java 9)Other Java SE modules like java.sql
Application (system)My code and JARs on the classpath or module path

Loading has three phases: loading (find the bytes), linking (verify the bytecode, allocate static fields with default values, resolve symbolic references) and initialization (run static initializers and assign static fields).

A class's identity at runtime is its name plus the loader that loaded it. The same com.acme.User loaded by two different loaders is two different classes, and casting between them fails with a ClassCastException that looks absurd: "User cannot be cast to User".

So in practice I rarely touch class loaders directly, but that last point explains many strange errors in app servers and plugin systems.

IntermediateWhat is the parent delegation model, and why does it exist?

So when a class loader is asked for a class, it first asks its parent, which asks its own parent, all the way up to bootstrap. Only if the parents can't find it does the loader try to load it itself. The request goes up first, and loading comes back down.

// Simplified ClassLoader.loadClass
protected Class<?> loadClass(String name, boolean resolve) {
    Class<?> c = findLoadedClass(name);            // 1. already loaded?
    if (c == null) {
        try {
            c = parent.loadClass(name, false);     // 2. ask the parent first
        } catch (ClassNotFoundException e) {
            c = findClass(name);                   // 3. only then look myself
        }
    }
    return c;
}

It exists for two reasons. Security: if I put my own java.lang.String on the classpath, the application loader asks bootstrap first, bootstrap finds the real one, and mine is never used. Core classes can't be replaced. Consistency: each class is loaded once, by the highest loader that can see it, so everyone shares the same String class and there are no duplicate types.

When I write a custom class loader, I override findClass, not loadClass, so I keep delegation for free.

Some systems deliberately invert it. Tomcat's web-app loader checks the WAR's own WEB-INF/lib before its parent, so each app can use its own library versions. It still delegates java.* to the parent.

So in practice, delegation is why "which JAR did this class actually come from?" is a real debugging question. -verbose:class or MyClass.class.getProtectionDomain().getCodeSource() answers it.

Interviewers look for: "parent first", the security reason (you can't override java.lang), and knowing that containers like Tomcat invert it for isolation.

IntermediateClassNotFoundException vs NoClassDefFoundError: what's the difference?

So ClassNotFoundException is a checked exception thrown when code asks for a class by name at runtime, via Class.forName or loadClass, and it isn't there. NoClassDefFoundError is an error thrown when a class that was present at compile time can't be loaded or initialized at runtime.

// 1. ClassNotFoundException: dynamic lookup, driver JAR missing
try { Class.forName("com.mysql.cj.jdbc.Driver"); }
catch (ClassNotFoundException e) { System.out.println("ClassNotFoundException: " + e.getMessage()); }
// ClassNotFoundException: com.mysql.cj.jdbc.Driver

// 2. NoClassDefFoundError: the class exists, but its static init failed
static class Config {
    static final int PORT = Integer.parseInt(System.getenv().getOrDefault("APP_PORT", "abc"));
}
for (int i = 1; i <= 2; i++) {
    try { System.out.println(Config.PORT); }
    catch (Throwable t) { System.out.println("attempt " + i + ": " + t.getClass().getName()); }
}
// attempt 1: java.lang.ExceptionInInitializerError
// attempt 2: java.lang.NoClassDefFoundError

The second case is the one that fools people. The first time, the static initializer throws, and I get ExceptionInInitializerError with the real cause. The JVM marks the class as failed, so every later use gets NoClassDefFoundError: Could not initialize class Config. If I only look at the latest logs, I see the error, check that the JAR is there, and get confused. The root cause is the first error.

The other common cause of NoClassDefFoundError is a dependency present at compile time but missing at runtime, like a Maven scope set to provided by mistake, or two versions of a library conflicting.

So in practice: CNFE means check the classpath and the name string. NCDFE means scroll back for an ExceptionInInitializerError, then check mvn dependency:tree.

Interviewers look for: checked exception vs error, dynamic lookup vs compile-time reference, and the static-initializer failure case.

IntermediatePredict the output: when does a class get initialized?
static class Parent {
    static { System.out.println("Parent static"); }
    { System.out.println("Parent instance"); }
    Parent() { System.out.println("Parent constructor"); }
}
static class Child extends Parent {
    static final int CONST = 42;
    static int counter = 7;
    static { System.out.println("Child static"); }
    { System.out.println("Child instance"); }
    Child() { System.out.println("Child constructor"); }
}

System.out.println(Child.CONST);
System.out.println("---");
System.out.println(Child.counter);
System.out.println("---");
new Child();
System.out.println("---");
new Child();

So the output is:

42
---
Parent static
Child static
7
---
Parent instance
Parent constructor
Child instance
Child constructor
---
Parent instance
Parent constructor
Child instance
Child constructor

Let me walk through it. Child.CONST is a static final compile-time constant, so the compiler inlines 42 into the caller and Child isn't initialized at all. Reading Child.counter is a real static field access, which triggers initialization, and a superclass is always initialized first: "Parent static", then "Child static".

Static blocks run once per class loader, so the second new Child() doesn't print them again. For each instance, the order is parent first: parent's instance initializers, parent constructor, then the child's.

A class is initialized on: new, a static method call, a non-constant static field access, Class.forName, a subclass being initialized, or being the main class. Declaring a variable of the type, or Child.class, doesn't do it.

So in practice, the constant-inlining rule has a real consequence: if I change a public static final constant in a library and don't recompile the callers, they keep the old value.

AdvancedWhen would you write a custom class loader, and how do class loader leaks happen?

So custom loaders exist for isolation and dynamic code: app servers giving each web app its own classes, plugin systems loading JARs at runtime, hot reload in dev tools like Spring DevTools, and loading classes generated at runtime or from encrypted or remote sources.

// Load a plugin JAR, isolated from other plugins
URL jar = Path.of("plugins/report.jar").toUri().toURL();
try (URLClassLoader loader = new URLClassLoader(new URL[]{jar},
                                                getClass().getClassLoader())) {
    Class<?> cls = loader.loadClass("com.acme.ReportPlugin");
    Plugin p = (Plugin) cls.getDeclaredConstructor().newInstance();  // shared interface
    p.run();
}

The shared Plugin interface has to come from the parent loader. If the plugin JAR also bundled its own copy of Plugin, the cast would fail, because they'd be two different classes.

The leak comes from this chain: every object knows its class, every class knows its loader, and a loader references every class it loaded. So one surviving object from a plugin or old web app keeps the whole loader, and all its classes and Metaspace, alive. The classic culprits I've seen are:

  • A JDBC driver registered in DriverManager by the web app and never deregistered.
  • A ThreadLocal value on a container thread that outlives the app.
  • A thread started by the app, like a timer, that's never stopped.

After a few redeploys, you see OutOfMemoryError: Metaspace. In a heap dump, I look for multiple instances of the web-app class loader and follow the path to GC roots of the old ones.

So in practice, that's one reason the industry moved to one app per JVM in a container: redeploying means a new process, so this whole class of leak disappears.

38. Performance, Profiling & Memory Leaks

BasicHow can Java have memory leaks if it has a garbage collector?

So the GC frees unreachable objects. A Java memory leak is objects that are still reachable, because some reference chain from a GC root still leads to them, but that the program will never use again. The GC can't read my intentions, so it has to keep them.

record Request(String id, byte[] payload) {}
static final Map<String, Request> INFLIGHT = new HashMap<>();   // GC root: static

static void handle(Request r) {
    INFLIGHT.put(r.id(), r);
    // ... process ...
    // missing: INFLIGHT.remove(r.id());
}
// after 1000 finished requests: INFLIGHT.size() == 1000, and it keeps growing

The usual suspects I check are:

  • Static collections and caches without eviction.
  • Listeners and callbacks registered and never removed.
  • ThreadLocals on pooled threads that are never cleared.
  • Unclosed resources: streams, connections, result sets, which also leak native memory.
  • Non-static inner classes and lambdas that capture the outer object and outlive it.
  • Mutable keys in a HashMap whose hash changes, so the entry can never be found and removed.

The symptom is a sawtooth heap graph where the low points after each GC keep rising, ending in more frequent GCs, latency spikes and finally OutOfMemoryError.

So in practice, any cache I write has a maximum size or TTL, any "register" has a matching "unregister", and resources go in try-with-resources.

IntermediateHow can ThreadLocal cause a memory leak or a data bug in a thread pool?

So a ThreadLocal value lives as long as the thread. In a pool, threads live for the whole application, so values set during one request stay on that thread after the request ends. That's a leak if the values are large, and a data bug if the next task on the same thread reads the previous user's data.

static final ThreadLocal<StringBuilder> BUF = ThreadLocal.withInitial(StringBuilder::new);
ExecutorService pool = Executors.newFixedThreadPool(1);

pool.submit(() -> BUF.get().append("user=alice")).get();
pool.submit(() -> BUF.get().toString()).get();          // "user=alice"  (leaked into next task)

pool.submit(() -> {
    try { BUF.get().append("x"); }
    finally { BUF.remove(); }                            // always clean up
}).get();
pool.submit(() -> BUF.get().toString()).get();          // ""

Internally, each thread has a ThreadLocalMap whose keys are weak references to the ThreadLocal object, but whose values are strong. So even if the ThreadLocal itself is gone, the value stays until that slot happens to be cleaned up. And in an app server, if that value's class came from a web app's class loader, the whole loader leaks too.

In a real incident I saw, a security context stored in a ThreadLocal wasn't cleared on an error path, and a later request on the same Tomcat thread briefly ran with the previous user's tenant id. That's a data leak, not only a memory leak.

So in practice: every set has a remove() in a finally, ideally in one filter or interceptor. On Java 21 with virtual threads, ScopedValue (preview) avoids the problem because the value is bound only for a scope.

Interviewers look for: "the thread outlives the request", remove() in finally, and weak key with strong value.

IntermediateHow would you find a memory leak in production?

So I work in three steps: confirm it's really a leak, capture a heap dump, and find what's holding the memory. A rising heap on its own isn't a leak. A rising heap after full GCs is.

1. Confirm. I look at GC logs or a metrics dashboard (Micrometer, Grafana) for old-gen usage after each collection. If the floor keeps climbing over hours, and GCs get more frequent while freeing less, it's a leak.

# Always on in production, so a crash leaves evidence
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps -Xlog:gc*:file=gc.log

# Quick look: which classes have the most instances (live objects only)
jcmd <pid> GC.class_histogram | head -20

# Full dump (pauses the app; take one instance out of the load balancer first)
jcmd <pid> GC.heap_dump /dumps/app.hprof

# Low overhead continuous option: JFR with old object sampling
jcmd <pid> JFR.start duration=30m settings=profile filename=/dumps/leak.jfr

2. Compare. Two class histograms taken 30 minutes apart are often enough. If byte[] and Request counts both grew by 200,000, I have a strong suspect.

3. Analyse. I open the dump in Eclipse MAT and look at the dominator tree and the "Leak Suspects" report, which show the objects that retain the most memory. Then I use "path to GC roots, excluding weak references" on a suspect object to see the chain that keeps it alive, like static RequestTracker.INFLIGHT → HashMap → Request. That chain points straight at the line of code.

In my last project this found a metrics tag built from a URL containing user ids, which created a new meter per user. The dominator tree showed the meter registry holding 1.4 GB.

So in practice: dump on OOM always enabled, histograms to find the suspect, and MAT's path to GC roots to find the cause. Then I write a test that reproduces it before fixing it.

Interviewers look for: a concrete sequence of tools (GC logs, jcmd, heap dump, MAT dominator tree, path to GC roots) and awareness that a heap dump pauses the JVM.

IntermediateA Java service is at 100% CPU. How do you troubleshoot it?

So first I figure out whether the CPU is burned by my code or by the garbage collector, then I find the exact threads and stack traces responsible. The core technique is matching the OS's per-thread CPU usage with a thread dump.

# 1. Which threads are hot? (Linux; -H shows threads, TIDs are decimal)
top -H -p <pid>
#   PID  %CPU  COMMAND
# 25347  99.0  java

# 2. Thread dump, then find that thread
jcmd <pid> Thread.print > dump.txt
# Java 21 prints nid in decimal:
"order-matcher" #20 [25347] daemon prio=5 ... cpu=1688.35ms ... nid=25347 runnable
   java.lang.Thread.State: RUNNABLE
	at Spin.lambda$main$0(Spin.java:3)
# Older JDKs print nid in hex: convert with  printf '%x\n' 25347  -> 6303

# 3. Is it GC? Check GC activity
jstat -gcutil <pid> 1000       # old gen near 100% and FGC climbing = GC thrashing

I take three dumps a few seconds apart. If the same thread is RUNNABLE in the same method every time, that's my hot spot. Typical causes I've seen: an infinite loop from a bad exit condition, a regex with catastrophic backtracking on user input, a HashMap used unsafely from multiple threads, busy-wait polling loops, and huge JSON serialization.

If the hot threads are named like GC Thread#0 or G1 Conc#0, the CPU problem is really a memory problem: the heap is nearly full and the JVM keeps collecting. Then I switch to the memory leak process.

For anything subtler than one stuck thread, I use a profiler: JFR (jcmd <pid> JFR.start) with JDK Mission Control, or async-profiler to get a flame graph, both safe for production.

So in practice: top -H, thread dump, match the thread ID, repeat the dump to confirm, rule out GC, then profile if needed.

Interviewers look for: the top -H plus thread dump technique, several dumps rather than one, and checking GC before blaming application code.

AdvancedHow does the JIT compiler work, and why does Java get faster after warm-up?

So the JVM starts by interpreting bytecode, counts how often each method and loop runs, and compiles hot code to native machine code. Since Java 8 it uses tiered compilation: first C1, a fast compiler with light optimisation, and then, for really hot code, C2, which produces highly optimised code based on the profile collected at runtime.

Tier 0: interpreter                         (collects profile)
Tier 3: C1 compiled + full profiling        (after ~200 calls / loop iterations)
Tier 4: C2 fully optimised                  (after ~thousands of calls)

java -XX:+PrintCompilation -jar app.jar     # watch methods being compiled

C2 can optimise better than a static compiler in some cases, because it sees real behaviour. It inlines small methods, removes checks it can prove unnecessary, uses escape analysis to avoid allocating objects that don't escape, and makes speculative assumptions, like "this interface call always hits the same class". If an assumption breaks, say a second implementation gets loaded, it deoptimises back to the interpreter and recompiles later.

That's why a freshly started service has slow first requests and a latency graph that settles after a minute or two. In Kubernetes I've handled this by sending warm-up traffic before the readiness probe passes. For startup-critical cases there's CDS/AppCDS for faster class loading, and GraalVM native image, or CRaC, for near-instant startup.

So in practice, warm-up is also why naive benchmarks lie: timing a method in a simple loop measures the interpreter, then the compiler, then possibly code that C2 deleted entirely.

AdvancedHow do you benchmark Java code correctly?

So I use JMH, the Java Microbenchmark Harness from the OpenJDK team. Hand-written timing loops are almost always wrong because of JIT warm-up, dead code elimination and constant folding. JMH handles all of that for me.

// Naive: misleading
long t = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) compute(i);   // result unused: C2 may delete it
System.out.println(System.nanoTime() - t);

// JMH
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5) @Measurement(iterations = 5) @Fork(2)
@State(Scope.Thread)
public class ConcatBench {
    String a = "order-", b = "42";

    @Benchmark
    public String plus() { return a + b; }            // returned: can't be eliminated

    @Benchmark
    public void builder(Blackhole bh) {
        bh.consume(new StringBuilder(a).append(b).toString());
    }
}

What JMH does: warm-up iterations so I measure compiled code, several forks (separate JVMs) so one lucky JIT decision doesn't skew results, Blackhole and return values to stop dead code elimination, and @State fields instead of constants so the compiler can't fold the whole calculation at compile time.

Then I read the error margin, not just the average. If two results overlap within the error, there's no real difference.

And microbenchmarks answer narrow questions. For "is my service fast enough", I load-test the whole system with Gatling or k6 and profile with JFR or async-profiler under that load. The bottleneck is usually I/O, a missing index or lock contention, not the microsecond detail.

So in practice: profile first to find what's actually slow, JMH to compare alternatives for that hot spot, and a load test to confirm the improvement end to end.

Interviewers look for: JMH by name, and at least two of the pitfalls: warm-up, dead code elimination, constant folding, forking.

Part 7 — Modern & Practical Java

39. Modern Java: 8 to 21

BasicWhat are the big features in Java 8, 11, 17 and 21, and which ones have you actually used?

So I think of it as four LTS releases, each with one headline theme. Java 8 brought functional programming, 11 was mostly a clean-up plus the HTTP client, 17 made data modelling nicer with records and sealed types, and 21 brought virtual threads and pattern matching together.

ReleaseWhat I'd name
Java 8 (2014)Lambdas, streams, Optional, default methods, java.time, CompletableFuture
Java 11 (2018)var in lambdas (var itself is 10), HttpClient, String.isBlank/strip/lines/repeat, Files.readString, run a single file with java App.java
Java 17 (2021)Records (16), sealed classes, text blocks (15), switch expressions (14), pattern matching for instanceof (16), helpful NullPointerExceptions, strong encapsulation of JDK internals
Java 21 (2023)Virtual threads, pattern matching for switch, record patterns, sequenced collections (getFirst, reversed)

In day-to-day work, streams and java.time are everywhere in my code. Records replaced most of our Lombok @Value DTOs when we moved to 17. On the 21 upgrade we switched our blocking HTTP fan-out service to Executors.newVirtualThreadPerTaskExecutor(), and that let us drop a hand-tuned thread pool of 200 threads without rewriting anything to reactive.

So in practice I always tie a feature to a problem it solved for me, not just list release notes.

Interviewers look for: real usage, not a memorised list. Knowing which version a feature landed in (records in 16, final in the 17 LTS) is a nice bonus.

BasicWhat is a record, and how is it different from a normal class?

So a record is a transparent carrier for immutable data. You declare the components once and the compiler gives you a canonical constructor, private final fields, accessors, and equals, hashCode and toString based on all components. Records were final in Java 16.

Let me show you with a quick example.

record Money(long cents, String currency) {
    Money {                                   // compact constructor: validation
        if (cents < 0) throw new IllegalArgumentException("negative");
        Objects.requireNonNull(currency);
    }
    Money plus(Money o) { return new Money(cents + o.cents, currency); }
}

Money a = new Money(500, "USD");
a.plus(new Money(250, "USD"));        // Money[cents=750, currency=USD]
a.equals(new Money(500, "USD"));      // true, value-based equality

The differences from a regular class: a record is implicitly final, it can't extend another class (it already extends java.lang.Record), and it can't declare extra instance fields. It can implement interfaces, have static fields, and add methods. Accessors are cents(), not getCents(), which matters for some older frameworks.

One catch: records are only shallowly immutable. If a component is a List, I copy it in the compact constructor with List.copyOf.

So in practice I use records for DTOs, API responses, map keys and value objects. I use a class when I need mutable state, inheritance, or JPA entities, because JPA needs a no-arg constructor and mutable fields.

Interviewers look for: "shallow immutability" and why records don't work as JPA entities.

BasicWhat does var do? What are the pros and cons?

So var, added in Java 10, is local variable type inference. The compiler works out the static type from the initializer. It's still fully statically typed. Nothing is dynamic at runtime, and var isn't a keyword, it's a reserved type name.

var names = new ArrayList<String>();          // ArrayList<String>
var byDept = new HashMap<String, List<Employee>>();   // saves a lot of noise
for (var entry : byDept.entrySet()) { ... }

var x;                 // compile error: no initializer
var n = null;          // compile error: can't infer
var list = List.of();  // List<Object>, probably not what you wanted
var total = service.compute();   // what type is this? reader has to go look

The pros: less repetition with long generic types, cleaner loops and try-with-resources, and names become more important, which pushes people to write good ones. The cons: it can hide the type when the right-hand side is a method call, it programs to the concrete type (ArrayList instead of List), and it's only allowed for locals, not fields, parameters or return types.

So in practice my rule is: use var when the type is obvious from the right-hand side, like new or a literal, and write the type out when it's a method call whose return type isn't obvious.

IntermediateWhat are text blocks and switch expressions, and why are they better than what we had?

So text blocks (Java 15) are multi-line string literals in triple quotes, and switch expressions (Java 14) let a switch return a value with arrow labels and no fall-through. Both remove a whole class of small bugs.

Let me show you with a quick example.

var json = """
        {
          "name": "Asha",
          "role": "dev"
        }
        """;
// the indentation of the closing """ decides how much leading space is stripped

int hours = switch (day) {
    case "SAT", "SUN" -> 0;
    default -> {
        int base = 8;
        yield base;          // yield returns a value from a block
    }
};

With text blocks I no longer write "{\n" + " \"name\"..." with escaped quotes. They're great for JSON in tests, SQL, and HTML templates. The compiler strips the common indentation, so the code stays nicely indented.

With the old switch statement, forgetting a break silently fell through to the next case. The arrow form never falls through, one arm can match several labels, and because it's an expression the compiler forces me to cover every case, or add a default, before I can assign the result.

So in practice I use the arrow switch everywhere now, even as a statement.

IntermediateWhat are sealed classes and when would you use one?

So a sealed class or interface (Java 17) lists exactly which types are allowed to extend it. It sits between final, which allows no subclasses, and a normal open type, which allows any. The payoff is that the compiler knows the full set of subtypes, so a switch over them can be checked for exhaustiveness.

Let me show you with a quick example.

sealed interface Shape permits Circle, Square, Rect { }
record Circle(double r) implements Shape { }
record Square(double side) implements Shape { }
record Rect(double w, double h) implements Shape { }

static double area(Shape s) {
    return switch (s) {                 // no default needed: compiler knows all cases
        case Circle c -> Math.PI * c.r() * c.r();
        case Square sq -> sq.side() * sq.side();
        case Rect(double w, double h) -> w * h;
    };
}
// area(new Rect(2, 3)) = 6.0

Each permitted subtype has to say what it is: final, sealed again, or non-sealed to reopen that branch. Records are implicitly final, so they fit perfectly.

The real use case is modelling a closed set of outcomes. In my last project a payment call returned sealed interface PaymentResult permits Approved, Declined, NeedsThreeDS. When product added a fourth case, every switch that didn't handle it stopped compiling, so we found every place to update in one build instead of in production.

So in practice: sealed plus records gives Java proper algebraic data types. I use it for results, commands and events, not for things plugins should extend.

Interviewers look for: the link between sealed types and exhaustive switches. That's the whole point, not just "restricting inheritance".

IntermediateExplain pattern matching for switch and record patterns in Java 21.

So pattern matching lets a case test the type of a value and bind it to a variable in one step. Java 16 did this for instanceof, and Java 21 brought it to switch, with guards using when, explicit case null, and record patterns that take a record apart.

static String describe(Object o) {
    return switch (o) {
        case null -> "null";
        case Integer i when i > 100 -> "big int " + i;
        case Integer i -> "int " + i;
        case String s -> "string of length " + s.length();
        default -> "something else";
    };
}
// describe(150) = "big int 150", describe(7) = "int 7", describe(null) = "null"

if (obj instanceof String s && s.length() > 3) {   // s is in scope here
    System.out.println(s.toUpperCase());
}

A few rules I keep in mind. Order matters: a more specific case must come before a more general one, or the compiler reports it as dominated. So Integer i when i > 100 goes above plain Integer i. Without case null, a switch on a null still throws NullPointerException, like before. And over a sealed type, no default is needed.

Record patterns go one level deeper: case Rect(double w, double h) matches and pulls the components out, and they can be nested.

So in practice this replaces long if/else instanceof chains and most uses of the Visitor pattern.

AdvancedYour team is on Java 11. Make the case for moving to 21. What do you gain, and what might break?

So the gains are real on both the language and the runtime side, and the risks are mostly about libraries that relied on JDK internals. I'd pitch it as mostly a dependency upgrade project, not a code rewrite.

What we gain:

  • Virtual threads: blocking I/O code scales to thousands of concurrent requests with no reactive rewrite.
  • Language: records, sealed types, pattern matching and text blocks mean less boilerplate and more compile-time safety.
  • Runtime: better G1 and ZGC (generational ZGC in 21), faster startup, lower memory, helpful NPE messages.
  • Support: Spring Boot 3 needs Java 17 minimum, so staying on 11 blocks framework upgrades too.

What can break:

  • Since JDK 16/17, internals like sun.misc and private fields of JDK classes are strongly encapsulated. Old versions of Lombok, Mockito, ByteBuddy and some serialization libraries fail with InaccessibleObjectException until you upgrade them.
  • Removed things: the Nashorn JS engine, the Security Manager is deprecated, and --illegal-access is gone.
  • Build plugins (compiler, surefire, jacoco) need newer versions.

In my last team we did it in steps: bump the build plugins and libraries on 11, then compile and test on 21 in CI while still targeting 11, fix the warnings, then switch the runtime image, and only then start using new language features.

So in practice: upgrade dependencies first, run the full test suite on the new JDK, and roll out one service at a time.

Interviewers look for: strong encapsulation as the main migration pain, and a staged plan instead of a big-bang switch.

40. I/O, NIO & Serialization

BasicWhat's the difference between byte streams and character streams?

So byte streams, InputStream and OutputStream, move raw bytes. Character streams, Reader and Writer, move chars and handle the encoding for you. The bridge between them is InputStreamReader and OutputStreamWriter, and that's where the charset gets decided.

Let me show you with a quick example.

// binary data: images, zip files, anything that isn't text
try (InputStream in = new BufferedInputStream(new FileInputStream("logo.png"))) { ... }

// text: always say which charset you mean
try (BufferedReader r = new BufferedReader(
        new InputStreamReader(new FileInputStream("users.csv"), StandardCharsets.UTF_8))) {
    String line;
    while ((line = r.readLine()) != null) { ... }
}

// modern shortcut (NIO.2), UTF-8 by default
List<String> lines = Files.readAllLines(Path.of("users.csv"));

The bug I've actually hit with this: someone used new FileReader(file), which before Java 18 used the platform default charset. It worked on every Mac, then a Windows server read the same UTF-8 file as Windows-1252 and names with accents came out garbled. Java 18 made UTF-8 the default everywhere, but I still pass the charset explicitly.

So in practice: bytes for binary, readers and writers for text, always with an explicit charset, always wrapped in a buffer, and always in try-with-resources.

BasicWhat is serialization? What are Serializable and serialVersionUID for?

So serialization turns an object graph into a byte stream so it can be stored or sent over the network, and deserialization rebuilds it. Serializable is a marker interface with no methods. It just tells ObjectOutputStream that the class agrees to be serialized, otherwise you get NotSerializableException.

class User implements Serializable {
    private static final long serialVersionUID = 1L;
    String name;
    transient String password;
}

try (var out = new ObjectOutputStream(new FileOutputStream("user.bin"))) {
    out.writeObject(new User("asha", "s3cret"));
}

serialVersionUID is the version stamp of the class. It gets written into the stream, and on read the JVM compares it with the class it has loaded. If they differ, it throws InvalidClassException.

If I don't declare it, the JVM computes one from the class structure: fields, methods, interfaces. So adding a harmless helper method changes the ID, and every object stored with the old version can't be read anymore. That's why I always declare it explicitly. Then I decide when a change really is incompatible, and compatible changes like adding a field just get default values on read.

A few more rules: every field must itself be serializable or transient, static fields aren't serialized, and the first non-serializable superclass must have a no-arg constructor, because it gets called on read.

Interviewers look for: what happens when you don't declare the UID: a generated value that changes with harmless edits.

BasicWhat does the transient keyword do?

So transient marks a field that Java serialization should skip. When the object is read back, that field gets its type's default value: null, 0 or false. The field initializer and constructor don't run.

Let me show you with a quick example.

static class User implements Serializable {
    private static final long serialVersionUID = 1L;
    String name;
    transient String password;
    transient int loginCount = 5;
    User(String name, String password) { this.name = name; this.password = password; }
}

User u = roundTrip(new User("asha", "s3cret"));   // write then read back
System.out.println(u.name + " " + u.password + " " + u.loginCount);
// asha null 0

The part people get wrong is loginCount. It's initialised to 5, but it comes back as 0, not 5. Deserialization doesn't call the constructor of a serializable class, so field initializers never run.

I use transient for three things: secrets like passwords and tokens, derived or cached values I can recompute (like a cached hash), and fields that can't be serialized at all, like a Thread, a socket or a logger. If I need a transient field restored, I rebuild it in a private readObject method.

So in practice: transient means "not part of the saved state", and I plan how it gets rebuilt.

IntermediateWhat's the difference between java.io and java.nio?

So java.io is stream-oriented and blocking: one thread reads from a stream, and it blocks until data arrives. java.nio (Java 1.4) is buffer- and channel-oriented, and it supports non-blocking I/O with a Selector, so one thread can watch many connections. Then NIO.2 in Java 7 added Path and Files, the modern file API.

java.iojava.nio
ModelStreams, one byte or char at a timeChannels read into and write from ByteBuffers
BlockingAlways blockingCan be non-blocking, with a Selector
Threads for 10k socketsAbout one per connectionA few event-loop threads
ExtrasSimple, easy to wrap with decoratorsMemory-mapped files, zero-copy transferTo, file locks, WatchService

In application code I rarely touch channels and selectors directly. Netty, which sits under Spring WebFlux, gRPC and many other servers, is built on NIO selectors, and that's where it matters. For files, I use Files.readString, Files.lines, Files.walk and Files.copy every day, because they have better error messages than the old File class, which returns false instead of telling you why a delete failed.

With virtual threads in Java 21 the picture shifted again. Simple blocking code on virtual threads now scales like NIO, so for a normal service I'd write blocking code rather than hand-roll a selector loop.

Interviewers look for: blocking vs non-blocking and the selector idea, plus the fact that NIO.2 Files replaced File.

IntermediateHow would you process a 10 GB log file in Java without running out of memory?

So the key is to stream it line by line and never hold the whole file in memory. Files.readAllLines or readString would load all 10 GB onto the heap and die with OutOfMemoryError.

Map<String, Long> errorsByService;
try (Stream<String> lines = Files.lines(Path.of("app.log"), StandardCharsets.UTF_8)) {
    errorsByService = lines
            .filter(l -> l.contains(" ERROR "))
            .map(l -> l.split(" ")[2])                 // service name column
            .collect(Collectors.groupingBy(s -> s, Collectors.counting()));
}

Files.lines is lazy. It reads through a buffered reader as the stream pulls, so memory stays flat no matter how big the file is. It holds an open file handle, though, so it has to be in try-with-resources. That's one of the few streams you must close.

If I need more speed: a BufferedReader with a bigger buffer, a parallel stream (splitting works well for files on Java 9 and later), or splitting the file into byte ranges and handing each chunk to a worker. For pure byte scanning, a memory-mapped FileChannel is the fastest option.

So in practice: stream it, close it, and only aggregate the small result.

AdvancedWhy do people say you should avoid Java serialization? What would you use instead?

So there are three reasons: security, fragility, and the fact that it quietly breaks your class's invariants. Even the Java architects have called it a mistake, and I'd never use it for data coming from outside the process.

  • Security: readObject can build any serializable class on the classpath, and it runs their readObject methods along the way. Attackers chain those calls, called "gadget chains", into remote code execution. The 2015 Apache Commons Collections exploit hit WebLogic, Jenkins and JBoss exactly like this.
  • Hidden constructor: deserialization builds objects without calling your constructor, so validation is skipped, and it's how singletons and immutable objects get broken.
  • Fragility: the format is tied to class structure and serialVersionUID, it's Java-only, verbose and slow.
// if you are stuck with it, allow-list what may be deserialized (Java 9+, JEP 290)
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
        "com.myapp.dto.*;java.base/*;!*");
in.setObjectInputFilter(filter);

Instead I use JSON with Jackson for APIs and config, Protobuf or Avro for high-volume messages between services, because they have explicit schemas and versioning rules, and records as the DTOs being mapped. For caches like Redis I use JSON or a Kryo/Protobuf serializer, not the JDK default.

So in practice: an explicit data format with a schema, and the domain classes never implement Serializable just because some library wanted it.

Interviewers look for: deserialization of untrusted data as a remote code execution risk, and input filters as the mitigation.

41. Design Patterns in Java

BasicHow do you implement a Singleton in Java? Walk me through the variants.

So there are five common versions, and they differ in laziness and thread safety. My default is either the holder idiom or an enum.

VariantLazy?Thread-safe?Comment
Eager: static final INSTANCE = new X()NoYesSimplest. Fine if creation is cheap.
Lazy if (instance == null)YesNoTwo threads can both create one.
synchronized getInstance()YesYesTakes a lock on every call.
Double-checked locking + volatileYesYesCorrect only with volatile.
Holder class idiomYesYesJVM class init does the locking.
enum { INSTANCE }NoYesAlso safe against reflection and serialization.
final class Config {
    private Config() { }
    private static final class Holder {
        static final Config INSTANCE = new Config();   // runs on first Holder access
    }
    static Config getInstance() { return Holder.INSTANCE; }
}

The holder idiom works because the JVM initialises a class lazily, the first time it's used, and class initialisation is guaranteed thread-safe. So Holder isn't loaded until getInstance() is first called, and there's no locking after that.

Double-checked locking needs volatile because instance = new X() isn't atomic. Without it, another thread can see the reference before the constructor has finished and get a half-built object.

So in practice, in a Spring app I rarely write one at all. Beans are singletons by default and they're easier to test than a static getInstance().

Interviewers look for: why volatile is needed in double-checked locking, and the testability downside of singletons.

BasicWhy use the Builder pattern instead of telescoping constructors?

So telescoping constructors means a chain of overloads, each adding one more parameter. Once you have more than three or four optional parameters it becomes unreadable and easy to get wrong. A Builder gives you named, chainable setters and one place to validate before the object is created.

// telescoping: which boolean is which?
new Pizza("large", "stuffed", true, false, true);

// builder: reads like a sentence, the object itself stays immutable
Pizza p = Pizza.builder("large")      // required params go in the builder factory
        .crust("stuffed")
        .extraCheese()
        .topping("olive")
        .build();                     // validation happens here

The telescoping problems are: callers pass arguments in the wrong order and the compiler can't catch it when two params share a type, you need an overload for every combination, and reading true, false, true tells you nothing. The JavaBeans alternative, a no-arg constructor plus setters, fixes readability but leaves the object mutable and possibly half-configured.

The builder keeps the target immutable with a private constructor and final fields, puts required values in the builder's constructor, and checks cross-field rules in build(). You can see the full version in the coding tasks below.

So in practice I reach for a builder at around four or more parameters, often with Lombok's @Builder. For a small record, a static factory like Money.of(5, USD) is enough.

IntermediateHow can a Singleton be broken, and why does an enum fix it?

So a normal singleton can be broken three ways: reflection can call the private constructor, deserialization creates a new object without the constructor, and clone() copies it if the class is cloneable. An enum is protected against all three by the JVM itself.

Let me show you with a quick example.

Constructor<Lazy> c = Lazy.class.getDeclaredConstructor();
c.setAccessible(true);
c.newInstance() != Lazy.get();          // true   reflection made a second one
roundTrip(Lazy.get()) != Lazy.get();    // true   serialize + deserialize made another
Lazy.get().clone() != Lazy.get();       // true   clone made another

// the manual defences
private Guarded() {
    if (INSTANCE != null) throw new IllegalStateException("already created");
}
private Object readResolve() { return INSTANCE; }   // swap the copy for the real one
// and don't implement Cloneable (or make clone() throw)

enum IdGenerator { INSTANCE; ... }
roundTrip(IdGenerator.INSTANCE) == IdGenerator.INSTANCE;   // true
// reflection: IllegalArgumentException: Cannot reflectively create enum objects

Walking through it: the constructor guard stops reflection, readResolve makes deserialization return the existing instance, and not supporting clone closes the last hole. That's three separate fixes you have to remember.

With an enum, the JVM refuses to create enum instances through reflection, enum serialization writes only the name and looks it up with valueOf on read, and enums can't be cloned. That's why Joshua Bloch calls a single-element enum the best way to implement a singleton.

So in practice I use the enum when the singleton doesn't need to extend a class or be lazy.

Interviewers look for: all three attacks, readResolve by name, and the enum as the answer.

IntermediateFactory Method vs Abstract Factory: what's the difference?

So a Factory Method creates one product and hides which concrete class you get. An Abstract Factory creates a family of related products that have to match each other, and you swap the whole family by swapping the factory.

Let me show you with a quick example.

// Factory method: one product, chosen by input
static Notifier notifierFor(String channel) {
    return switch (channel) {
        case "email" -> new EmailNotifier();
        case "sms" -> new SmsNotifier();
        default -> throw new IllegalArgumentException("unknown channel " + channel);
    };
}
notifierFor("sms").send("hi");     // sms: hi

// Abstract factory: a family that must stay consistent
interface CloudFactory {
    BlobStore blobStore();
    MessageQueue queue();
    SecretStore secrets();
}
class AwsFactory implements CloudFactory { ... }    // S3, SQS, Secrets Manager
class GcpFactory implements CloudFactory { ... }    // GCS, Pub/Sub, Secret Manager

With the factory method, the caller asks for "a notifier" and gets the right one. With the abstract factory, the point is consistency. If the app is wired with AwsFactory, every component it hands out is an AWS one, so I can't accidentally pair S3 with Pub/Sub.

In the JDK, List.of, Calendar.getInstance() and NumberFormat.getInstance() are factory methods. DocumentBuilderFactory in the XML API is closer to an abstract factory, because the implementation it picks decides a whole set of parser classes.

So in practice, a static factory method covers most cases. In Spring, the DI container plus @Profile or @ConditionalOnProperty often plays the abstract factory role for me.

IntermediateHow do lambdas change the Strategy pattern?

So Strategy means passing in the algorithm instead of hard-coding it. Before Java 8 that meant an interface plus one class per strategy. Now any functional interface is a strategy type, and a lambda or method reference is the implementation, so most of those small classes disappear.

enum Pricing implements DoubleUnaryOperator {
    REGULAR(p -> p), SALE(p -> p * 0.8), CLEARANCE(p -> Math.max(0, p - 50));
    private final DoubleUnaryOperator f;
    Pricing(DoubleUnaryOperator f) { this.f = f; }
    public double applyAsDouble(double p) { return f.applyAsDouble(p); }
}

final class Checkout {
    private final DoubleUnaryOperator pricing;
    Checkout(DoubleUnaryOperator pricing) { this.pricing = pricing; }
    double total(List<Double> prices) {
        return prices.stream().mapToDouble(pricing::applyAsDouble).sum();
    }
}

new Checkout(Pricing.SALE).total(List.of(100.0, 40.0));   // 112.0
new Checkout(p -> p * 0.5).total(List.of(100.0, 40.0));   // 70.0, ad-hoc strategy

What I like here: the named strategies live in an enum, so they're discoverable and can be picked from config with Pricing.valueOf("SALE"). Checkout still accepts any lambda, which makes tests trivial. And the JDK does this everywhere: Comparator passed to sort is the textbook Strategy.

When a strategy needs state, several methods, or its own dependencies, I go back to a proper interface with classes, often as Spring beans collected into a Map<String, PricingStrategy>.

So in practice: lambdas for one-method strategies, classes when it's more than a function.

Interviewers look for: Comparator as a JDK example, and knowing when a lambda isn't enough.

IntermediateImplement the Observer pattern in Java. What pitfalls do you watch for?

So Observer means a subject keeps a list of listeners and notifies them when something happens, without knowing what they do. In modern Java I just store Consumers and return an unsubscribe handle.

final class OrderEvents {
    private final List<Consumer<String>> listeners = new CopyOnWriteArrayList<>();

    Runnable subscribe(Consumer<String> l) {
        listeners.add(l);
        return () -> listeners.remove(l);        // caller keeps this to unsubscribe
    }
    void publish(String orderId) { listeners.forEach(l -> l.accept(orderId)); }
}

Runnable unsubscribe = events.subscribe(id -> System.out.println("email for " + id));
events.subscribe(id -> System.out.println("analytics for " + id));
events.publish("A1");     // email for A1, analytics for A1
unsubscribe.run();
events.publish("A2");     // analytics for A2

The pitfalls I watch for:

  • Memory leaks: the subject holds strong references, so a listener that never unsubscribes lives forever, along with everything it captured. Returning an unsubscribe handle makes cleanup easy.
  • Concurrent modification: a listener that unsubscribes during publish would break an ArrayList iteration. CopyOnWriteArrayList handles that, and it's cheap because reads far outnumber writes.
  • One bad listener: an exception in one stops the rest, so in production I catch and log per listener.
  • Slow listeners: notification is synchronous, so a slow one blocks the publisher. Then I'd use an executor or a message queue.

So in practice, inside a Spring app I use ApplicationEventPublisher and @EventListener, and between services I use Kafka. java.util.Observable has been deprecated since Java 9.

AdvancedWhere is the Decorator pattern used in the JDK?

So java.io is the classic example. Each stream wraps another stream of the same type and adds one behaviour: buffering, compression, character decoding. You stack them at runtime instead of having a subclass for every combination.

Let me show you with a quick example.

try (Writer w = new BufferedWriter(              // adds buffering
        new OutputStreamWriter(                  // adds chars -> bytes (UTF-8)
            new GZIPOutputStream(                // adds compression
                new FileOutputStream(tmp)),      // the real sink
            StandardCharsets.UTF_8))) {
    w.write("hello decorator");
}

try (BufferedReader r = new BufferedReader(
        new InputStreamReader(new GZIPInputStream(new FileInputStream(tmp)), StandardCharsets.UTF_8))) {
    System.out.println(r.readLine());            // hello decorator
}

Every layer is an OutputStream or Writer and has one inside it. That's the decorator shape: same interface, wraps an instance, forwards calls and adds something. FilterInputStream is literally the base decorator class. Without it you'd need BufferedGzipFileOutputStream and every other combination as its own class.

Two things to know. Closing the outermost stream closes the whole chain, so one try-with-resources is enough. And order matters: buffering outside the GZIP stream buffers chars before compression, which is what you want.

Other JDK decorators are Collections.unmodifiableList and synchronizedMap. In Spring, @Transactional and @Cacheable are decorators built at runtime as proxies.

So in practice: decorator vs inheritance is composition over inheritance. You add behaviour per instance, at runtime, in any combination.

Interviewers look for: the "is-a and has-a the same type" structure, and why it beats a subclass for every combination.

42. SOLID in Java

BasicWhat does SOLID stand for? Give me a one-line example for each.

So SOLID is five design principles from Robert Martin. Together they're about making code easy to change without breaking unrelated parts.

PrincipleMeaningJava example
Single ResponsibilityOne reason to changeInvoiceCalculator doesn't also send the email or write the PDF
Open/ClosedExtend without editing existing codeNew discount = new DiscountPolicy class, not another if
Liskov SubstitutionSubtypes must honour the parent's contractA Square extends Rectangle that breaks setWidth violates it
Interface SegregationSmall, focused interfacesReadable and Writable instead of one fat Repository with 30 methods
Dependency InversionDepend on abstractions, not concretionsOrderService takes an OrderRepository interface in its constructor

They support each other. Small interfaces (I) are easy to depend on (D), and depending on interfaces is what lets you add new implementations without editing callers (O).

So in practice I don't apply them mechanically. For a 50-line script they're overkill. They pay off in code that many people change over years.

IntermediateExplain Single Responsibility with a real example. What does "one reason to change" mean?

So SRP says a class should have one reason to change, and a "reason" really means one stakeholder or one area of the business. It doesn't mean "one method". If finance and the email team can both force edits to the same class, it has two responsibilities.

Let me show you with a quick example.

// before: three reasons to change in one class
class ReportService {
    Report build(Month m) { ... }             // finance rules change
    byte[] toPdf(Report r) { ... }            // layout changes
    void email(byte[] pdf, String to) { ... } // SMTP provider changes
}

// after: each class changes for one reason
class ReportBuilder   { Report build(Month m) { ... } }
class PdfRenderer     { byte[] render(Report r) { ... } }
class ReportMailer    { void send(byte[] pdf, String to) { ... } }
class MonthlyReportJob {                       // just coordinates the three
    void run(Month m) { mailer.send(renderer.render(builder.build(m)), "cfo@corp"); }
}

In my last project we had a 2,000-line OrderService. Changing the tax rules meant touching the same file as the refund flow, so merge conflicts and regressions were constant. Splitting it by responsibility (pricing, payment, fulfilment) meant each team owned a class and tests got much smaller, because I could test pricing without mocking an SMTP server.

The trade-off is going too far: fifty tiny classes that each do one line make the flow hard to follow.

So in practice I split when I see a class that needs mocks for unrelated things in its tests, or when its name needs "And" or "Manager".

Interviewers look for: "reason to change = actor or stakeholder", not "a class does one thing".

IntermediateHow do you apply Open/Closed and Dependency Inversion in Java code?

So Open/Closed means I can add behaviour by adding code, not by editing code that already works. Dependency Inversion is what makes that possible: high-level logic depends on an interface, and the concrete details plug in from outside.

interface DiscountPolicy { double apply(double amount); }
record NoDiscount() implements DiscountPolicy { public double apply(double a) { return a; } }
record PercentOff(double pct) implements DiscountPolicy {
    public double apply(double a) { return a * (1 - pct / 100); }
}

interface OrderRepository { void save(String order); }

final class OrderService {
    private final OrderRepository repo;          // abstraction
    private final DiscountPolicy discount;       // abstraction
    OrderService(OrderRepository repo, DiscountPolicy discount) {
        this.repo = repo; this.discount = discount;
    }
    double place(String id, double amount) {
        double total = discount.apply(amount);
        repo.save(id);
        return total;
    }
}

new OrderService(new InMemoryRepo(), new PercentOff(10)).place("A1", 200);   // 180.0

Adding a "buy one get one" discount is a new class. OrderService doesn't change and doesn't need re-testing. It also doesn't know whether the repository is JPA, Mongo or an in-memory fake, which is why I can unit test it with no database.

One nuance with Java 17+: a sealed interface with an exhaustive switch deliberately goes the other way. Adding a case forces edits everywhere. I choose that when the set of variants is part of the domain and I want the compiler to find every place to update.

So in practice: interfaces at the boundaries (repositories, gateways, policies), constructor injection, and Spring wires the concrete classes.

IntermediateWhat is the Liskov Substitution Principle? Show me a violation.

So LSP says anywhere the code expects a parent type, I should be able to pass a subtype and the code should still behave correctly. Compiling isn't enough. The subtype must keep the parent's promises: no stronger preconditions, no weaker postconditions, no surprise exceptions.

Let me show you with a quick example.

class Rectangle {
    protected int w, h;
    void setWidth(int w) { this.w = w; }
    void setHeight(int h) { this.h = h; }
    int area() { return w * h; }
}
class Square extends Rectangle {
    @Override void setWidth(int w) { this.w = w; this.h = w; }
    @Override void setHeight(int h) { this.w = h; this.h = h; }
}

static int stretch(Rectangle r) { r.setWidth(5); r.setHeight(4); return r.area(); }

stretch(new Rectangle());   // 20
stretch(new Square());      // 16  the caller's assumption is broken

Mathematically a square is a rectangle, but a mutable square isn't a mutable rectangle. The Rectangle contract says setting the height doesn't change the width, and Square breaks it. The fix is to not make them parent and child. Use immutable value types, or a common Shape interface with just area().

A JDK example people hit: List.of(...) returns a List, but add throws UnsupportedOperationException. That's a documented "optional operation", but it's still an LSP smell that bites people in production.

So in practice, whenever an override throws UnsupportedOperationException or needs an instanceof check in the caller, that's my sign the inheritance is wrong.

Interviewers look for: "behavioural contract, not just type compatibility". The square/rectangle outputs make it concrete.

IntermediateWhat is Interface Segregation? When is an interface too big?

So ISP says clients shouldn't be forced to depend on methods they don't use. If implementers keep writing empty methods or throw new UnsupportedOperationException(), the interface is too big and should be split by who uses it.

// fat interface: a simple printer has to fake scan and fax
interface Machine {
    void print(Doc d);
    void scan(Doc d);
    void fax(Doc d);
}

// segregated: each client depends only on what it needs
interface Printer { void print(Doc d); }
interface Scanner { void scan(Doc d); }
class BasicPrinter implements Printer { ... }
class OfficeMachine implements Printer, Scanner { ... }

The practical benefit I see most is in testing and in change impact. A service that only reads users should depend on a UserReader with two methods, not a UserRepository with thirty. Its fake is tiny, and adding a method to the write side doesn't recompile or re-test the readers.

Java makes this easy because a class can implement many interfaces, and a lambda can implement any one-method interface. The JDK's java.util.function package is ISP taken to the limit: Supplier, Consumer, Predicate, each with one job.

The trade-off is interface explosion, so I split along real client needs, usually read vs write, not method by method.

AdvancedWhat's the difference between Dependency Inversion, Dependency Injection and Inversion of Control?

So they're related but they sit at different levels. Dependency Inversion is a design principle: depend on abstractions, and let the abstraction be owned by the high-level module. Dependency Injection is a technique: the object receives its dependencies from outside instead of creating them. Inversion of Control is the broader idea that a framework calls your code and manages its lifecycle, instead of your code calling everything.

// no DI, no DIP: creates a concrete dependency itself
class OrderService { private final JpaOrderRepo repo = new JpaOrderRepo(); }

// DI without DIP: injected, but still a concrete class
class OrderService { OrderService(JpaOrderRepo repo) { ... } }

// DI + DIP: injected abstraction, owned by the domain package
class OrderService { OrderService(OrderRepository repo) { ... } }

The subtle point about DIP is ownership. The OrderRepository interface lives in the domain module, and the JPA implementation in the infrastructure module depends on it. The dependency arrow points from the details to the domain. That's the "inversion", and it's the basis of hexagonal or clean architecture.

Spring is IoC: the container creates beans and calls your @PostConstruct and your controllers. It does DI through constructor injection. But Spring can't give you DIP. If you inject concrete classes everywhere you get DI without the design benefit.

So in practice: constructor injection, which makes dependencies explicit and final and is easy to test with new, and interfaces at the boundaries that the domain owns.

Interviewers look for: keeping the three terms apart, and that DI alone doesn't give you DIP.

43. Reflection & Annotations

BasicWhat is reflection, and where is it actually used?

So reflection is the API for inspecting and using classes at runtime when you don't know them at compile time. You can list fields, methods and constructors, read annotations, create instances, call methods and read or write fields, including private ones if access is allowed.

Let me show you with a quick example.

Method m = String.class.getMethod("toUpperCase");
m.invoke("reflect");                                   // "REFLECT"

Class<?> cls = Class.forName("java.util.ArrayList");   // class name as a string
Object list = cls.getDeclaredConstructor().newInstance();
list.getClass().getSimpleName();                       // "ArrayList"

I almost never write reflection in business code, but nearly every framework I use depends on it:

  • Spring: scans for @Component, calls constructors, injects @Autowired fields, and builds proxies for @Transactional.
  • Jackson: finds getters, fields and record components to map JSON.
  • Hibernate/JPA: reads @Entity and @Column, and sets private fields when loading rows.
  • JUnit: finds @Test methods and invokes them.
  • Plugins and JDBC drivers: load classes by name from config.

So in practice reflection is framework plumbing. In application code I'd reach for an interface or a lambda first, because those are type-checked by the compiler.

BasicWhat are annotations, and what do the retention policies mean?

So annotations are metadata attached to code: classes, methods, fields, parameters. They don't do anything by themselves. Some tool has to read them: the compiler, an annotation processor, or a framework at runtime. Retention says how long the annotation survives, and that decides who can see it.

RetentionKept inWho reads itExample
SOURCESource only, dropped by javacCompiler, annotation processors@Override, @SuppressWarnings, Lombok's @Getter
CLASS (default).class file, not loaded at runtimeBytecode toolsSome static-analysis annotations
RUNTIME.class file and visible by reflectionFrameworks at runtime@Test, @Entity, @Autowired, @Deprecated

The classic bug: someone writes a custom annotation, forgets @Retention(RetentionPolicy.RUNTIME), and then getAnnotation always returns null, because the default is CLASS. The code compiles fine, and the validation just silently never runs.

The other meta-annotations are @Target, which says where it can be placed, @Inherited, which makes a class-level annotation pass to subclasses, @Documented, and @Repeatable.

So in practice: if a framework needs to see it at runtime, it must be RUNTIME.

IntermediateWhat are the costs and risks of reflection?

So there are four: it's slower, it throws away compile-time safety, it breaks encapsulation, and since Java 16/17 it can simply be refused by the module system.

  • Performance: a reflective call does access and argument checks and boxing, and it's harder for the JIT to inline. Since Java 18, core reflection is built on method handles, which helps, but a hot loop of Method.invoke is still slower than a direct call. Looking up the Method each time is the expensive part, so frameworks cache it.
  • No type safety: a renamed method becomes a NoSuchMethodException at runtime instead of a compile error, and refactoring tools can't see string-based lookups.
  • Breaks encapsulation: setAccessible(true) can change private and even final fields, which breaks invariants and thread-safety guarantees.
  • Modules: JDK internals are strongly encapsulated, so setAccessible on, say, a private field of String throws InaccessibleObjectException unless you add --add-opens.

There's one more cost people forget: GraalVM native images need reflection declared ahead of time, because the compiler can't see what a string-based lookup will load. That's why Spring Boot 3 has AOT processing that generates that configuration.

So in practice I cache Method and Field objects, prefer MethodHandle or a lambda built once when performance matters, and keep reflection at the edges of the system.

Interviewers look for: the module-system restriction, not just "it's slow".

IntermediateWrite a custom annotation and the code that processes it.

So I'd define the annotation with runtime retention and a field target, then write a small validator that walks the fields with reflection and checks each annotated one. It's a mini version of Bean Validation's @NotBlank.

Let me show you with a quick example.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
@interface NotBlank {
    String message() default "must not be blank";
}

class SignupForm {
    @NotBlank(message = "email is required") String email = "";
    @NotBlank String name = "Asha";
    String nickname = "";                        // not annotated, not checked
}

static void validate(Object o) throws IllegalAccessException {
    for (Field f : o.getClass().getDeclaredFields()) {
        NotBlank rule = f.getAnnotation(NotBlank.class);
        if (rule == null) continue;
        f.setAccessible(true);
        Object v = f.get(o);
        if (v == null || v.toString().isBlank()) {
            System.out.println(f.getName() + ": " + rule.message());
        }
    }
}

validate(new SignupForm());     // email: email is required

Walking through it: annotation elements look like methods and can have defaults. When there's a single element named value, callers can skip the name. getDeclaredFields returns private fields too but not inherited ones, so a real version would walk up getSuperclass(). name passes because it's not blank, and nickname is ignored.

Element types are limited to primitives, String, Class, enums, other annotations, and arrays of those, and the values must be compile-time constants.

So in practice, for real validation I use Jakarta Bean Validation, where I'd write a custom ConstraintValidator. But this is exactly how it works underneath.

Interviewers look for: RUNTIME retention, @Target, and getDeclaredFields vs getFields.

AdvancedHow does Spring's @Transactional work, and why doesn't it work when a method calls another method in the same class?

So Spring doesn't change your class. It wraps the bean in a proxy, and other beans get the proxy injected. The proxy starts a transaction, calls your real method, then commits or rolls back. The JDK's dynamic proxy shows the mechanism.

static <T> T withTransaction(T target, Class<T> type) {
    InvocationHandler h = (proxy, method, args) -> {
        System.out.println("BEGIN");
        try {
            Object result = method.invoke(target, args);
            System.out.println("COMMIT");
            return result;
        } catch (InvocationTargetException e) {
            System.out.println("ROLLBACK");
            throw e.getCause();
        }
    };
    return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] { type }, h);
}

AccountService svc = withTransaction(new RealAccountService(), AccountService.class);
svc.transfer("A", "B", 500);
// BEGIN
// moving 500 from A to B
// COMMIT

JDK proxies only work for interfaces. For plain classes Spring uses CGLIB to generate a subclass at runtime, which is why @Transactional on a final or private method is silently ignored.

Self-invocation: if placeOrder() calls this.saveAudit(), that call goes to the real object through this, not through the proxy. So saveAudit's @Transactional(propagation = REQUIRES_NEW) never runs. I've debugged exactly this: audit rows vanished whenever the order rolled back. The fix was to move saveAudit to a separate bean so the call goes through its proxy.

By default, rollback also happens only for unchecked exceptions. A checked exception commits unless you set rollbackFor.

So in practice: annotation plus proxy, public methods only, and calls must come from outside the bean.

Interviewers look for: proxy-based AOP, the self-invocation trap, and the checked-exception rollback default.

AdvancedRuntime annotation processing vs compile-time annotation processing: how do Lombok and MapStruct differ from Spring?

So Spring reads RUNTIME annotations with reflection while the app is starting. Lombok, MapStruct, Dagger and Immutables run as annotation processors inside javac, and they do their work before the code ever runs.

Runtime (Spring, Hibernate, Jackson)Compile time (MapStruct, Dagger, Lombok)
WhenAt startup and on each callDuring javac
OutputProxies, reflective callsGenerated .java source (Lombok edits the AST)
Errors foundAt startup or in productionAs compile errors
Startup costClasspath scanning, reflectionNone, it's plain code
DebuggingStack traces through proxiesYou can read the generated code

MapStruct is a nice example. You write an interface with @Mapper, and the processor generates a plain class full of getter and setter calls. If a target field has no source, you get a compile warning instead of a silent null at runtime. Lombok is the odd one out: it uses internal javac APIs to change the class being compiled rather than generating new files, which is why it tends to break on new JDK releases.

This is also the direction the ecosystem is moving. Micronaut and Quarkus do DI at compile time, and Spring Boot 3 AOT pre-computes bean definitions, for faster startup and GraalVM native images.

So in practice I like compile-time generation for mapping and boilerplate, and I accept runtime reflection for the DI container.

44. Build Tools & Testing: Maven, Gradle, JUnit

BasicMaven vs Gradle: what's the difference, and which would you pick?

So both resolve dependencies from Maven Central, compile, test and package. The difference is the model. Maven is declarative XML with a fixed lifecycle and convention over configuration. Gradle is a programmable build in Kotlin or Groovy with a task graph, incremental builds and a build cache.

MavenGradle
Configpom.xml, declarativebuild.gradle.kts, code
Build modelFixed phases, plugins bound to phasesA graph of tasks with inputs and outputs
SpeedRebuilds a lotIncremental, build cache, daemon. Much faster on big multi-module builds.
FlexibilityCustom logic means writing a pluginCustom logic inline, which is also easy to abuse
Where it's commonMost enterprise Spring backendsAndroid, large monorepos, Kotlin projects

My experience: Maven builds are boring in a good way. Any Java developer can read a pom, and two projects look the same. On a 60-module repo we moved to Gradle and CI went from about 25 minutes to about 8, mostly because of the build cache and only rebuilding modules that changed. The cost was that some build scripts turned into real code that needed its own review.

So in practice: Maven for typical services and teams that value predictability, Gradle for big multi-module builds or when build speed is the bottleneck. And always commit the wrapper (mvnw or gradlew) so everyone uses the same version.

BasicExplain Maven dependency scopes and their Gradle equivalents.

So a scope decides which classpaths a dependency lands on (compile, test, runtime) and whether it's passed on to projects that depend on yours.

Maven scopeCompileTestRuntime / packagedGradleExample
compile (default)YesYesYes, transitiveapi / implementationGuava, Jackson
providedYesYesNo, the container supplies itcompileOnlyServlet API on Tomcat, Lombok
runtimeNoYesYesruntimeOnlyJDBC driver
testTest code onlyYesNotestImplementationJUnit, Mockito, Testcontainers
importOnly in dependencyManagement, pulls in a BOM's versionsspring-boot-dependencies

The JDBC driver is the example I like. My code only talks to java.sql interfaces, so the Postgres driver should be runtime. Then nobody can accidentally import a Postgres-specific class.

Gradle splits Maven's compile in two. implementation dependencies aren't exposed to consumers' compile classpath, and api ones are. That gives cleaner boundaries and faster incremental builds. There's also system, which points at a local jar path, but it's deprecated and I'd never use it.

So in practice: the narrowest scope that works, and test libraries never leaking into the production jar.

Interviewers look for: provided vs runtime with a real example of each.

IntermediateWhat happens when you run mvn clean install?

So that runs two things: the clean lifecycle, which deletes target/, and then the default lifecycle up to and including install. Maven runs every phase before the one you name, in order.

validate -> compile -> test -> package -> verify -> install -> deploy
              |          |         |          |          |
         compiler   surefire   jar/war   failsafe   copies to ~/.m2/repository
                  (unit tests)       (integration tests)

Walking through the key ones: compile compiles src/main/java, test runs unit tests with Surefire (the build fails if any test fails), package builds the jar, verify runs integration tests with Failsafe plus checks like JaCoCo coverage, and install copies the jar into my local ~/.m2 so other local projects can depend on it. deploy would push it to Nexus or Artifactory.

Things I use a lot: -DskipTests skips running tests but still compiles them, while -Dmaven.test.skip=true doesn't even compile them. -pl module -am builds one module plus what it needs. -o works offline.

And a small opinion: in CI I run mvn verify, not install. Installing into the local repository is only useful when another local build needs the artifact, and clean is often unnecessary and just slows things down.

IntermediateWhat changed from JUnit 4 to JUnit 5?

So JUnit 5 is a rewrite. It's split into the Platform (the launcher that IDEs and build tools talk to), Jupiter (the new API), and Vintage (runs old JUnit 4 tests). For daily work, the new annotations, the extension model and parameterized tests are the main differences.

JUnit 4JUnit 5 (Jupiter)
@Before / @After@BeforeEach / @AfterEach
@BeforeClass / @AfterClass@BeforeAll / @AfterAll
@Ignore@Disabled
@RunWith, @Rule@ExtendWith, one extension model
@Test(expected = X.class)assertThrows(X.class, () -> ...) returns the exception
Public class and methods requiredPackage-private is fine
Parameterized runner, clunky@ParameterizedTest with @CsvSource, @MethodSource, and so on
@DisplayName, @Nested, @Tag, assertAll, assertTimeout

The biggest practical win is extensions. In JUnit 4 you could have only one @RunWith, so combining the Spring runner with the Mockito runner was painful. In JUnit 5, @ExtendWith({SpringExtension.class, MockitoExtension.class}) just works.

assertThrows is also better than expected. It pins the exception to one line and lets me assert on the message, instead of passing if any line throws.

So in practice: JUnit 5 for all new code, with Vintage only as a bridge while migrating.

IntermediateHow do you use Mockito? Show me a test with a mock.

So Mockito creates fake implementations of a class's dependencies so I can test the class alone. I stub what the fakes return with when(...).thenReturn(...), and verify the interactions that matter with verify(...).

Let me show you with a quick example.

class OrderServiceTest {
    PaymentGateway gateway;
    OrderRepository repo;
    OrderService service;

    @BeforeEach
    void setUp() {
        gateway = mock(PaymentGateway.class);
        repo = mock(OrderRepository.class);
        service = new OrderService(gateway, repo);
    }

    @Test
    @DisplayName("successful charge saves PAID")
    void paidWhenChargeSucceeds() {
        when(gateway.charge(eq("c1"), anyLong())).thenReturn(true);

        String status = service.checkout("o1", "c1", 500);

        assertEquals("PAID", status);
        verify(repo).save("o1", "PAID");
    }

    @Test
    void rejectsNonPositiveAmount() {
        var ex = assertThrows(IllegalArgumentException.class,
                () -> service.checkout("o1", "c1", 0));
        assertEquals("amount must be positive", ex.getMessage());
        verifyNoInteractions(gateway, repo);
    }

    @ParameterizedTest
    @CsvSource({"true, PAID", "false, FAILED"})
    void statusFollowsGateway(boolean charged, String expected) {
        when(gateway.charge(anyString(), anyLong())).thenReturn(charged);
        assertEquals(expected, service.checkout("o2", "c2", 100));
    }
}

Walking through it: an unstubbed mock returns defaults (false, 0, null, empty collections). If one argument uses a matcher, all of them must, which is why I wrote eq("c1"). And I only verify side effects, like the save. Verifying every call makes tests break on harmless refactors.

A mock fakes everything, while a spy wraps a real object and only fakes what you stub. With @ExtendWith(MockitoExtension.class) I can use @Mock and @InjectMocks, and strict stubs flag stubbing I never used. Mockito 5 can also mock final classes and static methods.

So in practice: mock the things I don't own or that are slow (HTTP clients, gateways, clocks), and use real objects for simple values and collaborators.

Interviewers look for: stub vs verify, the matcher rule, and not over-mocking.

IntermediateUnit tests vs integration tests: what's the difference, and how do you balance them?

So a unit test checks one class or small unit in isolation, in memory, in milliseconds, with its dependencies faked. An integration test checks that real pieces work together (database, HTTP, Kafka, Spring wiring), so it's slower but catches problems mocks can never catch.

UnitIntegration
ScopeOne class or methodSeveral components, real infrastructure
SpeedMillisecondsSeconds
CatchesLogic and edge casesSQL mistakes, mappings, config, serialization, transactions
ToolsJUnit, Mockito, AssertJ@SpringBootTest, @DataJpaTest, Testcontainers, WireMock
MavenSurefire, *TestFailsafe, *IT, in the verify phase

A real case: our repository unit tests were all green with a mocked repository, but the native query used a Postgres-only function and failed on H2 in one environment and on real Postgres in another. We switched those tests to Testcontainers running the same Postgres version as production, and that class of bug disappeared.

I follow the test pyramid: lots of unit tests for business rules, a solid layer of integration tests at the boundaries (repositories, HTTP clients, message consumers), and a few end-to-end tests for the critical flows. Slice tests like @WebMvcTest and @DataJpaTest are a nice middle ground because they load only part of the Spring context.

So in practice: unit tests for logic, integration tests for anything that talks to something I don't control, and real databases in containers rather than H2.

AdvancedTwo libraries need different versions of the same dependency. How do you find and fix the conflict?

So first I find it with the dependency tree, then I pin one version centrally and make sure it's compatible. The usual symptom is a runtime NoSuchMethodError or ClassNotFoundException even though everything compiled, because the jar that actually got picked is a different version from the one the code was compiled against.

mvn dependency:tree -Dverbose -Dincludes=com.fasterxml.jackson.core
./gradlew dependencyInsight --dependency jackson-databind

<dependencyManagement>
  <dependencies>
    <dependency>                      <!-- a BOM keeps a whole family aligned -->
      <groupId>com.fasterxml.jackson</groupId>
      <artifactId>jackson-bom</artifactId>
      <version>2.17.2</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

The resolution rules differ, and it's worth knowing them. Maven uses "nearest wins": the version closest to your project in the tree is chosen, and on a tie, the first one declared. So it can quietly pick an older version. Gradle picks the highest version by default.

The fixes, from best to worst: align with a BOM (Spring Boot's does most of this for you), pin the version in dependencyManagement or Gradle constraints, use <exclusions> to drop a bad transitive dependency, and as a last resort shade or relocate the library when two truly incompatible major versions must coexist.

To stop it coming back, I add the Maven Enforcer plugin with dependencyConvergence, which fails the build when two paths bring different versions.

Interviewers look for: NoSuchMethodError at runtime as the symptom, "nearest wins", and BOMs.

Practice

Predict the Output

BasicWhat does this print? (String == vs equals)
String a = "java";
String b = "java";
String c = new String("java");
String d = "ja" + "va";
String part = "ja";
String e = part + "va";
System.out.println(a == b);
System.out.println(a == c);
System.out.println(a == d);
System.out.println(a == e);
System.out.println(a == e.intern());
System.out.println(a.equals(c));

Output: true, false, true, false, true, true, one per line.

So a and b are both literals, and literals are interned into the string pool, so they point to the same object: true. new String always creates a new heap object, so a == c is false.

d is the interesting one. "ja" + "va" is two constants, so the compiler folds it into the literal "java" at compile time, and it's the pooled object: true. e uses a variable, part, which isn't a compile-time constant, so the concatenation happens at runtime and creates a new object: false. If part were declared final, it would be a constant and this would be true.

intern() returns the pooled copy, so true. And equals compares characters, so true. So in practice I use == only to ask "same object?", never for text.

BasicWhat does this print? (Integer cache)
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b);
System.out.println(c == d);
System.out.println(c.equals(d));
System.out.println(c == 128);

Output: true, false, true, true.

So autoboxing calls Integer.valueOf, and valueOf returns cached objects for -128 to 127. Both 127s come from the cache and are the same object, so == is true. 128 is outside the cache, so each boxing creates a new Integer and == compares two different references: false.

equals compares values, so true. And the last line is a trap in the other direction. Comparing an Integer with an int literal unboxes c, so it's a numeric comparison: true.

I've seen this as a real bug: two Long IDs compared with == passed every test, because test IDs were small, and then failed in production once IDs grew past 127. The upper bound can be raised with -XX:AutoBoxCacheMax, but the fix is simply equals, or Objects.equals when either side can be null.

BasicWhat does this print? (char + int)
System.out.println('a' + 1);
System.out.println("" + 'a' + 1);
System.out.println('a' + 'b' + "c");
System.out.println("c" + 'a' + 'b');
System.out.println((char) ('a' + 1));

Output:

98
a1
195c
cab
b

So a char is a numeric type. In 'a' + 1 it's promoted to int: 97 plus 1 is 98, and println(int) prints the number.

+ is evaluated left to right, and once one side is a String it becomes concatenation. In "" + 'a' + 1, the string comes first, so we get "a", then "a1".

'a' + 'b' + "c" is the one people miss. The first + is between two chars, so it's arithmetic: 97 + 98 = 195. Only then does it meet "c", which gives 195c. In "c" + 'a' + 'b', the string is first, so everything is concatenation: cab.

The last line casts 98 back to a char, which is b. That's the trick I use for things like (char) ('a' + index).

BasicWhat does this print? (i = i++)
int i = 5;
i = i++;
System.out.println(i);

int j = 5;
j = j++ + ++j;
System.out.println(j);

Output: 5 then 12.

So i++ is post-increment. It evaluates to the old value, 5, and then increments i to 6 as a side effect. Then the assignment runs last and stores the value it already evaluated, 5, back into i. The increment is overwritten, so i stays 5 forever, no matter how many times the line runs.

For j, Java evaluates strictly left to right. j++ gives 5 and makes j 6. Then ++j is pre-increment, so j becomes 7 and the expression gives 7. So 5 + 7 = 12, which is assigned to j.

Unlike C, this is fully defined in Java because of the left-to-right rule. But I'd still flag either line in a code review. Just write i++ on its own line.

BasicWhat does this print? (0.1 + 0.2 and float comparison)
System.out.println(0.1 + 0.2);
System.out.println(0.1 + 0.2 == 0.3);
System.out.println(0.1f + 0.2f);
System.out.println(0.1f + 0.2f == 0.3f);
System.out.println(0.1f == 0.1);
System.out.println(Math.abs((0.1 + 0.2) - 0.3) < 1e-9);
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));

Output:

0.30000000000000004
false
0.3
true
false
true
0.3

So doubles are binary fractions, and 0.1, 0.2 and 0.3 can't be represented exactly. The rounding errors of 0.1 and 0.2 add up to a double that's one step above the nearest double to 0.3, so == is false.

In float the errors happen to round to exactly the same float as 0.3f, so that comparison is true. That's luck, not correctness. 0.1f == 0.1 is false because the float is widened to double, and the float's approximation of 0.1 is a different number from the double's.

So in practice I compare floating-point values with a tolerance, and for money I use BigDecimal built from a String, or long cents. new BigDecimal(0.1) from a double would carry the binary error along.

BasicWhat does this print? (Integer.MAX_VALUE + 1)
int max = Integer.MAX_VALUE;
System.out.println(max + 1);
System.out.println(max + 1L);
System.out.println(Math.abs(Integer.MIN_VALUE));
try {
    Math.addExact(max, 1);
} catch (ArithmeticException e) {
    System.out.println(e.getMessage());
}

Output:

-2147483648
2147483648
-2147483648
integer overflow

So int arithmetic wraps around silently in two's complement. 2147483647 + 1 overflows to Integer.MIN_VALUE, with no exception. With 1L, the expression is promoted to long before adding, so it's correct.

Math.abs(Integer.MIN_VALUE) is still negative, because +2147483648 doesn't fit in an int. That one has caused real bugs with Math.abs(hashCode()) % n producing a negative bucket index. Math.floorMod is the safe version.

The *Exact methods throw ArithmeticException instead of wrapping. The classic place this matters is binary search, where (lo + hi) / 2 can overflow on huge arrays, so I write lo + (hi - lo) / 2 or (lo + hi) >>> 1.

IntermediateWhat does this print? (finally overriding return)
static int f() {
    try {
        return 1;
    } finally {
        return 2;
    }
}
static int g() {
    int x = 1;
    try {
        return x;
    } finally {
        x = 5;
    }
}
System.out.println(f());
System.out.println(g());

Output: 2 then 1.

So in f, return 1 evaluates the value and gets ready to return, but finally always runs before the method actually exits. The finally block does its own return 2, and that replaces the pending return. The compiler even warns that "finally block does not complete normally".

g is the opposite. return x evaluates x right then, which is 1, and saves that value. The finally then changes the local variable to 5, but the saved return value is already 1, so the method returns 1. For an object it would be different: if finally mutated the object, the caller would see the change, because only the reference was saved.

A return in finally also swallows any exception thrown in the try. So in practice I never return or throw from finally. It's only for cleanup.

IntermediateWhat does this print? (static and instance initializer order with inheritance)
class Parent {
    static { System.out.println("1. Parent static"); }
    { System.out.println("Parent instance block"); }
    Parent() { System.out.println("Parent constructor"); }
}
class Child extends Parent {
    static { System.out.println("2. Child static"); }
    { System.out.println("Child instance block"); }
    Child() { System.out.println("Child constructor"); }
}

new Child();
System.out.println("---");
new Child();

Output:

1. Parent static
2. Child static
Parent instance block
Parent constructor
Child instance block
Child constructor
---
Parent instance block
Parent constructor
Child instance block
Child constructor

So there are two stages. First, class initialisation, which happens once per class, parent before child. That's why both static blocks run first and never run again on the second new Child().

Then, for each object, the constructor chain. Child() implicitly starts with super(), so the parent part is fully built first: the parent's instance initializers and field initializers run in textual order, then the rest of the parent constructor body. Only after super() returns do the child's instance blocks run, followed by the rest of the child constructor.

A trap that follows from this: if the parent constructor calls an overridable method, the child's override runs before the child's fields are initialised, so it sees nulls and zeros. That's why I never call overridable methods from a constructor.

IntermediateWhat does this print? (overloading with null)
static void print(Object o) { System.out.println("Object"); }
static void print(String s) { System.out.println("String"); }

print(null);
print((Object) null);
Object o = "hello";
print(o);

Output: String, Object, Object.

So null can be passed to both methods, and when several overloads match, the compiler picks the most specific one. String is a subtype of Object, so print(String) is more specific and wins.

With the cast, the static type of the argument is Object, so only print(Object) applies. And the third call is the important lesson: o holds a String at runtime, but overloading is resolved at compile time from the declared type, which is Object. Only overriding is dynamic.

If I add a third overload print(Integer), then print(null) doesn't compile: "reference to print is ambiguous", because String and Integer are unrelated and neither is more specific. The fix is to cast the null to the type I mean.

IntermediateWhat does this print? (ternary unboxing NPE)
Map<String, Integer> stock = new HashMap<>();
boolean known = true;
Integer fromMap = stock.get("apple");
System.out.println(fromMap);
Integer count = known ? stock.get("apple") : 0;
System.out.println(count);

Output: null, then NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null.

So the first line is fine: get returns null for a missing key, and we store it in an Integer.

The ternary is the trap. One branch is Integer and the other is the int literal 0. The type rules for the conditional operator say that when one operand is a primitive and the other is its wrapper, the result type is the primitive int. So Java unboxes stock.get("apple") by calling intValue() on null, which throws, even though we're assigning back into an Integer that could hold null.

The fix is to make both branches the same reference type, like Integer.valueOf(0), or better, to avoid the ternary: stock.getOrDefault("apple", 0). The helpful NPE message (Java 14+) pointing at intValue() is the clue that it was hidden unboxing.

IntermediateWhat does this print? (HashSet with a mutated key)
static class Point {
    int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }
    @Override public boolean equals(Object o) {
        return o instanceof Point p && p.x == x && p.y == y;
    }
    @Override public int hashCode() { return Objects.hash(x, y); }
}

Set<Point> set = new HashSet<>();
Point p = new Point(1, 2);
set.add(p);
p.x = 99;
System.out.println(set.contains(p));
System.out.println(set.contains(new Point(1, 2)));
System.out.println(set.remove(p));
System.out.println(set.size());

Output: false, false, false, 1.

So when p was added, it went into the bucket for the hash of (1, 2). Changing x changes its hashCode, but the set doesn't know, and the object stays in the old bucket.

contains(p) now computes the hash for (99, 2), looks in a different bucket, and finds nothing. contains(new Point(1, 2)) looks in the right bucket and finds our object, but equals compares (1, 2) with (99, 2), so it's false. The element can't be found by any key and can't be removed, yet size() is still 1. It's effectively leaked.

So in practice: fields used in hashCode and equals must not change while the object is in a hash-based collection. That's the best argument for immutable keys like String, records, or final fields.

IntermediateWhat does this print? (static method hiding vs overriding)
class Animal {
    static String kind() { return "Animal.kind"; }
    String sound() { return "Animal.sound"; }
}
class Dog extends Animal {
    static String kind() { return "Dog.kind"; }
    @Override String sound() { return "Dog.sound"; }
}

Animal a = new Dog();
System.out.println(a.kind());
System.out.println(a.sound());
Dog d = new Dog();
System.out.println(d.kind());

Output: Animal.kind, Dog.sound, Dog.kind.

So static methods belong to the class, not the object, and they can't be overridden, only hidden. Calling a static method through a variable is legal but misleading. The compiler replaces a.kind() with Animal.kind() based on the declared type of a. The actual object is never looked at, and that's why it prints Animal.kind. It would even work if a were null.

sound() is an instance method, so it uses dynamic dispatch. The JVM looks at the runtime object, a Dog, and calls the override. d.kind() uses the declared type Dog, so it gives Dog.kind.

Putting @Override on the static kind() would fail to compile, which is a quick way to prove it's hiding. So in practice I always call static methods through the class name, and IDEs warn when you don't.

IntermediateWhat does this print? (List.remove(int) vs remove(Object))
List<Integer> list = new ArrayList<>(List.of(10, 20, 30, 1));
list.remove(1);
System.out.println(list);
list.remove(Integer.valueOf(1));
System.out.println(list);
Collection<Integer> c = list;
c.remove(10);
System.out.println(list);

Output:

[10, 30, 1]
[10, 30]
[30]

So List has two overloads: remove(int index) and remove(Object o). With the literal 1, which is an int, the compiler picks remove(int) without boxing, because overload resolution tries exact primitive matches before autoboxing. So it removes the element at index 1, which is 20, not the value 1.

To remove the value, I pass an object: Integer.valueOf(1), and the 1 at the end goes away.

The last call is a nice twist. Collection only has remove(Object), so through a Collection reference the 10 gets autoboxed and removes the value 10. Same object, same argument, different result, because overloads are picked from the static type. I've seen this bug in "remove all IDs in this list" code, so with List<Integer> I always box explicitly or use removeIf.

AdvancedWhat does this print? (exceptions thrown from catch and finally)
static void run() {
    try {
        System.out.println("try");
        throw new RuntimeException("from try");
    } catch (RuntimeException e) {
        System.out.println("catch: " + e.getMessage());
        throw new IllegalStateException("from catch");
    } finally {
        System.out.println("finally");
        throw new IllegalArgumentException("from finally");
    }
}

try {
    run();
} catch (Exception e) {
    System.out.println("caller got: " + e.getClass().getSimpleName() + " " + e.getMessage());
    System.out.println("suppressed: " + e.getSuppressed().length);
}

Output:

try
catch: from try
finally
caller got: IllegalArgumentException from finally
suppressed: 0

So the try throws, and the catch handles it and prints. The catch then throws its own IllegalStateException, but before that can leave the method, finally runs. The finally throws IllegalArgumentException, which replaces the pending exception completely.

The caller only ever sees the finally exception. The one from catch is simply lost: it isn't attached as a cause or as a suppressed exception, which is why the suppressed count is 0. In production that means the log shows a cleanup error and hides the real failure.

That's exactly the problem try-with-resources fixes. If close() throws while an exception is already in flight, the close exception is added with addSuppressed and the original one propagates. So in practice: never throw from finally, and use try-with-resources for anything closeable.

Interviewers look for: "the finally exception masks the original", and try-with-resources' suppressed exceptions as the fix.

Coding Tasks

BasicWrite an immutable class that has a List field.

So the rules are: the class is final, fields are private and final, no setters, and the part people forget: defensive copies of mutable fields, both coming in and going out. List.copyOf handles both in one go, because it returns an unmodifiable copy.

import java.util.*;

public final class Team {
    private final String name;
    private final List<String> members;

    public Team(String name, List<String> members) {
        this.name = Objects.requireNonNull(name);
        this.members = List.copyOf(members);   // defensive copy, unmodifiable
    }

    public String name() { return name; }
    public List<String> members() { return members; }   // already unmodifiable, safe to share

    public Team withMember(String m) {                  // "modify" by returning a new object
        List<String> next = new ArrayList<>(members);
        next.add(m);
        return new Team(name, next);
    }

    public static void main(String[] args) {
        List<String> source = new ArrayList<>(List.of("Asha", "Ben"));
        Team t = new Team("core", source);
        source.add("Eve");                             // does not leak into t
        System.out.println(t.members());               // [Asha, Ben]
        try {
            t.members().add("Mallory");
        } catch (UnsupportedOperationException e) {
            System.out.println("cannot modify");
        }
        Team t2 = t.withMember("Chen");
        System.out.println(t.members() + " " + t2.members());   // [Asha, Ben] [Asha, Ben, Chen]
    }
}

Walking through it: if I'd stored the caller's list directly, source.add("Eve") would have changed my "immutable" team. If I'd returned a mutable list, callers could change it through the getter. List.copyOf fixes both, and it also rejects null elements. Collections.unmodifiableList(members) alone would not be enough, because it's a view, and the original list could still change underneath it.

The class is final so a subclass can't add mutable state or override getters. The elements here are Strings, which are immutable. If they were mutable objects, I'd need a deep copy too.

With Java 16+, I'd usually write this as record Team(String name, List<String> members) with members = List.copyOf(members) in the compact constructor.

BasicWrite a custom checked exception and show exception chaining.

So a checked exception extends Exception (not RuntimeException), and the important part is chaining: when I translate a low-level exception into my own, I pass the original as the cause so the stack trace keeps the root problem.

import java.io.*;
import java.nio.file.*;

public class ConfigLoader {
    static class ConfigLoadException extends Exception {
        private static final long serialVersionUID = 1L;
        private final String path;

        ConfigLoadException(String path, Throwable cause) {
            super("Could not load config from " + path, cause);   // chain the cause
            this.path = path;
        }

        String path() { return path; }
    }

    static String loadConfig(String path) throws ConfigLoadException {
        try {
            return Files.readString(Path.of(path));
        } catch (IOException e) {
            throw new ConfigLoadException(path, e);   // translate, don't swallow
        }
    }

    public static void main(String[] args) {
        try {
            loadConfig("/no/such/app.yml");
        } catch (ConfigLoadException e) {
            System.out.println(e.getMessage());
            System.out.println("cause: " + e.getCause().getClass().getSimpleName());
            System.out.println("path: " + e.path());
        }
    }
}
// Could not load config from /no/such/app.yml
// cause: NoSuchFileException
// path: /no/such/app.yml

Walking through it: callers now deal with one meaningful exception, "config couldn't load", instead of an I/O detail. But getCause() still has the NoSuchFileException, and a printed stack trace shows both with a "Caused by:" section. I also carry the path as a field so the handler doesn't have to parse the message.

The mistakes I look for in code review: throw new ConfigLoadException(e.getMessage()), which loses the original stack trace; catching and only logging, which swallows it; and logging and rethrowing, which logs the same error twice.

So in practice: I use checked exceptions when the caller can reasonably recover (use defaults, retry, ask the user), and an unchecked exception extending RuntimeException for programming errors. In Spring services most custom exceptions end up unchecked.

BasicImplement the Builder pattern.

So the target class gets a private constructor that takes the builder, and final fields. A static nested Builder holds the required values in its constructor and the optional ones with defaults. Each setter returns this for chaining, and build() validates and creates the object.

import java.util.*;

public final class Pizza {
    private final String size;           // required
    private final String crust;          // optional
    private final boolean extraCheese;   // optional
    private final List<String> toppings; // optional

    private Pizza(Builder b) {
        this.size = b.size;
        this.crust = b.crust;
        this.extraCheese = b.extraCheese;
        this.toppings = List.copyOf(b.toppings);   // builder can't change it later
    }

    public static Builder builder(String size) { return new Builder(size); }

    @Override public String toString() {
        return "Pizza[" + size + ", " + crust + ", cheese=" + extraCheese + ", " + toppings + "]";
    }

    public static final class Builder {
        private final String size;
        private String crust = "thin";               // default
        private boolean extraCheese;
        private final List<String> toppings = new ArrayList<>();

        private Builder(String size) { this.size = Objects.requireNonNull(size); }

        public Builder crust(String crust) { this.crust = crust; return this; }
        public Builder extraCheese() { this.extraCheese = true; return this; }
        public Builder topping(String t) { this.toppings.add(t); return this; }

        public Pizza build() {
            if (toppings.size() > 5) throw new IllegalStateException("max 5 toppings");
            return new Pizza(this);
        }
    }

    public static void main(String[] args) {
        Pizza p = Pizza.builder("large")
                .crust("stuffed")
                .extraCheese()
                .topping("olive")
                .topping("basil")
                .build();
        System.out.println(p);                          // Pizza[large, stuffed, cheese=true, [olive, basil]]
        System.out.println(Pizza.builder("small").build());   // Pizza[small, thin, cheese=false, []]
    }
}

Walking through the design choices: the required size goes in builder(size), so you can't forget it, and the compiler enforces that rather than a runtime check. Validation of cross-field rules happens once in build(), so an invalid Pizza can never exist. List.copyOf in the constructor matters: if I reused the builder after build(), adding a topping must not change the pizza already built.

So in practice I'd often generate this with Lombok's @Builder, but writing it by hand shows I understand why each piece is there.

IntermediateWrite a thread-safe, lazily initialised Singleton.

So I'd give two versions: the holder idiom, which is lazy with no locking, and the enum, which is the simplest and also safe against reflection and serialization. I'll show double-checked locking too, because it's often the follow-up.

// 1. Holder idiom: lazy, thread-safe via class initialisation, no locks
final class Config {
    private Config() { System.out.println("Config created"); }
    private static final class Holder {
        static final Config INSTANCE = new Config();
    }
    static Config getInstance() { return Holder.INSTANCE; }
}

// 2. Double-checked locking: correct only because of volatile
final class Registry {
    private static volatile Registry instance;
    private Registry() { }
    static Registry getInstance() {
        Registry r = instance;               // one volatile read on the fast path
        if (r == null) {
            synchronized (Registry.class) {
                r = instance;
                if (r == null) instance = r = new Registry();
            }
        }
        return r;
    }
}

// 3. Enum: my default when laziness is not needed
enum IdGenerator {
    INSTANCE;
    private final AtomicLong next = new AtomicLong();
    long nextId() { return next.incrementAndGet(); }
}

// test: 100 tasks on 8 threads
System.out.println("before first call");
ExecutorService pool = Executors.newFixedThreadPool(8);
Set<Object> seen = ConcurrentHashMap.newKeySet();
for (int i = 0; i < 100; i++)
    pool.submit(() -> { seen.add(Config.getInstance()); seen.add(Registry.getInstance()); });
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("distinct instances: " + seen.size());
System.out.println(IdGenerator.INSTANCE.nextId() + " " + IdGenerator.INSTANCE.nextId());
// before first call
// Config created
// distinct instances: 2
// 1 2

"Config created" prints only after "before first call" and only once, which shows it's both lazy and created exactly once. "distinct instances: 2" means one Config and one Registry across all 100 tasks.

The holder works because the JVM loads Holder only on first access, and class initialisation is guarded by a lock the JVM manages. In double-checked locking, volatile prevents another thread seeing a non-null reference to an object whose constructor hasn't finished, and the local r avoids a second volatile read.

So in practice: enum if I can, holder if I need laziness or a class hierarchy, and double-checked locking mostly for lazy instance fields, where the holder trick doesn't apply.

IntermediateWrite a producer-consumer using a BlockingQueue.

So BlockingQueue already does all the hard parts. put blocks when the queue is full, take blocks when it's empty, and it's thread-safe. I just need a clean way to stop the consumer, and I'll use a "poison pill" value.

import java.util.concurrent.*;

public class ProducerConsumer {
    private static final int POISON = -1;

    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(2);   // bounded = back-pressure

        Thread producer = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) {
                    queue.put(i);                    // blocks when full
                    System.out.println("produced " + i);
                }
                queue.put(POISON);                   // tell the consumer to stop
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        Thread consumer = new Thread(() -> {
            try {
                int sum = 0;
                while (true) {
                    int item = queue.take();         // blocks when empty
                    if (item == POISON) break;
                    sum += item;
                }
                System.out.println("consumer done, sum = " + sum);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        producer.start();
        consumer.start();
        producer.join();
        consumer.join();
    }
}
// produced 1 ... produced 5 (timing varies), then: consumer done, sum = 15

Walking through it: the capacity of 2 is deliberate. A bounded queue gives back-pressure, so a fast producer gets slowed down instead of filling the heap. With an unbounded LinkedBlockingQueue, a slow consumer eventually causes an OutOfMemoryError. The poison pill goes through the same queue, so it arrives after all real items, and the consumer shuts down cleanly. With N consumers I'd put N pills.

On interruption I restore the interrupt flag rather than swallowing it, so the code that owns the thread can see it.

So in practice I'd usually use an ExecutorService, which is itself a producer-consumer queue, but this is the pattern underneath.

IntermediatePrint numbers 1 to 10 with two threads, one odd and one even, in order.

So the two threads share one counter and one lock. Each thread waits until it's its turn (odd or even), prints, increments, and notifies the other. The shared state decides whose turn it is, so start order doesn't matter.

public class OddEven {
    private static final int MAX = 10;
    private static int current = 1;
    private static final Object lock = new Object();

    static void printWhile(boolean wantOdd) {
        synchronized (lock) {
            while (current <= MAX) {
                boolean isOdd = current % 2 == 1;
                if (isOdd == wantOdd) {
                    System.out.println(Thread.currentThread().getName() + ": " + current);
                    current++;
                    lock.notifyAll();
                } else {
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        return;
                    }
                }
            }
            lock.notifyAll();   // wake the other thread so it can see we are done
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread odd = new Thread(() -> printWhile(true), "odd");
        Thread even = new Thread(() -> printWhile(false), "even");
        even.start();           // started first on purpose: order still comes out right
        odd.start();
        odd.join();
        even.join();
    }
}
// odd: 1
// even: 2
// odd: 3
// ...
// even: 10

Walking through it: I deliberately start the even thread first. It takes the lock, sees current is 1 (not its turn), and calls wait(), which releases the lock. The odd thread gets in, prints 1, increments, and notifies. From then on they alternate.

The loop re-checks current <= MAX after every wake-up, and the final notifyAll matters. Without it, whichever thread finishes last could leave the other one waiting forever and the program would hang on join.

So in practice there are other ways too: two Semaphores passing a permit back and forth, or a ReentrantLock with a Condition. The semaphore version is the shortest, but I like showing wait and notify because that's what this question is testing.

IntermediateImplement an LRU cache using LinkedHashMap.

So LinkedHashMap has everything built in. The third constructor argument, accessOrder = true, makes get and put move an entry to the end, and overriding removeEldestEntry lets me evict the head, the least recently used entry, whenever we go over capacity.

import java.util.*;

public class LruCache<K, V> extends LinkedHashMap<K, V> {
    private static final long serialVersionUID = 1L;
    private final int capacity;

    public LruCache(int capacity) {
        super(16, 0.75f, true);   // accessOrder = true: get() moves entry to the end
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;  // called after every put
    }

    public static void main(String[] args) {
        LruCache<String, Integer> cache = new LruCache<>(3);
        cache.put("a", 1);
        cache.put("b", 2);
        cache.put("c", 3);
        cache.get("a");              // a is now most recently used
        cache.put("d", 4);           // evicts b, the least recently used
        System.out.println(cache.keySet());   // [c, a, d]

        Map<String, Integer> safe = Collections.synchronizedMap(new LruCache<>(3));
    }
}

Walking through it: after inserting a, b and c the order is a, b, c. get("a") moves a to the end, giving b, c, a. Putting d makes the size 4, so removeEldestEntry returns true and the head, b, is removed. The result is c, a, d, and every operation is O(1), because it's a hash map plus a doubly linked list.

Two caveats I'd mention. It's not thread-safe, and with access order even get modifies the structure, so a read-write lock won't help. Readers need exclusive access too. Collections.synchronizedMap works but everything goes through one lock. And extending exposes all of LinkedHashMap's API, so for a library I'd use composition and expose just get and put.

So in practice, for a real service cache I'd use Caffeine, which is concurrent, has near-optimal eviction and supports expiry. In a DSA round I'd write the hash map plus doubly linked list by hand.

IntermediateUsing streams, find the top 3 highest-paid employees in each department.

So it's a groupingBy on department, and the downstream collector turns each group into a sorted, limited list of names. collectingAndThen lets me post-process each group's list, and a TreeMap gives stable department order in the output.

import java.util.*;
import java.util.stream.*;

public class TopEarners {
    record Employee(String name, String dept, double salary) { }

    public static void main(String[] args) {
        List<Employee> employees = List.of(
                new Employee("Asha", "Eng", 180),
                new Employee("Ben", "Eng", 150),
                new Employee("Chen", "Eng", 210),
                new Employee("Dev", "Eng", 120),
                new Employee("Eve", "Sales", 90),
                new Employee("Farah", "Sales", 110),
                new Employee("Gus", "HR", 70));

        Map<String, List<String>> top3 = employees.stream()
                .collect(Collectors.groupingBy(
                        Employee::dept,
                        TreeMap::new,
                        Collectors.collectingAndThen(Collectors.toList(), list -> list.stream()
                                .sorted(Comparator.comparingDouble(Employee::salary).reversed())
                                .limit(3)
                                .map(Employee::name)
                                .toList())));

        top3.forEach((dept, names) -> System.out.println(dept + " -> " + names));
    }
}
// Eng -> [Chen, Asha, Ben]
// HR -> [Gus]
// Sales -> [Farah, Eve]

Walking through it: groupingBy with three arguments takes the key function, a map factory, and a downstream collector. Each department's employees are collected to a list, then collectingAndThen sorts that list by salary descending, keeps 3, and maps to names. Dev in Eng is the fourth-highest, so he's dropped, and departments with fewer than 3 people just return what they have.

A few follow-ups I'd expect. For ties at third place, I'd add .thenComparing(Employee::name) so the result is deterministic. For huge groups, sorting each whole group is O(n log n). A size-3 min-heap per department would be O(n log 3), but for normal HR data the stream version is far more readable. And if they want only the single top earner, Collectors.maxBy is simpler, though it returns an Optional.

So in practice: I'd write this readable version first, and optimise only if profiling shows it matters.

AdvancedNow write producer-consumer without BlockingQueue, using wait and notify.

So I'd build a small bounded buffer where both methods synchronize on the buffer. put waits while it's full, take waits while it's empty, and each one wakes the other side after changing the state.

import java.util.*;

public class WaitNotifyDemo {
    static final class BoundedBuffer<T> {
        private final Deque<T> items = new ArrayDeque<>();
        private final int capacity;

        BoundedBuffer(int capacity) { this.capacity = capacity; }

        public synchronized void put(T item) throws InterruptedException {
            while (items.size() == capacity) wait();   // while, not if
            items.addLast(item);
            notifyAll();
        }

        public synchronized T take() throws InterruptedException {
            while (items.isEmpty()) wait();
            T item = items.removeFirst();
            notifyAll();
            return item;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        BoundedBuffer<Integer> buf = new BoundedBuffer<>(2);
        Thread producer = new Thread(() -> {
            try {
                for (int i = 1; i <= 5; i++) buf.put(i);
                buf.put(-1);                                 // poison pill
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });
        Thread consumer = new Thread(() -> {
            try {
                List<Integer> got = new ArrayList<>();
                for (int x; (x = buf.take()) != -1; ) got.add(x);
                System.out.println("consumed " + got);       // consumed [1, 2, 3, 4, 5]
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });
        producer.start(); consumer.start();
        producer.join(); consumer.join();
    }
}

Three details the interviewer is checking. First, wait and notify must be called while holding the monitor of the same object, which is why the methods are synchronized. Otherwise it's an IllegalMonitorStateException. Second, the condition is checked in a while loop, because a thread can wake up spuriously, or another thread can grab the slot first, so it has to re-check after waking. Third, notifyAll rather than notify: with one shared monitor, notify could wake another producer instead of a consumer, and everyone ends up waiting.

wait() releases the lock while waiting and takes it back before returning, and that's what lets the other side get in.

So in practice I'd use BlockingQueue, or a ReentrantLock with two Conditions (notFull, notEmpty) so each side wakes only the right threads.

Interviewers look for: while not if, notifyAll, and calling wait inside synchronized on the same object.

AdvancedWrite code that deadlocks, then fix it.

So the classic deadlock is two threads taking the same two locks in opposite order. A bank transfer shows it naturally: thread 1 moves money A to B, thread 2 moves B to A. Each takes its "from" lock and waits forever for the other's. The fix is a global lock ordering, or tryLock with a timeout.

import java.util.concurrent.*;
import java.util.concurrent.locks.*;

public class DeadlockDemo {
    static final class Account {
        final int id;
        int balance;
        final Lock lock = new ReentrantLock();
        Account(int id, int balance) { this.id = id; this.balance = balance; }
    }

    // BROKEN: locks in argument order, so a->b and b->a can deadlock
    static void transferBroken(Account from, Account to, int amount) {
        synchronized (from) {
            sleep(50);                       // widen the window to make it reliable
            synchronized (to) {
                from.balance -= amount;
                to.balance += amount;
            }
        }
    }

    // FIX 1: always lock the lower id first, so every thread agrees on the order
    static void transfer(Account from, Account to, int amount) {
        Account first = from.id < to.id ? from : to;
        Account second = first == from ? to : from;
        synchronized (first) {
            sleep(50);
            synchronized (second) {
                from.balance -= amount;
                to.balance += amount;
            }
        }
    }

    // FIX 2: tryLock with timeout, back off and retry
    static boolean transferTry(Account from, Account to, int amount) throws InterruptedException {
        while (true) {
            if (from.lock.tryLock(100, TimeUnit.MILLISECONDS)) {
                try {
                    if (to.lock.tryLock(100, TimeUnit.MILLISECONDS)) {
                        try {
                            from.balance -= amount;
                            to.balance += amount;
                            return true;
                        } finally { to.lock.unlock(); }
                    }
                } finally { from.lock.unlock(); }
            }
            Thread.sleep(ThreadLocalRandom.current().nextInt(10));   // random back-off
        }
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }

    public static void main(String[] args) throws Exception {
        Account a = new Account(1, 100), b = new Account(2, 100);
        Thread t1 = new Thread(() -> transferBroken(a, b, 10));
        Thread t2 = new Thread(() -> transferBroken(b, a, 10));
        t1.setDaemon(true); t2.setDaemon(true);       // so the JVM can still exit
        t1.start(); t2.start();
        t1.join(500); t2.join(500);
        System.out.println("broken version stuck: " + (t1.isAlive() && t2.isAlive()));

        Account c = new Account(3, 100), d = new Account(4, 100);
        Thread t3 = new Thread(() -> transfer(c, d, 10));
        Thread t4 = new Thread(() -> transfer(d, c, 30));
        t3.start(); t4.start(); t3.join(); t4.join();
        System.out.println("ordered locks: " + c.balance + " " + d.balance);
    }
}
// broken version stuck: true
// ordered locks: 120 80

Walking through it: a deadlock needs four conditions at once: mutual exclusion, hold-and-wait, no preemption, and a circular wait. Lock ordering breaks the circular wait. Both threads lock account 3 first, so one simply waits its turn. tryLock breaks hold-and-wait: if I can't get the second lock, I release the first and retry after a random back-off, so the threads don't keep colliding (livelock). In my run the tryLock version also ends with 120 and 80.

To diagnose one in production I take a thread dump with jstack <pid> or jcmd <pid> Thread.print. The JVM prints "Found one Java-level deadlock" along with the lock cycle.

So in practice: a consistent lock order, as few nested locks as possible, and timeouts on anything that can block.