What is Java, and why should you care?
Java is a class-based, statically-typed language that compiles to bytecode and runs on a virtual machine. That last part is the whole trick: write your program once, and it runs identically on Windows, macOS, Linux, a phone, a fridge or a mainframe — with a garbage collector handling memory so you never free a pointer.
Let's start at the very beginning. Every program you run is ultimately a set of instructions for a CPU and a set of bytes in memory. Some languages sit far above that reality: Python and JavaScript hand your source to an interpreter that reads it line by line. C and C++ compile straight to machine code for one specific CPU and leave you personally responsible for never freeing memory twice, never writing past the end of an array, and never letting two threads touch the same variable at once. Get it wrong and your program leaks, crashes, or gets exploited.
Java found a third path. Your source compiles not to machine code, but to bytecode — a portable, CPU-independent instruction set. A program called the JVM (Java Virtual Machine) translates that bytecode into real machine code for whatever hardware it's actually running on, while a garbage collector reclaims memory automatically. You get C++-grade performance after warm-up, memory safety without a borrow checker, and "write once, run anywhere" that isn't a marketing slogan — it's the architecture.
The three pillars of Java
Automatic memory
A garbage collector finds and reclaims unreachable objects. No malloc, no free, no double-free, no dangling pointers — an entire class of memory bugs simply cannot happen.
Everything is an object
Java is squarely object-oriented: you model your domain as classes with state and behaviour, and the type system enforces the contracts between them at compile time.
Write once, run anywhere
Bytecode is platform-neutral. The same JAR runs on a laptop, a Kubernetes cluster or an Android handset — the JVM adapts to the hardware, not your code.
Java is "C++ without the pointer arithmetic, plus a virtual machine that makes your build artifact portable and a collector that makes your memory management boring." It's a language built around a single idea: reliability at scale, over decades, across teams.
A short history
A side project called "Green"
James Gosling and colleagues at Sun Microsystems set out to build software for interactive television set-top boxes. The language — originally called Oak, after the tree outside Gosling's office — needed to run on cheap, unreliable consumer devices. The constraints of that problem shaped everything that followed.
Java debuts with applets
Sun launches Java 1.0 alongside the Netscape browser. Applets — tiny programs running inside a web page — are a sensation for about five years. The real innovation nobody noticed yet: bytecode and the "write once, run anywhere" promise.
The language grows up
Generics, enhanced for-loops, autoboxing, varargs, enums and the java.util.concurrent package arrive in one enormous release. This is the version that made Java viable for serious concurrent server work.
Sun falls, Oracle rises
Sun open-sources the JDK under GPLv2 — one of the most consequential licensing decisions in software history. In 2010 Oracle acquires Sun, and with it, Java. A long litigation interlude follows, then settles.
Lambdas and streams change the style
The single most important release for day-to-day code. Lambdas, method references and the Stream API let Java finally express what you want instead of how to loop for it. Most Java codebases still look like "Java 8 style" today.
OpenJDK, six-month cadence, JetBrains
Java moves to OpenJDK with a predictable six-month release train — records in 16, sealed types in 17, virtual threads in 21. Meanwhile JetBrains ships Kotlin in 2016 (open-sourced 2017) and Google makes it Android's preferred language in 2019.
The most-used runtime on Earth
An estimated 3 billion devices run Java. It underpins banking, insurance, government systems, big data (Hadoop, Kafka, Spark), Android, and the back end of a huge share of the web. Kotlin, Scala, Groovy and Clojure all run on the same JVM.
Where Java sits in the language landscape
It helps to know what Java is not. It isn't the fastest — C and C++ beat it on raw throughput and it can't match Rust's compile-time memory guarantees. It isn't the most concise — Kotlin, Python and JavaScript all say more with less. It isn't the most cutting-edge. What Java is, is the most dependable combination of "fast enough", "safe enough", "portable everywhere" and "nobody gets fired for choosing it" that has ever existed.
Rough positioning, not a benchmark. Java and Kotlin sit close together — they compile to the same bytecode — but Kotlin trades a little Java's ceremony for far more expressive syntax.
What makes the JVM's guarantees possible
The magic isn't one trick, it's a stack of them. Understanding this stack is the difference between "I can write Java" and "I understand what the JVM is doing while my code runs":
javac compiles Java; kotlinc compiles Kotlin. Both produce bytecode — the same format, in the same .class files.
A compiler that only sees your source has to guess which paths are hot. A JIT compiler watches your program run and optimises the code that actually executes — inlining small methods, unboxing types it has proven are always integers, even speculatively removing null checks after it has verified a value is never null. In long-running server processes, Java routinely catches up to and sometimes beats C++ on steady-state throughput, because it's optimising with real evidence.
public class Hello {
public static void main(String[] args) {
String name = args.length > 0 ? args[0] : "world";
System.out.printf("Hello, %s!%n", name);
}
}
Compile it, run it, and watch the two-step process that defines the entire platform:
javac Hello.java # step 1: source -> Hello.class (BYTECODE, not machine code)
java Hello Ashton # step 2: JVM loads the bytecode and runs it
# Hello, Ashton!
javap -c Hello | head -12 # look at the actual bytecode instructions
That .class file is the whole point. It contains no x86, no ARM, no machine code for any particular CPU — just JVM instructions like aload_0, invokevirtual and areturn. Any device with a JVM can run it, which is the reason a JAR built on Linux in 2010 still executes today on hardware that didn't exist then.
Java in the real world
The pattern in that list: anything that must run for a decade, survive team turnover, handle heavy concurrency, and be maintained by people who weren't there when it was written. Java's verbosity — its greatest cliché — exists because explicit, boring, greppable code is what a decade-long project actually needs.
How Java compares at a glance
| Property | Java | C / C++ | Go | Python / JS |
|---|---|---|---|---|
| Memory management | Garbage collector | Manual (malloc/free, RAII) | Garbage collector | Garbage collector |
| Null references | Everywhere — any reference can be null | Everywhere + segfaults | Zero-values, no null by default | null / undefined |
| Portability | Bytecode — identical artifact | Per-platform rebuild | Good (static binaries) | Source-level (needs runtime) |
| Typical speed | Fast after JIT warm-up | Fastest tier | Fast (≈1.5–2.5× C) | 10–100× slower |
| Startup time | Slow (JVM boot, JIT warm-up) | Instant | Fast | Fast |
| Concurrency model | Platform threads → virtual threads | OS threads, manual | Goroutines (M:N) | Event loop / GIL |
| Learning curve | Moderate — verbose but regular | Steep — pointers & UB | Gentle | Gentle |
| Where it shines | Servers, Android, big data, enterprise | Games, embedded, kernels | CLIs, cloud infra, DevOps | Scripting, ML, web front end |
1. Startup and memory footprint. Booting a JVM costs time and tens of megabytes before your code runs — irrelevant for a server that lives for months, painful for a CLI tool or a serverless function. Project CRaC and Project Loom's virtual threads both attack this from different angles.
2. Verbose boilerplate. A class that holds four fields used to need fifty lines of getters and setters. Records, var, lambdas and text blocks have shrunk this dramatically — and Kotlin shrinks it to almost nothing, which is exactly why Part II exists.
This belief is roughly twenty years out of date. Modern Java — thanks to the JIT, escape analysis, biased locking and compact strings — sits in the same performance tier as C# and within striking distance of C++ for most business logic. Where Java genuinely does lose is startup time and small-memory footprints, which is a different claim entirely, and one the ecosystem is actively fixing.
What you'll build in this handbook
Part I · Java — 18 chapters
From Hello, World through object design, generics, collections and streams, error handling, concurrency and the JVM's own internals. You'll finish knowing not just what a stream does, but why it exists and what it compiles to.
Part II · Kotlin — 10 chapters
Null safety, data classes, extension functions, scope functions, coroutines and Flow. Kotlin isn't "Java with shortcuts" — it's a different answer to the same problem, and it runs on the runtime you already understand.
- Java compiles to bytecode, not machine code — that's what makes it portable.
- The JVM runs the bytecode; the JIT makes hot code fast; the GC frees your memory.
- Java is optimised for reliability over decades, not for shortest-possible syntax.
- Kotlin compiles to the same bytecode and shares the JVM — Part II is a companion, not a replacement.
Installing Java & your first program
Install one JDK, run a real program in a single command, and learn the four tools you'll type every day — java, javac, javap and jshell — plus the project layout and editor setup that keep everything working long after this chapter.
Newcomers lose their first hour to the toolchain, never the language: java: command not found, a JDK installed in three places, an IDE still pointing at Java 8 because that shipped with the laptop, or a build tool quietly downloading its own compiler and writing class files your editor can't see. None of that is Java's fault, and all of it disappears with one clean install and five commands you can type from memory.
Install the JDK, not a runtime. The old "JRE vs JDK" confusion dates from Java 8, when Oracle shipped two installers and only one of them could compile. A modern JDK — around 180 MB — contains the compiler, the runtime, the disassembler and the REPL, and it's what every IDE, build tool and tutorial expects to find. By the end of this chapter you'll have that JDK on PATH, a program running on it, and a skeleton you'll fill for the next thirty chapters.
Install a JDK in 60 seconds
Pick whichever block matches your machine. SDKMAN is the one I'd choose if you have no other constraints: it installs side-by-side JDKs and switches between them with one command, which matters the first time a project demands Java 17 while your default is 21.
# SDKMAN — version manager for Java, Kotlin, Gradle (macOS / Linux)
curl -s "https://get.sdkman.io" | bash
sdk list java | grep -m6 tem # Temurin builds, pick a 21.x id
sdk install java 21.0.5-tem
sdk default java 21.0.5-tem
# Debian / Ubuntu — Ubuntu 24.04+ and Debian 13 ship OpenJDK 21
sudo apt update && sudo apt install -y openjdk-21-jdk-headless
# macOS
brew install --cask temurin@21
# Any platform: Temurin tarball straight from Adoptium's API
BASE="https://api.adoptium.net/v3/binary/latest/21/ga/linux/x64/jdk"
curl -L -o jdk21.tar.gz "$BASE/hotspot/normal/eclipse?project=jdk"
tar -xzf jdk21.tar.gz && export PATH="$PWD"/jdk-21*/bin:$PATH
# Verify — all three must agree on 21
java -version # runtime (prints to stderr)
javac -version # compiler
echo "$JAVA_HOME" # IDEs & build tools read this one
java on one machinejava -version and javac -version coming from different installs is the single most common "it compiles here and fails there" bug — the compiler targets 21, the launcher runs an older runtime, and you get an unsupported class file version error. Make them identical, set JAVA_HOME to the JDK root (the directory containing bin/), and let your IDE read that rather than shipping its own bundled runtime.
Kotlin rides on the same JVM, so this JDK comes first there too: brew install kotlin or sdk install kotlin after the JDK, then kotlinc -version to confirm both halves work.
Your first program, two ways
Type this into a file named Hello.java. The filename must match the public class — that's not a convention, it's a rule the compiler enforces.
public class Hello {
public static void main(String[] args) {
String who = args.length > 0 ? args[0] : "world";
System.out.println("Hello, " + who + "!");
System.out.println("Running Java " + Runtime.version().feature());
}
}
Now run it — first the modern shortcut, then the two-step you'll use inside every real project:
java Hello.java # Java 11+: compiles in memory, runs, discards
# Hello, world!
# Running Java 21
javac Hello.java && java Hello # the classic two-step: compile, then run
java Hello Ada # args work identically both ways
# Hello, Ada!
javap -c Hello # dissect the class file you just produced
# public static void main(java.lang.String[]);
# Code:
# 0: aload_0
# 1: arraylength
# 2: ifle 11 # args.length > 0 ? args[0] : "world"
# 5: aload_0
# 6: iconst_0
# 7: aaload
# 8: goto 13
# 11: ldc #7 // String world
# 13: astore_1
Those mnemonics — aload_0, arraylength, ldc, invokevirtual — are the entire execution model of the platform. There is no x86 and no ARM in that file, which is precisely why the artifact is portable:
Hello.class: portable JVM bytecode, still no machine code.
java boots a JVM, loads your class and verifies the bytecode before a single instruction executes.
main returns, the JVM shuts down.
java Script.java compiles into memory, runs, and leaves no .class files behind — perfect for a 30-line algorithm experiment, an interview question, or a script you'll delete in ten minutes. It stops being right the moment you have two files, a dependency, or a test: that's a project, and Chapter 17 gives you a build tool for it. (Java 22 widened the launcher to whole multi-file programs under JEP 458; on Java 21 it's strictly one source file.)
jshell — Java with training wheels off
jshell is the read-eval-print loop that shipped with Java 9. It's where you settle arguments about operator precedence, check what Math.round(-2.5) actually returns, or prototype a loop before committing it to a class. Snippets compile and run on Enter; expressions show you their value.
$ jshell
| Welcome to JShell -- Version 21
| For an introduction type /help intro
jshell> int x = 21 * 2
x ==> 42
jshell> "byte".concat("code")
$2 ==> "bytecode"
jshell> for (int i = 1; i <= 3; i++) System.out.println(i * i);
1
4
9
jshell> /exit
| Goodbye
Useful one-liners: /list shows what you've run, /open Foo.java plays a file into the session, /vars lists declared variables, and a stray ; or blank line ends a multi-line statement. Everything you type is a full snippet — imports included — so import java.util.stream.*; works exactly as it would at the top of a class.
A layout every tool already expects
Before you write a second program, adopt the directory structure that Maven invented and Gradle adopted. It costs nothing today and saves a migration later — every IDE, CI system and dependency scanner assumes it on sight.
my-app/
├── pom.xml # Maven; Gradle uses build.gradle.kts
├── src/
│ ├── main/
│ │ ├── java/com/example/App.java # your code — folders ARE the package
│ │ └── resources/ # config, messages, images
│ └── test/
│ └── java/com/example/AppTest.java
└── target/ # compiled output (build/ in Gradle)
| Concern | Maven | Gradle | Plain javac |
|---|---|---|---|
| Build file | pom.xml — XML, declarative | build.gradle.kts — Kotlin DSL, imperative | None — you type it all |
| Layout | Fixed src/main/java → target/ | Same defaults, overridable | Whatever you pass in |
| Dependencies | Resolved from the pom, from Maven Central | Resolved from the build script, cached in a daemon | You download jars |
| Reproducible build | ./mvnw wrapper pins the version | ./gradlew wrapper ships with the repo | Depends on your machine |
| Best for | Convention over configuration; predictable CI | Fast incremental builds and custom tasks | One file, one experiment |
The java flags you'll actually meet
Nobody needs the full 300-option reference. These four come up in your first month, and they're worth understanding rather than copying:
java -Xmx512m -jar app.jar # cap the heap at 512 MB
java -Xms1g -Xmx4g -cp target/classes com.example.App
java -ea -Dfile.encoding=UTF-8 App # assertions on, one system property
java --enable-preview App # opt in to preview features
javac --enable-preview --release 21 App.java # ...and match it at compile
-Xmx sets the maximum heap — the difference between an OutOfMemoryError you caused and a garbage collector that keeps up. -jar runs the class named in the manifest of a JAR, which is how every shipped Java application starts. -cp (a.k.a. --class-path) is where the JVM searches for your classes and libraries. And --enable-preview opts into features that might still change before they're finalized: it must be passed to both javac and java, the class file is marked as preview-only, and it has no business in production.
Pick an editor
Java is the one language where the IDE isn't optional — the compiler is fast, but not fast enough to be your inner loop, and you want diagnostics, refactoring and test runners while you type.
IntelliJ IDEA
The reference Java IDE, from the people who make Kotlin. Community edition is free and entirely sufficient for this handbook: completion, refactorings, debugger, JUnit runner. Point Project Structure → SDK at your JDK 21 rather than its bundled runtime.
VS Code + Extension Pack
Microsoft's Extension Pack for Java gives you a language server, debugger, test explorer and Maven/Gradle views in a lighter shell. Set java.configuration.runtimes in settings so it compiles against 21, not whatever it found first.
Neovim + jdtls
Eclipse JDT Language Server under the hood, so the diagnostics match a real compiler. mason.nvim installs jdtls, nvim-lspconfig wires it up, and you get go-to-definition, rename and inline errors with nothing but a terminal.
Your terminal compiles fine but the IDE underlines String. That's almost always a project SDK mismatch — the build targets 21 while the IDE language level sits at 8, or vice versa. Fix it in one place (JAVA_HOME plus the IDE's project SDK), run a clean rebuild, and never debug red squiggles you don't understand first.
Make it interactive
A program that only prints is a demo. Two inputs exist in Java: the argument array your launcher hands over, and standard input, which is how you read from a pipe or a keyboard. Wire both into one file:
import java.util.Scanner;
public class Greeter {
public static void main(String[] args) {
String name = args.length > 0 ? args[0] : prompt();
System.out.printf("Hello, %s!%n", name);
}
static String prompt() {
System.out.print("Who are you? ");
return new Scanner(System.in).nextLine().trim();
}
}
Run it three ways — java Greeter.java Ada for an argument, echo "Ada Lovelace" | java Greeter.java for a pipe, or bare java Greeter.java to be asked. The same program in Kotlin is three lines, and it runs on the JDK you already installed: kotlinc Greeter.kt -include-runtime -d greeter.jar, then java -jar greeter.jar Ada — or as a script, kotlin greeter.main.kts.
- One install. JDK 21 on
PATH,JAVA_HOMEset,java -versionandjavac -versionagreeing. - Two ways to run.
java File.javafor one-file experiments;javac→javafor everything with a structure. javap -cis your X-ray. When Java surprises you, read the bytecode — it never lies about what it did.jshellis a scratch pad for expressions, imports and half-built loops. No file, no ceremony.src/main/java+src/test/javafrom day one, even on a solo project.- Flags you'll use:
-Xmxfor the heap,-jarto launch,-cpfor libraries,--enable-previewnever in production.
Variables, types & strings
Java has eight primitive types and an ocean of objects. Knowing which is which — and how strings actually work — removes most early confusion.
Every variable in Java has a type fixed at compile time. Primitives hold raw values on the stack; objects are references to things on the heap. This split is the single most important fact about Java's memory model.
8 primitives
byte short int long float double boolean char. Never null, never boxed unless used as a generic type argument.
References
Any class type. A variable holds an address; two can point at the same object. null means "points at nothing".
Everything is typed
Types are checked before a line runs. There are no implicit conversions except numeric widening.
public class Primitives {
public static void main(String[] args) {
byte b = -128; // 8-bit · -128..127
short s = 32_000; // 16-bit
int i = 2_000_000_000; // 32-bit · the default for whole numbers
long l = 9_000_000_000L; // 64-bit · note the L suffix
float f = 3.14f; // 32-bit · note the f suffix
double d = 3.14159; // 64-bit · the default for decimals
boolean ok = true; // true or false — never 0/1
char c = 'A'; // 16-bit Unicode code unit, in single quotes
System.out.printf("%d %d %d%n", b, s, i);
}
}
| Type | Bits | Range / notes |
|---|---|---|
byte | 8 | -128 to 127 |
short | 16 | -32,768 to 32,767 |
int | 32 | ±2.1 billion — the usual choice |
long | 64 | Timestamps, IDs, counters |
float | 32 | ~7 significant digits |
double | 64 | ~15 digits — default for decimals |
boolean | — | true / false only |
char | 16 | Unicode code unit, e.g. 'A' = 65 |
0.1 + 0.2 == 0.3 is false in Java and every other language using IEEE-754. Binary can't represent those decimals exactly.
Money is never double. Use BigDecimal, or store cents as long.
double wrong = 0.1 + 0.2;
System.out.println(wrong == 0.3); // false
System.out.println(0.1 + 0.2); // 0.30000000000000004
import java.math.BigDecimal;
BigDecimal a = new BigDecimal("0.10");
BigDecimal b = new BigDecimal("0.20");
System.out.println(a.add(b).equals(new BigDecimal("0.30"))); // true
Reference semantics
int x = 1;
int y = x;
y = 99;
System.out.println(x); // 1 — primitives copy the value
int[] p = {1, 2, 3};
int[] q = p;
q[0] = 99;
System.out.println(p[0]); // 99 — both names point at the SAME array
String s1 = new String("hi");
String s2 = new String("hi");
System.out.println(s1.equals(s2)); // true — content comparison
System.out.println(s1 == s2); // false — different objects
Java passes everything by value. For objects, the value is the reference. So you can mutate the object a method points at, but you can never rebind the caller's variable — reassigning a parameter inside a method affects only the local copy. The confusion comes from the word "reference" doing two different jobs.
Strings are special
String looks primitive but is a full object. Two things about it explain 90% of interview questions:
Immutable
Every "modification" returns a new String. Safe to share, cache and use as a map key. Also means a loop of concatenation creates many throwaway objects.
Interned literal pool
Literals like "hi" live in one pooled instance. == can work by accident — but you should never rely on it.
String a = "hello";
String b = "hello";
String c = new String("hello");
System.out.println(a == b); // true — same pooled literal
System.out.println(a == c); // false — c is a fresh object
System.out.println(a.equals(c)); // true
// Immutability: each call returns a NEW string
String upper = a.toUpperCase();
System.out.println(a); // "hello" unchanged
System.out.println(upper); // "HELLO"
// Concatenation in a loop: O(n²) churn
String acc = "";
for (int i = 0; i < 10_000; i++) acc += i; // avoid
StringBuilder sb = new StringBuilder(); // do this
for (int i = 0; i < 10_000; i++) sb.append(i);
String done = sb.toString();
String— immutable, for values that don't change.StringBuilder— mutable, not thread-safe, fast. The default for building text.StringBuffer— mutable and synchronized. Rarely the right choice today.- The compiler already turns
+in one expression into aStringBuilder— the problem is only loops.
String raw = " Ashton Tink ";
raw.strip() // "Ashton Tink" — preferred over trim()
.toLowerCase() // "ashton tink"
.replace(' ', '-') // "ashton-tink"
.split("-")[0]; // "ashton"
"abc".repeat(3); // "abcabcabc"
"42".isBlank(); // false
" ".isBlank(); // true
"key=1".split("="); // ["key", "1"]
String.join(", ", "a", "b"); // "a, b"
"abc".chars().forEach(System.out::println); // 97, 98, 99
"template %s and %d".formatted("x", 7); // "template x and 7"
var is not dynamic typingSince Java 10, var lets the compiler infer a local variable's type. The type is still fixed at compile time — var x = 1; x = "hi"; fails. It only saves you typing when the right-hand side is obvious (a long constructor chain, a lambda target).
- Primitives copy; references copy the address. That explains all aliasing behaviour.
- Never use
doublefor money —BigDecimalor integer cents. - Compare strings with
equals, never==. - Build text in loops with
StringBuilder. varis inference, not dynamism — the type never changes.
Control flow, arrays & records
Java's control flow is deliberately boring — if, switch, for, while — but pattern matching and switch expressions make it noticeably less verbose than it was a decade ago.
There is nothing exotic here: no unless, no implicit truthiness, no ternary-as-statement. What's changed recently is that switch can now match on types and return values, and if can bind a variable from a type test.
int score = 87;
// if / else if — no implicit truthiness, the condition MUST be boolean
if (score >= 90) System.out.println("A");
else if (score >= 80) System.out.println("B");
else System.out.println("C");
// Enhanced for — the default for anything iterable
for (int n : new int[]{1, 2, 3}) System.out.println(n);
// Classic for — when you need the index
for (int i = 0; i < 5; i++) { /* ... */ }
// While / do-while
int i = 5;
while (i-- > 0) { /* runs while true */ }
do { /* runs at least once */ } while (false);
// Ternary — expression only, never a statement
String grade = score >= 60 ? "pass" : "fail";
The classic switch required break on every case — forget one and execution silently continues. Since Java 14, switch expressions use -> syntax and never fall through. The old colon form still exists and still bites.
Switch, then and now
String desc;
switch (day) {
case MONDAY:
case FRIDAY:
desc = "start slow";
break; // forget this → fall through
case SUNDAY:
desc = "rest";
break;
default:
desc = "grind";
}Each case needs an explicit break, and desc must be declared outside for the compiler to prove it's assigned.
String desc = switch (day) {
case MONDAY, FRIDAY -> "start slow";
case SUNDAY -> "rest";
case SATURDAY -> "projects";
default -> "grind";
};
// Java 21+: pattern matching — match on type
static String describe(Object o) {
return switch (o) {
case Integer n when n < 0 -> "negative int";
case Integer n -> "int: " + n;
case String s -> "string: " + s;
case null -> "nothing";
default -> o.getClass().getSimpleName();
};
}Exhaustive by construction, no fall-through, and when guards let you add conditions on the same pattern.
When a switch expression covers an enum or a sealed type, the compiler requires every case. Add a new enum constant tomorrow and the build fails at every switch — instead of quietly returning null at runtime. This is the same idea as Kotlin's when, which we'll meet in chapter 21.
// if with pattern binding — replaces the old instanceof dance
static String handle(Object obj) {
if (obj instanceof String s && s.length() > 3) {
return s.toUpperCase(); // s is bound and narrowed here
}
if (obj instanceof int[] arr) {
return "array of " + arr.length;
}
return "unknown";
}
// Pre-Java-16 equivalent, for reference:
// if (obj instanceof String) {
// String s = (String) obj; // cast, then hope
// ...
// }
Arrays
Arrays are fixed-size, covariant, and reified — the type knows its length at runtime. For anything that grows, use a collection.
int[] nums = {5, 3, 9, 1}; // declaration + init
int[] more = new int[4]; // [0, 0, 0, 0]
String[] names = new String[3]; // all null
System.out.println(nums.length); // 4 — field, no parens
nums[0] = 42;
// Sorting and searching — use Arrays, never hand-roll
java.util.Arrays.sort(nums);
System.out.println(java.util.Arrays.toString(nums)); // [1, 3, 9, 42]
int idx = java.util.Arrays.binarySearch(nums, 9); // 2
int[][] grid = {{1,2},{3,4}}; // jagged, rows can differ
String[] is a Object[] in Java — so this compiles and then throws ArrayStoreException at runtime: Object[] a = new String[1]; a[0] = 42;. Generic collections reject that at compile time, which is a major reason to prefer List over arrays.
Arrays are fast
Contiguous memory, one bounds check, no boxing for primitives. Hot numeric code should use them.
Jagged, not rectangular
int[][] is an array of arrays — each row can be a different length.
Covariant
Subtype arrays are assignable, which trades compile-time safety for runtime exceptions.
Records
Since Java 16, a record declares a transparent carrier for data: the components define the constructor, accessors, equals, hashCode and toString in one line.
public record Point(int x, int y) {
// Optional compact constructor — validation runs from every constructor
public Point {
if (x < 0 || y < 0) throw new IllegalArgumentException("negative");
}
// Optional extra methods
public double magnitude() {
return Math.sqrt(x * x + y * y);
}
// Optional additional constructors must delegate
public Point() { this(0, 0); }
}
var p = new Point(3, 4);
p.x(); // accessor is x(), not getX() — unless @AutoProperty
p.magnitude(); // 5.0
new Point(3, 4).equals(new Point(3, 4)); // true
System.out.println(p); // Point[x=3, y=4]
| Class | Record | |
|---|---|---|
| Mutable fields | Yes | No — implicitly final |
Write equals/hashCode/toString | Yes, by hand | Generated |
| Constructor | By hand | From components |
| Normal inheritance | Yes | Extends only Record |
| Best for | Behaviour, identity | Plain data |
Records have value equality — two instances with identical components are interchangeable. That's perfect for a Money, DTO or Point, and wrong for an Order whose identity is its ID. JPA entities, in particular, want mutable classes. Use records where identity doesn't matter.
- Conditions must be
boolean— noif (1), no null/empty truthiness. - Prefer
->switch expressions — exhaustive, no fall-through. - Pattern matching kills the
instanceof-cast dance and can bindwhenguards. - Arrays are fixed-size and covariant — collections for anything that grows.
- Records are for data, with value equality and no mutability.
Classes, objects & constructors
A class is a description and an object is an allocation — internalise that one split and Java's constructors, access modifiers, static, equals() and records stop being a list of rules to memorise and become the obvious consequences of how instances are born, shared, compared and discarded.
You can write a surprising amount of Java with arrays and local variables. Then you inherit a module where account numbers live in one array, balances in another, and somewhere a line reads balances[i] -= amount with nothing but an int tying the two together. Swap one index and money quietly disappears. That is the failure mode object orientation exists to prevent: not "model the real world", but make illegal states unrepresentable and illegal operations unexpressible.
Java's answer is a class — a single declaration that bundles state (fields) with the operations that know how to keep that state valid (methods), then makes the bundle the only route to the state. A deposit() method can refuse a negative amount; a bare long cannot. And a class compiles once into a .class file — a description sitting on disk — while an object is a run-time allocation on the heap. One BankAccount description; potentially millions of distinct accounts.
Blueprint vs allocation
Here is a class that could not be simpler and yet already carries a real invariant: an account always has an owner, and its balance never goes negative or moves by a non-positive amount. Note the field types — long for money, never double, because binary floating point cannot represent 0.10 exactly and auditors notice:
public class BankAccount {
private final String owner; // set once, at construction
private long balanceCents; // long, not double: money is not a float
public BankAccount(String owner, long opening) {
if (owner == null || owner.isBlank())
throw new IllegalArgumentException("owner required");
this.owner = owner; // 'this' = this object's copy of the field
this.balanceCents = opening;
}
public void deposit(long cents) {
if (cents <= 0)
throw new IllegalArgumentException("cents must be > 0");
balanceCents += cents;
}
public void withdraw(long cents) {
if (cents > balanceCents)
throw new IllegalStateException("overdrawn");
balanceCents -= cents;
}
public long balance() { return balanceCents; }
}
new BankAccount("Ashton", 10_000) is where the description becomes a thing. The new operator is four steps, and knowing them explains half the "gotchas" people file under Java:
0, 0.0, false, null. For a moment the object exists with none of your invariants intact.
{ ... } blocks run in source order, parent first (via its own constructor). This is where private List<X> xs = new ArrayList<>(); happens.
this fully wired. It is the only step that can validate arguments and refuse to hand back an object at all — which is why constructors exist rather than plain factory methods alone.
new is an expression; its value is a reference to the finished object. From here on, only methods you exposed can touch the state.
Static initialisation sits outside this diagram: the first time anything touches the class (a new, a static field read, a static method call), the class's own static fields and static { } blocks run once, before any object exists.
Fields, methods and the four access modifiers
Access control is Java's main tool for the "only methods you exposed can touch the state" promise. There are exactly four levels, and the fourth one has no keyword at all:
| Modifier | Same class | Same package | Subclass, other package | Everyone |
|---|---|---|---|---|
private | yes | no | no | no |
| (package-private) — no keyword | yes | yes | no | no |
protected | yes | yes | yes | no |
public | yes | yes | yes | yes |
private unless there is a reason not to, and most methods in a well-packaged design are package-private — a level many developers forget exists.protected is wider than you thinkIn C++, protected means "subclasses only". In Java it means package + subclasses — every class in your package can still reach it. If something should be invisible to the outside world but shared across sibling classes, that is package-private (no modifier), not protected. Reserve protected for the one case it names: an extension point for people who will subclass you from another package.
Constructors, chaining, and initialisation order
A constructor has the same name as the class and no return type. Java gives you a free one: if you declare no constructors at all, the compiler writes a public no-arg constructor for you. Declare a single constructor of your own and the free one disappears instantly — there is no way to get it back except by writing it. That is deliberate: a class with a required field should not be constructible without it.
# What the compiler actually emits for a class with no constructors:
$ javap -p Empty.class
public class Empty {
public Empty();
}
# Add ONE constructor to BankAccount and the free no-arg one is gone:
$ javac Use.java
Use.java:5: error: constructor BankAccount in class BankAccount cannot be
applied to given types
new BankAccount();
^
required: java.lang.String,long
found: no arguments
reason: actual and formal argument lists differ in length
1 error
Two constructors are allowed to share work through chaining: this(...) delegates to another constructor in the same class, and super(...) delegates to the parent. Both must be the first statement of the constructor — the compiler enforces it, because the object has to reach a fully-initialised state exactly once, along exactly one path.
1. Static fields and static { } blocks of the class — superclass first — run once per JVM, on first use. 2. Per object: the parent's instance initialisers, then the parent's constructor body, then this class's instance initialisers and field defaults, then this constructor body.
Field initialisers are compiled into the constructor at the top of the this(...) chain — the one that calls super(...) — so no matter how many constructors you chain through, they run exactly once, ahead of that final body.
Encapsulation: getters, setters and the honest middle
Encapsulation is not "make everything private and add a getter" — that just moves the coupling one door over. The point is that the class owns its invariants. Below the line, code can corrupt state; above it, it can only ask for behaviour.
- It reveals state without offering a way to corrupt it
- Prefer
account.balance()overaccount.getBalance()— Java has no property syntax, so drop theget - Return immutable types (
List.copyOf,Instant) or you have merely moved the leak
- The state genuinely changes over the object's life and
- It must be validated or coordinated with other fields
- A named command would be a lie —
setPaid(true)vsinvoice.markPaid(clock.instant())
If your setter body is literally this.x = x;, delete it: the constructor already did that job, and a public setter turns every invariant you wrote into a suggestion. Prefer commands over setters — transferTo(other, cents), close(), markPaid() — because a named operation can maintain two fields at once while a setter never can.
static: the half that isn't about objects
Every instance method secretly receives the receiver as its first parameter — deposit(BankAccount this, long cents) — which the JVM pushes on the stack before the call. static means "no hidden this parameter", and every other consequence follows: a static method cannot read an instance field, an instance method can freely use both, and a static field is one copy shared by all instances rather than one copy per object.
static field
One copy, living on the class, shared by every instance. Use it for values that are genuinely per-class — a counter, a cache, a private static final constant table — never for per-object state.
static method
Behaviour with no receiver: Math.max, Integer.parseInt, List.of. It belongs to the class because it doesn't need any object's state — the classic sign you're writing a utility, not a domain type.
static { } block
Initialisation too complex for an assignment: build a lookup table, System.loadLibrary, seed a cache. Runs once, on first use of the class, in the order written.
class Widget {
private static int instances; // ONE copy — shared by every Widget
private final int id; // one per object
Widget() {
this.id = ++instances; // 1, 2, 3 … for this JVM run
}
static int instanceCount() { // no hidden 'this' — cannot see 'id'
return instances;
}
}
class Demo {
public static void main(String[] args) {
new Widget();
new Widget();
System.out.println(Widget.instanceCount()); // 2
// widget.id — a static method has no 'widget' at all
}
}
Static initialisers run in textical order, exactly once, at class initialisation — not at class loading, and not on your schedule. If static int CACHE_SIZE = table.length; is written above static final int[] table = buildTable();, it silently reads 0 with no warning and no error. Worse: class initialisation is guarded by a JVM-wide lock, so a slow static { } (network call, file scan, native library) blocks every thread that touches the class, and a static block that re-enters its own class sees half-initialised values.
Identity: ==, equals() and the classic trap
These three do three different jobs, and confusing the first two is the single most common Java bug that passes local testing:
== — identity (primitives: value)
Integer a = 128, b = 128;
System.out.println(a == b); // false — two objects
Integer c = 100, d = 100;
System.out.println(c == d); // true — Integer.valueOf
// caches only −128..127On references, == asks "same object in the heap?". The Integer cache makes it pass for small numbers and fail for large ones — a bug that survives every test written with fixture data.
.equals() — value comparison
Integer a = 128, b = 128;
System.out.println(a.equals(b)); // true — contents match
String s = new String("hi");
String t = new String("hi");
System.out.println(s.equals(t)); // trueValues compare with .equals(), always. Note the second trap: two separately-built Strings are equal but not identical — interning is an optimisation, never a semantics.
equals without hashCode is a silent corruptionPut two equal objects in a HashSet with mismatched hash codes and the set will happily hold both — lookups then fail in one direction only. The contract: equal objects must produce equal hash codes, unequal ones should spread. The two methods are a pair; overriding one is a bug. (Kotlin sidesteps the whole trap: == there calls equals() with a null check, and === is Java's ==.)
@Override
public boolean equals(Object o) {
if (this == o) return true; // same object: cheap win
if (!(o instanceof BankAccount other)) // Java 16 pattern matching
return false;
return balanceCents == other.balanceCents
&& owner.equals(other.owner);
}
@Override
public int hashCode() {
return Objects.hash(owner, balanceCents); // never hand-roll badly
}
@Override
public String toString() {
return "BankAccount[owner=" + owner
+ ", balanceCents=" + balanceCents + "]";
}
equals contract, in five lines- Reflexive —
x.equals(x)is true. - Symmetric —
x.equals(y)iffy.equals(x); asymmetric implementations breakHashSetin ways that depend on insertion order. - Transitive — if A=B and B=C then A=C; impossible if you compare a superclass field one way and a subclass field another.
- Consistent — no field used in
equalsmay ever change, or the object cannot live in a hash collection. - Never null —
x.equals(null)returns false, never throws. hashCodefollows — equal ⇒ equal hashes.Objects.hashallocates an array; on hot paths write31 * …by hand (the JIT turns31*xinto a shift-and-subtract).
System.out.println(someObject) does not print gibberish by accident: println calls String.valueOf, which calls someObject.toString(). The default implementation returns BankAccount@1c2b33d — class name plus the JVM's identity hash, not a memory address. Overriding toString() turns every log line, debugger view and assertion message into a diagnostic for free.
Records and immutability
If a class exists only to carry data — no invariants beyond "not null", no behaviour beyond accessors — a record replaces it in one line and writes equals, hashCode, toString and accessors for you. Chapter 07 goes deep; here is the immutability recipe that records encode by hand:
public final class Invoice { // final: no subclass can weaken it
private final String id; // final: set once, in the ctor
private final List<Line> lines;
public Invoice(String id, List<Line> lines) {
this.id = Objects.requireNonNull(id);
this.lines = List.copyOf(lines); // defensive COPY at the boundary
}
public List<Line> lines() {
return lines; // already immutable — safe to share
}
}
Two moves make this genuinely immutable: final fields stop reassignment, and List.copyOf stops the caller from holding a handle into your internals. Without the copy, a getLines().add(...) from anywhere in the program mutates your invoice behind your back — Collections.unmodifiableList alone only wraps the view, so an attacker with the original reference still writes through it.
- Yes: value objects, DTOs, exceptions' payloads, results of a parse, anything you would have given four getters and an
equals. - No: anything with a mutable field, a no-arg constructor, a superclass, or identity that matters across time.
- Rule: if you can't name an invariant that a constructor must defend, it's a record.
The Kotlin counterpart
Kotlin doesn't remove these concepts, it collapses the ceremony around them. A data class generates equals/hashCode/toString/copy and destructuring components; val is a getter-only property (the field is private by default), var adds a setter; and there is no static keyword at all — you get companion object for per-class members and object declarations for singletons.
data class BankAccount(val owner: String, var balanceCents: Long) {
init { // runs with the primary constructor
require(owner.isNotBlank()) { "owner required" }
}
fun deposit(cents: Long) {
require(cents > 0) { "cents must be > 0" }
balanceCents += cents
}
}
class Widget { // no 'static' anywhere in Kotlin
companion object {
private var instances = 0
fun instanceCount() = instances // as Widget.instanceCount()
}
init { instances++ }
}
object Json // declaration = singleton, one INSTANCE field
fun demo() {
val a = BankAccount("Ashton", 10_000)
val b = a.copy()
println(a == b) // true — == CALLS equals()
println(a === b) // false — === is Java's ==
}
- A class is a description in a
.classfile; an object is an allocation on the heap.new= allocate → zero → initialisers → constructor → reference. - Fields are
private; four access levels exist, and the no-keyword one (package-private) is the most underused. - Declare a constructor and the compiler's free no-arg one vanishes.
this(...)andsuper(...)must be the first statement. - Getters yes, setters rarely — prefer named commands that can maintain several fields at once.
static= "no hiddenthisparameter". Static init runs once, in textual order, under a class-wide lock.==is identity,.equals()is value, andhashCode()must be overridden wheneverequals()is.- Plain data →
recordin Java,data classin Kotlin. Immutable object →finalfields plus defensive copies.
Inheritance, interfaces & polymorphism
Java gives you exactly one class slot and unlimited interface slots — inherit implementation from a single parent, compose contracts from as many types as you like — and the language's costliest design bugs come from reaching for the first tool when the second one was built for the job.
Start with the problem polymorphism actually solves. You keep Circle, Square and Triangle in one list, and every renderer, exporter and hit-tester needs to do something with them. Without a shared supertype you write if (x instanceof Circle) … else if (x instanceof Square) … in every method, and adding a fourth shape means grepping for every chain you ever wrote. One supertype with an abstract draw() moves that work to the compiler: implement it once per shape, call it anywhere, and a missing implementation is a build error instead of a null in production.
extends buys you that and free implementation reuse — which is exactly the trap. Inheritance is handed two jobs at once: subtyping (a Circle is a Shape, so a Shape reference may point at it) and code reuse (borrow the parent's methods). The first is a sound, powerful type-system feature. The second is a coupling decision you cannot undo. Java's answer is to give reuse its own mechanism — interfaces with default methods, sealed families, and delegation — so that you choose deliberately instead of by default.
extends: inheriting a whole parent object
A subclass object physically contains the superclass's fields — including its private ones. The storage is there; the name simply isn't accessible from the subclass. That containment is why the constructor chain runs parent-first: the parent half of the object must satisfy its own invariants before any child code can run against it. Java enforces this syntactically — super(...) must be the first statement of every constructor, and if the parent declares no no-arg constructor, javac will not invent one for you; you must write the call yourself.
abstract class Shape {
private final String name; // parent state, private to Shape
protected Shape(String name) { this.name = name; }
abstract void draw(); // no body — subclasses must supply one
@Override public String toString() { return name; }
}
final class Circle extends Shape {
private final double r;
Circle(double r) { super("circle"); this.r = r; } // super(...) FIRST
@Override void draw() { System.out.println("circle r=" + r); }
}
class Paint {
public static void main(String[] args) {
Shape[] shapes = { new Circle(1), new Square(2) }; // many types
for (Shape s : shapes) s.draw(); // dispatch on RUNTIME type
}
}
That loop is polymorphism in one line: the reference s is a Shape at compile time, and each element is a Circle or Square at run time. The JVM executes it with invokevirtual, which looks the method up in the object's own method table on every call — so s.draw() resolves to the right body without the caller knowing or caring which body it is.
new Shape()The compiler answers with one line: error: Shape is abstract; cannot be instantiated. It isn't pedantry — new must produce an object whose every method-table slot is filled, and an abstract class deliberately has empty ones. An abstract class is a half-finished template: useful as a base, meaningless on its own. You can still hold one as a reference type, and the classic way is an anonymous subclass that finishes it on the spot: Shape s = new Shape() { void draw() { … } };
Overriding — and the three lookalikes that aren't
@Override is not documentation, it's a type-check. Write it every single time. Without it, a typo like equals(MyAccount other) doesn't override anything — it quietly declares a brand-new overload, your equals contract silently stops applying, and HashSet starts misbehaving weeks later. With the annotation, the same typo is a compile error on the spot.
Signature matches exactly
Same name, same parameter types. The return type may stay the same or be narrowed — a covariant return like Circle self() overriding Shape self() is legal, because callers expecting a Shape still get one.
Visibility may only widen
protected → public is fine. public → protected is a compile error: you'd be removing a method that every existing caller of the parent can already reach.
Exceptions may only narrow
An override may throw the same checked exceptions or narrower subtypes of them — never broader. Otherwise code written against the parent could suddenly fail with something it never handled.
Not every method participates
private methods are invisible to subclasses and never overridden. static methods are hidden, not overridden — the JVM binds them with invokestatic at compile time. final refuses outright.
Overloading means several methods with different parameters — picked statically, from the reference type, before anything runs. Overriding means the same signature in a subclass — picked dynamically, from the object's actual class. Declare draw(double r) in the child while the parent has draw(int r) and you have overloaded, not overridden: a call through a Shape reference still runs the parent's method, and nobody gets an error. This is why @Override is non-negotiable — it is the compiler telling you which of the two you actually wrote.
One more rule with teeth: package-private methods only override inside the same package. A subclass in another package that redeclares one is declaring a new method, and the parent's own call sites will still run the parent's copy. A quiet source of "why is my logging not firing?" bugs across jar boundaries.
The Liskov trap: Square cannot extend Rectangle
Liskov's Substitution Principle says a subtype must be usable everywhere its supertype is promised, with the same observable guarantees. It sounds abstract until you watch it fail with numbers:
class Rectangle {
private int w, h;
Rectangle(int w, int h) { this.w = w; this.h = h; }
void setSize(int w, int h) { this.w = w; this.h = h; }
int area() { return w * h; }
}
final class Square extends Rectangle { // compiles. Breaks the world.
Square(int side) { super(side, side); }
@Override void setSize(int w, int h) { // a square can't move one side
super.setSize(Math.max(w, h), Math.max(w, h));
}
}
static void widen(Rectangle r) {
r.setSize(5, 3);
assert r.area() == 15; // every caller of Rectangle relies on this
}
// widen(new Rectangle(2, 2)) → 15 ✅
// widen(new Square(2)) → 25 ❌ and it changes with argument order
The code compiles, the hierarchy reads beautifully, and the assertion now fails depending on which concrete class was passed in. The flaw isn't Square — it's that Rectangle promised "set the sides independently, then ask me the area" and a square cannot honour that promise while remaining a square. Two fixes, both good: make both types immutable values with no setters at all (then the impossible operation simply doesn't exist), or model the shared contract by capability — Shape { int area(); void draw(); } — rather than by data layout.
Inheritance is for subtyping. Reuse is a side effect, and it's the side effect that bites: every protected method and every state field you inherit becomes permanent API you never wrote and can never shrink, your subclass is coupled to the parent's bugs and its schedule, and one parent change ripples through every child. When you find yourself inheriting purely to borrow twenty lines of code, that's a signal — move the twenty lines into a small class and hold one. Prefer composition over inheritance is the oldest design rule in Java literature, and it's still the one that ages best.
Abstract classes vs interfaces
Both declare void draw() without writing it. The differences are all about what else each can carry:
| Question | Abstract class | Interface |
|---|---|---|
| Instance state? | Yes — private fields, real per-object data | No — only public static final constants |
| Constructors? | Yes — chaining, validation, super calls | None — nothing to instantiate |
| How many? | One — extends is single | Many — implements A, B, C |
| Adding a method later | Breaks every subclass without it | Safe — ship it as a default |
| Access levels | All four | Mostly public — private helpers only for splitting bodies (Java 9+) |
| Models | "is a" — a family sharing heritage | "can do" — a capability or contract |
JpaRepository, JDBC's ResultSet) almost always hands you an interface plus a tiny abstract helper.Interfaces became a code-sharing tool in Java 8. default methods give a body inside the interface, static methods on the interface are utility functions that don't pollute implementations (Java 8), and private interface methods let you extract shared helpers that callers never see (Java 9+). This exists because interfaces must be evolvable: adding removeIf and stream() to Collection would have broken every implementation on Earth if each one had to write the method itself.
interface Named {
default String hello() { return "hi " + name(); }
String name();
}
interface Loud {
default String hello() { return "HEY " + name(); } // the diamond
String name();
}
// class Person implements Named, Loud ❌ compile error:
// types Named and Loud are unrelated; class must override hello()
class Employee implements Named, Loud { // ✅ resolve it yourself
public String name() { return "Ashton"; }
@Override public String hello() {
return Named.super.hello() + " (" + name() + ")"; // explicit parent
}
}
- 1. The class's own method always wins. A concrete
hello()inEmployeebeats any interface default. - 2. The most specific interface wins. If
Loud extends Named,Loud's default beatsNamed's — same rule as class overriding. - 3. Unrelated defaults? The compiler refuses and demands an explicit override, resolved with
Named.super.hello(). No run-time ambiguity is ever possible. - The class's abstract methods beat defaults too — you can always be forced to write a method, never forced to accept someone else's.
This is why multiple interface inheritance is uncontroversial while multiple class inheritance is banned. A class can be Comparable<Order>, Runnable, Serializable and AutoCloseable simultaneously — four independent capabilities, each with its own contract, none dragging in state. Single class inheritance plus multiple interface inheritance is the compromise that keeps the object layout predictable while letting you compose behaviour freely.
Sealed types: a set the compiler can count
Open inheritance has a blind spot: a switch over a hierarchy can never be proven complete, because tomorrow someone adds a subclass in another jar. Java 17 closed that gap. A sealed class names its permitted subtypes, and the compiler can finally enumerate them:
public sealed interface Shape
permits Circle, Rect, Triangle {}
public record Circle(double r) implements Shape {} // implicitly final
public record Rect(double w, double h) implements Shape {}
public record Triangle(double b, double h) implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.r() * c.r();
case Rect r -> r.w() * r.h();
case Triangle t -> 0.5 * t.b() * t.h();
// no 'default' — the compiler PROVED all three of them
};
}
| Modifier on a subtype | Who may extend it? | Typical use |
|---|---|---|
sealed | Only types named in permits | A closed family the compiler can enumerate |
final | Nobody | Leaf types — records are final automatically |
non-sealed | Anyone | A deliberate escape hatch out of a sealed hierarchy |
| (no modifier) | Anyone | Open extension point — and switches need a default |
permits may be omitted when every subtype is declared in the same file; otherwise it's mandatory. All permitted types must live in the same module — and, for code in the unnamed module, the same package. Chapter 07 builds real sum types on this.Composition, with a concrete refactor
Here is inheritance doing what it's usually sold as, and the bill arriving on the second requirement. You need card and PayPal payments; then fraud checks; then retries:
// BEFORE — a base class. Each cross-cutting need MULTIPLIES the subclasses:
// Card + PayPal × fraud on/off × retry on/off = 8 near-identical classes
abstract class PaymentProcessor { abstract void pay(long cents); }
class CardProcessor extends PaymentProcessor { /* card plumbing */ }
class PaypalProcessor extends PaymentProcessor { /* paypal plumbing */ }
// AFTER — one contract, small single-purpose pieces you nest like sentences
interface PaymentProcessor { void pay(long cents); }
record FraudChecked(PaymentProcessor next) implements PaymentProcessor {
@Override public void pay(long cents) {
Fraud.check(cents); // written once, used anywhere
next.pay(cents);
}
}
record Retrying(PaymentProcessor next, int attempts)
implements PaymentProcessor {
@Override public void pay(long cents) {
for (int i = 0; i < attempts; i++) next.pay(cents);
}
}
// var p = new FraudChecked(new Retrying(new CardProcessor(), 3));
Eight classes collapse to four small ones, each testable with a one-line fake, and the combinations are assembled at the call site instead of frozen at compile time. You can't un-inherit — deleting a superclass is a refactor across every subclass — but you can delete new FraudChecked(…) from one line. That asymmetry is the whole argument. Keep inheritance for genuine is-a families where the parent is a stable, meaningful abstraction; reach for interfaces, small collaborators and delegation everywhere else.
The Kotlin counterpart
Kotlin's biggest single difference from Java here: classes are final by default. Nothing is inheritable until you write open — inheritance becomes a decision you announce at the declaration site, which is exactly the discipline the previous section argued for and Java can't enforce. Everything else maps over: interfaces may hold bodies, Any is Object, and sealed hierarchies have been checked exhaustively by when since Kotlin 1.0 — years before Java shipped the feature.
abstract class Shape { abstract fun draw() }
open class Circle(private val r: Double) : Shape() { // 'open' opts in
override fun draw() = println("circle r=$r") // override needs 'open'
}
interface PaymentProcessor { fun pay(cents: Long) }
class CardProcessor : PaymentProcessor {
override fun pay(cents: Long) { /* card plumbing */ }
}
// 'by' = delegation: inherit the implementation from a held instance
class LoggingProcessor(private val inner: PaymentProcessor)
: PaymentProcessor by inner {
override fun pay(cents: Long) {
println("paying $cents"); inner.pay(cents) // explicit beats 'by'
}
}
sealed interface Shape2 { // Kotlin: sealed since 1.0
data class Circle(val r: Double) : Shape2
data class Rect(val w: Double, val h: Double) : Shape2
}
fun area(s: Shape2): Double = when (s) {
is Shape2.Circle -> Math.PI * s.r * s.r
is Shape2.Rect -> s.w * s.h
// no 'else': "when must be exhaustive" is a compile ERROR
}
- One class, many interfaces.
extendsfor a stable is-a family;implementsfor capabilities you compose. super(...)is always the first statement; subclass fields initialise after the parent is fully built.- Annotate every override with
@Override— it converts a silent overload into a compile error. private,staticandfinalmembers are not polymorphic; visibility may widen, exceptions may only narrow.- A subtype must be behaviourally substitutable —
Square extends Rectanglefails atsetSize, not at the compiler. - Interfaces carry contracts and default methods; abstract classes carry state and shared implementation.
sealed+permitsmakes a hierarchy countable, so switches can be proven exhaustive.- Kotlin: classes are final by default (
openopts in), interfaces have bodies, andbydelegates instead of inherits.
Designing data: enums, records & builders
Most Java code is either carrying data around or describing a fixed set of things. Java has first-class syntax for both — most teams simply don't use it.
An OO design problem usually splits into two halves: things with behaviour and identity (classes), and things that are just values (records). Confusing the two is where most modeling pain starts.
Enum
A fixed, named set with real methods. Not "an int with a name" — it's a class with exactly N instances.
Record
An immutable value carrier with generated equality. For DTOs, money, coordinates, events.
Builder
For classes with many optional fields where a telescoping constructor becomes unreadable.
Enums are full classes
public enum Planet {
MERCURY(3.303e+23, 2.4397e6),
VENUS(4.869e+24, 6.0518e6),
EARTH(5.976e+24, 6.37814e6);
private final double mass; // enums are classes — fields are fine
private final double radius;
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
}
private static final double G = 6.67300E-11;
public double surfaceGravity() {
return G * mass / (radius * radius);
}
public double weight(double otherMass) {
return otherMass * surfaceGravity();
}
public static Planet fromName(String name) {
// values() gives the fixed set — switch can then prove exhaustiveness
for (Planet p : values())
if (p.name().equalsIgnoreCase(name)) return p;
throw new IllegalArgumentException("unknown: " + name);
}
}
System.out.println(EARTH.weight(70)); // 686.0-ish newtons
System.out.println(Planet.valueOf("MARS")); // IllegalArgumentException if absent
- Singleton set —
values(),valueOf(),ordinal(). - Safe in
switch— exhaustiveness becomes a compile error. - Can implement interfaces — each constant can override behaviour (the "enum as strategy table").
- Serialization-safe — unlike raw ints, renames don't silently shift values.
public enum Operation {
ADD { public double apply(double a, double b) { return a + b; } },
MULTIPLY { public double apply(double a, double b) { return a * b; } },
MAX { public double apply(double a, double b) { return Math.max(a, b); } };
public abstract double apply(double a, double b);
}
// ...replaces a switch, a strategy map, or a whole class hierarchy
double r = Operation.ADD.apply(2, 3);
ordinal() as an IDIt's the declaration index. Insert a constant at the top and every stored ordinal shifts — corrupting databases and config files. Store name() or an explicit field like code.
Records for pure data
public record Money(long cents, Currency currency) {
public Money { // compact constructor: runs before assignment sticks
if (cents < 0) throw new IllegalArgumentException("negative amount");
}
public static Money of(long units, Currency c) {
return new Money(units * 100, c); // static factory reads better
}
public Money plus(Money other) {
if (!currency.equals(other.currency))
throw new IllegalArgumentException("currency mismatch");
return new Money(cents + other.cents, currency);
}
public Money scale(double factor) {
return new Money(Math.round(cents * factor), currency);
}
}
Money a = Money.of(20, Currency.USD);
Money b = Money.of(25, Currency.USD);
System.out.println(a.plus(b)); // Money[cents=4500, currency=USD]
System.out.println(a.equals(Money.of(20, Currency.USD))); // true — value equality
Set<Money> seen = new HashSet<>(); // correct as a map key, too
class Money {
private long cents;
private Currency c;
// getters, setters, equals, hashCode, toString,
// constructor x3, plus a builder...
// ...and it's still mutable, so equals() lies
}Fifty lines, a mutable object whose hash can change after insertion, and equality you had to write correctly by hand.
record Money(long cents, Currency c) {
public Money {
if (cents < 0) throw new IllegalArgumentException();
}
public Money plus(Money o) {
return new Money(cents + o.cents, c);
}
}Immutable, correct equality, safe as a key — and the type system enforces that last property.
An enum is a closed set you control. When the set of variants is closed but each variant carries different data, use a sealed interface: sealed interface Shape permits Circle, Rect. Exhaustive switch over it then gives you the same compile-time guarantee — and this is exactly how Kotlin models it with sealed class.
The builder, when constructors get long
public class HttpClientConfig {
private final String baseUrl;
private final int timeoutMs; // required
private final int retries; // optional
private final Map<String, String> headers;
private HttpClientConfig(Builder b) {
this.baseUrl = b.baseUrl;
this.timeoutMs = b.timeoutMs;
this.retries = b.retries;
this.headers = Map.copyOf(b.headers);
}
public static Builder builder(String baseUrl) { return new Builder(baseUrl); }
public static final class Builder {
private final String baseUrl;
private int timeoutMs = 5_000; // sensible defaults
private int retries = 3;
private final Map<String, String> headers = new LinkedHashMap<>();
private Builder(String baseUrl) { this.baseUrl = Objects.requireNonNull(baseUrl); }
public Builder timeoutMs(int v) { this.timeoutMs = v; return this; }
public Builder retries(int v) { this.retries = v; return this; }
public Builder header(String k, String v) { headers.put(k, v); return this; }
public HttpClientConfig build() { return new HttpClientConfig(this); }
}
}
var cfg = HttpClientConfig.builder("https://api.acme.com")
.timeoutMs(2_000)
.header("X-Trace", "abc")
.build();
| Problem | Reach for | Why |
|---|---|---|
| Fixed set of constants | enum | Exhaustive switch, singleton, serializable |
| Immutable value / DTO | record | Free equality, no mutability |
| Closed set, varied payloads | sealed + records | Exhaustive matching with data |
| Many optional fields | builder | Readable, order-free, defaults |
| Long fixed set of fields, always same | constructor | Builders are overkill when there's nothing optional |
| Optional nullable field (1–2) | Optional param or overload | Quieter than a builder |
A builder adds a nested class, a mutable intermediate, and boilerplate per type. If a constructor has three parameters and they're all always supplied, a plain constructor is clearer. Build the habit of asking: which of these is optional, and how often? If the answer is "none", skip the builder.
- Enums are classes — give them fields and methods; keep behaviour beside the constant.
- Never persist
ordinal()— it changes when you reorder. - Records are immutable values — ideal for anything whose identity is its content.
- Sealed types close a set of types so
switchproves exhaustiveness. - Builders are for optionality, not a reflex for every new class.
Generics & variance
Generics let one ArrayList serve Strings, Integers and your own classes while the compiler proves at every line that you never mix them up — and the single fact that Java implements them by erasure explains every strange restriction you are about to meet.
Before Java 5 shipped in 2004, a collection was a bag of Object. You could put anything in, you got Object back out, and you cast it to whatever you believed was inside. The compiler could not check that belief, so the mistake did not surface as a compile error — it surfaced as a ClassCastException three methods away from the line that caused it, in production, at 2 a.m.
Generics add one piece of information — "this list holds Strings" — and the compiler uses it to type-check every add and every get. Nothing about the runtime changes. That gap between "checked at compile time" and "gone at runtime" is the whole story of this chapter, and it is why Java's generics look slightly dumber than C++ templates and slightly dumber than Kotlin's.
List names = new ArrayList(); // raw type: List of Object
names.add("Ada");
names.add(42); // compiles — nothing stops you
String s = (String) names.get(1);
System.out.println(s.length()); // 💥 ClassCastException: IntegerThe failure is four lines from the mistake that caused it. Every read is an unchecked cast the compiler signs off on blindly.
List<String> names = new ArrayList<>();
names.add("Ada");
names.add(42); // ❌ Integer is not a String
String s = names.get(1); // no cast — already known
System.out.println(s.length()); // ✅Identical bytecode discipline, one line of type information, and an entire category of bug becomes a red squiggle in your editor.
Generic classes, interfaces and methods
A type parameter is a placeholder declared in angle brackets after the class or method name. Conventions are strong and worth obeying — every Java dev reads K/V as map key/value without thinking:
| Parameter | Meaning | You'll see it in |
|---|---|---|
T, U, V | a general type | Box<T>, Comparable<T> |
E | element | Collection<E>, List<E> |
K / V | key / value | Map<K,V> |
N | a number | Number helpers, statistics libs |
S, R | second, result | function types, reduce |
TypeParam in CamelCase is legal but reads as amateurish in Java.import java.io.Serializable;
import java.util.List;
public class Generics {
// A generic CLASS: T is a type PARAMETER, resolved at each use site
public static class Box<T> {
private T value;
public void set(T value) { this.value = value; }
public T get() { return value; }
}
// Generic METHOD: the <T> lives on the method, and inference finds it
public 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;
}
// Multiple bounds: at most ONE class, then interfaces, joined by &
// (the class goes first because it is the most restrictive slot)
public static <T extends Number & Comparable<T> & Serializable>
void export(T t) {
System.out.println(t + " is comparable to itself: "
+ (t.compareTo(t) == 0));
}
public static void main(String[] args) {
Box<String> b = new Box<>(); // diamond: infer <String> from the left
b.set("hello");
List<Integer> nums = List.of(3, 9, 4);
System.out.println(max(nums)); // 9 — T inferred as Integer
export(42); // T inferred as Integer
}
}
The bound <T extends Comparable<T>> is the workhorse constraint: "T must be able to compare itself to another T." It is what makes max type-safe rather than a cast-fest, and it is exactly the bound Collections.sort puts on its argument. A bound is a promise the compiler enforces — pass a type that isn't Comparable and you get a compile error at the call site, not a ClassCastException.
You wrote new Box<>() and max(nums), never spelling out Box<String> or <Integer>. Java 7 added the diamond; Java 8 massively improved target typing, so the compiler works backwards from the assignment, the parameter or the return type to solve for the type arguments. Chained fluent calls like Collectors.toList() inside stream.collect(...) rely on this — you only ever write the types at the outermost edge.
Type erasure: the one fact that explains everything
Here is the single most important thing to know about Java generics: type arguments exist only at compile time. The compiler checks your code, then throws the type arguments away before producing bytecode. A List<String> and a List<Integer> are, at runtime, the same class java.util.ArrayList. This is type erasure, and it exists for one reason: Java 5 had to stay binary-compatible with the mountain of pre-generics code already deployed. Erasure let ArrayList gain type safety without gaining a second, incompatible runtime identity.
Because the type parameter is gone at runtime, none of these compile:
1. new T[] — the JVM must know an array's component type to allocate it, and T is not a runtime type. 2. instanceof List<String> — there is nothing to test; o instanceof List<> (Java 9+) is the most you get, and it only proves the raw class. 3. static T members — static fields and methods belong to the class, not an instance, and a class has exactly one set of erased fields, so T is not in scope in a static context.
public class Erased<T> {
// T[] make() { return new T[10]; } // ❌ generic array creation
// static T first(...) { ... } // ❌ static cannot see T
// boolean bad(Object o) {
// return o instanceof List<String>; // ❌ illegal generic type
// }
// boolean ok(Object o) {
// return o instanceof List<>; // ✅ Java 9+ unbounded only
// }
// What actually happens: the compiler inserts a CAST at every read.
// It writes this for String s = box.get();:
public String getAndCast() {
return (String) (Object) getRaw(); // the invisible cast, made visible
}
private Object getRaw() { return "hello"; }
}
Proof: the bridge method
You can watch erasure in the bytecode. Compile a class implementing Comparable<MyInt>, then disassemble it:
cat > MyInt.java <<'EOF'
public class MyInt implements Comparable<MyInt> {
private final int value;
public MyInt(int v) { this.value = v; }
public int compareTo(MyInt other) {
return Integer.compare(value, other.value);
}
}
EOF
javac MyInt.java
javap -p MyInt
# Compiled from "MyInt.java"
# public class MyInt implements java.lang.Comparable<MyInt> {
# private final int value;
# public MyInt(int);
# public int compareTo(MyInt); <-- what you wrote
# public volatile int compareTo(java.lang.Object); <-- SYNTHETIC BRIDGE
# }
Comparable's real method signature takes an Object. After erasure your compareTo(MyInt) no longer matches it, so the compiler synthesises a bridge method — compareTo(Object) — that casts its argument to MyInt and delegates. It's the compiler quietly re-inserting the cast erasure deleted. If you ever see a ClassCastException thrown from a line that contains no cast, you're almost certainly being hit by a bridge method.
Unchecked warnings and when to shut them up
Some code is genuinely type-safe but unprovable to the compiler — anything touching Class<T>, reflection, or a raw legacy API. That produces an unchecked conversion warning. The discipline: verify the safety by hand, then annotate the exact method, never the whole file, and say why.
public class Unchecked {
// Raw type returned by a pre-generics library
@SuppressWarnings("unchecked") // we promise the runtime list is List<T>
static <T> List<T> legacyQuery(Class<T> type) {
List raw = new java.util.ArrayList(); // raw — no type info
raw.add("built for " + type.getSimpleName());
return raw;
}
// SAFER alternative when you have the Class token: a checked copy
static <T> List<T> checkedCopy(Class<T> type, List<?> in) {
List<T> out = new java.util.ArrayList<>();
for (Object o : in) {
if (!type.isInstance(o)) {
throw new ClassCastException(
"expected " + type.getName() + ", got " + o.getClass());
}
out.add(type.cast(o)); // the cast happens HERE, with a real check
}
return out;
}
}
@SuppressWarnings("unchecked") is a loaded gunIt does not remove the risk, it removes the warning about the risk. Erasure means a List<String> and a List<Integer> are the same object at runtime, so a single leaked raw reference can put an Integer into a list of Strings that the compiler believes is pure — heap pollution. The failure then appears, via a bridge method, far from the leak. Keep the annotation narrow (one method), add a comment stating exactly why it's safe, and prefer a checked copy using type.cast(o) when the code path is cold.
Variance: arrays lie, generics don't
Variance is the question "if Integer is a Number, is a list of Integers a list of Numbers?" Java gives two different answers, and the inconsistency is deliberate.
Arrays are covariant — Integer[] is a subtype of Number[], and it compiles. Generics are invariant — List<Integer> is not a List<Number>, and it doesn't compile. Arrays made that concession in 1.0 to let polymorphism work with collections of primitives-era code, and the JVM has paid for it ever since:
public class ArrayStore {
public static void main(String[] args) {
// Covariance: an Integer[] IS a Number[] as far as javac is concerned
Number[] nums = new Integer[3];
nums[0] = 10; // ✅ fine
// ...and here is the hole. The compiler accepted this because
// Integer is a Number. The JVM remembers the real component type
// and refuses at RUNTIME:
nums[1] = 3.14; // 💥 ArrayStoreException
// java.lang.ArrayStoreException: java.lang.Double
// Generics close this hole by refusing to compile it at all:
// List<Number> l = new ArrayList<Integer>(); // ❌ incompatible types
}
}
Erasure makes the runtime representation identical to raw Object, but the compile-time effect is enormous: generics turn a whole family of runtime crashes into build failures, let the compiler infer and check method signatures, and enable the wildcard rules below. The cost is real too — you can't overload on type argument, can't construct T, and can't test against a concrete argument type. C# picked reified generics instead and got those abilities at the price of runtime complexity Java chose never to pay.
Wildcards: ? extends, ? super and PECS
Invariance would be unbearable if there were no escape hatch. There is one: use-site wildcards, which express variance at the point of use rather than at the declaration.
| Declaration | Meaning | You may read | You may write |
|---|---|---|---|
List<? extends Number> | a list of Number or any subtype | as Number | nothing but null |
List<? super Number> | a list of Number or any supertype | as Object | Number and subtypes |
List<Number> | exactly a list of Number | as Number | Number and subtypes |
List<?> | a list of something unknown | as Object | nothing but null |
extends. If it consumes values from you, use super.import java.util.ArrayList;
import java.util.List;
public class Pecs {
// CONSUMER: we only put Numbers in → "? super Number" (PECS)
static void fill(List<? super Number> sink, Number... values) {
for (Number n : values) sink.add(n); // ✅ adding Number is safe
}
// PRODUCOR: we only take Numbers out → "? extends Number" (PECS)
static double sum(Collection<? extends Number> src) {
double total = 0;
for (Number n : src) total += n.doubleValue(); // ✅ reads as Number
return total;
}
public static void main(String[] args) {
List<Number> nums = new ArrayList<>();
List<Object> any = new ArrayList<>();
fill(nums, 1, 2.5, 3L); // ✅ List<Number> is within "? super Number"
fill(any, 1, 2.5, 3L); // ✅ List<Object> too — Object is a supertype
// List<Integer> ints = ...; fill(ints, ...); // ❌ Integer is a SUBtype
// // you'd get Integers,
// // not Numbers. Correctly
// // rejected.
System.out.println(sum(nums)); // 6.5
System.out.println(sum(any)); // 6.5 — same call, wider argument
}
}
List<Object> cannot hold a List<String>If it could, the compiler would have to let you call add(Integer.valueOf(1)) on that List<Object> — and that list is really a List<String> somewhere else in your program, so the next get returns an Integer to code expecting a String. Erasure means the JVM could not stop it. Invariance stops it at compile time instead. If you only need to read Strings through an Object-typed reference, write List<? extends Object> — which is what Iterable<? extends Object> means, and why the for-each loop works on any collection at all.
Kotlin's answer: variance on the declaration
Kotlin has the same erasure — it runs on the same JVM — but it moves the variance decision to the declaration site, with out and in. Declare it once on the class and every use site inherits it, so ordinary Kotlin code has no wildcards at all.
// out T = covariant ("produce only"). in T = contravariant ("consume only").
interface Source<out T> { fun next(): T } // T in out position ✅
interface Sink<in T> { fun put(t: T) } // T in in position ✅
// interface Bad<out T> { fun put(t: T) } // ❌ out-projected type
// // used in in position
fun main() {
val names: List<String> = listOf("Ada", "Grace")
// Kotlin's List IS declared `List<out E>` — so covariance is free:
val asAny: List<Any> = names // ✅ no wildcard, no ceremony
val first: Any = asAny[0]
// Mutable lists stay invariant, exactly like Java:
// val w: MutableList<Any> = mutableListOf<String>() // ❌ type mismatch
// Star projection: "I know the shape, not the argument"
val star: List<*> = names
println("${star.size} items, first = ${star[0]}") // element type = Any?
// Reified type parameters — the thing Java genuinely cannot do:
val mixed: List<Any> = listOf(1, "two", 3.0)
println(mixed.filterIsInstance<Int>()) // [1] — survives erasure!
println(isA<String>(mixed[0])) // false
}
// `inline` + `reified` copies the body into the caller with T substituted,
// so `is T` and `::class` work. Only possible in functions, never classes.
inline fun <reified T> isA(value: Any): Boolean = value is T
Reification is the one place Kotlin is strictly ahead. Because the function is inline, the compiler pastes its body at each call site with the type argument written in concretely — value is Int — so the check is real. Java cannot do this for classes or methods generally; its only equivalent is passing a Class<T> token and calling isInstance on it manually, which is precisely why filterIsInstance exists as a Kotlin library function and not a Java one.
- Generics move ClassCastException to compile time — that's the entire benefit.
- Type parameters are conventions:
Ttype,Eelement,K/Vkey/value. - Bounds:
<T extends Comparable<T>>; multiple bounds join with&, class first. - Erasure: type arguments vanish at runtime → no
new T[], noinstanceof List<String>, nostatic T. - Watch a bridge method with
javap -p— it's the erased cast made visible. - Arrays are covariant and can throw
ArrayStoreException; generics are invariant and can't. - Use PECS: producer
extends, consumersuper. - Kotlin declares variance once with
out/in, andinline fun+reifiedbeats erasure where Java cannot.
Collections, streams & the right container
Java's collection framework is one giant, well-engineered hierarchy — and choosing the wrong branch is the most common performance bug in ordinary code.
Everything lives under java.util.Collection: lists you index, sets you deduplicate with, queues you drain, and maps that aren't technically collections but dominate real use. Learn the interfaces, not the classes — code written against List survives whatever implementation you pick later.
Illustrative asymptotics for teaching. Constants matter enormously — ArrayList beats LinkedList for almost every real workload despite identical big-O for sequential scans.
| Need | Use | Avoid for this |
|---|---|---|
| Indexed sequence, grow/shrink | ArrayList | Frequent middle insertions |
| Frequent insert/remove at both ends | ArrayDeque | Random access |
| Frequent middle insert + traversal | LinkedList | Random access — get(i) is O(n) |
| No duplicates, fast contains | HashSet | Any ordering requirement |
| No duplicates, insertion order | LinkedHashSet | Sorted output |
| Sorted, range queries | TreeSet / TreeMap | Fastest possible lookup |
| Key → value, order irrelevant | HashMap | Sorted keys, thread-safety |
| Key → value, insertion order | LinkedHashMap | Sorted output |
| Priority / next-best | PriorityQueue | Iterating in sorted order (it doesn't) |
| Thread-safe map | ConcurrentHashMap | Compound atomicity without care |
List<String> names = new ArrayList<>(); // diamond infers type args
names.addAll(List.of("ada", "grace", "alan"));
Set<Integer> seen = new HashSet<>();
Map<String, Integer> counts = new HashMap<>();
counts.merge("ada", 1, Integer::sum); // idiomatic frequency count
counts.computeIfAbsent("grace", k -> new ArrayList<>().size());
// Immutable factories — Java 9+, prefer these for fixed data
var fixed = List.of("a", "b", "c");
var kv = Map.of("a", 1, "b", 2);
var ordered = Map.entry("k", "v"); // single entry
var copy = List.copyOf(names); // truly immutable snapshot
var nullish = new ArrayList<>(); // List.of rejects null
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2);
System.out.println(stack.pop()); // 2 — LIFO
Deque<Integer> queue = new ArrayDeque<>();
queue.add(1); queue.add(2);
System.out.println(queue.remove()); // 1 — FIFO
PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.addAll(List.of(5, 1, 3));
System.out.println(pq.poll()); // 1 — smallest first
list.remove(x) inside a for-each throws ConcurrentModificationException — even single-threaded. Use removeIf, an explicit Iterator.remove, or build a new list with a stream.
List<String> users = new ArrayList<>(List.of("a", "b", "c"));
// ❌ for (String s : users) if (s.equals("b")) users.remove(s);
users.removeIf(s -> s.equals("b")); // ✅ one pass, no CME
users.removeAll(Set.of("c")); // ✅ set-based, faster
// Replace the whole list rather than mutating in place
users = users.stream().filter(s -> !s.isEmpty()).toList();
Streams: declaring what, not how
A stream is a lazy pipeline over a source. Nothing runs until a terminal operation — which is why filter before map matters and why side effects inside a stream are a design smell.
record User(String name, int age, String dept) {}
List<User> users = List.of(
new User("Ada", 36, "eng"),
new User("Grace", 45, "eng"),
new User("Alan", 41, "research"),
new User("Katherine", 34, "ops"));
// Pipeline: filter → map → sort → collect
List<String> engOver40 = users.stream()
.filter(u -> u.dept().equals("eng"))
.filter(u -> u.age() > 40)
.map(User::name)
.sorted()
.toList(); // [Grace]
// Grouping — the killer feature over imperative loops
Map<String, List<User>> byDept = users.stream()
.collect(Collectors.groupingBy(User::dept));
// Counts, sums, averages in one expression
Map<String, Long> headcount = users.stream()
.collect(Collectors.groupingBy(User::dept, Collectors.counting()));
OptionalDouble avg = users.stream().mapToInt(User::age).average();
// Reduce to a single value
String all = users.stream().map(User::name).collect(Collectors.joining(", "));
// Short-circuiting
users.stream().filter(u -> u.age() > 40).findFirst();
users.stream().anyMatch(u -> u.dept().equals("ops"));
Intermediate ops are lazy
filter, map, sorted build a plan. Only collect, count, findFirst actually pull data through.
Short circuits
anyMatch, findFirst and limit stop early — a stream over 10M rows can touch 3.
No side effects
Stateful lambdas break under parallelism. Return values from the lambda; don't mutate captured variables.
A stream wins when the pipeline is genuinely declarative — filter, transform, group, join. A plain loop wins when you need early break, multiple interacting accumulators, or an if/else that doesn't map to a predicate. Reaching for peek() three times means you wanted a loop.
Map<String, List<User>> byDept = new HashMap<>();
for (User u : users) {
List<User> bucket = byDept.get(u.dept());
if (bucket == null) {
bucket = new ArrayList<>();
byDept.put(u.dept(), bucket);
}
bucket.add(u);
}The null-check-and-create dance is pure ceremony — and it's the exact code you forget to update when a requirement changes.
Map<String, List<User>> byDept =
users.stream()
.collect(Collectors.groupingBy(User::dept));Names the intent, allocates the buckets for you, and composes — add a count with a second argument instead of rewriting the loop.
- Program to interfaces:
List,Map,Set— neverArrayListin a signature. ArrayList+HashMapare the defaults — deviate only with a measured reason.ArrayDequebeatsLinkedListfor both stacks and queues.removeIfinstead of mutating during iteration.- Streams are for pipelines; loops are for control flow. Don't force either.
Text, formatting & regex
Almost every program eventually reads text someone else produced. Java gives you String, StringBuilder, Formatter and a full regex engine — plus records and streams for when you need to parse, split and reshape at scale.
Chapter 03 covered references and immutability. This chapter is about doing things with text: reading input, formatting output, matching patterns, and parsing structured strings without drowning in substring calls.
Reading input
import java.util.Scanner;
import java.io.BufferedReader;
import java.io.InputStreamReader;
public class Input {
public static void main(String[] args) throws Exception {
// Scanner — easy, line-oriented, great for puzzles and scripts
Scanner sc = new Scanner(System.in);
String line = sc.nextLine(); // whole line
if (sc.hasNextInt()) {
int n = sc.nextInt();
}
sc.close();
// BufferedReader — for real programs reading many lines fast
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
String each;
while ((each = br.readLine()) != null) { // null = end of input
System.out.println(each);
}
}
}
Scanner when…- Reading tokens from stdin
- Prototyping or competitive programming
- You want
nextInt()-style convenience
BufferedReader / Files.lines when…- Performance on large input matters
- You're streaming a big file
- It's production code, not a script
Scanner is slow and closes System.inDon't call sc.close() on stdin if something else still needs it. For bulk work use System.in.transferTo(System.out) or a buffered reader — a naive Scanner loop can be an order of magnitude slower.
Formatting output
String name = "Ashton";
int age = 30;
double pi = 3.14159265;
// printf / String.format — printf-style, position-aware
System.out.printf("Hi, %s, you are %d%n", name, age);
System.out.printf("|%10.2f|%n", pi); // | 3.14|
System.out.printf("|%-10.2f|%n", pi); // |3.14 |
System.out.printf("%,d%n", 1000000); // 1,000,000
System.out.printf("%08.3f%n", pi); // 003.142
// Reordering — useful for i18n, where word order differs by language
System.out.printf("%2$s is %1$d years old", 30, "Ada");
// formatted() on String — same engine, returns a String
String s = "%s scored %.1f%%".formatted("Ada", 97.5);
// text blocks — Java 15+, preserves formatting
String html = """
<div class="card">
<p>Hello, %s</p>
</div>
""".formatted(name);
| Specifier | Meaning | Example output |
|---|---|---|
%s | String / any object | Ada |
%d | Decimal integer | 1042 |
%f | Decimal float (width .precision) | 3.14 |
%,d / %.2f | Grouping / precision | 1,000 / 3.14 |
%n | Platform newline (not \n) | \n on Linux |
%b | Boolean | true |
%c | Character | A |
%x / %o | Hex / octal | ff / 12 |
%-10s | Left-align in 10 columns | Ada |
Remember from chapter 03: every + on a String allocates. Inside a loop over a log file, that's the difference between 20 MB and 2 GB of garbage. Use StringBuilder, or better, build a List and String.join once at the end.
// Streaming a file into a summary — no intermediate String churn
long bytes = 0;
StringBuilder out = new StringBuilder();
try (var lines = Files.lines(Path.of("app.log"))) {
for (var it = lines.iterator(); it.hasNext(); ) {
String line = it.next();
bytes += line.length();
if (line.startsWith("ERROR")) out.append(line).append('\n');
}
}
Files.writeString(Path.of("errors.log"), out); // one final write
Regular expressions
Regex lives in java.util.regex: a Pattern is compiled and reusable, a Matcher walks one input. Compiling inside a loop is a classic hot-path mistake.
import java.util.regex.*;
public class Regex {
// Compile ONCE as a static field
private static final Pattern LOG = Pattern.compile(
"^\\s*(?<time>\\d{2}:\\d{2}:\\d{2})\\s+"
+ "(?<level>INFO|WARN|ERROR)\\s+"
+ "(?<msg>.*)$");
public static void main(String[] args) {
String line = "14:03:22 ERROR database timed out";
Matcher m = LOG.matcher(line);
if (m.matches()) {
System.out.println(m.group("level")); // ERROR — named groups
System.out.println(m.group("msg")); // database timed out
}
// find() searches anywhere; matches() requires the WHOLE string
System.out.println(Pattern.compile("\\d+").matcher("a12b345")
.find()); // true
// Replacing with a lambda — captured text flows back in
String masked = line.replaceAll(
Pattern.quote("database"), "REDACTED");
// Splitting with a limit
String[] parts = "a,b,,c".split(",", -1); // [a, b, , c]
// Quick validation without Pattern boilerplate
System.out.println("ada@example.com".matches("[\\w.]+@[\\w.]+")); // true
}
}
| Token | Means |
|---|---|
\d / \D | Digit / non-digit (remember: \\d in Java source) |
\w / \W | Word char / non-word char |
\s / \S | Whitespace / non-whitespace |
+ * ? | One+, zero+, zero-or-one |
{n,m} | Between n and m repetitions |
[abc] / [^abc] | Char class / negated class |
(?<name>...) | Named capture — readable group("name") |
(...) | Capture group, referenced as group(1) |
(?:...) | Non-capturing group — cheaper, for structuring only |
^ / $ | Start / end of input |
| | Alternation |
\Q...\E | Literal text — Pattern.quote(s) wraps automatically |
Pair a pattern with a record to turn a line of text into a typed object in one step — this is where chapters 07 and 10 meet. record Hit(String level, String msg) plus a named-group pattern gives you a parsed, testable model instead of a six-element String[].
For a single fixed character, String.split or indexOf beats a hand-written pattern, and Pattern compilation is the expensive part. Compile once, reuse the Pattern, and measure before reaching for regex on a hot path. Also prefer Pattern.quote(userInput) — an unescaped dot or bracket from user data turns validation into a bug.
Scannerfor tokens, buffered readers for volume.- Use
%n, not\n, inprintf— portable newlines. - Compile
Patternonce; reuse it across everyMatcher. - Named groups (
(?<level>...)) make captures self-documenting. StringBuilderin loops,String.joinat the end.
Exceptions & the error-handling model
Java's exceptions are checked at compile time — a design no mainstream language copied, and the most argued-about feature in the language. Understand why it exists before you work around it.
Every throwable descends from Throwable: Error for things you shouldn't catch (out of memory, stack overflow) and Exception for things programs are expected to handle. RuntimeException and its children are unchecked — the compiler doesn't force you to declare or catch them.
Checked
Subclass of Exception, not RuntimeException. Must be declared in throws or caught. Compiler enforces it.
Unchecked
RuntimeException and subclasses. No declaration required — NullPointerException, IllegalArgumentException.
Error
JVM-level failures. Let them propagate — catching OutOfMemoryError rarely helps.
public class Throwing {
// Checked: the signature forces callers to deal with it
static int parseAge(String raw) throws NumberFormatException {
if (raw == null || raw.isBlank())
throw new NumberFormatException("blank input");
return Integer.parseInt(raw); // throws on garbage
}
// Unchecked: no declaration — signals a programming mistake
static String lookup(String[] table, int i) {
if (i < 0 || i >= table.length)
throw new IndexOutOfBoundsException("index " + i + " out of bounds");
return table[i];
}
// Custom checked exception: real domain failure
public static class InsufficientFunds extends Exception {
private final long shortfall;
public InsufficientFunds(long shortfall) {
super("short by " + shortfall + " cents");
this.shortfall = shortfall;
}
public long shortfall() { return shortfall; }
}
static void withdraw(long balance, long amount) throws InsufficientFunds {
if (amount > balance) throw new InsufficientFunds(amount - balance);
}
}
catch (Exception e) {} deletes the failure and leaves you debugging a symptom three layers away. If you truly cannot handle it, at minimum log with the stack trace — e.printStackTrace() beats silence.
try { risky(); }
catch (Exception e) {} // swallow
try { risky(); }
catch (Exception e) { return; } // hide it, plus no context
try { risky(); }
catch (Exception e) {
throw new RuntimeException(e); // loses the call site semantics
}
try { risky(); }
catch (IOException e) { throw e; } // pointless — declare it insteadEach one makes the original failure harder to find. Rethrow e itself when you can't add value.
try {
send(order);
} catch (ConnectException e) {
// This layer knows the retry policy — handle it here
log.warn("mail down, queueing {}", order.id());
outbox.enqueue(order);
}
// Let everything else propagate
// Never catch Exception or Throwable broadly in library codeCatch at the layer that has enough information to choose a different path. Otherwise, let it travel up.
Anything implementing AutoCloseable closes automatically, even if an exception is thrown mid-block. This replaced the nested finally pyramid and eliminated an entire class of leaked file handles. Multiple resources close in reverse order of declaration.
// Multiple resources, reverse-order close, no finally block
try (var in = Files.newInputStream(path);
var out = Files.newOutputStream(dest)) {
in.transferTo(out);
} catch (IOException e) {
log.error("copy failed from {}", path, e);
} // both closed here, guaranteed, even on exception
// Scoped lock — same pattern for synchronization
synchronized (lock) { /* ... */ } // releases on every exit path
// Custom resource
class Session implements AutoCloseable {
private final Socket s = connect();
public void close() { s.close(); }
}
try (var session = new Session()) {
session.send("hi");
} // session.close() runs automatically
try {
parse(config);
} catch (ParseException e) {
// Add context WITHOUT losing the original stack trace
throw new IllegalStateException("bad config at line " + e.getLine(), e);
}
// Printing — pass the throwable as the LAST arg to the formatter
log.error("failed to write {}: {}", path, e); // message, arg, throwable
// Read a trace programmatically
e.getStackTrace(); // StackTraceElement[]
e.getCause(); // the chained original
e.getSuppressed(); // extra failures (try-with-resources adds these)
- Name it after the problem:
InsufficientFunds, notMyException. - Carry the data needed to recover — the ID, the offending value, the line number.
- Message for humans, fields for code. Never parse a message.
- Make it checked only if callers can plausibly handle it. Most domain failures are unchecked.
- Chain the cause when wrapping:
new X(msg, e).
They force a decision at the call site — genuinely valuable at boundaries (file, network). But they compose terribly: wrapping a checked exception in another checked exception to satisfy a signature produces boilerplate, and they leak into interfaces where nobody can recover. Kotlin, Scala and C# all dropped them. Use them where recovery is plausible; otherwise prefer unchecked with a clear message.
| Scenario | Use |
|---|---|
| Caller can realistically recover | Checked exception + throws |
| Programming bug (null, bad index, bad arg) | Unchecked — IllegalArgument, IllegalState |
| Resource must be released | try-with-resources |
| Partial failure worth reporting alongside success | addSuppressed(e) |
| Expected absence, not an error | Optional or null — not an exception |
| OOM, stack overflow, linkage errors | Let it go — Error |
- Checked vs unchecked is about whether the caller can act — not about importance.
- Catch only to handle; everything else propagates.
- Always use try-with-resources for files, sockets and locks.
- Wrap with the cause:
new IllegalStateException(msg, e). - Never catch
Throwableor swallow an exception silently.
Functional Java: lambdas, Optional & method refs
Java 8 bolted functional programming onto an object-oriented language. The result isn't Haskell — it's a pragmatic set of tools for passing behaviour instead of objects.
A lambda is an anonymous function: no name, no class file you wrote, and it can be stored in a variable, passed to a method, and returned. The type it binds to is a functional interface — any interface with exactly one abstract method.
import java.util.function.*;
public class Lambdas {
public static void main(String[] args) {
// Explicit type — verbose, occasionally useful for clarity
Runnable r1 = () -> System.out.println("hi");
// Inferred — the target type gives the compiler everything it needs
Function<String, Integer> len = s -> s.length();
Predicate<String> long1 = s -> s.length() > 4;
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
System.out.println(len.apply("hello")); // 5
System.out.println(long1.test("hi")); // false
System.out.println(add.apply(2, 3)); // 5
// Blocks, when the body needs more than one expression
Comparator<String> byLenThenAlpha = (a, b) -> {
int d = Integer.compare(a.length(), b.length());
return d != 0 ? d : a.compareTo(b);
};
// Effectively final captured variables
String prefix = "log: "; // cannot be reassigned after this line
Consumer<String> out = s -> System.out.println(prefix + s);
out.accept("started");
}
}
| Interface | Method | Use |
|---|---|---|
Function<T,R> | R apply(T) | Transform a value |
Predicate<T> | boolean test(T) | Filter / test |
Consumer<T> | void accept(T) | Side effect, no result |
Supplier<T> | T get() | Produce on demand (lazily) |
BiFunction<T,U,R> | R apply(T,U) | Two inputs, one output |
UnaryOperator<T> | T apply(T) | Same type in and out |
Comparator<T> | compare(T,T) | Ordering — with static factories |
A lambda runs later, possibly on another thread, after your method's stack frame is gone. If it captured a variable that had changed, it would read a value that no longer exists. Java freezes the capture at creation time instead — which is why you can't reassign prefix after the lambda uses it.
Method references
A method reference is a lambda with the name spelled out. Four shapes cover almost everything.
// 1. Static method — args become the parameters
Function<String, Integer> parse = Integer::parseInt;
// same as s -> Integer.parseInt(s)
// 2. Instance method of a particular object
String greeting = "hello";
Supplier<String> upper = greeting::toUpperCase;
// same as () -> greeting.toUpperCase()
// 3. Instance method of the argument — very common in streams
Function<String, Integer> len = String::length;
// same as s -> s.length()
// 4. Constructor — an object factory
Function<String, StringBuilder> newSb = StringBuilder::new;
// same as s -> new StringBuilder(s)
// In practice
List.of("b", "a", "c").stream()
.map(String::toUpperCase)
.sorted(Comparator.comparingInt(String::length))
.forEach(System.out::println);
Comparator<User> byName = Comparator.comparing(User::name)
.thenComparing(User::age);
- The logic is a short expression you'd write inline anyway
- You're capturing a local variable
- The intent is clearer as an expression
- It's just a call to an existing method
User::namereads better thanu -> u.name()- The target signature matches exactly
Function and Predicate both have and, or and negate. Chaining named predicates reads like a specification; three levels of nested filter(a -> a && (b || c)) lambdas reads like a puzzle.
Predicate<String> notBlank = s -> !s.isBlank();
Predicate<String> hasAt = s -> s.contains("@");
Predicate<String> isShort = s -> s.length() < 100;
Predicate<String> validEmail = notBlank.and(hasAt).and(isShort.negate());
// Function composition — apply right-to-left like maths
Function<Integer, Integer> doubleIt = x -> x * 2;
Function<Integer, Integer> plusOne = x -> x + 1;
Function<Integer, Integer> pipeline = doubleIt.andThen(plusOne);
System.out.println(pipeline.apply(5)); // 11 (5*2, then +1)
Function<Integer, Integer> reverse = doubleIt.compose(plusOne);
System.out.println(reverse.apply(5)); // 12 (+1 first, then *2)
Optional — the anti-null
Optional<T> makes absence part of the type: Optional<String> either has a String or it doesn't, and the API forces you to decide.
Optional<User> found = repo.find("ada");
// ✅ Declare absences as Optional (return types, fields of a config)
// ✅ Chain the decision instead of branching
String name = found
.map(User::name)
.filter(n -> n.length() > 2)
.orElse("anonymous");
found.ifPresent(u -> log.info("hit {}", u.name()));
found.ifPresentOrElse(
this::render,
() -> response.status(404));
long count = found.stream() // 0 or 1 elements — elegant for streams
.filter(u -> u.age() > 18)
.count();
// Supplier for expensive fallbacks — runs only when absent
User fallback = found.orElseGet(() -> User.anonymous());
// ❌ Never: Optional fields, Optional parameters, .get() without a guard
// private Optional<String> name; // use a plain null OR a plain field
// String s = opt.get(); // that's just NullPointerException with steps
| Method | Returns | When |
|---|---|---|
map | Optional | Transform if present, pass empty through |
filter | Optional | Keep only if a predicate holds |
flatMap | Optional | Transform when the function already returns Optional |
orElse | T | Cheap default, always evaluated |
orElseGet | T | Lazy default — use when creation is costly |
orElseThrow | T | Absence is genuinely exceptional |
ifPresent | void | Side effect only when present |
stream | Stream | 0-or-1 element source in a pipeline |
Optional is designed as a return type. Using it for fields adds a wrapper object per field; using it as a parameter forces callers into Optional.of(...) ceremony; and .get() without checking is the NullPointerException you were avoiding, one line later. Return Optional, then handle it — don't just move it around.
- Target type drives lambda inference — declare the interface, write the body.
- Captured variables must be effectively final.
User::nameoveru -> u.name()when it's a straight call.- Compose with
and/or/andTheninstead of nesting. Optionalis a return type — map/filter, then decide.
Reflection, annotations & dynamic proxies
Reflection lets a program inspect and modify itself at runtime — the mechanism behind every dependency injector, mapper, serializer and test runner on the JVM.
You use reflection constantly without seeing it: JUnit finds your @Test methods, Jackson reads your record components, Spring autowires your beans. Writing it yourself is a specialist skill — but understanding it explains how the ecosystem works.
Inspect
Read a class's fields, methods, constructors and annotations without knowing its type at compile time.
Invoke
Call methods and constructors found at runtime. Check exceptions the old way — all become InvocationTargetException.
Breach
Set private fields. Powerful for frameworks, dangerous for design — it hides real dependencies.
import java.lang.reflect.*;
public class Reflect {
record User(String name, int age) {}
public static void main(String[] args) throws Exception {
Class<?> c = User.class; // literal — cheap and direct
Class<?> d = Class.forName("Reflect$User"); // by name — resolved at runtime
Object e = new User("Ada", 36).getClass();
System.out.println(c.getName()); // Reflect$User
System.out.println(c.getSimpleName()); // User
// Constructors
for (Constructor<?> ctor : c.getDeclaredConstructors()) {
System.out.println(ctor + " params=" + ctor.getParameterCount());
for (Parameter p : ctor.getParameters())
System.out.println(" " + p.getName()); // needs -parameters
}
// Build an instance reflectively
Constructor<?> chosen = c.getDeclaredConstructor(String.class, int.class);
Object u = chosen.newInstance("Grace", 45);
// Private field access
class Secret { private String key = "hidden"; }
Field f = Secret.class.getDeclaredField("key");
f.setAccessible(true); // open the door
Secret s = new Secret();
System.out.println(f.get(s)); // "hidden"
f.set(s, "changed");
System.out.println(s.key); // "changed" (compile error if direct)
// Methods: find by name, then invoke
Method m = c.getMethod("name"); // public only
Method[] all = c.getDeclaredMethods(); // incl. private
try {
System.out.println(m.invoke(u)); // "Grace"
} catch (InvocationTargetException ex) {
throw (RuntimeException) ex.getCause(); // unwrap the real failure
}
}
}
Method lookups involve string matching and security checks — orders of magnitude slower than a direct call (though JIT inlines some hot paths). Worse, a rename is a runtime failure instead of a compile error. Always cache Method/Field objects, and never let user input reach Class.forName.
Annotations
Annotations are metadata with @ syntax. Some are read by the compiler and discarded; others are stored in the class file and read at runtime — those are the interesting ones.
import java.lang.annotation.*;
import java.lang.reflect.*;
// 1. Declaring an annotation with retention + target
@Retention(RetentionPolicy.RUNTIME) // keep it in the class file
@Target(ElementType.METHOD) // only on methods
@interface Route {
String value(); // annotation members look like methods
String method() default "GET"; // defaults make callers terse
}
// 2. Using it
class Controller {
@Route("/users")
public List<User> all() { return store.all(); }
@Route(value = "/users/{id}", method = "POST")
public User create(String body) { return store.create(body); }
}
// 3. Reading it — this IS how frameworks discover routes
public static void scan(Class<?> c) {
for (Method m : c.getDeclaredMethods()) {
Route r = m.getAnnotation(Route.class); // null if absent
if (r != null) {
System.out.println(r.method() + " " + r.value() + " → " + m.getName());
}
}
}
scan(Controller.class);
// GET /users → all
// POST /users/{id} → create
| Retention | Visible at | Used for |
|---|---|---|
SOURCE | Compile time only | @Override, @SuppressWarnings, Lombok-style generation |
CLASS | Class file (not runtime) | Bytecode tools, static analysis |
RUNTIME | Runtime via reflection | Frameworks: Spring, Jackson, JUnit |
@Inherited
Let a class-level annotation be visible on subclasses. Off by default — surprising the first time.
@Target
Restricts where it may appear: field, method, type, parameter. Prevents misuse at compile time.
@Repeatable
Allows applying the same annotation multiple times, collected into a container annotation.
Annotations that annotate annotations — @Retention, @Target, @Documented, @Inherited, @Repeatable. Spring's @Transactional is itself annotated @Inherited and @Target(TYPE, METHOD), which is exactly why it doesn't work on a private method.
Dynamic proxies
A Proxy creates, at runtime, an implementation of any interface that forwards every call through one handler method. This is the mechanism behind mocking frameworks and every RPC client.
import java.lang.reflect.*;
interface Greeting { String hi(String who); }
public class Proxy {
public static void main(String[] args) {
Greeting real = who -> "Hello, " + who + "!";
Greeting traced = (Greeting) java.lang.reflect.Proxy.newProxyInstance(
Greeting.class.getClassLoader(),
new Class<?>[]{ Greeting.class },
(proxy, method, methodArgs) -> {
long t0 = System.nanoTime();
try {
return method.invoke(real, methodArgs); // forward
} catch (InvocationTargetException e) {
throw e.getCause(); // preserve semantics
} finally {
System.out.printf("%s took %d ns%n",
method.getName(), System.nanoTime() - t0);
}
});
System.out.println(traced.hi("Ada")); // Hello, Ada! + a timing line
}
}
Where you see it
Mockito mocks, Spring's @Autowired and @Transactional AOP, Retrofit's HTTP clients, Hibernate lazy-loading, JUnit's interception.
The limits
Only interfaces (for Proxy) — classes need bytecode generation (ByteBuddy). Constructors are off-limits, and final methods can't be intercepted.
- Frameworks — you're building a container, mapper or router.
- Cross-cutting behaviour — logging, metrics, retries around unknown implementations.
- Plugin systems — load by name, call through a stable interface.
- Not for application logic — a direct call is clearer, faster, and refactorable.
Modules, SecurityManager-style policies and strong encapsulation (setAccessible throwing InaccessibleObjectException) increasingly block deep access — especially into JDK internals on Java 17+. Reflection gets you to your own code easily and to the JDK's increasingly not. That's a feature.
- Cache reflective
Method/Field— lookup is the expensive part. - Unwrap
InvocationTargetExceptionor you log the wrong failure. RUNTIMEretention is required for anything a framework reads.- Proxies route every call through one handler — that's the whole trick.
- Prefer direct calls in application code; reflection is a tool for infrastructure.
Threads, locks & the memory model
Concurrency is not "two things at once" — it's two threads reading and writing the same memory while the CPU interleaves them anywhere, and the JVM deciding freely when (or whether) one thread ever sees the other's writes. Locks fix the interleaving; only the Java Memory Model fixes the visibility.
Every hard concurrency bug comes from one thing: shared mutable state. Shared — two threads can see it. Mutable — someone can change it. State — it holds a value that matters later. Kill any one of the three and the bug class disappears: make it immutable (a final record), keep it confined to one thread, or share by passing messages instead of pointing both threads at the same field.
The JVM gives you real OS threads, per-object monitors, and a written specification — the Java Memory Model — that says exactly which writes a thread is obliged to observe. That specification is what everyone skips, and it's what explains why a loop spins forever on your laptop and exits instantly in CI. Chapter 15 builds pools, futures and virtual threads on top of it, so this chapter comes first.
Creating threads — and the trap in start()
A platform thread costs an OS scheduling entity plus roughly 1 MB of stack. Java gives you three ways to use one: subclass Thread, hand it a Runnable (no result), or submit a Callable (returns a value, may throw). The last two are what you actually use.
import java.util.concurrent.*;
import java.util.stream.IntStream;
public class ThreadsDemo {
public static void main(String[] args) throws Exception {
// (1) a bare Thread wrapping a Runnable
Thread t = new Thread(
() -> System.out.println("hi from " + Thread.currentThread()));
t.start(); // NEW OS thread — this is the magic
t.join(); // main blocks until t is TERMINATED
// (2) Runnable: no result. Callable: returns a value.
Runnable job = () -> System.out.println("fire and forget");
Callable<Integer> sum = () -> IntStream.range(0, 100).sum();
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> f = pool.submit(sum);
System.out.println(f.get()); // 4950 — blocks main until ready
pool.shutdown();
}
}
run() instead of start()t.run() is an ordinary method call. It executes on your current thread, synchronously, and no second thread is ever created. The bug is silent — output still appears, logic still works, it just isn't concurrent, and any exception it throws lands in your caller. Only start() asks the OS for a thread, and each thread may be started once: a second start() throws IllegalThreadStateException.
The six thread states
Java exposes these via Thread.State. They matter operationally: when a thread dump lands on your desk, the state column is the first thing you read.
| State | What the thread is doing | You get there by |
|---|---|---|
NEW | Object exists, no OS thread yet | new Thread(...) |
RUNNABLE | Ready for the CPU, or actually running | start() |
BLOCKED | Waiting to enter a synchronized block someone else holds | Contended monitor |
WAITING | Parked until another thread acts | wait(), join(), LockSupport.park() |
TIMED_WAITING | Waiting with a deadline | sleep(ms), wait(ms), join(ms) |
TERMINATED | run() returned or threw | End of life |
BLOCKED on a monitor is the signature of lock contention; a whole thread dump full of BLOCKED on the same object is the signature of a hotspot — or a deadlock.synchronized — you lock an object, not some code
Every Java object carries an intrinsic lock (a monitor). synchronized acquires and releases it, and the JVM guarantees that only one thread may hold a given monitor at a time. Crucially, what gets locked is the object reference in the parentheses, not the block of code — so two threads entering the same method of two different instances sail straight past each other.
public class Counter {
private int count; // guarded by `this`
private static int instances; // guarded by Counter.class
private final Object gate = new Object(); // a private, dedicated lock
public synchronized void bump() { // (1) method → locks `this`
count++;
}
public static synchronized void resetAll() { // (2) static → locks the
instances = 0; // Class object itself
}
public int read() {
synchronized (gate) { // (3) block: you choose the
return count; // exact object to lock
}
}
}
Three rules keep you out of trouble. Lock the object that owns the state — a private final Object is best, because nobody outside can grab it and lock it too. Never lock on something publicly reachable (this in a library class, String.class, a boxed Integer). Monitors are also reentrant: a thread already holding a lock may re-enter code needing it, which is why a synchronized method can safely call another on itself.
The race you can reproduce: count++
count++ looks atomic and is not. It is three operations: read the field, add one to the local copy, write it back. Interleave those three steps between two threads and updates vanish — each thread reads the same stale value and both write the same result.
int hits = 0; // shared field
// thread A: read 0 → add → write 1
// thread B: read 0 → add → write 1 ← A's work is gone
hits++; // 8 threads × 1000 increments → maybe 3100The lost update is invisible in testing and certain at scale. More threads, more cores and a slower GC all make it worse.
AtomicInteger hits = new AtomicInteger();
hits.incrementAndGet(); // read-modify-write in ONE step
int now = hits.get(); // always a value some thread really wroteNo lock is taken. The JVM emits a compare-and-set loop on a single CPU instruction.
That compare-and-set (CAS) is the whole trick of lock-free code: read the old value, compute the new one, then compareAndSet(old, new) — one lock cmpxchg instruction on x86. If another thread got there first, the CAS fails and you retry. Nothing blocks, so nothing deadlocks. And if you only ever add and only read at the end, use LongAdder: it stripes the counter across per-thread cells so contending threads stop fighting, then sums them in sum() — routinely much faster than AtomicLong under load.
volatile is not a lockvolatile guarantees visibility and ordering — every thread reads the latest write, and no read or write may be moved across it. It guarantees nothing about atomicity: volatile int n; n++; still loses updates, because the read-modify-write is still three steps. Use it for flags, snapshots and immutable published state; use synchronized, Atomic* or a concurrent collection when a value is read, changed and written back.
The same trap hides in collections: mutating a plain ArrayList during a for-each throws ConcurrentModificationException. Across two threads it's worse — fail-fast is best-effort, so you may corrupt the list silently instead of getting an exception. Confine mutation to one thread, or use CopyOnWriteArrayList / ConcurrentHashMap from Chapter 13.
The Java Memory Model — happens-before
The JMM does not promise a write becomes visible "soon". It promises visibility when a happens-before edge connects write to read: if write A happens-before read B, B must see A's value or something later. Edges come from program order within a thread, unlocking then re-locking the same monitor, a volatile write then read, start(), and join() returning. No edge — no obligation, and your thread may legally read a stale copy from a register forever.
public class DirtyFlag {
private volatile boolean running = true; // ← delete this keyword: hang
private int port = 8080; // written once, before start
public void stop() { // called from another thread
running = false;
}
public void run() {
while (running) { // re-read on every iteration
Thread.onSpinWait(); // JDK 9+ polite yield hint
}
System.out.println("clean exit on " + port);
}
}
Without volatile, the JIT may prove that run() never writes running, hoist the field into a register and turn your loop into an unconditional jump. The other thread's write lands in memory and is never read again — the method hangs forever, on one JDK build, after warm-up. That's not a JVM bug; it's correct optimisation under a model that never promised you that read.
Thread A — stack & registers
The JIT proved this thread never writes the field, so it reads a cached copy. No happens-before edge exists, so staleness is legal.
Shared heap
The write is real and has landed. Landing in memory and being observed by another thread are two different events.
Thread B — stop()
With volatile the write→read edge is mandatory, and every earlier write by B rides along with it.
Key insight: the memory model is not about memory placement — all three columns touch the same heap. It's about obligations between threads. synchronized, volatile, final fields (frozen at the end of the constructor) and thread lifecycle calls are the handful of constructs that create those obligations.
The universal beginner fix is Thread.sleep(50) after the write. It works on your machine and fails in production, because a delay is not an edge — it only makes the window smaller. Reach for a construct that defines one: a monitor, a volatile field, an AtomicInteger, a concurrent queue, or start()/join().
Deadlock, livelock, starvation
Deadlock is two threads each holding what the other needs, so both sit in BLOCKED forever. It requires all four Coffman conditions to hold at once — which is good news, because breaking any single one prevents it.
1 · Mutual exclusion
Only one thread can hold a resource. Break it: use immutable state or atomics that nobody "holds".
2 · Hold and wait
A thread holds lock A while waiting for B. Break it: acquire all locks up front, or none.
3 · No preemption
Nobody can take a lock away. Break it: tryLock(timeout) gives up and releases.
4 · Circular wait
A → B → A in a cycle. Break it: one global lock order, obeyed everywhere.
public class Deadlock {
private final Object a = new Object();
private final Object b = new Object();
void aThenB() { // thread 1 takes a, then b
synchronized (a) {
synchronized (b) { work(); }
}
}
void bThenA() { // thread 2 takes b, then a 💀
synchronized (b) {
synchronized (a) { work(); } // circular wait: both BLOCKED
}
}
// FIX 1 — one lock order, everywhere, no exceptions
// FIX 2 — bounded acquisition: never wait forever
boolean tryAB() throws InterruptedException {
ReentrantLock la = new ReentrantLock();
ReentrantLock lb = new ReentrantLock();
if (!la.tryLock(50, TimeUnit.MILLISECONDS)) return false;
try {
if (!lb.tryLock(50, TimeUnit.MILLISECONDS)) return false;
try { return work(); }
finally { lb.unlock(); } // unlock in finally, always
} finally { la.unlock(); }
}
void work() { /* ... */ }
}
It never throws, never logs, never times out — the process just stops making progress and CPU sits idle. Take jstack <pid> (or jcmd <pid> Thread.print) and look for a cycle of threads in BLOCKED, each waiting for a monitor another blocked thread owns. Fix it with a documented global lock order; use timeouts as the safety net, not the strategy.
Livelock is deadlock's restless cousin: both threads running, retrying and yielding, nothing progressing — two people sidestepping in a corridor. It comes from optimistic retry loops with no backoff. Starvation is a thread that never wins because greedy threads re-acquire continuously; a fair ReentrantLock(true) fixes it at a real cost to throughput, which is a trade-off to make consciously.
Cooperation: wait, interruption, ThreadLocal
Threads hand work to each other through the monitor's wait set: call wait() inside a synchronized block to release the lock and sleep, then be woken by notify() or notifyAll(). And you must — must — wrap that call in while, never if.
final class Box {
private int items = 0;
private static final int FULL = 16;
void produce() throws InterruptedException {
synchronized (this) {
while (items == FULL) wait(); // WHILE — never `if`
items++;
notifyAll(); // wake everyone, they recheck
}
}
void consume() throws InterruptedException {
synchronized (this) {
while (items == 0) wait(); // re-test after EVERY wakeup
items--;
notifyAll();
}
}
}
if (cond) wait() is fine, I checked first"Two reasons the while is mandatory. First, the JVM is permitted to deliver spurious wakeups — wait() can return with no notify at all. Second, with several consumers, another thread can take the item between your wakeup and your reacquisition of the monitor; an if proceeds on a condition that is no longer true. notifyAll() over notify() for the same reason: a single notify may wake a thread that can't use the item, leaving the one that can still asleep.
Interruption, join, and never swallowing the flag
Java has no thread kill — stop() is deprecated because it can release monitors halfway through an invariant. Control is cooperative: t.interrupt() sets a flag, and blocking methods (sleep, wait, join, BlockingQueue.take) notice by throwing InterruptedException. t.isInterrupted() reads the flag, Thread.interrupted() reads and clears it, and join() blocks until the thread reaches TERMINATED.
void shutdown(Thread worker) throws InterruptedException {
worker.interrupt(); // ask politely; do not kill
worker.join(1_000); // wait up to 1s for TERMINATED
if (worker.isAlive()) log.warn("worker ignored its interrupt");
}
void loop() {
while (!Thread.currentThread().isInterrupted()) {
process(queue.take()); // throws when interrupted
}
}
// When you cannot propagate the exception, put the flag back:
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // restore it
throw new IllegalStateException("shut down", e); // or return
}
Swallowing InterruptedException is one of the most common concurrency sins: it silently destroys the caller's ability to shut you down, because an executor awaiting termination now believes your task ignored it. Either rethrow it, or restore the flag with Thread.currentThread().interrupt().
ThreadLocal<T> gives each thread its own value — the classic fix for non-thread-safe helpers like SimpleDateFormat, and how request context gets stashed without threading it through fifteen signatures. Its trap is the thread pool: threads live forever and are reused, so a set() with no matching remove() leaks that value for the pool's whole life and can leak yesterday's request context into today's request. Always remove it in a finally, and prefer Java 21's ScopedValue (preview) once you're on virtual threads, where thousands of threads would otherwise mean thousands of copies.
The same concurrency in Kotlin
Kotlin changes nothing about the runtime story: same bytecode, same JVM, same Java Memory Model. @Volatile, synchronized(lock) { … } (an inline function returning the block's value) and java.util.concurrent's atomics behave exactly as in Java.
import java.util.concurrent.atomic.AtomicInteger
class SharedState {
@Volatile var ready = false // kotlin.jvm.Volatile → JVM volatile
var payload: String? = null
private set
private val lock = Any() // private dedicated monitor
private val hits = AtomicInteger()
fun publish(data: String) {
synchronized(lock) { // inline: locks, runs, returns block
payload = data // write payload first...
ready = true // ...volatile write creates the edge
}
}
fun record(): Int = hits.incrementAndGet() // lock-free CAS, as ever
fun stop() {
// Threads, monitors and InterruptedException are all JVM-native here.
// The REAL Kotlin answer to "many concurrent tasks" is coroutines.
}
}
What Kotlin adds is a different answer to the same problem: coroutines, where the runtime multiplexes thousands of tasks onto a handful of threads and structured concurrency makes leaks and lost cancellation hard to write. That's Chapter 25 — but it sits on these exact primitives, so everything above still applies whenever a coroutine blocks.
start()creates a thread;run()doesn't — the bug is silent, so check it in review.synchronizedlocks the object you name, not the code. Lock a privatefinalfield that owns the state.count++is read → add → write; useAtomicIntegerorLongAdderfor counters, atomics for CAS.volatile= visibility + ordering, never atomicity. It fixes flags, not counters.- No happens-before edge means no visibility — ever. Monitors, volatile, atomics,
start()andjoin()create them. - Deadlock needs all four Coffman conditions: a global lock order plus
tryLocktimeouts breaks the cycle. wait()lives in awhile, always — spurious wakeups and stolen items are real.- Never swallow
InterruptedException, andremove()everyThreadLocalin a pool.
Executors, CompletableFuture & virtual threads
A platform thread costs about 1 MB of stack and a kernel scheduling entity, so new Thread() per request dies at a few thousand connections — this chapter is about the three tools Java offers instead: bounded pools for real parallelism, CompletableFuture for composing concurrent work, and virtual threads for making ten thousand blocking tasks cheap.
Spawning a raw thread per task fails in three separate ways: creation and context switches are syscalls, 1 MB of stack per thread turns 10 000 connections into 10 GB of resident memory, and the OS scheduler degrades long before that. The fix since Java 5 has been to separate the task from the thread — you submit work, an ExecutorService decides which thread runs it and how many exist.
The second problem is compositional: two Futures cannot be joined without blocking a thread, so expressing "fetch A and B, then combine" means hand-built latches. CompletableFuture fixes that with a chainable, non-blocking graph. And Java 21's virtual threads attack the first problem from below — thousands of threads that cost kilobytes each, because the JVM, not the OS, schedules them.
Pools: build them yourself
| Factory | Threads | Queue | Verdict |
|---|---|---|---|
newFixedThreadPool(n) | Fixed n | Unbounded | Tasks pile up silently → heap grows → OOM |
newCachedThreadPool() | 0 → 2³¹−1 | None (direct handoff) | Thread storm under load |
newSingleThreadExecutor() | 1 | Unbounded | Serialises work; same queue risk |
newScheduledThreadPool(n) | Fixed n | Delayed-work queue | Right tool for timers |
newVirtualThreadPerTaskExecutor() | 1 per task | None | Java 21+ for blocking I/O |
Executors factoriesnewFixedThreadPool hands you a LinkedBlockingQueue with no capacity: producers outpace consumers and the queue grows until the heap dies — with the pool looking perfectly healthy. newCachedThreadPool makes the opposite mistake: an unbounded thread count, so a burst of slow requests creates tens of thousands of OS threads and the machine starts swapping.
Both are unbounded, and an unbounded anything is a load-shedding decision you didn't make. Configure ThreadPoolExecutor explicitly: bounded queue, named threads, and a rejection policy you chose.
ExecutorService pool = new ThreadPoolExecutor(
8, // core
16, // max — hard ceiling
60, TimeUnit.SECONDS, // idle threads reaped
new ArrayBlockingQueue<>(1000), // BOUNDED: back-pressure
r -> { // named, daemonised threads
Thread t = new Thread(r, "worker");
t.setDaemon(true);
return t;
},
new ThreadPoolExecutor.CallerRunsPolicy()); // reject = slow the caller
pool.execute(() -> work()); // void: failures hit the handler
Future<Integer> f = pool.submit(() -> compute()); // Future + exceptions
pool.shutdown(); // stop accepting…
execute takes a Runnable and returns nothing — if it throws, the exception dies in the thread's uncaught-handler. submit wraps your work in a Future: return values arrive through get(), and exceptions come back wrapped in ExecutionException. CallerRunsPolicy is the elegant part of that example: when the queue is full, the submitting thread runs the task itself, which is back-pressure that throttles the producer for free.
Future — the API that can't compose
Future is deliberately minimal, and the pain shows the moment work must happen together:
Future<User> u = pool.submit(() -> loadUser(id));
Future<Orders> o = pool.submit(() -> loadOrders(id));
// Two tasks already running… and now you stall a THREAD waiting:
User user = u.get(); // blocks — throws ExecutionException on failure
Orders ord = o.get(); // blocks again — no "combine these two"
// "Run both, then join" needs a third submit + CountDownLatch + manual
// exception plumbing. Future has no thenApply, no allOf, no composition.
Every get() pins a thread doing nothing. Two futures, four threads. This is the gap CompletableFuture was designed for in Java 8: a Future you can attach callbacks to, so completion — not blocking — drives the next step.
CompletableFuture: the real async API
Json → User. Runs on whichever thread completed step 1.record Page(String name, String body) {}
CompletableFuture<String> page(String url) {
return CompletableFuture.supplyAsync(
() -> httpClient.body(url), POOL); // never the common pool
}
Page loadAll() {
CompletableFuture<String> a = page("https://a.example");
CompletableFuture<String> b = page("https://b.example");
CompletableFuture<String> c = page("https://c.example");
// zip two independent futures — no thread waits for the other
CompletableFuture<Page> header =
a.thenCombine(b, (x, y) -> new Page("head", x + y));
// fan out the rest, then join — still zero blocked threads
CompletableFuture.allOf(header, c).join();
return new Page("home", header.join().body() + c.join());
}
A bare thenApply executes on whichever thread finished the previous stage — often a pool thread you don't own. If your callback blocks (a JDBC query, a big JSON parse), you stall someone else's worker. Pass an executor: thenApplyAsync(fn, POOL). Rule of thumb: keep callbacks non-blocking, and hand anything blocking to an explicit executor — or to a virtual thread.
thenApply vs thenCompose — map vs flatMap
The single most common CompletableFuture mistake is using thenApply on a function that already returns a future, which nests one future inside another (CompletableFuture<CompletableFuture<User>>) and needs a second join() to unwrap it. If the function returns a future, flatten it with thenCompose.
// ThenApplyWrapper is the bug: nested futures
CompletableFuture<Profile> nested =
fetchUser(id)
.thenApply(u -> fetchProfile(u.id())) // CF<CF<Profile>> ❌
.join().join();
// Flat: one level, one join — this is what you meant
CompletableFuture<Profile> flat =
fetchUser(id)
.thenCompose(u -> fetchProfile(u.id())) // CF<Profile> ✅
.exceptionally(ex -> Profile.anonymous()) // recovery
.whenComplete((p, ex) -> log(p, ex)); // side effect, passes on
// thenApply = map (function returns a plain value)
// thenCompose = flatMap (function returns a future)
// thenCombine = zip (two independent futures, one function)
Three more you'll want: exceptionally recovers from failure with a fallback; whenComplete observes the result (or the exception) and passes it through untouched — good for logging, useless for recovery; handle takes both value and exception, letting you normalise success and failure in one branch. anyOf completes when the first branch does — the shape of every timeout or redundant-fallback pattern.
Virtual threads — Project Loom, Java 21
A virtual thread is a thread the JVM schedules onto a small pool of carrier platform threads — an M:N scheduler, the same idea as Go's goroutines or Kotlin's coroutines. When a virtual thread blocks on I/O, the JVM parks it, moves the carrier to another virtual thread, and resumes it later. One carrier is now a multiplexed lane, not a busy worker, so the economics flip: threads cost kilobytes, and creating one is a heap allocation rather than an OS call.
// (1) plain as a Thread — but it costs kilobytes, not 1 MB
Thread vt = Thread.ofVirtual().name("vt", counter)
.start(() -> handleRequest());
vt.join();
// (2) the idiomatic form: one virtual thread per task, no queue at all
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> results = urls.stream()
.map(u -> executor.submit(() -> httpClient.body(u)))
.toList();
for (Future<String> f : results) System.out.println(f.get());
}
// 50 000 concurrent HTTP calls, ~50 000 virtual threads,
// a handful of carriers (default = number of CPU cores).
What they're for: thousands of tasks that spend their lives waiting — HTTP calls, JDBC queries, file reads, sleeps. What they are not: extra CPU cores. Eight cores still do eight computations at once, and a virtual thread stuck in a hot arithmetic loop only adds scheduling overhead. Virtual threads buy concurrency, never parallelism — Chapter 14's locks still apply, because they are still real threads with a real Java Memory Model.
| Platform thread | Virtual thread | |
|---|---|---|
| Scheduled by | The OS kernel | The JVM, onto carrier threads |
| Footprint | ~1 MB stack + kernel entity | Kilobytes (heap-allocated stack) |
| Cheap to create | No — syscall, ~µs | Yes — an allocation |
| Blocking I/O | Occupies an OS thread | Carrier moves on |
| CPU-bound work | True parallelism | Same cores, extra overhead |
| Thousands at once | GBs of stack | Effortless |
| Typical count | Tens–hundreds | One per in-flight request |
setDaemon on virtual threads — the flag is ignored — and don't pool them: a pool's whole point is limiting thread count, which virtual threads don't need.A virtual thread that blocks inside a synchronized block can get pinned — it holds its carrier hostage, and every other virtual thread waiting for that carrier stalls behind it. On Java 21–23 this is real; diagnose it with -Djdk.tracePinnedThreads=full. Java 24 (JEP 491) reworked monitor entry so synchronized no longer pins in these cases, so the safe rule is: on 21, keep blocking I/O out of synchronized (or use ReentrantLock); on 24+, it mostly stops mattering. Native (JNI) calls can still pin on any version.
Structured concurrency — preview in Java 21 as StructuredTaskScope — applies the same discipline to a fan-out: subtasks are scoped, so the scope cannot finish until every child has either succeeded or been cancelled, and a failure cancels the siblings instead of leaking. It's try-with-resources for parallel work, and it's the API that makes virtual threads safe to use at scale.
Which tool, honestly
parallelStream() | CompletableFuture | Virtual threads | |
|---|---|---|---|
| Best for | Chunking CPU-bound data work | Genuinely concurrent composition | Many blocking I/O tasks |
| Threads used | Common pool — shared, unowned | Whichever executor you give it | One per task, kilobytes each |
| Blocking calls inside | Starves the pool | Stalls that stage | The point |
| Ordering / cancellation | Bulk only | Per-branch | Interruption + scope |
| Verdict | Use sparingly | Composition glue | Default for servers |
Asynchronous code does not make a slow call return sooner; it only decides which thread twiddles its thumbs. Before Java 21 that trade was often worth it — one thread per connection was too expensive. With virtual threads the trade flips: write plain, blocking, readable code, and let the runtime park the thread. Reach for CompletableFuture when you truly need to combine independent work or express a timeout graph — not as a substitute for threads.
The Kotlin counterpart: coroutines
Kotlin's answer to this whole chapter is kotlinx.coroutines, and it's a better default: suspend functions, structured concurrency built into every scope, and Dispatchers.IO — a pool sized for exactly the "thousands of blocking tasks" case. async/await mirrors CompletableFuture/join with less ceremony, and awaitAll is allOf.
suspend fun loadAll(urls: List<String>): String =
coroutineScope { // structured: waits for children
val deferred = urls.map { url ->
async(Dispatchers.IO) { // IO pool — made for blocking
httpClient.body(url)
}
}
deferred.awaitAll().joinToString("") // allOf(...).join()
}
suspend fun profile(id: Long): Profile =
runCatching { fetchProfile(fetchUser(id).id) }
.getOrElse { Profile.anonymous } // exceptionally
// Failure in any child cancels the scope — you get that for free.
Note the shape difference: Java's CompletableFuture is an explicit object graph you thread through your code, while Kotlin's coroutines are language-level — suspend reads like ordinary sequential code, and the compiler transforms it into a state machine instead of blocking a thread. Under the hood both solve the identical problem: stop dedicating a thread to waiting.
- Never take an unbounded queue or unbounded thread count — build
ThreadPoolExecutorwith a bounded queue and a rejection policy. execute= fire and forget;submit= returns aFutureand wraps failures inExecutionException.Futureblocks and can't compose — that's the whole reasonCompletableFutureexists.thenComposewhen the function returns a future;thenApplywhen it doesn't.- Pass an executor to
*Async— bare callbacks run on the completing thread, which isn't yours. - Virtual threads = concurrency, not parallelism: perfect for blocking I/O, pointless for maths.
- Keep blocking I/O out of
synchronizedon Java 21–23; Java 24 (JEP 491) largely removed the pinning. - Default to blocking code + virtual threads; add
CompletableFutureonly to compose concurrent branches.
Inside the JVM: bytecode, memory & GC
The JVM is the reason your code is portable — and the reason a for-loop can finish faster than you wrote it. This chapter opens it up: what a .class file actually contains, how classes get loaded, where objects live at runtime, which collector reclaims them, and how the JIT turns your bytecode into machine code (and occasionally throws it away again).
You have been shipping .class files since Chapter 01 without looking inside one. That's fine until something odd happens: a class loads twice, a static initialiser runs at an unexpected moment, a "tiny" allocation eats the heap, or your loop is mysteriously slow on the first request. All four are explained by what's in this chapter, and none of it is exotic — it's the machinery every Java process runs on.
Reading bytecode with javap
Bytecode is a stack machine: there are no registers to address, just an operand stack that instructions push onto and pop from. Watch a two-line class disassemble:
cat Point.java
public class Point {
private final int x;
public Point(int x) { this.x = x; }
private int scaled(int v) { return v * 2; }
public int sum(int y) { return this.x + scaled(y); }
}
javac Point.java
javap -c Point # -c = show the code attribute
public int sum(int);
Code:
0: aload_0 // push `this` (local var 0) onto the stack
1: getfield #7 // Field x:I — pop `this`, push this.x
4: aload_0 // `this` again, as the receiver of the call
5: iload_1 // push argument y
6: invokevirtual #13 // Method scaled:(I)I — pop receiver + arg
9: iadd // x + scaled(y)
10: ireturn // pop an int and return it
Five opcodes explain most of what you'll ever read: aload_0 loads this, getfield/putfield read and write instance fields, invokevirtual calls an instance method with dynamic dispatch (invokespecial covers constructors, private methods and super calls; invokestatic handles statics; invokedynamic is what lambdas compile to), and ireturn/areturn hand back an int or a reference. The numbers on the left are bytecode offsets — the branch targets of every if.
The class file format
| Section | Contents | Why you'd care |
|---|---|---|
| Magic number | 0xCAFEBABE | First 4 bytes of every .class. Wrong magic → not a class file. |
| Version | minor / major, e.g. 0 / 65 | 65 = Java 21. A newer major is a hard UnsupportedClassVersionError. |
| Constant pool | Every class, field, method, string and number, index-addressed | The symbol table — one copy of each name, everything else is an int index. |
| Access flags | public, final, interface, enum… | Bitmask describing the class itself. |
| This / super / interfaces | Constant-pool indices | The inheritance chain, resolved lazily. |
| Fields & methods | Name, descriptor, attributes | Descriptor (I)I = takes an int, returns an int. |
| Attributes | Code, LineNumberTable, Signature, annotations, BootstrapMethods | Where stack traces get line numbers and lambdas get their links. |
I = int, Z = boolean, Ljava/lang/String; = String. Generics are erased — List<Integer> and List<String> have the same descriptor, which is exactly why Chapter 11's type erasure warnings exist.The constant pool is the file's nervous system: a single table of names, types and literals that every instruction references by index. It's why "hello" appears once no matter how often you use it, why javap -v prints such a verbose table, and why the JVM can link a method reference the first time it's actually executed rather than at load time.
Class loading — seven phases, three loaders
Loading happens lazily: a class is opened when it's first needed, not at startup. Then five more phases run — linking is the umbrella for the middle three:
Class object for them.0, null, false). No user code runs yet.<clinit>: static blocks and static field assignments, exactly once, thread-safe. Now the class is ready.Those last two phases explain classic surprises: a static field is null before initialisation runs (preparation), and a class's static block executes at the moment something first touches the class — not when the program starts.
java.lang, java.util, everything in java.base. Implemented in native code and associated with null as its loader.java.sql, jdk.unsupported…).Thread.currentThread().getContextClassLoader() — what frameworks use to find your classes.The application loader doesn't try your classpath first — it asks the platform loader, which asks the bootstrap loader, and only falls back on the way down. So java.lang.String always comes from the JDK: dropping a forged java/lang/String.class on the classpath is ignored rather than loaded, which is the JVM's original class-spoofing defence. It also keeps one runtime definition per class name. The escape hatch when you need parent-last behaviour (JDBC drivers, SLF4J bindings) is the thread context class loader.
Runtime memory: where things actually live
Thread stack — private, frames, fast
Each method call pushes a frame: local-variable slots, an operand stack (the one aload_0/getfield dance above) and a return address. Size is known at compile time, so a call is just a stack-pointer move.
Heap — shared, GC-managed
Everything a thread can see, in one arena: Eden → Survivor → old gen. The stack holds only references (compressed 4-byte pointers); the objects themselves are here.
Native — Metaspace & code
ByteBufferNot in the Java heap at all — Metaspace replaced PermGen in Java 8 and lives in native memory. Fill the code cache and compiled methods get thrown back to the interpreter.
Key insight: a method's locals are free and precise — the JVM knows exactly where every reference is, so it can trace them as GC roots. An Object on the heap costs an allocation, a header and a garbage-collector visit later.
Object layout is where the cost hides. Every object starts with a header: an 8-byte mark word (hash code, lock bits, GC age) plus a class pointer — compressed to 4 bytes on heaps under 32 GB. Fields follow in declaration order, padded to an 8-byte boundary. That's why Integer in an array is expensive:
int[] primitives = new int[1_000_000]; // ~4 MB, contiguous, SIMD-friendly
Integer[] boxed = new Integer[1_000_000]; // 4 MB of REFERENCES + 1M objects
for (int i = 0; i < boxed.length; i++) {
boxed[i] = primitives[i]; // 1,000,000 allocations, 16 B each ≈ 16 MB
} // plus one pointer chase per element
// The JIT can make allocations disappear — if they never escape:
static long hotLoop(int n) {
long acc = 0;
for (int i = 0; i < n; i++) {
Point p = new Point(i, i + 1); // never leaves this method
acc += p.x(); // → scalar-replaced: NO allocation
}
return acc;
}
// Watch it happen: -XX:+UnlockDiagnosticVMOptions -XX:+PrintEscapeAnalysis
The second half is escape analysis: if the JIT proves an object never escapes the method, it keeps the fields in registers and never allocates it at all — scalar replacement. That's why new Object() in a hot loop can cost nothing, and why an innocent-looking refactor that lets an object escape (return it, store it in a field) can triple your allocation rate overnight.
Garbage collection
The collector never "scans the heap looking for garbage" — it starts from the roots and marks everything reachable: local variables on every thread's stack, static fields, JNI references, and a few runtime internals. Anything unmarked after the trace is unreachable and gets reclaimed, at some point, when the collector runs. Reachability is the only definition of liveness; an object with a non-null reference that no root can reach is still garbage, and an object nobody can reach but that's still linked from a root is very much alive.
Because roughly 98% of objects die before their first birthday, the heap is split: new objects land in Eden, survivors of a young-collection copy between two Survivor spaces while ageing, and anything that survives long enough is promoted to the old generation. That's the generational hypothesis, and it's why minor collections are cheap — most of Eden is already dead, so there's almost nothing to copy.
| Collector | Strategy | Pauses | Reach for it when… |
|---|---|---|---|
Serial | Single-threaded, stop-the-world | Proportional to heap | Small heaps, containers with 1 CPU, toys |
Parallel | Multi-threaded stop-the-world | Tens–hundreds of ms | Batch jobs where throughput beats latency |
G1 | Region-based, incremental mixed GC | Target: ~200 ms | The default since Java 9 — usually just leave it |
ZGC | Concurrent, load barriers (generational since 21) | Sub-millisecond | Latency-sensitive services with large heaps |
Shenandoah | Concurrent mark & compact | Low, predictable | Low-pause OpenJDK workloads; not in every JDK build |
# Setting the heap — the only flags most services ever need
java -Xms4g -Xmx4g -XX:+UseG1GC -Xlog:gc*:file=gc.log MyService
# ^min ^max ^the default since 9 ^everything, timestamped
[1.203s][info][gc] GC(0) Pause Young (Normal) G1 Evacuation Pause
1024M->187M(4096M) 12.4ms
^before->after(heap) ^what you actually care about
# Diagnose before you touch a single tuning flag:
jcmd <pid> GC.heap_info # occupancy by generation
jstat -gc <pid> 1000 # live numbers every second
jcmd <pid> GC.run_histogram # what's holding the heap open
Most production Java services need exactly one memory decision — a sensible -Xmx, ideally with -Xms set to match so the heap never resizes under load — and then nothing else. G1 is the default and is rarely the bottleneck people assume it is.
The order that works: set the heap size → measure with -Xlog:gc* and JFR → identify the actual problem → change one flag → measure again. Choose ZGC because your pause SLO is violated and your logs prove it, not because a blog called it "sub-millisecond". Fix your allocation rate and your leak first; they are almost always the real bug.
Heap too small: the collector runs constantly, spends most of its time in old-gen collections and finally throws OutOfMemoryError: Java heap space — the log shows full GCs reclaiming almost nothing, which means a live-set problem, not a sizing one. Metaspace leak: too many class loaders (typically frameworks loaded in a hot-redeploy loop) exhaust native memory and throw OutOfMemoryError: Metaspace; cap it with -XX:MaxMetaspaceSize=256m so it fails loudly instead of quietly eating the host.
The JIT: interpreter → C1 → C2
HotSpot doesn't compile on startup — it watches. Code begins in the interpreter, and once a method (or a loop, via on-stack replacement — OSR lets a method already executing swap into compiled code mid-loop) accumulates enough invocations, a fast compiler (C1) compiles it while collecting profiles: which branches are taken, which types actually appear, which null checks never fail. When a method proves really hot, C2 recompiles it using those profiles — inlining aggressively, unrolling loops, eliminating the null checks it has evidence for, and vectorising. Roughly: interpreter counts, C1 compiles fast, C2 compiles well.
# Watch compilation happen (JDK 9+; -XX:+PrintCompilation still works)
java -Xlog:jit+compilation:file=jit.log MyService
142 3 4 java.util.HashMap::getNode (39 bytes)
143 4 3 java.util.HashMap::getNode (39 bytes)
901 5 4 java.util.HashMap::getNode (39 bytes) # C2, tier 4
# ...and watch optimisations being made (or undone)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining MyService
# If speculation fails — a branch flips, a class loads — C2's code is
# thrown out and the method drops back to the interpreter: that's the
# deoptimisation cliff, a sudden latency spike right after warm-up.
Two consequences you must design for. First, warm-up: the first seconds of a JVM run are interpreter- and C1-speed, and steady state can be 10–100× faster, which is why a server's early latency numbers lie. Second, the JIT optimises with evidence, and evidence can be invalidated — hence deoptimisation: compiled code is discarded, execution falls back to the interpreter, and later gets recompiled. It's normal, but a spike right after a new class loads or an uncommon branch fires is almost always this.
A hand-rolled loop measures three things that aren't your code: interpreter warm-up, tier transitions, and the JIT's ability to delete the entire benchmark because nothing observable happens to the result. Use JMH — it forks a fresh JVM, warms up properly, consumes values through a Blackhole so dead-code elimination can't cheat, and reports confidence intervals.
plugins {
id 'me.champeau.jmh' version '0.7.2'
}
jmh {
benchmarkMode = ['avgt'] // average time per operation
timeUnit = 'us'
fork = 2 // fresh JVM per run — honest isolation
warmupIterations = 5 // let the JIT reach C2 first
iterations = 5
}
// ./gradlew jmh — never System.currentTimeMillis() in a for-loop.
Kotlin compiles to exactly this
Here's the best news in this chapter: Kotlin targets the same JVM bytecode format, so everything above applies unchanged to Kotlin — the class file layout, the loaders, the heap, the collectors, the JIT, the warm-up. What Kotlin adds is metadata: a kotlin.Metadata annotation on every Kotlin class stores the information that was erased (nullability, value classes, suspend signatures) so kotlinc can restore it when another Kotlin module compiles against it — the JVM never sees it.
cat Greet.kt
fun greet(name: String): String = "Hi, $name"
internal fun helper() = 42 # mangled: helper$main
javap -c -p GreetKt | head -20
public static final java.lang.String greet(java.lang.String);
Code:
0: aload_0
1: ldc #12 // String name
3: invokestatic #19 // Method kotlin/jvm/internal/
// Intrinsics.checkNotNullParameter
6: invokedynamic #26 // makeConcatWithConstants
11: areturn
$ javap -v GreetKt | grep NotNull
RuntimeVisibleAnnotations: 0 org/jetbrains/annotations/NotNull
Two things to read there. Kotlin's non-null parameter compiles to an explicit null-check call in the bytecode (Intrinsics.checkNotNullParameter) — enforced at runtime for Java callers, not merely documented — plus a @NotNull annotation so Java's IDEs and annotation processors see it too. And internal isn't real visibility: it compiles to a public member with the module name mangled onto it (helper$main), because the JVM's access flags have no "internal". Kotlin's privacy is enforced by the compiler, not the VM.
- A
.classfile starts with0xCAFEBABE, then version, constant pool, then fields, methods and attributes. javap -creads bytecode:aload_0=this,getfield/putfield= fields,invokevirtual= dispatch,ireturn/areturn= results.- Loading → verify → prepare → resolve → initialize; a class initialises on first use, not at startup.
- Loaders delegate parent-first — that's why your
java.lang.Stringcan never replace the JDK's. - Stack = frames and primitives, private and free. Heap = shared objects, Eden → Survivor → old gen. Metaspace and the code cache live off-heap.
- GC marks from roots (stack locals, statics, JNI) — reachability, not
null-ness, decides liveness. - G1 is the default and usually correct; measure with
-Xlog:gc*before touching a tuning flag. - Escape analysis can delete allocations — and any microbenchmark you didn't write with JMH.
- Kotlin emits the same bytecode plus
kotlin.Metadataand real null checks — this whole chapter is your chapter too.
Builds, tests & modules
A build tool is what turns a folder of source files into a reproducible artifact — and a test suite is what lets you change that code in a year without fear.
Real Java is never compiled by hand. You declare dependencies, run tests, produce a JAR, and repeat the same steps on your laptop and in CI. Two tools do almost all of this work.
- You want a build script written in code (Kotlin DSL)
- You need custom tasks or non-standard layouts
- The project is Android — Gradle is required there
- You want one standard, predictable lifecycle
- Convention over configuration matters more than flexibility
- The team is large and long-lived
| Maven | Gradle | Plain javac | |
|---|---|---|---|
| Config | pom.xml (XML) | build.gradle.kts (Kotlin/Groovy) | shell script |
| Speed | Good | Faster (incremental, daemon) | Fastest — until you add deps |
| Learning curve | Low, rigid | Higher, flexible | You own everything |
| Best for | Servers, enterprise | Android, complex builds | Learning, tiny scripts |
The standard project layout
hello/
├── pom.xml # Maven: deps, plugins, versions
├── build.gradle.kts # Gradle: the same, in Kotlin
└── src/
├── main/java/com/acme/App.java
└── test/java/com/acme/AppTest.java
That src/test sibling of src/main is the single most valuable convention in the JVM world: tests live in the same package, compile with the same dependencies, and are found automatically.
Maven in one screen
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.acme</groupId>
<artifactId>hello</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
mvn -q test # compile + run all tests
mvn -q package # produce target/hello-1.0.jar
mvn -q verify # tests + integration checks
mvn dependency:tree # who pulled in what, and why
# Gradle equivalents
./gradlew test build dependencies
Maven re-resolves transitive dependency versions on every build, so two machines on the same pom.xml can silently get different jars. Gradle's gradle.lockfile pins the whole graph. For anything long-lived, pin versions explicitly.
Testing with JUnit 5
import static org.junit.jupiter.api.Assertions.*;
class AppTest {
@Test
void adds_two_numbers() {
assertEquals(4, Calculator.add(2, 2));
}
@ParameterizedTest
@CsvSource("0,0", "1,1", "-3,-3")
void identity(int n, int expected) {
assertEquals(expected, Calculator.identity(n));
}
@Test
void rejects_negative() {
assertThrows(IllegalArgumentException.class,
() -> Calculator.factorial(-1));
}
}
Three naming habits pay off immediately: tests describe behaviour (rejects_negative), not implementation (test1); one assertion about one rule; and every test must be able to run alone or in any order. A test that only passes in sequence is a bug report, not a safety net.
Lots of fast unit tests, fewer integration tests, a handful of end-to-end. The expensive mistake is inverting it: a suite of flaky UI tests nobody trusts is worse than no suite, because it trains people to click "re-run" instead of investigating.
The module system (JPMS)
Since Java 9, the JDK itself is modular, and your project can declare what it exposes. This replaces the old "everything on the classpath is public to everyone" model.
module com.acme.hello {
requires java.net.http; // what I use
exports com.acme.hello.api; // what others may see
opens com.acme.hello.model
to com.fasterxml.jackson.databind; // reflection only, for Jackson
}
requires
Declare a dependency on another module. The compiler enforces it — a missing dependency is a build error, not a runtime NoClassDefFoundError.
exports
Make a package accessible to other modules. Unexported packages are invisible — a real API boundary the JVM enforces.
opens
Allow reflective access to a package, optionally only to named modules. This is how Jackson and Spring keep working.
For most applications: not urgently. You get real value from them when you want strong encapsulation (blocks reflection attacks), clean API boundaries, or jlink images that ship only the JDK parts you use. Start on the classpath, adopt modules when a boundary earns its complexity.
What to run before every commit
mvn -q spotless:apply test # or: ./gradlew spotlessApply test
# wire the same line into a git pre-commit hook and into CI
Formatting, then tests, in exactly one command, run identically on your machine and in CI. Automation beats discipline every time — the goal is that "is my code correct?" has the same answer everywhere.
- Maven for convention, Gradle for control. Either is correct; arguing about it is not.
src/testis the convention that makes testing frictionless — keep tests in the matching package.- Name tests after behaviour.
rejects_negativedocuments the contract. - Modules give enforced boundaries. Adopt them when you have a real API to protect.
- Format and test in one command, wired into both pre-commit and CI.
Performance & how Java compares
Java is fast in the way a motorway is fast: not the quickest single lap, but predictable throughput at scale, forever, without a developer stopping to refuel.
The honest framing first. Java will not beat C or Rust on a tight numeric loop, because those languages hand you the CPU with nothing in between. What Java offers instead is a JIT compiler that optimises based on what your program actually does, a collector that removes an entire category of bugs, and identical behaviour on every platform you deploy to.
The warm-up story
Every Java benchmark that ignores warm-up is lying to you. The JVM starts by interpreting, measures which methods are hot, compiles them with C1, then recompiles the very hottest with full profile-guided optimisation. Throughput climbs, then plateaus.
System.nanoTime() in a loopYou'll measure the interpreter, not the language. Use JMH, which handles warm-up, dead-code elimination and blackhole consumption for you. If your numbers came from a stopwatch, they're an anecdote.
Where Java sits
Illustrative normalised figures for teaching, not a published benchmark. Real numbers vary enormously by workload, JDK version and warm-up. The tiering is the point: Java is in the compiled top group, not the interpreted bottom group.
| Dimension | Java | C / C++ / Rust | Go |
|---|---|---|---|
| Peak throughput | Excellent | Best | Good |
| Startup | Slow (JVM + warm-up) | Instant | Fast |
| Memory footprint | Tens of MB | Minimal | Small |
| Pause predictability | Good — G1/ZGC sub-ms | No pauses | STW pauses |
| Scaling cores | Excellent | Excellent | Excellent |
| Deployment | JAR + JRE, identical everywhere | Per-platform binaries | Single static binary |
Kotlin compiles to the same bytecode and runs on the same JVM. A Kotlin for loop over a list is the same code after compilation. The gaps that do exist come from idioms — Kotlin allocates a bit more via extension-heavy stdlib calls and boxed primitives in generics — and they are measured in single-digit percentages, not multiples. Neither language is the reason your service is slow; your design usually is.
What actually makes Java code slow
Blocking on one lock
Threads parked behind a single synchronized block serialise your whole service. Measure lock contention before optimising anything else.
Allocation storms
Creating millions of short-lived objects per second gives the GC constant work. It's survivable — but it's measurable churn. Prefer reuse for hot paths.
Unbounded queues
Memory grows until the process dies. A queue must have a bound and a rejection strategy, or it isn't a queue — it's a slow leak.
Chatty I/O
One round trip per record in a loop. Batching to a database is routinely a 100× win — far larger than any language-level micro-optimisation.
- Measure first. JMH for code, a profiler for services, a flame graph for truth.
- Fix the algorithm. O(n²) → O(n log n) beats any JIT trick available.
- Fix the I/O pattern. Batch, cache, and pipeline before touching arithmetic.
- Then look at allocation and locks — usually together.
- Never hand-optimise readability for an unmeasured guess.
Cold start and memory floor. A minimal JVM wants tens of megabytes and hundreds of milliseconds before your main runs — a poor fit for CLI tools and short-lived serverless functions. This is why Project CRaC (checkpoint/restore), Project Loom and the GraalVM native image path exist. For a server that lives for hours, none of it matters.
What is Kotlin, and why does it exist?
Kotlin is a statically-typed language from JetBrains that compiles to JVM bytecode — designed specifically to remove Java's sharpest edges without leaving the runtime you already run in production.
JetBrains builds IntelliJ IDEA, the IDE most Java developers use. Writing a Java IDE means staring at Java's pain points eight hours a day, for years. Kotlin is what happened when the people best positioned to fix those problems decided to write a new language instead of filing more bug reports.
Kotlin is "Java's runtime and ecosystem, minus the boilerplate, with null safety in the type system." It doesn't compete with the JVM — it competes with the parts of Java you got tired of typing.
What it actually fixes
Null safety
Nullable types are distinct types. String and String? cannot be assigned to each other. The billion-dollar mistake becomes a compile error.
Conciseness
Data classes in one line, default and named arguments, no checked exceptions, no static keyword.
Interop both ways
Call Java from Kotlin and Kotlin from Java, in the same project, file by file. Full binary compatibility with the JVM.
A taste of the difference
public final class User {
private final String name;
private final int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String name() { return name; }
public int age() { return age; }
// equals, hashCode, toString — 30 more
}Every field needs a constructor assignment, an accessor, and three generated methods you shouldn't write by hand.
data class User(val name: String, val age: Int)
fun main() {
val u = User("Ashton", 30)
println(u) // User(name=Ashton, age=30)
}data generates equals, hashCode, toString, and component/copy functions. The constructor is the field list.
A short history
Project begins at JetBrains
The team behind IntelliJ starts a language internally, codenamed "JetBrains Language" — later a pun on Kotlin Island near St. Petersburg.
Kotlin 1.0
Stable language and standard library. The compatibility promise begins: 1.x code keeps compiling across minor releases.
Google backs it for Android
Kotlin becomes a first-class Android language alongside Java. Open-sourced on GitHub with Apache 2.0.
Declared the preferred language
Google announces Android development with Kotlin first. Adoption accelerates sharply.
Kotlin Multiplatform
Shared logic across iOS, Android, desktop and web backends. The compiler is rewritten in Kotlin itself (K2), dramatically faster.
A top-tier JVM language
Used on Android, Spring Boot, Ktor, Gradle build scripts, and server-side services — anywhere Java runs, plus places Java doesn't.
| Property | Java | Kotlin |
|---|---|---|
| Null handling | Any ref can be null | Types enforce it |
| Classes final by default | No — inherit freely | Yes — opt in with open |
| Data holders | Record, or boilerplate | data class |
| Checked exceptions | Yes | None |
| Concise null coalescing | Optional | ?: elvis operator |
| Coroutines | Virtual threads (Loom) | Built into the language |
| Compiles to | Identical JVM bytecode — same runtime, same performance profile | |
No. Kotlin and Java coexist in one Gradle project — even in one package. Teams migrate file by file: a Kotlin data class can be a Java method's return type, and Java can call Kotlin extension functions normally. There's no flag to flip, no rewrite to schedule.
Cold start and memory footprint — Kotlin's stdlib is a real dependency. Also: some build tooling and framework examples are Java-first, and language features arrive in Java first and Kotlin later (records arrived in Kotlin as data classes years earlier, but pattern matching landed Java-first). Pick per project, not by ideology.
- Kotlin runs on the JVM — everything from chapter 16 applies unchanged.
- It was built by the IDE authors who use Java all day, to remove daily friction.
- Null safety and data classes are the two changes you'll feel within an hour.
- Interop is free — migrate incrementally, never as a big-bang rewrite.
Kotlin basics: variables, types & functions
Kotlin has two kinds of variable, almost no boilerplate, and functions that can return values directly from an expression — the syntax habits that make the rest of Part II feel fast.
Coming from Java, the first thing you notice is that almost everything is shorter. The second thing you notice is that the compiler refuses more of what you write. Both are deliberate.
val and var
Every variable declares up front whether it can be reassigned. There is no final keyword — it's part of the declaration.
fun main() {
val score = 10 // read-only binding — cannot be reassigned
var count = 0 // mutable
count += 1
// score = 20 // ❌ compile error: val cannot be reassigned
println("$score, $count") // 10, 1
println("sum = ${score + count}") // ${} for expressions
}
val when…- It's a constant or the value never changes
- You want to prove a value is stable
- Ninety percent of declarations — this is the default
var when…- You're accumulating into a counter or buffer
- The value genuinely mutates during a scope
- A loop variable that changes each iteration
val ≠ immutable objectval means the binding can't change, not the value. val list = mutableListOf(1) then list.add(2) is perfectly legal. Immutability of contents needs an immutable type — listOf(1). Java's final has exactly the same trap.
Type inference
val a = 10 // inferred Int
val b = 10L // inferred Long
val pi = 3.14 // inferred Double
val msg: String = "hi" // explicit when it helps
val n: Int? = null // nullable — must be declared as such
fun add(x: Int, y: Int): Int = x + y // return type on a single expression fun
The rule: Kotlin infers local variable types but always requires function return types on public functions. That's a deliberate readability choice — you shouldn't have to read a function body to know what it returns.
| Java | Kotlin | Notes |
|---|---|---|
int, long | Int, Long | Objects, but boxed only when nullable |
char | Char | Single character |
boolean | Boolean | |
float | Float | Declare explicitly; default is Double |
char[] | CharArray | Primitive arrays exist |
final int X = 5; | const val X = 5 | Compile-time constant, top-level or in an object |
Kotlin won't quietly turn an Int into a Long: val x: Long = 5 works for literals only. For a variable you must call .toLong(). Same for Int → Double (.toDouble()). It's more typing than Java's widening rules, and it stops a whole class of silent precision bugs.
Functions as expressions
// Single-expression functions: the return keyword is optional
fun square(n: Int): Int = n * n
// Block body: return required
fun describe(n: Int): String {
return if (n > 0) "positive" else "non-positive"
}
// Default and named arguments — replaces most overloading
fun greet(name: String, greeting: String = "Hello") =
"$greeting, $name!"
greet("Ashton") // "Hello, Ashton!"
greet("Ashton", greeting = "Yo") // named argument
greet(greeting = "Hi", name = "Sam") // order doesn't matter
// varargs
fun sum(vararg nums: Int): Int = nums.sum()
Named arguments
Pass parameters by name and skip defaults. In Java you'd need four overloads or a builder; in Kotlin it's one function.
String templates
"$name" and "${expr}" interpolate directly. No + chains, no String.format.
valby default,varonly when reassignment is real.- Type inference is local — public functions declare return types.
- No implicit widening — convert explicitly with
.toLong(). - Default and named arguments remove most of Java's overloading.
- String templates make concatenation a formatting problem, not a builder problem.
Null safety: the billion-dollar fix
Java's null is the value that can appear anywhere, satisfies no type contract, and crashes billions of dollars of software a year. Kotlin moves it into the type system — String and String? are different types.
Java can't tell the difference between a variable that holds a string and one that might hold null. Kotlin can, because it requires you to say: ? marks a type as nullable, and the compiler then enforces every path.
String name = user.getName();
int len = name.length(); // compiles fine
// NPE at 3am if getName() returned null
// Java 8+ workaround
String safe = Optional.ofNullable(user.getName()).orElse("anon");Nothing in the signature tells you nullability. Every call is a bet.
var name: String = user.name
name.length // ✅ String is never null — safe by construction
var maybe: String? = user.email
maybe.length // ❌ compile error: only safe calls allowed
maybe?.length // ✅ Int? — result may be null, type says soThe type system rejects the bug before it exists. Nullability is in the API.
The four operators
fun describe(user: User?): String {
// 1. Safe call — ?. returns null if the receiver is null
val city: String? = user?.address?.city
// chains freely; the whole chain is null if ANY link is null
// 2. Elvis — ?: give a default when the left side is null
val name: String = user?.name ?: "anonymous"
// 3. Not-null assertion — !! "I guarantee this is non-null"
val forced: String = user!!.name // throws NPE if user is null
// 4. Smart cast — compiler narrows after a null check
if (user != null) {
return user.name // no !!, no ?., no cast
}
return "anonymous"
}
| Operator | Reads as | Result |
|---|---|---|
?.name | "call if non-null" | String? — null if receiver was null |
?: "x" | "or else" | Non-null — the fallback guarantees it |
!!.name | "must be non-null" | Non-null type, but throws at runtime |
let { } | "if non-null, do this" | Runs block only for non-null |
?.also { } | "if non-null, side effect" | Used for logging nulls too |
data class Address(val city: String?)
data class User(val name: String, val address: Address?)
fun main() {
val u: User? = User("Ada", Address("London"))
// Safe call chain: any null link short-circuits to null
val city: String? = u?.address?.city
println(city) // London
// Elvis at the end: give the whole chain a default
val shown: String = u?.address?.city ?: "unknown"
println(shown) // unknown when u or address is null
// let: act only inside the non-null branch
u?.let {
println("hello ${it.name}")
}
// let + ?. : handle the null case explicitly
val greeting = u?.name?.let { "hi $it" } ?: "who?"
// ?: with a lazy default — don't build the expensive value unless needed
val config = loadConfig() ?: defaultConfig()
// Chain Elvis operators for layered fallbacks
val theme = prefs.theme ?: env.theme ?: "dark"
}
!! is a deferred crashuser!!.name is exactly the Java NullPointerException you came to Kotlin to escape — just with a different syntax. It's occasionally justified at a boundary where you've already proven non-null, but a codebase full of !! is a Java codebase with better formatting. Search for !! before merging any Kotlin PR.
True, but far less often. The real sources are: platform types from Java (String! — see below), !!, lateinit before assignment, and unboxing a null in an unexpected place. Eliminate the first two and NPEs stop being a normal part of your logs.
Platform types: the seam with Java
When Kotlin reads a Java declaration it can't know if it returns null. So it uses a platform type, written String! — "unknown, could be either". Kotlin lets you treat it as non-null or nullable.
// Java: public String getName() { return maybeNull; }
val a: String = javaObj.name // compiles — you promised it's non-null
val b: String? = javaObj.name // compiles — you're being cautious
val c = javaObj.name // platform type String!
// Rule of thumb:
// Java API you don't control → treat as String? (defensive)
// Java API you've verified → String is fine
// Your own Kotlin called from Java → declare ? or non-null explicitly
Add JetBrains' @Nullable/@NotNull (or JSR-305, or javax.annotation) to your Java APIs. The moment you do, Kotlin's compiler enforces null-safety across the boundary. This is how a Java/Kotlin codebase gets gradually safer rather than staying as weak as its Java half.
lateinit
Defer non-null initialization — but only for non-primitive types, and it throws UninitializedPropertyAccessException if you race ahead.
by lazy
Non-null and computed once on first access. Safer than lateinit when you can compute it from something already available.
::x.isInitialized
Check a lateinit before use — the only way to avoid the exception, and a sign the ordering is unclear.
class Session {
lateinit var user: User // injected later — throws if read too early
val config: Config by lazy { // built on first use, then cached
Config.load() // expensive — but only if someone asks
}
fun ready() = ::user.isInitialized
}
// Optional wrapper — Kotlin's answer to java.util.Optional
val maybeName: String? = findName() // preferred for nullable
val boxed: Optional<String> = Optional.of("hi") // only when you need Optional's API
?is part of the type —String?andStringdon't interconvert.?. + ?:covers most null handling in one expression.- Smart casts remove the need for
!!after a!= nullcheck. - Avoid
!!— it reintroduces the exact bug you're avoiding. - Platform types (
T!) are the danger zone — annotate Java APIs.
Kotlin classes, data & sealed types
Kotlin's classes are final by default, constructors live in the header, and everything you'd write by hand in Java — equals, copy, destructuring — comes from one modifier.
The Java chapters covered classes, inheritance and data modelling separately. Kotlin folds most of it together: a primary constructor, property declarations in the header, data for value semantics, and sealed for closed type hierarchies.
// Primary constructor lives in the class header
class Person(val name: String, val age: Int) {
// Property with a custom getter — computed, not stored
val isAdult: Boolean
get() = age >= 18
// Secondary constructor must delegate to the primary
constructor(name: String) : this(name, 0)
// init blocks run in declaration order alongside property initializers
init {
require(age >= 0) { "age must not be negative: $age" }
}
// Member function
fun greet() = println("Hi, I'm $name")
}
fun main() {
val p = Person("Ada", 36)
println(p.name) // property, not getName()
println(p.isAdult) // getter runs each access
p.greet()
println(p) // Person(name=Ada, age=36) — toString is generated
}
final by defaultKotlin inverts Java. You must write open class to allow inheritance — so every new hierarchy is a deliberate decision. This is Effective Java's "design for inheritance or forbid it", enforced by the language.
val in the header = read-only propertyclass P(val name: String) generates a private field, a getter, and a constructor parameter — one word doing what Java spreads across four lines. Use var only when mutation is intended.
val is not a deep immutableclass Box(val items: MutableList<String>) can't have its items field reassigned, but the list itself is fully mutable. Same rule as Java's final from chapter 03: the binding is fixed, the contents may not be. Use listOf and Map for real immutability.
Data classes
One keyword generates the constructor, equals, hashCode, toString, copy and positional destructuring.
data class User(val name: String, val age: Int, val city: String?)
fun main() {
val a = User("Ada", 36, "London")
val b = a.copy(age = 37) // named args — partial update in one line
println(a) // User(name=Ada, age=36, city=London)
println(a == b) // false — structural equality
println(a.equals(b)) // same as ==
// Destructuring: component1() = name, component2() = age, ...
val (name, age, city) = a
println("$name is $age in $city")
// Component access without naming
println(a.component2()) // 36
// Works in maps for small records
val (_, ageOnly) = a
}
Generated by data | Not generated |
|---|---|
equals / hashCode on all constructor properties | Mutable state (properties should be val) |
toString: User(name=…) | Validation beyond your own init |
copy with named arguments | Anything non-constructor (put it in the body) |
componentN for destructuring | Superclass equality (the parent isn't included) |
data class Order(val id: String, val status: String) with var status is legal and common — but then hashCode changes while the object is in a HashSet, corrupting it. Data classes are for value semantics: if two instances with the same fields are interchangeable, use data with val. Identity-based things (entities with an ID) should be plain classes.
Sealed hierarchies
// Sealed: every subtype is known at compile time (same package/module, Kotlin 1.5+)
sealed interface Result<out T>
data class Ok<T>(val value: T) : Result<T>
data class Err(val message: String, val cause: Throwable?) : Result<Nothing>
fun <T> compute(x: T): Result<T> = when {
x == null -> Err("null input", null)
else -> Ok(x)
}
// Exhaustive when — add a subtype and THIS becomes a compile error
fun describe(r: Result<*>): String = when (r) {
is Ok -> "ok: ${r.value}"
is Err -> "failed: ${r.message}"
// no else needed — the compiler proved it's exhaustive
}
// when as an expression, with smart casts inside
val (label, detail) = when (val r = compute(41)) {
is Ok -> "success" to r.value.toString()
is Err -> "error" to r.message
}
Sealed
Subtypes must be declared in the same package/module. Gives exhaustive matching — the compiler checks every branch.
open
Inheritance permitted, any subtype anywhere. Use when you expect third parties to extend.
Default (final)
No inheritance. Almost always the right starting point.
An enum is a closed set of instances, each with the same shape. A sealed hierarchy is a closed set of types, each carrying its own data. Enums are for RED/GREEN/BLUE; sealed is for Success/Error/Loading. Kotlin also supports sealed class when you need a real base class with shared state.
Interfaces, delegation & objects
interface Clickable {
fun click() // abstract
fun focus() { println("focused") } // default implementation — no separate class
}
interface Draggable { fun drag() }
// Multiple interface inheritance
class Button : Clickable, Draggable {
override fun click() = println("click")
override fun drag() = println("drag")
}
// by = class delegation: implement by forwarding to another object
class Panel(clickable: Clickable) : Clickable by clickable
// Object declarations: singletons without a static holder
object Json {
fun encode(v: Any): String = """{"v":"$v"}"""
}
Json.encode(42)
// Companion — the Kotlin replacement for static members
class Counter private constructor() {
companion object {
var created = 0
fun create() = Counter().also { created++ }
}
}
Counter.create()
// Named + default args remove most factory methods
data class Request(val url: String, val method: String = "GET", val timeout: Int = 5000)
Request(url = "/users") // GET, 5000
Request("/users", method = "POST") // order irrelevant
open- Final unless
open— inheritance is opt-in. datafor value types, plain classes for identity.sealedgives exhaustivewhen— no silent fallthrough.bydelegates without forwarding boilerplate.- Default and named arguments replace most overloads and factories.
Kotlin collections & sequences
Kotlin's collection API is small, coherent and reads left to right — and its functions take a trailing lambda so pipelines look like sentences rather than nested calls.
The same interfaces you know — List, Set, Map — but with immutable factories as the default and extension functions doing the work that required Collectors in Java.
// Immutable by default — these are the ones to reach for
val fixed = listOf("a", "b", "c")
val noDupes = setOf(1, 1, 2, 3) // [1, 2, 3]
val lookup = mapOf("a" to 1, "b" to 2) // LinkedHashMap, insertion order
val empty = emptyList<String>()
// Mutable when you genuinely need it
val names = mutableListOf("ada")
val seen = mutableSetOf<Int>()
val counts = mutableMapOf<String, Int>()
// Converting
val fromJava = javaList.toKotlinList()
val frozen = names.toList() // snapshot — safe to share
val copy = names.toMutableList() // independent mutable copy
val pairs = mapOf("a" to 1).toMutableMap()
// Ranges and steps — a signature Kotlin feature
val oneToTen = 1..10 // IntRange, inclusive
val down = 10 downTo 1 // 10, 9, …, 1
val evens = 0..100 step 2
val noTop = 0 until 10 // excludes 10
repeat(3) { print(it) } // 012
to and trailing lambdasTwo habits make Kotlin collections read well. "a" to 1 builds a Pair — that's how map literals work. And because a lambda is always the last parameter, you can move it outside the parentheses: list.filter { … } instead of list.filter({ … }). The second one is why Kotlin pipelines look so much cleaner than Java's.
Transforming pipelines
data class User(val name: String, val age: Int, val dept: String)
val users = listOf(
User("Ada", 36, "eng"),
User("Grace", 45, "eng"),
User("Alan", 41, "research"),
User("Katherine", 34, "ops"))
// filter → map → sorted — each line is one clear idea
val engNames = users
.filter { it.dept == "eng" }
.filter { it.age > 40 }
.map { it.name }
.sorted() // [Grace]
// Grouping — one call where Java needed Collectors.groupingBy
val byDept: Map<String, List<User>> = users.groupBy { it.dept }
// Counting, aggregation
val headcount = users.groupingBy { it.dept }.eachCount() // eng=2, …
val avgAge = users.map { it.age }.average() // 39.0
val oldest = users.maxByOrNull { it.age }
val anyEng = users.any { it.dept == "eng" }
val first50 = users.takeWhile { it.age < 40 }
// associate: build a map in one pass
val byName: Map<String, User> = users.associateBy { it.name }
// flatMap: expand each element into many
val allDepts = users.map { it.dept }.toSortedSet()
// fold: the general-purpose accumulator
val shout = users.fold(StringBuilder()) { acc, u ->
acc.append(u.name.uppercase()).append(' ')
}.toString().trim()
| Task | Kotlin | Java streams equivalent |
|---|---|---|
| Keep matching | filter { } | .filter(u -> …) |
| Transform | map { } | .map(u -> …) |
| Group | groupBy { } | Collectors.groupingBy |
| Frequencies | groupingBy{ }.eachCount() | groupingBy(…, counting()) |
| Sort | sortedBy { } | .sorted(Comparator.comparing…) |
| First match | firstOrNull { } | .findFirst() + Optional |
| Single match | singleOrNull { } | .filter(…).findAny() |
| Reduce | fold / reduce | .reduce((a,b) -> …) |
| Flatten nested | flatMap { } | .flatMap(u -> …) |
| Partition | partition { } | two filters (or a loop) |
filter on a Map keeps entriesmap.filter { it.value > 0 } works, but map.filterKeys, filterValues and filterNot are clearer. Also: firstOrNull { } returns a String? — chaining ?.name after it is the idiomatic null-safe pattern, not a sign something went wrong.
val out = mutableListOf<String>()
for (u in users) {
if (u.dept == "eng" && u.age > 40) {
out.add(u.name.uppercase())
}
}
out.sort()Three mutable statements and a loop body holding two conditions — every one of which can drift out of sync with the requirements.
val out = users
.filter { it.dept == "eng" && it.age > 40 }
.map { it.name.uppercase() }
.sorted()Reads as a spec, allocates nothing mutable, and stays correct when the filter changes.
Sequences — when to stop allocating
A Iterable pipeline runs each stage over the whole collection: filter every element, then map every survivor, then sort. A Sequence does one element at a time — lazy, and often with far less garbage.
// List: 5 intermediate lists created
val a = (1..1_000_000)
.map { it * 2 } // allocates a full list
.filter { it % 3 == 0 } // another
.take(3)
.toList()
// Sequence: lazy, one pass, stops early
val b = (1..1_000_000)
.asSequence()
.map { it * 2 }
.filter { it % 3 == 0 }
.take(3)
.toList() // terminal — pipeline runs here, and only until 3 found
// generateSequence: infinite sources, stopped by take/first
val fibs = generateSequence(Pair(0, 1)) { (a, b) -> Pair(b, a + b) }
.map { it.first }
val first10 = fibs.take(10).toList()
// sequenceOf for a small literal
sequenceOf(1, 2, 3).map { it + 1 }.toList()
Small lists → list
Under a few thousand elements, the Iterable version is simpler and fast enough. Overhead of laziness isn't free.
Long pipelines → sequence
Four or more intermediate stages, or huge inputs — sequences avoid creating a list per stage.
Infinite / early-exit → sequence
generateSequence plus take/first can search a space that never ends.
- Start with immutable factories —
listOf/setOf/mapOf; opt intomutable…. - Trailing lambda syntax makes pipelines read top to bottom.
groupBy+eachCount()replaces most manual map-building loops.firstOrNullreturnsT?— chain?.…and?:, don't assert.- Reach for a sequence when you're allocating a list per stage or reading lazily.
Functional Kotlin: scoping, scope & contracts
Kotlin has first-class functions, so the standard library can define the control-flow patterns Java libraries had to invent interfaces for — let, run, apply, also, and one crucial difference: it.
Functions are values: assign them, pass them, return them, call them with (…). The consequence is a set of scope functions that every Kotlin codebase uses constantly — and argues about occasionally.
val double: (Int) -> Int = { it * 2 }
val sum: (Int, Int) -> Int = { a, b -> a + b }
fun apply(f: (Int) -> Int, v: Int) = f(v) // functions as parameters
fun makeDoubler(): (Int) -> Int = { it * 2 } // and as return values
println(apply(double, 21)) // 42
println(listOf(1, 2, 3).map(double)) // [2, 4, 6]
// Local functions — name a chunk of logic without a class
fun process(input: String): Int {
fun clean(s: String) = s.trim().lowercase()
fun score(s: String) = s.length * 2
return score(clean(input))
}
// Extension functions — add to types you don't own
fun String.wordCount(): Int = trim().split(Regex("\\s+")).size
"the quick brown fox".wordCount() // 4
// Infix — for single-parameter functions that read as an operation
infix fun Int.to(other: String): String = "$this=$other"
1 to "one"
// Operator overloading via conventions
data class Money(val cents: Long) {
operator fun plus(o: Money) = Money(cents + o.cents)
operator fun get(i: Int) = if (i == 0) cents else 0
}
Money(100) + Money(50) // Money(150)
A lambda with a receiver — T.() -> Unit — runs with this bound to T, so you call its members without a prefix. It's how buildString { append("a") }, with(user) { name } and every Kotlin DSL work: the block looks like the body of a method on that object.
The four scope functions
val user: User? = findUser()
// let — NULL CHECK + transform; return is the lambda result; receiver = it
val greeting = user?.let { "hi ${it.name}" } // String?
user?.let { log.info("found ${it.name}") }
// with — group operations on one object; receiver = this; returns result
val text = with(StringBuilder()) {
append("line1")
append("line2")
toString()
}
// apply — CONFIGURE a fresh object; receiver = this; returns the object
val request = Request().apply {
method = "POST"
url = "/users"
header("X-Trace", "abc")
} // returns request
// also — SIDE EFFECT that doesn't change the flow; receiver = it; returns object
val users = mutableListOf<User>()
.also { log.debug("created, size=${it.size}") }
.apply { add(User("Ada")) }
// run — set up + compute; combines apply's receiver with let's return
val ports = run {
val s = Socket()
s.bind()
s.localPort
}
| Function | Receiver | Arg | Returns | Reach for it when… |
|---|---|---|---|---|
let | it | — | Lambda result | Null-checking, one-off transform |
run | this | — | Lambda result | Compute something from an object |
with | this | Object | Lambda result | Several calls on one non-null object |
apply | this | — | The receiver | Configuring a newly built object |
also | it | — | The receiver | Logging, side effects, no change to flow |
takeIf | it | Predicate | Receiver or null | Conditional filtering that returns the value |
val x = user
?.also { println(it.name) }
?.let { transform(it) }
?.apply { normalize() }
?.run { format() }
?: default()
// ...every step returns something different,
// and nobody on the team can say which is whichFive scope functions in one expression is a puzzle, not code. Each has a job — chaining all of them blurs the job away.
val target = user ?: return default()
log.debug("loaded ${target.name}") // side effect — plain code
val normalized = transform(target) // transform — a named function
val payload = normalize(normalized).apply {
this.id = requireId() // configure — apply returns receiver
}Reads straight down. The scope functions are where they belong, not stacked as a single unreadable line.
it shadowing trapNested let blocks each introduce their own it — inside two levels, it refers to the innermost. Give the outer one a name: user?.let { outer -> … inner?.let { … } }. Similarly, also/let use it while apply/run/with use this — picking wrong means you can't see the object.
Kotlin's contract DSL lets a function declare effects, enabling smart casts the compiler couldn't otherwise prove. That's why require(x != null), check, also and let unlock smart casting — and it's how you make your own wrappers safe too:
import kotlin.contracts.*
fun <T> ensureNotNull(value: T?): T {
contract {
returns() implies (value != null) // declare the effect
}
return requireNotNull(value)
}
fun handle(raw: String?) {
val v = ensureNotNull(raw)
println(v.length) // ✅ smart cast — compiler trusts the contract
}
// Without the contract: "Smart cast to 'String' is impossible,
// because 'raw' is a function parameter that could change."
Higher-order functions
let, run, apply, also, with are ordinary stdlib functions taking lambdas — no magic.
Extensions
Add methods to any type without inheriting or wrapping. Compile to static calls — no runtime cost.
Contracts
A function can state its effects so the compiler can smart-cast on your behalf.
The rule the Kotlin style guide gives: use let for nullable transforms, apply for configuration, also for side effects, run/with for grouped computation — and if you need a comment to explain which is which, write plain code instead. Scope functions are a readability tool; they fail at their only job when they reduce readability.
- Functions are values — pass them, return them, store them in
val. - Extensions add behaviour without ownership of the type.
apply→ returns the object;let→ returns the block's value.- Rename nested
it—let { outer -> … }when nesting. - Contracts unlock smart casts in your own wrapper functions.
Coroutines: concurrency without threads
A coroutine is a function you can suspend and resume. Thousands can be in flight while a handful of threads serve them — which is why Kotlin treats concurrency as a language feature rather than an API.
Java's answer to this problem arrived as virtual threads (Project Loom, chapter 15). Kotlin's arrived a decade earlier, in the language itself: suspend marks a function that can pause, and the compiler rewrites its body into a state machine.
suspend
The function may suspend without blocking. It can only be called from a coroutine — the compiler enforces this.
Builder
launch, async, runBlocking create a CoroutineScope and start work.
Dispatcher
Decides which thread resumes the coroutine: Default, IO, Dispatchers.Main.
import kotlinx.coroutines.*
suspend fun fetchUser(id: String): User {
delay(200) // suspends, does NOT block the thread
return User(id, "Ada")
}
fun main() = runBlocking { // bridges sync world → coroutine world
println("before")
val job = launch { // fire and forget — returns Job
val u = fetchUser("u1")
println("got ${u.name}")
}
val deferred: Deferred<String> = async { // returns a value
fetchUser("u2").name
}
job.join() // wait for that one
println("name=${deferred.await()}") // get that one
println("after")
}
// Each waiting request holds a whole OS thread
ExecutorService pool = Executors.newFixedThreadPool(200);
pool.submit(() -> {
User u = http.get("/users/1"); // thread sits idle here
DB.write(u); // ...and here
}); // 200 threads, mostly asleepThreads cost ~1 MB of stack each and are expensive to schedule. Thread-per-request caps you in the low thousands.
coroutineScope {
val u = fetchUser("u1") // suspends — thread goes to do other work
db.write(u) // resumes later, maybe on another thread
}
// 16 threads can serve hundreds of thousands of in-flight callsThe thread is released the moment the coroutine waits. Concurrency becomes a count of logical tasks, not OS threads.
suspend actually compiles toThe compiler transforms the function body into a state machine: each suspension point is a label, and the Continuation object carries local variables across the pause. Nothing magical runs on another thread by itself — that's the dispatcher's job. It also means a coroutine's locals live in a heap object, not on a stack frame, which is exactly how virtual threads do it too.
Structured concurrency: scopes
Every coroutine belongs to a scope. When the scope ends, its children are cancelled. This single rule is what stops leaks and orphaned work — the failure mode of raw thread management.
import kotlinx.coroutines.*
suspend fun loadDashboard(): Dashboard = coroutineScope {
// Concurrent — total time = slowest child, not the sum
val user = async { fetchUser("u1") }
val posts = async { fetchPosts("u1") }
val alerts = async { fetchAlerts("u1") }
// If ANY child throws, the scope cancels the rest and rethrows.
Dashboard(user.await(), posts.await(), alerts.await())
}
// Lifecycle scopes in an app
class ViewModel : CoroutineScope {
private val job = SupervisorJob()
override val coroutineContext = Dispatchers.Main + job
fun refresh() = launch {
val data = withContext(Dispatchers.IO) { // hop to a blocking-friendly pool
repository.load()
}
ui.render(data) // back on Main
}
fun onCleared() { job.cancel() } // cancels every child
}
| Builder | Returns | Cancels siblings? | Use |
|---|---|---|---|
launch | Job | — | Fire-and-forget work |
async | Deferred<T> | Yes, on failure | Value you'll await |
runBlocking | T | — | Bridge at the edge only — tests, main |
withContext | T | — | Switch dispatcher, same logical task |
coroutineScope | T | On failure | Parallel children inside one function |
supervisorScope | T | No | Children fail independently |
runBlocking blocks the calling threadIt exists to run suspending code from ordinary synchronous code — fun main() and tests. Putting it inside a service method means every caller waits, defeating the whole design. In a library or framework entry point, use CoroutineScope(...).launch or a framework-provided scope instead.
A coroutine only stops where it checks: inside delay, withContext, or a suspending collection. A tight while (true) loop never checks, so cancel() does nothing. Call ensureActive() or use yield() in your own loops — and never swallow CancellationException.
suspend fun work(items: Flow<Item>) = coroutineScope {
launch {
try {
items.collect { process(it) }
} catch (e: CancellationException) {
throw e // ✅ always rethrow — it means "stop"
} catch (e: Exception) {
report(e) // real failure — handle it
}
}
}
suspend fun riskyLoop() {
while (true) {
ensureActive() // ❌ without this: cancellation ignored
step()
}
}
// Supervisor: children don't take each other down
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
scope.launch { fetchUser("a") } // if this fails…
scope.launch { fetchPosts("b") } // …this keeps running
Dispatchers.Default
CPU-bound work — parsing, sorting, computation. Bounded by core count.
Dispatchers.IO
Blocking I/O — JDBC, file reads, HTTP without a suspending client. Shares a pool with Default.
Dispatchers.Main
The UI thread. Android and Compose render here; never block it.
There's still a thread under every resumed coroutine — the dispatcher decides which one. Coroutines change the unit of concurrency from "thread I hold" to "task I'm running", so you stop paying for idle threads. Blocking a dispatcher thread with Thread.sleep or JDBC is still a bug: it stalls every coroutine scheduled behind you.
suspend= may pause; the compiler turns the body into a state machine.asyncfor values,launchfor effects;await()to collect.- Scopes own children — structure beats manual cancellation.
- Pick the dispatcher: Default for CPU, IO for blocking, Main for UI.
- Always rethrow
CancellationException.
Flow: reactive streams on coroutines
A Flow is a cold, suspending sequence of values — Java's Stream crossed with Publisher, but with none of the ceremony and backpressure handled by default.
Two shapes cover almost everything: Flow for an ordered sequence of values, and StateFlow/SharedFlow for a current value that new subscribers can observe. Both are built on coroutines, so every rule from chapter 25 applies.
Cold
Nothing runs until collected, and each collector gets its own fresh run. A flow isn't "something happening" — it's a recipe.
Backpressured
Collection pace drives production. A slow collector slows the producer instead of overflowing a buffer.
Structured
Cancelling the collecting coroutine cancels the producer. No orphaned subscriptions.
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
// Building a flow — suspending, one value at a time
fun numbers(): Flow<Int> = flow {
for (i in 1..5) {
delay(100) // suspends — not blocking
emit(i) // hand a value to the collector
}
}
suspend fun main() = runBlocking {
// Nothing happens until collect
numbers().collect { value -> print("$value ") } // 1 2 3 4 5
// Transform operators — same shape as sequences
numbers()
.filter { it % 2 == 1 }
.map { it * it }
.take(2)
.collect { println(it) } // 1, 9
// Terminal with a result
val sum = numbers().reduce { a, b -> a + b }
val first = numbers().first()
val list = numbers().toList()
}
emit only inside its own flow { }Calling emit from a nested lambda or another coroutine throws IllegalStateException. flow { } gives you a scope tied to the collector — that's what makes backpressure and cancellation work. For multiple sources, use channelFlow.
data class Tick(val seq: Long, val price: Double)
fun ticks(): Flow<Tick> = /* socket feed */ emptyFlow()
suspend fun main() = runBlocking {
// Intermediate — lazy, runs per value
ticks()
.filter { it.price > 100.0 }
.distinctUntilChanged() // suppress repeats — essential for signals
.debounce(250) // wait for a pause in a bursty stream
.onEach { audit.log(it) } // side effect without changing values
.collect { println(it) }
// Combining and flattening
val a: Flow<Int> = flowOf(1, 2, 3)
val b: Flow<String> = flowOf("x", "y")
a.zip(b) { n, s -> "$n$s" }.collect { print("$it ") } // 1x 2y
a.combine(b) { n, s -> "$n$s" } // emits on either
// Merging concurrent sources
merge(flowOf(1, 3), flowOf(2, 4)).collect { print(it) } // interleaved
// Flattening a flow of flows — flatMapConcat vs Latest vs Merge
a.flatMapConcat { n -> flowOf(n, n) }.toList() // sequential, in order
a.flatMapLatest { n -> flowOf(n) }.toList() // newest wins, old cancelled
}
| Operator | Does | Typical use |
|---|---|---|
map / filter | Transform / keep | Everywhere |
distinctUntilChanged | Drops repeats | State, search input, sensors |
debounce | Waits for quiet | Typeahead, resize events |
sample | Takes periodic snapshots | High-frequency telemetry |
take / takeWhile | Stops early | Limits, bounded reads |
onEach / onStart / onCompletion | Side effects, no value change | Logging, metrics, teardown |
catch | Handles upstream exceptions | Fallbacks — not downstream |
zip / combine | Pair up two flows | Joined reads, form validation |
reduce / toList / first | Terminal | End of the pipeline |
catch only sees upstream failuresflow.filter { … }.catch { … } catches exceptions thrown by the producers and operators before it — not by the collector's collect block. That's deliberate: an exception in your UI code shouldn't be silently swallowed by a stream operator. To handle everything, wrap the whole collect.
StateFlow & SharedFlow
import kotlinx.coroutines.flow.*
class SearchViewModel : CoroutineScope {
// StateFlow: always has a value, new collectors get it immediately
private val _query = MutableStateFlow("") // write side
val query: StateFlow<String> = _query.asStateFlow() // read-only exposure
// SharedFlow: multi-subscriber events, no replay by default
private val _events = MutableSharedFlow<UiEvent>()
val events: SharedFlow<UiEvent> = _events.asSharedFlow()
val results: StateFlow<List<Item>> = _query
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { q ->
if (q.isBlank()) flowOf(emptyList()) else search(q)
}
.stateIn(
scope = this,
started = SharingStarted.WhileSubscribed(5_000), // stop 5s after last UI
initialValue = emptyList()
)
fun onQueryChanged(s: String) { _query.value = s }
}
StateFlowA current value with history of exactly one. Always has something to give a new collector, conflates rapid updates, and never emits duplicates. Use for UI state: one screen, one truth.
SharedFlowA stream of events — navigation, toasts, one-shot effects. Configurable replay and buffer. Each subscriber sees what it subscribed to, so an event never happens twice or zero times by accident.
The concepts map closely — Flow ≈ Observable, operators correspond one for one. But Flow is much smaller by design: no Observable/Observer/Scheduler triple, no observeOn, no custom backpressure protocol. Cancellation comes free from coroutines, and the cold/hot distinction is one sentence rather than an operator taxonomy.
Starting launch { flow.collect { … } } to feed a field is a leak waiting to happen — it outlives the UI and ignores lifecycle. stateIn/shareIn bind the collection to a scope with an explicit SharingStarted policy. For Compose, just call collectAsStateWithLifecycle().
- Flows are cold recipes —
collectis what runs them. emitonly in your ownflow { }block.distinctUntilChanged+debouncemake UI input streams sane.StateFlowfor state,SharedFlowfor events.stateInwith a scope instead of an unboundedlaunch.
Java ↔ Kotlin interop, both directions
Kotlin and Java compile to the same bytecode, so interop is free — but each language has rules the other doesn't, and those rules decide whether your mixed codebase is pleasant or cursed.
The single most valuable skill in a mixed project is knowing what each language shows to the other. Kotlin sees Java's types with unknown nullability; Java sees Kotlin's features flattened into JVM constructs it doesn't understand.
| Kotlin feature | What Java sees | Practical consequence |
|---|---|---|
| Top-level functions | FooKt.staticMethod() | Java callers need the FooKt facade class |
object / companion | Singleton INSTANCE / Companion field | Call Json.INSTANCE.encode() or add @JvmStatic |
| Default arguments | Overloads generated with @JvmOverloads | Without it, Java must pass every parameter |
fun f(a: String = "x") | String $default synthetic method | Not directly callable — use @JvmOverloads |
| Named arguments | Nothing | Java has no equivalent — you lose this, not Java |
val property | getName() getter | Reads as a Java bean — good for frameworks |
var property | getX() / setX() | JavaBean convention for free |
| Extension function | Static method with receiver param | s.wordCount() → UtilKt.wordCount(s) |
String? | String (platform type) | Java won't know — annotate with @Nullable |
| Suspending function | Continuation-based method | Ugly to call — wrap it or use runBlocking |
inline function | Inlined at Kotlin call sites only | Body visible to Java as a normal method |
@JvmStatic— a real static method on the enclosing class (companion/object).@JvmOverloads— generates overloads for each defaulted parameter.@JvmField— expose a property as a plain Java field (drops the getter).@JvmName("…")— rename the JVM symbol without touching Kotlin callers.@file:JvmName("Util")— name the top-level facade instead ofUtilKt.@Nullable/@NotNull— from JetBrains or JSR-305, so Kotlin knows.
@file:JvmName("Strings") // Java: Strings.wordCount(...) instead of StringsKt
package com.acme.util
fun wordCount(s: String): Int = s.trim().split(Regex("\\s+")).size
class Greeter {
companion object {
@JvmStatic fun hello(who: String) = "hi $who" // Greeter.hello(...)
fun build(who: String = "world") = "hi $who" // needs @JvmOverloads
@JvmStatic @JvmOverloads
fun shout(who: String = "world") = "HI $who" // ✅ full Java ergonomics
}
@JvmOverloads
constructor(prefix: String = "> ")
@JvmField var label: String = "k" // Java: g.label (no getter)
}
// From Java:
// Strings.wordCount("a b c");
// Greeter.hello("Ada");
// new Greeter(); // thanks to @JvmOverloads
// g.label = "x"; // thanks to @JvmField
// Kotlin: fun findUser(id: String): User? = maybe(id)
User u = UserService.findUser("x"); // no warning
String name = u.getName(); // compiles — NPE later
// Java has no idea the return can be null.Platform types again (chapter 21). The fix lives in the Kotlin declaration, not the Java call.
// Kotlin
@Nullable fun findUser(id: String): User? = maybe(id)
// or, on Java side: @Nullable User u = ... → IDE flags the dereference
// Java 8+ idiomatic handling
Optional.ofNullable(UserService.findUser("x"))
.map(User::getName)
.orElse("anonymous");One annotation restores the guarantee across the boundary — the cheapest safety win in a mixed project.
import com.acme.util.Strings; // file-level @JvmName
import com.acme.model.User;
import com.acme.net.Api;
import kotlin.Unit;
public class Caller {
public static void main(String[] args) throws Exception {
// Top-level Kotlin function → static on the facade
int n = Strings.wordCount("one two three");
// Kotlin object with @JvmStatic → plain static
String hi = Greeter.hello("Ada");
// Lambda → SAM conversion (Kotlin fun interfaces are SAM-friendly)
Runnable r = Greeter::helloRunnable; // or a KotlinInterface { ... }
// Suspending Kotlin function — the awkward one
try {
User u = Api.blockingFetch("u1"); // wrapped in runBlocking in Kotlin
System.out.println(u.getName());
} catch (Exception e) {
System.out.println("failed: " + e.getMessage());
}
}
}
The JVM signature becomes fetch(String, Continuation<? super User>) — technically callable, effectively unreadable. The right answer is to wrap it: expose a blocking method in Kotlin that calls runBlocking { … } (fine for tests and CLIs), or call through Kotlin where the coroutine context already exists.
Declare kotlin("jvm"), keep src/main/kotlin and src/main/java side by side, and let kotlin compile both — it handles the cross-references in both directions. Put new code in kotlin, convert old Java lazily, and never rewrite a file just to "make it Kotlin".
Kotlin → Java
Works out of the box. Annotate nullability and add @JvmStatic/@JvmOverloads where Java ergonomics suffer.
Java → Kotlin
Works out of the box. You lose named arguments and default-argument syntax for plain Java methods.
Same package
Both languages can share one package — no import gymnastics needed for a partial migration.
Kotlin's guarantees stop exactly at the boundary. A Kotlin function returning User? called from Java gives you an unchecked reference; a Java callback that throws sneaks past Kotlin's "no checked exceptions" assumption; and a Kotlin object mutated from Java has the same thread-safety problem it always had. Write the boundary as carefully as the code inside it.
- Same bytecode, different vocabulary — learn what Java sees for each Kotlin feature.
@JvmStatic/@JvmOverloads/@JvmField/@JvmNamefix ergonomics.- Annotate nullability in Kotlin so Java's compiler can help.
- Don't call suspend functions from Java — wrap or stay in Kotlin.
- Migrate file by file; both languages can live in one package.
Kotlin Multiplatform & one codebase
Write shared logic once and compile it to JVM, Android, iOS, JavaScript and native binaries — sharing code, not binaries, so each platform keeps its own UI.
The pitch is narrow and honest: share the logic — networking, models, validation, business rules — while each platform writes its own UI with its own toolkit. Kotlin compiles the same source to each target because there is no shared runtime to port.
Illustrative proportions for teaching, not a measurement — the real split depends heavily on how platform-specific your UI is. The consistent shape is the point: logic goes shared, UI stays native.
Shared by default
commonMain holds everything all targets can compile — models, repositories, view-models, use cases.
expect / actual
Declare a capability in common code; implement it per platform. Compile error if a target is missing.
Targets & source sets
jvm, android, iosArm64, iosSimulatorArm64, js, linuxX64 each get an actual.
plugins {
kotlin("multiplatform") version "2.0.21"
}
kotlin {
jvm() // server, desktop, Android (as a library)
iosArm64() // real device
iosSimulatorArm64() // simulator on Apple Silicon
js(IR) { browser() } // web
linuxX64() // native server / CLI
sourceSets {
commonMain.dependencies { // shared code sees this
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
implementation("io.ktor:ktor-client-core:3.0.1")
}
jvmMain.dependencies { // only the JVM target
implementation("io.ktor:ktor-client-cio:3.0.1")
}
iosMain.dependencies { // Apple targets
implementation("io.ktor:ktor-client-darwin:3.0.1")
}
}
}
// commonMain — declares WHAT, not HOW
expect class Platform() {
val name: String
fun timestamp(): Long
}
expect fun runBlockingCompat(block: suspend () -> T): T
// commonMain — real, shared business logic
class Greeting(private val repo: Repo) {
suspend fun welcome(): String =
"Hello from ${platformName()} at ${now()}"
}
// jvmMain/actual
actual class Platform actual constructor() {
actual val name: String = "JVM ${System.getProperty("java.version")}"
actual fun timestamp(): Long = System.currentTimeMillis()
}
// iosMain/actual
actual class Platform actual constructor() {
actual val name: String = NSProcessInfo.processInfo.operatingSystemVersionString
actual fun timestamp(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
}
expectWhere possible, define an interface in commonMain (interface Clock { fun now(): Long }) and inject the platform implementation. You get dependency injection, testability with fakes, and no compile-time magic. Reserve expect for things only the compiler can wire — like filesystem or crypto primitives.
| Approach | Shares | Trade-off |
|---|---|---|
| Two native apps | Nothing | Doubled logic, two bug backlogs |
| Web-based (React Native / Flutter-ish) | Almost everything | Native feel and APIs need bridging |
| KMP — shared logic | Logic, not UI | Native UI, one rules layer, per-platform view code |
| KMP + Compose Multiplatform | Logic + UI | Shared UI too — desktop/web/Android; iOS support is newer |
commonMain naturally becomes a clean-architecture core: domain models, use cases, and a repository interface; each platform supplies the data source and the view. That's not incidental — it's the same structure you'd want anyway, now enforced by the build. Teams that adopt KMP often fix their layering first and get sharing second.
- Build complexity — Xcode, CocoaPods, Gradle and multiple toolchains in one pipeline.
- Debugging across targets — a bug only reproducing on iOS simulator burns hours.
- Library gaps — a JVM-only dependency forces an
expectwrapper and a second implementation. - Team skills — you now need Swift/UIKit/Compose competence for the UI halves.
- Smaller talent pool than pure Android or iOS hiring.
You will still write per-platform UI — SwiftUI or Compose, navigation glue, platform permissions, notifications. The saving is that the hard-to-keep-in-sync parts (pricing rules, sync logic, parsing, validation) exist once. If your app is mostly UI, KMP saves less than the pitch suggests.
Test once
commonTest runs against every target — your domain logic gets the full matrix without duplicating a single test file.
Ship as a library
Many teams start by publishing an SDK to iOS from a KMP module — the smallest, highest-leverage first step.
Adopt gradually
New feature in commonMain first; leave existing screens alone. Big-bang migrations are how KMP projects fail.
- Share logic, keep native UI — that's the trade being made.
commonMainis the shared module; source sets give per-platform deps.expect/actualfor platform primitives, interfaces + DI for everything else.- Test everything in
commonTestonce and run it everywhere. - Start with an SDK or a feature, not an entire application rewrite.
Compiler errors decoded
Java's and Kotlin's compiler messages are written for people who already know what went wrong. Here are the twelve you'll actually hit, translated into what they mean and what to change.
Every error below is a normal Tuesday, not a sign you're bad at this. The skill isn't memorising messages — it's recognising the shape of the problem: a type that doesn't match, a possibility the compiler can't prove, or a null that was never accounted for.
| Message | Actually means | Fix |
|---|---|---|
incompatible types: String cannot be converted to int | You assigned the wrong type — no implicit conversion | Parse or convert explicitly: Integer.parseInt(s) |
unreported exception; must be caught or declared | A checked exception escaped the method | throws it, or catch where you can handle it |
variable might not have been initialized | A path reaches use before assignment | Assign at declaration, or cover the missing branch |
cannot find symbol: class Foo | Wrong package, missing import, or not on the classpath | Check import, then your dependency |
non-static variable cannot be referenced from a static context | main is static; the field isn't | Make it static, or create an instance first |
method does not override or implement a method from a supertype | @Override on something that isn't a member — usually a typo | Match the signature exactly, or drop the annotation |
ArrayIndexOutOfBoundsException | You touched an index outside 0..length-1 | Loop to < length, not <= length |
ConcurrentModificationException | Structurally changed a collection while iterating | removeIf, Iterator.remove, or build a new list |
Hash invariant violated | An object's hashCode changed while in a HashSet | Make keys immutable (records, or val fields) |
| Kotlin message | Actually means | Fix |
|---|---|---|
Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver | You called a member on T? | ?.foo(), ?: default, or prove non-null with a check |
Type mismatch: inferred type is T? but String was expected | A nullable flowed into a non-null slot | ?: fallback, or change the target type to T? |
Smart cast to 'T' is impossible, because 'x' is a property that has open or custom getter | Compiler can't guarantee the value is unchanged between check and use | Copy to a local val, then check the local |
This declaration needs opt-in. Use @OptIn(ExperimentalApi::class) | API may change in a future release | Add @OptIn and pin the version, or avoid the API |
Suspend function should be called only from a coroutine or another suspend function | suspend called from ordinary code | Wrap in runBlocking, or make the caller suspend |
Variable 'x' must be initialized / lateinit | Read before assignment | Initialise earlier, or use by lazy when it's derivable |
String! platform types... warnings on dereference | Java code above you didn't declare nullability | Treat as nullable, and annotate the Java API |
Too many arguments for 'foo(bar: String)' | Calling a Kotlin function with defaults from Java | Add @JvmOverloads to the Kotlin declaration |
The compiler reports where it failed, not where you went wrong. A "cannot find symbol" three files from your change usually means you renamed something upstream. If the fix feels wrong — adding a cast, loosening a type, deleting an assertion — reread the message and go back one step. The fix that fights the compiler is usually the wrong fix.
// ❌ "incompatible types: int cannot be converted to String"
String s = 42;
// ✅ decide what you meant:
String s = String.valueOf(42); // display
int i = Integer.parseInt(s); // parse
// ❌ "unreported exception IOException"
static void copy(Path a, Path b) { Files.copy(a, b); }
// ✅
static void copy(Path a, Path b) throws IOException { Files.copy(a, b); }
// ❌ "local variables referenced from an inner class must be final or effectively final"
String tag = "x";
Runnable r = () -> System.out.println(tag);
tag = "y"; // ❌ breaks the lambda above
// ✅ capture a fixed copy before mutating:
String frozen = tag;
val name: String? = user?.name
println(name.length) // ❌ safe-call required
// ✅ three legitimate answers to "what if null":
println(name?.length) // pass the nullability along
println(name?.length ?: 0) // choose a default
if (name != null) println(name.length) // narrow first (smart cast)
// ❌ smart cast impossible — open/custom getter
class C { var x: String? = null }
fun f(c: C) { if (c.x != null) c.x.length } // property may change
// ✅ local copy — now immutable, so the compiler trusts it
fun f(c: C) { val v = c.x; if (v != null) v.length }
- Read the whole message — the second line often has the actual hint.
- Find the first error in the list; later ones are frequently cascades.
- Check your imports and package for "cannot find symbol".
- For type errors, write the types down — literally annotate the variables.
- If a fix feels like fighting the compiler, you've misread it. Go back one step.
- Stuck for 15 minutes? Reduce it to the smallest snippet that still fails, then read it aloud.
The single most common Kotlin error is also the single most valuable: it's your program telling you this path can be null and you haven't decided what to do. Every safe (?.) or non-null asserted (!!.) message is a requirement to choose — default, propagate, or assert. Choosing explicitly is exactly what the Java version of this code failed to do.
- Compiler messages describe the failure point, not the bug's origin.
- Checked exception and "might not have initialized" both mean: an unhandled path.
- Kotlin's nullable-receiver errors are the compiler demanding a null decision.
- Smart cast failures → copy to a
val, then check the copy. - If the fix fights the compiler, re-read the message.
Projects & a study plan
Knowing the syntax isn't the same as knowing the language. What makes it stick is a sequence of projects small enough to finish and awkward enough to force the right lessons.
The plan below is ordered so each project needs exactly one thing you haven't built yet. Do them in sequence, ship each one, and don't start the next until the current one runs without a crash.
Java: CLI toolkit
Build a command-line todo and a log analyser. Forces: parsing, StringBuilder, files, records, enums for commands, exceptions for bad input. Milestone: it reads a file, does real work, and prints something useful.
Java: collections & streams
Reimplement both tools using streams and Collectors. Add a JSON-ish report. Forces: grouping, joining, groupingBy, choosing List vs Map. Milestone: no explicit for loops left where a pipeline is clearer.
Java: HTTP service
Expose your data over a tiny REST API with Gradle and JUnit 5. Forces: build tool, tests, threading, a real exception-handling strategy. Milestone: tests run green in CI.
Java: concurrency
Add a fetcher that pulls 50 URLs concurrently with virtual threads, with bounded parallelism and timeouts. Forces: executors, memory model, cancellation, backpressure. Milestone: no thread pool exhaustion under load.
Kotlin: port it
Rebuild the CLI in Kotlin, side by side with the Java version. Forces: val, data classes, null safety, when, extension functions. Milestone: same tests pass against both implementations.
Kotlin: coroutines & Flow
Turn the fetcher into a suspending pipeline with progress events. Forces: scopes, dispatchers, Flow, StateFlow, cancellation. Milestone: cancel it mid-run and it stops cleanly.
Interoperate, then extend
Call the Java library from the Kotlin app (or the reverse) — annotations, @JvmStatic, platform types. Then pick: a KMP SDK, a Gradle plugin, or a bytecode/reflection deep-dive.
Project ideas by skill
- Generics + variance — a type-safe event bus
- Reflection — a mini dependency injector
- Streams — a word-frequency / n-gram generator
- Concurrency — a bounded worker pool with a retry queue
- Flow — a live log tailer with debounce
- Interop — a Kotlin wrapper over a Java HTTP client
Project rules that matter
- Finishable in 2–4 evenings. If it's bigger, cut it.
- Ship it. A small tool someone actually runs beats a large one nobody does.
- Write tests before you refactor. Chapter 17 exists for this.
- Read your own code a week later — that's the real review.
- Explain one concept aloud each week; teaching exposes gaps.
Build four evenings, read one chapter of the JDK/Kotlin docs, review one error from the last week (chapter 29), refactor your previous project for one hour, and explain one thing out loud. Four weeks of that covers far more than a month of passive tutorials, because the bugs you hit are the ones you remember.
| Where you are | Start with | Skip for now |
|---|---|---|
| New to programming | Ch. 01–04, then a tiny CLI | Concurrency, reflection, KMP |
| Know another language | Ch. 03, 05, 07 (Java differs), then Kotlin | Re-reading basic syntax |
| Java dev learning Kotlin | Ch. 19–24 straight through | Ch. 01–16 — you already have it |
| Kotlin dev learning Java | Ch. 05–13, then 14–17 | Null safety re-reads |
| Interview prep | Ch. 08, 09, 11, 14, 16 + 31 | Ch. 30 projects — do those separately |
| Working on a legacy codebase | Ch. 17, 18, 27, 29 | Starting a rewrite |
The single best Java resource is source you already have: open ArrayList, HashMap, Optional, StringJoiner. Read them the way you'd read any unfamiliar codebase — what's the invariant, where's the state, what does the doc comment promise. Kotlin's standard library is on GitHub in the same style. Twenty minutes here teaches more than an hour of API-surface docs.
- Tutorial purgatory — watching someone else build is not building. Close the video after each concept and reproduce it from memory.
- Framework-first — if Spring or Ktor is doing the work, you're learning the framework. Get the language right in plain Java/Kotlin first; the framework becomes trivial after.
- Small, finished projects beat large, abandoned ones — every time.
- Build in order: syntax → collections → build tooling → concurrency → Kotlin → coroutines.
- Port one project both ways to feel Java's and Kotlin's real difference.
- Read library source — you already own the best reference.
- Refactor your own week-old code — it's the cheapest review available.
Cheat sheets & glossary
Everything worth pinning above your desk, in four tables — syntax, collections, concurrency, and the vocabulary that makes the rest of this handbook make sense.
Java — syntax at a glance
| Task | Code |
|---|---|
| Declare a constant | static final int MAX = 100; |
| Infer a local type | var list = new ArrayList<String>(); |
| Record | record Point(int x, int y) {} |
| Sealed interface | sealed interface S permits A, B {} |
| Switch expression | String s = switch (n) { case 1 -> "one"; default -> "many"; }; |
| Pattern match | if (o instanceof String str && !str.isEmpty()) |
| Safe nullable return | Optional<String> find(); |
| Try with resources | try (var in = Files.newInputStream(p)) { … } |
| Method reference | stream.map(User::name) |
| Text block | String sql = """SELECT * FROM t""" |
| Formatted output | System.out.printf("%s: %.2f%n", k, v); |
| Varargs | void log(String... parts) |
Kotlin — syntax at a glance
| Task | Code |
|---|---|
| Immutable / mutable | val x = 1 · var y = 0 |
| Nullable type | val s: String? = null |
| Safe call + default | user?.name ?: "anon" |
| Data class | data class User(val name: String, val age: Int) |
| Sealed hierarchy | sealed interface R + is A -> … in when |
| Exhaustive when | val s = when (r) { is Ok -> "ok"; is Err -> "err" } |
| Function with defaults | fun greet(name: String, hi: String = "Hi") |
| Extension | fun String.words() = split(" ") |
| String template | "${user.name} is ${user.age}" |
| Trailing lambda | list.filter { it.age > 30 } |
| Scope functions | apply → this · let → it · also → it, returns receiver |
| Singleton | object Json { fun enc(v: Any) … } |
| Companion / static | companion object { fun create() … } |
| Suspend + coroutine | suspend fun f() · scope.launch { f() } |
| Delegation | class P(c: Clickable) : Clickable by c |
Collections & concurrency chooser
| You need | Java | Kotlin |
|---|---|---|
| Ordered, indexed | ArrayList | mutableListOf |
| Fixed ordered | List.of | listOf |
| No duplicates | HashSet | setOf |
| Key → value | HashMap | mapOf |
| Sorted / range | TreeMap/TreeSet | sortedMapOf |
| Stack / queue | ArrayDeque | ArrayDeque |
| Group | Collectors.groupingBy | groupBy { } |
| Counts | groupingBy(x, counting()) | groupingBy{ }.eachCount() |
| Lazy pipeline | stream() | asSequence() |
| You're doing | Reach for |
|---|---|
| Fire-and-forget task | Java: executor.submit · Kotlin: launch |
| Task returning a value | Java: CompletableFuture · Kotlin: async |
| Wait for completion | join() / await() |
| Switch thread pool | Java: .supplyAsync(…, pool) · Kotlin: withContext(Dispatchers.IO) |
| CPU-bound work | Dispatchers.Default / common pool |
| Blocking I/O | Dispatchers.IO / cached pool |
| Bound parallelism | Semaphore · Kotlin: Semaphore(n) |
| UI thread | Dispatchers.Main |
| Stream of values | Java: Flow/Reactive · Kotlin: Flow |
| Current UI state | StateFlow + stateIn |
Java: interfaces, not classes · StringBuilder in loops · no double for money · try-with-resources · catch only to handle. Kotlin: val first · ?.… ?: … before !! · data for values, sealed for variants · apply to configure, let to transform · scopes own their children.
Glossary A–Z
throws) or catch. Subclass of Exception that isn't a RuntimeException.Continuation, so it can pause and resume without holding a thread.List<Dog> usable where List<Animal> is expected (? extends T / out T). Safe for reads, unsafe for writes.data modifier: generates equals, hashCode, toString, copy and componentN from the constructor.Default, IO, Main.a ?: b in Kotlin — evaluate b when a is null. The null-coalescing shorthand.switch or when covering every possible case. Enforced by the compiler for enums and sealed types — a missing branch is a build error.Function, Predicate, Runnable).List<String>) checked at compile time. Java's are erased at runtime; Kotlin's are mostly erased too, with specialised inline functions as the exception.module-info.java declaring requires, exports and opens.volatile and synchronized; Kotlin inherits it.Type::method — a lambda whose body is a single existing call. Four shapes: static, bound instance, unbound instance, constructor.T and T? are distinct types, with the compiler enforcing every path. Java approximates it with Optional and annotations.T! — what Kotlin sees for a Java declaration whose nullability isn't annotated. Treat as nullable unless proven otherwise.equals, hashCode and toString.let, run, with, apply, also — each runs a lambda with the receiver bound as this or it.sealed interface in Java, sealed/sealed class in Kotlin. Enables exhaustive matching.try (resource) { … } — automatic close in reverse declaration order, on every exit path.out/covariant (produce), in/contravariant (consume), invariant (both).Official: the JDK release notes (features arrive with clear rationales), kotlinlang.org's idioms page, and Effective Java — still the best distillation of Java judgement. In the code you own: pick one chapter from this handbook and refactor one file against it this week. The handbook's job is done when you stop needing it.
Test yourself
Twenty questions drawn from all thirty-one chapters. Answer each in your head before expanding — the gap between your guess and the explanation is what you still owe yourself a reread on.
Two rules: don't expand a card until you've said an answer out loud, and if you get three in a row right on the first try, skip ahead. Re-reading material you already know is the most common way to make studying feel productive while changing nothing.
17–20: you're ready to build something new. 13–16: revisit the chapters behind your misses. Under 13: chapters 01–16 deserve a second pass — that's normal and not a verdict.
Part 1 — Java fundamentals
1. Why does int[] a = {1,2,3}; int[] b = a; b[0] = 9; change a, while the equivalent with int x = 1 doesn't?
Primitives hold their value directly, so assignment copies the value. An array variable holds a reference to a heap object — assignment copies that address, and both names then point at the same object. This is why chapter 03's rule is "primitives copy, references copy the address".
2. What's wrong with using double for a bank balance?
Binary floating point can't represent most decimal fractions exactly (0.1 + 0.2 == 0.3 is false). Errors accumulate over arithmetic and rounding. Use BigDecimal with an explicit scale, or store integer cents in a long.
3. When is a record the wrong choice for a domain type?
When identity matters more than content. Records have value equality, so two instances with identical components are interchangeable — correct for Money, wrong for an Order identified by its database ID. Mutable entities (JPA-managed, var state) also break the immutability assumption.
4. What does instanceof String s give you over plain instanceof?
A binding: s is declared and narrowed to String inside the block, eliminating the separate cast and the mismatched-cast class of bug. Combined with && guards (instanceof String s && s.length() > 3) and switch patterns with when, it replaces the old cast-then-check dance entirely.
5. Why must desc be declared before a classic switch that assigns it?
Because a switch with fall-through can't guarantee a branch assigns it — the compiler's definite-assignment analysis fails, so desc "might not have been initialized". Switch expressions with -> fix this by requiring exhaustiveness: every path yields a value.
Part 2 — Java in practice
6. You're checking membership of IDs in a list, 100k times. Why is it slow and what's the fix?
List.contains is O(n) — 100k scans over a large list is O(n·m). Build a HashSet of the IDs first; contains is then O(1) and the cost becomes a single pass. Rule of thumb: if you call contains more than once, you wanted a set.
7. Why does calling list.remove(x) inside a for-each loop throw?
The iterator tracks a modification count and fails fast on structural change (ConcurrentModificationException) — a deliberate guard against silently skipping elements, even single-threaded. Use removeIf, an explicit Iterator.remove, or build a new list.
8. What's the difference between orElse and orElseGet?
orElse(T) evaluates its argument eagerly — it's built even when the Optional is present. orElseGet(Supplier) runs only when absent. Use orElseGet for anything with cost (a database hit, an object allocation), and orElseThrow when absence is genuinely exceptional.
9. Why should a Pattern be a static field?
Compilation is the expensive part — parsing and optimising the regex. Creating a new Pattern per call inside a loop repeats that cost for every input. Compile once, reuse the Pattern, create a fresh Matcher per input (Matchers aren't thread-safe; Patterns are).
10. Name two reasons a Java program is slow that no amount of code-level tuning will fix.
The classic pair: chatty I/O (one round trip per record — batching is routinely 100×) and lock contention (threads serialised behind one monitor). Also unbounded queues (memory growth until OOM) and an algorithmic choice like O(n²) where O(n log n) exists. All four are design problems, not syntax problems.
11. What does try-with-resources guarantee that finally often doesn't?
That every resource closes, in reverse declaration order, on every exit path — including when the body throws, and including the case where the first resource's constructor throws and later ones were already opened. Manual finally blocks routinely miss one path; suppressed exceptions tell you about failures during close.
12. What is a functional interface and why does it matter to lambdas?
An interface with exactly one abstract method — the target type the compiler uses to type-check a lambda. Runnable, Comparator, Function<T,R>; your own with @FunctionalInterface to catch accidental extra methods. It's why stream.map(u -> u.name()) compiles without an explicit cast.
Part 3 — Kotlin
13. What's the difference between val x: String? being null and x being an unassigned variable?
They're different states. A declared-but-unassigned local is a definite-assignment error the compiler rejects at use; a nullable String? is assigned, and its value may be null — so every access must be safe (?.), defaulted (?:) or proven non-null. Type nullability and initialisation are independent checks.
14. What does !! do that ? doesn't, and why avoid it?
!! asserts non-nullness: it returns the value as a non-null type, or throws an NPE if it was null — reintroducing the exact runtime failure Kotlin exists to prevent. It's legitimate only where you've already established non-nullness in a way the compiler can't see. A PR full of !! is a Java codebase with different punctuation.
15. data class generates six things. Which of these does it not generate?
It generates equals, hashCode, toString, copy, componentN functions, and the canonical constructor. It does not generate: validation, immutability of nested mutable objects, a no-arg constructor (needed by some serializers), or inclusion of superclass state in equality.
16. Why is a when over a sealed hierarchy better than one over an enum?
Both give exhaustiveness, but sealed variants are types with their own data — each branch can smart-cast and read its own fields (is Ok -> r.value), whereas enum constants are uniform instances. Sealed is for Success/Error/Loading; enums are for RED/GREEN/BLUE.
17. When should a collection pipeline use asSequence() instead of plain filter/map?
When intermediate stages would each allocate a whole list — long pipelines, large inputs, or early termination via take/first. Sequences process one element through the entire chain lazily, so a take(3) after a million-element filter may only touch a few thousand elements. For three stages over fifty items, plain collections are simpler and fast enough.
Part 4 — Concurrency & systems
18. What's the practical difference between launch and async?
launch returns a Job — fire-and-forget, for side effects where you only need to wait or cancel. async returns a Deferred — it represents a value you'll await(). Using launch when you need a result forces an awkward shared variable; using async for pure effects invites you to await work that produces nothing.
19. Why does a while (true) { … } loop inside a coroutine ignore cancellation?
Cancellation is cooperative — it's only checked at suspension points (delay, withContext, suspending collections) or explicit checks. A tight loop never hits one, so cancel() sets the flag and nothing happens. Call ensureActive() or yield() each iteration. And never swallow CancellationException in a catch — rethrow it.
20. A Kotlin function returns User? and Java calls it. What goes wrong?
Java sees a platform type (User!) — nullability isn't visible, so it dereferences without warning and you get a runtime NPE instead of a compile error. Annotate the Kotlin declaration with @Nullable (JetBrains or JSR-305) so Java's compiler and IDE can flag it. This is the boundary where Kotlin's guarantees stop.
- Pick your weakest part and reread only that — chapters 29 and 31 are the fastest repair tools.
- Then build. Chapter 30's plan is the actual curriculum; this handbook is the reference beside it.
- Come back when a bug surprises you. These chapters were written for that moment, not for linear reading.