Java — Concept Notes

Interview Q&A

The Java language from the ground up to senior level: OOP, core APIs, functional Java, concurrency and the JVM, in easy words and full depth. Written for Java 21; every code example has been compiled and run. Print this page for a PDF.

Part 1 — Java Basics

1. What Java Is: JDK, JRE & JVM

In plain words

Java is two things at once: a programming language (the code you type) and a platform (the software that runs that code). You write .java files, a compiler turns them into bytecode — instructions for an imaginary computer — and a program called the JVM (Java Virtual Machine) runs that bytecode on the real computer.

Three names come up all the time. The JVM is the engine that runs bytecode. The JRE (Java Runtime Environment) is the JVM plus the standard library — everything you need to run Java programs. The JDK (Java Development Kit) is the JRE plus developer tools such as the compiler javac — everything you need to write Java programs.

Think of a game console. The bytecode is the game disc: the same disc works on every console of that model. The JVM is the console itself — a different circuit board in each country, but it plays the same disc. The JRE is the console plus the cables and controllers you need to actually play. The JDK is the studio kit game makers use to build and test new discs.

JDK — Java Development Kit (to write and build) Tools: javac (compiler), java (launcher), jar, javadoc, jshell, jlink, jdb, javap JRE — Java Runtime Environment (to run) Java class library: java.lang, java.util, java.io, java.net, java.time ... JVM — Java Virtual Machine (runs bytecode) Class loader Verifier Interpreter JIT compiler Garbage collector
JDK contains JRE contains JVM. Developers install a JDK; the JVM inside it is what actually executes your program.

JDK vs JRE vs JVM

JVMJREJDK
What it isAn abstract machine (a spec) plus its implementation that executes bytecodeJVM + standard class libraryJRE + compiler and developer tools
Main jobLoad, verify and execute .class files; manage memory (GC); run threadsProvide everything a program needs at run timeCompile, package, debug, document, profile
Contains javac?NoNoYes
Platform-specific?Yes — one build per OS/CPUYesYes
Who needs itEvery machine that runs JavaServers/users that only run apps (historically)Developers and build servers
Modern detail: since Java 11, Oracle and most vendors no longer ship a separate JRE download. You install a JDK, and to ship a small runtime you build a custom one with jlink (Java 9 module system) containing only the modules your app uses. "JRE" is still a useful concept in interviews.

Bytecode and "write once, run anywhere"

C or C++ compile straight to machine code for one CPU and OS; to run on another platform you recompile. Java adds a middle step: javac produces bytecode, a compact, platform-neutral instruction set stored in .class files. Each platform has its own JVM that understands that same bytecode. So the same .class or .jar file runs on Windows, macOS and Linux without recompiling — "write once, run anywhere" (WORA).

+--> JVM for Windows x64 --> Windows machine code Hello.java --javac--> Hello.class --+--> JVM for macOS ARM64 --> Apple silicon machine code (source) (bytecode, +--> JVM for Linux x64 --> Linux machine code same file) compile ONCE one JVM per platform, bytecode unchanged
  • Bytecode is platform independent; the JVM is not. This is the most common trick question in this area.
  • Bytecode is not only for Java. Kotlin, Scala, Groovy and Clojure also compile to .class files, so they run on the same JVM and can call Java libraries.
  • WORA has limits: native code (JNI), OS-specific file paths (\ vs /) and line endings can still make a program behave differently. Use Path.of(...) and System.lineSeparator().

Is Java compiled or interpreted?

Both. Source is compiled ahead of time to bytecode by javac. At run time the JVM first interprets bytecode (reads and executes it instruction by instruction), and the JIT (Just-In-Time) compiler turns frequently-run "hot" methods into optimised native machine code. That is why long-running Java servers get faster after a warm-up period. Section 2 walks through this pipeline in detail.

What kind of language is Java?

PropertyMeaning
Statically and strongly typedEvery variable has a type checked at compile time; no silent conversion from String to int.
Object-oriented (not "pure")Everything lives in classes, but the 8 primitives (int, double, ...) are not objects, and static members belong to no object.
Garbage collectedYou never call free. The JVM finds unreachable objects and reclaims their memory.
No pointer arithmeticReferences point to objects, but you cannot do ptr + 4. This removes a whole class of memory bugs.
MultithreadedThreads, locks and a memory model are built into the language and JVM. Java 21 added cheap virtual threads.
Safe by designArray bounds are checked, bytecode is verified before running, casts are checked at run time.

Versions you should know

Since Java 10 (2018) a new version ships every six months. Every few releases is an LTS (Long-Term Support) version that companies actually run in production.

VersionYearHeadline features
8 (LTS)2014Lambdas, Streams, Optional, java.time, default methods in interfaces
9 / 102017–18Module system, jshell, List.of (9); var for local variables (10)
11 (LTS)2018Single-file launch java Hello.java, HttpClient, String.strip/isBlank/repeat/lines
14 – 162020–21Switch expressions (14), text blocks (15), records and instanceof patterns (16)
17 (LTS)2021Sealed classes; the common "modern Java" baseline
21 (LTS)2023Virtual threads, pattern matching for switch, record patterns, sequenced collections
25 (LTS)2025Compact source files and instance main methods finalised, scoped values

Distributions and JVM implementations

  • OpenJDK is the open-source reference code. Oracle JDK, Eclipse Temurin, Amazon Corretto, Azul Zulu and Microsoft Build of OpenJDK are all builds of it — functionally almost identical; they differ in licence and support.
  • HotSpot is the standard JVM inside those builds. Alternatives: OpenJ9 (low memory footprint) and GraalVM, whose native image compiles an app ahead of time into a standalone binary that starts in milliseconds (no JIT warm-up, but less peak optimisation).
// Check what you are running on
public class S1Runtime {
    public static void main(String[] args) {
        System.out.println(Runtime.version().feature());          // 21
        System.out.println(System.getProperty("java.vm.name"));   // Java HotSpot(TM) 64-Bit Server VM
    }
}
// From a terminal:  java -version   and   javac -version

The JVM is a specification

"The JVM" means two things. The JVM Specification is a document that defines the bytecode instruction set, the .class file format, how classes are loaded and linked, and what memory areas must exist. A JVM implementation (HotSpot, OpenJ9, GraalVM) is a real program that follows that document. The spec says what must happen — for example "unreachable objects may be reclaimed" — but not how, so garbage collectors and JIT compilers differ between implementations and even between versions of HotSpot.

The Java Language Specification (JLS) is a separate document that defines the language itself (syntax, types, overload rules). javac follows the JLS; the JVM follows the JVM spec. Other JVM languages skip the JLS entirely and only target the JVM spec.

Common misconceptions

MythReality
"Java is slow because it is interpreted"Hot code is JIT-compiled to optimised native code; steady-state speed is close to C++ for most server workloads. Start-up and warm-up are the real costs.
"The JVM is platform independent"The bytecode is; each JVM is a native program built for one OS and CPU.
"You need a JRE to run and a JDK to build"True historically; today you install a JDK or ship a jlink runtime image.
"Garbage collection means no memory leaks"Objects that are still reachable (a static map that only grows, unremoved listeners) are never collected.
"Java passes objects by reference"Java is always pass-by-value; for objects the value copied is the reference (section 7).

Trying things quickly: jshell (Java 9)

$ jshell
jshell> int x = 7 / 2;
x ==> 3
jshell> "java".toUpperCase()
$2 ==> "JAVA"
jshell> List.of(3, 1, 2).stream().sorted().toList()
$3 ==> [1, 2, 3]
jshell> /exit

jshell is a REPL (read-eval-print loop) shipped with the JDK: no class, no main, no semicolon needed for expressions, and common packages like java.util are imported for you. Ideal for checking an edge case during interview prep.

Interview angle: "Explain JDK, JRE, JVM" — answer with the nesting (JDK ⊃ JRE ⊃ JVM) and one sentence each. Follow-ups: "Is Java platform independent?" (the bytecode is; the JVM is platform-specific). "Is Java pure OO?" (no — primitives and statics). "Compiled or interpreted?" (both: javac to bytecode, then interpreter plus JIT). "Why is the first request to a Java service slow?" (class loading and JIT warm-up).

2. How a Java Program Runs

In plain words

Running Java is a relay race with four runners. 1) javac compiles your .java source into .class files full of bytecode. 2) You start the JVM with java, and a class loader finds and loads the classes it needs. 3) A verifier checks the bytecode is safe. 4) The execution engine runs it — first with an interpreter, then by compiling hot code to native machine code with the JIT compiler.

A recipe written in a universal shorthand (bytecode). Any kitchen in the world (a JVM) can follow it. The chef first reads it line by line (interpreting). After cooking the same dish a hundred times, the chef memorises it and cooks it from memory much faster (JIT compilation).

COMPILE TIME (once, on the developer machine) Hello.java source code javac Hello.class bytecode (portable) RUN TIME (every start, inside the JVM: java Hello) Class loader find + load .class Verifier is bytecode safe? Execution engine Interpreter all code, at first JIT (C1, C2) hot methods native machine code runs on the CPU Garbage collector frees unreachable heap objects Classes are loaded lazily: only when first used, not all at start-up
From source file to running machine code. Compile once; the JVM does the rest on every start.

Step 1 — compile with javac

public class Hello {
    static int add(int a, int b) {
        return a + b;
    }
    public static void main(String[] args) {
        System.out.println("Hello, " + (args.length > 0 ? args[0] : "world"));
        System.out.println(add(2, 3));
    }
}

// $ javac Hello.java        -> creates Hello.class
// $ java Hello              -> Hello, world   then 5
// $ java Hello Ana          -> Hello, Ana     then 5
  • javac takes a file name (Hello.java); java takes a class name (Hello). java Hello.class fails with "Could not find or load main class Hello.class".
  • A public class must live in a file with the same name. Each class — including nested ones — becomes its own file: App.java with a nested Helper produces App.class and App$Helper.class.
  • The compiler checks types, resolves overloads, folds constants, and reports errors — but does little optimisation. The heavy optimisation happens later in the JIT.

You can look at bytecode with javap -c. The JVM is a stack machine: instructions push values onto an operand stack and pop them off.

$ javap -c Hello
  static int add(int, int);
    Code:
       0: iload_0        // push local variable 0 (a)
       1: iload_1        // push local variable 1 (b)
       2: iadd           // pop both, push a + b
       3: ireturn        // return the int on top of the stack
  ...
      16: invokedynamic #15,  0   // InvokeDynamic #0:makeConcatWithConstants  (string +, Java 9+)
      29: invokestatic  #25       // Method add:(II)I

Packages, classpath and JARs

// src/com/acme/App.java  starts with:  package com.acme;
$ javac -d out src/com/acme/App.java      // -d puts classes in out/com/acme/
$ java -cp out com.acme.App               // -cp = classpath; use the FULLY qualified name
App running from package com.acme

$ jar --create --file app.jar --main-class com.acme.App -C out .
$ java -jar app.jar                        // manifest says which class has main
App running from package com.acme

The classpath is the list of folders and JARs where the class loader looks for classes. Using the simple name (java -cp out App) fails with ClassNotFoundException: App.

Step 2 — class loading (loading, linking, initialisation)

  1. Loading — a class loader reads the bytes of the .class file and creates a Class object in memory (class metadata goes into Metaspace, which replaced PermGen in Java 8).
  2. Linking — verify (bytecode is well-formed and type-safe: no stack overflow tricks, no jumping into the middle of an instruction); prepare (allocate static fields with default values); resolve (turn symbolic names like java/lang/System into real references, often lazily).
  3. Initialisation — run static initialisers and assign static field values. This happens on first active use of the class, not at start-up.
Class loaderLoadsgetClassLoader() prints
BootstrapCore JDK classes: java.lang.String, java.util.*null (it is native code, not a Java object)
Platform (Java 9+; was "extension")Other JDK modules, e.g. java.sqlplatform
Application (system)Your classes and JARs on the classpathapp

Loaders use parent delegation: before loading a class itself, a loader asks its parent first. So a class named java.lang.String on your classpath can never replace the real one — the bootstrap loader always wins. This is a security feature.

public class S2Loaders {
    static { System.out.println("S2Loaders initialised"); }
    static class Lazy {
        static { System.out.println("Lazy initialised"); }
        static int X = 1;
    }
    public static void main(String[] args) {
        System.out.println(String.class.getClassLoader());                          // null
        System.out.println(java.sql.Connection.class.getClassLoader().getName());   // platform
        System.out.println(S2Loaders.class.getClassLoader().getName());             // app
        System.out.println("before Lazy");
        System.out.println(Lazy.X);   // first use: prints "Lazy initialised", then 1
    }
}
// Output order: S2Loaders initialised, null, platform, app, before Lazy, Lazy initialised, 1

Step 3 — execution: interpreter plus JIT

The interpreter starts instantly but is slow per instruction. The JVM counts how often each method is called and each loop runs. When a method becomes hot, HotSpot compiles it to native code using tiered compilation:

tier 0 interpreter -- every method starts here, collects profiling data tier 3 C1 (client) -- quick compile with profiling (after ~ thousands of calls) tier 4 C2 (server) -- slow, aggressive compile using the profile: inlining, escape analysis, loop unrolling, dead-code removal deopt back to tier 0 -- if an assumption breaks (e.g. a new subclass is loaded)
$ java -XX:+PrintCompilation JitDemo | grep JitDemo::
     53    7       2       JitDemo::square (4 bytes)
     53    8       4       JitDemo::square (4 bytes)          // promoted to C2
     53    7       2       JitDemo::square (4 bytes)   made not entrant   // old version retired
     57    9 %     3       JitDemo::main @ 4 (32 bytes)       // % = on-stack replacement of a hot loop
// Timings and ids differ on every run.

Because the JIT uses real run-time data (which branches are taken, which class a call site actually sees), it can sometimes beat ahead-of-time compiled code at peak speed. The price is warm-up: the first requests to a fresh JVM are slower. OSR (on-stack replacement) lets a long loop already running in main switch to compiled code mid-loop.

JVM run-time memory areas (overview)

AreaPer thread or sharedHolds
HeapSharedAll objects and arrays; managed by the garbage collector
StackOne per threadA frame per method call: local variables, parameters, operand stack. Too deep = StackOverflowError
MetaspaceShared (native memory)Class metadata, method bytecode (Java 8+; replaced PermGen)
PC registerOne per threadAddress of the bytecode instruction being executed
Native method stackOne per threadFrames for native (C/C++) code called via JNI

The main method

public static void main(String[] args)
  • public — the launcher calls it from outside the class. static — it runs before any object exists. void — the exit code is set with System.exit(n), not returned. String[] args — command-line arguments (never null; empty array if none).
  • Also legal: String... args, final String[] args, static public. You may overload main, but the launcher only calls the String[] version.
  • Wrong signature (say static void main()) compiles fine but fails at launch: "Error: Main method not found in class NoMain, please define the main method as: public static void main(String[] args)".

Single-file launch and simpler main

// Java 11 (JEP 330): run a source file directly -- compiled in memory, no .class written
$ java Hello.java Bob
Hello, Bob
5

// Java 21 PREVIEW (JEP 445): unnamed class + instance main.  File Simple.java:
void main() {
    System.out.println("No class, no static, no String[] args");
}
$ java --enable-preview --source 21 Simple.java
No class, no static, no String[] args

In Java 21 the simpler main needs --enable-preview; it was finalised in Java 25 as "compact source files and instance main methods". Java 22 extended source launch to programs with several files. For quick experiments, use jshell (section 1).

Errors you will meet at this stage

ErrorMeaningUsual fix
ClassNotFoundExceptionCode asked for a class by name (launcher, Class.forName) and it is not on the classpathFix -cp, use the fully qualified name
NoClassDefFoundErrorClass existed at compile time but is missing at run time, or its static initialiser failed earlierAdd the missing JAR; check the first exception in the log
UnsupportedClassVersionErrorClass compiled for a newer Java than the JVM running it (Java 21 = class-file major version 65)Upgrade the runtime or compile with --release 17
Interview angle: "Walk me through what happens when you run java Hello." Say: JVM starts, bootstrap loader loads core classes, application loader loads Hello (parent delegation), it is verified, prepared and initialised, the launcher calls main on the main thread, code is interpreted then hot methods are JIT-compiled, objects go on the heap and the GC reclaims them. Bonus points: lazy class loading, tiered C1/C2, and why warm-up matters for benchmarks (use JMH, not System.nanoTime in a loop).

3. Types, Variables & Wrappers

In plain words

Every Java variable has a fixed type. There are two families. Primitive types (8 of them: byte, short, int, long, float, double, char, boolean) store the value itself. Reference types (classes, interfaces, arrays, String) store a reference — an arrow to an object that lives on the heap.

Each primitive has a wrapper class (int → Integer) so it can be used where an object is required, like in a List. Java converts between them automatically: autoboxing (primitive → wrapper) and unboxing (wrapper → primitive).

A primitive is cash in your pocket: the value is right there. A reference is a cloakroom ticket: the ticket is small, and it tells you where the real coat (the object) is hanging. Two tickets can point to the same coat.

The 8 primitive types

TypeSizeRangeDefault (fields)WrapperLiteral
byte8 bit-128 to 1270Byte(byte) 10
short16 bit-32,768 to 32,7670Short(short) 10
int32 bitabout ±2.1 billion (231 − 1 = 2,147,483,647)0Integer42, 0xFF, 0b1010, 1_000_000
long64 bitabout ±9.2 × 10180LLong10_000_000_000L
float32 bitabout ±3.4 × 1038, ~7 significant digits0.0fFloat3.14f
double64 bitabout ±1.8 × 10308, ~15–16 significant digits0.0Double3.14, 1e-9
char16 bit0 to 65,535 (unsigned; one UTF-16 code unit)'\u0000'Character'A', '\n'
booleannot specified (JVM-dependent)true / falsefalseBooleantrue
  • Sizes are fixed on every platform — an int is always 32 bits, unlike C. All integer types are signed except char.
  • Defaults apply only to fields and array elements. A local variable has no default: reading it before assigning is a compile error — variable x might not have been initialized.
  • An integer literal is an int by default: long big = 3_000_000_000; fails with "integer number too large" — add L. A decimal literal is a double: float f = 3.14; fails with "possible lossy conversion from double to float" — add f.
  • char is a number: 'A' + 1 is 66, and (char) ('A' + 1) is 'B'.

Primitive vs reference

Primitive (int x = 5)Reference (Integer y = 5, String s)
Variable holdsThe value itselfA reference (address) to an object on the heap
Can be null?NoYes
== comparesValuesIdentity (same object?) — use equals for content
Memory4 bytes for intReference (4–8 bytes) + object (~16 bytes for an Integer)
Generics / collectionsNot allowed: List<int> is illegalAllowed: List<Integer>

Wrappers, autoboxing and unboxing (Java 5)

Wrappers exist because generics and collections only hold objects, because sometimes you need "no value" (null), and to host helper methods like Integer.parseInt, Integer.MAX_VALUE, Character.isDigit. Wrappers are immutable. The compiler inserts the conversion calls for you:

Integer boxed = 42;          // compiler writes: Integer.valueOf(42)      (autoboxing)
int back = boxed;            // compiler writes: boxed.intValue()         (unboxing)
List<Integer> list = new ArrayList<>();
list.add(7);                 // boxes 7
int first = list.get(0);     // unboxes

Integer.parseInt("123") + 1; // 124  String -> int
Integer.toBinaryString(10);  // "1010"
// new Integer(5) is deprecated (Java 9) and marked for removal (Java 16): use Integer.valueOf(5)

The Integer cache (-128 to 127)

Integer.valueOf — and therefore autoboxing — returns a cached, shared object for values from -128 to 127. Outside that range it creates a new object each time. So == on two Integers "works" for small numbers and silently breaks for larger ones.

STACK (references) HEAP (objects) a b x y Integer cache: -128 ... 127 Integer 127 Integer 128 Integer 128 a == b true x == y false x.equals(y) true
Integer a = 127, b = 127 share one cached object; Integer x = 128, y = 128 are two different objects.
Integer a = 127, b = 127, x = 128, y = 128;
System.out.println(a == b);          // true   same cached object
System.out.println(x == y);          // false  two objects
System.out.println(x.equals(y));     // true   compare values with equals

Long l1 = 127L, l2 = 127L;
System.out.println(l1 == l2);        // true   Long, Short, Byte also cache -128..127
Integer mixed = 1000; int prim = 1000;
System.out.println(mixed == prim);   // true   one side is primitive, so the other is UNBOXED
System.out.println(Long.valueOf(1).equals(1));  // false  1 boxes to Integer, not Long

Map<String, Integer> m = new HashMap<>();
int v = m.get("missing");            // NullPointerException: unboxing null
  • Character caches 0–127, Boolean has just TRUE/FALSE. Float/Double have no cache. The Integer upper bound can be raised with -XX:AutoBoxCacheMax=<n> — never rely on it.
  • Rule: compare wrappers with equals (or unbox first). Beware a null wrapper being unboxed in arithmetic, if (flag), or a ternary.
  • Boxing in a hot loop (Long sum = 0L; sum += i;) creates millions of objects. Use primitives for arithmetic.

Widening and narrowing conversions

Widening (automatic, no cast): byte -> short -> int -> long -> float -> double char -^ Narrowing (the other direction) needs an explicit cast: int i = (int) 3.99;
int i = 100;  long wl = i;  double wd = wl;     // 100  100.0   widening, always safe in range
(int) 3.99            // 3            cast TRUNCATES toward zero (no rounding)
(int) -3.99           // -3
Math.round(3.99)      // 4            use Math.round / floor / ceil to round
(byte) 300            // 44           keeps the low 8 bits: 300 - 256
(int) 1e20            // 2147483647   double->int clamps to MAX_VALUE
(long) Double.NaN     // 0

// Widening can still LOSE PRECISION: long -> float and int -> float
long precise = 123456789123456789L;
float lossy = precise;          // compiles without a cast!
(long) lossy                    // 123456790519087104   digits lost
float ff = 16_777_217;          // 2^24 + 1
(int) ff                        // 16777216

// Arithmetic on byte/short/char is done in int
byte p = 10, q = 20;
int r = p + q;                  // OK
// byte r2 = p + q;             // compile error: possible lossy conversion from int to byte
byte r2 = (byte) (p + q);       // 30

Overflow — silent wrap-around

int max = Integer.MAX_VALUE;
max + 1                                // -2147483648  wraps to MIN_VALUE, no exception
Math.abs(Integer.MIN_VALUE)            // -2147483648  still negative!
long wrong = 1_000_000 * 1_000_000;    // -727379968   int*int overflowed BEFORE widening
long right = 1_000_000L * 1_000_000;   // 1000000000000
Math.multiplyExact(1_000_000, 1_000_000);  // throws ArithmeticException: integer overflow
// also addExact, subtractExact, toIntExact; BigInteger for arbitrary size

Floating point is approximate

0.1 + 0.2                  // 0.30000000000000004  (binary cannot store 0.1 exactly)
0.1 + 0.2 == 0.3           // false -- compare with Math.abs(a - b) < 1e-9
new BigDecimal("0.1").add(new BigDecimal("0.2"))   // 0.3  use BigDecimal (from String!) for money
1.0 / 0                    // Infinity   (no exception for doubles)
0.0 / 0                    // NaN
Double.NaN == Double.NaN   // false -- use Double.isNaN(x)
7 / 0                      // ArithmeticException: / by zero   (integers DO throw)

var — local type inference (Java 10)

var names = new ArrayList<String>();   // type is ArrayList<String>, fixed at compile time
var count = 0;                          // int
// var x;            // error: cannot infer without an initialiser
// var n = null;     // error
// Only for local variables (and for-loop / lambda params) -- not fields, not method params or returns.

var is not dynamic typing: the compiler infers one fixed type. Use it when the right-hand side makes the type obvious; avoid it when it hides the type (var result = service.process();).

Interview angle: "What are the primitive types and their sizes?" (the table). "Why does Integer a = 128, b = 128; a == b print false?" (the -128..127 cache; == compares references). "What is autoboxing and what can go wrong?" (NPE on unboxing null, identity comparisons, hidden allocations). "What does (byte) 200 give?" (-56: keep the low 8 bits). Best practice: use primitives for maths, wrappers only for collections or nullable values, long for large counts and money in cents or BigDecimal.

4. Operators & Control Flow

In plain words

Operators are the symbols that compute values: + - * / % for maths, == < > for comparisons, && || ! for logic. Control flow decides which lines run and how often: if/else, switch, and the loops for, while, do-while.

Most of it looks like C, but interviews love the small rules: integer division throws away the fraction, i++ and ++i return different values, && can skip its right side, and modern Java (14+) has a much safer switch.

Operators are the buttons on a calculator; control flow is the set of road signs ("if the road is closed, turn left; repeat until you reach the town"). Knowing the exact rules of each button prevents a wrong turn.

Arithmetic: integer division and remainder

7 / 2              // 3     int / int = int; the fraction is dropped (truncated toward zero)
-7 / 2             // -3    toward zero, not -4
7 / 2.0            // 3.5   one double operand makes the whole division double
double avg = 7 / 2;  // 3.0 -- the division happened in int BEFORE assignment
7 % 3              // 1
-7 % 3             // -1    result takes the sign of the LEFT operand
Math.floorMod(-7, 3) // 2   always non-negative for a positive divisor (use for wrap-around indexes)
7 / 0              // ArithmeticException: / by zero
7.0 / 0            // Infinity

Fix the "average" bug by making one operand a double first: (double) sum / count. Writing (double) (sum / count) is too late — the truncation already happened.

Increment: pre vs post

int i = 5; int a = i++; // POST: use the old value, then add 1 -> a = 5, i = 6 int b = ++i; // PRE : add 1 first, then use the value -> i = 7, b = 7 int j = 5; j = j++; // j stays 5! j++ yields 5, bumps j to 6, then the assignment writes 5 back int k = 1; int res = k++ + ++k; // 1 + 3 = 4, and k ends as 3 (evaluated strictly left to right)

Java guarantees left-to-right evaluation of operands, so these results are defined (unlike in C). Still, never write this in real code — use i++ alone on its own line or in a for header.

Compound assignment hides a cast

short sh = 10;
sh += 5;          // OK: means sh = (short) (sh + 5)
// sh = sh + 5;   // compile error: possible lossy conversion from int to short
byte by = 127;
by += 1;          // -128 -- the hidden cast lets it overflow silently

Logical operators and short-circuiting

&& and || short-circuit: if the left side already decides the answer, the right side is not evaluated. & and | on booleans always evaluate both sides.

static boolean check(String label, boolean result) {
    System.out.println("  evaluated " + label);
    return result;
}
boolean r1 = check("left", false) && check("right", true);   // prints only: evaluated left
boolean r2 = check("left", false) &  check("right", true);   // prints: evaluated left, evaluated right

String s = null;
if (s != null && s.length() > 3) { ... }   // safe: length() never runs when s is null

Bitwise and shift operators

6 & 3     // 2    AND   110 & 011 = 010
6 | 3     // 7    OR    110 | 011 = 111
6 ^ 3     // 5    XOR   110 ^ 011 = 101
~6        // -7   NOT   flips every bit (two's complement: ~x == -x - 1)
1 << 4    // 16   shift left  = multiply by 2^4
-16 >> 2  // -4   arithmetic right shift keeps the sign bit
-16 >>> 28 // 15  logical right shift fills with zeros

Precedence you actually need

Group (high to low)Operators
Postfix, unaryx++ x--, ++x --x + - ! ~ (cast)
Multiplicative, additive* / %, then + -
Shift, relational<< >> >>>, then < > <= >= instanceof
Equality== !=
Bitwise, then logical&, ^, |, then &&, then ||
Ternary, assignment (right to left)? :, then = += -= ...

String + is evaluated left to right too: 1 + 2 + "3" + 4 + 5 is "3345" — numbers add until the first String appears, then everything is concatenation. When in doubt, add brackets.

The ternary operator

String grade = score >= 90 ? "A" : score >= 70 ? "B" : "C";   // score 72 -> "B"

// Trap 1: unboxing null
Integer nullable = null;
int v = true ? nullable : 0;       // NullPointerException -- the result type is int, so nullable is unboxed
// Trap 2: numeric promotion
System.out.println(true ? 1 : 2.0);   // 1.0 -- both branches promoted to double

switch statement (classic) — watch the fall-through

int day = 2; String name;
switch (day) {
    case 1: name = "Mon";
    case 2: name = "Tue";          // no break: falls through to the next case!
    case 3: name = "Wed"; break;
    default: name = "?";
}
System.out.println(name);          // Wed

Classic switch works on byte, short, char, int (and their wrappers), String (Java 7, compared with equals) and enum. It cannot switch on long, float, double or boolean.

switch expression (Java 14)

String dayName = switch (day) {
    case 1, 7 -> "Weekend-ish";      // several labels, comma-separated
    case 2 -> "Tue";                 // arrow: no fall-through, no break needed
    default -> {
        String tmp = "Day " + day;
        yield tmp;                   // yield returns a value from a block
    }
};                                   // it is an expression, so it ends with ;
Old case X: statementNew case X -> / switch expression
Fall-throughYes, unless you breakNo — only the matching branch runs
Produces a valueNo (assign inside cases)Yes, with -> or yield
ExhaustiveNot requiredRequired for expressions (default, all enum constants, or all sealed subtypes)
Multiple labelsStacked case 1: case 7:case 1, 7 ->

Pattern matching (Java 16 – 21)

instanceof patterns (Java 16) test and cast in one step; the new variable is only in scope where the test is true:

Object o = "hello";
if (o instanceof String str && str.length() > 3) {
    System.out.println(str.toUpperCase());      // HELLO -- no manual (String) cast
}

Pattern matching for switch (preview in 17–20, final in Java 21) lets you switch on type, add when guards, handle case null, and destructure records with record patterns (also final in Java 21). With a sealed hierarchy the compiler knows every subtype, so no default is needed.

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) {                           // exhaustive: all 3 permitted types covered
        case Circle c -> Math.PI * c.r() * c.r();
        case Square sq -> sq.side() * sq.side();
        case Rect(double w, double h) -> w * h;   // record pattern: pulls out the components
    };
}

static String describe(Object o) {
    return switch (o) {
        case null -> "null!";                      // without this, a null throws NPE
        case Integer i when i > 100 -> "big int " + i;   // guard; more specific case first
        case Integer i -> "int " + i;
        case String str -> "string of length " + str.length();
        default -> "something else";
    };
}
// area: 3.14, 4.00, 6.00     describe(500) = "big int 500", describe(2.5) = "something else"

Unnamed patterns _ were a preview in Java 21 (final in Java 22): if (o instanceof Point(int x, _)) ignores components you do not need. In Java 21 they need --enable-preview --source 21. Pattern matching on primitive types is a later preview (Java 23+).

Loops, break and continue

for (int i = 0; i < n; i++) { ... }       // classic
for (var x : new int[]{1, 2, 3}) { ... } // enhanced for: arrays and any Iterable; no index
while (cond) { ... }                      // may run 0 times
do { n++; } while (n < 0);               // runs at least once -- n becomes 1

outer:                                    // label: break out of BOTH loops at once
for (int r = 0; r < 3; r++) {
    for (int c = 0; c < 3; c++) {
        if (r * c == 2) { System.out.println("found at " + r + "," + c); break outer; }
    }
}
// found at 1,2     (continue outer; would skip to the next r instead)
  • Modifying a List while looping over it with enhanced for throws ConcurrentModificationException — use an Iterator and it.remove(), or removeIf.
  • Comparing floats: 0.1f == 0.1 is false (the float is widened to a slightly different double).
Interview angle: "What does i = i++ leave in i?" (unchanged). "Difference between & and &&?" (short-circuit; also bitwise on ints). "Why is sh += 1 legal but sh = sh + 1 not?" (hidden cast). "Switch statement vs switch expression?" (arrows, no fall-through, yields a value, exhaustiveness). Senior follow-up: "How do sealed types and pattern matching replace the visitor pattern?" — the compiler checks the switch covers every subtype, so adding a new subtype becomes a compile error everywhere it is not handled.

5. Strings

In plain words

A String is a sequence of characters, and in Java it is an object that can never change after it is created (immutable). Methods like toUpperCase() or replace() do not edit the string — they return a brand-new one.

Because strings never change, Java can safely share them. String literals (text in double quotes in your code) are stored once in a special area called the String pool, and every identical literal points to that one copy. When you need to build text piece by piece, use StringBuilder, which is the mutable version.

A printed book is immutable: to "fix a typo" you print a new edition. A library keeps one copy of each edition on the shelf and lends everyone the same one (the pool). A notebook, on the other hand, you can scribble in and erase (StringBuilder).

Immutability in practice

String s = "java";
s.toUpperCase();          // creates "JAVA" and throws it away
System.out.println(s);    // java
s = s.toUpperCase();      // re-point s at the new object
System.out.println(s);    // JAVA

The variable s can be re-pointed; the object "java" never changes. String is a final class (no subclass can add mutability), its internal array is private, and no method modifies it.

Why immutable?

  • Pooling — sharing one copy is only safe if nobody can change it.
  • Security — file paths, URLs, class names and passwords checked once cannot be altered afterwards by another reference.
  • Thread safety — can be shared across threads with no locking.
  • Hash caching — hashCode() is computed once and cached, which makes strings fast HashMap keys.

Inside, since Java 9 (compact strings) a String stores a byte[] plus a "coder" flag: 1 byte per character when all characters are Latin-1, 2 bytes (UTF-16) otherwise. That roughly halves memory for typical English text.

The String pool, == and equals

STACK HEAP a = "hello" b = "hello" c = new String d = c.intern() String pool "hello" "hello" separate object (outside pool) a == b true a == d true a == c false a.equals(c) true
Literals share one pooled object. new String(...) always makes a new object; intern() returns the pooled copy.
String a = "hello";
String b = "hello";
String c = new String("hello");
String d = c.intern();
a == b          // true   same pooled object
a == c          // false  c is a separate heap object
a.equals(c)     // true   same characters
a == d          // true   intern() returns the pooled copy

// Compile-time constants are folded into one literal; runtime concatenation is not
String hel = "hel";
final String helFinal = "hel";
a == "hel" + "lo"          // true   folded to "hello" by javac
a == hel + "lo"            // false  built at run time -> new object
a == helFinal + "lo"       // true   final + literal is a constant expression
a == (hel + "lo").intern() // true
  • Always compare content with equals. == compares references and only "works" by accident with literals.
  • Null-safe comparison: "x".equals(maybeNull) (literal first) or Objects.equals(a, b). equalsIgnoreCase ignores case; compareTo orders alphabetically (by UTF-16 code).
  • Since Java 7 the pool lives in the normal heap (it was in PermGen before), so pooled strings can be garbage collected.
  • intern() can reduce memory when you have millions of duplicate strings, but it costs a hash lookup; modern GCs (G1) can also do string deduplication automatically (-XX:+UseStringDeduplication).
  • new String("hello") may create two objects — the pooled literal (if not already there) and the new heap copy. A classic trivia question.

String vs StringBuilder vs StringBuffer

StringStringBuilder (Java 5)StringBuffer (Java 1.0)
Mutable?NoYesYes
Thread-safe?Yes (immutable)NoYes — every method synchronized
SpeedEach change copies the whole stringFastestSlower (locking)
Use forValues, keys, constantsBuilding text in a method or loop (default choice)Legacy code; almost never needed today
// Bad: O(n^2) -- each += copies everything built so far
String acc = "";
for (int i = 0; i < 20_000; i++) acc += "x";

// Good: amortised O(1) append
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 20_000; i++) sb.append("x");
String result = sb.toString();

StringBuilder b2 = new StringBuilder("ab");
b2.append('c').append(1).insert(0, "[").append("]");   // [abc1]  (methods chain)
b2.length();                                            // 6
b2.reverse();                                           // ]1cba[  (in place)
// also: deleteCharAt, setCharAt, setLength, replace, indexOf

A single-line "a" + x + "b" is fine: since Java 9 the compiler emits one invokedynamic call (StringConcatFactory) that sizes the result once. The problem is only += inside a loop. Note that StringBuilder does not override equals — compare with sb1.toString().equals(...) or sb1.compareTo(sb2) (Java 11).

Text blocks (Java 15)

String json = """
    {
      "name": "Ana",
      "age": 30
    }
    """;          // indentation up to the closing """ is stripped; ends with a newline
String oneLine = """
    one \
    line""";      // backslash joins lines; closing """ on the same line = no trailing newline
// oneLine is "one line"

Text blocks are ordinary Strings (same pool, same methods) — just easier to write multi-line JSON, SQL and HTML without \n and escaped quotes.

Common methods

MethodExample with w = "Hello, World"Result
length(), charAt(i)w.length(), w.charAt(4)12, 'o'
indexOf / lastIndexOfw.indexOf('o'), w.lastIndexOf('o'), w.indexOf("xyz")4, 8, -1
substring(begin[, end])w.substring(7), w.substring(0, 5)"World", "Hello" (end exclusive)
contains, startsWith, endsWithw.contains("World"), w.endsWith("!")true, false
replace, toLowerCasew.replace('l', 'L')"HeLLo, WorLd"
trim() / strip() (11)" Hi ".strip()"Hi"; strip also removes Unicode whitespace
isEmpty() / isBlank() (11)" ".isEmpty(), " ".isBlank()false, true
repeat(n) (11), lines() (11)"ab".repeat(3), "a\nb\nc".lines().count()"ababab", 3
split(regex)"a,b,,c,,".split(",")[a, b, , c] (trailing empties dropped)
String.join, String.valueOfString.join("-", List.of("x","y","z"))"x-y-z"
formatted (15), String.format"%s is %d".formatted("Ana", 30)"Ana is 30"
toCharArray(), chars()"abc".chars().sum()294 (97 + 98 + 99)

Pitfalls

  • split takes a regex: "a.b.c".split(".") returns an empty array because . matches every character. Use split("\\.") → [a, b, c].
  • substring, replace, trim return new strings — forgetting to assign the result is a silent bug.
  • Different strings can share a hash: "Aa".hashCode() and "BB".hashCode() are both 2112. Equal hash never means equal strings.
  • length() counts UTF-16 code units, not visible characters — an emoji or some CJK characters take 2. Use codePointCount when that matters.
  • Do not keep passwords in a String (it lingers in memory until GC and may be pooled) — APIs use char[] so you can wipe it.
Interview angle: "Why is String immutable?" (pool, security, thread safety, cached hash). "== vs equals?" (identity vs content, with the pool example). "How many objects does new String("hello") create?" (up to two). "StringBuilder vs StringBuffer?" (same API; Buffer is synchronized and slower; use Builder). "Is + in a loop bad?" (yes, O(n²) copying; one-line concatenation is fine). Best practice: equals for comparison, StringBuilder in loops, isBlank/strip for input cleaning, text blocks for multi-line literals.

6. Arrays

In plain words

An array is a fixed-size row of slots that all hold the same type, numbered from 0. You choose the size when you create it and it can never grow or shrink. Reading or writing slot i is instant (O(1)) because the JVM computes its address directly.

In Java an array is an object on the heap: the variable holds a reference to it, it has a length field, and every slot starts with a default value (0, false, or null). A "2D array" is really an array of references to other arrays.

A row of numbered post-office boxes built into a wall. You can open box 7 directly without checking boxes 0–6, but you cannot add an eighth box to the wall later — you would need to build a new, bigger wall and move the letters.

Declaring and creating

int[] nums = new int[3];          // [0, 0, 0]           size fixed at 3
int[] primes = {2, 3, 5, 7};      // initializer (only in a declaration)
int[] more = new int[]{1, 2};     // anonymous array: usable anywhere, e.g. as an argument
int legacy[] = new int[2];        // C-style; legal but avoid -- keep [] with the type
String[] strs = new String[2];    // [null, null]  -- no String objects created yet!

primes.length                     // 4    a FIELD (no parentheses); String uses length()
primes[4]                         // ArrayIndexOutOfBoundsException: Index 4 out of bounds for length 4
new int[0]                        // legal empty array; return this instead of null
System.out.println(primes);       // [I@5ca881b5  -- type code + hash, NOT the contents
Arrays.toString(primes)           // [2, 3, 5, 7]
Element typeDefault value
byte, short, int, long0
float, double0.0
char'\u0000' (the character with code 0)
booleanfalse
Any reference (String, Integer, int[], ...)null

Arrays are checked on every access: an invalid index always throws, it never reads random memory as in C. The size can be a run-time value (new int[n]) but is limited to about Integer.MAX_VALUE because indexes are int.

2D and jagged arrays

int[][] grid = new int[2][3];          // 2 rows x 3 columns, all 0
grid[1][2] = 9;
Arrays.deepToString(grid)              // [[0, 0, 0], [0, 0, 9]]

int[][] jagged = new int[3][];         // only the outer array; rows are null for now
jagged[0] = new int[]{1};
jagged[1] = new int[]{1, 2};
jagged[2] = new int[]{1, 2, 3};
Arrays.deepToString(jagged)            // [[1], [1, 2], [1, 2, 3]]
jagged[2].length                       // 3 -- each row has its own length

for (int[] row : jagged) {             // always use row.length, never assume a rectangle
    for (int v : row) { ... }
}
STACK HEAP jagged int[][] (outer) 0 1 2 ref ref ref 1 12 123 length 1 length 2 length 3 4 separate array objects: 1 outer array of references + 3 row arrays
A 2D array is an array of references to row arrays. Rows are separate objects, so they can differ in length (jagged) and live anywhere in the heap.

Because rows are separate objects, iterating row by row (grid[r][c] with c in the inner loop) is cache-friendly; iterating column-first jumps between objects and is noticeably slower on large grids.

The Arrays utility class

int[] data = {5, 2, 9, 1};
Arrays.sort(data);                        // [1, 2, 5, 9]   dual-pivot quicksort for primitives
Arrays.binarySearch(data, 5)              // 2              array MUST be sorted
Arrays.binarySearch(data, 3)              // -3             not found: -(insertionPoint) - 1
Arrays.copyOf(data, 6)                    // [1, 2, 5, 9, 0, 0]   pads with defaults (or truncates)
Arrays.copyOfRange(data, 1, 3)            // [2, 5]         end exclusive
Arrays.fill(filled, 7)                    // [7, 7, 7, 7]
Arrays.setAll(sq, i -> i * i)             // [0, 1, 4, 9, 16]
Arrays.stream(data).sum()                 // 17
Arrays.compare(new int[]{1, 2}, new int[]{1, 3})          // -1  lexicographic (Java 9)
Arrays.mismatch(new int[]{1, 2, 3}, new int[]{1, 9, 3})   // 1   first differing index (Java 9)

Integer[] boxed = {3, 1, 2};
Arrays.sort(boxed, Comparator.reverseOrder());   // [3, 2, 1]  comparator needs OBJECTS, not int[]

List<Integer> view = Arrays.asList(boxed);       // fixed-size LIST VIEW backed by the array
boxed[0] = 100;  // view is now [100, 2, 1]   -- writes show through both ways
view.add(4);     // UnsupportedOperationException
Arrays.asList(new int[]{1, 2}).size()            // 1 -- a List<int[]> holding one array!

Comparing and copying

int[] x = {1, 2}, y = {1, 2};
x == y                  // false  different objects
x.equals(y)             // false  arrays do NOT override equals (inherits identity from Object)
Arrays.equals(x, y)     // true   compares elements
int[][] g1 = {{1}}, g2 = {{1}};
Arrays.equals(g1, g2)       // false  compares the row REFERENCES
Arrays.deepEquals(g1, g2)   // true   recurses into rows

int[] orig = {1, 2, 3};
int[] alias = orig;  alias[0] = 99;     // same object: orig is now [99, 2, 3]
int[] cloned = orig.clone(); cloned[1] = 42;   // new array: orig unchanged, cloned is [99, 42, 3]

int[][] deep = {{1, 2}, {3, 4}};
int[][] shallow = deep.clone();         // copies the outer array only -- rows are SHARED
shallow[0][0] = 100;                    // deep[0][0] is now 100 too!

int[] dst = new int[5];
System.arraycopy(orig, 0, dst, 1, 3);   // [0, 99, 2, 3, 0]   (src, srcPos, dest, destPos, length)

For a true deep copy of a 2D array, clone each row: int[][] copy = Arrays.stream(deep).map(int[]::clone).toArray(int[][]::new);

Array covariance and ArrayStoreException

Arrays are covariant: because String is a subtype of Object, String[] is treated as a subtype of Object[]. That compiles, but it opens a hole that the JVM must plug at run time — every store into a reference array is type-checked.

Object[] objs = new String[2];   // compiles: covariance
objs[0] = "fine";
objs[1] = 42;                    // compiles, but at run time: ArrayStoreException: java.lang.Integer

// Generics are INVARIANT, so the same mistake is caught at compile time:
// List<Object> lo = new ArrayList<String>();   // compile error

This is one reason you cannot create a generic array (new T[10] or new List<String>[10] are compile errors): generics are erased at run time, so the JVM could not do this store check. Prefer List<T> over T[] in APIs.

Array vs ArrayList

Array int[] / String[]ArrayList<E>
SizeFixed at creationGrows automatically (about 1.5x when full)
PrimitivesYes — int[] stores raw intsNo — boxes to Integer (more memory, slower)
Type safetyRun-time store check (covariant)Compile-time (invariant generics)
Lengtharr.lengthlist.size()
equals / toStringIdentity / [I@hash — use Arrays.*Element-wise, readable
Best forKnown size, performance-critical numeric code, DP tablesEverything else in business code

Pitfalls

  • Arrays as HashMap/HashSet keys use identity: set.contains(new int[]{1}) is false even after adding new int[]{1}. Use List.of(...) or a record as the key.
  • new String[3] gives three nulls — calling a method on an element throws NullPointerException.
  • Off-by-one: the last index is length - 1; loop with i < arr.length.
  • Returning an internal array exposes it to modification. Return arr.clone() or an unmodifiable list.
Interview angle: "Default value of an array element?" (0, false, null). "How do you compare two arrays?" (Arrays.equals, deepEquals for nested). "Is clone() deep or shallow?" (shallow for nested arrays). "What is ArrayStoreException and why can't generics have it?" (array covariance with a run-time check vs invariant, erased generics). "Array or ArrayList?" (fixed size and primitives vs flexibility and the collections API).

7. Methods & Pass-by-Value

In plain words

A method is a named block of code that takes inputs (parameters), does some work, and may return one value. Several methods in a class can share a name if their parameter lists differ — that is overloading. A varargs parameter (int... nums) accepts any number of arguments.

When you call a method, Java always copies the value of each argument into the parameter — this is pass-by-value, with no exceptions. For a primitive the copied value is the number itself. For an object, the copied value is the reference (the arrow), not the object. So the method can change the object the arrow points to, but it can never make the caller's variable point somewhere else.

You give a friend a photocopy of your house address. They can go to the house and repaint the door (mutate the object) — you will see the new colour. But if they scribble a different address on their photocopy, your own copy of the address is unchanged.

Anatomy of a method

//  modifiers     return type  name   parameters              exceptions
    public static int          max   (int first, int... rest) throws IllegalStateException {
        int m = first;
        for (int r : rest) m = Math.max(m, r);
        return m;                // max(3) = 3, max(3, 9, 4) = 9
    }
  • The signature is the name plus the parameter types in order: max(int, int[]). Return type, parameter names, modifiers and throws are not part of it.
  • static methods belong to the class (Math.max); instance methods need an object and can use this.
  • A method returns at most one value. To return several, return a record (Java 16): record MinMax(int min, int max) {}.

Overloading rules

Overloaded methods must differ in the number, types or order of parameters. A different return type alone is not enough:

static int  m(int x) { return 1; }
static long m(int x) { return 2; }   // compile error: method m(int) is already defined

The compiler picks the overload at compile time using the declared (static) types of the arguments, in three phases. It stops at the first phase that finds a match, and within a phase chooses the most specific method:

Phase 1: exact match or WIDENING only (int -> long -> float -> double, String -> Object) Phase 2: allow BOXING / UNBOXING (int -> Integer, Integer -> int) Phase 3: allow VARARGS (int -> int...) => widening beats boxing, boxing beats varargs
static String f(int x)     { return "int"; }
static String f(long x)    { return "long"; }
static String f(Integer x) { return "Integer"; }
static String f(Object x)  { return "Object"; }
static String f(int... x)  { return "varargs"; }

f(1)                   // int       exact
f(1L)                  // long      exact
f((byte) 1)            // int       widening byte -> int (closest)
f('a')                 // int       char widens to int
f(Integer.valueOf(1))  // Integer   exact
f("s")                 // Object    String widens to Object
f()  /  f(1, 2)        // varargs   only varargs accepts 0 or 2 args

static String g(long x)    { return "long"; }
static String g(Integer x) { return "Integer"; }
g(5)                   // long      phase 1 (widening) wins before boxing is even tried

static String h(Object o)  { return "Object"; }
static String h(String s)  { return "String"; }
h(null)                // String    both match; String is more specific

static void m(Integer x) {}
static void m(String x) {}
m(null);               // compile error: reference to m is ambiguous
Overloading vs overriding: overloading = same name, different parameters, chosen at compile time (static binding). Overriding = a subclass redefines a method with the same signature, chosen at run time by the object's actual class (dynamic dispatch) — covered in the OOP part.

Varargs (Java 5)

static String v(int... x) { return "varargs " + x.length; }
v()                  // varargs 0   -- x is an empty array, never null
v(1, 2, 3)           // varargs 3
v(new int[]{7})      // varargs 1   -- you can pass an array directly

String.format("%s-%s", "a", "b");   // format(String, Object...) -- the classic varargs API
  • A varargs parameter is just an array: int... x is int[] x inside the method.
  • Only one per method and it must be last. To require at least one value, use (int first, int... rest).
  • Each call allocates a new array — avoid in very hot paths (that is why List.of has fixed-arity overloads up to 10 elements).
  • Generic varargs (List<String>...) trigger "possible heap pollution" warnings; @SafeVarargs suppresses them when the method only reads the array.

Java is ALWAYS pass-by-value

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

static void bump(int n)      { n = n + 1; }                 // changes the local copy only
static void rename(Dog d)    { d.name = "Max"; }            // follows the copied reference: mutates the object
static void replace(Dog d)   { d = new Dog("Rex"); d.name = "Buddy"; }  // re-points the LOCAL copy only
static void swap(Dog a, Dog b) { Dog t = a; a = b; b = t; }

int n = 5;           bump(n);       // n is still 5
Dog dog = new Dog("Rex");
rename(dog);                        // dog.name is "Max"
replace(dog);                       // dog.name is STILL "Max"
Dog a = new Dog("A"), b = new Dog("B");
swap(a, b);                         // a.name = "A", b.name = "B" -- nothing swapped
STACK (one frame per call) HEAP replace(Dog d) -- top frame d = copy of ref discarded when replace returns main -- caller frame n = 5 dog = ref Dog object name = "Max" new Dog object name = "Buddy" before after main's dog still points here: "Max"
The parameter d is a separate variable holding a copy of the reference. Re-pointing d never affects dog in the caller; mutating the object through d would.

More cases that follow from the same single rule:

static void addItem(List<String> list) {
    list.add("new");              // mutates the caller's list  -- visible
    list = new ArrayList<>();     // re-points the local copy   -- invisible
    list.add("lost");
}
List<String> items = new ArrayList<>(List.of("x"));
addItem(items);                   // items is [x, new]

static void mutate(int[] a) { a[0] = 100; a = new int[]{-1, -1}; }
int[] arr = {1, 2};  mutate(arr); // arr is [100, 2]

static void upper(String s)  { s = s.toUpperCase(); }
String s = "hi";     upper(s);    // still "hi"  -- String is immutable, so it can only be re-pointed

static void incBox(Integer i) { i++; }   // i++ means i = Integer.valueOf(i + 1): a NEW object
Integer boxed = 10;  incBox(boxed);      // still 10
Inside the method you...Caller sees the change?Why
Assign a new value to a primitive parameterNoThe parameter is a copy of the number
Call a mutating method / set a field on an object parameterYesThe copied reference points to the same object
Assign a new object to the parameterNoOnly the local copy of the reference changes
"Change" a String or wrapper parameterNoThey are immutable; every "change" makes a new object

To give a result back, return it (s = upper(s)) rather than trying to modify a parameter. That is also cleaner design.

The call stack and recursion

Each call pushes a stack frame holding its parameters and local variables; return pops it. Recursion works because each call gets its own frame — factorial(5) builds 5 frames and returns 120. Recursion with no base case (or too deep) fills the thread's stack and throws StackOverflowError (an Error, not an Exception) — typically after thousands to tens of thousands of frames; the default stack size can be raised with -Xss. Java does not do tail-call optimisation, so deep recursion (say 105 levels) should be rewritten as a loop.

static int factorial(int n) { return n <= 1 ? 1 : n * factorial(n - 1); }   // factorial(5) = 120
static void recurse() { recurse(); }   // StackOverflowError
Interview angle: "Is Java pass-by-reference?" Answer: "No. Java is always pass-by-value. For objects the value that is copied is the reference, so a method can mutate the object but cannot make the caller's variable point to a different object — a swap(a, b) method cannot work." Then show the swap example. Other favourites: "Can you overload by return type?" (no), "Which overload does f(5) call with f(long) and f(Integer)?" (long: widening before boxing), "What is m(null) with m(String) and m(Integer)?" (ambiguous — compile error).
Part 2 — Object-Oriented Programming

8. Classes & Objects

In plain words

A class is a blueprint: it says what data a thing holds (fields) and what it can do (methods). An object is one real thing built from that blueprint, living in memory with its own copy of the fields. new builds an object; a constructor is the code that sets it up.

Your variable does not hold the object itself. It holds a reference — an arrow pointing to the object on the heap. Two variables can point at the same object.

A class is the architect's drawing of a house; objects are the actual houses on the street. Each house has its own paint colour (field values), and your variable is the house's address written on a piece of paper — copy the paper and you still have one house.

Anatomy of a class

public class Pizza {
    // fields (state) -- every object gets its own copy
    private final String size;
    private int toppings;

    // static field -- ONE copy shared by the whole class
    private static int made = 0;

    // constructor -- same name as the class, NO return type
    public Pizza(String size) {
        this.size = size;       // 'this' = the object being built
        made++;
    }

    // instance method -- works on one object's state
    public void addTopping() { toppings++; }

    // static method -- belongs to the class, no 'this'
    public static int totalMade() { return made; }
}

Pizza p = new Pizza("large");   // declare a reference, create the object, link them

What new actually does

new Pizza("large") 1. Is class Pizza loaded and initialised? no -> load it, run static initialisers (only ONCE) 2. Allocate memory on the HEAP for the object (header + all instance fields) 3. Set every field to its default: 0, 0.0, false, '\u0000', null 4. Run the constructor chain: super(...) first, then field initialisers and instance blocks, then the rest of the constructor body 5. Return the REFERENCE, which is stored in variable p (on the stack)
Stack (main's frame) Heap Pizza p1 = ● Pizza p2 = ● Pizza p3 = ● Pizza object #1 size="medium" crust="thin" Pizza object #2 size="medium" crust="thin" p1 == p3 is true (same object) · p1 == p2 is false (equal contents, different objects)
Variables hold references. == on objects compares the arrows, not the contents.

Default values: fields yes, local variables no

TypeField defaultLocal variable
int, long, short, byte0No default. Reading it before assigning is a compile error ("variable might not have been initialized").
double, float0.0
booleanfalse
char'\u0000'
any reference (String, arrays, objects)null

Constructors

  • A constructor has the class's name and no return type. Writing public void Pizza() creates an ordinary method that happens to share the name — a classic trick question.
  • If you write no constructor, the compiler adds a public no-arg default constructor. The moment you write any constructor, that free one disappears — so new Pizza() stops compiling after you add Pizza(String).
  • Constructors are not inherited and cannot be static, final or abstract. They can be private (singletons, factory methods, utility classes).

Constructor chaining with this(...) and super(...)

this(...) calls another constructor of the same class; super(...) calls a constructor of the parent. Chaining keeps all the real set-up in one place instead of copy-pasting it into every overload.

class Pizza {
    private final String size, crust;
    private final int toppings;

    Pizza()                          { this("medium"); }              // -> next one
    Pizza(String size)               { this(size, "thin"); }
    Pizza(String size, String crust) { this(size, crust, 0); }
    Pizza(String size, String crust, int toppings) {                  // the one "real" constructor
        this.size = size; this.crust = crust; this.toppings = toppings;
    }
    @Override public String toString() { return size + "/" + crust + "/" + toppings; }
}

class Animal {
    final String name;
    Animal(String name) { this.name = name; }
}
class Dog extends Animal {
    Dog() { super("Rex"); }   // REQUIRED: Animal has no no-arg constructor
}

new Pizza();          // medium/thin/0
new Pizza("large");   // large/thin/0
new Dog().name;       // Rex
  • this(...) or super(...) must be the first statement, and you can have only one of them. (Java 25 relaxes this a little with "flexible constructor bodies", but in Java 21 it is a hard rule.)
  • If you write neither, the compiler inserts super() — which fails to compile if the parent has no no-arg constructor. That is the error "constructor Animal in class Animal cannot be applied to given types".
  • A cycle (A() calls this(1) which calls this()) is a compile error: "recursive constructor invocation".

Initialization order (verified)

This is the most-asked "what does this print?" question about classes. Here is a parent and child, each with a static field, a static block, an instance field, an instance block, and a constructor:

public class InitOrder {
    static class Parent {
        static String ps = log("1. Parent static field");
        static { log("2. Parent static block"); }
        String pf = log("5. Parent instance field");
        { log("6. Parent instance block"); }
        Parent() { log("7. Parent constructor"); }
    }
    static class Child extends Parent {
        static String cs = log("3. Child static field");
        static { log("4. Child static block"); }
        String cf = log("8. Child instance field");
        { log("9. Child instance block"); }
        Child() {
            super();                       // implicit if omitted
            log("10. Child constructor");
        }
    }
    static String log(String s) { System.out.println(s); return s; }

    public static void main(String[] args) {
        System.out.println("--- first new Child() ---");
        new Child();
        System.out.println("--- second new Child() ---");
        new Child();
    }
}
--- first new Child() ---
1. Parent static field
2. Parent static block
3. Child static field
4. Child static block
5. Parent instance field
6. Parent instance block
7. Parent constructor
8. Child instance field
9. Child instance block
10. Child constructor
--- second new Child() ---
5. Parent instance field
6. Parent instance block
7. Parent constructor
8. Child instance field
9. Child instance block
10. Child constructor
ONCE per class (first use): Parent statics -> Child statics (top to bottom, in source order) EVERY new Child(): Parent fields + blocks -> Parent ctor body -> Child fields + blocks -> Child ctor body

The rules behind it:

  • Static initialisers run once, when the class is first initialised (first new, first static method call, or first access to a non-constant static field). Parent before child.
  • Field initialisers and instance blocks are copied by the compiler into every constructor, right after the super(...) call. That is why they run after the parent constructor but before the rest of the child constructor.
  • Within one level, field initialisers and blocks run in the order they appear in the source.
  • A constructor that starts with this(...) does not re-run the field initialisers — only the constructor that calls super(...) does.

Common pitfalls

  • Comparing objects with == checks identity. new Pizza() == new Pizza() is false even with equal fields; use equals (section 13).
  • Calling an overridable method from a constructor lets a subclass run code before its own fields are set — you will see null (verified demo in section 10).
  • Letting this "escape" from a constructor (registering it as a listener, starting a thread with it) exposes a half-built object to other code.
Interview angle: expect "what is the difference between a class and an object?", "what happens when you write new?", "when does the default constructor exist?" and a print-the-output puzzle on static/instance blocks. Answer the puzzle with the two-line rule: statics once, parent first; then for each object, parent fields/blocks/constructor, then child fields/blocks/constructor.

9. Encapsulation & Access Modifiers

In plain words

Encapsulation means an object keeps its data private and lets the outside world touch it only through methods it chooses. The object guards its own rules (a balance never goes negative, an email is always valid) instead of trusting every caller to behave.

Access modifiers (private, package-private, protected, public) are the locks that make this possible — they decide who can see each class, field, method and constructor.

A cash machine. You cannot open the box and grab notes; you press buttons (methods), and the machine checks your PIN and your balance before anything moves. The machine can change its insides tomorrow and your buttons still work.

The four access levels

ModifierSame classSame packageSubclass in another packageEveryone elseTypical use
privateyesnononoFields, helper methods
(none) package-privateyesyesnonoInternals shared inside one package; tests in the same package
protectedyesyesyes, through inheritance onlynoHooks meant for subclasses
publicyesyesyesyesThe API
public — any code anywhere protected — + subclasses in other packages package-private (no keyword) — + same package private — this class only (includes its nested classes)
Each ring adds more code that can see the member. Start at the innermost ring and widen only when there is a reason.

Rules people forget

  • A top-level class can only be public or package-private. Only nested classes can be private or protected. One public top-level class per file, and the file name must match it.
  • private is per class, not per object. Inside Point, you can read other.x of another Point — that is how equals works. An outer class and its nested classes can also read each other's private members.
  • protected includes package access. It is wider than package-private, not narrower — a common wrong answer.
  • Interface members are public by default (methods are public abstract, fields are public static final).

The protected subtlety (verified with javac)

A subclass in a different package can use a protected member only through its own type (this, or a reference to itself or its subclasses) — not through a reference of the parent type.

// file com/shop/model/Account.java
package com.shop.model;
public class Account {
    public    String owner = "amy";   // everyone
    protected int    level = 1;       // same package + subclasses
              double rate  = 0.05;    // package-private
    private   long   balance = 100;   // this class only
    public long getBalance() { return balance; }
}

// file com/shop/web/Premium.java  -- a DIFFERENT package
package com.shop.web;
import com.shop.model.Account;
public class Premium extends Account {
    int myLevel()            { return level; }     // OK: inherited, via 'this'
    int viaSelf(Premium p)   { return p.level; }   // OK: reference is a Premium
    int viaParent(Account a) { return a.level; }   // ERROR
    double r()               { return rate; }      // ERROR
}
Premium.java:8: error: level has protected access in Account
Premium.java:9: error: rate is not public in Account; cannot be accessed from outside package

Why? A random Account could be some other company's subclass. Protected means "you may use the parts you inherited", not "you may poke into every Account". Meanwhile a class in com.shop.model (same package) can read a.level and a.rate freely.

Getters, setters and why encapsulation matters

Making fields private and adding a getter and setter for each one is not encapsulation by itself — it is a public field with extra steps. Real encapsulation means the class exposes behaviour and protects its invariants (rules that must always be true).

// Leaky: private field, but the getter hands out the internal list
class LeakyCart {
    private final List<String> items = new ArrayList<>();
    public List<String> getItems() { return items; }
}
LeakyCart cart = new LeakyCart();
cart.getItems().add("hacked");        // caller changed private state
cart.getItems();                      // [hacked]

// Encapsulated: validation inside, read-only view out
class BankAccount {
    private long balanceCents;
    private final List<String> history = new ArrayList<>();

    public long getBalanceCents() { return balanceCents; }       // read: fine

    public void deposit(long cents) {                            // no setBalance()!
        if (cents <= 0) throw new IllegalArgumentException("deposit must be positive");
        balanceCents += cents;
        history.add("+" + cents);
    }
    public void withdraw(long cents) {
        if (cents > balanceCents) throw new IllegalStateException("insufficient funds");
        balanceCents -= cents;
        history.add("-" + cents);
    }
    public List<String> getHistory() { return Collections.unmodifiableList(history); }
}
acc.deposit(500); acc.withdraw(200);
acc.getBalanceCents() + " " + acc.getHistory();   // 300 [+500, -200]
acc.withdraw(10_000);                             // IllegalStateException: insufficient funds
acc.getHistory().add("+1000000");                 // UnsupportedOperationException
BenefitWhat it buys you
InvariantsValidation lives in one place; the object can never be in an illegal state.
Freedom to changeStore cents instead of a double, add caching, switch to a database — callers never notice.
Thread safetyIf all access goes through methods, you can add locking in one place.
DebuggingA breakpoint in one setter catches every change; a public field can be changed from anywhere.

Best practice

  • Make every field private (and final if it never changes). Widen only when you have a reason.
  • Prefer intention-revealing methods (withdraw, markShipped) over raw setters. "Tell, don't ask."
  • Never return an internal mutable collection or array. Return an unmodifiable view, a copy, or List.copyOf.
  • For pure data carriers, a record (section 15) gives private final fields and accessors for free.
Interview angle: "What is the difference between protected and default access?" (protected adds subclasses in other packages, and only through inheritance). "Is a class with getters and setters for every field encapsulated?" (not really — it still leaks its structure and has no rules). "Can a top-level class be private?" (no).

10. Inheritance

In plain words

Inheritance lets one class (the subclass or child) take all the fields and methods of another (the superclass or parent) with extends, then add new ones or replace (override) some. It models an IS-A relationship: a Dog is an Animal.

Java allows a class to extend only one class, but implement many interfaces. Every class ultimately extends java.lang.Object.

A new car model built on last year's platform. The engine, wheels and seats come along for free; the designers add a sunroof and replace the headlights. But if the platform's engineers quietly change the chassis, every model built on it is affected.

Object equals, hashCode, toString Animal name, sound(), describe() Dog extends Animal overrides sound(), adds fetch() Cat extends Animal overrides sound()
Arrows point from child to parent ("extends"). A Dog reference can be stored in an Animal or Object variable.

extends, super and overriding

class Animal {
    protected String name;
    Animal(String name) { this.name = name; }
    String sound()    { return "..."; }
    String describe() { return name + " says " + sound(); }
    Animal reproduce() { return new Animal(name + " Jr"); }
    void save() throws IOException { }
    final String kingdom() { return "Animalia"; }       // cannot be overridden
}

class Dog extends Animal {
    Dog(String name) { super(name); }                    // parent constructor first
    @Override String sound() { return "Woof"; }
    @Override String describe() { return super.describe() + "!"; }   // reuse parent code
    @Override Dog reproduce() { return new Dog(name + " Jr"); }      // covariant return
    @Override public void save() throws FileNotFoundException { }    // wider access, narrower exception
}

Animal a = new Dog("Rex");
a.describe();                       // "Rex says Woof!"  -- parent code calls the CHILD's sound()
Dog pup = new Dog("Rex").reproduce();  // no cast needed thanks to the covariant return
pup.name + " " + pup.kingdom();     // "Rex Jr Animalia"

super.method() calls the parent's version; super(...) calls the parent's constructor. super.super.x() does not exist.

Method overriding rules

RuleAllowedNot allowed
Name and parameter typesExactly the same (otherwise it is an overload, not an override)—
Return typeSame, or a subtype (covariant return, Java 5)A different or wider type; for primitives it must be identical
AccessSame or wider (protected → public)Narrower (public → package-private)
Checked exceptionsSame, a subclass, or none; any unchecked exceptionsNew or broader checked exceptions
Modifiers on parent—final methods; static methods are hidden, not overridden; private methods are not inherited at all

The reasoning is the Liskov substitution principle: code written against Animal must keep working when handed a Dog. A narrower access or a new checked exception would break that caller. Here is what javac says when you break the rules:

class Dog extends Animal {
    String sound() { return "Woof"; }    // parent's is public
    void save() throws Exception { }     // parent throws IOException
    String kingdom() { return "x"; }     // parent's is final
}
final class Money { }
class Coins extends Money { }
error: sound() in Dog cannot override sound() in Animal
  attempting to assign weaker access privileges; was public
error: save() in Dog cannot override save() in Animal
  overridden method does not throw Exception
error: kingdom() in Dog cannot override kingdom() in Animal
  overridden method is final
error: cannot inherit from final Money

Always write @Override. If you misspell the name or get a parameter type wrong (equals(Point p) instead of equals(Object o)), the annotation turns a silent overload into a compile error.

final classes and methods

  • A final method cannot be overridden: use it when the parent's logic must not change (security checks, template-method skeletons).
  • A final class cannot be extended: String, Integer and all wrappers, LocalDate, every record. This guarantees nobody can create a "String" subclass that mutates or lies about its contents.
  • Java 17 adds a middle ground: sealed classes list exactly which subclasses are allowed (section 12).

Why no multiple class inheritance: the diamond problem

A { void hello() } / \ B C both override hello() differently \ / D D extends B, C ? new D().hello() -- B's or C's? and does D hold ONE copy of A's fields or TWO?

Java refuses class D extends B, C to avoid this ambiguity about both behaviour and state. Interfaces had no state and no code, so implementing many was always safe. Java 8 default methods brought code into interfaces, so the diamond came back for methods (never for fields). The resolution rules are:

  1. Class wins. A method in the class or any superclass beats any interface default.
  2. More specific interface wins. If B extends A and both have a default, B's is used.
  3. Otherwise you must override it, and you may pick one with X.super.method().
interface Swimmer { default String move() { return "swim"; } }
interface Flyer   { default String move() { return "fly"; } }

class Duck implements Swimmer, Flyer {
    @Override public String move() {               // required: otherwise compile error
        return Swimmer.super.move() + " + " + Flyer.super.move();
    }
}
new Duck().move();   // "swim + fly"
// Without the override:
// error: types Swimmer and Flyer are incompatible;
//   class Duck inherits unrelated defaults for move() from types Swimmer and Flyer

Pitfall: overridable method called from a constructor

class Base {
    Base() { System.out.println("Base ctor sees: " + label()); }
    String label() { return "base"; }
}
class Derived extends Base {
    private String tag = "derived";          // assigned AFTER Base() finishes
    @Override String label() { return tag; }
}
new Derived();   // prints "Base ctor sees: null"

Dynamic dispatch works even inside the parent's constructor, so Derived.label() runs before tag is initialised (see the order in section 8). Rule: constructors should call only private, final or static methods.

Composition over inheritance

Inheritance couples you to the parent's implementation, not just its contract. The famous example from Effective Java: count how many elements were ever added to a set.

class CountingSet<E> extends HashSet<E> {
    int added = 0;
    @Override public boolean add(E e) { added++; return super.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) {
        added += c.size(); return super.addAll(c);
    }
}
s.addAll(List.of("a", "b", "c"));
s.added;   // 6, not 3 -- HashSet.addAll() internally calls add() for each element

class CountingSet2<E> {                           // composition: HAS-A set
    private final Set<E> inner = new HashSet<>();
    int added = 0;
    boolean add(E e) { added++; return inner.add(e); }
    boolean addAll(Collection<? extends E> c) { added += c.size(); return inner.addAll(c); }
}
s2.added;  // 3

This is the fragile base class problem. Use inheritance for a true IS-A relationship where the parent was designed to be extended; otherwise wrap an object and delegate to it.

Interview angle: "Why doesn't Java support multiple inheritance?" (diamond problem for state and behaviour; interfaces give multiple types without it). "What happens if two interfaces have the same default method?" (compile error until you override; use X.super.m()). "Can you override a static or private method?" (no — static is hidden, private is invisible). "Can an overriding method throw a broader exception?" (not a checked one).

11. Polymorphism

In plain words

Polymorphism ("many forms") means one piece of code can work with many types. You write shape.draw() once, and a circle draws a circle, a square draws a square.

Java has two kinds. Compile-time polymorphism is overloading: several methods with the same name but different parameters; the compiler picks one by looking at the argument types. Runtime polymorphism is overriding: the JVM looks at the actual object at run time and calls its version — this is dynamic dispatch.

A universal remote with a "power" button. You press the same button; the TV, the speaker and the projector each turn on in their own way. The remote does not need to know which device it is pointing at.

Overloading vs overriding

OverloadingOverriding
Also calledCompile-time / static polymorphismRuntime / dynamic polymorphism
WhereSame class (or inherited)Subclass redefines a parent method
SignatureSame name, different parametersSame name and parameters
Return typeAnythingSame or covariant
Decided byCompiler, using the declared type of the argumentsJVM, using the runtime type of the object
Works with static / private / finalYesNo

One demo, all the rules (verified)

class Shape {
    String name = "shape";                          // field
    static String kind() { return "Shape.kind"; }   // static
    String draw() { return "drawing a shape"; }     // instance
}
class Circle extends Shape {
    String name = "circle";                         // HIDES Shape.name (a second field)
    static String kind() { return "Circle.kind"; }  // HIDES Shape.kind()
    @Override String draw() { return "drawing a circle"; }
    double radius() { return 1.0; }
}
class Square extends Shape {
    @Override String draw() { return "drawing a square"; }
}

static String greet(Shape s)  { return "greet(Shape)"; }
static String greet(Circle c) { return "greet(Circle)"; }

Shape s = new Circle();        // upcast: implicit, always safe
s.draw();                      // "drawing a circle"  -- runtime type decides
s.name;                        // "shape"             -- fields: declared type decides
((Circle) s).name;             // "circle"
s.kind();                      // "Shape.kind"        -- static: declared type decides
greet(s);                      // "greet(Shape)"      -- overload chosen at compile time
  • Only instance methods are polymorphic. Fields and static methods are resolved from the declared type of the variable. A subclass field with the same name is a second, separate field (field hiding) — avoid it.
  • Static methods are hidden, not overridden. Calling a static method through an instance compiles but javac -Xlint warns: "static method should be qualified by type name, Shape, instead of by an expression".
  • Overload choice is fixed at compile time: greet(s) picks greet(Shape) even though s holds a Circle. Overloading does not dispatch on runtime type.

How dynamic dispatch works inside

Shape s = ● Circle object header: class ptr ● Shape.name, Circle.name Circle class: vtable (simplified) toString -> Object equals -> Object hashCode -> Object draw -> Circle radius -> Circle s.draw(): compiler checks Shape HAS draw(); JVM follows object -> class -> slot for draw -> Circle.draw
The compiler decides which slot (method signature); the JVM decides whose code is in that slot, using the object's real class. A subclass table copies the parent's slots and overwrites the ones it overrides.

HotSpot calls this the vtable (virtual method table); interfaces use a similar itable. The JIT compiler often removes the lookup entirely: if only one class is ever seen at a call site, it inlines that method directly ("monomorphic inline cache"), so polymorphism is effectively free in hot code.

Overload resolution order

When several overloads could match, the compiler tries in three phases and stops at the first that works:

  1. Exact match or widening of primitives (int → long), no boxing.
  2. Boxing / unboxing (int → Integer).
  3. Varargs.
static String pick(long x)    { return "long (widening)"; }
static String pick(Integer x) { return "Integer (boxing)"; }
static String pick(int... x)  { return "varargs"; }
pick(5);   // "long (widening)" -- widening beats boxing beats varargs

If two overloads are equally good (for example f(String) and f(Integer) called with null), it is a compile error: "reference to f is ambiguous".

Upcasting, downcasting and pattern matching

Shape[] shapes = { new Circle(), new Square(), new Shape() };
for (Shape sh : shapes) sh.draw();   // circle, square, shape -- one loop, many forms

Shape sq = new Square();
Circle c = (Circle) sq;              // compiles, but throws ClassCastException at run time

// Java 16+: instanceof with a pattern variable -- test and cast in one step
if (s instanceof Circle c && c.radius() > 0) {
    System.out.println("radius " + c.radius());   // radius 1.0
}

// Java 21: pattern matching for switch
String out = switch (sh) {
    case Circle c -> "circle r=" + c.radius();
    case Square q -> "square";
    default       -> "plain shape";
};
// circle r=1.0, square, plain shape
  • Upcast (child to parent) is automatic and always safe; you only lose access to child-only methods.
  • Downcast (parent to child) needs an explicit cast and is checked at run time. Casting between unrelated classes ((String) someInteger) is a compile error.
  • Order matters in a pattern switch: a case Shape s placed before case Circle c is a compile error ("this case label is dominated by a preceding case label").
  • A long instanceof chain is often a sign that the behaviour belongs in an overridden method. Pattern switches shine over sealed hierarchies and records (section 12, 15), where the compiler checks you covered every case.
Interview angle: the classic puzzle is Parent p = new Child(); p.field; p.staticMethod(); p.method(); — answer: parent's field, parent's static method, child's instance method. Then "is overloading polymorphism?" (yes, compile-time; overriding is runtime). And "how does the JVM find the right method?" (object → class → vtable slot, inlined by the JIT when only one type is seen).

12. Abstract Classes vs Interfaces

In plain words

An abstract class is a half-finished class: it can hold fields, constructors and working methods, but also leaves some methods abstract (no body) for subclasses to fill in. You cannot new it. A class can extend only one.

An interface is a contract: a list of things a type promises it can do. Since Java 8 it can also carry some ready-made code (default and static methods), but it has no instance state. A class can implement many.

An abstract class is a franchise kit: the kitchen, the recipes and the logo come built in, and you add the local menu. An interface is a certification badge like "wheelchair accessible" — any building can earn it, whatever else it is, and a building can hold many badges.

Side by side

FeatureAbstract classInterface
Instantiate with newNoNo (but lambdas / anonymous classes can implement it)
How many per classExtend oneImplement many
Instance fields (state)Yes, any kindNo — only public static final constants
ConstructorsYes (called via super(...))No
Method bodiesAny methoddefault and static (Java 8), private (Java 9)
Abstract methodsMust say abstractImplicitly public abstract
Member accessAny modifierPublic by default; private allowed for helpers (Java 9)
Restrict who extendssealed ... permits (Java 17)sealed ... permits (Java 17)
RelationshipIS-A, shared identity and codeCAN-DO, a capability
Lambda targetNoYes, if exactly one abstract method (functional interface)

Abstract class: shared state and a template method

abstract class Report {
    private final String title;                    // state lives here
    protected Report(String title) { this.title = title; }

    public final String render() {                 // template method: the fixed skeleton
        return "== " + title + " ==\n" + body() + "\n-- end --";
    }
    protected abstract String body();              // the step subclasses fill in
}

class SalesReport extends Report {
    SalesReport() { super("Sales"); }
    @Override protected String body() { return "total: 42"; }
}

System.out.println(new SalesReport().render());
// == Sales ==
// total: 42
// -- end --

A class with even one abstract method must itself be abstract. An abstract class may have zero abstract methods — that just stops people instantiating it. An abstract method cannot be private, static or final (each would make overriding impossible).

Interface: Java 8 default and static, Java 9 private

interface Discount {
    double apply(double price);                    // abstract

    default double applyTwice(double price) {      // Java 8: implementers inherit this
        return round(apply(apply(price)));
    }
    static Discount percent(double p) {            // Java 8: factory, called as Discount.percent(..)
        return price -> price * (1 - p / 100);
    }
    private double round(double v) {               // Java 9: helper hidden from implementers
        return Math.round(v * 100) / 100.0;
    }
    int MAX_PERCENT = 90;                          // implicitly public static final
}

Discount tenOff = Discount.percent(10);
tenOff.apply(200);         // 180.0
tenOff.applyTwice(99.99);  // 80.99
Discount.MAX_PERCENT;      // 90

Why default methods exist: Java 8 wanted to add stream() and forEach() to Collection. Adding an abstract method would have broken every existing implementation in the world; a default method gives them a working version for free. Static interface methods are not inherited by implementing classes — you must call Discount.percent(...).

Java 17: sealed interfaces

sealed interface Payment permits Card, Upi, Cash {}
record Card(String number) implements Payment {}
record Upi(String vpa)     implements Payment {}
record Cash()              implements Payment {}

static String fee(Payment p) {
    return switch (p) {                            // exhaustive: no default needed (Java 21)
        case Card c -> "2% card fee";
        case Upi u  -> "no fee for " + u.vpa();
        case Cash c -> "no fee";
    };
}
// 2% card fee / no fee for amy@bank / no fee

Every permitted subtype must be final (records are), sealed, or non-sealed. Because the compiler knows the full list, a pattern switch needs no default; add a fourth payment type and every switch that forgot it stops compiling. This is Java's version of an algebraic data type.

Java 8 default methods static methods Java 9 private methods Java 17 sealed ... permits Java 21 exhaustive pattern switch over sealed types
Interfaces have steadily gained features, but never instance fields or constructors.

Which one to pick

  • Default to an interface. It defines a type without locking callers into a hierarchy, and a class can mix in several (Comparable, Serializable, AutoCloseable).
  • Use an abstract class when subclasses genuinely share state or constructor logic, or you need non-public members, or a template method with a fixed skeleton (AbstractList, HttpServlet).
  • A common combination: an interface for the type plus an abstract "skeletal implementation" (List + AbstractList). Callers depend on the interface; implementers extend the skeleton to save work.
  • Use sealed when the set of subtypes is closed and known (payment kinds, AST nodes, results like Success / Failure).
Interview angle: "Now that interfaces have default methods, what is the difference from an abstract class?" — interfaces still have no instance state and no constructors, and a class can implement many. "Why were default methods added?" — to evolve interfaces like Collection without breaking existing implementations. "Can an interface have a private method?" — yes, since Java 9. "What is a functional interface?" — exactly one abstract method, so a lambda can implement it (@FunctionalInterface makes the compiler check).

13. The Object Class: equals, hashCode, toString

In plain words

Every class in Java secretly extends java.lang.Object, so every object has a few built-in methods. The important ones: equals (are two objects "the same" in meaning?), hashCode (a number used to find the object quickly in hash tables) and toString (a text version for printing and logs).

The defaults compare and hash by identity (the memory object), which is rarely what you want for value-like classes. If you override equals, you must override hashCode too, or HashMap and HashSet silently break.

A library. equals decides whether two books are the same title; hashCode is the shelf number. If two copies of the same title get different shelf numbers, the librarian looks on the wrong shelf and says "we don't have it" while it sits two aisles away.

Methods every object has

MethodDefault behaviourOverride?
equals(Object)this == o (identity)For value classes
hashCode()Identity-based number (not the memory address in modern JVMs)Always together with equals
toString()ClassName@hexHashAlmost always
getClass()The runtime Class objectCannot (final)
clone()Protected; shallow field copy if the class implements CloneableRarely; prefer copy constructors
wait(), notify(), notifyAll()Thread coordination on the object's monitorCannot (final)
finalize()Called before GC, maybe neverNever — deprecated since Java 9, for removal since 18

The equals contract

  • Reflexive: x.equals(x) is true.
  • Symmetric: x.equals(y) if and only if y.equals(x).
  • Transitive: if x.equals(y) and y.equals(z), then x.equals(z).
  • Consistent: the answer does not change unless the objects change.
  • Non-null: x.equals(null) is false (never throws).

The hashCode contract

  • If a.equals(b), then a.hashCode() == b.hashCode(). This is the one that matters.
  • Equal hash codes do not require equal objects (collisions are allowed, just slower).
  • The value must stay the same while the fields used in equals do not change.

Broken HashSet: equals without hashCode (verified)

class BadPoint {
    final int x, y;
    BadPoint(int x, int y) { this.x = x; this.y = y; }
    @Override public boolean equals(Object o) {
        return o instanceof BadPoint p && x == p.x && y == p.y;
    }
    // no hashCode()!
}
Set<BadPoint> bad = new HashSet<>();
bad.add(new BadPoint(1, 2));
bad.add(new BadPoint(1, 2));
new BadPoint(1, 2).equals(new BadPoint(1, 2));   // true
bad.size();                                      // 2   -- a "duplicate" got in
bad.contains(new BadPoint(1, 2));                // false -- can't find an equal element
HashSet buckets (array of 16) 0361115 BadPoint(1,2) #1 identity hash -> 3 BadPoint(1,2) #2 identity hash -> 11 contains(new BadPoint(1,2)) -> bucket 6: empty, equals never called HashSet checks the bucket FIRST (by hashCode), and only then calls equals inside that bucket
Bucket numbers are illustrative: identity hash codes differ per object, so equal points scatter to random buckets.

A correct equals and hashCode

final class Point {
    private final int x, y;
    Point(int x, int y) { this.x = x; this.y = y; }

    @Override public boolean equals(Object o) {
        if (this == o) return true;                    // fast path
        if (!(o instanceof Point p)) return false;     // also handles null
        return x == p.x && y == p.y;
    }
    @Override public int hashCode() { return Objects.hash(x, y); }
    @Override public String toString() { return "Point(" + x + ", " + y + ")"; }
}
good.add(new Point(1, 2)); good.add(new Point(1, 2));
good.size();                       // 1
good.contains(new Point(1, 2));    // true
good;                              // [Point(1, 2)]
  • The parameter must be Object. equals(Point p) is an overload; collections call equals(Object) and never see it. @Override catches this.
  • Compare double fields with Double.compare, objects with Objects.equals(a, b) (null-safe), arrays with Arrays.equals.
  • instanceof vs getClass(): with instanceof, a subclass that adds a field (ColorPoint) breaks symmetry — point.equals(colorPoint) is true but not the reverse. Either make the class final (as above) or use getClass() != o.getClass(), which treats subclasses as never equal.
  • Records (section 15) generate a correct equals, hashCode and toString from their components.

Mutable keys: the other way to lose an entry

Set<Tag> tags = new HashSet<>();
Tag t = new Tag("java");            // equals/hashCode use 'name'
tags.add(t);
t.name = "kotlin";                  // mutate AFTER inserting
tags.contains(t);                   // false -- looks in the bucket for "kotlin"
tags.size();                        // 1     -- the entry is stuck in the "java" bucket

Never change a field that equals/hashCode use while the object is a key in a map or an element of a set. The safest fix: make keys immutable (String, records, enums).

toString

The default prints ObjectMethods$BadPoint@ followed by a hex hash — useless in a log. Override it for any class you will ever print or debug. Keep it cheap, never throw from it, and never include secrets (passwords, tokens), because logs end up everywhere.

clone: shallow vs deep

class Team implements Cloneable {
    String name;
    List<String> members = new ArrayList<>();
    @Override public Team clone() {
        try { return (Team) super.clone(); }          // copies fields one by one
        catch (CloneNotSupportedException e) { throw new AssertionError(e); }
    }
    Team deepCopy() {                                 // copy-constructor style
        Team t = new Team(name);
        t.members = new ArrayList<>(members);        // NEW list with the same elements
        return t;
    }
}
a.members.add("amy");
Team shallow = a.clone(), deep = a.deepCopy();
shallow.members.add("bob");
a.members + " " + shallow.members + " " + deep.members;   // [amy, bob] [amy, bob] [amy]
a.members == shallow.members;                             // true -- one shared list
shallow clone: a.members ──┐ ├──> [amy, bob] one list, two owners shallow.members ┘ deep copy: a.members ─────────> [amy, bob] deep.members ──────> [amy] separate list

Cloneable is widely considered a broken design: it is a marker interface with no clone method, Object.clone skips constructors, and it does not work with final fields that need a fresh copy. Prefer a copy constructor (new Team(other)) or a static factory (Team.copyOf(other)). Arrays are the exception: array.clone() is fine (and shallow).

Interview angle: "What happens if you override equals but not hashCode?" — equal objects land in different buckets, so HashSet keeps duplicates and contains/get fail. "Can two unequal objects have the same hash code?" — yes, that is a collision. "What is the difference between == and equals?" — identity vs logical equality. "Shallow vs deep copy?" — shared vs copied inner objects.

14. Nested, Inner & Anonymous Classes

In plain words

Java lets you write a class inside another class, or even inside a method. There are four kinds: a static nested class (just a class stored inside another for organisation), an inner class (each object is tied to one object of the outer class), a local class (declared inside a method) and an anonymous class (a one-off class with no name, written right where it is used).

The big practical difference: an inner (non-static) class secretly holds a reference to its outer object. That is handy, but it can keep a large outer object alive in memory long after you are done with it.

A static nested class is a separate booklet stored in the same folder. An inner class is a sticky note glued to one specific page — wherever the note goes, it drags the page along with it.

The four kinds

KindDeclaredNeeds an outer object?Can accessTypical use
Static nestedIn a class, with staticNoOuter's static members (including private)Builders, Map.Entry-style helpers, holder idiom
Inner (member)In a class, no staticYes: outer.new Inner()Everything in the outer objectIterators over the outer collection
LocalInside a method or blockYes, if the method is an instance methodOuter members + effectively final localsA helper used by one method only
AnonymousInline with new Type() { ... }Yes, in an instance contextSame as localOne-off implementations; mostly replaced by lambdas

All four in one program (verified)

public class Nested {
    private int outerValue = 10;
    private static int counter = 0;

    static class StaticNested {                  // no link to a Nested object
        int read() { return counter; }           // only static members
    }
    class Inner {                                // tied to one Nested object
        int outerValue = 20;
        int read() { return outerValue + Nested.this.outerValue; }   // 20 + 10
    }

    Runnable localAndAnonymous(String prefix) {
        int base = 5;                            // effectively final
        class Local {                            // visible only inside this method
            String show() { return prefix + (base + outerValue); }
        }
        return new Runnable() {                  // anonymous class
            @Override public void run() {
                System.out.println(new Local().show() + ", anon class = " + this.getClass().getName());
            }
        };
    }
    Runnable lambda() {
        return () -> System.out.println("lambda this is outer? " + (this.getClass() == Nested.class));
    }

    public static void main(String[] args) {
        Nested outer = new Nested();
        StaticNested sn = new Nested.StaticNested();     // no outer instance needed
        Nested.Inner in = outer.new Inner();             // needs an outer instance
        System.out.println(sn.read() + " " + in.read()); // 0 30
        outer.localAndAnonymous("sum=").run();           // sum=15, anon class = Nested$1
        outer.lambda().run();                            // lambda this is outer? true
    }
}
  • Nested.this.outerValue reaches the outer field when the inner class shadows the name.
  • Anonymous classes compile to Outer$1, Outer$2 …; nested ones to Outer$Inner.
  • Inside an anonymous class, this is the anonymous object. Inside a lambda, this is the enclosing object — a lambda is not a class of its own and does not introduce a new scope.
  • Local and anonymous classes (and lambdas) can use a local variable only if it is effectively final (never reassigned). Adding base++ gives "local variables referenced from an inner class must be final or effectively final". The value is copied into the object, so allowing changes would create two versions that drift apart.
  • Since Java 16, inner classes may declare static members, and you can declare local records, enums and interfaces.

The hidden reference and memory leaks

The compiler gives every inner class that uses its outer object a hidden field, this$0, pointing to that object. Reflection shows it:

for (Field f : Inner.class.getDeclaredFields())
    System.out.println("Inner field: " + f.getName() + (f.isSynthetic() ? " (synthetic)" : ""));
// Inner field: outerValue
// Inner field: this$0 (synthetic)      <-- reference to the outer object

class Unused { int x; }                  // an inner class that never touches Outer
// Unused fields: [x]                    -- javac 18+ drops this$0 when it is not used
StaticNested.class.getDeclaredFields().length;   // 0 -- no outer reference at all
static cache lives forever Listener (inner) few bytes + this$0 Screen (outer) byte[] image: 50 MB closed, no longer used but still reachable this$0 GC keeps everything reachable from a root. Make Listener a static nested class and the Screen can be freed.
The classic Android leak: an inner-class listener or handler registered somewhere long-lived keeps a whole Activity (screen) in memory.
  • Rule of thumb (Effective Java): if a nested class does not need the outer instance, make it static. It is smaller, cannot leak, and can be created without an outer object.
  • Anonymous classes created in instance methods can capture this too. A lambda captures this only if its body actually uses an instance member.
  • Inner-class objects are also a serialization trap: serializing one drags in the outer object.

Where each one shows up

  • Static nested: Builder in the builder pattern, Map.Entry, the lazy holder idiom for singletons (section 16), private node classes (LinkedList.Node).
  • Inner: ArrayList.Itr — the iterator needs the list's array and size.
  • Anonymous: pre-Java 8 callbacks (new Comparator<>() {...}), or when you need to implement an interface with several methods or extend an abstract class inline.
  • Lambda: the modern replacement for anonymous classes of functional interfaces (section 24).
Interview angle: "Static nested vs inner class?" — inner holds a reference to an outer instance, needs outer.new Inner(), and can leak memory. "Why must captured variables be effectively final?" — the value is copied into the object; mutation would make the two copies disagree (and cause races across threads). "How is this different in a lambda and an anonymous class?" — lambda: enclosing object; anonymous: itself.

15. Enums & Records

In plain words

An enum is a type with a fixed, known list of values — days of the week, order statuses, planets. Each value is a real object, created once, so enums can have fields, constructors and methods, and you compare them safely with ==.

A record (Java 16) is a short way to write a class that is just data. You list the fields once; Java writes the constructor, the accessors, equals, hashCode and toString, and makes every field private final.

An enum is the set of sizes on a coffee menu: small, medium, large — you cannot order "extra-medium". A record is a filled-in form: once printed, two forms with the same answers are considered the same form.

Enums are classes

enum Planet {
    MERCURY(3.303e+23, 2.4397e6),
    EARTH(5.976e+24, 6.37814e6);                    // constants first, then a semicolon

    private final double mass, radius;              // fields
    Planet(double mass, double radius) {            // constructor is always private
        this.mass = mass; this.radius = radius;
    }
    double surfaceGravity() { return 6.67300E-11 * mass / (radius * radius); }
}
Planet.EARTH.surfaceGravity();                      // 9.80 (printed with %.2f)

// Constant-specific bodies: each constant has its own implementation
enum Op {
    PLUS("+")  { int apply(int a, int b) { return a + b; } },
    TIMES("*") { int apply(int a, int b) { return a * b; } };
    final String symbol;
    Op(String symbol) { this.symbol = symbol; }
    abstract int apply(int a, int b);
}
Op.TIMES.apply(6, 7);                               // 42
enum Day { MON, TUE, ... } compiles to roughly: public final class Day extends java.lang.Enum<Day> { public static final Day MON = new Day("MON", 0); created once, when Day is initialised public static final Day TUE = new Day("TUE", 1); ... public static Day[] values() -- a fresh copy of all constants public static Day valueOf(String name) -- exact-name lookup, throws if unknown }

Built-in methods, switch and the enum collections

enum Day { MON, TUE, WED, THU, FRI, SAT, SUN }

Day.valueOf("WED").ordinal();     // 2   (position, starting at 0)
Day.values().length;              // 7
Day.FRI.name();                   // "FRI"
Day.SAT.compareTo(Day.MON);       // 5   (compares ordinals)
Day.valueOf("Funday");            // IllegalArgumentException

String type = switch (d) {        // case labels are written WITHOUT "Day."
    case SAT, SUN -> "weekend";
    default       -> "weekday";
};                                // "weekend" for SAT

EnumMap<Day, Integer> hours = new EnumMap<>(Day.class);
hours.put(Day.FRI, 6); hours.put(Day.MON, 8);
hours;                            // {MON=8, FRI=6}  -- always in declaration order
EnumSet.range(Day.MON, Day.WED);  // [MON, TUE, WED]
  • EnumMap is backed by a plain array indexed by ordinal, and EnumSet by a bit mask (one long for up to 64 constants). Both are faster and smaller than HashMap/HashSet — always prefer them for enum keys.
  • Compare enums with ==: it is null-safe and each constant exists exactly once.
  • Never persist ordinal() (in a database or file). Inserting a new constant in the middle shifts every number. Store name() or an explicit code field.
  • A switch over an enum in a switch expression (Java 14) must be exhaustive; if you list every constant, no default is needed.

Singleton via enum

enum IdGenerator {
    INSTANCE;
    private long next = 1;
    synchronized long nextId() { return next++; }
}
IdGenerator.INSTANCE.nextId();   // 1
IdGenerator.INSTANCE.nextId();   // 2

Effective Java calls this the best way to write a singleton: the JVM guarantees exactly one instance, creation is thread-safe, serialization cannot create a second copy, and reflection cannot call the constructor (Constructor.newInstance refuses enum types). Its only limit: an enum cannot extend another class.

Records (Java 16)

record Money(long cents, String currency) {
    Money {                                        // COMPACT constructor: no parameter list
        if (cents < 0) throw new IllegalArgumentException("negative: " + cents);
        currency = currency.toUpperCase();         // reassign the PARAMETER; fields set after
    }
    Money add(Money o) { return new Money(cents + o.cents, currency); }   // returns a new object
    static Money zero(String cur) { return new Money(0, cur); }
}

Money m = new Money(500, "usd");
m;                                  // Money[cents=500, currency=USD]
m.cents();                          // 500   -- accessor is cents(), not getCents()
m.equals(new Money(500, "USD"));    // true  -- compares all components
m.add(Money.zero("usd")) == m;      // false -- a new object every time
new Money(-1, "usd");               // IllegalArgumentException: negative: -1
You writeThe compiler generates
record Money(long cents, String currency)private final fields cents, currency; a canonical constructor; accessors cents() and currency(); equals, hashCode, toString based on all components. The class is implicitly final and extends java.lang.Record.
  • The compact constructor is for validation and normalisation. The assignments this.cents = cents are added automatically at the end, so you work on the parameters.
  • A record may add methods, static fields and static factories, and implement interfaces. It may not declare extra instance fields or extend a class.
  • Records are shallowly immutable. A List component can still be changed by whoever holds that list — copy it:
    record Team(String name, List<String> members) {
        Team { members = List.copyOf(members); }       // defensive copy, also unmodifiable
    }
    List<String> src = new ArrayList<>(List.of("amy"));
    Team t = new Team("core", src);
    src.add("bob");
    t;                                                 // Team[name=core, members=[amy]]
  • Java 21 record patterns destructure them: if (obj instanceof Money(long c, String cur)) ... or case Money(var c, var cur) -> ... in a switch.

When not to use a record

  • JPA / Hibernate entities — they need a no-arg constructor, mutable fields and proxies (subclasses). Records fit DTOs and query projections instead.
  • The object must change over time, or has identity beyond its values (two users with the same name are still two users).
  • You need to hide some state, compute lazy/cached fields, or extend a base class.
  • The public API would lock you in: every component becomes a public accessor and part of equals, and renaming one breaks callers.
Interview angle: "Can an enum have a constructor? Can it be public?" — yes; no, it is always private. "Why is enum the best singleton?" — one instance guaranteed by the JVM, safe against serialization and reflection. "Is a record immutable?" — its fields are final, but only shallowly; copy mutable components. "Record vs Lombok @Data?" — record is built in, final and immutable; @Data generates setters and a mutable class.

16. static, final & Immutability

In plain words

static means "belongs to the class, not to any one object": there is one copy, shared by everyone, usable without new. final means "cannot be changed after it is set": a final variable is assigned once, a final method cannot be overridden, and a final class cannot be extended.

An immutable object is one whose state can never change after construction — like String. To "change" it, you create a new one. Immutable objects are automatically thread-safe and make perfect map keys.

static is the office whiteboard: one board, everyone reads and writes the same thing. final is writing in pen instead of pencil. An immutable object is a printed and laminated card — if you want different text, you print a new card.

static in all its forms

WhereMeaningExample
FieldOne copy per class, shared by all objectsstatic int instances;
MethodCalled on the class; no this, cannot touch instance fields directlyMath.max, factories
BlockRuns once, when the class is initialisedLoading a lookup table
Nested classNo hidden reference to an outer objectBuilder, holder idiom
ImportUse static members without the class nameimport static java.lang.Math.*;
public class StaticDemo {
    static final int MAX = 100;                         // compile-time constant (inlined by javac)
    static final Map<String, Integer> CODES;
    static {                                            // runs once, on first use of the class
        CODES = Map.of("IN", 91, "US", 1);
        System.out.println("StaticDemo initialised");
    }
    static class Holder {                               // lazy, thread-safe singleton idiom
        static { System.out.println("Holder initialised"); }
        static final StaticDemo INSTANCE = new StaticDemo();
    }
    static StaticDemo get() { return Holder.INSTANCE; }

    public static void main(String[] args) {
        System.out.println("main starts, MAX = " + MAX + ", IN = " + CODES.get("IN"));
        System.out.println(get() == get());
    }
}
// StaticDemo initialised
// main starts, MAX = 100, IN = 91
// Holder initialised           <-- only when get() is first called
// true

The holder idiom works because the JVM initialises a class lazily and under a lock: Holder is not touched until get() runs, and the JVM guarantees its static initialiser runs exactly once, even with many threads.

  • A static method cannot use instance state: static void f() { count++; } gives "non-static variable count cannot be referenced from a static context".
  • Static fields live with the class (in the Class object on the heap in modern HotSpot), so a static List or Map that keeps growing is a classic memory leak — it is never garbage collected while the class is loaded.
  • Static mutable state is global state: hard to test, and shared by all threads. Prefer constants (static final) and pass everything else in.

final in all its forms

Applied toEffect
Local variable / parameterAssigned once
Instance fieldMust be assigned exactly once by the end of every constructor ("blank final" if not assigned at declaration). Gets a JMM guarantee: other threads see its constructed value once the object is safely published.
Static fieldAssigned once, at declaration or in a static block. A static final primitive or String with a constant value is a compile-time constant and gets inlined into calling classes.
MethodCannot be overridden
ClassCannot be extended (String, Integer, records)
final List<String> names = new ArrayList<>();
names.add("still mutable");          // allowed -- the OBJECT changes, the reference does not
names = new ArrayList<>();           // error: cannot assign a value to final variable names

int limit = 3;                       // never reassigned -> EFFECTIVELY FINAL (Java 8)
Runnable r = () -> System.out.println("limit " + limit);   // OK, prints "limit 3"
// adding limit++ anywhere gives:
// error: local variables referenced from a lambda expression must be final or effectively final

final reference is not an immutable object. final freezes the arrow, not the thing it points to. This is the single most common confusion in this topic.

final names = ● ArrayList contents CAN change arrow fixed, object mutable String s = ● "hi" never changes "hi!" new object after s += "!"
Top: final fixes the reference only. Bottom: an immutable object never changes; "modifying" it re-points a (non-final) variable at a new object.

How to build an immutable class: the checklist

  1. Make the class final (or give it only private constructors), so no subclass can add mutable state or override accessors.
  2. Make every field private final.
  3. Provide no setters and no methods that change fields.
  4. Defensive copy in: copy every mutable argument (lists, arrays, Date) in the constructor.
  5. Defensive copy out: never return a mutable internal object; return a copy or an unmodifiable view.
  6. "Modifying" methods return a new object (withItem, plusDays).
  7. Do not let this escape from the constructor.
// BROKEN: final fields, but it holds the caller's mutable objects
final class LeakyOrder {
    private final List<String> items;
    private final Date placedAt;
    LeakyOrder(List<String> items, Date placedAt) { this.items = items; this.placedAt = placedAt; }
    List<String> items() { return items; }
    Date placedAt() { return placedAt; }
}

// CORRECT
public final class Order {                                 // 1. final class
    private final String id;                               // 2. private final fields
    private final List<String> items;
    private final Date placedAt;

    public Order(String id, List<String> items, Date placedAt) {
        this.id = Objects.requireNonNull(id);
        this.items = List.copyOf(items);                   // 4. copy in (and unmodifiable)
        this.placedAt = new Date(placedAt.getTime());      //    copy the mutable Date
    }
    public String id() { return id; }                      // 3. no setters
    public List<String> items() { return items; }          //    already unmodifiable: safe
    public Date placedAt() { return new Date(placedAt.getTime()); }  // 5. copy out
    public Order withItem(String item) {                   // 6. change = new object
        List<String> next = new ArrayList<>(items);
        next.add(item);
        return new Order(id, next, placedAt);
    }
}

The same attack on both — mutate the caller's list and date after construction, mutate what the getters return:

list.add("HACK"); when.setTime(999_999_999_999L); leaky.items().add("HACK2");
leaky.items() + " " + leaky.placedAt().getTime();   // [pen, HACK, HACK2] 999999999999

list.add("HACK"); when.setTime(999_999_999_999L); order.placedAt().setTime(123);
order.items().add("HACK2");                         // UnsupportedOperationException
order.items() + " " + order.placedAt().getTime();   // [pen] 0
order.withItem("ink").items();                      // [pen, ink]  -- order itself is still [pen]

Better still, avoid mutable parts altogether: use Instant or LocalDate (immutable, Java 8) instead of Date, and a record with a compact constructor for the copying.

Why immutability is worth it

  • Thread-safe for free — nothing changes, so no locks are needed.
  • Safe map keys and set elements — the hash code can never change under the collection (see the mutable-key bug in section 13).
  • Easy to reason about and cache — share one instance everywhere (String pool, Integer cache, BigDecimal.ZERO).
  • Cost: more objects created on each "change". Modern GCs handle short-lived objects cheaply; for heavy building, use a mutable builder (StringBuilder) and freeze at the end.

Why is String immutable? So it can be shared from the string pool, used safely as a HashMap key (its hash is cached), passed across threads without locking, and trusted in security checks (a file path or class name cannot change after it is validated).

Interview angle: "How do you make a class immutable?" — recite the checklist and mention defensive copies both ways; that detail separates good answers from average ones. "final vs finally vs finalize?" — modifier; block that always runs after try; deprecated GC hook. "Can a static method be overridden?" — no, it is hidden. "What does effectively final mean?" — never reassigned after initialisation, so lambdas and inner classes may capture it.
Part 3 — Core APIs

17. Exceptions

In plain words

An exception is an object that says "something went wrong here". When code throws it, Java stops the normal flow and walks back up the call stack until some catch block handles it. If nobody catches it, the thread dies and prints a stack trace (the list of method calls that led to the problem).

Java splits problems into two kinds: checked exceptions (things a careful program should expect, like a missing file — the compiler forces you to deal with them) and unchecked exceptions (programming bugs like null access, or JVM trouble like running out of memory — the compiler does not force anything).

A fire alarm in a building. When smoke is detected (throw), normal work stops and the alarm travels up floor by floor until someone responsible (catch) deals with it. The cleanup crew that always tidies the room, fire or no fire, is finally.

The hierarchy

Throwable Error unchecked, JVM trouble Exception checked (except below) OutOfMemoryError StackOverflowError AssertionError Checked IOException SQLException InterruptedException RuntimeException unchecked NullPointerException IllegalArgumentException IllegalStateException IndexOutOfBoundsException ArithmeticException Checked = Exception and its subclasses, MINUS RuntimeException and its subclasses. Unchecked = RuntimeException + Error.
Everything throwable extends Throwable. The compiler only checks one branch: Exception minus RuntimeException.
CheckedUnchecked (RuntimeException)Error
MeaningExpected, recoverable problem outside your controlA bug in the calling codeSerious JVM/system failure
ExamplesIOException, SQLExceptionNullPointerException, IllegalArgumentExceptionOutOfMemoryError, StackOverflowError
Compiler ruleMust catch or declare with throwsNo ruleNo rule
Should you catch it?Yes, where you can do something usefulUsually no — fix the bug; catch at a top-level boundaryAlmost never

try / catch / finally — the exact rules

  • catch blocks are tried top to bottom; the first matching type wins. A subclass catch must come before its superclass catch, or the compiler rejects it as unreachable.
  • finally runs whether the try block finished normally, returned, or threw. It is for cleanup.
  • If the try block executes return x, the value of x is saved first, then finally runs. Changing a primitive in finally does not change the returned value — but mutating a returned object does.
  • A return inside finally overrides the try's return and even swallows an exception in flight. Never do it.
@SuppressWarnings("finally")
static int returnInFinally() {
    try { return 1; } finally { return 2; }
}                                          // returns 2

@SuppressWarnings("finally")
static String swallow() {
    try { throw new IllegalStateException("lost!"); }
    finally { return "finally won"; }      // the exception silently disappears
}                                          // returns "finally won"

static int captured() {
    int x = 10;
    try { return x; } finally { x = 20; }  // 10 was already saved
}                                          // returns 10

static List<String> capturedRef() {
    List<String> list = new ArrayList<>();
    try { list.add("try"); return list; }
    finally { list.add("finally"); }       // same object, so the change shows
}                                          // returns [try, finally]

When does finally NOT run?

public static void main(String[] args) {
    try {
        System.out.println("in try");
        System.exit(0);                    // JVM starts shutting down right here
    } finally {
        System.out.println("finally");     // never printed
    }
}
// Output: in try

finally is skipped when the JVM itself stops: System.exit(), Runtime.halt(), a crash, the process being killed (kill -9), or a power cut. It also never runs if the try block never ends (infinite loop, deadlock), and a daemon thread's finally is skipped when the JVM exits. Shutdown hooks (Runtime.addShutdownHook) run on System.exit, but finally blocks do not.

try-with-resources (Java 7) and suppressed exceptions

Any object that implements AutoCloseable can be declared in the try header. Java closes it automatically, in reverse order of declaration, even if the body throws. If close() also throws, that exception is not lost and does not hide the real one: it is attached to the main exception as a suppressed exception.

static class Res implements AutoCloseable {
    private final String name;
    Res(String name) { this.name = name; System.out.println("open " + name); }
    @Override public void close() throws IOException {
        System.out.println("close " + name);
        throw new IOException("close failed: " + name);
    }
}

try (Res a = new Res("A"); Res b = new Res("B")) {
    System.out.println("body");
    throw new IllegalStateException("body failed");
} catch (Exception 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                         <- reverse order
// close A
// caught: body failed             <- the REAL error wins
//   suppressed: close failed: B
//   suppressed: close failed: A

With the old try/finally style, an exception from close() in finally would replace the body's exception, and you would debug the wrong problem. Since Java 9 you can also use an existing effectively-final variable: try (reader) { ... }.

Multi-catch (Java 7)

for (String in : new String[]{"abc", null}) {
    try {
        System.out.println(Integer.parseInt(in.trim()));
    } catch (NumberFormatException | NullPointerException e) {
        System.out.println("multi-catch: " + e.getClass().getSimpleName());
    }
}
// multi-catch: NumberFormatException
// multi-catch: NullPointerException

The variable e is implicitly final. The alternatives cannot be parent and child (IOException | FileNotFoundException does not compile — the parent already covers the child).

Custom exceptions and chaining

// Checked: the caller is expected to recover (show a message, retry with less)
class InsufficientFundsException extends Exception {
    private final long shortBy;
    InsufficientFundsException(String msg, long shortBy) { super(msg); this.shortBy = shortBy; }
    long shortBy() { return shortBy; }       // extra data the handler can use
}

// Unchecked wrapper that keeps the original as its CAUSE
class DataAccessException extends RuntimeException {
    DataAccessException(String msg, Throwable cause) { super(msg, cause); }
}

try {
    throw new SQLException("connection refused");
} catch (SQLException e) {
    throw new DataAccessException("could not load user 42", e);   // chain, don't drop
}
// e.getMessage()            -> could not load user 42
// e.getCause().getMessage() -> connection refused

Exception chaining means wrapping a low-level exception in a higher-level one while keeping the original as the cause. The stack trace then shows both, with a Caused by: line. Translating exceptions at layer boundaries (SQL error becomes a "data access" error) keeps the upper layers independent of JDBC details — Spring does exactly this.

final vs finally vs finalize

What it isUse
finalKeywordVariable cannot be reassigned, method cannot be overridden, class cannot be extended
finallyBlock after try/catchCleanup code that runs whether or not an exception happened
finalize()Method on ObjectCalled by the GC "sometime" before collecting an object. Deprecated since Java 9 and deprecated for removal since Java 18 (JEP 421): no timing guarantee, may never run, slows GC, can resurrect objects. Use try-with-resources or java.lang.ref.Cleaner.

Best practices

  • Catch specific types, not Exception or Throwable, except at a top-level boundary (a request handler, a thread's run loop) where you log and return an error.
  • Never swallow: an empty catch {} hides bugs. At least log it; usually rethrow or wrap.
  • Keep the cause when wrapping: new XException("context", e). Losing the original stack trace is the most common logging mistake.
  • Log or rethrow, not both — doing both prints the same error many times up the stack.
  • Don't use exceptions for normal control flow. Creating one fills in a stack trace, which is slow compared to an if.
  • Fail fast with clear messages: Objects.requireNonNull(user, "user must not be null"), IllegalArgumentException for bad arguments, IllegalStateException for "wrong time to call this".
  • If you catch InterruptedException and cannot rethrow it, restore the flag: Thread.currentThread().interrupt().
  • Prefer unchecked exceptions for application errors in modern code (Spring, most libraries); use checked only when the caller can realistically recover.
Interview angle: "Checked vs unchecked?" — say what the compiler enforces, give two examples of each, and your rule for choosing. Follow-ups: "Does finally run if you return from try?" (yes, after the value is saved), "What if finally also returns?" (it wins and swallows exceptions), "When does finally not run?" (System.exit, JVM crash/kill), "What are suppressed exceptions?" (close-time failures attached to the main exception by try-with-resources). Bonus: since Java 14 (default on in 15), helpful NullPointerException messages tell you exactly which reference was null, e.g. user.name().trim() fails with Cannot invoke "String.trim()" because the return value of "NpeMsg$User.name()" is null.

18. Generics

In plain words

Generics (Java 5) let you write a class or method once with a type parameter — a placeholder like T — and fill in the real type when you use it: List<String>, Map<Long, User>. The compiler then checks that you only put Strings in and it knows you get Strings out, so no casts and no surprise ClassCastException at runtime.

The catch: generics exist only at compile time. The compiler checks the types and then erases them, so at runtime a List<String> is just a List.

A labelled storage box. The label "Books only" lets the warehouse staff (the compiler) refuse a shoe at the door, and whoever opens it knows they will find books. But once the box is on the shelf (runtime), the label has been peeled off — the box itself does not know what it holds.

Why generics: before and after

// Before Java 5: everything is Object, casts everywhere, errors at runtime
List names = new ArrayList();
names.add("amy");
names.add(42);                     // compiler happy
String s = (String) names.get(1);  // ClassCastException at runtime

// With generics: the mistake is a compile error
List<String> typed = new ArrayList<>();   // diamond <> since Java 7
typed.add("amy");
// typed.add(42);                  // does not compile
String t = typed.get(0);           // no cast

Generic classes, methods and bounded types

class Box<T> {                          // T = type parameter
    private T value;
    Box(T value) { this.value = value; }
    T get() { return value; }
}
Box<String> b = new Box<>("hi");
String s = b.get();                      // "hi", no cast

// Generic METHOD: the <T> before the return type declares it
static <T> T firstOrNull(List<T> list) { return list.isEmpty() ? null : list.get(0); }
firstOrNull(List.of("x", "y"));          // T inferred as String -> "x"
GenDemo.<String>firstOrNull(List.of("x"));   // explicit "type witness", rarely needed

// BOUNDED type: T must be Comparable, so compareTo is allowed
static <T extends Comparable<T>> T max(List<T> list) {
    T best = list.get(0);
    for (T t : list) if (t.compareTo(best) > 0) best = t;
    return best;
}
max(List.of(3, 9, 4));                   // 9
max(List.of("pear", "apple", "zucchini")); // "zucchini"

Naming convention: T type, E element, K/V key/value, R result. Multiple bounds use &: <T extends Number & Comparable<T>> (a class first, then interfaces). The JDK's own signature is the more flexible <T extends Comparable<? super T>>, so it also works when a parent class implements Comparable.

Invariance: why List<Integer> is not a List<Number>

Even though Integer is a Number, a List<Integer> is not a List<Number> (generics are invariant). If it were, you could add a Double into a list of Integers through the List<Number> reference. Arrays made the opposite choice (they are covariant) and pay for it at runtime:

Integer[] ia = {1, 2};
Object[] oa = ia;          // allowed: arrays are covariant
oa[0] = "boom";            // compiles, but throws ArrayStoreException: java.lang.String

List<Integer> ints = List.of(1, 2, 3);
// List<Number> nums = ints;   // compile error: generics catch it earlier

Wildcards and PECS

A wildcard ? means "some type I don't know". Bounded wildcards restore flexibility safely:

WildcardAcceptsRead givesCan addRole
List<? extends Number>List<Integer>, List<Double>, List<Number>Numbernothing (except null)Producer — you read from it
List<? super Integer>List<Integer>, List<Number>, List<Object>Object onlyIntegerConsumer — you write into it
List<?>any listObjectnothing (except null)Read-only, type doesn't matter

PECS: Producer Extends, Consumer Super. If a parameter gives you values, use extends; if it takes values from you, use super. If it does both, use a plain T.

static double sum(List<? extends Number> nums) {   // PRODUCER: we only read
    double s = 0;
    for (Number n : nums) s += n.doubleValue();
    return s;
}
sum(List.of(1, 2, 3));          // 6.0
sum(List.of(1.5, 2.5));         // 4.0

static void addInts(List<? super Integer> sink) {  // CONSUMER: we only write
    sink.add(1); sink.add(2);
}
addInts(new ArrayList<Number>());   // [1, 2]
addInts(new ArrayList<Object>());   // [1, 2]

// Both at once -- the shape of Collections.copy
static <T> void copy(List<? super T> dst, List<? extends T> src) {
    for (T t : src) dst.add(t);
}
List<Object> dst = new ArrayList<>(List.of("start"));
copy(dst, List.of(1, 2, 3));    // [start, 1, 2, 3]
Object | Number List<? super Integer> : Integer, Number, Object (go UP) / | \ Integer Double Long List<? extends Number> : Number, Integer, Double, Long (go DOWN) Why can't you add to "? extends Number"? The real list might be List<Double>; adding an Integer would corrupt it. Why do reads from "? super Integer" give Object? The real list might be List<Object> holding anything.

JDK examples: Collections.max(Collection<? extends T>, Comparator<? super T>), list.sort(Comparator<? super E>) — a Comparator<Object> can sort a List<String>.

Type erasure and its consequences

After checking, the compiler replaces each type parameter with its bound (Object if unbounded, or the first bound), and inserts casts where you read values. One compiled class serves all type arguments.

List<String> ls = new ArrayList<>();
List<Integer> li = new ArrayList<>();
ls.getClass() == li.getClass();     // true -- both are java.util.ArrayList at runtime
You can't...WhyWorkaround
new T()At runtime T is unknownPass a factory: Supplier<T> or Class<T>
new T[10] or new List<String>[10]Arrays must know their element type at runtimeUse List<T>; or (T[]) new Object[n] internally (as ArrayList does)
x instanceof List<String>The <String> part no longer existsx instanceof List<?>, then check elements
List<int>Erasure to Object needs reference typesList<Integer> (boxing) or int[] / IntStream
Overload f(List<String>) and f(List<Integer>)Both erase to f(List) — "name clash"Use different method names
static T fieldStatic belongs to the one shared class, not to each Box<X>Make the static member generic on its own
catch (T e) / generic exception classesThe catch needs a real runtime typeCatch a concrete type
// Workaround for new T(): pass a factory
static <T> List<T> fill(int n, Supplier<T> factory) {
    List<T> out = new ArrayList<>();
    for (int i = 0; i < n; i++) out.add(factory.get());
    return out;
}
fill(3, StringBuilder::new);        // 3 new StringBuilders

Bridge methods

When a class implements a generic interface with a concrete type, erasure would break overriding: Comparable<T>.compareTo(T) erases to compareTo(Object), but your class declares compareTo(Name). The compiler adds a hidden bridge method compareTo(Object) that casts and calls yours. You can see it with reflection:

class Name implements Comparable<Name> {
    final String s; Name(String s) { this.s = s; }
    public int compareTo(Name o) { return s.compareTo(o.s); }
}
for (Method m : Name.class.getDeclaredMethods())
    if (m.getName().equals("compareTo"))
        System.out.println(m.getParameterTypes()[0].getSimpleName() + " bridge=" + m.isBridge());
// Name bridge=false
// Object bridge=true     <- generated by the compiler

Raw types and heap pollution

A raw type is a generic type used without its type argument (List instead of List<String>). It exists only for compatibility with pre-Java-5 code. It turns the checks off, and the error then shows up far from the real bug:

List<String> names = new ArrayList<>();
List raw = names;           // raw type: warning only
raw.add(42);                // "unchecked" warning, but compiles -- heap pollution
String first = names.get(0);
// ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
// (thrown HERE, at the hidden cast, not where 42 was added)

Use List<?> when you truly don't care about the type. Treat "unchecked" warnings as errors. @SafeVarargs is how a generic varargs method promises it doesn't pollute the heap.

Interview angle: "What is type erasure?" — generic types are checked at compile time and removed from bytecode; the compiler inserts casts. Then list two consequences (no new T(), no instanceof List<String>) and mention bridge methods. "Explain PECS" — give Collections.copy(List<? super T> dst, List<? extends T> src). "Why is List<Integer> not a List<Number>?" — invariance protects you from adding a Double, unlike covariant arrays and their ArrayStoreException.

19. The Collections Framework

In plain words

The Collections Framework (in java.util) is Java's set of ready-made data structures: lists, sets, queues and maps. Interfaces (List, Set, Map...) say what a structure can do; implementations (ArrayList, HashSet, TreeMap...) say how it does it, and therefore how fast each operation is.

Write your code against the interface (List<String> x = new ArrayList<>()) so you can swap the implementation later without touching the rest.

Kitchen containers. "Something to hold liquid" is the interface; a jug, a bottle and a thermos are implementations. All hold liquid, but one pours fast, one seals tight and one keeps it hot — you pick based on what you do most.

The hierarchy

Iterable Collection List Set Queue ArrayList LinkedList HashSet LinkedHashSet TreeSet Deque PriorityQueue ArrayDeque LinkedList Map<K, V> separate: not a Collection HashMap LinkedHashMap TreeMap ConcurrentHashMap Plain boxes = interfaces Tinted boxes = classes
Interfaces on top, classes at the bottom. LinkedList implements both List and Deque. Map has its own tree; you reach its contents as collections through keySet(), values() and entrySet().
  • List — ordered by index, duplicates allowed.
  • Set — no duplicates (decided by equals/hashCode, or by the comparator for TreeSet).
  • Queue — hold items for processing (usually FIFO); Deque ("deck") adds both ends, so it works as a stack too.
  • Map — key to value pairs, unique keys. Not a Collection because its elements are pairs.
  • Also: SortedSet/NavigableSet and SortedMap/NavigableMap add sorted views and floor/ceiling; Java 21 added SequencedCollection/SequencedMap (JEP 431) with getFirst(), getLast(), reversed() for anything with a defined order.

Each implementation, inside

ClassBuilt onOrderNullsNotes
ArrayListResizable array (default capacity 10, allocated on first add; grows by 50%)IndexYesThe default list
LinkedListDoubly linked nodesIndexYesExtra node object per element; poor cache locality
ArrayDequeCircular resizable arrayEndsNoBest stack and queue
PriorityQueueBinary heap in an arraySmallest first on pollNoIteration order is NOT sorted
HashSetA HashMap (elements are the keys)NoneOneNeeds good hashCode/equals
LinkedHashSetLinkedHashMapInsertionOneDedupe while keeping order
TreeSetTreeMap (red-black tree)SortedNo*Needs Comparable or a Comparator
HashMapArray of buckets (see s20)NoneOne key, many valuesThe default map
LinkedHashMapHashMap + doubly linked list through entriesInsertion or accessYesLRU cache building block
TreeMapRed-black treeSorted by keyNo* keysfloorKey, ceilingKey, headMap, tailMap

*Unless your comparator explicitly handles null.

Complexity table

Classget / accessaddremovecontainsNext / iterate
ArrayListO(1)O(1) amortised at end, O(n) middleO(n) (O(1) at end)O(n)O(1) each
LinkedListO(n)O(1) at endsO(1) at ends / via iterator; O(n) to findO(n)O(1) each
ArrayDequeO(1) ends onlyO(1) amortised at endsO(1) at endsO(n)O(1) each
PriorityQueuepeek O(1)O(log n)poll O(log n), remove(x) O(n)O(n)unsorted
HashSet / LinkedHashSet—O(1) avgO(1) avgO(1) avgO(capacity) / O(n)
TreeSetfirst/last O(log n)O(log n)O(log n)O(log n)sorted
HashMap / LinkedHashMapO(1) avgO(1) avgO(1) avgkey O(1) avg, value O(n)O(capacity) / O(n)
TreeMapO(log n)O(log n)O(log n)key O(log n), value O(n)sorted

Iterating a HashMap visits every bucket, so a map that once held a million entries and is now nearly empty still iterates slowly. LinkedHashMap follows its linked list and costs only O(n).

Order in practice

List<String> in = List.of("banana", "apple", "cherry", "date");
new LinkedHashSet<>(in);     // [banana, apple, cherry, date]  insertion order
new TreeSet<>(in);           // [apple, banana, cherry, date]  sorted
PriorityQueue<Integer> pq = new PriorityQueue<>(List.of(5, 1, 8, 3, 2));
System.out.println(pq);      // [1, 2, 8, 3, 5]  heap layout, not sorted!
pq.poll();                   // 1

// LRU cache in 5 lines: access order + evict eldest
Map<String, Integer> lru = new LinkedHashMap<>(16, 0.75f, true) {
    protected boolean removeEldestEntry(Map.Entry<String, Integer> e) { return size() > 3; }
};
lru.put("a", 1); lru.put("b", 2); lru.put("c", 3);
lru.get("a");                // "a" becomes most recent
lru.put("d", 4);             // evicts "b", the least recently used
System.out.println(lru);     // {c=3, a=1, d=4}

// Java 21 sequenced collections
List<Integer> seq = new ArrayList<>(List.of(1, 2, 3));
seq.getFirst(); seq.getLast(); seq.reversed();   // 1, 3, [3, 2, 1]

How to choose

Need key -> value? yes -> sorted keys / floor-ceiling? TreeMap keep insertion order / LRU? LinkedHashMap many threads? ConcurrentHashMap otherwise HashMap no -> unique elements only? sorted? TreeSet keep insertion order? LinkedHashSet otherwise HashSet always take the smallest/largest next? PriorityQueue stack or FIFO queue? ArrayDeque otherwise (a plain list) ArrayList

LinkedList almost never wins: even "insert in the middle" needs an O(n) walk to find the spot, and ArrayList's System.arraycopy shift is very fast in practice. Legacy classes Vector, Stack and Hashtable synchronize every method; use ArrayList, ArrayDeque and HashMap/ConcurrentHashMap instead.

Immutable and unmodifiable collections

String[] arr = {"x", "y", "z"};
List<String> asList = Arrays.asList(arr);
asList.set(0, "X");          // ok -- and it writes through: arr is now [X, y, z]
asList.add("w");             // UnsupportedOperationException (fixed size)
Arrays.asList("a", null);    // [a, null] -- nulls fine
Arrays.asList(new int[]{1, 2, 3}).size();   // 1 !  a List<int[]> with one element

List<String> backing = new ArrayList<>(List.of("x", "y", "z"));
List<String> unmod = Collections.unmodifiableList(backing);
unmod.set(0, "X");           // UnsupportedOperationException
backing.add("w");
System.out.println(unmod);   // [x, y, z, w]  -- a read-only VIEW, sees changes

List<String> copy = List.copyOf(backing);  // real immutable snapshot (Java 10)
List<String> of = List.of("x", "y", "z");  // Java 9
of.set(0, "X");              // UnsupportedOperationException
List.of("a", null);          // NullPointerException
of.contains(null);           // NullPointerException (even asking!)
Set.of("a", "a");            // IllegalArgumentException: duplicate element
Stream.of(1, 2).toList().add(3);   // UnsupportedOperationException (Java 16)
Arrays.asListCollections.unmodifiableListList.of / List.copyOf
add / removeThrowsThrowsThrows
setAllowed (writes to the array)ThrowsThrows
NullsAllowedAllowedRejected (NPE)
Changes underneath show?Yes (array-backed)Yes (it is a view)No — truly immutable
Since1.21.29 / 10
Interview angle: "ArrayList vs LinkedList?" — ArrayList for almost everything: O(1) random access, compact memory, cache friendly; LinkedList only if you truly add/remove at both ends through an iterator (and then ArrayDeque is usually better). "HashMap vs TreeMap vs LinkedHashMap?" — O(1) unordered vs O(log n) sorted vs O(1) insertion/access order. "List.of vs unmodifiableList?" — immutable copy with no nulls vs read-only view that still reflects the original. Note that immutability is shallow: a List.of(mutableObject) still lets you change the object.

20. How HashMap Works Inside

In plain words

A HashMap is an array of buckets. To store a key, Java turns it into a number (its hash), uses that number to pick a bucket index, and puts the entry in that bucket. To find the key later it repeats the same math and looks in just that one bucket — which is why get and put are O(1) on average.

When two keys land in the same bucket (a collision), the bucket holds a small linked list of entries, and if that list grows long (and the table is big enough) it becomes a balanced tree.

A coat check with numbered hooks. The attendant computes a hook number from your ticket, so she walks straight to one hook instead of searching the whole room. If two coats end up on the same hook, she checks each ticket on that hook — still quick as long as hooks rarely get crowded.

hash = h ^ (h >>> 16) index = (n - 1) & hash index 0 4 9 13 15 n=16 null=v null key always slot 0 "cat"=3 single Node "apple"=1 next "grape"=7 collision: linked list (new nodes go at the tail) TreeNode TreeNode TreeNode red-black tree bin 9th node and n >= 64 Node = { hash, key, value, next }. Empty slots cost one null reference each. Resize doubles n when size > n x 0.75 (16 -> 32 after the 13th entry).
A HashMap with 16 buckets. Most buckets hold zero or one entry; collisions form a short list; a very crowded bucket becomes a tree.

Step 1: hashing and spreading

// Inside HashMap (Java 8+)
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// Bucket index: (n - 1) & hash       where n = table length, always a power of 2
  • Why (n - 1) & hash? When n is a power of two, n - 1 is all ones in binary (16 - 1 = 1111), so the AND keeps just the low bits: the same result as hash % n for non-negative numbers, but faster, and never negative even when hashCode() is negative.
  • Why spread with h ^ (h >>> 16)? A small table only looks at the lowest few bits. Keys whose hash codes differ only in the high bits would all collide. XOR-ing the top 16 bits into the bottom 16 lets high bits influence the index.
int h = "apple".hashCode();        // 93029210
int spread = h ^ (h >>> 16);       // 93030097
int index = spread & (16 - 1);     // 1

int a = 0x10000, b = 0x20000;      // differ only in high bits
(a & 15) + "," + (b & 15)                          // "0,0"  -- both in bucket 0
(spread(a) & 15) + "," + (spread(b) & 15)          // "1,2"  -- spread apart

Step 2: put and get

put(key, value) 1. table empty? -> allocate it now (lazy: new HashMap() has no array until the first put) 2. i = (n - 1) & hash(key) 3. bucket i empty? -> place a new Node there 4. else walk the bucket: same hash AND (same reference OR key.equals(k))? -> replace value, return old value otherwise append a new Node at the TAIL 5. bucket now has 9+ nodes? -> treeify (or resize if the table is under 64) 6. ++size > threshold? -> resize (double the table) get(key): compute i the same way, walk that bucket comparing hash first (cheap), then equals.

That is why hashCode and equals must agree: hashCode picks the bucket, equals picks the entry within it. put returns the previous value (null if new): pm.put("x", 1) gives null, then pm.put("x", 2) gives 1.

Step 3: load factor and resize

The load factor (default 0.75) is how full the table may get. The threshold is capacity × loadFactor. When size goes above it, the table doubles and every entry is moved. 0.75 is a trade-off: lower wastes memory, higher means more collisions.

HashMap<Integer, String> m = new HashMap<>();   // table is still null here
// after 1st put:  capacity=16 threshold=12
// after 12th put: capacity=16 threshold=12
// after 13th put: capacity=32 threshold=24   <- resized

new HashMap<>(100);   // capacity rounds UP to a power of two: 128 (threshold 96)

On a resize from n to 2n, each entry either stays at index i or moves to i + n, decided by the single bit hash & n. Java 8 splits each bucket into a "lo" and "hi" list and keeps the relative order. If you know the size in advance, pre-size: new HashMap<>(expected * 4 / 3 + 1), or since Java 19 HashMap.newHashMap(expected), to avoid repeated resizing.

Step 4: collisions and treeify

A bucket starts as a linked list. When a put makes a bucket reach 9 nodes (it already had 8 = TREEIFY_THRESHOLD), HashMap wants to turn it into a red-black tree so lookups in that bucket become O(log n) instead of O(n). But if the whole table is smaller than 64 (MIN_TREEIFY_CAPACITY), it resizes instead — a small table probably just needs more buckets. A tree shrinks back to a list when a resize split leaves 6 or fewer nodes (UNTREEIFY_THRESHOLD).

// Every key has the same hashCode -> all land in ONE bucket
record BadKey(int id) implements Comparable<BadKey> {
    @Override public int hashCode() { return 42; }
    @Override public int compareTo(BadKey o) { return Integer.compare(id, o.id); }
}

// Table already at 64 (new HashMap<>(64)); bucket type read via reflection:
// nodes=8  bucket=Node       (linked list)
// nodes=9  bucket=TreeNode   <- 9th node triggers treeify

// Default table of 16:
// nodes=8  cap=16 bucket=Node
// nodes=9  cap=32 bucket=Node       <- table too small: resize instead
// nodes=10 cap=64 bucket=Node       <- resize again
// nodes=11 cap=64 bucket=TreeNode   <- now treeify

(The bucket type was read with reflection and --add-opens java.base/java.util=ALL-UNNAMED; do not do this in real code.) Trees order nodes by hash, then by compareTo if the keys are Comparable, so making keys Comparable helps in the worst case. Treeify exists mainly as a defence against hash-flooding attacks, where someone sends many keys with the same hash to make your server do O(n) work per lookup.

The null key

HashMap<String, Integer> nm = new HashMap<>();
nm.put(null, 1); nm.put(null, 2);
nm.get(null);                                    // 2, size 1, stored in bucket 0 (hash(null) = 0)
new Hashtable<String, Integer>().put(null, 1);          // NullPointerException
new ConcurrentHashMap<String, Integer>().put(null, 1);  // NullPointerException

ConcurrentHashMap forbids null because get(k) == null would be ambiguous between threads: "absent" or "mapped to null"? You cannot safely follow up with containsKey when another thread may change the map in between.

Why keys should be immutable

final class MutableKey {
    int id;
    MutableKey(int id) { this.id = id; }
    @Override public boolean equals(Object o) { return o instanceof MutableKey m && m.id == id; }
    @Override public int hashCode() { return Integer.hashCode(id); }
}
Map<MutableKey, String> mm = new HashMap<>();
MutableKey key = new MutableKey(1);
mm.put(key, "data");
key.id = 2;                           // hash changes, but the entry stays in the OLD bucket
mm.get(key);                          // null  -- looks in the bucket for hash(2)
mm.get(new MutableKey(1));            // null  -- right bucket, but equals fails (id is now 2)
mm.size();                            // 1     -- the entry is stuck: a memory leak
mm.containsValue("data");             // true

Use immutable keys: String, Integer, records with immutable fields, enums. String also caches its hash code, which makes it a fast key.

Java 7 vs Java 8

Java 7Java 8+
Bucket structureLinked list only (worst case O(n))List, turns into red-black tree (worst case O(log n))
Insert position in bucketHead of listTail of list
ResizeRehash each entry; list order reversedSplit into lo/hi lists by hash & oldCap; order kept
Concurrent misuseHead insertion during a concurrent resize could form a cycle, so get looped forever (100% CPU)No cycle, but still lost updates and corrupted state
Hash spreadingSeveral shifts and XORsOne XOR: h ^ (h >>> 16)
Entry classEntryNode and TreeNode

Not thread-safe

Two threads calling put at the same time can overwrite each other's entries, lose updates to size, or corrupt a bucket during resize. Options: ConcurrentHashMap (the right answer: CAS on empty buckets plus a lock on one bucket's head node, lock-free reads) or Collections.synchronizedMap (one lock for the whole map, slower). Hashtable is legacy.

Interview angle: "How does HashMap work?" is one of the most asked Java questions. Walk through: hashCode → spread → (n - 1) & hash → bucket → compare hash then equals → tail insert → treeify at the 9th node if table ≥ 64 → resize past 0.75 × capacity by doubling. Then the follow-ups: why power-of-two sizes, why equals/hashCode must be consistent, what a mutable key does, the null key in bucket 0, the Java 7 infinite loop, and why ConcurrentHashMap for threads. Average O(1), worst case O(log n) per bucket since Java 8.

21. Comparable, Comparator & Sorting

In plain words

To sort objects, Java needs to know how to compare two of them. Comparable is the object's own built-in, "natural" order: the class implements compareTo (Strings sort alphabetically, numbers by value). A Comparator is a separate rule you pass in from the outside, so you can sort the same objects many different ways.

Both return an int: negative if the first is smaller, zero if equal, positive if the first is bigger.

Books on a shelf. Comparable is the order printed on the spine — the library's call number, one fixed order. A Comparator is you deciding today to arrange them by colour, or by height, without changing the books.

Comparable<T>Comparator<T>
Packagejava.langjava.util
Methodint compareTo(T other)int compare(T a, T b)
Where it livesInside the class being sortedOutside: a lambda, a constant, a separate class
How many ordersOne (natural order)As many as you like
Used byCollections.sort(list), TreeSet, TreeMap, PriorityQueue by defaultlist.sort(cmp), new TreeMap<>(cmp), stream.sorted(cmp)
Use whenThere is one obvious order and you own the classSeveral orders, or you can't change the class

Both in code

record Employee(String name, String dept, int salary) implements Comparable<Employee> {
    @Override public int compareTo(Employee o) { return name.compareTo(o.name); }  // natural: by name
}
List<Employee> staff = new ArrayList<>(List.of(
    new Employee("Maya", "Eng", 120), new Employee("Arun", "Sales", 90),
    new Employee("Zoe", "Eng", 150),  new Employee("Liam", "Sales", 90),
    new Employee("Chen", "Eng", 120)));

Collections.sort(staff);   // natural order -> [Arun, Chen, Liam, Maya, Zoe]

// Comparator chain: dept A-Z, then salary high-to-low, then name A-Z
staff.sort(Comparator.comparing(Employee::dept)
        .thenComparing(Employee::salary, Comparator.reverseOrder())
        .thenComparing(Employee::name));
// Eng 150 Zoe
// Eng 120 Chen
// Eng 120 Maya
// Sales 90 Arun
// Sales 90 Liam

The Comparator toolkit (Java 8+)

MethodWhat it does
Comparator.comparing(keyFn)Compare by an extracted key (which must be Comparable)
comparing(keyFn, keyCmp)Compare by a key using a given comparator for the key
comparingInt / Long / DoubleSame, without boxing — prefer for numbers
.thenComparing(...)Tie-breaker, used only when earlier comparisons return 0
.reversed()Reverse the whole chain so far
Comparator.naturalOrder() / reverseOrder()Natural order and its reverse
nullsFirst(cmp) / nullsLast(cmp)Handle null elements instead of throwing NPE
List<String> withNull = new ArrayList<>(Arrays.asList("pear", null, "apple"));
withNull.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
// [null, apple, pear]
withNull.sort(Comparator.nullsLast(Comparator.<String>naturalOrder().reversed()));
// [pear, apple, null]

TreeMap<String, Integer> ci = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
ci.put("Apple", 1); ci.put("apple", 2);
// {Apple=2}  -- the comparator says they're equal, so it's the SAME key

Trap: comparing(A).thenComparing(B).reversed() reverses both A and B. To reverse only one key, pass Comparator.reverseOrder() as its second argument, as in the salary example above.

Sorting stability

A sort is stable if elements that compare equal keep their original relative order. List.sort, Collections.sort, Arrays.sort(Object[]) and Stream.sorted use TimSort, which is stable and O(n log n) (close to O(n) on nearly sorted data). Arrays.sort(int[]) uses dual-pivot quicksort, not stable — which does not matter because equal primitives are indistinguishable.

// Sort by name first, then by salary: equal salaries stay in name order
staff.sort(Comparator.naturalOrder());
staff.sort(Comparator.comparingInt(Employee::salary));
// [90:Arun, 90:Liam, 120:Chen, 120:Maya, 150:Zoe]
Before (sorted by name): Arun90 Chen120 Liam90 Maya120 Zoe150 Stable sort by salary: Arun90 Liam90 Chen120 Maya120 Zoe150 \____/ ties keep A-before-L and C-before-M

The a - b overflow bug

Comparator<Integer> bad  = (x, y) -> x - y;          // looks clever, is broken
Comparator<Integer> good = Integer::compare;

int big = 2_000_000_000, neg = -2_000_000_000;
bad.compare(neg, big);     // 294967296   positive?! -4e9 overflowed and wrapped
good.compare(neg, big);    // -1

List<Integer> nums = new ArrayList<>(List.of(big, neg, 0));
nums.sort(bad);            // [2000000000, -2000000000, 0]   wrong
nums.sort(good);           // [-2000000000, 0, 2000000000]   right

Use Integer.compare, Long.compare, Double.compare or Comparator.comparingInt. Subtraction is only safe when you know both values are small and non-negative — and it's not worth the risk.

The comparator contract

  • Antisymmetric: sign of compare(a, b) = minus sign of compare(b, a).
  • Transitive: if a > b and b > c, then a > c.
  • Consistent: if compare(a, b) == 0, then a and b compare the same against everything else.
  • Recommended: consistent with equals (compare == 0 exactly when equals is true). Sorted collections use only the comparator, so if they disagree, sets behave oddly:
new HashSet<>(List.of(new BigDecimal("1.0"), new BigDecimal("1.00"))).size();   // 2 (equals: scale differs)
new TreeSet<>(List.of(new BigDecimal("1.0"), new BigDecimal("1.00"))).size();   // 1 (compareTo: same value)

Breaking the contract (for example a comparator that returns random results or mixes up signs) can make TimSort throw IllegalArgumentException: Comparison method violates its general contract! — but only for some inputs, so it shows up as a rare production bug.

Interview angle: "Comparable vs Comparator?" — one natural order inside the class vs any number of external orders. Then show a chain with comparing/thenComparing/reversed. Expected follow-ups: "Is Java's sort stable?" (yes for objects, TimSort), "What's wrong with a - b?" (overflow), and "What if compareTo is inconsistent with equals?" (TreeSet/TreeMap treat compare-equal elements as duplicates).

22. Iterators, Fail-fast & Fail-safe

In plain words

An Iterator is a cursor that walks through a collection one element at a time: hasNext() asks "is there more?", next() gives the next one, and remove() deletes the one you just got. The for-each loop uses an iterator behind the scenes.

If you change a normal collection (add or remove) while iterating it without going through the iterator, Java's iterators notice and throw ConcurrentModificationException. That is fail-fast. Concurrent collections instead let the loop continue safely — fail-safe (or "weakly consistent").

Reading a numbered list aloud while someone else crosses lines out. A fail-fast reader stops and shouts "the list changed under me!". A fail-safe reader either works from a photocopy taken at the start, or just carries on and may or may not see the latest edits.

Iterator and ListIterator

// for (String s : list) { ... }  is compiled to roughly:
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String s = it.next();
    ...
}

// ListIterator: both directions, index, set and add while walking
List<String> words = new ArrayList<>(List.of("a", "b", "c"));
ListIterator<String> li = words.listIterator();
while (li.hasNext()) {
    int idx = li.nextIndex();
    String w = li.next();
    li.set(w.toUpperCase());         // replace the element just returned
    if (idx == 0) li.add("a2");      // insert after it; the cursor skips past it
}
// words = [A, a2, B, C]
StringBuilder back = new StringBuilder();
while (li.hasPrevious()) back.append(li.previous());
// back = "CBa2A"
IteratorListIterator
Works onEvery CollectionList only
DirectionForwardForward and backward (hasPrevious, previous)
Modifyremove()remove(), set(e), add(e)
PositionNonextIndex(), previousIndex()

ConcurrentModificationException and modCount

Each ArrayList/HashMap keeps a counter, modCount, that goes up on every structural change (add, remove, clear, resize — but not set). An iterator copies it into expectedModCount when created. Every next() compares the two; if they differ, it throws.

list = [1, 2, 3, 4, 5] modCount = 0 iterator created expectedModCount = 0 next() -> 1 0 == 0 ok next() -> 2 0 == 0 ok list.remove(2) modCount = 1 (NOT through the iterator) next() 1 != 0 -> ConcurrentModificationException it.remove() instead removes AND sets expectedModCount = modCount -> safe
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
for (Integer x : list) if (x == 2) list.remove(x);
// ConcurrentModificationException  (list is now [1, 3, 4, 5])

// The famous quirk: removing the SECOND-TO-LAST element does not throw
List<Integer> q = new ArrayList<>(List.of(1, 2, 3));
for (Integer x : q) if (x == 2) q.remove(x);
// no exception, q = [1, 3] -- but 3 was never visited!
// hasNext() is just "cursor != size": cursor=2, size=2, so the loop ends before next() can check.

Map<String, Integer> hm = new HashMap<>(Map.of("a", 1, "b", 2));
for (String k : hm.keySet()) hm.put(k + "x", 0);   // ConcurrentModificationException

The exception has "Concurrent" in its name but happens here in a single thread. Fail-fast is also only "best effort": with real multi-threading, it is not guaranteed to fire, so never rely on it for correctness — it exists to reveal bugs.

The right ways to remove while iterating

// 1. Iterator.remove()
Iterator<Integer> it = r.iterator();
while (it.hasNext()) if (it.next() % 2 == 0) it.remove();
// [1, 2, 3, 4, 5] -> [1, 3, 5]

// 2. removeIf (Java 8) -- shortest, and O(n) for ArrayList (one compaction pass)
r2.removeIf(x -> x % 2 == 0);                 // [1, 3, 5]

// 3. On maps, go through a view
scores.values().removeIf(v -> v < 50);        // {a=10, b=55, c=70} -> {b=55, c=70}
scores.entrySet().removeIf(e -> e.getKey().startsWith("tmp"));

// 4. Build a new collection (streams / filter) instead of mutating
List<Integer> odds = r.stream().filter(x -> x % 2 != 0).toList();

// 5. Index loop going BACKWARDS (removing forwards skips elements)
for (int i = list.size() - 1; i >= 0; i--) if (list.get(i) % 2 == 0) list.remove(i);

Removing with it.remove() in a loop over an ArrayList is O(n) per removal (each shifts the tail), so O(n²) overall; removeIf does it in one pass.

Fail-safe: CopyOnWriteArrayList and ConcurrentHashMap

// CopyOnWriteArrayList: every write copies the whole array; iterators use a SNAPSHOT
List<Integer> cow = new CopyOnWriteArrayList<>(List.of(1, 2, 3));
for (Integer x : cow) { if (x == 1) cow.add(99); System.out.print(x + " "); }
// prints: 1 2 3        (the snapshot doesn't see 99)
// cow afterwards: [1, 2, 3, 99]
cow.iterator().remove();   // UnsupportedOperationException -- snapshot iterators are read-only

// ConcurrentHashMap: weakly consistent iterators -- never throw CME
Map<String, Integer> chm = new ConcurrentHashMap<>(Map.of("a", 1, "b", 2, "c", 3));
for (String k : chm.keySet()) if (k.equals("a")) chm.remove("b");
// no exception; map is now {a=1, c=3}
Fail-fastFail-safe: snapshotFail-safe: weakly consistent
ExamplesArrayList, HashMap, HashSet, TreeMapCopyOnWriteArrayList, CopyOnWriteArraySetConcurrentHashMap, ConcurrentLinkedQueue, ConcurrentSkipListMap
Change during iterationThrows CME (best effort)Never throws; iterator doesn't see itNever throws; may or may not see it
Iterates overThe live collectionA copy of the array at iterator creationThe live structure
CostCheapEvery write copies O(n) — for read-mostly lists (listeners)Cheap; fine-grained concurrency
iterator.remove()SupportedUnsupportedSupported
Interview angle: "What is ConcurrentModificationException and how do you avoid it?" — explain modCount vs expectedModCount, then offer Iterator.remove, removeIf, or a concurrent collection. "Fail-fast vs fail-safe?" — live check that throws vs copy/weakly-consistent iterators that don't. The second-to-last-element quirk is a favourite trick question: it shows you know hasNext() doesn't check modCount.

23. Optional

In plain words

Optional<T> (Java 8) is a small box that either holds one value or is empty. A method returns Optional<User> instead of a User that might be null, so the type itself tells the caller "there may be no result — handle that case". It is meant mainly as a return type.

Instead of if (x != null) checks everywhere, you chain steps like map, filter and orElse, and the empty case flows through automatically.

A parcel locker. You get a slip that says "locker 12" — it might contain your package or be empty, and the slip makes you check before walking away. Compare with null, which is like being handed nothing and finding out only when you try to open it.

Creating one

Optional.of("x");            // Optional[x]      value must be non-null
Optional.of(null);           // NullPointerException
Optional.ofNullable(null);   // Optional.empty   use when the value might be null
Optional.empty();            // Optional.empty

static Optional<User> findById(int id) {
    return Optional.ofNullable(db.get(id));   // Map.get returns null when absent
}

Transforming: map, flatMap, filter

findById(id) -> map(User::address) -> map(Address::city) -> orElse("unknown") Optional[User] Optional[Address] Optional["Pune"] "Pune" Optional[User] Optional.empty (null) Optional.empty "unknown" Optional.empty Optional.empty Optional.empty "unknown" Each step runs only if there is a value; a null result turns the chain empty.
record Address(String city) {}
record User(String name, Address address, String email) {
    Optional<String> emailOpt() { return Optional.ofNullable(email); }
}
// db: 1 -> Asha (Pune, asha@x.io),  2 -> Ben (no address, no email)

findById(1).map(User::address).map(Address::city).orElse("unknown");   // "Pune"
findById(2).map(User::address).map(Address::city).orElse("unknown");   // "unknown"
findById(9).map(User::address).map(Address::city).orElse("unknown");   // "unknown"

// map vs flatMap: use flatMap when the function ALREADY returns an Optional
findById(1).map(User::emailOpt);       // Optional[Optional[asha@x.io]]  -- nested
findById(1).flatMap(User::emailOpt);   // Optional[asha@x.io]            -- flat

findById(1).filter(u -> u.name().startsWith("A"))
           .ifPresentOrElse(u -> System.out.println("hello " + u.name()),
                            () -> System.out.println("nobody"));        // hello Asha

Getting the value out

MethodIf presentIf emptySince
orElse(x)valuex (always evaluated!)8
orElseGet(() -> x)valuecalls the supplier8
orElseThrow(() -> ex)valuethrows your exception8
orElseThrow()valueNoSuchElementException10
get()valueNoSuchElementException — same as above, but the name hides it8
ifPresent(fn) / ifPresentOrElse(fn, run)runs fnnothing / runs run8 / 9
or(() -> otherOptional)itselfthe other Optional9
stream()1-element streamempty stream9
isPresent() / isEmpty()true / falsefalse / true8 / 11

orElse vs orElseGet — the side-effect trap

The argument to orElse is an ordinary expression, so Java evaluates it before calling the method — even when the Optional has a value and the result is thrown away. orElseGet takes a Supplier and calls it only when empty.

static int calls = 0;
static String expensiveDefault() {
    calls++;
    System.out.println("  expensiveDefault() called");
    return "default";
}

System.out.println("orElse on present value:");
String v1 = Optional.of("real").orElse(expensiveDefault());
System.out.println("  result=" + v1);
System.out.println("orElseGet on present value:");
String v2 = Optional.of("real").orElseGet(OptDemo::expensiveDefault);
System.out.println("  result=" + v2);
System.out.println("calls=" + calls);

// Output:
// orElse on present value:
//   expensiveDefault() called        <- ran for nothing
//   result=real
// orElseGet on present value:
//   result=real                      <- supplier never called
// calls=1

Rule: orElse for constants (orElse("unknown"), orElse(0)); orElseGet when the default costs something or has side effects (database call, new object, logging). A real bug: findUser(id).orElse(createUser(id)) creates a user in the database every time.

orElseThrow and chaining alternatives

User u = findById(9).orElseThrow(() -> new NoSuchElementException("user 9 not found"));
// NoSuchElementException: user 9 not found

findById(9).orElseThrow();   // NoSuchElementException: No value present
findById(9).get();           // NoSuchElementException: No value present

findById(9).or(() -> findById(2)).map(User::name).orElse("-");   // "Ben"

// Flatten a list of Optionals: keep only the present ones
List.of(1, 2, 9).stream()
    .map(OptDemo::findById)
    .flatMap(Optional::stream)                 // drops the empty ones
    .flatMap(u -> u.emailOpt().stream())
    .toList();                                 // [asha@x.io]

Anti-patterns

Don'tWhyDo instead
if (opt.isPresent()) return opt.get();Just a null check with extra stepsopt.orElse(...), map, ifPresent
opt.get() without checkingThrows on empty — the NPE problem again, renamedorElseThrow() (same behaviour, but the name admits it)
Optional fieldsOptional is not Serializable, adds an object per field, and wasn't designed for itNullable field; return Optional.ofNullable(field) from the getter
Optional parametersCallers must wrap; they can still pass null for the Optional itselfOverloads, or a nullable parameter checked inside
Optional<List<T>>Two ways to say "nothing"Return an empty list
Returning null from a method typed OptionalDefeats the whole pointReturn Optional.empty()
Optional<Integer> in hot pathsBoxing plus a wrapper objectOptionalInt/OptionalLong/OptionalDouble (e.g. IntStream.max())
== or synchronized on an OptionalIt is a value-based class; identity is not meaningfulequals: Optional.of("a").equals(Optional.of("a")) is true
OptionalInt max = IntStream.of().max();   // OptionalInt.empty
max.orElse(-1);                           // -1
Interview angle: "Why Optional?" — to make "maybe no result" explicit in a method's return type and push callers to handle it, reducing NPEs. "orElse vs orElseGet?" — eager vs lazy; describe the side-effect demo. "map vs flatMap?" — flatMap when the function already returns an Optional, to avoid Optional<Optional<T>>. "Where should you NOT use it?" — fields, parameters, collections, and anywhere get() without a check sneaks in. Brian Goetz's summary: Optional is for return types where "no result" is a normal outcome.
Part 4 — Functional Java

24. Lambdas & Functional Interfaces

In plain words

A lambda (Java 8) is a short, nameless function you can store in a variable or pass to a method: (a, b) -> a + b. It lets you hand behaviour around the way you hand data around.

A lambda needs a "slot" to fit into: a functional interface, which is simply an interface with exactly one abstract method. The lambda becomes the body of that one method. Runnable, Comparator and Function are all functional interfaces.

A job form with one blank line: "What should I do with each item? ______". The lambda is what you write on that line. The form (interface) decides what goes in and what comes out; you only fill in the action.

Syntax, from long to short

@FunctionalInterface
interface Calculator { int apply(int a, int b); }   // one abstract method

// Before Java 8: an anonymous class
Calculator addOld = new Calculator() {
    @Override public int apply(int a, int b) { return a + b; }
};

// Lambda forms
Calculator add = (a, b) -> a + b;                    // types inferred, expression body
Calculator mul = (int a, int b) -> { return a * b; }; // explicit types, block body needs return
Runnable hello = () -> System.out.println("hi");     // no parameters: empty ()
Function<String, Integer> len = s -> s.length();     // one parameter: () optional
BiFunction<Integer, Integer, Integer> sum = (var x, var y) -> x + y;  // var allowed (Java 11)

add.apply(2, 3);   // 5
mul.apply(2, 3);   // 6

The compiler works out the lambda's type from the target type — the interface it is assigned or passed to. The same lambda text () -> 42 can be a Supplier<Integer> or a Callable<Integer> depending on where it lands. A lambda on its own, with no target, does not compile (var f = x -> x; is an error).

@FunctionalInterface

The annotation is optional, but it makes the compiler check that the interface really has exactly one abstract method. If someone later adds a second abstract method, the build fails instead of every lambda breaking silently. default and static methods do not count, and neither do methods that match Object's public methods (that is why Comparator can declare equals and still be functional).

The built-in functional interfaces (java.util.function)

InterfaceMethodShapeTypical use
Function<T, R>R apply(T t)T in, R outmap, converting values
BiFunction<T, U, R>R apply(T t, U u)two in, one outMap.merge, replaceAll
Supplier<T>T get()nothing in, T outlazy values, factories, orElseGet
Consumer<T>void accept(T t)T in, nothing outforEach, side effects
BiConsumer<T, U>void accept(T t, U u)two in, nothing outMap.forEach
Predicate<T>boolean test(T t)T in, yes/no outfilter, removeIf
UnaryOperator<T>T apply(T t)Function where in and out are the same typeList.replaceAll, Stream.iterate
BinaryOperator<T>T apply(T a, T b)two T in, T outreduce
Function<String, Integer> len    = s -> s.length();
Supplier<List<String>> newList   = ArrayList::new;
Consumer<String> printer         = s -> System.out.println("> " + s);
Predicate<String> isEmpty        = String::isEmpty;
UnaryOperator<String> upper      = String::toUpperCase;
BinaryOperator<Integer> max      = Math::max;

len.apply("java");      // 4
newList.get().size();   // 0
printer.accept("hi");   // prints "> hi"
isEmpty.test("");       // true
upper.apply("abc");     // "ABC"
max.apply(3, 9);        // 9

Primitive specialisations — avoid boxing

Generics only work with objects, so Function<Integer, Integer> boxes every int into an Integer. For hot code Java offers primitive versions that take and return real int/long/double:

FamilyExamples
PredicateIntPredicate, LongPredicate, DoublePredicate
Function (primitive in)IntFunction<R>, IntUnaryOperator, IntToLongFunction
Function (primitive out)ToIntFunction<T>, ToLongFunction<T>, ToDoubleFunction<T>
Supplier / ConsumerIntSupplier, BooleanSupplier, IntConsumer, ObjIntConsumer<T>
OperatorsIntBinaryOperator, LongBinaryOperator, DoubleUnaryOperator
IntPredicate even      = n -> n % 2 == 0;     // even.test(4)          -> true
IntUnaryOperator sq    = n -> n * n;          // sq.applyAsInt(7)      -> 49
ToIntFunction<String> l = String::length;     // l.applyAsInt("abc")   -> 3
IntBinaryOperator plus = Integer::sum;        // plus.applyAsInt(2, 3) -> 5

Note the method names change (applyAsInt, test, getAsInt) so the compiler never has to box.

Composing functions

Default methods on these interfaces let you glue small functions into bigger ones without writing a new lambda.

f.andThen(g) : x -> f -> g -> result "do f, THEN g" f.compose(g) : x -> g -> f -> result "run g FIRST, feed it into f" plus2 = x + 2, times3 = x * 3, input 1 plus2.andThen(times3) : (1 + 2) * 3 = 9 plus2.compose(times3) : (1 * 3) + 2 = 5
Function<Integer, Integer> plus2  = x -> x + 2;
Function<Integer, Integer> times3 = x -> x * 3;
plus2.andThen(times3).apply(1);   // 9
plus2.compose(times3).apply(1);   // 5
Function.<Integer>identity().apply(42);   // 42 -- returns its input

Predicate<String> longWord = s -> s.length() > 3;
Predicate<String> startsA  = s -> s.startsWith("a");
longWord.and(startsA).test("apple");           // true  (both)
longWord.or(startsA).test("an");               // true  (either)
longWord.negate().test("apple");               // false
Predicate.not(String::isBlank).test(" ");      // false  (Java 11; handy in filter)

Consumer<List<String>> addA = l -> l.add("a");
List<String> list = new ArrayList<>();
addA.andThen(l -> l.add("b")).accept(list);    // list is [a, b]

and / or short-circuit exactly like && / ||: the second predicate is not called if the first already decides the answer.

Capturing variables: "effectively final"

A lambda can read local variables from the method around it, but only if they are final or effectively final — assigned once and never changed afterwards.

int base = 10;
Function<Integer, Integer> addBase = x -> x + base;   // OK: base never changes
addBase.apply(5);                                     // 15

int count = 0;
List.of("a", "b").forEach(s -> count++);
// error: local variables referenced from a lambda expression
//        must be final or effectively final

Why the rule? Local variables live on the thread's stack and vanish when the method returns, but the lambda may run later or on another thread. So Java copies the value into the lambda. If the variable could still change, the copy and the original would disagree — the rule removes that confusion (and data races on locals). Fields and array elements are not copied (the lambda holds a reference to the object), so they can be changed:

int[] counter = {0};                                 // the array reference is effectively final
List.of("a", "b", "c").forEach(s -> counter[0]++);   // compiles; counter[0] == 3
// Works, but it is a code smell and NOT thread-safe. Prefer stream.count() or an AtomicInteger.

this: lambda vs anonymous class

public class Demo {
    void show() {
        Runnable lambda = () -> System.out.println(this.getClass().getSimpleName());
        Runnable anon = new Runnable() {
            public void run() { System.out.println(this.getClass().getName()); }
        };
        lambda.run();   // Demo      -- 'this' is the enclosing object
        anon.run();     // Demo$1    -- 'this' is the anonymous object itself
    }
}
LambdaAnonymous class
thisThe enclosing instanceThe anonymous object
New scope?No — cannot redeclare a local variable name from the outer methodYes — can shadow outer names
State (fields)NoneCan have fields and several methods
TargetOnly functional interfacesAny interface or (abstract) class
Compiled toAn invokedynamic call site; no extra .class file; stateless lambdas are reused as one objectA separate Outer$1.class, new object per evaluation
Interview angle: "What is a functional interface?" — one abstract method; @FunctionalInterface is a compile-time check. "Why effectively final?" — the value is copied into the lambda, so it must not change. "Can a lambda throw a checked exception?" — only if the interface method declares it; Function.apply does not, so you must catch inside or write your own interface like ThrowingFunction.
Best practice: keep lambdas to one or two lines; if it grows, move it into a named private method and use a method reference. Prefer the standard interfaces over inventing your own, and use the primitive versions (IntPredicate, ToIntFunction) in hot paths.

25. The Streams API

In plain words

A stream (Java 8) is a way to describe "take these items, keep some, change them, and combine the result" as a chain of steps, instead of writing the loop yourself. A stream does not store data; it pulls items from a source (a collection, an array, a file, a generator) and pushes them through the steps.

Every pipeline has three parts: a source, zero or more intermediate operations (filter, map, sorted — each returns a new stream), and exactly one terminal operation (collect, count, forEach) that actually runs the whole thing and produces a result.

A factory conveyor belt. Setting up the machines (intermediate steps) does nothing on its own. Only when someone at the end presses "start, I want the boxes" (the terminal operation) do items begin to move — one by one through every machine.

Source list.stream() filter stateless map stateless sorted stateful (barrier) collect terminal intermediate: lazy, each returns a new Stream runs everything not copied Elements travel one at a time through filter and map; sorted must collect them all first
A stream pipeline. Nothing happens until the terminal operation asks for a result.

Building a pipeline

record Emp(String name, String dept, int salary) {}

List<String> highPaidEng = emps.stream()                 // source
    .filter(e -> e.dept().equals("Eng"))                  // intermediate
    .filter(e -> e.salary() >= 100)
    .map(Emp::name)                                       // Emp -> String
    .sorted()                                             // intermediate, stateful
    .toList();                                            // terminal (Java 16)

// Other sources
Stream.of("a", "b");                  Arrays.stream(intArray);
IntStream.range(0, 10);               // 0..9
Stream.iterate(1, n -> n * 2);        // infinite: 1, 2, 4, 8, ...
Stream.generate(Math::random);        // infinite
Files.lines(path);                    // lines of a file (close it: try-with-resources)
"text".chars();                       // IntStream of chars

Laziness — proved with peek

peek runs an action on each element as it passes. It lets us see when work really happens:

Stream<String> s = Stream.of("a", "bb", "ccc")
    .peek(x -> System.out.println("peek " + x));
System.out.println("pipeline built, nothing printed yet");
System.out.println("count of long = " + s.filter(x -> x.length() > 1).count());
pipeline built, nothing printed yet
peek a
peek bb
peek ccc
count of long = 2

Now watch how elements move. Streams process vertically — one element goes through every step before the next one starts — and findFirst stops as soon as it has an answer:

Optional<Integer> first = Stream.of(1, 2, 3, 4, 5, 6)
    .peek(n -> System.out.println("saw " + n))
    .filter(n -> n % 2 == 0)
    .map(n -> n * 10)
    .findFirst();
System.out.println("result " + first.get());

// Output:
// saw 1
// saw 2
// result 20        -- elements 3..6 were never touched
peek trap: since Java 9, count() may skip the pipeline entirely if it can know the size up front. Stream.of("a", "b", "c").peek(System.out::println).count() prints nothing and returns 3. Use peek only for debugging, never for real side effects.

Stateless vs stateful operations

KindOperationsWhat it means
Statelessfilter, map, flatMap, peek, mapToIntEach element handled on its own; nothing remembered. Cheap, parallel-friendly.
Statefulsorted, distinct, limit, skip, takeWhile, dropWhileMust remember earlier elements. sorted must see all input before emitting anything, and needs O(n) memory.
Stream.of(3, 1, 2)
    .peek(n -> System.out.println("before sort " + n))
    .sorted()
    .forEach(n -> System.out.println("after sort " + n));
// before sort 3, before sort 1, before sort 2, after sort 1, after sort 2, after sort 3

Short-circuiting

A short-circuiting operation can finish without looking at every element — which is what makes infinite streams usable.

Stream.iterate(1, n -> n * 2).limit(5).toList();            // [1, 2, 4, 8, 16]
IntStream.iterate(1, n -> n + 1).anyMatch(n -> n * n > 50);  // true (stops at 8)
Stream.iterate(1, n -> n < 20, n -> n * 3).toList();        // [1, 3, 9]   (Java 9)
Stream.of(1, 2, 3, 10, 4).takeWhile(n -> n < 5).toList();   // [1, 2, 3]   (Java 9)
Stream.of(1, 2, 3, 10, 4).dropWhile(n -> n < 5).toList();   // [10, 4]
// Others: findFirst, findAny, allMatch, noneMatch

takeWhile is not filter: it stops at the first failure (the 4 after 10 is dropped).

map vs flatMap

map turns each element into exactly one element. flatMap turns each element into a stream of zero or more elements and flattens them into one stream.

map(List::size) [[1,2], [3], []] -> [2, 1, 0] one out per one in flatMap(List::stream) [[1,2], [3], []] -> [1, 2, 3] each list "opened up"
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]

Stream.of("hi there", "java")
    .flatMap(line -> Arrays.stream(line.split(" ")))
    .toList();                                     // [hi, there, java]
// Also: mapMulti (Java 16) -- a push-style flatMap that avoids creating small streams

reduce — fold everything into one value

int sum = Stream.of(1, 2, 3, 4).reduce(0, Integer::sum);      // 10   identity + accumulator
Optional<Integer> max = Stream.of(1, 7, 3).reduce(Integer::max); // Optional[7]  no identity
Stream.<Integer>empty().reduce(Integer::sum);                  // Optional.empty

// Three-arg form: result type differs from element type
int totalLen = Stream.of("ab", "cde")
    .reduce(0, (acc, w) -> acc + w.length(), Integer::sum);    // 5  (combiner used in parallel)

Rules for correct (and parallel-safe) reduce: the identity must really be neutral (0 for sum, 1 for product), and the operation must be associative ((a+b)+c == a+(b+c)). Subtraction is not, so a parallel reduce with (a, b) -> a - b gives wrong answers. For building collections, use collect, not reduce.

A stream can be used only once

Stream<Integer> once = Stream.of(1, 2, 3);
once.count();
once.count();   // IllegalStateException: stream has already been operated upon or closed

// Need it twice? Keep a Supplier and make a fresh stream each time
Supplier<Stream<Integer>> nums = () -> Stream.of(1, 2, 3);
nums.get().count(); nums.get().count();   // fine

Primitive streams and the cost of boxing

Stream<Integer> holds Integer objects; IntStream, LongStream and DoubleStream hold raw numbers and add math methods (sum, average, summaryStatistics, range).

IntStream.rangeClosed(1, 5).summaryStatistics();
// IntSummaryStatistics{count=5, sum=15, min=1, average=3.000000, max=5}
IntStream.of(3, 5).average().getAsDouble();           // 4.0
Stream.of("a", "bb").mapToInt(String::length).sum();  // 3   object -> primitive
IntStream.range(0, 3).boxed().toList();               // [0, 1, 2]  primitive -> object

On a 5-million-element List<Integer>, reduce(0, Integer::sum) (unbox, add, re-box every step) ran several times slower than mapToInt(Integer::intValue).sum() in our test, and a plain for loop over an int range was faster still. Exact numbers vary by machine and JIT warm-up, but the order holds: convert to a primitive stream as early as you can.

Parallel streams — and when they hurt

.parallel() (or collection.parallelStream()) splits the source into chunks and runs them on the shared ForkJoinPool.commonPool() (about one thread per CPU core), then combines results.

// BROKEN: many threads calling ArrayList.add at once
List<Integer> unsafe = new ArrayList<>();
IntStream.range(0, 10_000).parallel().forEach(unsafe::add);
// In our run, 15 of 20 trials lost elements or threw ArrayIndexOutOfBoundsException

// CORRECT: let the stream build the result
List<Integer> safe = IntStream.range(0, 10_000).parallel().boxed().toList();  // always 10000

IntStream.range(0, 8).parallel().forEach(i -> System.out.print(i + " "));
// e.g. 2 5 3 4 7 6 0 1   -- order is NOT kept (differs every run)
IntStream.range(0, 8).parallel().forEachOrdered(i -> System.out.print(i + " "));
// 0 1 2 3 4 5 6 7

Parallel hurts when:

  • The data is small (thousands of elements) — splitting and joining cost more than the work.
  • The source splits badly: LinkedList, Stream.iterate, I/O lines. ArrayList, arrays and IntStream.range split well.
  • The steps block (HTTP, DB calls) — they tie up the common pool that the whole JVM shares.
  • The pipeline has order-dependent ops (limit, findFirst, sorted) on an ordered stream, or shared mutable state / locks.
  • Boxing dominates the cost anyway.

It helps for large, CPU-heavy, independent work on an easily split source. Always measure (JMH) before and after.

Stream vs loop

Prefer a stream whenPrefer a loop when
The logic is a clear filter / map / group / collectYou need break, continue, early return with complex conditions
You want a declarative "what, not how" styleYou need checked exceptions, index access, or to change local variables
Grouping and aggregating (collectors do the hard part)Hot inner loops over primitive arrays where every nanosecond counts
You may want to try parallelism cheaplyDebugging step by step matters more than brevity
Interview angle: "Are streams lazy?" — yes, intermediate ops only build a recipe; the terminal op runs it, element by element. "Difference between map and flatMap?" "Can you reuse a stream?" "Is parallel always faster?" — no; give two reasons. "What is wrong with forEach(list::add)?" — side effects on shared state; use collect.
Best practice: no side effects inside lambdas (except in forEach at the end); one operation per line; filter before map and before sorted to shrink work early; close streams that wrap resources (Files.lines).

26. Collectors & Method References

In plain words

A collector is a recipe that tells collect(...) how to pour the stream's items into a final container: a list, a set, a map, a single string, a count, or a map of groups. The Collectors class has ready-made recipes you can nest inside each other.

A method reference (String::length) is a shorter way to write a lambda that only calls one existing method.

Sorting mail. toList is one big pile. groupingBy(city) is one pigeonhole per city. groupingBy(city, counting()) is a pigeonhole per city with just a number written on it instead of the letters.

All examples below use this data:

record Emp(String name, String dept, int salary) {}
List<Emp> emps = List.of(
    new Emp("Asha", "Eng", 120), new Emp("Ben", "Eng", 100),
    new Emp("Cara", "Sales", 80), new Emp("Dev", "Sales", 90),
    new Emp("Eli", "HR", 70));

toList: three versions, three behaviours

List<String> a = emps.stream().map(Emp::name).collect(Collectors.toList());
a.add("X");                  // works -- in practice an ArrayList (spec does not promise the type)

List<String> b = emps.stream().map(Emp::name).toList();         // Java 16
b.add("X");                  // UnsupportedOperationException -- unmodifiable

List<String> c = emps.stream().map(Emp::name).collect(Collectors.toUnmodifiableList()); // Java 10

Stream.of("a", null).toList();                                    // [a, null]  allows null
Stream.of("a", null).collect(Collectors.toUnmodifiableList());    // NullPointerException
Collectors.toList()Stream.toList()toUnmodifiableList()
Modifiable?Yes (not guaranteed)NoNo
NullsAllowedAllowedNPE
SinceJava 8Java 16Java 10

Swapping collect(Collectors.toList()) for .toList() during a refactor is a classic bug if some later code calls add on the result.

toMap and the duplicate-key trap

Map<String, Integer> byName = emps.stream()
    .collect(Collectors.toMap(Emp::name, Emp::salary));   // names are unique: fine

emps.stream().collect(Collectors.toMap(Emp::dept, Emp::name));
// IllegalStateException: Duplicate key Eng (attempted merging values Asha and Ben)

// Fix: a merge function says what to do when two values share a key
Map<String, Integer> deptTotal = emps.stream()
    .collect(Collectors.toMap(Emp::dept, Emp::salary, Integer::sum, TreeMap::new));
// {Eng=220, HR=70, Sales=170}   4th arg picks the map type (sorted here)

Other merge choices: (a, b) -> a keep first, (a, b) -> b keep last. Also, toMap throws NullPointerException if a value is null (it uses Map.merge inside).

groupingBy — the SQL GROUP BY of Java

groupingBy( classifier , [mapFactory] , [downstream collector] ) | | | key for each which Map what to do with each group element (HashMap default) (toList default) Eng -> [Asha, Ben] --counting()--> 2 --summingInt(salary)--> 220 HR -> [Eli] --counting()--> 1 --summingInt(salary)--> 70 Sales -> [Cara, Dev] --counting()--> 2 --summingInt(salary)--> 170
Map<String, List<Emp>> byDept = emps.stream().collect(Collectors.groupingBy(Emp::dept));
// HashMap: key order not defined

Map<String, Long> counts = emps.stream()
    .collect(Collectors.groupingBy(Emp::dept, TreeMap::new, Collectors.counting()));
// {Eng=2, HR=1, Sales=2}

Map<String, List<String>> names = emps.stream()
    .collect(Collectors.groupingBy(Emp::dept, TreeMap::new,
             Collectors.mapping(Emp::name, Collectors.toList())));
// {Eng=[Asha, Ben], HR=[Eli], Sales=[Cara, Dev]}

Map<String, Integer> sums = emps.stream()
    .collect(Collectors.groupingBy(Emp::dept, TreeMap::new, Collectors.summingInt(Emp::salary)));
// {Eng=220, HR=70, Sales=170}

Map<String, Double> avg = emps.stream()
    .collect(Collectors.groupingBy(Emp::dept, TreeMap::new, Collectors.averagingInt(Emp::salary)));
// {Eng=110.0, HR=70.0, Sales=85.0}

Map<String, Optional<Emp>> top = emps.stream()
    .collect(Collectors.groupingBy(Emp::dept,
             Collectors.maxBy(Comparator.comparingInt(Emp::salary))));
// Eng -> Asha, HR -> Eli, Sales -> Dev

// Word frequency -- a very common interview task
Map<String, Long> freq = Arrays.stream("the cat and the hat and the bat".split(" "))
    .collect(Collectors.groupingBy(Function.identity(), TreeMap::new, Collectors.counting()));
// {and=2, bat=1, cat=1, hat=1, the=3}

Useful downstream collectors: counting, summingInt/Long/Double, averagingInt, minBy/maxBy, mapping, filtering and flatMapping (Java 9), toSet, joining, collectingAndThen (e.g. unwrap the Optional from maxBy). Use groupingByConcurrent for parallel streams.

partitioningBy, joining, teeing

// partitioningBy: exactly two groups, keyed true / false
Map<Boolean, List<String>> part = emps.stream()
    .collect(Collectors.partitioningBy(e -> e.salary() >= 100,
             Collectors.mapping(Emp::name, Collectors.toList())));
// {false=[Cara, Dev, Eli], true=[Asha, Ben]}
Stream.<Emp>empty().collect(Collectors.partitioningBy(e -> true));
// {false=[], true=[]}   both keys always present (groupingBy would give {})

// joining: strings only, with optional separator, prefix, suffix
emps.stream().map(Emp::name).collect(Collectors.joining(", ", "[", "]"));
// [Asha, Ben, Cara, Dev, Eli]

// teeing (Java 12): feed every element to TWO collectors, then merge the results
double avgAll = emps.stream().collect(Collectors.teeing(
    Collectors.summingInt(Emp::salary),
    Collectors.counting(),
    (sum, n) -> (double) sum / n));                 // 92.0

record MinMax(int min, int max) {}
MinMax mm = emps.stream().map(Emp::salary).collect(Collectors.teeing(
    Collectors.minBy(Integer::compare), Collectors.maxBy(Integer::compare),
    (mn, mx) -> new MinMax(mn.get(), mx.get())));   // MinMax[min=70, max=120]

Method references — four kinds

KindSyntaxExampleSame as lambda
Static methodClass::staticMethodInteger::parseInts -> Integer.parseInt(s)
Instance method of a particular object (bound)object::methodprefix::concat, System.out::printlns -> prefix.concat(s)
Instance method of an arbitrary object of a type (unbound)Class::instanceMethodString::toUpperCases -> s.toUpperCase()
ConstructorClass::newArrayList::new, int[]::new() -> new ArrayList<>()
Function<String, Integer> parse = Integer::parseInt;            // static
parse.apply("42") + 1;                                          // 43

String prefix = "Hello, ";
Function<String, String> greet = prefix::concat;               // bound: receiver fixed now
greet.apply("Ana");                                             // "Hello, Ana"

Function<String, String> up = String::toUpperCase;             // unbound: receiver is the 1st arg
BiFunction<String, String, Boolean> eq = String::equalsIgnoreCase;
eq.apply("Java", "JAVA");                                       // true  ("Java".equalsIgnoreCase("JAVA"))

Supplier<ArrayList<String>> mk = ArrayList::new;               // constructor
Function<Integer, int[]> arr = int[]::new;                      // arr.apply(3).length == 3
String[] out = Stream.of("x", "y").toArray(String[]::new);      // [x, y]

The trick for unbound references: the first parameter of the functional interface becomes the object the method is called on; the rest become its arguments. A bound reference evaluates its receiver once, when the reference is created (and throws NPE right then if it is null).

Interview angle: "Group employees by department and count / sum / find the top earner" is asked constantly — know groupingBy with a downstream collector by heart. Also: "What happens with duplicate keys in toMap?", "Difference between Collectors.toList() and Stream.toList()?", "Name the four kinds of method reference."
Best practice: pass TreeMap::new or LinkedHashMap::new when the output order matters (tests, reports). Always give toMap a merge function unless keys are guaranteed unique. Use a method reference when it is clearer than the lambda, not by reflex.

27. Date & Time API

In plain words

Java 8 added java.time, a clean and immutable set of classes for dates and times. It replaced the old java.util.Date and Calendar, which were confusing and error-prone.

The key idea: pick the class that says exactly what you mean. A birthday is a date with no time or zone (LocalDate). A moment something happened on a server is a point on the global timeline (Instant). A meeting in New York is a local time in a zone (ZonedDateTime).

"My birthday is 20 May" (LocalDate) is true everywhere. "The rocket launched at this exact moment" (Instant) is one point in time for the whole planet. "Lunch at 12:00 in Tokyo" (ZonedDateTime) needs the city to mean anything.

Why java.util.Date and Calendar are bad

Date d = new Date(2024, 1, 15);           // deprecated constructor
d.getYear() + 1900;   // 3924  -- year is an offset from 1900
d.getMonth();         // 1     -- months start at 0, so 1 means FEBRUARY

Calendar cal = Calendar.getInstance();
cal.set(2024, 1, 15);                     // also February: 0-based months again

Date shared = new Date(0);
Date alias = shared;
alias.setTime(86_400_000L);               // mutable: changes 'shared' too
  • Mutable — a Date passed to another method or stored in a field can be changed behind your back; not thread-safe.
  • Bad naming — Date is really an instant (milliseconds since 1970), not a date; its toString uses the JVM's default zone.
  • 0-based months, 1900-based years.
  • SimpleDateFormat is not thread-safe — sharing one as a static field corrupts results under load.
  • No separate types for "date only", "time only", "duration".

Convert at the edges when an old API forces you: date.toInstant() and Date.from(instant).

The main classes

Human view (no zone) Machine view LocalDate 2024-03-10 Instant 2024-03-10T06:30:00Z LocalTime 14:30 (nanoseconds since 1970-01-01T00:00Z, always UTC) LocalDateTime 2024-03-10T14:30 | + ZoneId("America/New_York") ^ v | toInstant() ZonedDateTime 2024-03-10T01:30-05:00[America/New_York] ---+ OffsetDateTime 2024-03-10T12:00+05:00 (fixed offset, no DST rules)
ClassHoldsUse for
LocalDateyear-month-dayBirthdays, holidays, due dates
LocalTimehour:min:sec.nanosOpening hours "09:00"
LocalDateTimedate + time, no zoneA wall-clock value whose zone is known elsewhere; not a moment in time
ZonedDateTimedate + time + zone rulesScheduling in a region (handles DST)
OffsetDateTimedate + time + fixed offsetAPIs and DB columns (ISO-8601 with offset)
Instantpoint on the UTC timelineTimestamps, logs, event times, storing in the DB
Durationseconds + nanosMachine time amounts: "90 seconds", timeouts
Periodyears, months, daysCalendar amounts: "2 months", age

Working with dates — all immutable

LocalDate date = LocalDate.of(2024, 2, 28);    // months are 1-based here
date.plusDays(1);                     // 2024-02-29  (2024 is a leap year)
date.plusDays(2);                     // 2024-03-01
LocalDate.of(2024, 1, 31).plusMonths(1);        // 2024-02-29  clamped to last valid day
date.with(TemporalAdjusters.lastDayOfMonth());  // 2024-02-29
date.getDayOfWeek();                  // WEDNESDAY

LocalDate x = LocalDate.of(2024, 1, 1);
x.plusDays(5);                        // result thrown away!
System.out.println(x);                // 2024-01-01 -- unchanged; write x = x.plusDays(5)

Period age = Period.between(LocalDate.of(1990, 5, 20), LocalDate.of(2024, 3, 10));
// P33Y9M19D  -> 33 years, 9 months, 19 days
Duration shift = Duration.between(LocalTime.of(9, 15), LocalTime.of(17, 45));
// PT8H30M, shift.toMinutes() == 510
ChronoUnit.DAYS.between(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 12, 31));  // 365

Instant.ofEpochSecond(0);                        // 1970-01-01T00:00:00Z
Instant.ofEpochMilli(1_700_000_000_000L);        // 2023-11-14T22:13:20Z

Formatting and parsing

DateTimeFormatter f = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");   // immutable, thread-safe
LocalDateTime.of(2024, 3, 10, 14, 30).format(f);     // "10/03/2024 14:30"
LocalDateTime.parse("25/12/2024 08:05", f);          // 2024-12-25T08:05
LocalDate.parse("2024-07-04");                       // ISO format needs no formatter
DateTimeFormatter.ofPattern("EEE, d MMM yyyy", Locale.US).format(date);   // "Wed, 28 Feb 2024"

LocalDate.parse("2024-13-01");
// DateTimeParseException: ... Invalid value for MonthOfYear (valid values 1 - 12): 13
The YYYY bug: Y is the week-based year, y is the calendar year. LocalDate.of(2024, 12, 30) formatted with "YYYY-MM-dd" gives 2025-12-30, because that day belongs to week 1 of 2025. Also MM = month but mm = minutes, and HH = 0–23 but hh = 1–12.

Time zones and DST pitfalls

A zone (ZoneId.of("America/New_York")) is a set of rules that says what offset from UTC applies on each date — and those offsets change with daylight saving time (DST). An offset (+05:00) is just a fixed number.

ZoneId ny = ZoneId.of("America/New_York");
// US clocks jump from 02:00 to 03:00 on 2024-03-10
ZonedDateTime t = ZonedDateTime.of(2024, 3, 10, 1, 30, 0, 0, ny);
// 2024-03-10T01:30-05:00[America/New_York]
t.plusHours(1);   // 2024-03-10T03:30-04:00  -- exactly 1 hour of real time later
t.plusDays(1);    // 2024-03-11T01:30-04:00  -- same wall-clock time next day (only 23 real hours)

ZonedDateTime.of(2024, 3, 10, 2, 30, 0, 0, ny);
// 2024-03-10T03:30-04:00  -- 02:30 does not exist (the "gap"); shifted forward

// Clocks fall back 02:00 -> 01:00 on 2024-11-03: 01:30 happens TWICE (the "overlap")
ZonedDateTime o = ZonedDateTime.of(2024, 11, 3, 1, 30, 0, 0, ny);
o.withEarlierOffsetAtOverlap();   // 2024-11-03T01:30-04:00
o.withLaterOffsetAtOverlap();     // 2024-11-03T01:30-05:00

ZonedDateTime noon = ZonedDateTime.of(2024, 3, 9, 12, 0, 0, 0, ny);
Duration.between(noon, noon.plusDays(1)).toHours();   // 23, not 24

t.withZoneSameInstant(ZoneId.of("Asia/Tokyo"));  // 2024-03-10T15:30+09:00  same moment
t.withZoneSameLocal(ZoneId.of("Asia/Tokyo"));    // 2024-03-10T01:30+09:00  same clock, different moment
  • Store and compute in UTC (Instant / OffsetDateTime in UTC); convert to a user's zone only for display.
  • Never use LocalDateTime for events — it has no zone, so "2024-11-03T01:30" is ambiguous and cannot be ordered across users.
  • Use region IDs (Europe/Paris), not abbreviations like "EST"/"PST" (ambiguous, ignore DST) or fixed offsets for people who live somewhere.
  • Don't rely on the server's default zone (ZoneId.systemDefault()) — pass zones explicitly.
  • Inject a Clock (LocalDate.now(clock)) so tests can freeze time with Clock.fixed(...).
  • Zone rules change by law; the JDK ships a tz database that needs updating (JDK updates or the TZUpdater tool).
Interview angle: "Why was java.time introduced?" — mutability, thread-unsafe formatter, 0-based months, no clear types. "Instant vs LocalDateTime?" — a moment in time vs a wall-clock reading with no zone. "Period vs Duration?" — calendar units vs exact seconds; adding Period.ofDays(1) across DST keeps the clock time, adding Duration.ofDays(1) adds exactly 24 hours.
Part 5 — Concurrency

28. Threads & Their Lifecycle

In plain words

A process is a running program with its own memory. A thread is one path of execution inside a process. A process can have many threads; they all share the same heap (objects), but each has its own stack (local variables and call chain) and program counter.

Threads let a program do several things at once: serve many users, keep a UI responsive while downloading, use every CPU core.

A restaurant is a process: one kitchen, one fridge (shared memory). The cooks are threads. They work in parallel and share ingredients — which is fast, but if two cooks grab the same pan at once, things go wrong. The rest of Part 5 is about keeping the cooks from colliding.

Process vs thread

ProcessThread
MemoryOwn address space, isolatedShares heap with other threads of the process; own stack
Cost to createHeavy (new memory map, OS structures)Lighter, but each platform thread reserves its own stack (about 1 MB by default on 64-bit)
CommunicationIPC: pipes, sockets, filesJust read/write shared objects (needs synchronization)
Crash impactOther processes unaffectedAn uncaught error kills that thread; a JVM crash kills all
Context switchExpensiveCheaper

A classic Java Thread is a platform thread: a thin wrapper around one OS thread. Java 21 also adds cheap virtual threads (section 34).

Three ways to create work for a thread

// 1. Extend Thread (least flexible: uses up your one superclass)
Thread t1 = new Thread() {
    @Override public void run() { System.out.println("subclass: " + Thread.currentThread().getName()); }
};

// 2. Pass a Runnable (preferred: task separated from the thread that runs it)
Thread t2 = new Thread(() -> System.out.println("runnable: " + Thread.currentThread().getName()),
                       "worker-2");
t1.start(); t1.join();     // subclass: Thread-0
t2.start(); t2.join();     // runnable: worker-2

// 3. Callable: returns a value and may throw checked exceptions
Callable<Integer> task = () -> 6 * 7;
FutureTask<Integer> ft = new FutureTask<>(task);   // a Runnable that captures the result
new Thread(ft).start();
ft.get();                                           // 42 (blocks until done)

// Java 21 builder API
Thread b = Thread.ofPlatform().name("built").start(() -> System.out.println("builder"));
b.join();
RunnableCallable<V>
Methodvoid run()V call() throws Exception
Returns a valueNoYes, through a Future
Checked exceptionsMust catch insideCan throw; rethrown wrapped in ExecutionException by Future.get()
Use withnew Thread(r), executorsExecutors (submit), FutureTask

In real code you rarely call new Thread; you submit tasks to an ExecutorService (section 31).

Thread lifecycle

NEW RUNNABLE ready or running TERMINATED start() run() ends BLOCKED waiting for a monitor WAITING no timeout TIMED_WAITING with a timeout synchronized, lock busy got the lock wait(), join() notify, join done sleep(ms), wait(ms) time up, notify interrupt() wakes a WAITING or TIMED_WAITING thread with InterruptedException
Thread.getState() returns one of these six values. RUNNABLE covers both "running on a CPU" and "ready, waiting for the scheduler".

Each state, seen by calling getState() in a real run:

Thread sleeper = new Thread(() -> { try { Thread.sleep(500); } catch (InterruptedException e) {} });
sleeper.getState();   // NEW
sleeper.start();
sleeper.getState();   // RUNNABLE
Thread.sleep(100);
sleeper.getState();   // TIMED_WAITING   (inside sleep)
sleeper.join();
sleeper.getState();   // TERMINATED

// thread calling lock.wait()                    -> WAITING
// thread trying to enter synchronized(lock)
//   while main holds that lock                  -> BLOCKED

BLOCKED vs WAITING: BLOCKED means "I want to enter a synchronized block but another thread holds the lock" — it gets out automatically when the lock frees. WAITING means "I chose to wait until someone signals me" (wait, join, LockSupport.park). A thread waiting on a ReentrantLock shows as WAITING, not BLOCKED, because those locks use park.

start() vs run()

Thread r = new Thread(() -> System.out.println("run() executes on: " + Thread.currentThread().getName()),
                      "worker");
r.run();     // run() executes on: main     -- just a normal method call, no new thread!
r.start();   // run() executes on: worker   -- new thread, which then calls run()
r.join();
r.start();   // IllegalThreadStateException -- a thread can be started only once

join, sleep, wait

  • t.join() — the calling thread waits until t finishes. join(ms) waits at most that long. It also guarantees you see everything t wrote (happens-before, section 29).
  • Thread.sleep(ms) — pause the current thread. It is static: t.sleep(1000) still sleeps the caller, not t.
  • obj.wait() — release obj's lock and wait for a notify.
Thread.sleepObject.wait
Belongs toThread (static)Object (any object)
Releases locks?No — keeps every lock it holdsYes — releases that object's monitor
Must hold the lock?NoYes, else IllegalMonitorStateException
Wakes up whenTime passes (or interrupt)notify/notifyAll, timeout, interrupt, or spuriously
PurposePauseCoordinate between threads

Interruption — the polite way to stop a thread

Java has no safe way to kill a thread (Thread.stop was deprecated long ago and throws UnsupportedOperationException since Java 20, because it could leave objects half-updated). Instead you ask it to stop with t.interrupt(), which sets a flag. The thread must cooperate:

// Case 1: the thread is blocked in sleep / wait / join -> it gets InterruptedException
Thread worker = new Thread(() -> {
    try {
        Thread.sleep(10_000);
    } catch (InterruptedException e) {
        // throwing InterruptedException CLEARS the flag
        System.out.println(Thread.currentThread().isInterrupted());   // false
        Thread.currentThread().interrupt();                            // restore it
        System.out.println(Thread.currentThread().isInterrupted());   // true
    }
});
worker.start(); worker.interrupt();

// Case 2: the thread is busy computing -> it must check the flag itself
Thread loop = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // do a chunk of work
    }
    // clean up and exit
});
Never swallow InterruptedException. An empty catch loses the stop request and the thread keeps running. Either rethrow it (declare throws InterruptedException) or call Thread.currentThread().interrupt() so code higher up can see it. Also: Thread.interrupted() (static) reads and clears the flag; isInterrupted() only reads it.

Daemon threads

The JVM exits when all non-daemon (user) threads have finished. Daemon threads are background helpers (GC, JIT compiler threads) that do not keep the JVM alive — when the last user thread ends, daemons are stopped abruptly, without running finally blocks.

Thread d = new Thread(() -> {
    try { Thread.sleep(60_000); } catch (InterruptedException e) {}
    System.out.println("never printed");
});
d.setDaemon(true);   // must be called BEFORE start()
d.start();
// main returns -> JVM exits immediately (our run finished in about 1 second, not 60)

Never use a daemon thread for work that must finish (writing a file, committing to a DB). A new thread inherits daemon status from the thread that created it.

Interview angle: "What happens if you call run() instead of start()?" "Can you start a thread twice?" "Difference between sleep and wait?" (lock release is the key point). "Name the thread states" and "BLOCKED vs WAITING". "How do you stop a thread?" — interruption, not stop().
Best practice: prefer Runnable/Callable + an executor over subclassing Thread; always name threads (it makes thread dumps readable); handle interruption properly; set an UncaughtExceptionHandler so failures are logged, not lost.

29. synchronized, volatile & the Java Memory Model

In plain words

When several threads share data, two separate problems appear. Atomicity: a step like count++ is really three steps (read, add, write), and another thread can sneak in between them. Visibility: a value one thread writes may sit in a CPU cache or register, so another thread keeps seeing the old value.

synchronized fixes both: only one thread at a time runs the guarded code, and changes are published when the lock is released. volatile fixes only visibility. The Java Memory Model (JMM) is the rulebook that says when one thread is guaranteed to see another thread's writes.

A shared whiteboard with one marker. synchronized means you must hold the marker to write, so no two people scribble over each other. volatile is like a rule "always look at the actual board, not the photo you took earlier" — you see the latest value, but two people can still both read "5" and both write "6".

Race condition: count++ loses updates

static int count = 0;

// 4 threads, each does count++ 100,000 times
Runnable inc = () -> { for (int i = 0; i < 100_000; i++) count++; };
Thread[] ts = new Thread[4];
for (int i = 0; i < 4; i++) { ts[i] = new Thread(inc); ts[i].start(); }
for (Thread t : ts) t.join();
System.out.println(count);   // expected 400000

Three real runs printed 135641, 128279 and 113633 — different every time, never 400000 (the exact number is nondeterministic). Why:

count = 5 Thread A: read 5 ............... add 1 -> 6 .......... write 6 Thread B: ......... read 5 .......... add 1 -> 6 ............... write 6 Result: 6 (two increments happened, one was LOST)

A race condition is any bug where the result depends on the timing of threads. The code read-modify-write (count++) and check-then-act (if (!map.containsKey(k)) map.put(k, v)) are the two classic shapes.

synchronized and intrinsic locks

Every Java object has a built-in lock called its intrinsic lock or monitor. synchronized acquires it on entry and releases it on exit — even if an exception is thrown.

class Counter {
    private int count;
    private final Object lock = new Object();

    public synchronized void inc() { count++; }        // locks 'this'
    public static synchronized void sInc() { }         // locks Counter.class (a different lock!)

    public void incBlock() {
        // ...work that needs no lock...
        synchronized (lock) {                          // locks only this block, on a private object
            count++;
        }
    }
}
// With synchronized: all our runs printed exactly 400000.
synchronized methodsynchronized block
Lock usedthis (or the Class for static)Any object you choose
ScopeWhole methodOnly the critical lines — shorter lock hold, more throughput
SafetyOutside code can also lock on your object and interfereA private final lock object cannot be locked by anyone else

Reentrancy: a thread that already holds a lock can enter another block guarded by the same lock without deadlocking itself. The JVM keeps a hold count per owner.

static synchronized void outer() { inner(); }   // holds Counter.class lock
static synchronized void inner() { ... }        // same lock again: allowed, count goes to 2
// Without reentrancy, a synchronized method calling another would deadlock on itself,
// and so would a subclass overriding a synchronized method and calling super.

Pitfalls: locking on a boxed Integer or a String literal (shared across the JVM), locking on a field that gets reassigned, and guarding the same data with different locks in different places (no protection at all).

The visibility problem

Thread A (core 1) running = false; Thread B (core 2) while (running) { } cache: running=false cache: running=true (stale) Main memory: running volatile write: flush here. volatile read: always re-read from here maybe later maybe never
Without volatile or a lock, nothing forces A's write out or forces B to re-read. The JIT may even hoist the read out of B's loop entirely.
static boolean flag = false;            // plain field
new Thread(() -> { while (!flag) { } System.out.println("saw flag"); }).start();
Thread.sleep(200);
flag = true;
// In our runs the reader was STILL spinning after 1 second, every time:
// the JIT turned the loop into "if (!flag) loop forever".

static volatile boolean vflag = false;  // volatile field
// Same code with vflag: "saw flag" printed and the thread ended right away.

(Whether the plain version hangs depends on the JIT and hardware — it is not guaranteed either way, which is exactly the problem.)

volatile

Marking a field volatile means: every read goes to the latest written value, every write becomes visible to other threads, and the compiler/CPU may not reorder other memory operations across it in unsafe ways. Use it for:

  • Status flags: volatile boolean running, written by one thread and read by others.
  • Publishing an immutable object: volatile Config current, replaced as a whole.
  • The instance field in double-checked locking (below).

Why volatile does not make count++ atomic: count++ is still read, add, write — three separate steps. volatile makes each read and write fresh, but another thread can still write between your read and your write. Our run with volatile int printed 277680 (of 400000). Use synchronized or AtomicInteger for compound actions.

Happens-before, in easy words

"A happens-before B" is the JMM's promise: everything A's thread did before A is visible to B's thread after B. Without such a link, the other thread may see old values or see writes in a strange order. The main rules:

RuleA happens-before B when...
Program orderA comes before B in the same thread
Monitor lockA is an unlock of lock L, B is a later lock of L
VolatileA is a write to volatile V, B is a later read of V
Thread startA is t.start(), B is anything inside t
Thread joinA is anything inside t, B is after t.join() returns
TransitivityA hb B and B hb C, so A hb C

Also: final fields set in a constructor are visible to every thread once the constructor finishes (if this did not escape). Concurrent collections and executors document their own happens-before edges (putting into a ConcurrentHashMap hb getting it out).

int data; volatile boolean ready;
// Thread A                      // Thread B
data = 42;                       if (ready) {
ready = true;  // volatile write     System.out.println(data);   // guaranteed 42
                                 }
// The volatile write/read pair carries 'data' along with it.

Double-checked locking singleton

final class Config {
    private static volatile Config instance;          // volatile is ESSENTIAL
    private Config() { }

    static Config get() {
        Config local = instance;                      // one volatile read on the fast path
        if (local == null) {                          // check 1: no lock
            synchronized (Config.class) {
                local = instance;
                if (local == null) {                  // check 2: with lock
                    instance = local = new Config();
                }
            }
        }
        return local;
    }
}
// 4 threads calling Config.get() at once: constructor ran exactly once.

Why volatile? new Config() is allocate, run constructor, assign reference — and without volatile the CPU/JIT may assign the reference before the constructor finishes. Another thread passing check 1 would then get a half-built object. Simpler alternatives: the holder idiom (private static class Holder { static final Config I = new Config(); }, lazy and thread-safe thanks to class initialization) or an enum singleton.

wait / notify

wait, notify and notifyAll let threads coordinate on a condition ("buffer not empty") while holding the object's monitor.

final class Box {                       // bounded buffer, capacity 2
    private final Deque<Integer> q = new ArrayDeque<>();
    private final int cap = 2;

    synchronized void put(int v) throws InterruptedException {
        while (q.size() == cap) wait();  // releases the lock while waiting
        q.addLast(v);
        notifyAll();                     // wake consumers
    }
    synchronized int take() throws InterruptedException {
        while (q.isEmpty()) wait();
        int v = q.removeFirst();
        notifyAll();                     // wake producers
        return v;
    }
}
// Producer puts 1..5 then -1; consumer prints: 1 2 3 4 5
  • Always call them inside synchronized on the same object, or you get IllegalMonitorStateException.
  • Always wait in a while loop, never if: threads can wake spuriously, or another thread may grab the item first.
  • Prefer notifyAll: notify wakes one arbitrary thread, which may be the wrong kind (a producer when a consumer was needed), causing a stall.
  • In new code use BlockingQueue or Lock + Condition (section 30) instead.
Interview angle: "Is count++ thread-safe? Is it with volatile?" — no and no. "Difference between synchronized and volatile?" — mutual exclusion + visibility vs visibility only. "Explain happens-before." "Why does double-checked locking need volatile?" "Why must wait be in a loop?" "Is synchronized reentrant?" — yes.

30. Locks & Atomic Variables

In plain words

java.util.concurrent.locks (Java 5) gives you explicit locks: objects you lock and unlock yourself. They do what synchronized does, plus extras — try to lock without waiting forever, give up if interrupted, be fair to waiting threads, allow many readers at once.

Atomic variables (AtomicInteger, LongAdder) make single updates like "add one" thread-safe without any lock, using a special CPU instruction called compare-and-swap (CAS).

synchronized is a bathroom door that locks automatically. A ReentrantLock is a key you carry: you can peek to see if the room is free and walk away (tryLock), but you must remember to return the key. An atomic variable is a tally counter at a turnstile: everyone clicks it, no queue needed.

ReentrantLock

final class Account {
    private final ReentrantLock lock = new ReentrantLock();
    private int balance = 100;

    boolean withdraw(int amt) {
        lock.lock();                  // outside the try
        try {
            if (balance < amt) return false;
            balance -= amt;
            return true;
        } finally {
            lock.unlock();            // ALWAYS in finally, or one exception locks it forever
        }
    }
}
// 4 threads x 10 withdrawals of 5 from 100: balance ends at 0, never negative.

ReentrantLock rl = new ReentrantLock();
rl.lock(); rl.lock();
rl.getHoldCount();    // 2  -- reentrant, just like synchronized; unlock twice

Its three extras, each verified while another thread was holding the lock:

// 1. tryLock: don't wait (or wait a bounded time) -- avoids deadlock, allows a fallback
lock.tryLock();                                // false (held by another thread)
lock.tryLock(100, TimeUnit.MILLISECONDS);      // false after 100 ms
if (lock.tryLock()) {
    try { /* work */ } finally { lock.unlock(); }
} else {
    // do something else, retry later, or report "busy"
}

// 2. lockInterruptibly: waiting can be cancelled with interrupt()
try {
    lock.lockInterruptibly();
    try { /* work */ } finally { lock.unlock(); }
} catch (InterruptedException e) {
    // "lockInterruptibly: gave up after interrupt"
    Thread.currentThread().interrupt();
}
// (A thread stuck in lock() or on synchronized ignores interrupts.)

// 3. Fairness: longest-waiting thread gets the lock next
ReentrantLock fair = new ReentrantLock(true);  // default is false (unfair = faster)

Fair vs unfair: an unfair lock lets a newly arriving thread "barge" ahead of the queue if the lock happens to be free — this avoids a context switch and gives much better throughput, but a thread can in theory wait a long time. Fair locks prevent starvation at a large speed cost. Keep the default unless you have measured starvation.

Condition — wait/notify for explicit locks

A Condition is a named wait-set tied to a lock. Unlike a monitor (which has one wait-set), a lock can have several, so producers and consumers wait on separate queues and signal wakes the right kind.

final class BoundedBuffer<T> {
    private final Deque<T> q = new ArrayDeque<>();
    private final int cap;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull  = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    BoundedBuffer(int cap) { this.cap = cap; }

    void put(T t) throws InterruptedException {
        lock.lock();
        try {
            while (q.size() == cap) notFull.await();   // like wait(): releases the lock
            q.addLast(t);
            notEmpty.signal();                         // wake ONE consumer
        } finally { lock.unlock(); }
    }
    T take() throws InterruptedException {
        lock.lock();
        try {
            while (q.isEmpty()) notEmpty.await();
            T t = q.removeFirst();
            notFull.signal();                          // wake ONE producer
            return t;
        } finally { lock.unlock(); }
    }
}
// Producer puts 1..5 into a capacity-2 buffer; consumer's sum is 15.

This is essentially how ArrayBlockingQueue is built. Same rules as wait: hold the lock, and await in a while loop.

ReadWriteLock and StampedLock

// Many readers at once OR one writer -- good for read-heavy data
final class Cache {
    private final Map<String, String> map = new HashMap<>();
    private final ReadWriteLock rw = new ReentrantReadWriteLock();

    String get(String k) {
        rw.readLock().lock();
        try { return map.get(k); } finally { rw.readLock().unlock(); }
    }
    void put(String k, String v) {
        rw.writeLock().lock();
        try { map.put(k, v); } finally { rw.writeLock().unlock(); }
    }
}

A write lock can be downgraded to a read lock (take read, then release write), but a read lock can not be upgraded to write — trying it deadlocks. If reads are very short, the bookkeeping of a read-write lock can make it slower than a plain lock; measure first.

StampedLock (Java 8) adds an optimistic read: read without locking at all, then check that no writer came in; if one did, retry with a real read lock. Very fast for read-mostly data, but it is not reentrant, has no Condition, and is easy to misuse.

double distance() {
    long stamp = sl.tryOptimisticRead();     // no lock taken
    double cx = x, cy = y;                   // copy fields into locals
    if (!sl.validate(stamp)) {               // a writer slipped in -> fall back
        stamp = sl.readLock();
        try { cx = x; cy = y; } finally { sl.unlockRead(stamp); }
    }
    return Math.sqrt(cx * cx + cy * cy);     // after move(3, 4): 5.0
}

synchronized vs ReentrantLock

synchronizedReentrantLock
UnlockAutomatic (even on exception)Manual, in finally
Try without blocking / timeoutNotryLock(), tryLock(time)
Interruptible wait for lockNolockInterruptibly()
Fairness optionNoYes
Wait conditionsOne (wait/notify)Many (newCondition())
Lock across methodsNo (block-scoped)Yes (lock in one method, unlock in another — careful)
Virtual threads (Java 21)Pins the carrier thread while blocked inside (fixed in Java 24)Does not pin

Default to synchronized for simple cases (shorter, can't forget to unlock). Reach for ReentrantLock when you need one of its extras — or with many virtual threads on Java 21.

Atomic variables and CAS

Compare-and-swap (CAS) is one CPU instruction that says: "if the value at this address is still expected, set it to new and tell me true; otherwise change nothing and tell me false." Because the check and the write happen as one indivisible step, no lock is needed.

incrementAndGet() as a CAS loop: loop: prev = value read 5 next = prev + 1 compute 6 CAS(value, 5 -> 6) succeeds if value is still 5 -> return 6 fails if another thread changed it -> go back to loop Nobody ever waits on a lock; a thread that loses just retries with the fresh value.
AtomicInteger ai = new AtomicInteger();
// 4 threads x 100,000 ai.incrementAndGet()  ->  exactly 400000

AtomicInteger v = new AtomicInteger(5);
v.compareAndSet(5, 6);                  // true,  v = 6
v.compareAndSet(5, 7);                  // false, v still 6 (expected value was wrong)
v.updateAndGet(x -> x * 10);            // 60   (function may run more than once: keep it pure)
v.accumulateAndGet(42, Math::max);      // 60

// Writing the CAS loop by hand: square the value atomically
AtomicInteger m = new AtomicInteger(3);
int prev, next;
do {
    prev = m.get();
    next = prev * prev;
} while (!m.compareAndSet(prev, next));  // m = 9

AtomicReference<String> ref = new AtomicReference<>("v1");
ref.compareAndSet("v1", "v2");           // true -- compares REFERENCES (==), not equals()

Family: AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference, AtomicIntegerArray, and field updaters. ABA problem: a value goes A to B and back to A; a CAS expecting A succeeds even though things changed in between. It matters for lock-free linked structures; AtomicStampedReference fixes it by pairing the value with a version number.

LongAdder — for hot counters

Under heavy contention, many threads CAS-ing the same variable keep failing and retrying, and the cache line bounces between cores. LongAdder (Java 8) spreads the count over several internal cells (roughly one per contending thread) and only adds them up when you call sum().

LongAdder hits = new LongAdder();
hits.increment();          // cheap even with many threads
hits.add(5);
long total = hits.sum();   // not an atomic snapshot if updates are still happening

// Our rough run, 8 threads x 2,000,000 increments each:
//   AtomicLong 842 ms, LongAdder 124 ms   (both correct: 16000000)
// Timings vary by machine; the gap grows with the number of contending threads.

Use LongAdder for statistics counters that are written a lot and read rarely. Use AtomicLong when you need the exact current value on every update (like generating IDs) or compareAndSet.

When to use which

NeedUse
A flag or reference written by one thread, read by othersvolatile
A single counter or value updated by many threadsAtomicInteger / AtomicLong / AtomicReference
A very hot counter, read rarelyLongAdder
Several fields that must change together (invariants)synchronized (simple) or ReentrantLock
Need timeout, interruptible wait, fairness, several conditionsReentrantLock + Condition
Read-mostly shared data with long readsReentrantReadWriteLock, or StampedLock for top speed
Producer–consumer handoffBlockingQueue (don't hand-roll it)
Shared mapConcurrentHashMap (section 33), not a locked HashMap
Interview angle: "synchronized vs ReentrantLock — when would you pick the lock?" (tryLock, timeout, interruptible, fairness, conditions). "What is CAS? Explain AtomicInteger.incrementAndGet." "What is the ABA problem?" "AtomicLong vs LongAdder?" "Why must unlock go in finally?" "Can a read lock be upgraded?"
Best practice: lock() right before try, unlock() in finally; keep critical sections short and never call unknown code (callbacks, I/O) while holding a lock; always acquire multiple locks in the same global order to avoid deadlock (section 34); prefer higher-level tools (ConcurrentHashMap, BlockingQueue, atomics) over hand-made locking.

31. Executors & Thread Pools

In plain words

Creating a new Thread for every small job is slow (each platform thread needs an OS thread and about 1 MB of stack) and dangerous (10,000 requests means 10,000 threads). A thread pool keeps a fixed team of worker threads alive and feeds them jobs from a queue. An Executor is the Java interface for "something that runs tasks for you" — you hand over the task and stop caring which thread runs it.

You submit Runnable (no result) or Callable (returns a value, may throw) tasks, and you get back a Future — a receipt you can use later to collect the result.

A restaurant kitchen. Customers do not hire a new cook for each order. Orders go on a rail (the queue), a fixed crew of cooks (the pool) takes them one by one, and the customer holds a ticket (the Future) to pick up the meal.

The interfaces

TypeWhat it adds
ExecutorOne method: execute(Runnable). Fire and forget.
ExecutorServicesubmit (returns a Future), invokeAll, invokeAny, lifecycle: shutdown, shutdownNow, awaitTermination. Since Java 19 it is AutoCloseable (close() = shutdown + wait).
ScheduledExecutorServiceschedule (run once after a delay), scheduleAtFixedRate, scheduleWithFixedDelay.
ThreadPoolExecutorThe real implementation behind most pools; every knob is exposed.
ForkJoinPoolWork-stealing pool for divide-and-conquer tasks; also runs parallel streams and default CompletableFuture async work.

The Executors factory methods — and their traps

FactoryThreadsQueueRisk
newFixedThreadPool(n)exactly nLinkedBlockingQueue, capacity Integer.MAX_VALUEQueue grows without limit under load → OutOfMemoryError
newSingleThreadExecutor()1unbounded LinkedBlockingQueueSame unbounded queue; useful for strict ordering
newCachedThreadPool()0 to Integer.MAX_VALUE, idle ones die after 60 sSynchronousQueue (holds nothing; hands off directly)A burst creates thousands of threads → out of memory / "unable to create native thread"
newScheduledThreadPool(n)n coredelay queue (unbounded)Tasks that throw silently stop repeating
newWorkStealingPool() (8)= CPU coresper-thread dequesA ForkJoinPool; not for blocking work
newVirtualThreadPerTaskExecutor() (21)one new virtual thread per tasknoneNo limit on concurrency: add a Semaphore to protect a database

I checked the first two rows with reflection on Java 21: the fixed pool's queue is a LinkedBlockingQueue with remainingCapacity() = 2147483647, and the cached pool uses a SynchronousQueue with maximum pool size 2147483647. That is why many style guides (Alibaba's Java manual is the famous one) forbid Executors.newXxx in production and ask you to build a ThreadPoolExecutor yourself so every limit is explicit.

How ThreadPoolExecutor decides what to do with a task

execute(task) or submit(task) threads < core? check 1 queue not full? check 2 threads < max? check 3 no no yes yes yes start core thread it runs the task now put in queue a free worker takes it start extra thread (up to max) no RejectedExecutionHandler (rejection policy) Abort (default, throws) · CallerRuns · Discard · DiscardOldest
The surprising part: extra threads above core are created only when the queue is full. With an unbounded queue the queue is never full, so maximumPoolSize is ignored.

Building a pool the production way

ThreadPoolExecutor pool = new ThreadPoolExecutor(
        2,                                   // corePoolSize: threads kept alive even when idle
        4,                                   // maximumPoolSize: hard ceiling
        30, TimeUnit.SECONDS,                // keepAliveTime for threads above core
        new ArrayBlockingQueue<>(2),         // BOUNDED queue, capacity 2
        r -> new Thread(r, "worker-" + n.incrementAndGet()),   // name your threads!
        new ThreadPoolExecutor.AbortPolicy()); // what to do when full

// Submit 7 tasks that all block until we release them
for (int i = 1; i <= 7; i++) {
    try {
        pool.execute(blocker);
        System.out.printf("task %d accepted: threads=%d queued=%d%n",
                i, pool.getPoolSize(), pool.getQueue().size());
    } catch (RejectedExecutionException e) {
        System.out.println("task " + i + " REJECTED");
    }
}

Output (verified, deterministic because every task blocks on a latch):

task 1 accepted: threads=1 queued=0     <- core thread 1
task 2 accepted: threads=2 queued=0     <- core thread 2
task 3 accepted: threads=2 queued=1     <- core full, so queue
task 4 accepted: threads=2 queued=2     <- queue now full
task 5 accepted: threads=3 queued=2     <- extra thread
task 6 accepted: threads=4 queued=2     <- extra thread, max reached
task 7 REJECTED                         <- 4 running + 2 queued = capacity 6
terminated=true completed=6
Rejection policyBehaviourUse when
AbortPolicy (default)Throws RejectedExecutionExceptionCaller can handle "busy, try later" (e.g. return HTTP 503)
CallerRunsPolicyThe submitting thread runs the task itselfNatural back-pressure: the producer slows down because it is busy working
DiscardPolicySilently drops the new taskLoss is fine (e.g. metrics samples) — dangerous otherwise
DiscardOldestPolicyDrops the oldest queued task, retries the new oneOnly the latest data matters

Sizing a pool

  • CPU-bound work (compression, math): about number of cores (or cores + 1). More threads only add context switches.
  • I/O-bound work (HTTP, database): threads ≈ cores × (1 + wait time / compute time). A task that waits 90 ms and computes 10 ms on 8 cores → 8 × 10 = 80 threads. In Java 21, virtual threads are often simpler here (see s34).
  • Always bound the queue and pick a rejection policy on purpose. Name threads with a ThreadFactory so thread dumps are readable.

submit vs execute

execute(Runnable)submit(Runnable / Callable)
Declared inExecutorExecutorService
ReturnsvoidFuture<T>
If the task throwsException kills the worker thread (the pool replaces it) and is printed by the default UncaughtExceptionHandlerException is stored in the Future; nothing is printed. You only see it when you call get(), wrapped in ExecutionException
ExecutorService pool = Executors.newFixedThreadPool(2);
Future<Integer> f = pool.submit(() -> 10 / 0);
try {
    f.get();
} catch (ExecutionException e) {
    System.out.println("get() threw: " + e.getCause());
}
// get() threw: java.lang.ArithmeticException: / by zero

Future<String> ok = pool.submit(() -> "hello");
ok.get(1, TimeUnit.SECONDS);   // "hello" -- ALWAYS prefer the timeout version
ok.isDone();                   // true
// also: f.cancel(true) interrupts the running task; f.isCancelled()
Classic bug: pool.submit(task) and never call get(). If the task throws, the error disappears without a trace. Either use execute, call get(), or catch and log inside the task. Future.get() with no timeout blocks forever if the task hangs.

invokeAll, invokeAny and scheduling

List<Callable<Integer>> tasks = List.of(() -> 1, () -> 2, () -> 3);
int sum = 0;
for (Future<Integer> r : pool.invokeAll(tasks)) sum += r.get();   // waits for ALL
// sum = 6; futures come back in the same order as the tasks
pool.invokeAny(tasks);   // result of ONE task that succeeded; the rest are cancelled

ScheduledExecutorService ses = Executors.newScheduledThreadPool(1);
ScheduledFuture<String> once = ses.schedule(() -> "ran after delay", 50, TimeUnit.MILLISECONDS);
once.get();   // "ran after delay"
ses.scheduleAtFixedRate(this::poll, 0, 20, TimeUnit.MILLISECONDS);     // start every 20 ms
ses.scheduleWithFixedDelay(this::poll, 0, 20, TimeUnit.MILLISECONDS);  // 20 ms gap AFTER each run ends

Fixed rate vs fixed delay: fixed rate keeps a steady clock (if a run is late, the next ones start back-to-back to catch up, never overlapping); fixed delay waits the full delay after each run ends. If a periodic task throws, all its future runs are cancelled silently — wrap the body in try/catch.

Shutting down

ExecutorService single = Executors.newSingleThreadExecutor();
single.submit(() -> { try { Thread.sleep(10_000); } catch (InterruptedException e) {
    System.out.println("running task interrupted"); } });
for (int i = 0; i < 3; i++) single.submit(() -> {});   // these wait in the queue

List<Runnable> neverRan = single.shutdownNow();   // prints "running task interrupted"
neverRan.size();                                   // 3
single.awaitTermination(1, TimeUnit.SECONDS);      // true
shutdown()shutdownNow()
Stops accepting new tasks (they get RejectedExecutionException)Same
Lets running and queued tasks finishInterrupts running tasks, returns queued tasks as a list, never runs them
Returns immediately; call awaitTermination to waitReturns immediately; tasks that ignore interrupts keep running

The standard pattern: shutdown(), then awaitTermination(timeout), and if it returns false, shutdownNow(). Pool threads are non-daemon, so forgetting to shut down a pool keeps the JVM alive after main ends. In Java 19+ you can write try (ExecutorService ex = ...) { ... } and close() does the shutdown and wait for you.

ForkJoinPool and work stealing

A ForkJoinPool gives each worker its own double-ended queue (deque). A task splits itself (fork) and pushes the halves onto its own deque; the owner pops from the top (newest, still-hot in cache). An idle worker steals from the bottom of someone else's deque (the oldest, usually biggest piece). Owners and thieves work at opposite ends, so they rarely collide.

worker 1 deque: [ big-old | mid | small-new ] <- owner pushes/pops here (LIFO) ^ idle worker 2 steals here (FIFO) -- takes the biggest chunk
class SumTask extends RecursiveTask<Long> {     // RecursiveAction if nothing is returned
    private static final int THRESHOLD = 10_000;
    private final long[] a; private final int lo, hi;
    SumTask(long[] a, int lo, int hi) { this.a = a; this.lo = lo; this.hi = hi; }

    @Override protected Long compute() {
        if (hi - lo <= THRESHOLD) {                // small enough: just do it
            long s = 0;
            for (int i = lo; i < hi; i++) s += a[i];
            return s;
        }
        int mid = (lo + hi) >>> 1;
        SumTask left = new SumTask(a, lo, mid);
        SumTask right = new SumTask(a, mid, hi);
        left.fork();                               // queue left half (may be stolen)
        long r = right.compute();                  // do right half in this thread
        return left.join() + r;                    // then collect left
    }
}
long[] data = new long[1_000_000];                 // 1..1,000,000
long total = ForkJoinPool.commonPool().invoke(new SumTask(data, 0, data.length));
// total = 500000500000

The common pool is shared by the whole JVM: parallel streams and CompletableFuture.supplyAsync without an executor both use it. Its parallelism is cores − 1 (verified: getParallelism() == availableProcessors() - 1 printed true). Never put blocking I/O on it — you starve every parallel stream in the application.

Interview angle: "Why not Executors.newFixedThreadPool?" — unbounded queue, silent memory growth. "Walk me through what happens when a task is submitted" — the three checks in the diagram. "What does CallerRunsPolicy give you?" — back-pressure. "Difference between submit and execute for exceptions?" — Future swallows it until get().

32. CompletableFuture

In plain words

A plain Future can only be asked "are you done?" or be waited on with a blocking get(). A CompletableFuture (Java 8) lets you say "when this finishes, do that next" without blocking: you chain steps, combine several async results, and handle errors, like a pipeline. It is Java's version of a JavaScript Promise.

Ordering at a food court with a buzzer. You don't stand at the counter (blocking get()). You tell them "when the burger is ready, add fries, then buzz me", and meanwhile you find a table. If the kitchen runs out, the plan says what to serve instead.

Starting async work

ExecutorService io = Executors.newFixedThreadPool(4, r -> new Thread(r, "io-pool"));

CompletableFuture<User> user = CompletableFuture.supplyAsync(() -> loadUser(1), io);  // returns a value
CompletableFuture<Void> log  = CompletableFuture.runAsync(() -> audit("login"), io);  // no value
CompletableFuture<String> ready = CompletableFuture.completedFuture("x");              // already done

// Manual completion -- bridge a callback API into a future
CompletableFuture<String> cf = new CompletableFuture<>();
client.onResponse(body -> cf.complete(body));
client.onError(ex -> cf.completeExceptionally(ex));

Without the second argument, supplyAsync runs on ForkJoinPool.commonPool(). For anything that blocks (HTTP, JDBC, files) always pass your own executor, otherwise a few slow calls block the shared pool used by parallel streams.

thenApply vs thenCompose vs thenCombine

thenApply A --(T -> U)--> B map: plain function on the result thenCompose A --(T -> CompletableFuture<U>)--> B flatMap: next step is itself async thenCombine A ---\ >--(T, U) -> V--> C two INDEPENDENT futures, run in parallel B ---/
// thenApply: transform the value
CompletableFuture<String> name = fetchUser(1, io).thenApply(User::name);
name.join();                                                        // "user1"

// If the function returns a future, thenApply NESTS it
CompletableFuture<CompletableFuture<Integer>> nested =
        fetchUser(2, io).thenApply(u -> fetchScore(u, io));        // awkward: join().join()
// thenCompose flattens it -- use it when step 2 depends on step 1's result
CompletableFuture<Integer> score = fetchUser(2, io).thenCompose(u -> fetchScore(u, io));
score.join();                                                       // 20

// thenCombine: both start now, combine when both are done
CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> 100, io);
CompletableFuture<Double>  rate  = CompletableFuture.supplyAsync(() -> 0.9, io);
price.thenCombine(rate, (p, r) -> p * r).join();                    // 90.0
MethodTakesStream / Optional twin
thenApplyFunction<T, U>map
thenComposeFunction<T, CompletableFuture<U>>flatMap
thenCombineanother future + BiFunction<T, U, V>zip
thenAcceptConsumer<T> → CompletableFuture<Void>forEach
thenRunRunnable (ignores the value)—
applyToEither / acceptEitheranother future; uses whichever finishes first—

Waiting for many: allOf and anyOf

List<CompletableFuture<User>> fs = IntStream.rangeClosed(1, 3)
        .mapToObj(i -> fetchUser(i, io)).toList();

List<String> names = CompletableFuture.allOf(fs.toArray(new CompletableFuture[0]))  // CompletableFuture<Void>
        .thenApply(v -> fs.stream().map(CompletableFuture::join)   // join is instant: all are done
                                   .map(User::name).toList())
        .join();
// [user1, user2, user3]

CompletableFuture<Object> first = CompletableFuture.anyOf(slow, fast);   // Object, not T
first.join();   // "fast" (slow sleeps 300 ms; timing-based, but reliable in practice)

allOf returns Void — you collect results from the original futures. If any future fails, allOf fails too (after all have finished). anyOf completes with the first one to finish, even if that one failed.

Error handling: exceptionally, handle, whenComplete

CompletableFuture<Integer> boom = CompletableFuture.supplyAsync(() -> {
    throw new IllegalStateException("db down");
}, io);

boom.exceptionally(ex -> -1).join();                        // -1   (recover with a fallback)
// ex here is a CompletionException wrapping the IllegalStateException -- use ex.getCause()

boom.handle((v, ex) -> ex == null ? "ok " + v
                                  : "failed: " + ex.getCause().getMessage()).join();
// "failed: db down"   (always runs; sees value OR error; can change the result)

boom.whenComplete((v, ex) -> log(ex)).join();
// whenComplete only OBSERVES: join() still throws CompletionException (cause: db down)

boom.get();    // throws ExecutionException (checked)
boom.join();   // throws CompletionException (unchecked) -- nicer inside lambdas
MethodRuns onCan change the result?Like
exceptionally(fn)failure onlyyes: returns a replacement valuecatch
handle((v, ex) -> …)success and failureyes: returns a new valuecatch + normal path
whenComplete((v, ex) -> …)success and failureno: passes the original outcome onfinally
exceptionallyCompose (12)failure onlyyes, with another future (e.g. retry call)async catch

An exception skips every thenApply/thenCompose step after it, until the first exceptionally/handle — exactly like a throw jumping to the nearest catch.

Which thread runs the callback?

CompletableFuture.completedFuture("x")
        .thenApply(s -> ...);        // already done: runs right now in the CALLER thread -> main

CompletableFuture.supplyAsync(() -> { sleep(100); return "y"; }, io)
        .thenApply(s -> ...);        // not done yet: runs in the thread that completes it -> io-pool

CompletableFuture.supplyAsync(() -> "z", io)
        .thenApplyAsync(s -> ...);   // *Async without executor -> ForkJoinPool.commonPool-worker-N
        // .thenApplyAsync(fn, io)   // *Async with executor   -> that executor

All three results above were printed by the verification run. Rule: a non-async callback runs in whichever thread happens to complete the previous stage, or in the thread that registers it if the stage is already done. You do not control it. If a callback is slow or blocking, use the ...Async(fn, executor) variant so it does not steal time from an I/O thread (for example, a Netty event loop).

Timeouts (Java 9)

CompletableFuture<String> slowCall = CompletableFuture.supplyAsync(() -> { sleep(500); return "real"; }, io);
slowCall.completeOnTimeout("fallback", 50, TimeUnit.MILLISECONDS).join();   // "fallback"

CompletableFuture<String> slowCall2 = CompletableFuture.supplyAsync(() -> { sleep(500); return "real"; }, io);
slowCall2.orTimeout(50, TimeUnit.MILLISECONDS).join();
// throws CompletionException, cause: java.util.concurrent.TimeoutException

Both complete the future; they do not stop the underlying work. The task keeps running on its thread until it finishes, and cancel(true) on a CompletableFuture does not interrupt it either. For real cancellation, make the task check a flag or use an HTTP client timeout.

A realistic pipeline

CompletableFuture<Dashboard> dashboard =
        CompletableFuture.supplyAsync(() -> userService.find(id), io)          // 1. user
            .thenCompose(user -> {
                var orders = CompletableFuture.supplyAsync(() -> orderService.recent(user), io);
                var recs   = CompletableFuture.supplyAsync(() -> recService.forUser(user), io);
                return orders.thenCombine(recs, (o, r) -> new Dashboard(user, o, r));  // 2. in parallel
            })
            .orTimeout(2, TimeUnit.SECONDS)                                     // 3. overall deadline
            .exceptionally(ex -> Dashboard.empty());                             // 4. degrade gracefully

Future vs CompletableFuture

Future (Java 5)CompletableFuture (Java 8)
Get the resultBlocking get() onlyget()/join(), or react with callbacks without blocking
Chain stepsNothenApply, thenCompose, …
Combine severalManual loop of get() callsthenCombine, allOf, anyOf
Error handlingExecutionException from get()exceptionally, handle, whenComplete
Complete it yourselfNocomplete, completeExceptionally
Timeoutsget(timeout) (blocks)orTimeout, completeOnTimeout (non-blocking, Java 9)

Common pitfalls

  • Calling join() too early: a.join(); b.join(); inside a loop makes the code sequential again. Start all futures first, then wait once with allOf.
  • Forgetting the returned future: cf.thenApply(...) returns a new future. Handlers attached to the old one do not see errors thrown in the new step.
  • Swallowed errors: a chain that ends in thenAccept and is never joined hides every exception. End chains with exceptionally or whenComplete that logs.
  • Blocking in the common pool: JDBC calls inside supplyAsync with no executor starve parallel streams across the JVM.
  • Deadlock by waiting on your own pool: a task in a 1-thread executor that calls join() on another task queued on the same executor waits forever.
Interview angle: "thenApply vs thenCompose?" — map vs flatMap; compose when the function returns a future. "What happens to an exception in the middle of a chain?" — later stages are skipped until exceptionally/handle. "Which pool runs supplyAsync?" — common ForkJoinPool unless you pass one; pass one for I/O. "get vs join?" — checked ExecutionException vs unchecked CompletionException.

33. Concurrent Collections

In plain words

The normal collections (HashMap, ArrayList) are not safe when several threads change them at once: updates get lost, and a HashMap can even be corrupted. java.util.concurrent has versions built for sharing: a map that lets many threads write different keys at the same time, a list that is cheap to read while others write, and queues that make threads wait politely for work.

A shared whiteboard. Collections.synchronizedMap is "one marker for the whole board": everyone queues for it. ConcurrentHashMap is a board split into many small boxes, each with its own marker: people writing in different boxes never wait for each other.

ConcurrentHashMap — how it works in Java 8+

Java 7 split the map into 16 "segments", each with its own lock. Java 8 threw that away. Now it is one array of bins (like HashMap) and:

  • Reads never lock. The table and node values are read with volatile semantics, so get sees the latest completed write.
  • Writing to an empty bin uses a single CAS (compare-and-swap, a lock-free CPU instruction) to install the first node.
  • Writing to a non-empty bin locks only that bin with synchronized on its first node. Two threads writing different bins run in parallel.
  • A bin with 8+ nodes becomes a red-black tree (TreeBin), same as HashMap.
  • Resizing is shared: threads that arrive during a resize help move bins to the new table.
  • size() sums per-thread counter cells (like LongAdder), so it is only an estimate while writes are happening.
Node[] table (read without locks) 0 1 2 3 4 5 6 7 k1=a k9=b writer: synchronized (first node) locks bin 1 only; bins 0, 2-7 stay free empty bin insert with CAS (no lock at all) Many writers on different bins run in parallel; readers never block.
ConcurrentHashMap (Java 8+): lock-free reads, CAS for an empty bin, a tiny lock per bin otherwise.

Use the atomic methods — get-then-put is still a race

ConcurrentHashMap<String, Integer> chm = new ConcurrentHashMap<>();
ConcurrentHashMap<String, Integer> racy = new ConcurrentHashMap<>();
// 4 threads, each doing this 100,000 times:
chm.merge("hits", 1, Integer::sum);                  // atomic read-modify-write
racy.put("hits", racy.getOrDefault("hits", 0) + 1);  // two separate calls: NOT atomic

chm.get("hits");    // 400000
racy.get("hits");   // less than 400000 -- updates lost (true in every run; exact number varies)

Each single call on a ConcurrentHashMap is thread-safe, but a sequence of calls is not. Any "check then act" must be one call:

map.putIfAbsent(k, v);                                   // insert only if missing
map.computeIfAbsent(k, key -> new CopyOnWriteArrayList<>()).add(item);  // lazy create, once
map.compute(k, (key, old) -> old == null ? 1 : old + 1); // full control
map.merge(k, 1, Integer::sum);                           // counting
map.replace(k, expectedOld, newValue);                   // CAS-style update
// Bulk ops (Java 8) take a parallelism threshold:
long total = map.reduceValuesToLong(1, Integer::longValue, 0L, Long::sum);

The function passed to compute/merge runs while the bin is locked: keep it short, and never modify the same map from inside it (Java 9+ can throw IllegalStateException: Recursive update).

No null keys or values

chm.put("k", null);   // NullPointerException
chm.put(null, 1);     // NullPointerException
new HashMap<>().put(null, null);   // fine

Why? In a concurrent map, get(k) == null must mean "absent". If nulls were allowed you could not tell "absent" from "present with value null", and you cannot fix that with a follow-up containsKey because another thread may change the map in between.

Collections.synchronizedMap vs ConcurrentHashMap vs Hashtable

HashtableCollections.synchronizedMapConcurrentHashMap
LockingEvery method synchronized on the tableEvery method synchronized on one mutexCAS + per-bin lock; reads lock-free
Concurrent writers1 at a time1 at a timeMany (different bins)
IterationFail-fast; you must lock manuallyFail-fast; you must hold synchronized (map) while iteratingWeakly consistent: never throws ConcurrentModificationException, may or may not show recent changes
NullsNoWhatever the wrapped map allowsNo
StatusLegacy — do not useFine for low contention or wrapping a TreeMapThe default choice
Map<String, Integer> sync = Collections.synchronizedMap(new HashMap<>());
synchronized (sync) {                        // required, or iteration can throw CME
    for (String k : sync.keySet()) { ... }
}
for (Integer k : chm.keySet()) chm.put(k + 10, 0);   // ConcurrentHashMap: no exception

Sorted and concurrent: ConcurrentSkipListMap / ConcurrentSkipListSet (O(log n), lock-free).

CopyOnWriteArrayList

Every write (add, set, remove) copies the whole internal array and swaps in the new one. Readers and iterators work on the old array — a snapshot — with no locking.

List<String> cow = new CopyOnWriteArrayList<>(List.of("a", "b"));
for (String s : cow) cow.add(s + "!");   // iterator sees the snapshot [a, b]
System.out.println(cow);                 // [a, b, a!, b!]  -- no exception, no infinite loop

List<String> al = new ArrayList<>(List.of("a", "b"));
for (String s : al) al.add(s);           // ConcurrentModificationException

Perfect for read-mostly lists: event listeners, config, routing tables. Terrible for write-heavy lists: each add is O(n) and creates garbage. The iterator does not support remove.

BlockingQueue and producer-consumer

A BlockingQueue adds methods that wait: put blocks while the queue is full, take blocks while it is empty. This is the safe way to hand work from one thread to another, and exactly what ThreadPoolExecutor uses inside.

ActionThrowsReturns special valueBlocksTimes out
Insertadd(e)offer(e) → falseput(e)offer(e, time, unit)
Removeremove()poll() → nulltake()poll(time, unit)
Lookelement()peek()——
private static final int POISON = -1;                 // "no more work" signal
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(3);   // bounded = back-pressure

Thread producer = new Thread(() -> {
    try {
        for (int i = 1; i <= 5; i++) queue.put(i);    // waits if 3 items are already queued
        queue.put(POISON);
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
Thread consumer = new Thread(() -> {
    try {
        int sum = 0;
        while (true) {
            int item = queue.take();                  // waits if empty
            if (item == POISON) break;
            System.out.println("consumed " + item);
            sum += item;
        }
        System.out.println("sum = " + sum);
    } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
producer.start(); consumer.start();
producer.join(); consumer.join();
// consumed 1 ... consumed 5, then: sum = 15   (FIFO order, so the output is deterministic)
QueueBounded?InsideNotes
ArrayBlockingQueueYes (fixed at creation)Array, one lock for put and takePredictable memory; optional fairness
LinkedBlockingQueueOptional (default Integer.MAX_VALUE)Linked nodes, two locks (put lock, take lock)Higher throughput; always pass a capacity
PriorityBlockingQueueNoHeapTakes the smallest element first
DelayQueueNoHeap by delayElement available only after its delay expires
SynchronousQueueCapacity 0Direct hand-offEach put waits for a take (cached thread pool)
ConcurrentLinkedQueueNoLock-free linked list (CAS)Not blocking: poll() returns null when empty; great for many threads, non-blocking use

Synchronizers: CountDownLatch, CyclicBarrier, Semaphore

// CountDownLatch: "wait until N things have happened" -- ONE-SHOT
CountDownLatch ready = new CountDownLatch(3);
for (String svc : List.of("db", "cache", "queue")) new Thread(ready::countDown).start();
ready.await();                 // main continues when the count hits 0

// CyclicBarrier: "N threads wait for EACH OTHER", then all go on -- REUSABLE
CyclicBarrier barrier = new CyclicBarrier(3, () -> System.out.println("phase finished by all"));
// each of 3 workers: for (int p = 0; p < 2; p++) barrier.await();
// prints "phase finished by all" twice: once per phase

// Semaphore: "at most N at a time" -- a pool of permits
Semaphore permits = new Semaphore(2);
permits.acquire();
try { callRateLimitedApi(); }
finally { permits.release(); }  // 6 tasks on 6 threads: max inside at once = 2 (verified)
CountDownLatchCyclicBarrierSemaphore
Who waitsOne or more "waiters" for other threads' eventsThe worker threads wait for each otherThreads wanting a permit
ReusableNo (count only goes down)Yes (resets after each trip)Yes
Typical useStart-up: wait for services; tests: wait for N callbacksPhased simulations, parallel algorithms in roundsLimit concurrent DB connections or API calls

Also know: Phaser (a flexible, reusable barrier with a changing number of parties) and Exchanger (two threads swap objects).

Interview angle: "How is ConcurrentHashMap thread-safe without locking the whole map?" — CAS on empty bins, synchronized on a bin's head node, volatile reads. "Why no nulls?" — ambiguity of get returning null. "Is if (!map.containsKey(k)) map.put(k, v) safe on a ConcurrentHashMap?" — no, use putIfAbsent/computeIfAbsent. "Write a producer-consumer" — bounded BlockingQueue + poison pill.

34. Virtual Threads & Deadlocks

In plain words

A normal Java thread (a platform thread) is a thin wrapper around an operating-system thread: expensive to create, about 1 MB of stack, and you can have only a few thousand. A virtual thread (final in Java 21) is a thread managed by the JVM itself. Millions can exist. When one waits for I/O, the JVM parks it on the heap and uses the real OS thread for another virtual thread.

A deadlock is when two or more threads each hold something the other needs, so all of them wait forever.

Virtual threads are like a restaurant where waiters (OS threads) never stand by a table waiting for the diner to decide — they take an order, move on, and come back when called. Deadlock is two people in a narrow hallway, each politely waiting for the other to step aside first.

Platform vs virtual threads

Platform threads T1 T2 T3 1 : 1 OS OS OS ~1 MB stack each, thousands at most Virtual threads (Java 21) v1 v2 v3 v4 v5 v6 v7 v8 v9 v10 mounted carrier 1 carrier 2 carrier 3 OS thread OS thread OS thread Carriers = a ForkJoinPool, one per core. Waiting virtual threads are parked on the heap (a few KB each).
M virtual threads run on N carrier threads. When v1 blocks on I/O, it unmounts and the carrier picks up another runnable virtual thread.

Using them

Thread vt = Thread.ofVirtual().name("my-virtual").start(() ->
        System.out.println("isVirtual=" + Thread.currentThread().isVirtual()));   // isVirtual=true
Thread.startVirtualThread(() -> work());              // shortest form
vt.isDaemon();                                        // true -- always daemon

// 10,000 tasks that each block for 1 second
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> ex.submit(() -> {
        Thread.sleep(Duration.ofSeconds(1));          // virtual thread unmounts while sleeping
        return i;
    }));
}   // close() waits for all tasks
// Verified run: finished in 1407 ms. A fixed pool of 100 platform threads needs 100 rounds = ~100 s.

The model is "one virtual thread per task". Do not pool virtual threads — they are cheap to create and pooling defeats the purpose. To limit load on a downstream system, use a Semaphore, not a small pool.

When they help and when they don't

WorkloadVirtual threads?Why
Web server, many requests each waiting on DB/HTTP (thread-per-request)Yes — the main use caseThroughput limited by how many requests can wait at once, not by CPU
CPU-bound (image resize, crypto, number crunching)No benefitOnly N cores exist; a pool of about N platform threads is just as good
Heavy ThreadLocal caches (e.g. per-thread buffers)CarefulA million threads = a million copies. Java 21 preview: ScopedValue
Long blocking inside synchronized (Java 21–23)Careful: pinningSee below

Virtual threads make blocking code cheap, not faster: a single request still takes as long. The win is that you can write simple blocking code instead of reactive/callback code and still handle huge concurrency.

Pinning

In Java 21, a virtual thread that blocks while inside a synchronized block (or a native method) cannot unmount — it pins its carrier thread. With only one carrier per core, a few pinned threads can stall the whole application.

synchronized (LOCK) {
    Thread.sleep(50);    // or a JDBC call -- pins the carrier in Java 21
}
// Run with -Djdk.tracePinnedThreads=short to detect it. Verified output:
// VirtualThread[#20]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
//     PinDemo.lambda$main$0(PinDemo.java:6) <== monitors:1

// Fix for Java 21: use a ReentrantLock, which virtual threads can unmount from
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try { callDatabase(); } finally { lock.unlock(); }

Java 24 (JEP 491) removed pinning for synchronized; on Java 21 it still matters, and interviewers like to ask.

Deadlock: the four conditions (Coffman)

A deadlock is possible only if all four hold. Break any one and it cannot happen.

ConditionMeaningHow to break it
Mutual exclusionA resource can be held by one thread at a timeUse lock-free or immutable data where possible
Hold and waitA thread holds one lock while waiting for anotherAcquire all locks at once, or release before waiting
No preemptionA lock cannot be taken away; only the owner releases ittryLock with timeout: give up and release what you hold
Circular waitT1 waits for T2, T2 waits for T1 (a cycle)Global lock ordering: everyone takes locks in the same order
t1 holds A ----wants----> B ^ | | held by held by | | v A <----wants---- t2 holds B cycle = deadlock

A deadlock you can reproduce every time

static final Object A = new Object();
static final Object B = new Object();
CountDownLatch bothHoldFirstLock = new CountDownLatch(2);   // forces the bad timing

Thread t1 = new Thread(() -> {
    synchronized (A) {
        bothHoldFirstLock.countDown(); await(bothHoldFirstLock);
        synchronized (B) { System.out.println("t1 got both"); }   // never printed
    }
}, "t1");
Thread t2 = new Thread(() -> {
    synchronized (B) {                                          // OPPOSITE order
        bothHoldFirstLock.countDown(); await(bothHoldFirstLock);
        synchronized (A) { System.out.println("t2 got both"); }   // never printed
    }
}, "t2");
t1.start(); t2.start();
Thread.sleep(300);

// Detect it from inside the JVM -- the same check jstack runs
long[] ids = ManagementFactory.getThreadMXBean().findDeadlockedThreads();
// deadlocked threads: 2
// t1 state=BLOCKED waiting for lock owned by t2
// t2 state=BLOCKED waiting for lock owned by t1

Without the latch it is timing-dependent (it may run fine 999 times), which is exactly why deadlocks escape testing.

Fixes

// Fix 1: lock ordering -- always lock the lower id first
void transfer(Account from, Account to, long amount) {
    Account first  = from.id() < to.id() ? from : to;
    Account second = first == from ? to : from;
    synchronized (first) {
        synchronized (second) {
            from.withdraw(amount);
            to.deposit(amount);
        }
    }
}

// Fix 2: tryLock with timeout -- back off instead of waiting forever
static void transfer(Lock first, Lock second) throws InterruptedException {
    while (true) {
        if (first.tryLock(50, TimeUnit.MILLISECONDS)) {
            try {
                if (second.tryLock(50, TimeUnit.MILLISECONDS)) {
                    try { doWork(); return; }
                    finally { second.unlock(); }
                }
            } finally { first.unlock(); }
        }
        Thread.sleep(ThreadLocalRandom.current().nextInt(10));   // random back-off
    }
}
// Two threads calling this with the locks in OPPOSITE order: finishes, no deadlock (verified)

Other fixes: hold locks for as short a time as possible, never call unknown code (listeners, callbacks) while holding a lock, and prefer higher-level tools (ConcurrentHashMap, queues) over nested locks.

Livelock and starvation

  • Livelock: threads are not blocked — they keep running — but make no progress, because each keeps reacting to the other. Two tryLock loops that back off and retry with the same fixed delay can keep colliding forever. Fix: random back-off (as above).
  • Starvation: one thread never gets the resource because others always win — e.g. a greedy thread re-grabbing an unfair lock, or low-priority tasks behind an endless stream of high-priority ones. Fix: fair locks (new ReentrantLock(true)), bounded work per turn.

Detecting a deadlock in production: jstack

$ jps                      # find the process id
$ jstack <pid>             # or: jcmd <pid> Thread.print
Found one Java-level deadlock:
=============================
"t1":
  waiting to lock monitor 0x000000013210fdd0 (object 0x0000000787f14ac8, a java.lang.Object),
  which is held by "t2"

"t2":
  waiting to lock monitor 0x0000000132117100 (object 0x0000000787f14ab8, a java.lang.Object),
  which is held by "t1"

That is real output from a program that deadlocks (addresses differ per run). Look for threads in state BLOCKED (waiting for a monitor) or WAITING on a lock. Take 2–3 dumps a few seconds apart: a thread stuck on the same line in every dump is your suspect. VisualVM and JDK Mission Control also have a "detect deadlock" button.

Interview angle: "Virtual vs platform threads?" — JVM-managed, cheap, unmount on blocking; for I/O-bound concurrency, not CPU work; don't pool them. "What is pinning?" — blocking inside synchronized keeps the carrier busy (Java 21). "Four conditions of deadlock and how to prevent it?" — break circular wait with lock ordering, or no-preemption with tryLock. "How do you find one in prod?" — jstack / jcmd Thread.print.
Part 6 — The JVM Inside

35. JVM Memory: Heap, Stack & Metaspace

In plain words

When a Java program runs, the JVM splits its memory into areas with different jobs. The heap holds every object you create with new, and is shared by all threads. Each thread also gets its own stack, which holds one frame per method call currently running (its local variables and parameters). Metaspace holds the description of each loaded class: its methods, fields and bytecode.

An office. The heap is the shared filing room — everyone can reach any folder, and a cleaner (the garbage collector) removes folders nobody points to. Each worker's stack is the pile of sticky notes on their own desk: the top note is the task they are on now, and it is thrown away the moment the task ends. Metaspace is the company handbook describing how each kind of form works.

The runtime data areas

Shared by all threads Heap (-Xms / -Xmx): all objects and arrays Young generation Eden S0 S1 Old generation long-lived objects Metaspace class metadata (native memory) Code cache JIT-compiled machine code Per thread (one set for each thread) Thread 1 PC register JVM stack parse() load() main() Native method stack Thread 2 PC register JVM stack handle() run() Native method stack
The top (highlighted) frame of each stack is the method running right now. A new frame is pushed on each call and popped on return.

What lives where

AreaHoldsShared?Error when it runs out
HeapAll objects and arrays, including their instance fields; the String pool (since Java 7); static fields (stored with the Class object, since Java 8)All threadsOutOfMemoryError: Java heap space
JVM stackOne frame per active call: local variables (primitives and references), operand stack, return addressPer threadStackOverflowError
Metaspace (Java 8+, replaced PermGen)Class metadata: method bytecode, field and method descriptions, runtime constant poolAll threadsOutOfMemoryError: Metaspace
Code cacheNative code produced by the JIT compilerAll threadsJIT stops compiling ("CodeCache is full" warning)
PC registerAddress of the bytecode instruction the thread is executingPer thread—
Native method stackFrames for native (C/C++) code called through JNIPer threadStackOverflowError

Tracing one method call

void process() {
    int count = 3;                       // primitive: value lives IN the stack frame
    String name = "report";              // reference in the frame -> String object in the heap (pool)
    List<Integer> ids = new ArrayList<>(); // reference in the frame -> ArrayList object in the heap
    ids.add(count);                      // Integer object on the heap, referenced by the list's array
}   // frame popped: count, name, ids disappear; the ArrayList is now unreachable -> garbage
STACK (thread main) HEAP +-----------------------+ | process() | | count = 3 | | name = ref --------+-----> "report" (String) | ids = ref --------+-----> ArrayList --> Object[] --> Integer(3) +-----------------------+ | main() | | args = ref --------+-----> String[0] +-----------------------+

"Primitives live on the stack, objects live on the heap" is the right first answer, with two refinements: a primitive field of an object lives inside that object on the heap; and the JIT's escape analysis may avoid allocating an object on the heap at all if it never leaves the method (it replaces it with local scalars).

How an object is allocated

  • Each thread gets a private chunk of Eden called a TLAB (thread-local allocation buffer). Allocating is just "bump a pointer" inside the thread's own TLAB — no locking, a few machine instructions. That is why new in Java is very cheap.
  • Every object has a header (mark word for hash code, lock state and GC age, plus a pointer to its class): 12 bytes on a 64-bit JVM with compressed pointers, and objects are padded to multiples of 8 bytes. So an empty new Object() is 16 bytes and an Integer is 16 bytes to hold 4 bytes of data.
  • Very large objects (bigger than half a G1 region) go straight into special humongous regions instead of Eden.
  • Escape analysis: if the JIT proves an object never leaves the method, it can replace it with plain local variables (scalar replacement) — no heap allocation at all.

StackOverflowError vs OutOfMemoryError

static int depth = 0;
static void recurse() { depth++; recurse(); }   // no base case

try { recurse(); }
catch (StackOverflowError e) { System.out.println("StackOverflowError after ~" + (depth / 1000) * 1000 + " calls"); }

List<long[]> hog = new ArrayList<>();
try { while (true) hog.add(new long[1_000_000]); }   // 8 MB each, all still reachable
catch (OutOfMemoryError e) { hog = null; System.out.println(e.getMessage()); }

try { int[] huge = new int[Integer.MAX_VALUE]; }
catch (OutOfMemoryError e) { System.out.println(e.getMessage()); }

Verified with java -Xmx64m -Xss512k MemDemo:

StackOverflowError after ~6000 calls        (with -Xss2m: ~45000; varies run to run)
OutOfMemoryError: Java heap space after 7 blocks
OutOfMemoryError: Requested array size exceeds VM limit
ErrorTypical causeFirst fix
StackOverflowErrorInfinite or very deep recursion; toString/equals/hashCode that call each other on a cycle (bidirectional JPA relations)Fix the base case, make it iterative; raise -Xss only if the depth is legitimate
OOM: Java heap spaceMemory leak, or genuinely too much data loaded at onceHeap dump, find what holds the objects (s38); stream data instead of loading it all
OOM: GC overhead limit exceededMore than 98% of time spent in GC recovering under 2% of the heap (Parallel GC)Same as heap space — usually a leak
OOM: MetaspaceToo many classes: class loader leak on hot redeploy, runaway proxy/lambda generationFind the loader that is never released; set -XX:MaxMetaspaceSize to fail early
OOM: unable to create native threadToo many platform threads (OS limit or no memory for stacks)Bounded pools or virtual threads
OOM: Direct buffer memoryByteBuffer.allocateDirect outside the heap (Netty, NIO)Release buffers; -XX:MaxDirectMemorySize
OOM: Requested array size exceeds VM limitAsked for an array close to Integer.MAX_VALUE elementsA bug in size calculation

Both are Errors, not Exceptions: you normally don't catch them. After an OOM the application may be in a broken state; let it crash, collect the heap dump, and restart.

The flags you should know

FlagMeaningExample
-XmsInitial heap size-Xms2g
-XmxMaximum heap size (default: 1/4 of physical RAM)-Xmx2g
-XssStack size per thread (default 1 MB on Linux x64; 2 MB on macOS Apple Silicon, checked with -XX:+PrintFlagsFinal)-Xss512k
-XmnYoung generation size-Xmn512m
-XX:MaxMetaspaceSizeCap metaspace (default: unlimited)-XX:MaxMetaspaceSize=256m
-XX:MaxRAMPercentageHeap as % of container memory — use in Docker/Kubernetes instead of a fixed -Xmx-XX:MaxRAMPercentage=75
-XX:+HeapDumpOnOutOfMemoryErrorWrite a heap dump when OOM happenswith -XX:HeapDumpPath=/dumps

In servers, set -Xms equal to -Xmx so the heap never has to grow or shrink at runtime. Remember that the Java process uses more than -Xmx: metaspace, thread stacks, code cache, direct buffers and the GC's own structures all live outside the heap. Leave headroom in a container or the OS will kill the process (Kubernetes OOMKilled) without any Java error.

Interview angle: "Where are local variables, objects, static fields stored?" — locals in the stack frame (references point to the heap), objects on the heap, static fields on the heap with the class mirror (not in PermGen/metaspace since Java 8). "PermGen vs Metaspace?" — PermGen was a fixed-size heap region (Java 7 and earlier, OOM: PermGen space); Metaspace is native memory that grows by default. "Is the stack thread-safe?" — yes, each thread has its own, so local variables are never shared.

36. Garbage Collection

In plain words

In Java you never free memory yourself. The garbage collector (GC) finds objects that the program can no longer reach and reuses their memory. "Reachable" means: you can get to the object by following references starting from a GC root — a local variable in a running method, a static field, an active thread, and so on. Everything else is garbage, even if it still points to other objects.

Balloons tied with strings. Hold a string (a root) and that balloon stays, along with any balloons tied to it. Cut the last string and it floats away — it doesn't matter that it is still tied to other loose balloons.

Reachability and GC roots

GC roots reachable (kept) unreachable (garbage) local variables in live frames ---> Order ---> Customer static fields ---> CACHE map ---> Session Cart <---> Item active threads ---> Thread object (a cycle, but no path JNI references, monitors held from any root: collected)

Because the JVM traces from roots, reference cycles are not a problem (unlike simple reference counting in, say, older Python or Objective-C). And System.gc() is only a hint; finalize() is deprecated (Java 9) and should never be used — use try-with-resources or Cleaner.

static class Node { Node other; }
Node a = new Node(), b = new Node();
a.other = b; b.other = a;                          // a cycle
WeakReference<Node> watchA = new WeakReference<>(a);   // watch without keeping it alive
a = null; b = null;                                // no root reaches the cycle any more
System.gc();
watchA.get() == null;                              // true -- the cycle was collected (verified)

The generational hypothesis

Studies of real programs show that most objects die young (temporary strings, iterators, request objects), and the few that survive a while tend to live a long time. So the heap is split into generations and the young one is collected often and cheaply.

Eden new objects (most die here) S0 survivor S1 survivor Old generation survived many GCs live objects swap sides each minor GC copy live promote (age) Minor (young) GC: Eden + one survivor space. Frequent, short pause. Major / full GC: old generation or the whole heap. Rare, longer pause.
Each object has an age (number of minor GCs survived). After a threshold (up to 15) it is promoted to the old generation.

The young collection is a copying collector: it copies the few live objects out and treats the whole of Eden as empty afterwards. Its cost depends on the number of live objects, not the amount of garbage — which is why short-lived garbage is almost free. The verified log below (G1, -Xmx64m -Xlog:gc, a loop allocating 200,000 temporary 1 KB arrays and keeping 200) shows each young GC shrinking the used heap from 38 MB to 1 MB in under a millisecond:

[0.005s][info][gc] Using G1
[0.040s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 24M->1M(64M) 0.888ms
[0.050s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 38M->1M(64M) 0.396ms
[0.056s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 38M->1M(64M) 0.493ms

Minor, major, full

KindWhat it collectsFrequency / cost
Minor (young) GCYoung generation onlyVery often; short pause (ms)
Major GCOld generation (in G1: "mixed" collections take young + some old regions)Less often; can be concurrent
Full GCWhole heap (+ metaspace), usually stop-the-world and single-pass compactingRare; long pause — a warning sign in G1/ZGC

The basic algorithm: mark, sweep, compact

heap before: [A][x][B][x][x][C][x] x = garbage 1. mark: trace from roots, flag A, B, C as live 2. sweep: [A][ ][B][ ][ ][C][ ] free the rest -- but memory is now FRAGMENTED 3. compact: [A][B][C][ ] slide live objects together, update references -- new objects get fast "bump the pointer" allocation

Copying (used for the young generation) combines sweep and compact: copy live objects to an empty space and forget the old one. Fast when few survive, but needs spare space.

Stop-the-world

A stop-the-world (STW) pause is when the GC freezes all application threads (at "safepoints") so the object graph cannot change under it. Every HotSpot collector has some STW phases; modern ones do most of the work concurrently with the application and keep pauses short. Throughput (total work done) and latency (length of pauses) pull against each other: that trade-off is the whole reason for having several collectors.

The collectors

CollectorFlagHowPausesBest for
Serial-XX:+UseSerialGCOne thread, generational, STW for everythingLong on big heapsSmall heaps, 1 CPU containers, CLI tools
Parallel (throughput)-XX:+UseParallelGCMany threads, STW, generationalHundreds of ms+ on big heapsBatch jobs where total throughput matters, not pauses. Default in Java 8
G1 (Garbage First)-XX:+UseG1GCHeap split into equal regions (1–32 MB, picked from heap size); concurrent marking; collects the regions with the most garbage first; aims for a pause targetTarget -XX:MaxGCPauseMillis=200 (default)General server apps. Default since Java 9 (verified: Java 21 here prints -XX:+UseG1GC)
ZGC-XX:+UseZGC (production 15; generational in 21 with -XX:+ZGenerational, default mode in 23)Almost everything concurrent, including moving objects (coloured pointers + load barriers)Under 1 ms, independent of heap sizeLarge heaps (GBs to TBs), latency-sensitive services
Shenandoah-XX:+UseShenandoahGC (production 15; in OpenJDK builds, not Oracle JDK)Concurrent compaction (Brooks pointers / load barriers)A few ms, independent of heap sizeSame niche as ZGC; Red Hat builds
Epsilon-XX:+UseEpsilonGC (experimental)Allocates, never collectsNonePerformance tests, very short-lived jobs

CMS (Concurrent Mark Sweep) was deprecated in Java 9 and removed in Java 14 — if you see it in old configs, it needs replacing with G1 or ZGC.

G1 in a little more depth

G1 heap = ~2048 equal regions, each labelled on the fly: [E][E][O][ ][S][O][H][H][E][ ][O][E][ ][O] E = eden S = survivor O = old H = humongous (huge object) [ ] = free young GC: evacuate all E + S regions (STW, parallel) -> copies live objects to free regions concurrent mark: when old occupancy passes ~45%, trace the old regions while the app runs mixed GC: evacuate young regions + the old regions with the MOST garbage ("garbage first")

Because it cleans a few regions at a time and chooses how many from its pause target, G1 keeps pauses roughly predictable without compacting the whole heap. A full GC in G1 means it fell behind (allocation faster than concurrent marking could keep up, or no free regions for evacuation) — treat it as a signal to grow the heap or fix allocation.

Tuning basics

  • Start with defaults. Set heap size (-Xms=-Xmx, or MaxRAMPercentage in containers) and choose the collector. Most apps need nothing else.
  • Turn on GC logging (Java 9+ unified logging): -Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=20m. Analyze with GCeasy or GCViewer.
  • Pauses too long on G1? Try lowering -XX:MaxGCPauseMillis, or move to ZGC. Frequent full GCs? Usually the heap is too small or there is a leak — tuning flags won't fix a leak.
  • Allocate less: the cheapest GC is the one that doesn't happen. Avoid boxing in hot loops, reuse builders, stream instead of loading everything.

Reference types

Object strong = new Object();                                  // normal reference
WeakReference<Object> weak = new WeakReference<>(new Object()); // nothing else points to it
WeakReference<Object> weakButHeld = new WeakReference<>(strong);
SoftReference<byte[]> soft = new SoftReference<>(new byte[1024]);
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantom = new PhantomReference<>(new Object(), queue);

System.gc();
weak.get() == null;          // true  -- cleared at the first GC
weakButHeld.get() == null;   // false -- still strongly reachable through 'strong'
soft.get() == null;          // false -- kept while memory is not low
phantom.get() == null;       // true  -- ALWAYS null; only tells you "it was collected"
queue.remove(1000) == phantom;   // true -- enqueued after collection

Map<Object, String> cache = new WeakHashMap<>();
Object key = new Object();
cache.put(key, "metadata");
key = null; System.gc();
cache.size();                // 0 -- entry vanished with its key

The results above are from a verified run (with G1, where System.gc() triggers a full collection; the spec does not guarantee it, so never rely on System.gc() in real code).

TypeCollected whenget()Used for
StrongNever, while reachable—Everything normal
SoftReferenceOnly when the JVM is low on memory (before throwing OOM)Object until clearedMemory-sensitive caches (but a real cache like Caffeine is better)
WeakReferenceAt the next GC once no strong/soft refs remainObject until clearedWeakHashMap, canonicalizing maps, listener registries that shouldn't keep listeners alive; ThreadLocalMap keys
PhantomReferenceAfter the object is finalized/collected; enqueued on a ReferenceQueueAlways nullCleanup of native resources after the object is gone (java.lang.ref.Cleaner, Java 9, is built on it)
Interview angle: "How does the GC decide an object is garbage?" — not reachable from GC roots; cycles are fine. "Why generations?" — most objects die young, so collecting the young space is cheap. "What is the default GC and why?" — G1 since Java 9: balanced throughput and predictable pauses via regions. "When would you use ZGC?" — big heaps and sub-millisecond pause needs. "Weak vs soft reference?" — weak: cleared at the next GC; soft: cleared only under memory pressure. "Can you force GC?" — no, System.gc() is a request.

37. Class Loading

In plain words

The JVM does not load all classes at start-up. It loads each class lazily, the first time it is really needed: it finds the .class bytes (in a JAR, a folder, over the network), checks them, and prepares them. Objects called class loaders do the finding, and they are arranged in a chain: each loader first asks its parent, so core classes like String always come from the JDK and can't be faked by your own classpath.

A library request desk. You ask your local branch for a book; the branch first asks the central library. Only if the central library doesn't have it does the branch look on its own shelves. That way everyone gets the one official copy of the classics.

Three phases: loading, linking, initialization

LOADING LINKING INITIALIZATION find .class --> verify --> prepare --> resolve --> run static initializers bytes, create bytecode is allocate static symbolic and static field Class object safe/valid fields, set to refs -> real assignments, in textual in metaspace DEFAULTS (0/null) (often lazy) order: <clinit>
  • Loading: a class loader reads the bytes and creates a Class object. Metadata goes to metaspace.
  • Verification: the bytecode verifier checks types, stack use and jumps are legal. This is why a hand-crafted .class file cannot corrupt the JVM.
  • Preparation: static fields get memory and default values (static int x = 5; is 0 here).
  • Resolution: symbolic names in the constant pool ("java/util/List") are turned into direct references, usually lazily when first used.
  • Initialization: the compiler-generated <clinit> method runs — all static field initializers and static { } blocks, top to bottom. Now x becomes 5. The JVM guarantees this runs once, under a lock, so it is thread-safe.

The loaders and parent delegation

Bootstrap loader java.base: java.lang, java.util (shows as null) Platform loader (Java 9+) java.sql, java.xml, other JDK modules Application (system) loader your classpath / module path Custom loaders app servers, plugins, hot reload 1. ask the parent first 2. parent can't find it: child searches itself
Parent delegation: a request goes up the chain first; each loader searches its own locations only if every parent above it failed.
String.class.getClassLoader();                          // null      (bootstrap is native code)
java.sql.Connection.class.getClassLoader().getName();   // "platform"
LoaderDemo.class.getClassLoader().getName();            // "app"
app.getParent().getName();                              // "platform"
app.getParent().getParent();                            // null      (bootstrap)

(All verified on Java 21.) Before Java 9 the middle loader was the extension loader reading jre/lib/ext; the module system replaced it with the platform loader.

Why delegation? (1) Security: your own java.lang.String on the classpath is never used — the bootstrap loader already provided the real one (and the JVM refuses to define classes in java.* packages from other loaders). (2) No duplicates: a class is loaded once by the highest loader that can see it. (3) A class's identity is its name + its loader: the same .class loaded by two loaders gives two different classes, and casting between them throws ClassCastException (a classic app-server puzzle: "cannot cast com.x.Foo to com.x.Foo").

Some systems deliberately break delegation: Tomcat's web-app loaders look in the WAR first (so each app can have its own library versions), and JDBC's ServiceLoader uses the thread context class loader so JDK code can load driver classes from your classpath.

When does a static initializer run? (verified)

static class Config {
    static final int MAX = 10;               // compile-time constant: copied into the caller
    static int counter = 5;                  // NOT a constant
    static { System.out.println("  [Config static block runs]"); }
    static void hello() { System.out.println("  Config.hello()"); }
}
static class Parent { static { System.out.println("  [Parent init]"); } static int p = 1; }
static class Child extends Parent { static { System.out.println("  [Child init]"); } static int c = 2; }

System.out.println("1. read constant MAX = " + Config.MAX);   // no init
System.out.println("2. declare array");
Config[] arr = new Config[3];                                 // no init
System.out.println("3. Config.class literal: " + Config.class.getSimpleName());  // no init
System.out.println("4. read non-constant counter");
int x = Config.counter;                                       // init happens HERE
System.out.println("5. call static method");
Config.hello();                                               // no second init
System.out.println("6. Child.p (field declared in Parent)");
int y = Child.p;                                              // only Parent is initialised
System.out.println("7. new Child()");
new Child();
System.out.println("8. Class.forName initialises by default");
Class.forName("InitDemo$Lazy");
1. read constant MAX = 10
2. declare array
3. Config.class literal: Config
4. read non-constant counter
  [Config static block runs]
5. call static method
  Config.hello()
6. Child.p (field declared in Parent)
  [Parent init]
7. new Child()
  [Child init]
8. Class.forName initialises by default
  [Lazy init]

A class is initialized on its first active use: new, calling a static method, reading/writing a non-constant static field, initializing a subclass (the parent goes first), reflection like Class.forName(name), or being the main class. It is not initialized by reading a compile-time static final constant (inlined by javac), creating an array of it, Foo.class, or accessing a parent's static field through a child. Class.forName(name, false, loader) loads without initializing.

This laziness is what makes the holder idiom singleton work: the inner Holder class, and therefore the instance, is created only when getInstance() first touches it, and the JVM's init lock makes it thread-safe with no synchronized of your own.

public final class Registry {
    private Registry() {}
    private static class Holder { static final Registry INSTANCE = new Registry(); }
    public static Registry getInstance() { return Holder.INSTANCE; }
}

Writing a custom class loader

Override findClass, not loadClass. The inherited loadClass already does the delegation (check cache, ask parent, then call your findClass), so you keep the safety guarantees for free.

static class DirLoader extends ClassLoader {
    private final Path dir;
    DirLoader(Path dir, ClassLoader parent) { super(parent); this.dir = dir; }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // called only after the parent chain failed
        Path file = dir.resolve(name.replace('.', '/') + ".class");
        try {
            byte[] bytes = Files.readAllBytes(file);
            return defineClass(name, bytes, 0, bytes.length);
        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    }
}

DirLoader l1 = new DirLoader(Path.of("plugins"), parent);
DirLoader l2 = new DirLoader(Path.of("plugins"), parent);
Class<?> c1 = l1.loadClass("Plugin");
Class<?> c2 = l2.loadClass("Plugin");
c1.getName().equals(c2.getName());          // true
c1 == c2;                                   // false -- different loaders, different classes
l1.loadClass("java.lang.String").getClassLoader() == null;   // true -- still delegated
c2.cast(c1.getDeclaredConstructor().newInstance());
// ClassCastException: Cannot cast Plugin to Plugin

This is how plugin systems, app servers and hot reload work: drop the loader (and every object from its classes), and the classes can be unloaded and reloaded fresh. Keep even one reference to an old object and the whole loader stays — the class loader leak from s38.

ClassNotFoundException vs NoClassDefFoundError

ClassNotFoundExceptionNoClassDefFoundError
KindChecked exceptionError (a LinkageError)
WhenYou load a class by name at runtime: Class.forName, loadClass, ClassLoader.findSystemClassThe class was there at compile time but the JVM cannot get it at run time
Typical causeTypo in the name, missing JDBC driver JAR, plugin not on classpathJAR missing from the runtime classpath, or the class's static initializer failed earlier
Class.forName("com.example.DoesNotExist");
// ClassNotFoundException: com.example.DoesNotExist

// Compiled UsesHelper + Helper, then deleted Helper.class:
// Exception in thread "main" java.lang.NoClassDefFoundError: Helper
//     at UsesHelper.main(UsesHelper.java:2)
// Caused by: java.lang.ClassNotFoundException: Helper

static class Broken {
    static int value = Integer.parseInt("not a number");   // static init throws
}
// first access:  ExceptionInInitializerError
// second access: NoClassDefFoundError   (the class is marked unusable forever)

The last case is a favourite trap: the log shows NoClassDefFoundError for a class that is clearly in the JAR. Scroll up to the first error — the real cause is the ExceptionInInitializerError from its static block.

Interview angle: "Explain parent delegation and why it exists" — ask parent first; security and uniqueness. "When is a static block executed?" — on first active use, once, thread-safely; not for compile-time constants. "Can two classes with the same name coexist?" — yes, in different loaders; they are different types. "CNFE vs NCDFE?" — dynamic lookup failed vs compiled-against class missing (or failed init) at runtime.

38. Performance, Profiling & Memory Leaks

In plain words

Java has a garbage collector, but it can still leak memory: a leak is an object you no longer need that is still reachable, usually because something long-lived (a static map, a listener list, a thread) keeps a reference to it. The GC only removes unreachable objects, so these pile up until OutOfMemoryError. Finding and fixing that — and slow code in general — is done with measurement tools, not guessing.

A coat check that hands out tickets but never takes coats back when people leave. Nothing is "lost" — every coat is neatly hung and tagged — but the room fills up anyway.

Common leak causes

CauseWhat happensFix
Static collections used as cachesstatic Map gets entries on every request, never removedBounded cache with eviction (Caffeine), TTL, or remove explicitly
Listeners / callbacks never removedAn event bus holds every registered listener, and each listener holds its whole screen/object graphUnregister in a close()/lifecycle hook; weak listener lists
ThreadLocal in thread poolsPool threads live forever, so their ThreadLocal values do too — and they leak into the next requestremove() in finally
Unclosed resourcesConnections, streams, statements hold native memory and handlestry-with-resources
Inner class / lambda referencesA non-static inner class (and an anonymous class) keeps a hidden reference to its outer instance; if the inner object outlives it, the outer object can't be collectedUse static nested classes for long-lived callbacks/tasks
Wrong equals/hashCode on keysMutable key or missing hashCode: put keeps adding "new" entries, remove never finds themImmutable keys (records) with proper equals/hashCode
Class loader leaksAfter a hot redeploy, one reference from a JDK/static object to the old app keeps every old class alive → OOM: MetaspaceClear drivers, threads and ThreadLocals on undeploy

The ThreadLocal leak you can see

static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();
ExecutorService pool = Executors.newSingleThreadExecutor();     // ONE reused thread

pool.submit(() -> CURRENT_USER.set("alice")).get();            // request 1 forgets to clean up
pool.submit(() -> System.out.println("request 2 sees user = " + CURRENT_USER.get())).get();
// request 2 sees user = alice        -- a leak AND a security bug

pool.submit(() -> {
    CURRENT_USER.set("bob");
    try { /* handle request */ }
    finally { CURRENT_USER.remove(); }                         // always clean up
}).get();
pool.submit(() -> System.out.println("after remove(), user = " + CURRENT_USER.get())).get();
// after remove(), user = null

Inside, each thread has a ThreadLocalMap whose keys are weak references to the ThreadLocal but whose values are strong. The value lives as long as the thread — forever, in a pool.

The tools

ToolWhat it doesExample
jpsList running JVMs and their process idsjps -l
jcmdThe Swiss army knife: send any diagnostic command to a JVMjcmd <pid> GC.class_histogram, GC.heap_dump, Thread.print, VM.flags, JFR.start
jstatGC statistics sampled over timejstat -gcutil <pid> 1000
jmapHeap histogram and heap dumps (older; jcmd covers it)jmap -dump:live,format=b,file=heap.hprof <pid>
jstackThread dump; detects deadlocksjstack <pid>
Eclipse MAT / VisualVMOpen a .hprof heap dump: dominator tree, "leak suspects", paths to GC roots. VisualVM also shows live CPU/heap/threadsGUI
JFR (Java Flight Recorder) + JDK Mission ControlLow-overhead (~1%) always-on recording: allocations, locks, GC, hot methods, I/O. Free since Java 11java -XX:StartFlightRecording=duration=60s,filename=app.jfr
async-profilerSampling CPU/allocation/lock profiler without safepoint bias; outputs flame graphsasprof -d 30 -f flame.html <pid>

Investigating a leak step by step

// The leak: a static cache that only grows
static final Map<String, Session> SESSIONS = new HashMap<>();
for (int i = 0; ; i++) {
    SESSIONS.put("s" + i, new Session("s" + i, new byte[10_000]));   // added on login, never removed
}
1. CONFIRM heap after each GC keeps rising (sawtooth whose floor climbs)? jstat -gcutil / GC log / metrics 2. CAPTURE heap dump: jcmd <pid> GC.heap_dump /tmp/heap.hprof (or it is written automatically with -XX:+HeapDumpOnOutOfMemoryError) 3. COMPARE class histogram twice, minutes apart: which class count keeps growing? 4. ANALYZE open the dump in Eclipse MAT: Dominator tree / Leak Suspects -> biggest retained size -> "Path to GC roots" shows WHO holds it 5. FIX remove the reference (evict, unregister, remove(), close); add a bound 6. VERIFY re-run the load test: the post-GC floor stays flat

Real output from running the program above with -Xmx256m (3 seconds in):

$ jcmd <pid> GC.class_histogram
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:         19639       59593328  [B (java.base@21.0.7)          <- byte[] payloads
   2:           697         782256  [Ljdk.internal.vm.FillerElement; (java.base@21.0.7)
   3:         13657         327768  java.lang.String (java.base@21.0.7)
   4:          6920         221440  java.util.HashMap$Node (java.base@21.0.7)

$ jstat -gcutil <pid> 1000 3
  S0     S1     E      O      M     CCS    YGC     YGCT     FGC    FGCT     CGC    CGCT       GCT
     -      -  21.74  55.04  48.98   7.27      3     0.016     1     0.008     0     0.000     0.023
     - 100.00  12.50  64.49  48.98   7.27      4     0.019     1     0.008     2     0.000     0.027
     - 100.00  53.85  74.71  48.98   7.27      5     0.023     1     0.008     2     0.000     0.031

The O (old generation % used) column climbs 55 → 64 → 75 even though young GCs keep running: long-lived data is growing. Left alone, the run ended in OutOfMemoryError and, with -XX:+HeapDumpOnOutOfMemoryError, wrote a 270 MB .hprof. In MAT, the dominator tree would show one HashMap retaining nearly all of it, and "path to GC roots" leads to LeakDemo.SESSIONS. Note: GC.class_histogram forces a full GC (the FGC column shows 1), so be careful running it on a busy production box; heap dumps also pause the JVM and can be as large as the heap.

Where the time usually goes

SymptomLook atTypical fix
High CPUCPU flame graph (async-profiler / JFR "hot methods")Better algorithm, avoid regex/reflection in hot paths, cache results
Low CPU but slowThread dumps: threads WAITING on I/O, pools, locksBigger/separate pools, timeouts, async or virtual threads, fix N+1 queries
Latency spikesGC log pauses, JFR GC eventsLess allocation, right heap size, G1 pause target or ZGC
Lock contentionJFR "Java monitor blocked", async-profiler -e lockSmaller critical sections, concurrent collections, LongAdder
Memory grows until OOMHeap dump, MATThe leak steps above

JIT warm-up

The JVM starts by interpreting bytecode. Methods that run often ("hot") are compiled to machine code by the JIT (just-in-time) compiler — first quickly by C1, then with heavy optimization by C2 (inlining, escape analysis, loop unrolling), a scheme called tiered compilation. So a Java program gets faster after it has been running for a while.

for (int round = 1; round <= 5; round++) {
    long t0 = System.nanoTime();
    sink += work(2_000_000);                        // same work every round
    System.out.println("round " + round + ": " + (System.nanoTime() - t0) / 1000 + " us");
}
// Verified run (numbers vary by machine and run):
// round 1: 5573 us
// round 2: 4504 us
// round 3: 4002 us
// round 4: 3253 us
// round 5: 3241 us
// With -Xint (JIT disabled): about 28000 us every round

Consequences: benchmark numbers from a cold JVM are wrong; the first requests after a deploy are slower (warm the service up before routing traffic); and short-lived processes never get fully optimized (Java 21 options: CDS archives for start-up, GraalVM native image; Project Leyden continues this work).

Benchmarking correctly with JMH

A hand-written timing loop like the one above is misleading: no warm-up control, the JIT can remove work whose result is unused (dead-code elimination), it can fold constants, and GC pauses land at random. JMH (Java Microbenchmark Harness, from the OpenJDK team) handles all of this.

// Needs the org.openjdk.jmh:jmh-core and jmh-generator-annprocess dependencies
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)        // let the JIT compile first
@Measurement(iterations = 5, time = 1)
@Fork(2)                                 // fresh JVMs, so one run's JIT choices don't leak into another
@State(Scope.Thread)
public class ConcatBench {
    @Param({"10", "1000"}) int n;

    @Benchmark
    public String plusInLoop() {
        String s = "";
        for (int i = 0; i < n; i++) s += i;
        return s;                        // RETURN the result so it can't be eliminated
    }

    @Benchmark
    public void builder(Blackhole bh) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < n; i++) sb.append(i);
        bh.consume(sb.toString());       // or sink it into a Blackhole
    }
}

(This snippet is not in the verified set because it needs the JMH library; it follows the standard JMH API.) Before micro-optimizing anything, profile first (JFR or async-profiler) to find where time really goes — it is usually I/O, database queries, locks or allocation, not the loop you suspect.

Interview angle: "Can Java leak memory with a GC?" — yes: reachable but unneeded objects; give two causes (static map, ThreadLocal in a pool). "How would you debug an OOM in production?" — the six steps above: confirm with GC logs, heap dump, MAT dominator tree, path to GC roots. "Service is slow — what do you do?" — measure: JFR/async-profiler flame graph, thread dumps for lock contention, GC logs for pauses. "Why is the first request slow?" — class loading plus JIT warm-up. "How do you benchmark in Java?" — JMH, never a bare System.nanoTime loop.
Part 7 — Modern & Practical Java

39. Modern Java: 8 to 21

In plain words

Java changes every six months, but most companies only run the LTS (Long-Term Support) versions: 8 (2014), 11 (2018), 17 (2021) and 21 (2023). Each LTS bundles everything added since the last one. Knowing which feature came in which version tells an interviewer you have actually kept up, and it tells you what you may use on a given project.

The big story: Java 8 made Java functional (lambdas, streams). Java 11–17 made it less wordy (var, records, text blocks, switch expressions). Java 21 made it data-oriented and massively concurrent (pattern matching everywhere, virtual threads).

Like a car model that gets a facelift every year: the engine you learned on still works, but the newer model has cruise control, a reversing camera and better mileage. If you only know the 2014 model, you drive the new one badly.

Java 8 (2014) Java 11 (2018) Java 17 (2021) Java 21 (2023) lambdas streams Optional java.time var (10) HttpClient strip, isBlank List.of (9) switch expr (14) text blocks (15) records (16) sealed (17) switch patterns record patterns sequenced colls virtual threads
The four LTS releases. Numbers in brackets are the non-LTS version where the feature became final; you get it in the next LTS.

The feature timeline

VersionFeatureWhat it gives you
8Lambdas, method references, functional interfacesPass behaviour as a value: list.sort((a, b) -> ...)
8Streams APIDeclarative pipelines: filter, map, collect
8Optional, java.time, default methodsExplicit "maybe absent"; a sane date API; interfaces that can evolve
9List.of, Set.of, Map.of, modules (JPMS)Immutable collection literals
10varLocal variable type inference
11 (LTS)HttpClient, String.strip/isBlank/lines/repeat, Files.readString, run java File.java directlyModern HTTP/2 client in the JDK
14Switch expressions (->, yield), helpful NullPointerException messagesSwitch returns a value, no fall-through
15Text blocks """Multi-line strings for JSON, SQL, HTML
16Records, pattern matching for instanceof, Stream.toList()Data classes in one line; no more cast after instanceof
17 (LTS)Sealed classes and interfacesClosed hierarchies the compiler knows about
21 (LTS)Pattern matching for switch, record patterns, sequenced collections, virtual threadsType-safe data dispatch; getFirst/getLast/reversed; millions of cheap threads

Java 8 — lambdas, streams, Optional

List<String> names = List.of("anna", "bob", "carla", "dan");
List<String> longUpper = names.stream()
        .filter(n -> n.length() > 3)       // lambda
        .map(String::toUpperCase)           // method reference
        .toList();                          // [ANNA, CARLA]   (toList() is Java 16)

Optional<String> firstD = names.stream().filter(n -> n.startsWith("d")).findFirst();
firstD.map(String::length).orElse(0);       // 3

Java 10/11 — var, String helpers, HttpClient

var map = new HashMap<String, List<Integer>>();     // type inferred: HashMap<String, List<Integer>>
map.computeIfAbsent("a", k -> new ArrayList<>()).add(1);   // {a=[1]}
for (var e : map.entrySet()) System.out.println(e.getKey() + "->" + e.getValue());  // a->[1]

"  hi  ".strip();          // "hi"   (Unicode-aware, unlike trim)
" ".isBlank();             // true
"ab".repeat(3);            // "ababab"
"a\nb\nc".lines().count(); // 3

HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
HttpRequest req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/hello")).GET().build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(resp.statusCode() + " " + resp.body());   // 200 {"msg":"hi"}

// Non-blocking version returns a CompletableFuture
String body = client.sendAsync(req, HttpResponse.BodyHandlers.ofString())
                    .thenApply(HttpResponse::body).join();

var is only for local variables with an initializer (not fields, parameters or return types). It is still static typing — the compiler fixes the type once. Use it when the right side already says the type (var list = new ArrayList<String>()); avoid it when it hides meaning (var x = service.process()).

Java 14 — switch expressions

int letters = switch (day) {
    case MON, FRI, SUN -> 6;          // several labels, no break needed
    case TUE -> 7;
    case THU, SAT -> 8;
    case WED -> {
        int n = "WEDNESDAY".length();
        yield n;                        // yield returns a value from a block
    }
};                                      // SAT -> 8

Arrow cases never fall through. For an enum, the compiler checks that every constant is covered, so no default is needed — and adding a new constant later becomes a compile error instead of a silent bug.

Java 15 — text blocks

String json = """
        {
          "name": "Ann",
          "age": 30
        }
        """;               // indentation up to the closing """ is stripped; 4 lines

Java 16 — records and instanceof patterns

record Point(int x, int y) {
    Point {                                   // compact constructor: validation only
        if (x < 0 || y < 0) throw new IllegalArgumentException("negative");
    }
    double dist() { return Math.sqrt(x * x + y * y); }
}
Point p = new Point(3, 4);
p + " " + p.x() + " " + p.dist() + " " + p.equals(new Point(3, 4));
// Point[x=3, y=4] 3 5.0 true   -- toString, accessors, equals/hashCode generated

Object o = "hello";
if (o instanceof String s && s.length() > 3) {   // s is bound only when the test succeeds
    System.out.println(s.toUpperCase());          // HELLO
}

Java 17 + 21 — sealed types, switch patterns, record patterns

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) {                                   // exhaustive: no default needed
        case Circle c -> Math.PI * c.r() * c.r();          // type pattern
        case Square(double side) -> side * side;           // record pattern (deconstruction)
        case Rect(double w, double h) when w == h -> w * w; // guard with "when"
        case Rect(double w, double h) -> w * h;
    };
}
// Circle(1) 3.14, Square(2) 4.00, Rect(2,3) 6.00, Rect(3,3) 9.00

static String describe(Object o) {
    return switch (o) {
        case null -> "null!";                              // null can be a case now
        case Integer i when i > 100 -> "big int " + i;
        case Integer i -> "int " + i;
        case String s -> "string of length " + s.length();
        case Point(int x, int y) -> "point at " + x + "," + y;
        default -> "something else";
    };
}
// 42 -> int 42, 500 -> big int 500, "abc" -> string of length 3,
// Point(1,2) -> point at 1,2, null -> null!, 2.5 -> something else

if (obj instanceof Point(int x, int y)) System.out.println(x + y);   // 15 for Point(7, 8)

Order matters: a more specific case (Integer i when i > 100) must come before the general one, or the compiler reports it as dominated. Sealed + records + switch patterns together are called data-oriented programming: the data is plain, and the operations live in exhaustive switches the compiler checks.

Java 21 — sequenced collections

List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
list.getFirst() + " " + list.getLast() + " " + list.reversed();   // 1 3 [3, 2, 1]

LinkedHashMap<String, Integer> lhm = new LinkedHashMap<>();
lhm.put("b", 2); lhm.put("a", 1); lhm.putFirst("z", 0);
lhm + " " + lhm.firstEntry() + " " + lhm.lastEntry();             // {z=0, b=2, a=1} z=0 a=1

LinkedHashSet<String> lhs = new LinkedHashSet<>(List.of("x", "y"));
lhs.addFirst("w");
lhs + " " + lhs.reversed();                                        // [w, x, y] [y, x, w]

New interfaces SequencedCollection, SequencedSet and SequencedMap give every ordered collection the same first/last/reversed methods. Before 21 you wrote list.get(list.size() - 1) or iterated a whole LinkedHashSet to find its last element. reversed() is a view, not a copy.

Java 21 — virtual threads

AtomicInteger done = new AtomicInteger();
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> exec.submit(() -> {
        Thread.sleep(Duration.ofMillis(100));   // blocking is cheap on a virtual thread
        done.incrementAndGet();
        return i;
    }));
}   // close() waits for every task
// 10000 tasks finished in well under 2 seconds on a laptop

Thread.ofVirtual().start(() -> System.out.println(Thread.currentThread().isVirtual()));  // true

A virtual thread is a thread managed by the JVM, not the OS. When it blocks (sleep, socket read, JDBC call) the JVM unmounts it from its carrier OS thread, which then runs another virtual thread. 10,000 platform threads would reserve about 1 MB of stack each (~10 GB of address space); 10,000 virtual threads cost a few MB. Use them for I/O-bound work (web requests, DB calls) — they do not make CPU-bound work faster. See section 34 for pinning and deadlocks.

Interview angle: "What changed between Java 8 and 21?" Answer by theme, not a list: less boilerplate (var, records, text blocks), safer modelling (sealed types, exhaustive switch, pattern matching), better APIs (HttpClient, sequenced collections, Stream.toList), and a new concurrency model (virtual threads). Follow-up traps: Stream.toList() returns an unmodifiable list while collect(Collectors.toList()) gives a mutable ArrayList in practice; records are shallowly immutable (a List field can still be mutated unless you copy it with List.copyOf).

40. I/O, NIO & Serialization

In plain words

I/O (input/output) is moving data between your program and the outside world: files, network, console. Java has two families: byte streams (InputStream/OutputStream) for raw bytes like images, and character streams (Reader/Writer) for text, which convert bytes to characters using a charset such as UTF-8.

NIO ("new I/O", Java 1.4, and NIO.2 in Java 7) adds Path and Files — the modern, simple way to work with files — plus channels and buffers for high-performance I/O. Serialization turns an object into bytes so it can be saved or sent, and back again.

Byte streams are a water pipe: they carry whatever flows. Character streams are the same pipe with a translator at the end who turns the flow into words. A buffer is a bucket: instead of carrying one cup at a time to the tap, you fill a bucket and make one trip.

The class family

Byte streams (binary)Character streams (text)
Base classesInputStream, OutputStreamReader, Writer
FilesFileInputStream, FileOutputStreamFileReader, FileWriter
BufferingBufferedInputStream, BufferedOutputStreamBufferedReader (has readLine), BufferedWriter
BridgeInputStreamReader / OutputStreamWriter turn bytes into chars with a charset
Objects / primitivesObjectInputStream, DataInputStreamPrintWriter (formatted text)
Use forImages, zip, protocol bytes, serialized objectsText files, CSV, logs, console input
file on disk bytes FileInputStream reads bytes InputStreamReader bytes to chars (UTF-8) BufferedReader 8 KB chunks, readLine new BufferedReader(new InputStreamReader(new FileInputStream(f), UTF_8))
Java I/O is built from wrappers — the Decorator pattern (section 41). Each layer adds one ability.

Byte vs character, with buffering

try (OutputStream out = new BufferedOutputStream(new FileOutputStream(bin.toFile()))) {
    out.write(new byte[]{72, 105});                  // raw bytes 'H' 'i'
}
try (InputStream in = new BufferedInputStream(new FileInputStream(bin.toFile()))) {
    int b;
    while ((b = in.read()) != -1) System.out.print(b + " ");   // 72 105
}

try (BufferedWriter w = Files.newBufferedWriter(txt, StandardCharsets.UTF_8)) {
    w.write("héllo"); w.newLine(); w.write("world"); w.newLine();
}
try (BufferedReader r = Files.newBufferedReader(txt, StandardCharsets.UTF_8)) {
    String line;
    while ((line = r.readLine()) != null) System.out.println("> " + line);
}
Files.size(txt);   // 13 bytes, not 12 -- 'é' takes 2 bytes in UTF-8

Why buffer? Each unbuffered read() can be a system call into the OS — thousands of times slower than reading from memory. A buffered stream reads 8 KB at once and serves later calls from that array. Always pass a charset for text: the platform default differed between machines before Java 18 (now UTF-8 by default), which caused "works on my laptop" bugs.

try-with-resources (Java 7)

Any object that implements AutoCloseable can go in the try (...) header. Java closes it automatically, in reverse order of opening, even when an exception is thrown. If close() also throws, that exception is suppressed and attached to the main one instead of hiding it.

static class Res implements AutoCloseable {
    final String n;
    Res(String n) { this.n = n; System.out.println("open " + n); }
    public void close() throws Exception { System.out.println("close " + n); throw new Exception("close failed " + n); }
}

try (Res a = new Res("A"); Res b = new Res("B")) {
    System.out.println("body");
    throw new IllegalStateException("body failed");
} catch (Exception 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

Before Java 7 you wrote finally { if (in != null) in.close(); }, and an exception from close() would replace the real error. That is why try-with-resources is the only acceptable style today.

java.nio.file — Path and Files (the modern way)

Path f = dir.resolve("sub/list.txt");
Files.createDirectories(f.getParent());
Files.write(f, List.of("apple", "banana", "cherry"));          // lines, UTF-8
Files.writeString(f, "date\n", StandardOpenOption.APPEND);       // Java 11
List<String> all = Files.readAllLines(f);                       // [apple, banana, cherry, date]

try (Stream<String> lines = Files.lines(f)) {                   // lazy, MUST be closed
    lines.filter(s -> s.contains("an")).count();                // 1
}
try (Stream<Path> walk = Files.walk(dir)) {                     // recursive tree walk
    walk.filter(Files::isRegularFile)
        .map(p -> dir.relativize(p).toString())
        .sorted().forEach(System.out::println);
}   // data.bin  notes.txt  sub/list.txt  sub/one.txt

Path p = Path.of("/home/ann/docs/report.pdf");                  // Java 11 (Paths.get before)
p.getFileName() + " " + p.getParent() + " " + p.getNameCount(); // report.pdf /home/ann/docs 4
Path.of("/a/b").resolve("c/d.txt");                             // /a/b/c/d.txt
Path.of("/a/b/../c").normalize();                               // /a/c
MethodLoadsUse when
Files.readString / readAllLinesWhole file into memorySmall files (config, test fixtures)
Files.linesOne line at a time, lazilyBig files; must use try-with-resources or it leaks a file handle
Files.newBufferedReaderStreamingFull control, custom parsing loops
Files.walk / Files.listDirectory entries lazilyRecursive / single-level listing; also must be closed

NIO channels and buffers (in brief)

A channel (FileChannel, SocketChannel) is a two-way connection to a file or socket. You never read bytes from it directly — you read into a ByteBuffer. A buffer has a position (next slot), a limit (end of valid data) and a capacity. flip() switches it from writing mode to reading mode.

try (FileChannel fc = FileChannel.open(ch, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
    ByteBuffer buf = ByteBuffer.allocate(16);
    buf.put("NIO!".getBytes());      // position=4 limit=16
    buf.flip();                      // position=0 limit=4  -- now ready to be drained
    fc.write(buf);
}
try (FileChannel fc = FileChannel.open(ch, StandardOpenOption.READ)) {
    ByteBuffer buf = ByteBuffer.allocate(16);
    fc.read(buf);
    buf.flip();
    System.out.println(StandardCharsets.UTF_8.decode(buf));   // NIO!
}

Where NIO matters: non-blocking sockets with a Selector let one thread watch thousands of connections (this is how Netty, and therefore Spring WebFlux, work); FileChannel.transferTo does zero-copy file-to-socket transfer; memory-mapped files (fc.map) let huge files be treated as memory. For everyday file work, Files is enough.

Serialization: Serializable, serialVersionUID, transient

static class User implements Serializable {       // marker interface: no methods
    @Serial private static final long serialVersionUID = 1L;
    String name;
    transient String password;                     // skipped when writing
    int age;
}

try (var oos = new ObjectOutputStream(Files.newOutputStream(ser))) {
    oos.writeObject(new User("ann", "secret", 30));
}
try (var ois = new ObjectInputStream(Files.newInputStream(ser))) {
    User u = (User) ois.readObject();
    System.out.println(u);                         // ann/null/30  -- transient came back as null
}
  • serialVersionUID is the class's version number. When reading, Java compares the stored UID with the current class's UID; if they differ you get InvalidClassException. If you don't declare one, Java computes it from the class shape, so adding a harmless method can break reading old data. Always declare it.
  • transient fields are not written; on reading they get default values (null, 0, false). Use it for passwords, caches, and non-serializable fields like a Thread or DB connection. static fields are never serialized either (they belong to the class).
  • Every field's type must also be Serializable, or you get NotSerializableException. During reading, the constructor of the first non-serializable superclass runs; the serializable class's own constructor does not.

Why Java serialization is discouraged

ProblemWhy it hurts
SecurityreadObject can build any class on the classpath from attacker-controlled bytes. "Gadget chains" in common libraries led to remote code execution bugs (e.g. the 2015 Apache Commons Collections exploits). Mark Reinhold, Java's chief architect, has called it a "horrible mistake" from 1997.
Fragile versioningPrivate fields become part of a public wire format; refactoring breaks old data
Java-onlyA Python or JS service cannot read it
Bypasses constructorsInvariants checked in constructors are skipped; singletons can be duplicated unless you add readResolve
Size and speedVerbose format, slow compared to JSON libraries or binary formats

Alternatives: JSON with Jackson (the default in Spring), Protocol Buffers or Avro for compact typed binary between services, and plain database rows for persistence. If you must deserialize Java objects, set an ObjectInputFilter (Java 9) allow-list.

// Jackson 2.x -- works directly with records
public record User(String name, int age,
                   @JsonProperty("e_mail") String email,   // rename in JSON
                   @JsonIgnore String password) {}         // never written

ObjectMapper mapper = new ObjectMapper();                  // create once and reuse: thread-safe, expensive
mapper.writeValueAsString(new User("ann", 30, "ann@x.com", "secret"));
// {"e_mail":"ann@x.com","name":"ann","age":30}

mapper.readValue("{\"name\":\"bob\",\"age\":25,\"e_mail\":\"b@x.com\"}", User.class);
// User[name=bob, age=25, email=b@x.com, password=null]
Interview angle: "Difference between transient and static for serialization?" Neither is written, but transient is a per-object field you chose to skip, while static never belonged to the object. "What if you don't define serialVersionUID?" Java generates one from the class structure, so any change may make old files unreadable. "Would you use Java serialization for a REST API?" No — JSON (Jackson) for readability and cross-language, Protobuf for performance.

41. Design Patterns in Java

In plain words

A design pattern is a named, proven solution to a problem that keeps coming up in object-oriented code. The classic catalogue (the "Gang of Four" book, 1994) groups them into three kinds: creational (how objects are made — Singleton, Builder, Factory), structural (how objects are combined — Decorator, Adapter), and behavioural (how objects talk — Strategy, Observer, Template Method).

Patterns are a shared vocabulary: saying "use a Strategy here" replaces ten minutes of explanation. In modern Java many patterns shrink to a few lines thanks to lambdas, enums and records.

Like recipes in a cookbook. "Béchamel" tells another cook exactly what you mean. You don't invent a new sauce every time — but you also don't put béchamel on everything.

PatternKindProblem it solvesSeen in the JDK / Spring
SingletonCreationalExactly one shared instanceRuntime.getRuntime(), Spring beans (singleton scope)
BuilderCreationalMany optional constructor parametersStringBuilder, HttpRequest.newBuilder(), Lombok @Builder
Factory MethodCreationalCaller should not know the concrete classList.of, Calendar.getInstance(), NumberFormat.getInstance()
StrategyBehaviouralSwap an algorithm at runtimeComparator, Spring PasswordEncoder
ObserverBehaviouralNotify many listeners of a changeSwing listeners, Spring ApplicationEvent, reactive streams
DecoratorStructuralAdd behaviour by wrappingBufferedReader, Collections.unmodifiableList
AdapterStructuralMake an incompatible interface fitArrays.asList, InputStreamReader
Template MethodBehaviouralFixed algorithm skeleton, variable stepsAbstractList, Spring JdbcTemplate

Singleton — five ways

// 1) Eager: created when the class loads. Simple and thread-safe.
final class EagerSingleton {
    private static final EagerSingleton INSTANCE = new EagerSingleton();
    private EagerSingleton() {}
    static EagerSingleton getInstance() { return INSTANCE; }
}

// 2) Lazy: NOT thread-safe -- two threads can both see null and create two objects.
final class LazySingleton {
    private static LazySingleton instance;
    private LazySingleton() {}
    static LazySingleton getInstance() {
        if (instance == null) instance = new LazySingleton();
        return instance;
    }
}

// 3) Double-checked locking: lazy and fast, but volatile is essential.
final class DclSingleton {
    private static volatile DclSingleton instance;
    private DclSingleton() {}
    static DclSingleton getInstance() {
        DclSingleton local = instance;              // one volatile read on the fast path
        if (local == null) {                        // 1st check, no lock
            synchronized (DclSingleton.class) {
                local = instance;
                if (local == null)                  // 2nd check, under the lock
                    instance = local = new DclSingleton();
            }
        }
        return local;
    }
}

// 4) Holder idiom: lazy AND thread-safe with no locking code.
final class HolderSingleton {
    private HolderSingleton() { System.out.println("Holder instance created"); }
    private static class Holder { static final HolderSingleton INSTANCE = new HolderSingleton(); }
    static HolderSingleton getInstance() { return Holder.INSTANCE; }
}
// Calling another static method first prints nothing; the instance is created
// only on the first getInstance(), because Holder is loaded only then.

// 5) Enum: Joshua Bloch's recommended form. Serialization- and reflection-safe.
enum Registry {
    INSTANCE;
    private final Map<String, String> data = new HashMap<>();
    void put(String k, String v) { data.put(k, v); }
    String get(String k) { return data.get(k); }
}
Registry.INSTANCE.put("env", "prod");   // Registry.INSTANCE.get("env") -> prod

Why volatile in double-checked locking? instance = new DclSingleton() is three steps: allocate memory, run the constructor, assign the reference. Without volatile, the JIT or CPU may reorder so the reference is published before the constructor finishes; another thread passes the first null-check and uses a half-built object. volatile forbids that reordering (happens-before, section 29).

When to use: truly global, stateless or read-mostly things (config, a registry). Downsides: hidden global state, hard to mock in tests. In Spring apps you almost never write one — beans are singletons by default and are injected.

Builder

final class Pizza {
    private final String size; private final boolean cheese; private final List<String> toppings;
    private Pizza(Builder b) { size = b.size; cheese = b.cheese; toppings = List.copyOf(b.toppings); }
    static Builder builder(String size) { return new Builder(size); }   // required param up front

    static final class Builder {
        private final String size; private boolean cheese; private final List<String> toppings = new ArrayList<>();
        private Builder(String size) { this.size = Objects.requireNonNull(size); }
        Builder cheese() { cheese = true; return this; }            // return this -> chaining
        Builder topping(String t) { toppings.add(t); return this; }
        Pizza build() {
            if (toppings.size() > 3) throw new IllegalStateException("max 3 toppings");  // validate once
            return new Pizza(this);
        }
    }
}
Pizza.builder("large").cheese().topping("olives").topping("basil").build();
// Pizza[large, cheese=true, [olives, basil]]

When: four or more parameters, many optional. It beats "telescoping constructors" (Pizza(size), Pizza(size, cheese), …) and produces an immutable object. For two or three fields, a record is simpler.

Factory (simple factory and Factory Method)

interface Notifier { String send(String msg); }
record EmailNotifier() implements Notifier { public String send(String m) { return "EMAIL: " + m; } }
record SmsNotifier()   implements Notifier { public String send(String m) { return "SMS: " + m; } }

// Simple (static) factory: one place decides the concrete class
static Notifier create(String channel) {
    return switch (channel) {
        case "email" -> new EmailNotifier();
        case "sms"   -> new SmsNotifier();
        default      -> throw new IllegalArgumentException("unknown channel " + channel);
    };
}
create("email").send("hi");                 // EMAIL: hi

// GoF Factory Method: a subclass overrides the creation step
abstract class Dialog {
    abstract Notifier createNotifier();     // the factory method
    String alert(String m) { return createNotifier().send(m.toUpperCase()); }
}
class SmsDialog extends Dialog { Notifier createNotifier() { return new SmsNotifier(); } }
new SmsDialog().alert("server down");      // SMS: SERVER DOWN

When: the caller should depend on an interface, and the choice of class depends on input or configuration. Static factory methods also get good names (Optional.empty(), Duration.ofSeconds) and can return cached instances.

Strategy — with lambdas

interface DiscountStrategy { double apply(double price); }       // a functional interface
record Checkout(DiscountStrategy strategy) {
    double total(double price) { return strategy.apply(price); }
}
Map<String, DiscountStrategy> strategies = Map.of(
        "none",       p -> p,
        "tenPercent", p -> p * 0.9,
        "flat5",      p -> Math.max(0, p - 5));
new Checkout(strategies.get("tenPercent")).total(100);   // 90.0
// none -> 100.0, flat5 -> 95.0

When: you see a growing if/else or switch choosing between algorithms (pricing rules, sorting, compression). Before Java 8 each strategy was a class; now it is usually a lambda. Comparator is the Strategy everyone already uses.

Observer

final class StockTicker {
    private final List<Consumer<Double>> listeners = new ArrayList<>();
    void subscribe(Consumer<Double> l) { listeners.add(l); }
    void setPrice(double p) { listeners.forEach(l -> l.accept(p)); }   // push to all
}
StockTicker t = new StockTicker();
List<Double> history = new ArrayList<>();
t.subscribe(p -> System.out.println("display: " + p));
t.subscribe(history::add);
t.subscribe(p -> { if (p > 150) System.out.println("ALERT: price above 150"); });
t.setPrice(120.0); t.setPrice(155.5);
// display: 120.0 / display: 155.5 / ALERT: price above 150 ; history = [120.0, 155.5]

When: one change must reach many independent parts that the source should not know about. Pitfalls: forgetting to unsubscribe (a classic memory leak), and a slow listener blocking the others. For thread safety use CopyOnWriteArrayList for the listener list. At system scale, the same idea becomes pub/sub with Kafka.

Decorator — the java.io example

Syrup (+0.7) Milk (+0.5) Espresso 2.0 the real object cost() = 2.0 + 0.5 + 0.7 = 3.2
Every decorator implements the same interface as what it wraps, so wrappers stack in any order.
interface Coffee { double cost(); String desc(); }
record Espresso() implements Coffee { public double cost() { return 2.0; } public String desc() { return "espresso"; } }
record Milk(Coffee inner) implements Coffee {
    public double cost() { return inner.cost() + 0.5; } public String desc() { return inner.desc() + " + milk"; } }
record Syrup(Coffee inner) implements Coffee {
    public double cost() { return inner.cost() + 0.7; } public String desc() { return inner.desc() + " + syrup"; } }
Coffee c = new Syrup(new Milk(new Espresso()));
c.desc() + " = " + c.cost();                 // espresso + milk + syrup = 3.2

// java.io uses exactly this: FilterReader is the decorator base class
final class CountingReader extends FilterReader {
    int count;
    CountingReader(Reader in) { super(in); }
    @Override public int read(char[] b, int off, int len) throws IOException {
        int n = super.read(b, off, len); if (n > 0) count += n; return n; }
    // (single-char read() overridden the same way)
}
try (CountingReader cr = new CountingReader(new StringReader("line one\nline two\n"));
     BufferedReader br = new BufferedReader(cr)) {       // decorator on a decorator
    while (br.readLine() != null) { }
    System.out.println(cr.count);                        // 18
}

When: you need combinations of extra behaviours (buffering + decoding + counting) without a subclass for every combination. Decorator vs inheritance: inheritance fixes behaviour at compile time; decorators compose at runtime.

Adapter

final class LegacyThermometer { double readFahrenheit() { return 98.6; } }   // can't change it
interface CelsiusSensor { double celsius(); }                                // what our code wants

record ThermometerAdapter(LegacyThermometer legacy) implements CelsiusSensor {
    public double celsius() { return (legacy.readFahrenheit() - 32) * 5 / 9; }
}
new ThermometerAdapter(new LegacyThermometer()).celsius();   // 37.0

When: integrating a third-party or legacy API whose shape does not match your interface. Adapter changes the interface; Decorator keeps the interface and adds behaviour. JDK examples: Arrays.asList (array to List), InputStreamReader (byte stream to character stream).

Template Method

abstract class DataImporter {
    final List<String> run(String raw) {                 // final: the skeleton cannot change
        List<String> rows = parse(raw);
        rows = rows.stream().filter(this::isValid).toList();
        log(rows.size() + " rows imported");
        return rows;
    }
    protected abstract List<String> parse(String raw);   // required step
    protected boolean isValid(String row) { return !row.isBlank(); }   // optional "hook"
    private void log(String m) { System.out.println("[" + getClass().getSimpleName() + "] " + m); }
}
class CsvImporter extends DataImporter {
    protected List<String> parse(String raw) { return List.of(raw.split(",")); }
}
class LineImporter extends DataImporter {
    protected List<String> parse(String raw) { return raw.lines().toList(); }
    @Override protected boolean isValid(String row) { return !row.startsWith("#"); }
}
new CsvImporter().run("a,,b, ,c");        // [CsvImporter] 3 rows imported -> [a, b, c]
new LineImporter().run("x\n#comment\ny"); // [LineImporter] 2 rows imported -> [x, y]

When: several classes share the same steps in the same order but differ in one or two steps. Modern alternative: pass the varying step as a lambda (Strategy) — favour composition when there is only one varying step.

Interview angle: Expect "implement a thread-safe singleton" (give holder or enum, explain DCL + volatile), "how can a singleton be broken?" (reflection calling the private constructor, serialization creating a copy, multiple class loaders — enum resists the first two), and "which patterns does the JDK/Spring use?" (Decorator in java.io, Strategy in Comparator, Template Method in JdbcTemplate, Proxy in Spring AOP and @Transactional, Factory in BeanFactory). Don't force patterns: name the problem first, then the pattern.

42. SOLID in Java

In plain words

SOLID is five rules of thumb (named by Robert C. Martin) for writing classes that are easy to change without breaking other things:

Single Responsibility — a class has one reason to change. Open/Closed — add features by adding code, not by editing working code. Liskov Substitution — a subclass must be usable anywhere its parent is. Interface Segregation — many small interfaces beat one fat one. Dependency Inversion — depend on interfaces, not on concrete classes.

A well-organised kitchen. Each drawer holds one kind of tool (S). You add a new appliance by plugging it in, not by rewiring the house (O). Any brand of kettle works in the socket (L). The toaster doesn't come with a built-in fridge you have to pretend to use (I). Appliances depend on the standard socket, not on one specific power plant (D).

BEFORE UserService MySqlDatabase new MySqlDatabase() AFTER UserService interface UserStore MySqlUserStore FakeUserStore (test)
Dependency inversion: the arrows from the low-level classes now point up to the abstraction the high-level class owns.

S — Single Responsibility

// BEFORE: calculation, formatting and persistence in one class -- three reasons to change
class InvoiceBefore {
    double amount;
    double total() { return amount * 1.2; }
    String toHtml() { return "<p>" + total() + "</p>"; }
    void saveToDb() { /* JDBC code */ }
}

// AFTER: one job each
record Invoice(double amount) { double total() { return amount * 1.2; } }
class InvoiceHtmlPrinter { String print(Invoice i) { return "<p>" + i.total() + "</p>"; } }
interface InvoiceRepository { void save(Invoice i); }
class InMemoryInvoiceRepository implements InvoiceRepository {
    final List<Invoice> saved = new ArrayList<>();
    public void save(Invoice i) { saved.add(i); }
}
// new InvoiceHtmlPrinter().print(new Invoice(100)) -> <p>120.0</p>

"One reason to change" means one actor: the tax team changes total(), the design team changes the HTML, the DBA changes storage. When they share a class, their edits collide. Smell: a class named ...Manager or ...Utils with 40 methods.

O — Open/Closed

// BEFORE: every new shape means editing (and re-testing) this method
static double areaBefore(Object shape) {
    if (shape instanceof double[] circle) return Math.PI * circle[0] * circle[0];
    // else if square ... else if triangle ... keeps growing
    throw new IllegalArgumentException();
}

// AFTER: open for extension (new class), closed for modification
interface Shape { double area(); }
record Circle(double r) implements Shape { public double area() { return Math.PI * r * r; } }
record Square(double s) implements Shape { public double area() { return s * s; } }
static double totalArea(List<Shape> shapes) { return shapes.stream().mapToDouble(Shape::area).sum(); }
// totalArea(List.of(new Circle(1), new Square(2))) -> 7.14

Tools for OCP in Java: interfaces + polymorphism, Strategy (a lambda per rule), and Spring injecting a List<Handler> of all beans of a type. Java 21 nuance: for a closed set of types (sealed), an exhaustive switch is fine — the compiler forces you to update every switch when a type is added.

L — Liskov Substitution

// BEFORE: Square "is a" Rectangle in maths, but not in code
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 SquareBad 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 resizeAndMeasure(Rectangle r) { r.setWidth(5); r.setHeight(4); return r.area(); }
resizeAndMeasure(new Rectangle());   // 20
resizeAndMeasure(new SquareBad());   // 16  -- caller's expectation broken

// AFTER: no inheritance between them; immutable siblings under one interface
record Rect(int w, int h) implements Shape { public double area() { return w * h; } }
record Sq(int side)       implements Shape { public double area() { return side * side; } }

A subclass must not strengthen preconditions (accept less), weaken postconditions (promise less) or throw new unchecked surprises. JDK violation everyone knows: List.of(...) is a List, but add throws UnsupportedOperationException.

I — Interface Segregation

// BEFORE: fat interface forces fake implementations
interface WorkerBefore { void work(); void eat(); }
class RobotBefore implements WorkerBefore {
    public void work() {}
    public void eat() { throw new UnsupportedOperationException("robots don't eat"); }
}

// AFTER: small role interfaces; implement only what applies
interface Workable { String work(); }
interface Eatable  { String eat(); }
class Human implements Workable, Eatable {
    public String work() { return "human works"; } public String eat() { return "human eats"; } }
class Robot implements Workable { public String work() { return "robot works"; } }
List<Workable> workers = List.of(new Human(), new Robot());   // human works, robot works

JDK examples of good segregation: Comparable, AutoCloseable, Runnable — one method each. Spring Data lets you choose CrudRepository vs the smaller Repository for the same reason.

D — Dependency Inversion

// BEFORE: high-level policy creates a concrete low-level detail
class MySqlDatabase { String find(int id) { return "row " + id; } }
class UserServiceBefore {
    private final MySqlDatabase db = new MySqlDatabase();   // hard-wired, cannot test without MySQL
    String name(int id) { return db.find(id); }
}

// AFTER: both depend on an abstraction; the dependency is injected
interface UserStore { String find(int id); }
class UserService {
    private final UserStore store;
    UserService(UserStore store) { this.store = store; }      // constructor injection
    String name(int id) { return store.find(id).toUpperCase(); }
}
UserService svc = new UserService(id -> "fake user " + id);  // test double as a lambda
svc.name(7);                                                   // FAKE USER 7

Dependency Inversion is the principle; dependency Injection is the technique (passing the dependency in); Spring's IoC container is a tool that does the injection for you. Prefer constructor injection with final fields over field @Autowired: dependencies are explicit, the object is never half-built, and tests need no framework.

PrincipleSmell that breaks itTypical fix
SRPGod class, "and" in the class descriptionSplit by actor / reason to change
OCPGrowing if/else on type or on a string codePolymorphism, Strategy, sealed + exhaustive switch
LSPOverrides that throw or ignore input; instanceof checks on subtypesComposition instead of inheritance
ISPMethods throwing UnsupportedOperationExceptionSplit into role interfaces
DIPnew ConcreteService() inside business logicInterface + constructor injection
Interview angle: Have one concrete story per principle, ideally from your own code. The Rectangle/Square LSP example and the "fat interface" ISP example are the ones most often asked. Senior follow-up: "Can SOLID be overdone?" Yes — an interface for every class with only one implementation, or ten tiny classes for a 20-line feature, is its own smell. Apply it where change is likely.

43. Reflection & Annotations

In plain words

Reflection lets a running program look at itself: list a class's fields and methods, create objects, call methods and read private fields — all by name, at runtime, without knowing the class when the code was compiled.

An annotation (@Something) is a label you attach to a class, method, field or parameter. By itself it does nothing. Some tool reads it — the compiler (@Override), an annotation processor at build time (Lombok), or a framework using reflection at runtime (@Test, @Autowired).

Reflection is an X-ray of an object: you can see the bones even though the skin (private) is closed. Annotations are sticky notes on the bones that say "test this" or "inject here" — the X-ray reader decides what to do with them.

Getting and inspecting a Class

static class Account {
    private String owner;
    private double balance;
    public static int created = 0;
    public Account() { this("nobody", 0); }
    public Account(String owner, double balance) { this.owner = owner; this.balance = balance; created++; }
    public void deposit(double amt) { balance += amt; }
    private String secret() { return "secret of " + owner; }
    public double getBalance() { return balance; }
}

Class<?> c1 = Account.class;                          // 1) class literal
Class<?> c2 = new Account().getClass();               // 2) from an object
Class<?> c3 = Class.forName("ReflectDemo$Account");   // 3) from a String name (loads the class)
// c1 == c2, c2 == c3 -> true true  (one Class object per class per class loader)

Arrays.stream(c1.getDeclaredMethods())                  // ALL methods declared here, incl. private
      .map(m -> Modifier.toString(m.getModifiers()) + " " + m.getReturnType().getSimpleName() + " " + m.getName())
      .sorted().forEach(System.out::println);
// private String secret
// public double getBalance
// public void deposit
c1.getMethods().length;   // 11 -- only PUBLIC ones, but including those inherited from Object

getDeclaredX = everything declared in this class (any visibility, no inherited). getX = public only, including inherited. The order of returned methods is not specified — never rely on it.

Creating objects, calling methods, breaking into private

Constructor<?> ctor = c1.getConstructor(String.class, double.class);
Object acc = ctor.newInstance("ann", 100.0);
c1.getMethod("deposit", double.class).invoke(acc, 50.0);
c1.getMethod("getBalance").invoke(acc);          // 150.0

Field bal = c1.getDeclaredField("balance");
bal.setAccessible(true);                         // switch off the private check
bal.set(acc, 999.0);
bal.get(acc);                                    // 999.0

Method secret = c1.getDeclaredMethod("secret");
secret.setAccessible(true);
secret.invoke(acc);                              // secret of ann
c1.getField("created").get(null);                // static field: pass null as the object

Exceptions thrown inside an invoked method arrive wrapped in InvocationTargetException; call getCause() for the real one. Since Java 16–17 (strong encapsulation of JDK internals, JEP 396 and 403), setAccessible(true) on JDK internals such as String.value throws InaccessibleObjectException unless you open the module with --add-opens. Your own classes on the classpath are still accessible.

Annotations: retention and target

@RetentionKept untilExample
SOURCECompiler only, dropped from .class@Override, @SuppressWarnings, Lombok's @Getter
CLASS (default)In the .class file but not visible to reflectionBytecode tools
RUNTIMEReadable with reflection at runtime@Test, @Autowired, @Entity, @JsonProperty

@Target limits where the annotation may appear: TYPE, METHOD, FIELD, PARAMETER, CONSTRUCTOR, RECORD_COMPONENT and more. Other meta-annotations: @Inherited (subclasses inherit a class annotation), @Repeatable, @Documented. Annotation elements may only be primitives, String, Class, enums, other annotations, or arrays of those.

@Retention(RetentionPolicy.CLASS) @Target(ElementType.TYPE) @interface BuildOnly {}
@BuildOnly static class Account { ... }
Account.class.isAnnotationPresent(BuildOnly.class);   // false -- CLASS retention is invisible at runtime

Custom annotations processed at runtime — a validator and a mini JUnit

source: @MyTest void add() { ... } | javac keeps it in the .class file (RUNTIME retention) v runtime: Class.getDeclaredMethods() -> method.getAnnotation(MyTest.class) | not null? v method.setAccessible(true); method.invoke(instance) -> PASS / FAIL
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD)
@interface NotBlank { String message() default "must not be blank"; }

static class Form {
    @NotBlank String username;
    @NotBlank(message = "email required") String email;
    String nickname;                                     // not validated
}

static List<String> validate(Object obj) throws IllegalAccessException {
    List<String> errors = new ArrayList<>();
    for (Field f : obj.getClass().getDeclaredFields()) {
        NotBlank nb = f.getAnnotation(NotBlank.class);
        if (nb == null) continue;
        f.setAccessible(true);
        Object v = f.get(obj);
        if (v == null || v.toString().isBlank()) errors.add(f.getName() + ": " + nb.message());
    }
    return errors;
}
validate(new Form("ann", " ", null));   // [email: email required]

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
@interface MyTest { String name() default ""; boolean enabled() default true; }

static class CalculatorTests {
    @MyTest(name = "adds numbers") void add() { if (1 + 1 != 2) throw new AssertionError("1+1"); }
    @MyTest void divide() { int x = 1 / 0; }
    @MyTest(enabled = false) void slow() { throw new AssertionError("should not run"); }
    void helper() {}                                     // no annotation: ignored
}

static void runTests(Class<?> testClass) throws Exception {
    Object instance = testClass.getDeclaredConstructor().newInstance();
    Method[] methods = testClass.getDeclaredMethods();
    Arrays.sort(methods, Comparator.comparing(Method::getName));   // order is not guaranteed
    for (Method m : methods) {
        MyTest t = m.getAnnotation(MyTest.class);
        if (t == null) continue;
        String label = t.name().isEmpty() ? m.getName() : t.name();
        if (!t.enabled()) { System.out.println("SKIP " + label); continue; }
        try {
            m.setAccessible(true);
            m.invoke(instance);
            System.out.println("PASS " + label);
        } catch (InvocationTargetException e) {
            System.out.println("FAIL " + label + " -> " + e.getCause());
        }
    }
}
PASS adds numbers
FAIL divide -> java.lang.ArithmeticException: / by zero
SKIP slow

That loop is, in miniature, what JUnit does with @Test and @Disabled, and what Bean Validation (Hibernate Validator) does with @NotBlank.

How frameworks use reflection

FrameworkWhat it does with reflection
SpringScans the classpath for @Component, reads constructor parameter types, instantiates beans, injects @Autowired fields, builds proxies for @Transactional / @Cacheable (JDK dynamic proxies or CGLIB/ByteBuddy subclasses)
JUnit 5Finds @Test methods, creates a new test instance per method, invokes lifecycle methods
Hibernate / JPAReads @Entity/@Column, sets private fields from result sets (needs a no-arg constructor)
JacksonDiscovers getters/fields/record components and @JsonProperty to map JSON
MockitoGenerates subclasses at runtime to intercept calls on mocks

Cost and risks

  • Speed: a reflective call skips compile-time linking and needs argument boxing and access checks. In a quick 5-million-call loop the reflective version was measurably slower than a direct call (exact numbers vary by machine and JIT warm-up). Since Java 18, core reflection is built on method handles, which narrowed the gap. Frameworks cache Method objects and pay the cost mostly at startup.
  • No compile-time safety: a renamed method becomes a runtime NoSuchMethodException.
  • Breaks encapsulation: setAccessible can change private and even final instance fields (records and static final fields are protected), and can call a singleton's private constructor:
Constructor<Single> sc = Single.class.getDeclaredConstructor();
sc.setAccessible(true);
sc.newInstance() != Single.INSTANCE;               // true -- singleton broken

Constructor<?> ec = EnumSingle.class.getDeclaredConstructors()[0];
ec.setAccessible(true);
ec.newInstance("X", 1);   // IllegalArgumentException: Cannot reflectively create enum objects
  • Native images and startup: GraalVM native images need reflection declared ahead of time; this is why Micronaut and Quarkus favour build-time annotation processing over runtime reflection.
Interview angle: "How does @Autowired work?" — Spring reads bean definitions, uses reflection to find constructors/fields with the annotation, resolves a bean of the matching type, and sets it (calling setAccessible for private fields). "Difference between an annotation processor and runtime reflection?" — a processor (javax.annotation.processing.Processor, e.g. Lombok, MapStruct) runs inside javac and generates code; reflection reads RUNTIME annotations while the program runs. "Why is my custom annotation always null?" — you forgot @Retention(RUNTIME).

44. Build Tools & Testing: Maven, Gradle, JUnit

In plain words

A build tool downloads the libraries your project needs (dependencies), compiles the code, runs the tests and packages a JAR — the same way on every machine and in CI. Maven uses an XML file (pom.xml) and a fixed lifecycle; Gradle uses a Groovy or Kotlin script (build.gradle(.kts)) and a task graph.

JUnit 5 is the standard framework for writing and running tests. Mockito creates fake versions of dependencies (mocks) so a unit test checks one class in isolation.

The build tool is a factory's assembly line: parts arrive (dependencies), are machined (compiled), inspected (tested) and boxed (packaged). Mockito is a crash-test dummy: you test the car without risking a real person.

Maven vs Gradle

MavenGradle
Configpom.xml, declarative XMLbuild.gradle.kts (Kotlin) or build.gradle (Groovy), code
ModelFixed lifecycle of phases; plugins bind goals to phasesGraph of tasks with inputs/outputs
SpeedRebuilds more; slower on big projectsIncremental builds, build cache, daemon — usually faster
FlexibilityConventions, custom logic needs a pluginVery flexible (and easier to make messy)
Where you see itMost enterprise Spring projectsAndroid (required), many newer and large multi-module projects
BothSame layout (src/main/java, src/test/java), same Maven Central repository, wrapper scripts (mvnw, gradlew) pin the tool version

The Maven lifecycle

validate -> compile -> test -> package -> verify -> install -> deploy | | | | | src/main JUnit via target/app.jar copy to upload to a -> target Surefire ~/.m2 (local) remote repo Running a phase runs every phase before it: mvn package = validate + compile + test + package mvn clean install (clean is a separate lifecycle: delete target/) mvn -DskipTests package | mvn test -Dtest=PriceCalculatorTest | mvn dependency:tree
<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>          <!-- organisation -->
  <artifactId>shop</artifactId>            <!-- project name -->
  <version>1.0.0-SNAPSHOT</version>        <!-- SNAPSHOT = still changing -->

  <properties>
    <maven.compiler.release>21</maven.compiler.release>
  </properties>

  <dependencies>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.17.2</version>              <!-- default scope: compile -->
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.11.0</version>
      <scope>test</scope>                   <!-- only on the test classpath -->
    </dependency>
    <dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-junit-jupiter</artifactId>
      <version>5.12.0</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
</project>
// build.gradle.kts -- the same project in Gradle
plugins { java }
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }
repositories { mavenCentral() }
dependencies {
    implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2")
    testImplementation("org.junit.jupiter:junit-jupiter:5.11.0")
    testImplementation("org.mockito:mockito-junit-jupiter:5.12.0")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test { useJUnitPlatform() }
// ./gradlew build   ./gradlew test --tests PriceCalculatorTest   ./gradlew dependencies
Maven scopeGradle configurationMeaning
compileimplementation / apiNeeded to compile and run
providedcompileOnlyCompile only; the server supplies it (Servlet API, Lombok)
runtimeruntimeOnlyRun only (a JDBC driver)
testtestImplementationTests only (JUnit, Mockito)

Transitive dependencies: Jackson pulls in jackson-core and jackson-annotations automatically. When two libraries need different versions of the same thing, Maven picks the nearest in the tree ("nearest wins"), Gradle picks the highest. Fix conflicts with <dependencyManagement> / a BOM (Spring Boot's parent does this for you) or <exclusions>.

JUnit 5 basics

class PriceCalculatorTest {
    private PriceCalculator calc;

    @BeforeAll static void beforeAll() { System.out.println("beforeAll (once)"); }
    @BeforeEach void setUp() { calc = new PriceCalculator(); }    // fresh object per test
    @AfterEach  void tearDown() { }
    @AfterAll  static void afterAll() { System.out.println("afterAll (once)"); }

    @Test
    @DisplayName("10% off 200 is 180")
    void applyDiscount_tenPercent_reducesPrice() {
        // Arrange
        double price = 200;
        // Act
        double result = calc.applyDiscount(price, 10);
        // Assert
        assertEquals(180.0, result, 0.0001);            // expected first, then actual, then delta
    }

    @Test
    void applyDiscount_negativePercent_throws() {
        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
                () -> calc.applyDiscount(100, -5));
        assertEquals("percent must be 0..100", ex.getMessage());
    }

    @Test
    void applyDiscount_groupedAssertions() {
        assertAll(                                       // reports ALL failures, not just the first
                () -> assertEquals(100.0, calc.applyDiscount(100, 0)),
                () -> assertEquals(0.0, calc.applyDiscount(100, 100)),
                () -> assertTrue(calc.applyDiscount(50, 50) < 50));
    }

    @ParameterizedTest(name = "{0} with {1}% -> {2}")
    @CsvSource({ "100, 0, 100", "100, 25, 75", "80, 50, 40" })
    void applyDiscount_table(double price, int percent, double expected) {
        assertEquals(expected, calc.applyDiscount(price, percent), 0.0001);
    }

    @ParameterizedTest
    @ValueSource(ints = { -1, 101, 500 })
    void applyDiscount_outOfRange_throws(int percent) {
        assertThrows(IllegalArgumentException.class, () -> calc.applyDiscount(10, percent));
    }

    @Test @Disabled("demo of a skipped test")
    void notReadyYet() { fail(); }
}

Run with the JUnit Platform launcher together with the Mockito test below: 13 tests found, 12 succeeded, 1 skipped; beforeAll and afterAll printed once, @BeforeEach ran 9 times for this class (each parameterized row counts as a test; the disabled test gets no lifecycle calls).

JUnit 5JUnit 4 equivalentNotes
@BeforeEach / @AfterEach@Before / @AfterRun around every test
@BeforeAll / @AfterAll@BeforeClass / @AfterClassMust be static (unless @TestInstance(PER_CLASS))
@Disabled@IgnoreSkip with a reason
assertThrows@Test(expected = ...)Returns the exception to inspect
@ExtendWith@RunWithPlug in Mockito, Spring
@Nested, @Tag, @Timeout—Group tests, filter, fail slow tests

Mockito basics

public class OrderService {
    public interface OrderRepository { Optional<Order> findById(long id); void save(Order o); }
    public interface EmailSender { void send(String to, String body); }
    public record Order(long id, String email, double total, boolean paid) {}

    private final OrderRepository repo;
    private final EmailSender email;
    public OrderService(OrderRepository repo, EmailSender email) { this.repo = repo; this.email = email; }

    public Order markPaid(long id) {
        Order o = repo.findById(id).orElseThrow(() -> new IllegalArgumentException("no order " + id));
        Order paid = new Order(o.id(), o.email(), o.total(), true);
        repo.save(paid);
        email.send(o.email(), "Order " + id + " paid: " + o.total());
        return paid;
    }
}

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock OrderService.OrderRepository repo;
    @Mock OrderService.EmailSender email;
    @InjectMocks OrderService service;                   // mocks passed to the constructor

    @Test
    void markPaid_existingOrder_savesAndSendsEmail() {
        when(repo.findById(42L)).thenReturn(Optional.of(new OrderService.Order(42, "a@x.com", 99.5, false)));

        OrderService.Order result = service.markPaid(42);

        assertTrue(result.paid());                                        // state
        ArgumentCaptor<OrderService.Order> saved = ArgumentCaptor.forClass(OrderService.Order.class);
        verify(repo).save(saved.capture());                              // interaction
        assertTrue(saved.getValue().paid());
        verify(email, times(1)).send(eq("a@x.com"), contains("99.5"));
        verifyNoMoreInteractions(email);
    }

    @Test
    void markPaid_missingOrder_throwsAndSendsNothing() {
        when(repo.findById(anyLong())).thenReturn(Optional.empty());
        assertThrows(IllegalArgumentException.class, () -> service.markPaid(7));
        verify(repo, never()).save(any());
        verifyNoInteractions(email);
    }

    @Test
    void manualMock_withoutAnnotations() {
        OrderService.EmailSender sender = mock(OrderService.EmailSender.class);
        doThrow(new RuntimeException("SMTP down")).when(sender).send(anyString(), anyString());  // void method
        RuntimeException ex = assertThrows(RuntimeException.class, () -> sender.send("x", "y"));
        assertEquals("SMTP down", ex.getMessage());
    }
}
  • An unstubbed mock method returns a default: null, 0, false, empty collection / empty Optional.
  • If one argument uses a matcher (eq, any), all must — mixing raw values and matchers throws InvalidUseOfMatchersException.
  • @Mock = all fake; @Spy = real object with some methods stubbed (use doReturn(..).when(spy).m() to avoid calling the real method while stubbing).
  • With MockitoExtension, stubs you never use fail the test (UnnecessaryStubbingException) — strict stubs keep tests honest.
  • Don't mock what you don't own (e.g. HttpClient) or value objects; wrap third-party APIs behind your own interface and mock that.

Test naming and structure

PracticeExample / reason
Name = method_condition_expectedResultmarkPaid_missingOrder_throwsAndSendsNothing reads like a spec when it fails
Arrange / Act / Assert (Given / When / Then)Three visible blocks; one Act per test
One behaviour per testSeveral asserts are fine if they check one outcome
Independent and repeatableNo order dependence, no shared mutable static state, no real clock (inject Clock)
FastUnit tests in milliseconds; real DB tests go in integration tests (Testcontainers)
Test behaviour, not implementationOver-verifying every call makes refactoring break tests
/\ E2E / UI tests -- few, slow, brittle / \ /----\ Integration tests -- DB, HTTP, Spring context (@SpringBootTest, Testcontainers) / \ /--------\ Unit tests -- many, fast, mocks for dependencies (JUnit + Mockito)
Interview angle: "Mock vs stub vs spy?" — a stub returns canned answers (when/thenReturn), a mock is additionally verified for interactions (verify), a spy wraps a real object. "Maven install vs deploy?" — local ~/.m2 vs remote repository. "How do you handle a dependency conflict?" — mvn dependency:tree, then pin the version with dependencyManagement or exclude it. "What is SNAPSHOT?" — a mutable in-development version that may be re-downloaded; releases are immutable.
Revision

Java Cheat Sheet

In plain words

Everything from the notes compressed into lookup tables for the last hour before an interview: which collection to pick, what each modifier keyword really does, the classic "difference between X and Y" pairs, and the cost of common operations.

The single index card you are allowed to bring into the exam — no explanations, just the facts you keep forgetting.

Which collection?

You need…UseAvoid
A list, mostly reads by index or appendsArrayListLinkedList, Vector
Stack or queue (FIFO/LIFO)ArrayDequeStack, LinkedList
Unique elements, fast lookup, no orderHashSetList.contains in a loop
Unique, insertion orderLinkedHashSet
Unique, sorted, range / floor / ceilingTreeSet
Key-value, fastestHashMapHashtable
Key-value, insertion or access order (LRU cache)LinkedHashMap (accessOrder=true, removeEldestEntry)
Key-value, sorted keysTreeMap
Enum keysEnumMap / EnumSetHashMap of enums
Smallest / largest firstPriorityQueueSorting after every insert
Thread-safe mapConcurrentHashMapCollections.synchronizedMap, Hashtable
Thread-safe list, many reads, rare writesCopyOnWriteArrayList
Producer-consumer hand-offArrayBlockingQueue / LinkedBlockingQueuewait/notify by hand
Constant dataList.of, Set.of, Map.of, List.copyOfArrays.asList (fixed-size but writable)

Keywords

KeywordOn a variable / fieldOn a methodOn a class
staticOne copy shared by the classCalled on the class; no this; hidden, not overriddenNested class without outer instance
finalAssigned once (reference, not object, is fixed)Cannot be overriddenCannot be extended (String)
abstract—No body; subclass must implementCannot be instantiated; may have constructors and state
synchronized—Locks this (or the Class for static) — mutual exclusion + visibility—
volatileReads/writes go to main memory; happens-before; no reordering. Not atomic for count++——
transientSkipped by Java serialization——
sealed / non-sealed——Only permits-listed subclasses allowed (17)
default—Interface method with a body (8)—
native / strictfp—Implemented in C via JNI / strict FP (redundant since 17)—

Difference between…

PairKey difference
== vs equalsSame object (reference) vs same content (as defined by the class)
String vs StringBuilder vs StringBufferImmutable vs mutable (not thread-safe) vs mutable synchronized (legacy)
ArrayList vs LinkedListArray: O(1) get, cache-friendly vs nodes: O(n) get, O(1) insert at a known node
HashMap vs HashtableUnsynchronized, one null key allowed vs synchronized legacy, no nulls
HashMap vs ConcurrentHashMapNot thread-safe vs per-bin locking/CAS, no null keys or values
HashMap vs TreeMap vs LinkedHashMapNo order O(1) vs sorted O(log n) vs insertion/access order O(1)
HashSet vs TreeSetHash O(1), no order, uses equals/hashCode vs red-black tree O(log n), sorted, uses compareTo
Comparable vs ComparatorNatural order inside the class (compareTo) vs external, many orders (compare)
Iterator fail-fast vs fail-safeThrows ConcurrentModificationException vs iterates a snapshot / weakly consistent view
Abstract class vs interfaceState + constructors, single inheritance vs no instance state, multiple inheritance of type
Overloading vs overridingSame name, different params, compile-time vs same signature in subclass, runtime dispatch
final vs finally vs finalizeModifier vs always-run block vs old GC hook (deprecated for removal since 18)
Checked vs unchecked exceptionMust catch or declare (IOException) vs RuntimeException subclasses, optional
throw vs throwsThrows an exception now vs declares a method may throw
Error vs ExceptionJVM problems you shouldn't catch (OutOfMemoryError) vs recoverable conditions
Stack vs heapPer-thread frames, locals, auto-freed vs shared objects, garbage collected
JDK vs JRE vs JVMTools + runtime vs runtime (libs + JVM) vs the engine that runs bytecode
sleep vs waitKeeps locks, Thread static vs releases the monitor, needs synchronized, woken by notify
Runnable vs CallableNo result, no checked exception vs returns a value, can throw
start() vs run()New thread vs plain method call on the current thread
synchronized vs ReentrantLockImplicit, block-scoped vs explicit: tryLock, timeout, fairness, multiple conditions
volatile vs AtomicIntegerVisibility only vs visibility + atomic read-modify-write (CAS)
Future vs CompletableFutureBlocking get() only vs chaining, combining, non-blocking callbacks
Platform vs virtual thread1:1 OS thread, ~1 MB stack vs JVM-scheduled, cheap, for blocking I/O (21)
map vs flatMapOne-to-one vs one-to-many then flatten
Intermediate vs terminal opLazy, returns a stream vs triggers the pipeline, returns a result
Optional.of vs ofNullableThrows on null vs empty on null
orElse vs orElseGetArgument always evaluated vs supplier called only when empty
Shallow vs deep copyCopies references vs copies the objects they point to
Static vs dynamic bindingResolved at compile time (static, private, final, overloads) vs at runtime (overrides)
record vs classFinal, immutable fields, generated equals/hashCode/toString vs everything by hand
Serializable vs ExternalizableAutomatic vs you write writeExternal/readExternal, needs public no-arg constructor
Maven vs GradleXML + fixed lifecycle vs script + task graph, incremental builds

Common method complexities

OperationCostOperationCost
ArrayList.get / setO(1)ArrayList.add (end)O(1) amortised
ArrayList.add(i, x) / remove(i)O(n)ArrayList.contains / indexOfO(n)
LinkedList.get(i)O(n)LinkedList.addFirst / removeFirstO(1)
ArrayDeque.push / pop / offer / pollO(1) amortisedPriorityQueue.offer / pollO(log n)
PriorityQueue.peekO(1)PriorityQueue.remove(x) / containsO(n)
HashMap.get / put / removeO(1) avg, O(log n) worst (treeified bin)TreeMap.get / put / floorKeyO(log n)
HashSet.containsO(1) avgTreeSet.first / ceilingO(log n)
String.charAt / lengthO(1)String.substring / concat / +O(n) (copies)
String.equals / hashCode (first call)O(n)StringBuilder.appendO(1) amortised
Arrays.sort(int[])O(n log n) dual-pivot quicksortCollections.sort / List.sortO(n log n) TimSort, stable
Arrays.binarySearchO(log n)Collections.reverse / shuffleO(n)
new PriorityQueue<>(collection)O(n) heapifyConcurrentHashMap.sizeO(counter cells); only an estimate while writes happen
Last-minute rules: override equals and hashCode together; program to interfaces (List, not ArrayList); prefer immutability; close resources with try-with-resources; never compare strings or boxed numbers with ==; use Integer.compare, not subtraction, in comparators.

Tricky Outputs — predict before you read

In plain words

Twelve short programs that interviewers love because the obvious answer is wrong. Cover the output, predict it, then check. Every output below was produced by running the code on Java 21.

Optical illusions for programmers: once you know why the lines only look bent, you never fall for it again.

1. String == vs equals

String s1 = "java";
String s2 = "java";
String s3 = new String("java");
String s4 = "ja" + "va";          // compile-time constant
String part = "ja";
String s5 = part + "va";          // built at runtime
System.out.println(s1 == s2);
System.out.println(s1 == s3);
System.out.println(s1 == s4);
System.out.println(s1 == s5);
System.out.println(s1 == s5.intern());
System.out.println(s1.equals(s3));
true
false
true
false
true
true

Why: literals and constant expressions share one pooled object; new and runtime concatenation make new objects; intern() returns the pooled one.

2. 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));
Long L = 127L;
System.out.println(L.equals(127));
true
false
true
false

Why: autoboxing uses Integer.valueOf, which caches −128..127; Long.equals(Integer) is false because the types differ.

3. char + int

System.out.println('a' + 'b');
System.out.println("" + 'a' + 'b');
System.out.println('a' + 1);
System.out.println((char) ('a' + 1));
System.out.println(1 + 2 + "3" + 4 + 5);
char ch = 'a';
ch += 1;
System.out.println(ch);
195
ab
98
b
3345
b

Why: chars are numbers (97 + 98) until a String appears on the left; + is evaluated left to right; += contains a hidden cast back to char.

4. finally overriding return

static int f() {
    try { return 1; }
    finally { return 2; }
}
static int g() {
    int x = 1;
    try { return x; }
    finally { x = 99; }
}
System.out.println(f());
System.out.println(g());
2
1

Why: a return in finally replaces the pending one (and swallows exceptions); but changing a local in finally doesn't affect a value already saved for return.

5. Static and instance initialisation order

class Parent {
    static { System.out.println("1 Parent static block"); }
    { System.out.println("3 Parent instance block"); }
    Parent() { System.out.println("4 Parent constructor"); }
}
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

Why: static blocks run once when each class is initialised (parent first); for every object, the parent is fully built (instance blocks, then constructor) before the child's.

6. Overload resolution with null

static void print(Object o) { System.out.println("Object"); }
static void print(String s) { System.out.println("String"); }
print(null);
Object o = "text";
print(o);
String
Object

Why: overloads are chosen at compile time by the most specific declared type — String is more specific than Object for null; the variable o is declared Object. (With print(Integer) added too, print(null) would not compile: ambiguous.)

7. i = i++

int i = 5;
i = i++;
System.out.println(i);
int j = 5;
j = j++ + ++j;
System.out.println(j);
5
12

Why: i++ yields the old value 5, increments i to 6, then the assignment writes 5 back; for j: 5 (j becomes 6) + 7 = 12.

8. Floating point

System.out.println(0.1 + 0.2);
System.out.println(0.1 + 0.2 == 0.3);
System.out.println(0.1f + 0.2f == 0.3f);
System.out.println(1.0 / 0);
System.out.println(0.0 / 0 == 0.0 / 0);
System.out.println(3 / 2);
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
0.30000000000000004
false
true
Infinity
false
1
0.3

Why: 0.1 has no exact binary form, and the float rounding happens to land on the same value while double doesn't; floating division by zero gives Infinity (integer division throws); NaN is never equal to anything, even itself; use BigDecimal from a String for money.

9. Integer overflow

System.out.println(Integer.MAX_VALUE + 1);
System.out.println(Math.abs(Integer.MIN_VALUE));
long wrong = 1000 * 1000 * 1000 * 10;
long right = 1000L * 1000 * 1000 * 10;
System.out.println(wrong + " " + right);
-2147483648
-2147483648
1410065408 10000000000

Why: int arithmetic silently wraps; |MIN_VALUE| doesn't fit in an int; the multiplication is done in int before widening to long — make the first operand long.

10. Ternary unboxing NullPointerException

Integer nothing = null;
boolean flag = false;
try {
    Integer r = flag ? 1 : nothing;
    System.out.println(r);
} catch (NullPointerException e) {
    System.out.println("NullPointerException");
}
Integer safe = flag ? Integer.valueOf(1) : nothing;
System.out.println(safe);
NullPointerException
null

Why: with an int and an Integer operand the ternary's type is int, so nothing is unboxed and throws; when both operands are Integer no unboxing happens.

11. HashSet with a mutable key

Set<List<Integer>> set = new HashSet<>();
List<Integer> key = new ArrayList<>(List.of(1, 2));
set.add(key);
key.add(3);                       // hashCode changes after insertion
System.out.println(set.contains(key));
System.out.println(set.size());
System.out.println(set.iterator().next().equals(key));
false
1
true

Why: the element is stored in the bucket for its old hash; contains looks in the bucket for the new hash and finds nothing — the element is "lost" but still there. Use immutable keys.

12. Method hiding (static) vs overriding

class A { static String who() { return "A"; } String me() { return "A"; } }
class B extends A { static String who() { return "B"; } @Override String me() { return "B"; } }
A x = new B();
System.out.println(x.me());
System.out.println(x.who());      // legal but misleading: static call through an instance
System.out.println(B.who());
B
A
B

Why: instance methods are dispatched on the runtime object (B); static methods are bound at compile time to the declared type (A) — they are hidden, not overridden.

Pattern behind all twelve: most traps come from three facts — boxing and caching (1, 2, 10), compile-time decisions (1, 6, 9, 12), and evaluation order (3, 4, 5, 7). When you see a trick question, ask: "what is decided at compile time here, and what is actually stored?"

Learning Path

In plain words

An ordered route from "I can write a loop" to "I can design, debug and tune a Java service in production". Each step lists what to learn (with the sections above), what to be able to explain in an interview, and a project that forces you to use it.

Like learning to drive: first the pedals (syntax), then the rules of the road (OOP, collections), then motorways (concurrency), then what is under the bonnet (JVM). Skipping steps works until the first breakdown.

Basics -> OOP -> Core APIs -> Functional -> Testing & Build -> Concurrency -> JVM -> Modern Java -> Spring & Production (1-7) (8-16) (17-23) (24-27) (44) (28-34) (35-38) (39-43)
StepLearnYou can explain…Project idea
1. Language basicsJDK/JVM, types, wrappers, strings, arrays, control flow, pass-by-value (s1–s7)Why Java is pass-by-value; String immutability; int vs IntegerCommand-line calculator and a word-frequency counter reading a text file
2. OOPClasses, encapsulation, inheritance, polymorphism, interfaces, equals/hashCode, enums, records (s8–s16)Abstract class vs interface; equals/hashCode contract; composition over inheritanceLibrary management system: books, members, loans, fines
3. Core APIsExceptions, generics, collections, HashMap internals, comparators, Optional (s17–s23)How HashMap works; fail-fast iterators; checked vs unchecked; type erasureBuild your own LRUCache and a generic MyHashMap<K,V> with resizing
4. Functional JavaLambdas, streams, collectors, java.time (s24–s27)Lazy evaluation; map vs flatMap; groupingByCSV sales-report generator: group, sum, top-N with streams
5. Testing and buildMaven/Gradle, JUnit 5, Mockito (s44), SOLID (s42)Unit vs integration test; mock vs stub; dependency scopesAdd 90% test coverage to steps 2–4 projects; set up a GitHub Actions build
6. ConcurrencyThreads, synchronized, volatile, locks, atomics, executors, CompletableFuture, concurrent collections, virtual threads (s28–s34)Happens-before; deadlock conditions; thread pool sizing; why count++ is not atomicMulti-threaded web crawler with a bounded pool, then rewrite with virtual threads and compare
7. JVM internalsMemory areas, GC, class loading, profiling (s35–s38)Heap generations; G1 vs ZGC; how to find a memory leakCreate a leak on purpose, capture a heap dump, find it in Eclipse MAT or VisualVM; tune GC flags with JFR
8. Modern and practical JavaJava 8–21 features, I/O and NIO, design patterns, reflection and annotations (s39–s43)Records + sealed + pattern matching; Singleton safety; how @Autowired worksMini dependency-injection container using your own @Inject annotation and reflection
9. Frameworks and productionSpring Boot (REST, Data JPA, Security, validation), SQL and transactions, Docker, logging, metricsBean lifecycle; @Transactional proxies; N+1 queries; connection poolsURL shortener or task tracker REST API with PostgreSQL, Testcontainers tests, Docker image
10. Senior levelSystem design, messaging (Kafka), caching (Redis), resilience (timeouts, retries, circuit breakers), observability, performance tuningTrade-offs, failure modes, capacity estimates; reading a flame graphSplit the step-9 app into two services talking over Kafka; load-test with Gatling and fix the bottleneck

How to study each step

  • Read, then run. Type every example yourself and change one thing to see what breaks — that is how the tricky-output questions stop being tricky.
  • Explain it out loud in two minutes, as in an interview: definition, how it works inside, one pitfall, when you'd use it.
  • Read JDK source for the classes you use most: ArrayList, HashMap, String, ConcurrentHashMap. Your IDE opens it with one click.
  • Practise DSA in Java alongside steps 3–4 (the DSA notes on this site), 2–3 problems a day.
  • Keep one "big" project that grows through steps 5–10 instead of many throw-away ones; it becomes your interview story.
LevelWhat interviewers expect
Junior (0–2 years)Steps 1–5 solid: OOP, collections, exceptions, streams, writing tests, clean code, DSA basics
Mid (2–5 years)Plus steps 6 and 9: concurrency correctness, Spring internals, SQL and transactions, design patterns applied to real code
Senior (5+ years)Plus steps 7, 8 and 10: JVM tuning and debugging production issues, modern Java idioms, system design, mentoring and trade-off reasoning
Final tip: for every topic, be ready with the three answers interviewers always want — what it is in one sentence, how it works underneath, and when you would (or would not) use it, ideally with a story from your own project.