◆ The complete beginner's handbook

Learn Java & Kotlin from absolute zero

A visual, deep-dive course that takes you from "what is a variable?" all the way to object design, generics, the stream API, virtual threads, null safety, coroutines and Flow — plus honest, measured comparisons against C++, Rust, Go, Python and JavaScript.

32
In-depth chapters, from main() to multiplatform
200+
Annotated, runnable Java & Kotlin examples
50+
Diagrams, charts & visual explainers
2
Languages, one JVM, zero context switching
"Java spent thirty years proving that a virtual machine can run anywhere and a garbage collector can be trusted. Kotlin spent a decade proving you didn't have to pay for that safety in verbosity. Together they compile to the same bytecode — so you choose, per file, whichever language says what you mean more clearly." — why the JVM remains the most battle-tested runtime in production software
Chapter 01

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.

💡 The one-sentence version

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

1991

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.

1995

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.

2004 · Java 5

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.

2006–2010

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.

2014 · Java 8

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.

2017 →

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.

Today

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.

↑ more safety & abstraction
more raw speed →
Performance & hardware control
Safety & abstraction
C
C++
Java
Kotlin
Rust
Go
C#
Python
JavaScript

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":

1 · .java / .kt Your source. javac compiles Java; kotlinc compiles Kotlin. Both produce bytecode — the same format, in the same .class files.
→
2 · .class files Platform-neutral instructions packed into a JAR. This is why a Linux build runs on Windows — the artifact contains no machine code for any particular CPU.
→
3 · Class loader Loads classes into the JVM on demand, in a strict hierarchy (bootstrap → platform → application), verifying bytecode as it enters so corrupt code can't run.
→
4 · JIT compiler The hot parts of your program — the 5% doing 95% of the work — get compiled to native machine code and cached. Everything else is interpreted.
→
5 · GC + threads The garbage collector reclaims memory you can't reach, and the OS schedules your platform threads. You write logic; the runtime does the bookkeeping.
🧠 Why the JIT matters more than you'd think

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.

Hello.java
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:

terminal
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

Android apps (via Kotlin/Java) Banking & trading systems Apache Kafka Apache Spark Elasticsearch Apache Hadoop Spring Boot back ends Microservices at scale Government & enterprise JetBrains IntelliJ IDEA Minecraft Gradle build tool

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

PropertyJavaC / C++GoPython / JS
Memory managementGarbage collectorManual (malloc/free, RAII)Garbage collectorGarbage collector
Null referencesEverywhere — any reference can be nullEverywhere + segfaultsZero-values, no null by defaultnull / undefined
PortabilityBytecode — identical artifactPer-platform rebuildGood (static binaries)Source-level (needs runtime)
Typical speedFast after JIT warm-upFastest tierFast (≈1.5–2.5× C)10–100× slower
Startup timeSlow (JVM boot, JIT warm-up)InstantFastFast
Concurrency modelPlatform threads → virtual threadsOS threads, manualGoroutines (M:N)Event loop / GIL
Learning curveModerate — verbose but regularSteep — pointers & UBGentleGentle
Where it shinesServers, Android, big data, enterpriseGames, embedded, kernelsCLIs, cloud infra, DevOpsScripting, ML, web front end
⚠️ Java's two honest weaknesses

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.

🚫 Myth: "Java is slow"

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.

3B+
devices running Java
1995
first public release
6 mo
modern release cadence
1
JVM runs Java, Kotlin, Scala & more
✅ Before you move on
  • 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.
Chapter 02

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.

terminal
# 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
🧠 Two java on one machine

java -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.

Hello.java
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:

terminal
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:

1 · Hello.java UTF-8 source, one public class per file, package implied by folder.
→
2 · javac Parses, type-checks, resolves symbols — and emits Hello.class: portable JVM bytecode, still no machine code.
→
3 · class loader java boots a JVM, loads your class and verifies the bytecode before a single instruction executes.
→
4 · interpreter → JIT Interpreted first, then the hot path is compiled to native code and cached. main returns, the JVM shuts down.
💡 When the single-file launcher is the right tool

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.

terminal
$ 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.

terminal
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)
ConcernMavenGradlePlain javac
Build filepom.xml — XML, declarativebuild.gradle.kts — Kotlin DSL, imperativeNone — you type it all
LayoutFixed src/main/java → target/Same defaults, overridableWhatever you pass in
DependenciesResolved from the pom, from Maven CentralResolved from the build script, cached in a daemonYou download jars
Reproducible build./mvnw wrapper pins the version./gradlew wrapper ships with the repoDepends on your machine
Best forConvention over configuration; predictable CIFast incremental builds and custom tasksOne file, one experiment
Maven and Gradle are nearly interchangeable from the outside — both compile to the same bytecode. Pick whichever your team already uses and don't litigate it.

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:

terminal
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.

⚠️ The editor bug that eats an afternoon

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:

Greeter.java
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.

✅ What to keep in your fingers
  • One install. JDK 21 on PATH, JAVA_HOME set, java -version and javac -version agreeing.
  • Two ways to run. java File.java for one-file experiments; javac → java for everything with a structure.
  • javap -c is your X-ray. When Java surprises you, read the bytecode — it never lies about what it did.
  • jshell is a scratch pad for expressions, imports and half-built loops. No file, no ceremony.
  • src/main/java + src/test/java from day one, even on a solo project.
  • Flags you'll use: -Xmx for the heap, -jar to launch, -cp for libraries, --enable-preview never in production.
Chapter 03

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.

Primitives.java
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);
    }
}
TypeBitsRange / notes
byte8-128 to 127
short16-32,768 to 32,767
int32±2.1 billion — the usual choice
long64Timestamps, IDs, counters
float32~7 significant digits
double64~15 digits — default for decimals
boolean—true / false only
char16Unicode code unit, e.g. 'A' = 65
⚠️ Floating point is not arithmetic

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.

Money.java
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

Refs.java
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
🚫 Myth: "Java passes objects by reference"

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.

Strings.java
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, StringBuilder, StringBuffer
  • 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 a StringBuilder — the problem is only loops.
text-fu.java
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 typing

Since 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).

8
primitive types
2
floating types
∞
times == misused on String
0
reasons to use double for money
✅ What to remember
  • Primitives copy; references copy the address. That explains all aliasing behaviour.
  • Never use double for money — BigDecimal or integer cents.
  • Compare strings with equals, never ==.
  • Build text in loops with StringBuilder.
  • var is inference, not dynamism — the type never changes.
Chapter 04

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.

Flow.java
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";
⚠️ No fall-through surprises in modern switch

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

❌ Classic statement switch
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.

✅ Switch expression with pattern matching
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.

🧠 Exhaustiveness is a compile error

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-pattern.java
// 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.

Arrays.java
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
⚠️ Array covariance is a trap

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.

