Java — Concept Notes
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.
1. What Java Is: JDK, JRE & JVM
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 vs JRE vs JVM
| JVM | JRE | JDK | |
|---|---|---|---|
| What it is | An abstract machine (a spec) plus its implementation that executes bytecode | JVM + standard class library | JRE + compiler and developer tools |
| Main job | Load, verify and execute .class files; manage memory (GC); run threads | Provide everything a program needs at run time | Compile, package, debug, document, profile |
Contains javac? | No | No | Yes |
| Platform-specific? | Yes — one build per OS/CPU | Yes | Yes |
| Who needs it | Every machine that runs Java | Servers/users that only run apps (historically) | Developers and build servers |
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).
- 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
.classfiles, 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. UsePath.of(...)andSystem.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?
| Property | Meaning |
|---|---|
| Statically and strongly typed | Every 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 collected | You never call free. The JVM finds unreachable objects and reclaims their memory. |
| No pointer arithmetic | References point to objects, but you cannot do ptr + 4. This removes a whole class of memory bugs. |
| Multithreaded | Threads, locks and a memory model are built into the language and JVM. Java 21 added cheap virtual threads. |
| Safe by design | Array 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.
| Version | Year | Headline features |
|---|---|---|
| 8 (LTS) | 2014 | Lambdas, Streams, Optional, java.time, default methods in interfaces |
| 9 / 10 | 2017–18 | Module system, jshell, List.of (9); var for local variables (10) |
| 11 (LTS) | 2018 | Single-file launch java Hello.java, HttpClient, String.strip/isBlank/repeat/lines |
| 14 – 16 | 2020–21 | Switch expressions (14), text blocks (15), records and instanceof patterns (16) |
| 17 (LTS) | 2021 | Sealed classes; the common "modern Java" baseline |
| 21 (LTS) | 2023 | Virtual threads, pattern matching for switch, record patterns, sequenced collections |
| 25 (LTS) | 2025 | Compact 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
| Myth | Reality |
|---|---|
| "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.
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
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).
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
javactakes a file name (Hello.java);javatakes a class name (Hello).java Hello.classfails with "Could not find or load main class Hello.class".- A
publicclass must live in a file with the same name. Each class — including nested ones — becomes its own file:App.javawith a nestedHelperproducesApp.classandApp$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)
- Loading — a class loader reads the bytes of the
.classfile and creates aClassobject in memory (class metadata goes into Metaspace, which replaced PermGen in Java 8). - 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/Systeminto real references, often lazily). - Initialisation — run static initialisers and assign static field values. This happens on first active use of the class, not at start-up.
| Class loader | Loads | getClassLoader() prints |
|---|---|---|
| Bootstrap | Core 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.sql | platform |
| Application (system) | Your classes and JARs on the classpath | app |
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:
$ 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)
| Area | Per thread or shared | Holds |
|---|---|---|
| Heap | Shared | All objects and arrays; managed by the garbage collector |
| Stack | One per thread | A frame per method call: local variables, parameters, operand stack. Too deep = StackOverflowError |
| Metaspace | Shared (native memory) | Class metadata, method bytecode (Java 8+; replaced PermGen) |
| PC register | One per thread | Address of the bytecode instruction being executed |
| Native method stack | One per thread | Frames 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 (nevernull; empty array if none). - Also legal:
String... args,final String[] args,static public. You may overloadmain, but the launcher only calls theString[]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
| Error | Meaning | Usual fix |
|---|---|---|
ClassNotFoundException | Code asked for a class by name (launcher, Class.forName) and it is not on the classpath | Fix -cp, use the fully qualified name |
NoClassDefFoundError | Class existed at compile time but is missing at run time, or its static initialiser failed earlier | Add the missing JAR; check the first exception in the log |
UnsupportedClassVersionError | Class 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 |
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
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
| Type | Size | Range | Default (fields) | Wrapper | Literal |
|---|---|---|---|---|---|
byte | 8 bit | -128 to 127 | 0 | Byte | (byte) 10 |
short | 16 bit | -32,768 to 32,767 | 0 | Short | (short) 10 |
int | 32 bit | about ±2.1 billion (231 − 1 = 2,147,483,647) | 0 | Integer | 42, 0xFF, 0b1010, 1_000_000 |
long | 64 bit | about ±9.2 × 1018 | 0L | Long | 10_000_000_000L |
float | 32 bit | about ±3.4 × 1038, ~7 significant digits | 0.0f | Float | 3.14f |
double | 64 bit | about ±1.8 × 10308, ~15–16 significant digits | 0.0 | Double | 3.14, 1e-9 |
char | 16 bit | 0 to 65,535 (unsigned; one UTF-16 code unit) | '\u0000' | Character | 'A', '\n' |
boolean | not specified (JVM-dependent) | true / false | false | Boolean | true |
- Sizes are fixed on every platform — an
intis always 32 bits, unlike C. All integer types are signed exceptchar. - 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
intby default:long big = 3_000_000_000;fails with "integer number too large" — addL. A decimal literal is adouble:float f = 3.14;fails with "possible lossy conversion from double to float" — addf. charis a number:'A' + 1is66, and(char) ('A' + 1)is'B'.
Primitive vs reference
Primitive (int x = 5) | Reference (Integer y = 5, String s) | |
|---|---|---|
| Variable holds | The value itself | A reference (address) to an object on the heap |
Can be null? | No | Yes |
== compares | Values | Identity (same object?) — use equals for content |
| Memory | 4 bytes for int | Reference (4–8 bytes) + object (~16 bytes for an Integer) |
| Generics / collections | Not allowed: List<int> is illegal | Allowed: 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.
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
Charactercaches 0–127,Booleanhas justTRUE/FALSE.Float/Doublehave no cache. TheIntegerupper bound can be raised with-XX:AutoBoxCacheMax=<n>— never rely on it.- Rule: compare wrappers with
equals(or unbox first). Beware anullwrapper 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
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();).
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
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
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, unary | x++ 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: statement | New case X -> / switch expression | |
|---|---|---|
| Fall-through | Yes, unless you break | No — only the matching branch runs |
| Produces a value | No (assign inside cases) | Yes, with -> or yield |
| Exhaustive | Not required | Required for expressions (default, all enum constants, or all sealed subtypes) |
| Multiple labels | Stacked 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
Listwhile looping over it with enhancedforthrowsConcurrentModificationException— use anIteratorandit.remove(), orremoveIf. - Comparing floats:
0.1f == 0.1isfalse(the float is widened to a slightly different double).
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
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 fastHashMapkeys.
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
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) orObjects.equals(a, b).equalsIgnoreCaseignores case;compareToorders 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
String | StringBuilder (Java 5) | StringBuffer (Java 1.0) | |
|---|---|---|---|
| Mutable? | No | Yes | Yes |
| Thread-safe? | Yes (immutable) | No | Yes — every method synchronized |
| Speed | Each change copies the whole string | Fastest | Slower (locking) |
| Use for | Values, keys, constants | Building 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
| Method | Example with w = "Hello, World" | Result |
|---|---|---|
length(), charAt(i) | w.length(), w.charAt(4) | 12, 'o' |
indexOf / lastIndexOf | w.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, endsWith | w.contains("World"), w.endsWith("!") | true, false |
replace, toLowerCase | w.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.valueOf | String.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
splittakes a regex:"a.b.c".split(".")returns an empty array because.matches every character. Usesplit("\\.")→[a, b, c].substring,replace,trimreturn new strings — forgetting to assign the result is a silent bug.- Different strings can share a hash:
"Aa".hashCode()and"BB".hashCode()are both2112. Equal hash never means equal strings. length()counts UTF-16 code units, not visible characters — an emoji or some CJK characters take 2. UsecodePointCountwhen that matters.- Do not keep passwords in a
String(it lingers in memory until GC and may be pooled) — APIs usechar[]so you can wipe it.
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
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 type | Default value |
|---|---|
byte, short, int, long | 0 |
float, double | 0.0 |
char | '\u0000' (the character with code 0) |
boolean | false |
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) { ... }
}
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> | |
|---|---|---|
| Size | Fixed at creation | Grows automatically (about 1.5x when full) |
| Primitives | Yes — int[] stores raw ints | No — boxes to Integer (more memory, slower) |
| Type safety | Run-time store check (covariant) | Compile-time (invariant generics) |
| Length | arr.length | list.size() |
| equals / toString | Identity / [I@hash — use Arrays.* | Element-wise, readable |
| Best for | Known size, performance-critical numeric code, DP tables | Everything else in business code |
Pitfalls
- Arrays as
HashMap/HashSetkeys use identity:set.contains(new int[]{1})isfalseeven after addingnew int[]{1}. UseList.of(...)or arecordas the key. new String[3]gives threenulls — calling a method on an element throwsNullPointerException.- Off-by-one: the last index is
length - 1; loop withi < arr.length. - Returning an internal array exposes it to modification. Return
arr.clone()or an unmodifiable list.
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
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 andthrowsare not part of it. staticmethods belong to the class (Math.max); instance methods need an object and can usethis.- 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:
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
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... xisint[] xinside 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.ofhas fixed-arity overloads up to 10 elements). - Generic varargs (
List<String>...) trigger "possible heap pollution" warnings;@SafeVarargssuppresses 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
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 parameter | No | The parameter is a copy of the number |
| Call a mutating method / set a field on an object parameter | Yes | The copied reference points to the same object |
| Assign a new object to the parameter | No | Only the local copy of the reference changes |
"Change" a String or wrapper parameter | No | They 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
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).8. Classes & Objects
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
== on objects compares the arrows, not the contents.Default values: fields yes, local variables no
| Type | Field default | Local variable |
|---|---|---|
int, long, short, byte | 0 | No default. Reading it before assigning is a compile error ("variable might not have been initialized"). |
double, float | 0.0 | |
boolean | false | |
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 addPizza(String). - Constructors are not inherited and cannot be
static,finalorabstract. They can beprivate(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(...)orsuper(...)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()callsthis(1)which callsthis()) 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
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 callssuper(...)does.
Common pitfalls
- Comparing objects with
==checks identity.new Pizza() == new Pizza()isfalseeven with equal fields; useequals(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.
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
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
| Modifier | Same class | Same package | Subclass in another package | Everyone else | Typical use |
|---|---|---|---|---|---|
private | yes | no | no | no | Fields, helper methods |
| (none) package-private | yes | yes | no | no | Internals shared inside one package; tests in the same package |
protected | yes | yes | yes, through inheritance only | no | Hooks meant for subclasses |
public | yes | yes | yes | yes | The API |
Rules people forget
- A top-level class can only be
publicor package-private. Only nested classes can beprivateorprotected. One public top-level class per file, and the file name must match it. privateis per class, not per object. InsidePoint, you can readother.xof anotherPoint— that is howequalsworks. An outer class and its nested classes can also read each other's private members.protectedincludes package access. It is wider than package-private, not narrower — a common wrong answer.- Interface members are
publicby default (methods arepublic abstract, fields arepublic 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
| Benefit | What it buys you |
|---|---|
| Invariants | Validation lives in one place; the object can never be in an illegal state. |
| Freedom to change | Store cents instead of a double, add caching, switch to a database — callers never notice. |
| Thread safety | If all access goes through methods, you can add locking in one place. |
| Debugging | A breakpoint in one setter catches every change; a public field can be changed from anywhere. |
Best practice
- Make every field
private(andfinalif 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.
10. Inheritance
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.
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
| Rule | Allowed | Not allowed |
|---|---|---|
| Name and parameter types | Exactly the same (otherwise it is an overload, not an override) | — |
| Return type | Same, or a subtype (covariant return, Java 5) | A different or wider type; for primitives it must be identical |
| Access | Same or wider (protected → public) | Narrower (public → package-private) |
| Checked exceptions | Same, a subclass, or none; any unchecked exceptions | New 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
finalmethod cannot be overridden: use it when the parent's logic must not change (security checks, template-method skeletons). - A
finalclass cannot be extended:String,Integerand all wrappers,LocalDate, everyrecord. This guarantees nobody can create a "String" subclass that mutates or lies about its contents. - Java 17 adds a middle ground:
sealedclasses list exactly which subclasses are allowed (section 12).
Why no multiple class inheritance: the diamond problem
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:
- Class wins. A method in the class or any superclass beats any interface default.
- More specific interface wins. If
B extends Aand both have a default, B's is used. - 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.
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
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
| Overloading | Overriding | |
|---|---|---|
| Also called | Compile-time / static polymorphism | Runtime / dynamic polymorphism |
| Where | Same class (or inherited) | Subclass redefines a parent method |
| Signature | Same name, different parameters | Same name and parameters |
| Return type | Anything | Same or covariant |
| Decided by | Compiler, using the declared type of the arguments | JVM, using the runtime type of the object |
| Works with static / private / final | Yes | No |
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 -Xlintwarns: "static method should be qualified by type name, Shape, instead of by an expression". - Overload choice is fixed at compile time:
greet(s)picksgreet(Shape)even thoughsholds a Circle. Overloading does not dispatch on runtime type.
How dynamic dispatch works inside
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:
- Exact match or widening of primitives (
int→long), no boxing. - Boxing / unboxing (
int→Integer). - 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 splaced beforecase Circle cis a compile error ("this case label is dominated by a preceding case label"). - A long
instanceofchain 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.
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
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
| Feature | Abstract class | Interface |
|---|---|---|
Instantiate with new | No | No (but lambdas / anonymous classes can implement it) |
| How many per class | Extend one | Implement many |
| Instance fields (state) | Yes, any kind | No — only public static final constants |
| Constructors | Yes (called via super(...)) | No |
| Method bodies | Any method | default and static (Java 8), private (Java 9) |
| Abstract methods | Must say abstract | Implicitly public abstract |
| Member access | Any modifier | Public by default; private allowed for helpers (Java 9) |
| Restrict who extends | sealed ... permits (Java 17) | sealed ... permits (Java 17) |
| Relationship | IS-A, shared identity and code | CAN-DO, a capability |
| Lambda target | No | Yes, 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.
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
sealedwhen the set of subtypes is closed and known (payment kinds, AST nodes, results likeSuccess/Failure).
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
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
| Method | Default behaviour | Override? |
|---|---|---|
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@hexHash | Almost always |
getClass() | The runtime Class object | Cannot (final) |
clone() | Protected; shallow field copy if the class implements Cloneable | Rarely; prefer copy constructors |
wait(), notify(), notifyAll() | Thread coordination on the object's monitor | Cannot (final) |
finalize() | Called before GC, maybe never | Never — deprecated since Java 9, for removal since 18 |
The equals contract
- Reflexive:
x.equals(x)is true. - Symmetric:
x.equals(y)if and only ify.equals(x). - Transitive: if
x.equals(y)andy.equals(z), thenx.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), thena.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
equalsdo 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
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 callequals(Object)and never see it.@Overridecatches this. - Compare
doublefields withDouble.compare, objects withObjects.equals(a, b)(null-safe), arrays withArrays.equals. instanceofvsgetClass(): withinstanceof, a subclass that adds a field (ColorPoint) breaks symmetry —point.equals(colorPoint)is true but not the reverse. Either make the classfinal(as above) or usegetClass() != o.getClass(), which treats subclasses as never equal.- Records (section 15) generate a correct
equals,hashCodeandtoStringfrom 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
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).
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
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
| Kind | Declared | Needs an outer object? | Can access | Typical use |
|---|---|---|---|---|
| Static nested | In a class, with static | No | Outer's static members (including private) | Builders, Map.Entry-style helpers, holder idiom |
| Inner (member) | In a class, no static | Yes: outer.new Inner() | Everything in the outer object | Iterators over the outer collection |
| Local | Inside a method or block | Yes, if the method is an instance method | Outer members + effectively final locals | A helper used by one method only |
| Anonymous | Inline with new Type() { ... } | Yes, in an instance context | Same as local | One-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.outerValuereaches the outer field when the inner class shadows the name.- Anonymous classes compile to
Outer$1,Outer$2…; nested ones toOuter$Inner. - Inside an anonymous class,
thisis the anonymous object. Inside a lambda,thisis 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
staticmembers, 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
- 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
thistoo. A lambda capturesthisonly 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:
Builderin 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).
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
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
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]
EnumMapis backed by a plain array indexed by ordinal, andEnumSetby a bit mask (onelongfor up to 64 constants). Both are faster and smaller thanHashMap/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. Storename()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
defaultis 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 write | The 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 = centsare 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
Listcomponent 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)) ...orcase 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.
@Data?" — record is built in, final and immutable; @Data generates setters and a mutable class.16. static, final & Immutability
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
| Where | Meaning | Example |
|---|---|---|
| Field | One copy per class, shared by all objects | static int instances; |
| Method | Called on the class; no this, cannot touch instance fields directly | Math.max, factories |
| Block | Runs once, when the class is initialised | Loading a lookup table |
| Nested class | No hidden reference to an outer object | Builder, holder idiom |
| Import | Use static members without the class name | import 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
Classobject on the heap in modern HotSpot), so a staticListorMapthat 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 to | Effect |
|---|---|
| Local variable / parameter | Assigned once |
| Instance field | Must 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 field | Assigned 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. |
| Method | Cannot be overridden |
| Class | Cannot 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 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
- Make the class
final(or give it only private constructors), so no subclass can add mutable state or override accessors. - Make every field
private final. - Provide no setters and no methods that change fields.
- Defensive copy in: copy every mutable argument (lists, arrays,
Date) in the constructor. - Defensive copy out: never return a mutable internal object; return a copy or an unmodifiable view.
- "Modifying" methods return a new object (
withItem,plusDays). - Do not let
thisescape 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 (
Stringpool,Integercache,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).
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.17. Exceptions
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. The compiler only checks one branch: Exception minus RuntimeException.| Checked | Unchecked (RuntimeException) | Error | |
|---|---|---|---|
| Meaning | Expected, recoverable problem outside your control | A bug in the calling code | Serious JVM/system failure |
| Examples | IOException, SQLException | NullPointerException, IllegalArgumentException | OutOfMemoryError, StackOverflowError |
| Compiler rule | Must catch or declare with throws | No rule | No rule |
| Should you catch it? | Yes, where you can do something useful | Usually no — fix the bug; catch at a top-level boundary | Almost never |
try / catch / finally — the exact rules
catchblocks 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.finallyruns whether the try block finished normally, returned, or threw. It is for cleanup.- If the try block executes
return x, the value ofxis saved first, thenfinallyruns. Changing a primitive infinallydoes not change the returned value — but mutating a returned object does. - A
returninsidefinallyoverrides 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 is | Use | |
|---|---|---|
final | Keyword | Variable cannot be reassigned, method cannot be overridden, class cannot be extended |
finally | Block after try/catch | Cleanup code that runs whether or not an exception happened |
finalize() | Method on Object | Called 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
ExceptionorThrowable, 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"),IllegalArgumentExceptionfor bad arguments,IllegalStateExceptionfor "wrong time to call this". - If you catch
InterruptedExceptionand 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.
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
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:
| Wildcard | Accepts | Read gives | Can add | Role |
|---|---|---|---|---|
List<? extends Number> | List<Integer>, List<Double>, List<Number> | Number | nothing (except null) | Producer — you read from it |
List<? super Integer> | List<Integer>, List<Number>, List<Object> | Object only | Integer | Consumer — you write into it |
List<?> | any list | Object | nothing (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]
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... | Why | Workaround |
|---|---|---|
new T() | At runtime T is unknown | Pass a factory: Supplier<T> or Class<T> |
new T[10] or new List<String>[10] | Arrays must know their element type at runtime | Use List<T>; or (T[]) new Object[n] internally (as ArrayList does) |
x instanceof List<String> | The <String> part no longer exists | x instanceof List<?>, then check elements |
List<int> | Erasure to Object needs reference types | List<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 field | Static belongs to the one shared class, not to each Box<X> | Make the static member generic on its own |
catch (T e) / generic exception classes | The catch needs a real runtime type | Catch 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.
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
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
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 forTreeSet). - 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
Collectionbecause its elements are pairs. - Also:
SortedSet/NavigableSetandSortedMap/NavigableMapadd sorted views andfloor/ceiling; Java 21 addedSequencedCollection/SequencedMap(JEP 431) withgetFirst(),getLast(),reversed()for anything with a defined order.
Each implementation, inside
| Class | Built on | Order | Nulls | Notes |
|---|---|---|---|---|
ArrayList | Resizable array (default capacity 10, allocated on first add; grows by 50%) | Index | Yes | The default list |
LinkedList | Doubly linked nodes | Index | Yes | Extra node object per element; poor cache locality |
ArrayDeque | Circular resizable array | Ends | No | Best stack and queue |
PriorityQueue | Binary heap in an array | Smallest first on poll | No | Iteration order is NOT sorted |
HashSet | A HashMap (elements are the keys) | None | One | Needs good hashCode/equals |
LinkedHashSet | LinkedHashMap | Insertion | One | Dedupe while keeping order |
TreeSet | TreeMap (red-black tree) | Sorted | No* | Needs Comparable or a Comparator |
HashMap | Array of buckets (see s20) | None | One key, many values | The default map |
LinkedHashMap | HashMap + doubly linked list through entries | Insertion or access | Yes | LRU cache building block |
TreeMap | Red-black tree | Sorted by key | No* keys | floorKey, ceilingKey, headMap, tailMap |
*Unless your comparator explicitly handles null.
Complexity table
| Class | get / access | add | remove | contains | Next / iterate |
|---|---|---|---|---|---|
ArrayList | O(1) | O(1) amortised at end, O(n) middle | O(n) (O(1) at end) | O(n) | O(1) each |
LinkedList | O(n) | O(1) at ends | O(1) at ends / via iterator; O(n) to find | O(n) | O(1) each |
ArrayDeque | O(1) ends only | O(1) amortised at ends | O(1) at ends | O(n) | O(1) each |
PriorityQueue | peek O(1) | O(log n) | poll O(log n), remove(x) O(n) | O(n) | unsorted |
HashSet / LinkedHashSet | — | O(1) avg | O(1) avg | O(1) avg | O(capacity) / O(n) |
TreeSet | first/last O(log n) | O(log n) | O(log n) | O(log n) | sorted |
HashMap / LinkedHashMap | O(1) avg | O(1) avg | O(1) avg | key O(1) avg, value O(n) | O(capacity) / O(n) |
TreeMap | O(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
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.asList | Collections.unmodifiableList | List.of / List.copyOf | |
|---|---|---|---|
| add / remove | Throws | Throws | Throws |
| set | Allowed (writes to the array) | Throws | Throws |
| Nulls | Allowed | Allowed | Rejected (NPE) |
| Changes underneath show? | Yes (array-backed) | Yes (it is a view) | No — truly immutable |
| Since | 1.2 | 1.2 | 9 / 10 |
List.of(mutableObject) still lets you change the object.20. How HashMap Works Inside
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.
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? Whennis a power of two,n - 1is all ones in binary (16 - 1 =1111), so the AND keeps just the low bits: the same result ashash % nfor non-negative numbers, but faster, and never negative even whenhashCode()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
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 7 | Java 8+ | |
|---|---|---|
| Bucket structure | Linked list only (worst case O(n)) | List, turns into red-black tree (worst case O(log n)) |
| Insert position in bucket | Head of list | Tail of list |
| Resize | Rehash each entry; list order reversed | Split into lo/hi lists by hash & oldCap; order kept |
| Concurrent misuse | Head 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 spreading | Several shifts and XORs | One XOR: h ^ (h >>> 16) |
| Entry class | Entry | Node 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.
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
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> | |
|---|---|---|
| Package | java.lang | java.util |
| Method | int compareTo(T other) | int compare(T a, T b) |
| Where it lives | Inside the class being sorted | Outside: a lambda, a constant, a separate class |
| How many orders | One (natural order) | As many as you like |
| Used by | Collections.sort(list), TreeSet, TreeMap, PriorityQueue by default | list.sort(cmp), new TreeMap<>(cmp), stream.sorted(cmp) |
| Use when | There is one obvious order and you own the class | Several 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+)
| Method | What 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 / Double | Same, 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]
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 ofcompare(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 == 0exactly whenequalsis 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.
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
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"
Iterator | ListIterator | |
|---|---|---|
| Works on | Every Collection | List only |
| Direction | Forward | Forward and backward (hasPrevious, previous) |
| Modify | remove() | remove(), set(e), add(e) |
| Position | No | nextIndex(), 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<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-fast | Fail-safe: snapshot | Fail-safe: weakly consistent | |
|---|---|---|---|
| Examples | ArrayList, HashMap, HashSet, TreeMap | CopyOnWriteArrayList, CopyOnWriteArraySet | ConcurrentHashMap, ConcurrentLinkedQueue, ConcurrentSkipListMap |
| Change during iteration | Throws CME (best effort) | Never throws; iterator doesn't see it | Never throws; may or may not see it |
| Iterates over | The live collection | A copy of the array at iterator creation | The live structure |
| Cost | Cheap | Every write copies O(n) — for read-mostly lists (listeners) | Cheap; fine-grained concurrency |
| iterator.remove() | Supported | Unsupported | Supported |
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
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
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
| Method | If present | If empty | Since |
|---|---|---|---|
orElse(x) | value | x (always evaluated!) | 8 |
orElseGet(() -> x) | value | calls the supplier | 8 |
orElseThrow(() -> ex) | value | throws your exception | 8 |
orElseThrow() | value | NoSuchElementException | 10 |
get() | value | NoSuchElementException — same as above, but the name hides it | 8 |
ifPresent(fn) / ifPresentOrElse(fn, run) | runs fn | nothing / runs run | 8 / 9 |
or(() -> otherOptional) | itself | the other Optional | 9 |
stream() | 1-element stream | empty stream | 9 |
isPresent() / isEmpty() | true / false | false / true | 8 / 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't | Why | Do instead |
|---|---|---|
if (opt.isPresent()) return opt.get(); | Just a null check with extra steps | opt.orElse(...), map, ifPresent |
opt.get() without checking | Throws on empty — the NPE problem again, renamed | orElseThrow() (same behaviour, but the name admits it) |
| Optional fields | Optional is not Serializable, adds an object per field, and wasn't designed for it | Nullable field; return Optional.ofNullable(field) from the getter |
| Optional parameters | Callers must wrap; they can still pass null for the Optional itself | Overloads, or a nullable parameter checked inside |
Optional<List<T>> | Two ways to say "nothing" | Return an empty list |
Returning null from a method typed Optional | Defeats the whole point | Return Optional.empty() |
Optional<Integer> in hot paths | Boxing plus a wrapper object | OptionalInt/OptionalLong/OptionalDouble (e.g. IntStream.max()) |
== or synchronized on an Optional | It is a value-based class; identity is not meaningful | equals: Optional.of("a").equals(Optional.of("a")) is true |
OptionalInt max = IntStream.of().max(); // OptionalInt.empty
max.orElse(-1); // -1
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.24. Lambdas & Functional Interfaces
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)
| Interface | Method | Shape | Typical use |
|---|---|---|---|
Function<T, R> | R apply(T t) | T in, R out | map, converting values |
BiFunction<T, U, R> | R apply(T t, U u) | two in, one out | Map.merge, replaceAll |
Supplier<T> | T get() | nothing in, T out | lazy values, factories, orElseGet |
Consumer<T> | void accept(T t) | T in, nothing out | forEach, side effects |
BiConsumer<T, U> | void accept(T t, U u) | two in, nothing out | Map.forEach |
Predicate<T> | boolean test(T t) | T in, yes/no out | filter, removeIf |
UnaryOperator<T> | T apply(T t) | Function where in and out are the same type | List.replaceAll, Stream.iterate |
BinaryOperator<T> | T apply(T a, T b) | two T in, T out | reduce |
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:
| Family | Examples |
|---|---|
| Predicate | IntPredicate, LongPredicate, DoublePredicate |
| Function (primitive in) | IntFunction<R>, IntUnaryOperator, IntToLongFunction |
| Function (primitive out) | ToIntFunction<T>, ToLongFunction<T>, ToDoubleFunction<T> |
| Supplier / Consumer | IntSupplier, BooleanSupplier, IntConsumer, ObjIntConsumer<T> |
| Operators | IntBinaryOperator, 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.
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
}
}
| Lambda | Anonymous class | |
|---|---|---|
this | The enclosing instance | The anonymous object |
| New scope? | No — cannot redeclare a local variable name from the outer method | Yes — can shadow outer names |
| State (fields) | None | Can have fields and several methods |
| Target | Only functional interfaces | Any interface or (abstract) class |
| Compiled to | An invokedynamic call site; no extra .class file; stateless lambdas are reused as one object | A separate Outer$1.class, new object per evaluation |
@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.IntPredicate, ToIntFunction) in hot paths.25. The Streams API
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.
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
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
| Kind | Operations | What it means |
|---|---|---|
| Stateless | filter, map, flatMap, peek, mapToInt | Each element handled on its own; nothing remembered. Cheap, parallel-friendly. |
| Stateful | sorted, distinct, limit, skip, takeWhile, dropWhile | Must 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.
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 andIntStream.rangesplit 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 when | Prefer a loop when |
|---|---|
| The logic is a clear filter / map / group / collect | You need break, continue, early return with complex conditions |
| You want a declarative "what, not how" style | You 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 cheaply | Debugging step by step matters more than brevity |
forEach(list::add)?" — side effects on shared state; use collect.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
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) | No | No |
| Nulls | Allowed | Allowed | NPE |
| Since | Java 8 | Java 16 | Java 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
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
| Kind | Syntax | Example | Same as lambda |
|---|---|---|---|
| Static method | Class::staticMethod | Integer::parseInt | s -> Integer.parseInt(s) |
| Instance method of a particular object (bound) | object::method | prefix::concat, System.out::println | s -> prefix.concat(s) |
| Instance method of an arbitrary object of a type (unbound) | Class::instanceMethod | String::toUpperCase | s -> s.toUpperCase() |
| Constructor | Class::new | ArrayList::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).
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."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
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
Datepassed to another method or stored in a field can be changed behind your back; not thread-safe. - Bad naming —
Dateis really an instant (milliseconds since 1970), not a date; itstoStringuses the JVM's default zone. - 0-based months, 1900-based years.
SimpleDateFormatis 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
| Class | Holds | Use for |
|---|---|---|
LocalDate | year-month-day | Birthdays, holidays, due dates |
LocalTime | hour:min:sec.nanos | Opening hours "09:00" |
LocalDateTime | date + time, no zone | A wall-clock value whose zone is known elsewhere; not a moment in time |
ZonedDateTime | date + time + zone rules | Scheduling in a region (handles DST) |
OffsetDateTime | date + time + fixed offset | APIs and DB columns (ISO-8601 with offset) |
Instant | point on the UTC timeline | Timestamps, logs, event times, storing in the DB |
Duration | seconds + nanos | Machine time amounts: "90 seconds", timeouts |
Period | years, months, days | Calendar 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
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/OffsetDateTimein UTC); convert to a user's zone only for display. - Never use
LocalDateTimefor 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 withClock.fixed(...). - Zone rules change by law; the JDK ships a tz database that needs updating (JDK updates or the TZUpdater tool).
Period.ofDays(1) across DST keeps the clock time, adding Duration.ofDays(1) adds exactly 24 hours.28. Threads & Their Lifecycle
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
| Process | Thread | |
|---|---|---|
| Memory | Own address space, isolated | Shares heap with other threads of the process; own stack |
| Cost to create | Heavy (new memory map, OS structures) | Lighter, but each platform thread reserves its own stack (about 1 MB by default on 64-bit) |
| Communication | IPC: pipes, sockets, files | Just read/write shared objects (needs synchronization) |
| Crash impact | Other processes unaffected | An uncaught error kills that thread; a JVM crash kills all |
| Context switch | Expensive | Cheaper |
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();
Runnable | Callable<V> | |
|---|---|---|
| Method | void run() | V call() throws Exception |
| Returns a value | No | Yes, through a Future |
| Checked exceptions | Must catch inside | Can throw; rethrown wrapped in ExecutionException by Future.get() |
| Use with | new Thread(r), executors | Executors (submit), FutureTask |
In real code you rarely call new Thread; you submit tasks to an ExecutorService (section 31).
Thread lifecycle
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 untiltfinishes.join(ms)waits at most that long. It also guarantees you see everythingtwrote (happens-before, section 29).Thread.sleep(ms)— pause the current thread. It is static:t.sleep(1000)still sleeps the caller, nott.obj.wait()— releaseobj's lock and wait for anotify.
Thread.sleep | Object.wait | |
|---|---|---|
| Belongs to | Thread (static) | Object (any object) |
| Releases locks? | No — keeps every lock it holds | Yes — releases that object's monitor |
| Must hold the lock? | No | Yes, else IllegalMonitorStateException |
| Wakes up when | Time passes (or interrupt) | notify/notifyAll, timeout, interrupt, or spuriously |
| Purpose | Pause | Coordinate 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
});
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.
stop().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
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:
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 method | synchronized block | |
|---|---|---|
| Lock used | this (or the Class for static) | Any object you choose |
| Scope | Whole method | Only the critical lines — shorter lock hold, more throughput |
| Safety | Outside code can also lock on your object and interfere | A 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
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:
| Rule | A happens-before B when... |
|---|---|
| Program order | A comes before B in the same thread |
| Monitor lock | A is an unlock of lock L, B is a later lock of L |
| Volatile | A is a write to volatile V, B is a later read of V |
| Thread start | A is t.start(), B is anything inside t |
| Thread join | A is anything inside t, B is after t.join() returns |
| Transitivity | A 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
synchronizedon the same object, or you getIllegalMonitorStateException. - Always wait in a
whileloop, neverif: threads can wake spuriously, or another thread may grab the item first. - Prefer
notifyAll:notifywakes one arbitrary thread, which may be the wrong kind (a producer when a consumer was needed), causing a stall. - In new code use
BlockingQueueorLock+Condition(section 30) instead.
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
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
synchronized | ReentrantLock | |
|---|---|---|
| Unlock | Automatic (even on exception) | Manual, in finally |
| Try without blocking / timeout | No | tryLock(), tryLock(time) |
| Interruptible wait for lock | No | lockInterruptibly() |
| Fairness option | No | Yes |
| Wait conditions | One (wait/notify) | Many (newCondition()) |
| Lock across methods | No (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.
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
| Need | Use |
|---|---|
| A flag or reference written by one thread, read by others | volatile |
| A single counter or value updated by many threads | AtomicInteger / AtomicLong / AtomicReference |
| A very hot counter, read rarely | LongAdder |
| Several fields that must change together (invariants) | synchronized (simple) or ReentrantLock |
| Need timeout, interruptible wait, fairness, several conditions | ReentrantLock + Condition |
| Read-mostly shared data with long reads | ReentrantReadWriteLock, or StampedLock for top speed |
| Producer–consumer handoff | BlockingQueue (don't hand-roll it) |
| Shared map | ConcurrentHashMap (section 33), not a locked HashMap |
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
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
| Type | What it adds |
|---|---|
Executor | One method: execute(Runnable). Fire and forget. |
ExecutorService | submit (returns a Future), invokeAll, invokeAny, lifecycle: shutdown, shutdownNow, awaitTermination. Since Java 19 it is AutoCloseable (close() = shutdown + wait). |
ScheduledExecutorService | schedule (run once after a delay), scheduleAtFixedRate, scheduleWithFixedDelay. |
ThreadPoolExecutor | The real implementation behind most pools; every knob is exposed. |
ForkJoinPool | Work-stealing pool for divide-and-conquer tasks; also runs parallel streams and default CompletableFuture async work. |
The Executors factory methods — and their traps
| Factory | Threads | Queue | Risk |
|---|---|---|---|
newFixedThreadPool(n) | exactly n | LinkedBlockingQueue, capacity Integer.MAX_VALUE | Queue grows without limit under load → OutOfMemoryError |
newSingleThreadExecutor() | 1 | unbounded LinkedBlockingQueue | Same unbounded queue; useful for strict ordering |
newCachedThreadPool() | 0 to Integer.MAX_VALUE, idle ones die after 60 s | SynchronousQueue (holds nothing; hands off directly) | A burst creates thousands of threads → out of memory / "unable to create native thread" |
newScheduledThreadPool(n) | n core | delay queue (unbounded) | Tasks that throw silently stop repeating |
newWorkStealingPool() (8) | = CPU cores | per-thread deques | A ForkJoinPool; not for blocking work |
newVirtualThreadPerTaskExecutor() (21) | one new virtual thread per task | none | No 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
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 policy | Behaviour | Use when |
|---|---|---|
AbortPolicy (default) | Throws RejectedExecutionException | Caller can handle "busy, try later" (e.g. return HTTP 503) |
CallerRunsPolicy | The submitting thread runs the task itself | Natural back-pressure: the producer slows down because it is busy working |
DiscardPolicy | Silently drops the new task | Loss is fine (e.g. metrics samples) — dangerous otherwise |
DiscardOldestPolicy | Drops the oldest queued task, retries the new one | Only 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
ThreadFactoryso thread dumps are readable.
submit vs execute
execute(Runnable) | submit(Runnable / Callable) | |
|---|---|---|
| Declared in | Executor | ExecutorService |
| Returns | void | Future<T> |
| If the task throws | Exception kills the worker thread (the pool replaces it) and is printed by the default UncaughtExceptionHandler | Exception 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()
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 finish | Interrupts running tasks, returns queued tasks as a list, never runs them |
Returns immediately; call awaitTermination to wait | Returns 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.
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.
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
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: 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
| Method | Takes | Stream / Optional twin |
|---|---|---|
thenApply | Function<T, U> | map |
thenCompose | Function<T, CompletableFuture<U>> | flatMap |
thenCombine | another future + BiFunction<T, U, V> | zip |
thenAccept | Consumer<T> → CompletableFuture<Void> | forEach |
thenRun | Runnable (ignores the value) | — |
applyToEither / acceptEither | another 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
| Method | Runs on | Can change the result? | Like |
|---|---|---|---|
exceptionally(fn) | failure only | yes: returns a replacement value | catch |
handle((v, ex) -> …) | success and failure | yes: returns a new value | catch + normal path |
whenComplete((v, ex) -> …) | success and failure | no: passes the original outcome on | finally |
exceptionallyCompose (12) | failure only | yes, 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 result | Blocking get() only | get()/join(), or react with callbacks without blocking |
| Chain steps | No | thenApply, thenCompose, … |
| Combine several | Manual loop of get() calls | thenCombine, allOf, anyOf |
| Error handling | ExecutionException from get() | exceptionally, handle, whenComplete |
| Complete it yourself | No | complete, completeExceptionally |
| Timeouts | get(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 withallOf. - 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
thenAcceptand is never joined hides every exception. End chains withexceptionallyorwhenCompletethat logs. - Blocking in the common pool: JDBC calls inside
supplyAsyncwith 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.
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
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
getsees 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
synchronizedon its first node. Two threads writing different bins run in parallel. - A bin with 8+ nodes becomes a red-black tree (
TreeBin), same asHashMap. - Resizing is shared: threads that arrive during a resize help move bins to the new table.
size()sums per-thread counter cells (likeLongAdder), so it is only an estimate while writes are happening.
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
Hashtable | Collections.synchronizedMap | ConcurrentHashMap | |
|---|---|---|---|
| Locking | Every method synchronized on the table | Every method synchronized on one mutex | CAS + per-bin lock; reads lock-free |
| Concurrent writers | 1 at a time | 1 at a time | Many (different bins) |
| Iteration | Fail-fast; you must lock manually | Fail-fast; you must hold synchronized (map) while iterating | Weakly consistent: never throws ConcurrentModificationException, may or may not show recent changes |
| Nulls | No | Whatever the wrapped map allows | No |
| Status | Legacy — do not use | Fine for low contention or wrapping a TreeMap | The 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.
| Action | Throws | Returns special value | Blocks | Times out |
|---|---|---|---|---|
| Insert | add(e) | offer(e) → false | put(e) | offer(e, time, unit) |
| Remove | remove() | poll() → null | take() | poll(time, unit) |
| Look | element() | 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)
| Queue | Bounded? | Inside | Notes |
|---|---|---|---|
ArrayBlockingQueue | Yes (fixed at creation) | Array, one lock for put and take | Predictable memory; optional fairness |
LinkedBlockingQueue | Optional (default Integer.MAX_VALUE) | Linked nodes, two locks (put lock, take lock) | Higher throughput; always pass a capacity |
PriorityBlockingQueue | No | Heap | Takes the smallest element first |
DelayQueue | No | Heap by delay | Element available only after its delay expires |
SynchronousQueue | Capacity 0 | Direct hand-off | Each put waits for a take (cached thread pool) |
ConcurrentLinkedQueue | No | Lock-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)
| CountDownLatch | CyclicBarrier | Semaphore | |
|---|---|---|---|
| Who waits | One or more "waiters" for other threads' events | The worker threads wait for each other | Threads wanting a permit |
| Reusable | No (count only goes down) | Yes (resets after each trip) | Yes |
| Typical use | Start-up: wait for services; tests: wait for N callbacks | Phased simulations, parallel algorithms in rounds | Limit 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).
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
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
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
| Workload | Virtual threads? | Why |
|---|---|---|
| Web server, many requests each waiting on DB/HTTP (thread-per-request) | Yes — the main use case | Throughput limited by how many requests can wait at once, not by CPU |
| CPU-bound (image resize, crypto, number crunching) | No benefit | Only N cores exist; a pool of about N platform threads is just as good |
Heavy ThreadLocal caches (e.g. per-thread buffers) | Careful | A million threads = a million copies. Java 21 preview: ScopedValue |
Long blocking inside synchronized (Java 21–23) | Careful: pinning | See 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.
| Condition | Meaning | How to break it |
|---|---|---|
| Mutual exclusion | A resource can be held by one thread at a time | Use lock-free or immutable data where possible |
| Hold and wait | A thread holds one lock while waiting for another | Acquire all locks at once, or release before waiting |
| No preemption | A lock cannot be taken away; only the owner releases it | tryLock with timeout: give up and release what you hold |
| Circular wait | T1 waits for T2, T2 waits for T1 (a cycle) | Global lock ordering: everyone takes locks in the same order |
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
tryLockloops 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.
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.35. JVM Memory: Heap, Stack & Metaspace
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
What lives where
| Area | Holds | Shared? | Error when it runs out |
|---|---|---|---|
| Heap | All objects and arrays, including their instance fields; the String pool (since Java 7); static fields (stored with the Class object, since Java 8) | All threads | OutOfMemoryError: Java heap space |
| JVM stack | One frame per active call: local variables (primitives and references), operand stack, return address | Per thread | StackOverflowError |
| Metaspace (Java 8+, replaced PermGen) | Class metadata: method bytecode, field and method descriptions, runtime constant pool | All threads | OutOfMemoryError: Metaspace |
| Code cache | Native code produced by the JIT compiler | All threads | JIT stops compiling ("CodeCache is full" warning) |
| PC register | Address of the bytecode instruction the thread is executing | Per thread | — |
| Native method stack | Frames for native (C/C++) code called through JNI | Per thread | StackOverflowError |
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
"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
newin 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 anIntegeris 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
| Error | Typical cause | First fix |
|---|---|---|
StackOverflowError | Infinite 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 space | Memory leak, or genuinely too much data loaded at once | Heap dump, find what holds the objects (s38); stream data instead of loading it all |
OOM: GC overhead limit exceeded | More than 98% of time spent in GC recovering under 2% of the heap (Parallel GC) | Same as heap space — usually a leak |
OOM: Metaspace | Too many classes: class loader leak on hot redeploy, runaway proxy/lambda generation | Find the loader that is never released; set -XX:MaxMetaspaceSize to fail early |
OOM: unable to create native thread | Too many platform threads (OS limit or no memory for stacks) | Bounded pools or virtual threads |
OOM: Direct buffer memory | ByteBuffer.allocateDirect outside the heap (Netty, NIO) | Release buffers; -XX:MaxDirectMemorySize |
OOM: Requested array size exceeds VM limit | Asked for an array close to Integer.MAX_VALUE elements | A 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
| Flag | Meaning | Example |
|---|---|---|
-Xms | Initial heap size | -Xms2g |
-Xmx | Maximum heap size (default: 1/4 of physical RAM) | -Xmx2g |
-Xss | Stack size per thread (default 1 MB on Linux x64; 2 MB on macOS Apple Silicon, checked with -XX:+PrintFlagsFinal) | -Xss512k |
-Xmn | Young generation size | -Xmn512m |
-XX:MaxMetaspaceSize | Cap metaspace (default: unlimited) | -XX:MaxMetaspaceSize=256m |
-XX:MaxRAMPercentage | Heap as % of container memory — use in Docker/Kubernetes instead of a fixed -Xmx | -XX:MaxRAMPercentage=75 |
-XX:+HeapDumpOnOutOfMemoryError | Write a heap dump when OOM happens | with -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.
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 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
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.
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
| Kind | What it collects | Frequency / cost |
|---|---|---|
| Minor (young) GC | Young generation only | Very often; short pause (ms) |
| Major GC | Old generation (in G1: "mixed" collections take young + some old regions) | Less often; can be concurrent |
| Full GC | Whole heap (+ metaspace), usually stop-the-world and single-pass compacting | Rare; long pause — a warning sign in G1/ZGC |
The basic algorithm: mark, sweep, compact
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
| Collector | Flag | How | Pauses | Best for |
|---|---|---|---|---|
| Serial | -XX:+UseSerialGC | One thread, generational, STW for everything | Long on big heaps | Small heaps, 1 CPU containers, CLI tools |
| Parallel (throughput) | -XX:+UseParallelGC | Many threads, STW, generational | Hundreds of ms+ on big heaps | Batch jobs where total throughput matters, not pauses. Default in Java 8 |
| G1 (Garbage First) | -XX:+UseG1GC | Heap 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 target | Target -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 size | Large 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 size | Same niche as ZGC; Red Hat builds |
| Epsilon | -XX:+UseEpsilonGC (experimental) | Allocates, never collects | None | Performance 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
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, orMaxRAMPercentagein 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).
| Type | Collected when | get() | Used for |
|---|---|---|---|
| Strong | Never, while reachable | — | Everything normal |
SoftReference | Only when the JVM is low on memory (before throwing OOM) | Object until cleared | Memory-sensitive caches (but a real cache like Caffeine is better) |
WeakReference | At the next GC once no strong/soft refs remain | Object until cleared | WeakHashMap, canonicalizing maps, listener registries that shouldn't keep listeners alive; ThreadLocalMap keys |
PhantomReference | After the object is finalized/collected; enqueued on a ReferenceQueue | Always null | Cleanup of native resources after the object is gone (java.lang.ref.Cleaner, Java 9, is built on it) |
System.gc() is a request.37. Class Loading
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: a class loader reads the bytes and creates a
Classobject. Metadata goes to metaspace. - Verification: the bytecode verifier checks types, stack use and jumps are legal. This is why a hand-crafted
.classfile 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 — allstaticfield initializers andstatic { }blocks, top to bottom. Nowxbecomes 5. The JVM guarantees this runs once, under a lock, so it is thread-safe.
The loaders and parent delegation
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
ClassNotFoundException | NoClassDefFoundError | |
|---|---|---|
| Kind | Checked exception | Error (a LinkageError) |
| When | You load a class by name at runtime: Class.forName, loadClass, ClassLoader.findSystemClass | The class was there at compile time but the JVM cannot get it at run time |
| Typical cause | Typo in the name, missing JDBC driver JAR, plugin not on classpath | JAR 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.
38. Performance, Profiling & Memory Leaks
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
| Cause | What happens | Fix |
|---|---|---|
| Static collections used as caches | static Map gets entries on every request, never removed | Bounded cache with eviction (Caffeine), TTL, or remove explicitly |
| Listeners / callbacks never removed | An event bus holds every registered listener, and each listener holds its whole screen/object graph | Unregister in a close()/lifecycle hook; weak listener lists |
ThreadLocal in thread pools | Pool threads live forever, so their ThreadLocal values do too — and they leak into the next request | remove() in finally |
| Unclosed resources | Connections, streams, statements hold native memory and handles | try-with-resources |
| Inner class / lambda references | A 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 collected | Use static nested classes for long-lived callbacks/tasks |
Wrong equals/hashCode on keys | Mutable key or missing hashCode: put keeps adding "new" entries, remove never finds them | Immutable keys (records) with proper equals/hashCode |
| Class loader leaks | After a hot redeploy, one reference from a JDK/static object to the old app keeps every old class alive → OOM: Metaspace | Clear 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
| Tool | What it does | Example |
|---|---|---|
jps | List running JVMs and their process ids | jps -l |
jcmd | The Swiss army knife: send any diagnostic command to a JVM | jcmd <pid> GC.class_histogram, GC.heap_dump, Thread.print, VM.flags, JFR.start |
jstat | GC statistics sampled over time | jstat -gcutil <pid> 1000 |
jmap | Heap histogram and heap dumps (older; jcmd covers it) | jmap -dump:live,format=b,file=heap.hprof <pid> |
jstack | Thread dump; detects deadlocks | jstack <pid> |
| Eclipse MAT / VisualVM | Open a .hprof heap dump: dominator tree, "leak suspects", paths to GC roots. VisualVM also shows live CPU/heap/threads | GUI |
| JFR (Java Flight Recorder) + JDK Mission Control | Low-overhead (~1%) always-on recording: allocations, locks, GC, hot methods, I/O. Free since Java 11 | java -XX:StartFlightRecording=duration=60s,filename=app.jfr |
| async-profiler | Sampling CPU/allocation/lock profiler without safepoint bias; outputs flame graphs | asprof -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
}
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
| Symptom | Look at | Typical fix |
|---|---|---|
| High CPU | CPU flame graph (async-profiler / JFR "hot methods") | Better algorithm, avoid regex/reflection in hot paths, cache results |
| Low CPU but slow | Thread dumps: threads WAITING on I/O, pools, locks | Bigger/separate pools, timeouts, async or virtual threads, fix N+1 queries |
| Latency spikes | GC log pauses, JFR GC events | Less allocation, right heap size, G1 pause target or ZGC |
| Lock contention | JFR "Java monitor blocked", async-profiler -e lock | Smaller critical sections, concurrent collections, LongAdder |
| Memory grows until OOM | Heap dump, MAT | The 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.
System.nanoTime loop.39. Modern Java: 8 to 21
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.
The feature timeline
| Version | Feature | What it gives you |
|---|---|---|
| 8 | Lambdas, method references, functional interfaces | Pass behaviour as a value: list.sort((a, b) -> ...) |
| 8 | Streams API | Declarative pipelines: filter, map, collect |
| 8 | Optional, java.time, default methods | Explicit "maybe absent"; a sane date API; interfaces that can evolve |
| 9 | List.of, Set.of, Map.of, modules (JPMS) | Immutable collection literals |
| 10 | var | Local variable type inference |
| 11 (LTS) | HttpClient, String.strip/isBlank/lines/repeat, Files.readString, run java File.java directly | Modern HTTP/2 client in the JDK |
| 14 | Switch expressions (->, yield), helpful NullPointerException messages | Switch returns a value, no fall-through |
| 15 | Text blocks """ | Multi-line strings for JSON, SQL, HTML |
| 16 | Records, pattern matching for instanceof, Stream.toList() | Data classes in one line; no more cast after instanceof |
| 17 (LTS) | Sealed classes and interfaces | Closed hierarchies the compiler knows about |
| 21 (LTS) | Pattern matching for switch, record patterns, sequenced collections, virtual threads | Type-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.
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
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 classes | InputStream, OutputStream | Reader, Writer |
| Files | FileInputStream, FileOutputStream | FileReader, FileWriter |
| Buffering | BufferedInputStream, BufferedOutputStream | BufferedReader (has readLine), BufferedWriter |
| Bridge | InputStreamReader / OutputStreamWriter turn bytes into chars with a charset | |
| Objects / primitives | ObjectInputStream, DataInputStream | PrintWriter (formatted text) |
| Use for | Images, zip, protocol bytes, serialized objects | Text files, CSV, logs, console input |
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
| Method | Loads | Use when |
|---|---|---|
Files.readString / readAllLines | Whole file into memory | Small files (config, test fixtures) |
Files.lines | One line at a time, lazily | Big files; must use try-with-resources or it leaks a file handle |
Files.newBufferedReader | Streaming | Full control, custom parsing loops |
Files.walk / Files.list | Directory entries lazily | Recursive / 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
}
serialVersionUIDis the class's version number. When reading, Java compares the stored UID with the current class's UID; if they differ you getInvalidClassException. 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.transientfields are not written; on reading they get default values (null, 0, false). Use it for passwords, caches, and non-serializable fields like aThreador DB connection.staticfields are never serialized either (they belong to the class).- Every field's type must also be
Serializable, or you getNotSerializableException. During reading, the constructor of the first non-serializable superclass runs; the serializable class's own constructor does not.
Why Java serialization is discouraged
| Problem | Why it hurts |
|---|---|
| Security | readObject 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 versioning | Private fields become part of a public wire format; refactoring breaks old data |
| Java-only | A Python or JS service cannot read it |
| Bypasses constructors | Invariants checked in constructors are skipped; singletons can be duplicated unless you add readResolve |
| Size and speed | Verbose 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]
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
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.
| Pattern | Kind | Problem it solves | Seen in the JDK / Spring |
|---|---|---|---|
| Singleton | Creational | Exactly one shared instance | Runtime.getRuntime(), Spring beans (singleton scope) |
| Builder | Creational | Many optional constructor parameters | StringBuilder, HttpRequest.newBuilder(), Lombok @Builder |
| Factory Method | Creational | Caller should not know the concrete class | List.of, Calendar.getInstance(), NumberFormat.getInstance() |
| Strategy | Behavioural | Swap an algorithm at runtime | Comparator, Spring PasswordEncoder |
| Observer | Behavioural | Notify many listeners of a change | Swing listeners, Spring ApplicationEvent, reactive streams |
| Decorator | Structural | Add behaviour by wrapping | BufferedReader, Collections.unmodifiableList |
| Adapter | Structural | Make an incompatible interface fit | Arrays.asList, InputStreamReader |
| Template Method | Behavioural | Fixed algorithm skeleton, variable steps | AbstractList, 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
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.
@Transactional, Factory in BeanFactory). Don't force patterns: name the problem first, then the pattern.42. SOLID in Java
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).
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.
| Principle | Smell that breaks it | Typical fix |
|---|---|---|
| SRP | God class, "and" in the class description | Split by actor / reason to change |
| OCP | Growing if/else on type or on a string code | Polymorphism, Strategy, sealed + exhaustive switch |
| LSP | Overrides that throw or ignore input; instanceof checks on subtypes | Composition instead of inheritance |
| ISP | Methods throwing UnsupportedOperationException | Split into role interfaces |
| DIP | new ConcreteService() inside business logic | Interface + constructor injection |
43. Reflection & Annotations
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
@Retention | Kept until | Example |
|---|---|---|
SOURCE | Compiler only, dropped from .class | @Override, @SuppressWarnings, Lombok's @Getter |
CLASS (default) | In the .class file but not visible to reflection | Bytecode tools |
RUNTIME | Readable 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
@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
| Framework | What it does with reflection |
|---|---|
| Spring | Scans 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 5 | Finds @Test methods, creates a new test instance per method, invokes lifecycle methods |
| Hibernate / JPA | Reads @Entity/@Column, sets private fields from result sets (needs a no-arg constructor) |
| Jackson | Discovers getters/fields/record components and @JsonProperty to map JSON |
| Mockito | Generates 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
Methodobjects and pay the cost mostly at startup. - No compile-time safety: a renamed method becomes a runtime
NoSuchMethodException. - Breaks encapsulation:
setAccessiblecan change private and evenfinalinstance fields (records andstatic finalfields 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.
@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
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
| Maven | Gradle | |
|---|---|---|
| Config | pom.xml, declarative XML | build.gradle.kts (Kotlin) or build.gradle (Groovy), code |
| Model | Fixed lifecycle of phases; plugins bind goals to phases | Graph of tasks with inputs/outputs |
| Speed | Rebuilds more; slower on big projects | Incremental builds, build cache, daemon — usually faster |
| Flexibility | Conventions, custom logic needs a plugin | Very flexible (and easier to make messy) |
| Where you see it | Most enterprise Spring projects | Android (required), many newer and large multi-module projects |
| Both | Same layout (src/main/java, src/test/java), same Maven Central repository, wrapper scripts (mvnw, gradlew) pin the tool version | |
The Maven lifecycle
<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 scope | Gradle configuration | Meaning |
|---|---|---|
compile | implementation / api | Needed to compile and run |
provided | compileOnly | Compile only; the server supplies it (Servlet API, Lombok) |
runtime | runtimeOnly | Run only (a JDBC driver) |
test | testImplementation | Tests 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 5 | JUnit 4 equivalent | Notes |
|---|---|---|
@BeforeEach / @AfterEach | @Before / @After | Run around every test |
@BeforeAll / @AfterAll | @BeforeClass / @AfterClass | Must be static (unless @TestInstance(PER_CLASS)) |
@Disabled | @Ignore | Skip with a reason |
assertThrows | @Test(expected = ...) | Returns the exception to inspect |
@ExtendWith | @RunWith | Plug 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 / emptyOptional. - If one argument uses a matcher (
eq,any), all must — mixing raw values and matchers throwsInvalidUseOfMatchersException. @Mock= all fake;@Spy= real object with some methods stubbed (usedoReturn(..).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
| Practice | Example / reason |
|---|---|
| Name = method_condition_expectedResult | markPaid_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 test | Several asserts are fine if they check one outcome |
| Independent and repeatable | No order dependence, no shared mutable static state, no real clock (inject Clock) |
| Fast | Unit tests in milliseconds; real DB tests go in integration tests (Testcontainers) |
| Test behaviour, not implementation | Over-verifying every call makes refactoring break tests |
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.Java Cheat Sheet
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… | Use | Avoid |
|---|---|---|
| A list, mostly reads by index or appends | ArrayList | LinkedList, Vector |
| Stack or queue (FIFO/LIFO) | ArrayDeque | Stack, LinkedList |
| Unique elements, fast lookup, no order | HashSet | List.contains in a loop |
| Unique, insertion order | LinkedHashSet | |
| Unique, sorted, range / floor / ceiling | TreeSet | |
| Key-value, fastest | HashMap | Hashtable |
| Key-value, insertion or access order (LRU cache) | LinkedHashMap (accessOrder=true, removeEldestEntry) | |
| Key-value, sorted keys | TreeMap | |
| Enum keys | EnumMap / EnumSet | HashMap of enums |
| Smallest / largest first | PriorityQueue | Sorting after every insert |
| Thread-safe map | ConcurrentHashMap | Collections.synchronizedMap, Hashtable |
| Thread-safe list, many reads, rare writes | CopyOnWriteArrayList | |
| Producer-consumer hand-off | ArrayBlockingQueue / LinkedBlockingQueue | wait/notify by hand |
| Constant data | List.of, Set.of, Map.of, List.copyOf | Arrays.asList (fixed-size but writable) |
Keywords
| Keyword | On a variable / field | On a method | On a class |
|---|---|---|---|
static | One copy shared by the class | Called on the class; no this; hidden, not overridden | Nested class without outer instance |
final | Assigned once (reference, not object, is fixed) | Cannot be overridden | Cannot be extended (String) |
abstract | — | No body; subclass must implement | Cannot be instantiated; may have constructors and state |
synchronized | — | Locks this (or the Class for static) — mutual exclusion + visibility | — |
volatile | Reads/writes go to main memory; happens-before; no reordering. Not atomic for count++ | — | — |
transient | Skipped 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…
| Pair | Key difference |
|---|---|
== vs equals | Same object (reference) vs same content (as defined by the class) |
String vs StringBuilder vs StringBuffer | Immutable vs mutable (not thread-safe) vs mutable synchronized (legacy) |
ArrayList vs LinkedList | Array: O(1) get, cache-friendly vs nodes: O(n) get, O(1) insert at a known node |
HashMap vs Hashtable | Unsynchronized, one null key allowed vs synchronized legacy, no nulls |
HashMap vs ConcurrentHashMap | Not thread-safe vs per-bin locking/CAS, no null keys or values |
HashMap vs TreeMap vs LinkedHashMap | No order O(1) vs sorted O(log n) vs insertion/access order O(1) |
HashSet vs TreeSet | Hash O(1), no order, uses equals/hashCode vs red-black tree O(log n), sorted, uses compareTo |
Comparable vs Comparator | Natural order inside the class (compareTo) vs external, many orders (compare) |
Iterator fail-fast vs fail-safe | Throws ConcurrentModificationException vs iterates a snapshot / weakly consistent view |
| Abstract class vs interface | State + constructors, single inheritance vs no instance state, multiple inheritance of type |
| Overloading vs overriding | Same name, different params, compile-time vs same signature in subclass, runtime dispatch |
final vs finally vs finalize | Modifier vs always-run block vs old GC hook (deprecated for removal since 18) |
| Checked vs unchecked exception | Must catch or declare (IOException) vs RuntimeException subclasses, optional |
throw vs throws | Throws an exception now vs declares a method may throw |
Error vs Exception | JVM problems you shouldn't catch (OutOfMemoryError) vs recoverable conditions |
| Stack vs heap | Per-thread frames, locals, auto-freed vs shared objects, garbage collected |
| JDK vs JRE vs JVM | Tools + runtime vs runtime (libs + JVM) vs the engine that runs bytecode |
sleep vs wait | Keeps locks, Thread static vs releases the monitor, needs synchronized, woken by notify |
Runnable vs Callable | No 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 ReentrantLock | Implicit, block-scoped vs explicit: tryLock, timeout, fairness, multiple conditions |
volatile vs AtomicInteger | Visibility only vs visibility + atomic read-modify-write (CAS) |
Future vs CompletableFuture | Blocking get() only vs chaining, combining, non-blocking callbacks |
| Platform vs virtual thread | 1:1 OS thread, ~1 MB stack vs JVM-scheduled, cheap, for blocking I/O (21) |
map vs flatMap | One-to-one vs one-to-many then flatten |
| Intermediate vs terminal op | Lazy, returns a stream vs triggers the pipeline, returns a result |
Optional.of vs ofNullable | Throws on null vs empty on null |
orElse vs orElseGet | Argument always evaluated vs supplier called only when empty |
| Shallow vs deep copy | Copies references vs copies the objects they point to |
| Static vs dynamic binding | Resolved at compile time (static, private, final, overloads) vs at runtime (overrides) |
record vs class | Final, immutable fields, generated equals/hashCode/toString vs everything by hand |
Serializable vs Externalizable | Automatic vs you write writeExternal/readExternal, needs public no-arg constructor |
| Maven vs Gradle | XML + fixed lifecycle vs script + task graph, incremental builds |
Common method complexities
| Operation | Cost | Operation | Cost |
|---|---|---|---|
ArrayList.get / set | O(1) | ArrayList.add (end) | O(1) amortised |
ArrayList.add(i, x) / remove(i) | O(n) | ArrayList.contains / indexOf | O(n) |
LinkedList.get(i) | O(n) | LinkedList.addFirst / removeFirst | O(1) |
ArrayDeque.push / pop / offer / poll | O(1) amortised | PriorityQueue.offer / poll | O(log n) |
PriorityQueue.peek | O(1) | PriorityQueue.remove(x) / contains | O(n) |
HashMap.get / put / remove | O(1) avg, O(log n) worst (treeified bin) | TreeMap.get / put / floorKey | O(log n) |
HashSet.contains | O(1) avg | TreeSet.first / ceiling | O(log n) |
String.charAt / length | O(1) | String.substring / concat / + | O(n) (copies) |
String.equals / hashCode (first call) | O(n) | StringBuilder.append | O(1) amortised |
Arrays.sort(int[]) | O(n log n) dual-pivot quicksort | Collections.sort / List.sort | O(n log n) TimSort, stable |
Arrays.binarySearch | O(log n) | Collections.reverse / shuffle | O(n) |
new PriorityQueue<>(collection) | O(n) heapify | ConcurrentHashMap.size | O(counter cells); only an estimate while writes happen |
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
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.
Learning Path
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.
| Step | Learn | You can explain… | Project idea |
|---|---|---|---|
| 1. Language basics | JDK/JVM, types, wrappers, strings, arrays, control flow, pass-by-value (s1–s7) | Why Java is pass-by-value; String immutability; int vs Integer | Command-line calculator and a word-frequency counter reading a text file |
| 2. OOP | Classes, encapsulation, inheritance, polymorphism, interfaces, equals/hashCode, enums, records (s8–s16) | Abstract class vs interface; equals/hashCode contract; composition over inheritance | Library management system: books, members, loans, fines |
| 3. Core APIs | Exceptions, generics, collections, HashMap internals, comparators, Optional (s17–s23) | How HashMap works; fail-fast iterators; checked vs unchecked; type erasure | Build your own LRUCache and a generic MyHashMap<K,V> with resizing |
| 4. Functional Java | Lambdas, streams, collectors, java.time (s24–s27) | Lazy evaluation; map vs flatMap; groupingBy | CSV sales-report generator: group, sum, top-N with streams |
| 5. Testing and build | Maven/Gradle, JUnit 5, Mockito (s44), SOLID (s42) | Unit vs integration test; mock vs stub; dependency scopes | Add 90% test coverage to steps 2–4 projects; set up a GitHub Actions build |
| 6. Concurrency | Threads, synchronized, volatile, locks, atomics, executors, CompletableFuture, concurrent collections, virtual threads (s28–s34) | Happens-before; deadlock conditions; thread pool sizing; why count++ is not atomic | Multi-threaded web crawler with a bounded pool, then rewrite with virtual threads and compare |
| 7. JVM internals | Memory areas, GC, class loading, profiling (s35–s38) | Heap generations; G1 vs ZGC; how to find a memory leak | Create a leak on purpose, capture a heap dump, find it in Eclipse MAT or VisualVM; tune GC flags with JFR |
| 8. Modern and practical Java | Java 8–21 features, I/O and NIO, design patterns, reflection and annotations (s39–s43) | Records + sealed + pattern matching; Singleton safety; how @Autowired works | Mini dependency-injection container using your own @Inject annotation and reflection |
| 9. Frameworks and production | Spring Boot (REST, Data JPA, Security, validation), SQL and transactions, Docker, logging, metrics | Bean lifecycle; @Transactional proxies; N+1 queries; connection pools | URL shortener or task tracker REST API with PostgreSQL, Testcontainers tests, Docker image |
| 10. Senior level | System design, messaging (Kafka), caching (Redis), resilience (timeouts, retries, circuit breakers), observability, performance tuning | Trade-offs, failure modes, capacity estimates; reading a flame graph | Split 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.
| Level | What 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 |