Point.java
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]
ClassRecord
Mutable fieldsYesNo — implicitly final
Write equals/hashCode/toStringYes, by handGenerated
ConstructorBy handFrom components
Normal inheritanceYesExtends only Record
Best forBehaviour, identityPlain data
🚫 Myth: "Records replace my domain entities"

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.

✅ What to remember
  • Conditions must be boolean — no if (1), no null/empty truthiness.
  • Prefer -> switch expressions — exhaustive, no fall-through.
  • Pattern matching kills the instanceof-cast dance and can bind when guards.
  • Arrays are fixed-size and covariant — collections for anything that grows.
  • Records are for data, with value equality and no mutability.
Chapter 05

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:

BankAccount.java
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:

1 · allocate & zero The JVM reserves heap space for the whole object — superclass fields included — and sets every field to a language-defined default: 0, 0.0, false, null. For a moment the object exists with none of your invariants intact.
→
2 · initialisers Instance field initialisers and { ... } blocks run in source order, parent first (via its own constructor). This is where private List<X> xs = new ArrayList<>(); happens.
→
3 · constructor body Your code runs with 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.
→
4 · return the reference 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:

ModifierSame classSame packageSubclass, other packageEveryone
privateyesnonono
(package-private) — no keywordyesyesnono
protectedyesyesyesno
publicyesyesyesyes
Defaults matter: fields should be 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.
🧠 Java's protected is wider than you think

In 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.

terminal
# 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.

⚠️ The exact initialisation order (memorise this once)

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.

✓ A getter is nearly always fine
  • It reveals state without offering a way to corrupt it
  • Prefer account.balance() over account.getBalance() — Java has no property syntax, so drop the get
  • Return immutable types (List.copyOf, Instant) or you have merely moved the leak
✓ A setter is justified only when…
  • 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) vs invoice.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.

Widget.java
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
    }
}
🛑 The static-initialiser trap

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..127

On 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));   // true

Values compare with .equals(), always. Note the second trap: two separately-built Strings are equal but not identical — interning is an optimisation, never a semantics.

⚠️ Overriding equals without hashCode is a silent corruption

Put 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 ==.)

BankAccount.java — the works
@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 + "]";
}
🧠 The equals contract, in five lines
  • Reflexive — x.equals(x) is true.
  • Symmetric — x.equals(y) iff y.equals(x); asymmetric implementations break HashSet in 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 equals may ever change, or the object cannot live in a hash collection.
  • Never null — x.equals(null) returns false, never throws.
  • hashCode follows — equal ⇒ equal hashes. Objects.hash allocates an array; on hot paths write 31 * … by hand (the JIT turns 31*x into 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:

Invoice.java
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.

💡 When a record beats a class
  • 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.

BankAccount.kt
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 ==
}
✅ What to remember from Chapter 05
  • A class is a description in a .class file; 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(...) and super(...) must be the first statement.
  • Getters yes, setters rarely — prefer named commands that can maintain several fields at once.
  • static = "no hidden this parameter". Static init runs once, in textual order, under a class-wide lock.
  • == is identity, .equals() is value, and hashCode() must be overridden whenever equals() is.
  • Plain data → record in Java, data class in Kotlin. Immutable object → final fields plus defensive copies.
Chapter 06

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.

Shapes.java
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.

🧠 Why you can't write 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 is not overriding

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:

Lsp.java
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.

🚫 Myth: "Inheritance is for code reuse"

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:

QuestionAbstract classInterface
Instance state?Yes — private fields, real per-object dataNo — only public static final constants
Constructors?Yes — chaining, validation, super callsNone — nothing to instantiate
How many?One — extends is singleMany — implements A, B, C
Adding a method laterBreaks every subclass without itSafe — ship it as a default
Access levelsAll fourMostly public — private helpers only for splitting bodies (Java 9+)
Models"is a" — a family sharing heritage"can do" — a capability or contract
Rule of thumb: interfaces for what a type can do, abstract classes for how much of its implementation you're willing to inherit. Public library code (Spring's 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.

Hello.java
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
    }
}
🧠 How the diamond resolves — in this order
  • 1. The class's own method always wins. A concrete hello() in Employee beats any interface default.
  • 2. The most specific interface wins. If Loud extends Named, Loud's default beats Named'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:

Shape.java
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 subtypeWho may extend it?Typical use
sealedOnly types named in permitsA closed family the compiler can enumerate
finalNobodyLeaf types — records are final automatically
non-sealedAnyoneA deliberate escape hatch out of a sealed hierarchy
(no modifier)AnyoneOpen 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:

Pay.java
// 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.

shapes.kt
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
}
✅ What to remember from Chapter 06
  • One class, many interfaces. extends for a stable is-a family; implements for 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, static and final members are not polymorphic; visibility may widen, exceptions may only narrow.
  • A subtype must be behaviourally substitutable — Square extends Rectangle fails at setSize, not at the compiler.
  • Interfaces carry contracts and default methods; abstract classes carry state and shared implementation.
  • sealed + permits makes a hierarchy countable, so switches can be proven exhaustive.
  • Kotlin: classes are final by default (open opts in), interfaces have bodies, and by delegates instead of inherits.
Chapter 07

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

Planet.java
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
✅ What enums give you for free
  • 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.
behaviour-enum.java
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);
⚠️ Don't use ordinal() as an ID

It'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

Money.java
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 used as a value
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.

✅ Same thing as a record
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.

🧠 Sealed types: enums for open sets

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

HttpClient.java
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();
ProblemReach forWhy
Fixed set of constantsenumExhaustive switch, singleton, serializable
Immutable value / DTOrecordFree equality, no mutability
Closed set, varied payloadssealed + recordsExhaustive matching with data
Many optional fieldsbuilderReadable, order-free, defaults
Long fixed set of fields, always sameconstructorBuilders are overkill when there's nothing optional
Optional nullable field (1–2)Optional param or overloadQuieter than a builder
🚫 Myth: "Builder for every class"

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.

N
instances of an enum, ever
0
lines of equals() in a record
7
fields before builders earn it
2
keywords: sealed, permits
✅ What to remember
  • 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 switch proves exhaustiveness.
  • Builders are for optionality, not a reflex for every new class.
Chapter 08

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.

❌ Pre-generics — raw collections, errors at runtime
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: Integer

The failure is four lines from the mistake that caused it. Every read is an unchecked cast the compiler signs off on blindly.

✅ Java 5+ — generics, errors at compile time
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:

ParameterMeaningYou'll see it in
T, U, Va general typeBox<T>, Comparable<T>
EelementCollection<E>, List<E>
K / Vkey / valueMap<K,V>
Na numberNumber helpers, statistics libs
S, Rsecond, resultfunction types, reduce
Single letters are the norm. TypeParam in CamelCase is legal but reads as amateurish in Java.
Generics.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.

🧠 Type inference did most of the work in that file

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.

🛑 Erasure: three things you simply cannot write

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.

Erased.java
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:

terminal
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.

Unchecked.java
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 gun

It 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:

ArrayStore.java
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
    }
}
🚫 Myth: "generics are just syntactic sugar for Object"

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.

DeclarationMeaningYou may readYou may write
List<? extends Number>a list of Number or any subtypeas Numbernothing but null
List<? super Number>a list of Number or any supertypeas ObjectNumber and subtypes
List<Number>exactly a list of Numberas NumberNumber and subtypes
List<?>a list of something unknownas Objectnothing but null
The mnemonic, straight from Joshua Bloch: PECS — Producer Extends, Consumer Super. If the collection produces values to you, use extends. If it consumes values from you, use super.
Pecs.java
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
    }
}
💡 Why 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.

Variance.kt
// 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.

✅ What to remember
  • Generics move ClassCastException to compile time — that's the entire benefit.
  • Type parameters are conventions: T type, E element, K/V key/value.
  • Bounds: <T extends Comparable<T>>; multiple bounds join with &, class first.
  • Erasure: type arguments vanish at runtime → no new T[], no instanceof List<String>, no static 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, consumer super.
  • Kotlin declares variance once with out/in, and inline fun + reified beats erasure where Java cannot.
Chapter 09

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.

Average operation cost
Lower is better · log-ish scale, illustrative · n = number of elements
ArrayList get
O(1)
HashMap get
O(1)
ArrayDeque push/pop
O(1)
LinkedList get
O(n)
ArrayList remove(0)
O(n)
TreeMap get
O(log n)
TreeSet contains
O(log n)
contains on unsorted List
O(n)

Illustrative asymptotics for teaching. Constants matter enormously — ArrayList beats LinkedList for almost every real workload despite identical big-O for sequential scans.

NeedUseAvoid for this
Indexed sequence, grow/shrinkArrayListFrequent middle insertions
Frequent insert/remove at both endsArrayDequeRandom access
Frequent middle insert + traversalLinkedListRandom access — get(i) is O(n)
No duplicates, fast containsHashSetAny ordering requirement
No duplicates, insertion orderLinkedHashSetSorted output
Sorted, range queriesTreeSet / TreeMapFastest possible lookup
Key → value, order irrelevantHashMapSorted keys, thread-safety
Key → value, insertion orderLinkedHashMapSorted output
Priority / next-bestPriorityQueueIterating in sorted order (it doesn't)
Thread-safe mapConcurrentHashMapCompound atomicity without care
Collections.java
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
⚠️ Iterating while mutating

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.

safe-removal.java
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.

streams.java
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.

🧠 Stream vs plain loop

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.

❌ Imperative grouping — 12 lines
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.

✅ Declarative — one expression
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.

✅ What to remember
  • Program to interfaces: List, Map, Set — never ArrayList in a signature.
  • ArrayList + HashMap are the defaults — deviate only with a measured reason.
  • ArrayDeque beats LinkedList for both stacks and queues.
  • removeIf instead of mutating during iteration.
  • Streams are for pipelines; loops are for control flow. Don't force either.
Chapter 10

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

Input.java
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);
        }
    }
}
✅ Pick Scanner when…
  • Reading tokens from stdin
  • Prototyping or competitive programming
  • You want nextInt()-style convenience
✅ Pick 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.in

Don'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

Formatting.java
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);
SpecifierMeaningExample output
%sString / any objectAda
%dDecimal integer1042
%fDecimal float (width .precision)3.14
%,d / %.2fGrouping / precision1,000 / 3.14
%nPlatform newline (not \n)\n on Linux
%bBooleantrue
%cCharacterA
%x / %oHex / octalff / 12
%-10sLeft-align in 10 columnsAda      
🧠 Immutability revisited, in real code

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.

log-parse.java
// 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.

Regex.java
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
    }
}
TokenMeans
\d / \DDigit / non-digit (remember: \\d in Java source)
\w / \WWord char / non-word char
\s / \SWhitespace / 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...\ELiteral text — Pattern.quote(s) wraps automatically
✅ Parsing with records + regex

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[].

🚫 Myth: "Regex is the fastest way to split a 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.

1
Pattern compiled statically
%s
specifier you'll type most
"""
text block, Java 15+
0
SCREAMING_CASE const conventions
✅ What to remember
  • Scanner for tokens, buffered readers for volume.
  • Use %n, not \n, in printf — portable newlines.
  • Compile Pattern once; reuse it across every Matcher.
  • Named groups ((?<level>...)) make captures self-documenting.
  • StringBuilder in loops, String.join at the end.
Chapter 11

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.

Throwing.java
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);
    }
}
⚠️ Never catch-and-swallow

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.

❌ The four sins
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 instead

Each one makes the original failure harder to find. Rethrow e itself when you can't add value.

✅ Catch only what you can handle
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 code

Catch at the layer that has enough information to choose a different path. Otherwise, let it travel up.

🧠 try-with-resources

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.

Resources.java
// 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
stacktrace.java
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)
✅ Designing an exception worth throwing
  • Name it after the problem: InsufficientFunds, not MyException.
  • 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).
🚫 Myth: "Checked exceptions are always better"

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.

ScenarioUse
Caller can realistically recoverChecked exception + throws
Programming bug (null, bad index, bad arg)Unchecked — IllegalArgument, IllegalState
Resource must be releasedtry-with-resources
Partial failure worth reporting alongside successaddSuppressed(e)
Expected absence, not an errorOptional or null — not an exception
OOM, stack overflow, linkage errorsLet it go — Error
2
branches: Exception, Error
0
empty catch blocks, ever
1
rule: add context, keep the cause
∞
bugs hidden by finally-less leaks
✅ What to remember
  • 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 Throwable or swallow an exception silently.
Chapter 12

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.

Lambdas.java
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");
    }
}
InterfaceMethodUse
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
🧠 Why "effectively final"?

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.

MethodRefs.java
// 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);
✅ Use a lambda when…
  • The logic is a short expression you'd write inline anyway
  • You're capturing a local variable
  • The intent is clearer as an expression
✅ Use a method ref when…
  • It's just a call to an existing method
  • User::name reads better than u -> u.name()
  • The target signature matches exactly
⚠️ Composition beats nesting

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.

composing.java
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.java
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
MethodReturnsWhen
mapOptionalTransform if present, pass empty through
filterOptionalKeep only if a predicate holds
flatMapOptionalTransform when the function already returns Optional
orElseTCheap default, always evaluated
orElseGetTLazy default — use when creation is costly
orElseThrowTAbsence is genuinely exceptional
ifPresentvoidSide effect only when present
streamStream0-or-1 element source in a pipeline
🚫 Myth: "Optional everywhere"

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.

1
abstract method per functional interface
4
method reference shapes
0
Optional fields in good code
8
the Java that made streams possible
✅ What to remember
  • Target type drives lambda inference — declare the interface, write the body.
  • Captured variables must be effectively final.
  • User::name over u -> u.name() when it's a straight call.
  • Compose with and/or/andThen instead of nesting.
  • Optional is a return type — map/filter, then decide.
Chapter 13

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.

Reflect.java
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
        }
    }
}
⚠️ Reflection is slow, brittle and open

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.

Annotations.java
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
RetentionVisible atUsed for
SOURCECompile time only@Override, @SuppressWarnings, Lombok-style generation
CLASSClass file (not runtime)Bytecode tools, static analysis
RUNTIMERuntime via reflectionFrameworks: 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.

🧠 Meta-annotations

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.

Proxy.java
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.

✅ When to reach for it
  • 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.
🚫 Myth: "Reflection lets you do anything"

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.

3
retention policies
1
handler method for a whole proxy
0
stringly-typed calls in app code
∞
frameworks built on these three ideas
✅ What to remember
  • Cache reflective Method/Field — lookup is the expensive part.
  • Unwrap InvocationTargetException or you log the wrong failure.
  • RUNTIME retention 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.
Chapter 14

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.

ThreadsDemo.java
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();
    }
}
🛑 The classic bug: 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.

StateWhat the thread is doingYou get there by
NEWObject exists, no OS thread yetnew Thread(...)
RUNNABLEReady for the CPU, or actually runningstart()
BLOCKEDWaiting to enter a synchronized block someone else holdsContended monitor
WAITINGParked until another thread actswait(), join(), LockSupport.park()
TIMED_WAITINGWaiting with a deadlinesleep(ms), wait(ms), join(ms)
TERMINATEDrun() returned or threwEnd of life
A thread stuck in 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.

Counter.java
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.

❌ Broken — three unsynchronised steps
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 3100

The lost update is invisible in testing and certain at scale. More threads, more cores and a slower GC all make it worse.

✅ Correct — one atomic instruction
AtomicInteger hits = new AtomicInteger();

hits.incrementAndGet();  // read-modify-write in ONE step
int now = hits.get();    // always a value some thread really wrote

No 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 lock

volatile 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.

DirtyFlag.java
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.

Same heap, different views — where the edge is missing
Thread A — stack & registers
running (in register)true — stale
loop iterations∞

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
running: booleanfalse — written by B
port: int8080

The write is real and has landed. Landing in memory and being observed by another thread are two different events.

Thread B — stop()
running = falsevolatile write
edge createdwrite → subsequent read

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.

🧠 Write the happens-before edge, not the sleep

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.

Deadlock.java
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() { /* ... */ }
}
🛑 Deadlock is invisible until it freezes production

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.

ProducerConsumer.java
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();
        }
    }
}
🚫 Myth: "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.

Shutdown.java
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.

SharedState.kt
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.

✅ What to remember
  • start() creates a thread; run() doesn't — the bug is silent, so check it in review.
  • synchronized locks the object you name, not the code. Lock a private final field that owns the state.
  • count++ is read → add → write; use AtomicInteger or LongAdder for 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() and join() create them.
  • Deadlock needs all four Coffman conditions: a global lock order plus tryLock timeouts breaks the cycle.
  • wait() lives in a while, always — spurious wakeups and stolen items are real.
  • Never swallow InterruptedException, and remove() every ThreadLocal in a pool.
Chapter 15

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

FactoryThreadsQueueVerdict
newFixedThreadPool(n)Fixed nUnboundedTasks pile up silently → heap grows → OOM
newCachedThreadPool()0 → 2³¹−1None (direct handoff)Thread storm under load
newSingleThreadExecutor()1UnboundedSerialises work; same queue risk
newScheduledThreadPool(n)Fixed nDelayed-work queueRight tool for timers
newVirtualThreadPerTaskExecutor()1 per taskNoneJava 21+ for blocking I/O
All five are one-liners. Only the last two are ones you should reach for without thinking.
⚠️ Why everyone stopped using the Executors factories

newFixedThreadPool 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.

Pools.java
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:

FuturePain.java
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

1 · supplyAsync Starts the graph on a pool thread. The future is pending; nobody blocks.
→
2 · thenApply Pure mapping: Json → User. Runs on whichever thread completed step 1.
→
3 · thenCombine Joins two independently-computed futures into one — both must finish, neither blocks a thread.
→
4 · allOf / anyOf Fan-out barrier: wait for every branch (or the first) without parking a thread.
→
5 · handle / join Normalise success and failure in one place, then take the value.
Fetch.java
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());
}
🧠 Callbacks run on the completing thread

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.

Chain.java
// 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.

VirtualThreads.java
// (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 threadVirtual thread
Scheduled byThe OS kernelThe JVM, onto carrier threads
Footprint~1 MB stack + kernel entityKilobytes (heap-allocated stack)
Cheap to createNo — syscall, ~µsYes — an allocation
Blocking I/OOccupies an OS threadCarrier moves on
CPU-bound workTrue parallelismSame cores, extra overhead
Thousands at onceGBs of stackEffortless
Typical countTens–hundredsOne per in-flight request
Never call 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.
🛑 Pinning: the one virtual-thread trap on Java 21

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()CompletableFutureVirtual threads
Best forChunking CPU-bound data workGenuinely concurrent compositionMany blocking I/O tasks
Threads usedCommon pool — shared, unownedWhichever executor you give itOne per task, kilobytes each
Blocking calls insideStarves the poolStalls that stageThe point
Ordering / cancellationBulk onlyPer-branchInterruption + scope
VerdictUse sparinglyComposition glueDefault for servers
There is no benchmark here because the winner is workload-shaped: parallelism is bounded by cores, latency-hiding is bounded by how much you can wait at once.
🚫 Myth: "async makes Java faster"

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.

Fetch.kt
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.

✅ What to remember
  • Never take an unbounded queue or unbounded thread count — build ThreadPoolExecutor with a bounded queue and a rejection policy.
  • execute = fire and forget; submit = returns a Future and wraps failures in ExecutionException.
  • Future blocks and can't compose — that's the whole reason CompletableFuture exists.
  • thenCompose when the function returns a future; thenApply when 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 synchronized on Java 21–23; Java 24 (JEP 491) largely removed the pinning.
  • Default to blocking code + virtual threads; add CompletableFuture only to compose concurrent branches.
Chapter 16

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:

terminal
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

SectionContentsWhy you'd care
Magic number0xCAFEBABEFirst 4 bytes of every .class. Wrong magic → not a class file.
Versionminor / major, e.g. 0 / 6565 = Java 21. A newer major is a hard UnsupportedClassVersionError.
Constant poolEvery class, field, method, string and number, index-addressedThe symbol table — one copy of each name, everything else is an int index.
Access flagspublic, final, interface, enum…Bitmask describing the class itself.
This / super / interfacesConstant-pool indicesThe inheritance chain, resolved lazily.
Fields & methodsName, descriptor, attributesDescriptor (I)I = takes an int, returns an int.
AttributesCode, LineNumberTable, Signature, annotations, BootstrapMethodsWhere stack traces get line numbers and lambdas get their links.
Descriptors are type signatures in compact form: 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:

1 · loading Find the bytes (classpath, JAR, module) and produce a Class object for them.
→
2 · verify Structural check: types line up, the stack depth is right, no jump lands mid-instruction. Corrupt or hostile bytecode dies here.
→
3 · prepare Allocate static fields and set them to their defaults (0, null, false). No user code runs yet.
→
4 · resolve Turn symbolic constant-pool references into direct ones — the lazy step, usually done on first use.
→
5 · initialize Run <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.

bootstrap Loads the JDK core: java.lang, java.util, everything in java.base. Implemented in native code and associated with null as its loader.
→
platform Java 9+'s middle layer: module-aware loaders for the non-core platform modules (java.sql, jdk.unsupported…).
→
application Your classpath. Also handed out as Thread.currentThread().getContextClassLoader() — what frameworks use to find your classes.
🧠 Why delegation asks the parent first

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

One thread's stack, the shared heap, and the native side
Thread stack — private, frames, fast
frame: main()locals + operand stack
  p: Point ─┐4-byte ref
  i: int = 7primitive, inline
frame: scaled()pushed on call
  v: int = 14dies on return

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
Point @4f2 ←┘header 12 B + x 4 B
Integer @7a1boxed: 16 B
String "hello"byte[] + coder + hash
old generationsurvivors promoted here

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
Metaspaceclass metadata, off-heap
Code cacheJIT'd machine code
Direct buffersNIO ByteBuffer

Not 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:

Layout.java
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.

CollectorStrategyPausesReach for it when…
SerialSingle-threaded, stop-the-worldProportional to heapSmall heaps, containers with 1 CPU, toys
ParallelMulti-threaded stop-the-worldTens–hundreds of msBatch jobs where throughput beats latency
G1Region-based, incremental mixed GCTarget: ~200 msThe default since Java 9 — usually just leave it
ZGCConcurrent, load barriers (generational since 21)Sub-millisecondLatency-sensitive services with large heaps
ShenandoahConcurrent mark & compactLow, predictableLow-pause OpenJDK workloads; not in every JDK build
All of them still stop the world at safe points — points where a thread's state is consistent (method entry, loop back-edges), so the JVM can freeze it. You can't stop a thread mid-bytecode-instruction, and no collector makes pauses literally zero.
terminal
# 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
🚫 Myth: "Tune the GC first"

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.

⚠️ The two heap failures worth recognising

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.

terminal
# 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.

🧠 Never benchmark Java with a stopwatch

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.

build.gradle
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.

terminal
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.

✅ What to remember
  • A .class file starts with 0xCAFEBABE, then version, constant pool, then fields, methods and attributes.
  • javap -c reads 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.String can 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.Metadata and real null checks — this whole chapter is your chapter too.
Chapter 17

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.

✅ Choose Gradle when…
  • 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
✅ Choose Maven when…
  • You want one standard, predictable lifecycle
  • Convention over configuration matters more than flexibility
  • The team is large and long-lived
MavenGradlePlain javac
Configpom.xml (XML)build.gradle.kts (Kotlin/Groovy)shell script
SpeedGoodFaster (incremental, daemon)Fastest — until you add deps
Learning curveLow, rigidHigher, flexibleYou own everything
Best forServers, enterpriseAndroid, complex buildsLearning, tiny scripts

The standard project layout

project tree
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

pom.xml
<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>
terminal
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
⚠️ Commit your lockfile — and know which one

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

AppTest.java
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.

🧠 Test pyramid, honestly

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-info.java
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.

💡 Do you actually need modules?

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

terminal
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.

2
dominant build tools
1
standard source layout
5
phases: load, link, init
0
reason to run tests manually in CI
✅ What to remember
  • Maven for convention, Gradle for control. Either is correct; arguing about it is not.
  • src/test is the convention that makes testing frictionless — keep tests in the matching package.
  • Name tests after behaviour. rejects_negative documents 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.
Chapter 18

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.

1 · interpret Bytecode is executed instruction by instruction. Fast to start, slow to run.
→
2 · C1 Hot methods get quick native compilation. Modest optimisation, low cost.
→
3 · C2 Profiling data drives aggressive inlining, unboxing, lock elision.
→
4 · plateau Steady state — often matching or beating hand-written C++ on business logic.
⚠️ Never benchmark with System.nanoTime() in a loop

You'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

Relative throughput — CPU-bound, steady state
Higher is better · normalised so C = 100 · illustrative, after JIT warm-up · lower runtime overhead is better
C
100
Rust
98
C++
97
Java
89
Kotlin
87
Go
81
C#
79
JS
60
Python
15

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.

DimensionJavaC / C++ / RustGo
Peak throughputExcellentBestGood
StartupSlow (JVM + warm-up)InstantFast
Memory footprintTens of MBMinimalSmall
Pause predictabilityGood — G1/ZGC sub-msNo pausesSTW pauses
Scaling coresExcellentExcellentExcellent
DeploymentJAR + JRE, identical everywherePer-platform binariesSingle static binary
🚫 Myth: "Kotlin is slower than Java"

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.

💡 The performance order of operations
  • 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.
🧠 Where Java genuinely loses

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.

Chapter 19

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.

💡 The one-sentence version

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

❌ Java — 14 lines of ceremony
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.

✅ Kotlin — one line
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

2010

Project begins at JetBrains

The team behind IntelliJ starts a language internally, codenamed "JetBrains Language" — later a pun on Kotlin Island near St. Petersburg.

Feb 2016

Kotlin 1.0

Stable language and standard library. The compatibility promise begins: 1.x code keeps compiling across minor releases.

2017

Google backs it for Android

Kotlin becomes a first-class Android language alongside Java. Open-sourced on GitHub with Apache 2.0.

2019

Declared the preferred language

Google announces Android development with Kotlin first. Adoption accelerates sharply.

2021 →

Kotlin Multiplatform

Shared logic across iOS, Android, desktop and web backends. The compiler is rewritten in Kotlin itself (K2), dramatically faster.

Today

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.

PropertyJavaKotlin
Null handlingAny ref can be nullTypes enforce it
Classes final by defaultNo — inherit freelyYes — opt in with open
Data holdersRecord, or boilerplatedata class
Checked exceptionsYesNone
Concise null coalescingOptional?: elvis operator
CoroutinesVirtual threads (Loom)Built into the language
Compiles toIdentical JVM bytecode — same runtime, same performance profile
🚫 Myth: "You have to pick one"

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.

⚠️ Where Java still wins

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.

✅ What to remember
  • 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.
Chapter 20

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.

Basics.kt
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
}
✅ Use 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
✅ Use 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 object

val 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

inference.kt
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.

JavaKotlinNotes
int, longInt, LongObjects, but boxed only when nullable
charCharSingle character
booleanBoolean
floatFloatDeclare explicitly; default is Double
char[]CharArrayPrimitive arrays exist
final int X = 5;const val X = 5Compile-time constant, top-level or in an object
⚠️ No implicit widening

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

functions.kt
// 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.

✅ What to remember
  • val by default, var only 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.
Chapter 21

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.

❌ Java — compiles, crashes later
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.

✅ Kotlin — refuses to compile
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 so

The type system rejects the bug before it exists. Nullability is in the API.

The four operators

NullOps.kt
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"
}
OperatorReads asResult
?.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
chained.kt
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 crash

user!!.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.

🚫 Myth: "Kotlin still throws NPEs"

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.

platform.kt
// 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
🧠 Annotating Java so Kotlin knows

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.

lateinit-lazy.kt
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
2
types: String and String?
1
operator you should almost never write
0
NullPointerExceptions from Kotlin declarations
2009
year the NPE was called a billion-dollar mistake
✅ What to remember
  • ? is part of the type — String? and String don't interconvert.
  • ?. + ?: covers most null handling in one expression.
  • Smart casts remove the need for !! after a != null check.
  • Avoid !! — it reintroduces the exact bug you're avoiding.
  • Platform types (T!) are the danger zone — annotate Java APIs.
Chapter 22

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.

Person.kt
// 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
}
✅ Classes are final by default

Kotlin 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 property

class 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 immutable

class 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.kt
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 dataNot generated
equals / hashCode on all constructor propertiesMutable state (properties should be val)
toString: User(name=…)Validation beyond your own init
copy with named argumentsAnything non-constructor (put it in the body)
componentN for destructuringSuperclass equality (the parent isn't included)
🚫 Myth: "Data class = immutable domain model"

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

Result.kt
// 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.

🧠 Sealed vs enum

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

Interfaces.kt
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
0
classes inheritable without open
1
keyword: data
6
members a data class generates
0
need for static with companion objects
✅ What to remember
  • Final unless open — inheritance is opt-in.
  • data for value types, plain classes for identity.
  • sealed gives exhaustive when — no silent fallthrough.
  • by delegates without forwarding boilerplate.
  • Default and named arguments replace most overloads and factories.
Chapter 23

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.

Creating.kt
// 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 lambdas

Two 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

Pipelines.kt
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()
TaskKotlinJava streams equivalent
Keep matchingfilter { }.filter(u -> …)
Transformmap { }.map(u -> …)
GroupgroupBy { }Collectors.groupingBy
FrequenciesgroupingBy{ }.eachCount()groupingBy(…, counting())
SortsortedBy { }.sorted(Comparator.comparing…)
First matchfirstOrNull { }.findFirst() + Optional
Single matchsingleOrNull { }.filter(…).findAny()
Reducefold / reduce.reduce((a,b) -> …)
Flatten nestedflatMap { }.flatMap(u -> …)
Partitionpartition { }two filters (or a loop)
⚠️ filter on a Map keeps entries

map.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.

❌ Mutable accumulation
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.

✅ The same, declaratively
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.

Sequences.kt
// 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.

✅ What to remember
  • Start with immutable factories — listOf/setOf/mapOf; opt into mutable….
  • Trailing lambda syntax makes pipelines read top to bottom.
  • groupBy + eachCount() replaces most manual map-building loops.
  • firstOrNull returns T? — chain ?.… and ?:, don't assert.
  • Reach for a sequence when you're allocating a list per stage or reading lazily.
Chapter 24

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.

Functions.kt
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)
💡 The receiver trick

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

Scopes.kt
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
}
FunctionReceiverArgReturnsReach for it when…
letit—Lambda resultNull-checking, one-off transform
runthis—Lambda resultCompute something from an object
withthisObjectLambda resultSeveral calls on one non-null object
applythis—The receiverConfiguring a newly built object
alsoit—The receiverLogging, side effects, no change to flow
takeIfitPredicateReceiver or nullConditional filtering that returns the value
❌ Chained confusion
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 which

Five scope functions in one expression is a puzzle, not code. Each has a job — chaining all of them blurs the job away.

✅ One job each, in order
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.

⚠️ The it shadowing trap

Nested 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.

🧠 Contracts: telling the compiler

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:

Contracts.kt
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.

🚫 Myth: "Use scope functions everywhere"

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.

5
scope functions, 2 receiver styles
1
rule: apply returns this, let returns result
0
classes needed for a lambda
∞
DSLs built on receiver lambdas
✅ What to remember
  • 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.
Chapter 25

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.

Hello.kt
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")
}
❌ Java-style: threads block
// 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 asleep

Threads cost ~1 MB of stack each and are expensive to schedule. Thread-per-request caps you in the low thousands.

✅ Kotlin: coroutines suspend
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 calls

The thread is released the moment the coroutine waits. Concurrency becomes a count of logical tasks, not OS threads.

🧠 What suspend actually compiles to

The 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.

Scopes.kt
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
}
BuilderReturnsCancels siblings?Use
launchJob—Fire-and-forget work
asyncDeferred<T>Yes, on failureValue you'll await
runBlockingT—Bridge at the edge only — tests, main
withContextT—Switch dispatcher, same logical task
coroutineScopeTOn failureParallel children inside one function
supervisorScopeTNoChildren fail independently
⚠️ runBlocking blocks the calling thread

It 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.

🧠 Cancellation is cooperative

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.

cancellation.kt
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.

🚫 Myth: "Coroutines replace threads entirely"

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.

1
keyword: suspend
2
builders for values: async, await
10⁶
plausible coroutines vs ~10³ threads
0
blocking calls allowed on a dispatcher
✅ What to remember
  • suspend = may pause; the compiler turns the body into a state machine.
  • async for values, launch for 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.
Chapter 26

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.

Basics.kt
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.

Operators.kt
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
}
OperatorDoesTypical use
map / filterTransform / keepEverywhere
distinctUntilChangedDrops repeatsState, search input, sensors
debounceWaits for quietTypeahead, resize events
sampleTakes periodic snapshotsHigh-frequency telemetry
take / takeWhileStops earlyLimits, bounded reads
onEach / onStart / onCompletionSide effects, no value changeLogging, metrics, teardown
catchHandles upstream exceptionsFallbacks — not downstream
zip / combinePair up two flowsJoined reads, form validation
reduce / toList / firstTerminalEnd of the pipeline
🧠 catch only sees upstream failures

flow.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

State.kt
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 }
}
✅ StateFlow

A 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.

✅ SharedFlow

A 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.

🚫 Myth: "Flows are Rx with worse syntax"

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.

⚠️ StateIn, not collect-in-a-launch

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().

3
shapes: Flow, StateFlow, SharedFlow
1
rule: cold until collected
0
manual backpressure tuning
300ms
the debounce value you'll try first
✅ What to remember
  • Flows are cold recipes — collect is what runs them.
  • emit only in your own flow { } block.
  • distinctUntilChanged + debounce make UI input streams sane.
  • StateFlow for state, SharedFlow for events.
  • stateIn with a scope instead of an unbounded launch.
Chapter 27

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 featureWhat Java seesPractical consequence
Top-level functionsFooKt.staticMethod()Java callers need the FooKt facade class
object / companionSingleton INSTANCE / Companion fieldCall Json.INSTANCE.encode() or add @JvmStatic
Default argumentsOverloads generated with @JvmOverloadsWithout it, Java must pass every parameter
fun f(a: String = "x")String $default synthetic methodNot directly callable — use @JvmOverloads
Named argumentsNothingJava has no equivalent — you lose this, not Java
val propertygetName() getterReads as a Java bean — good for frameworks
var propertygetX() / setX()JavaBean convention for free
Extension functionStatic method with receiver params.wordCount() → UtilKt.wordCount(s)
String?String (platform type)Java won't know — annotate with @Nullable
Suspending functionContinuation-based methodUgly to call — wrap it or use runBlocking
inline functionInlined at Kotlin call sites onlyBody visible to Java as a normal method
✅ The interop annotations that fix everything
  • @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 of UtilKt.
  • @Nullable / @NotNull — from JetBrains or JSR-305, so Kotlin knows.
KtCalledFromJava.kt
@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
❌ Java calling Kotlin, blind to nulls
// 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.

✅ Annotate, and the compiler helps
// 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.

JavaCallingKotlin.java
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());
        }
    }
}
⚠️ Calling suspend functions from Java

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.

🧠 Gradle in a mixed module

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.

🚫 Myth: "Interop means you can ignore Java's rules"

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.

5
annotations worth memorising
T!
platform type — the danger
1
facade class for top-level functions
0
reasons to migrate a file for its own sake
✅ What to remember
  • Same bytecode, different vocabulary — learn what Java sees for each Kotlin feature.
  • @JvmStatic / @JvmOverloads / @JvmField / @JvmName fix 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.
Chapter 28

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.

What a KMP project shares
Higher = more code moved out of per-platform copies · illustrative allocation for a typical app
Models & DTOs
~95%
Business rules
~88%
Validation
~90%
Networking / cache
~75%
Persistence
~60%
Analytics
~55%
UI
~0%

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.

build.gradle.kts
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")
        }
    }
}
Platform.kt
// 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()
}
✅ Prefer an abstraction over expect

Where 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.

ApproachSharesTrade-off
Two native appsNothingDoubled logic, two bug backlogs
Web-based (React Native / Flutter-ish)Almost everythingNative feel and APIs need bridging
KMP — shared logicLogic, not UINative UI, one rules layer, per-platform view code
KMP + Compose MultiplatformLogic + UIShared UI too — desktop/web/Android; iOS support is newer
🧠 The architecture this pushes you toward

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.

⚠️ Where KMP costs you
  • 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 expect wrapper 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.
🚫 Myth: "One Kotlin codebase means one app"

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.

1
source, N targets
expect
plus one actual per platform
0
UI shared by default
SDK
the highest-ROI first KMP project
✅ What to remember
  • Share logic, keep native UI — that's the trade being made.
  • commonMain is the shared module; source sets give per-platform deps.
  • expect/actual for platform primitives, interfaces + DI for everything else.
  • Test everything in commonTest once and run it everywhere.
  • Start with an SDK or a feature, not an entire application rewrite.
Chapter 29

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.

MessageActually meansFix
incompatible types: String cannot be converted to intYou assigned the wrong type — no implicit conversionParse or convert explicitly: Integer.parseInt(s)
unreported exception; must be caught or declaredA checked exception escaped the methodthrows it, or catch where you can handle it
variable might not have been initializedA path reaches use before assignmentAssign at declaration, or cover the missing branch
cannot find symbol: class FooWrong package, missing import, or not on the classpathCheck import, then your dependency
non-static variable cannot be referenced from a static contextmain is static; the field isn'tMake 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 typoMatch the signature exactly, or drop the annotation
ArrayIndexOutOfBoundsExceptionYou touched an index outside 0..length-1Loop to < length, not <= length
ConcurrentModificationExceptionStructurally changed a collection while iteratingremoveIf, Iterator.remove, or build a new list
Hash invariant violatedAn object's hashCode changed while in a HashSetMake keys immutable (records, or val fields)
Kotlin messageActually meansFix
Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiverYou called a member on T??.foo(), ?: default, or prove non-null with a check
Type mismatch: inferred type is T? but String was expectedA 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 getterCompiler can't guarantee the value is unchanged between check and useCopy to a local val, then check the local
This declaration needs opt-in. Use @OptIn(ExperimentalApi::class)API may change in a future releaseAdd @OptIn and pin the version, or avoid the API
Suspend function should be called only from a coroutine or another suspend functionsuspend called from ordinary codeWrap in runBlocking, or make the caller suspend
Variable 'x' must be initialized / lateinitRead before assignmentInitialise earlier, or use by lazy when it's derivable
String! platform types... warnings on dereferenceJava code above you didn't declare nullabilityTreat as nullable, and annotate the Java API
Too many arguments for 'foo(bar: String)'Calling a Kotlin function with defaults from JavaAdd @JvmOverloads to the Kotlin declaration
⚠️ The error is often downstream of the bug

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.

Diagnosing.java
// ❌ "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;
Diagnosing.kt
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 }
✅ A debugging order that works
  • 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.
🧠 Null-safety errors are a feature

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.

9
core Java errors you'll see daily
8
core Kotlin ones
1st
error in the list is the real one
15
minutes before reducing the repro
✅ What to remember
  • 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.
Chapter 30

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.

Week 1–2

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.

Week 3–4

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.

Week 5–6

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.

Week 7–8

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.

Week 9–10

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.

Week 11–12

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.

Week 13+

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.
✅ A weekly rhythm

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 areStart withSkip for now
New to programmingCh. 01–04, then a tiny CLIConcurrency, reflection, KMP
Know another languageCh. 03, 05, 07 (Java differs), then KotlinRe-reading basic syntax
Java dev learning KotlinCh. 19–24 straight throughCh. 01–16 — you already have it
Kotlin dev learning JavaCh. 05–13, then 14–17Null safety re-reads
Interview prepCh. 08, 09, 11, 14, 16 + 31Ch. 30 projects — do those separately
Working on a legacy codebaseCh. 17, 18, 27, 29Starting a rewrite
🧠 How to read the JDK itself

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.

⚠️ The two traps
  • 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.
13
weeks to a full arc
2–4
evenings per project
1
concept explained aloud each week
0
reasons to start a rewrite
✅ What to remember
  • 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.
Chapter 31

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

TaskCode
Declare a constantstatic final int MAX = 100;
Infer a local typevar list = new ArrayList<String>();
Recordrecord Point(int x, int y) {}
Sealed interfacesealed interface S permits A, B {}
Switch expressionString s = switch (n) { case 1 -> "one"; default -> "many"; };
Pattern matchif (o instanceof String str && !str.isEmpty())
Safe nullable returnOptional<String> find();
Try with resourcestry (var in = Files.newInputStream(p)) { … }
Method referencestream.map(User::name)
Text blockString sql = """SELECT * FROM t"""
Formatted outputSystem.out.printf("%s: %.2f%n", k, v);
Varargsvoid log(String... parts)

Kotlin — syntax at a glance

TaskCode
Immutable / mutableval x = 1 · var y = 0
Nullable typeval s: String? = null
Safe call + defaultuser?.name ?: "anon"
Data classdata class User(val name: String, val age: Int)
Sealed hierarchysealed interface R + is A -> … in when
Exhaustive whenval s = when (r) { is Ok -> "ok"; is Err -> "err" }
Function with defaultsfun greet(name: String, hi: String = "Hi")
Extensionfun String.words() = split(" ")
String template"${user.name} is ${user.age}"
Trailing lambdalist.filter { it.age > 30 }
Scope functionsapply → this · let → it · also → it, returns receiver
Singletonobject Json { fun enc(v: Any) … }
Companion / staticcompanion object { fun create() … }
Suspend + coroutinesuspend fun f() · scope.launch { f() }
Delegationclass P(c: Clickable) : Clickable by c

Collections & concurrency chooser

You needJavaKotlin
Ordered, indexedArrayListmutableListOf
Fixed orderedList.oflistOf
No duplicatesHashSetsetOf
Key → valueHashMapmapOf
Sorted / rangeTreeMap/TreeSetsortedMapOf
Stack / queueArrayDequeArrayDeque
GroupCollectors.groupingBygroupBy { }
CountsgroupingBy(x, counting())groupingBy{ }.eachCount()
Lazy pipelinestream()asSequence()
You're doingReach for
Fire-and-forget taskJava: executor.submit · Kotlin: launch
Task returning a valueJava: CompletableFuture · Kotlin: async
Wait for completionjoin() / await()
Switch thread poolJava: .supplyAsync(…, pool) · Kotlin: withContext(Dispatchers.IO)
CPU-bound workDispatchers.Default / common pool
Blocking I/ODispatchers.IO / cached pool
Bound parallelismSemaphore · Kotlin: Semaphore(n)
UI threadDispatchers.Main
Stream of valuesJava: Flow/Reactive · Kotlin: Flow
Current UI stateStateFlow + stateIn
✅ The five-line memory aid

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

Bytecode
The platform-neutral instruction set the Java and Kotlin compilers emit, executed by the JVM — the reason both languages run anywhere a JVM does.
C1 / C2
The two JIT compilers: C1 compiles hot methods quickly with modest optimisation; C2 recompiles the hottest with profile-guided, aggressive optimisation.
Checked exception
An exception the compiler forces you to declare (throws) or catch. Subclass of Exception that isn't a RuntimeException.
Coroutine
A suspending function in execution. Compiled to a state machine plus a Continuation, so it can pause and resume without holding a thread.
Covariance
List<Dog> usable where List<Animal> is expected (? extends T / out T). Safe for reads, unsafe for writes.
CPS
Continuation-Passing Style — how suspend functions are transformed: each suspension point passes a continuation holding the state to resume from.
Data class
Kotlin's data modifier: generates equals, hashCode, toString, copy and componentN from the constructor.
Dispatcher
The coroutine component deciding which thread resumes work — Default, IO, Main.
Effectively final
A local variable never reassigned after initialisation — therefore capturable by a lambda or inner class.
Elvis operator
a ?: b in Kotlin — evaluate b when a is null. The null-coalescing shorthand.
Exhaustiveness
A switch or when covering every possible case. Enforced by the compiler for enums and sealed types — a missing branch is a build error.
Flow
Kotlin's cold, suspending sequence of values. Backpressured, cancellable, and built on coroutines.
Functional interface
An interface with exactly one abstract method — the target type a lambda can bind to (Function, Predicate, Runnable).
Garbage collector (GC)
Reclaims heap objects that are no longer reachable. Java's G1 and ZGC aim for short, predictable pauses rather than none at all.
Generics
Parameterised types (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.
Identity vs value equality
Records and data classes compare by content; ordinary classes default to reference identity. Choosing wrong breaks map keys and set membership.
Interop
Calling between Kotlin and Java in one project — free at the bytecode level, with friction only where one language's features have no equivalent in the other.
JMH
Java Microbenchmark Harness — the only sanctioned way to benchmark JVM code. Handles warm-up, dead-code elimination and measurement error.
JPMS
The Java Platform Module System (Java 9+): module-info.java declaring requires, exports and opens.
Lambda
An anonymous function bound to a functional interface (Java) or a function type (Kotlin). Captures effectively-final locals.
Memory model
The rules defining when one thread's writes become visible to another. Java's guarantees volatile and synchronized; Kotlin inherits it.
Method reference
Type::method — a lambda whose body is a single existing call. Four shapes: static, bound instance, unbound instance, constructor.
Null safety
Kotlin's rule that T and T? are distinct types, with the compiler enforcing every path. Java approximates it with Optional and annotations.
Optional
Java's container for a possibly-absent value — designed as a return type, not a field or parameter type.
Platform type
T! — what Kotlin sees for a Java declaration whose nullability isn't annotated. Treat as nullable unless proven otherwise.
Record
Java's immutable value carrier: components define the constructor, accessors, equals, hashCode and toString.
Reflection
Inspecting and invoking classes, methods and fields discovered at runtime — the engine behind DI frameworks, mappers and test runners.
Scope function
Kotlin's let, run, with, apply, also — each runs a lambda with the receiver bound as this or it.
Sealed type
A closed set of subtypes known at compile time — sealed interface in Java, sealed/sealed class in Kotlin. Enables exhaustive matching.
Sequence
Kotlin's lazy collection pipeline — one element at a time, avoiding an intermediate list per stage.
Smart cast
Kotlin's automatic narrowing after a null or type check — no cast written, because the compiler proved it's safe.
StateFlow / SharedFlow
Kotlin's hot reactive types: StateFlow always has a current value; SharedFlow multicasts events to subscribers.
Structured concurrency
The rule that a scope owns its children — when it ends, they're cancelled or awaited. Prevents orphaned tasks.
Suspend
Kotlin's marker that a function may pause without blocking. Only callable from a coroutine or another suspend function.
try-with-resources
try (resource) { … } — automatic close in reverse declaration order, on every exit path.
Variance
How generic subtyping relates: out/covariant (produce), in/contravariant (consume), invariant (both).
Virtual thread
Java's Loom thread: cheap, scheduled onto a small pool of platform threads. Kotlin coroutines solve the same problem differently.
Warm-up
The period where the JVM interprets, then JIT-compiles hot code. Benchmarks that ignore it measure the wrong thing entirely.
🧠 Where to go next

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.

Chapter 32

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.

✅ Scoring honestly

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.

32
chapters, one file
2
languages, one runtime
20
questions worth rereading for
1
next step: build something
✅ You've reached the end
  • 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.