Java Deep Dive – Interactive Handbook

For intermediate developers who want more than tutorials. Built on JDK 21 LTS, this handbook teaches tradeoffs, internals, and production patterns—not just syntax.

JDK 21 LTS Virtual Threads Records • Sealed • Pattern Matching Streams • NIO.2 • HttpClient

01 Setup & Anatomy

Why JDK 21 LTS? It's the current long-term support release (supported until 2031), and it's where modern Java stabilized: virtual threads, record patterns, pattern matching for switch, and sequenced collections. If you're on 17, you can follow along—most features exist—but 21 removes preview flags and gives you the best performance.

Install via SDKMAN: sdk install java 21.0.2-tem or Homebrew: brew install openjdk@21. Verify with java -version.

Project structure: Use Maven/Gradle standard layout. It matters because tools expect it:

my-app/
 ├─ src/main/java/com/example/
 │   └─ App.java
 ├─ src/test/java/
 └─ pom.xml

Compile: javac -d out src/main/java/com/example/*.java Run: java -cp out com.example.App

Main method evolution

Traditional (still required for public APIs): public static void main(String[] args). Java 21 introduced JEP 445 (preview) allowing simplified launch: void main() in an unnamed class, and instance main methods. Java 25 makes this stable. For learning, use the classic form—then drop ceremony in scripts.

Why it matters: Understanding the entry point and module system prevents classpath hell. Modules give strong encapsulation—your internals stay internal.
HelloWorld.java
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, Stephen! JDK " + Runtime.version());
    }
}
module-info.java
module com.example.hello {
    // no exports needed for simple app
    requires java.base; // implicit
}

02 Core Syntax Deep Dive

Primitives vs Wrappers: Primitives (int, long, double etc) live on stack, no identity. Wrappers (Integer) are objects, nullable, live on heap. Autoboxing is convenient but in hot loops creates garbage. Integer cache covers -128 to 127—== works there by accident, fails outside. Always use .equals() for wrappers.

var: Local type inference (Java 10). The compiler infers from RHS. It's not dynamic—type is fixed at compile time. Use for generics noise: var map = new HashMap<String, List<User>>(); Don't use when type isn't obvious from initializer.

Text Blocks ("""): Java 15. Preserve formatting, strip incidental indent. Perfect for SQL, JSON, HTML. No more + "\n" concat.

Switch expressions: Java 14. Use arrow -> to avoid fall-through, yield a value, and be exhaustive with sealed types. Combined with pattern matching (Java 21), switch becomes a powerful type-safe dispatcher.

DataTypesDemo.java
public class DataTypesDemo {
    public static void main(String[] args) {
        // primitives vs wrappers
        int a = 127;
        Integer b = 127;
        Integer c = 127;
        System.out.println(b == c); // true (cached)

        Integer d = 128;
        Integer e = 128;
        System.out.println(d == e); // false! different objects

        // var - type inferred as ArrayList
        var names = new java.util.ArrayList();
        names.add("Stephen");

        // text block - perfect for JSON
        String json = """
            {
              "name": "%s",
              "jdk": 21,
              "features": ["records","virtual-threads"]
            }
            """.formatted(names.get(0));
        System.out.println(json);
    }
}
SwitchPatternDemo.java
public class SwitchPatternDemo {
    static String describe(Object obj) {
        // pattern matching for switch - Java 21
        return switch (obj) {
            case null -> "null";
            case String s when s.isBlank() -> "empty string";
            case String s -> "String length " + s.length();
            case Integer i -> "int " + i;
            case Long l -> "long " + l;
            default -> "unknown " + obj.getClass().getSimpleName();
        };
    }

    public static void main(String[] args) {
        System.out.println(describe("Hi"));
        System.out.println(describe(42));
        System.out.println(describe(null));
    }
}
Why it matters: Pattern matching removes ceremony and ClassCastException risk. Switch expressions force you to handle all cases—compiler helps you when you add new types.

03 Object-Oriented Mastery

Java is OOP-first but modernized. Encapsulation: private fields, public behavior. Inheritance: use sparingly—prefer composition. Polymorphism: dynamic dispatch on overridden methods.

Interfaces now have: default methods (evolution without breaking), static methods, private methods (Java 9). Use them for capability traits.

Records (Java 16): immutable data carriers. Compiler generates constructor, accessors, equals, hashCode, toString. Perfect for DTOs, keys, tuples. You can add validation in compact constructor.

Sealed classes (Java 17): sealed interface X permits A,B,C restricts who can implement. Enables exhaustive switch—compiler warns if you miss a case. Critical for domain modeling.

Shapes.java
abstract class Shape {
    abstract double area();
    String name() { return getClass().getSimpleName(); }
}

final class Circle extends Shape {
    private final double radius;
    Circle(double radius) { this.radius = radius; }
    @Override double area() { return Math.PI * radius * radius; }
}

final class Rectangle extends Shape {
    private final double w, h;
    Rectangle(double w, double h) { this.w = w; this.h = h; }
    @Override double area() { return w * h; }
}
Point.java
public record Point(int x, int y) {
    // compact constructor for validation
    public Point {
        if (x < 0 || y < 0) throw new IllegalArgumentException("positive only");
    }
    // additional method
    public double distanceFromOrigin() {
        return Math.hypot(x, y);
    }
}
Vehicle.java
public sealed interface Vehicle permits Car, Truck, Bike { }

final class Car implements Vehicle { int seats() { return 5; } }
final class Truck implements Vehicle { int payloadKg() { return 2000; } }
final class Bike implements Vehicle { }

class VehicleDemo {
    static String inspect(Vehicle v) {
        return switch(v) {
            case Car c -> "Car with " + c.seats() + " seats";
            case Truck t -> "Truck payload " + t.payloadKg();
            case Bike b -> "Bike";
            // exhaustive - no default needed
        };
    }
}

04 Exception Handling & Best Practices

Checked vs Unchecked: Checked (IOException) must be declared or caught—forces handling of recoverable conditions. Unchecked (RuntimeException) for programming bugs—don't catch broadly.

Try-with-resources: Anything implementing AutoCloseable is closed automatically, even on exception. Suppressed exceptions are attached—use e.getSuppressed() to debug.

Custom exceptions: Extend Exception for checked business errors, or RuntimeException for validation. Keep hierarchy shallow, include context.

FileReaderSafe.java
import java.io.*;
import java.nio.file.*;

public class FileReaderSafe {
    public static String readFirstLine(Path p) throws FileReadException {
        // try-with-resources guarantees close
        try (BufferedReader br = Files.newBufferedReader(p)) {
            return br.readLine();
        } catch (IOException e) {
            throw new FileReadException("Cannot read " + p, e);
        }
    }

    public static void main(String[] args) {
        try {
            System.out.println(readFirstLine(Path.of("README.md")));
        } catch (FileReadException e) {
            System.err.println(e.getMessage());
            e.printStackTrace();
        }
    }
}

// custom checked exception
class FileReadException extends Exception {
    FileReadException(String msg, Throwable cause) { super(msg, cause); }
}

05 Collections Framework In Depth

Lists: ArrayList backed by array—O(1) get, amortized O(1) add, O(n) insert middle. LinkedList—O(n) get, O(1) insert if you have iterator at position. In practice, ArrayList wins 99% of time due to cache locality.

Maps: HashMap O(1) average, buckets array length power of 2, hash spread via XOR. Load factor 0.75 triggers resize. When bucket >8 entries, treeifies to red-black tree (Java 8+) to prevent hash-DoS. LinkedHashMap maintains insertion order, TreeMap sorted O(log n).

Comparator vs Comparable: Comparable defines natural order (class User implements Comparable). Comparator is external strategy—prefer for multiple orderings.

CollectionsLab.java
import java.util.*;

public class CollectionsLab {
    public static void main(String[] args) {
        // ArrayList vs LinkedList microbenchmark concept
        List array = new ArrayList<>();
        List linked = new LinkedList<>();

        for (int i = 0; i < 100_000; i++) {
            array.add(i);
            linked.add(i);
        }

        long t1 = System.nanoTime();
        array.get(50_000);
        long t2 = System.nanoTime();
        linked.get(50_000);
        long t3 = System.nanoTime();
        System.out.println("ArrayList get: " + (t2-t1)/1000 + "µs");
        System.out.println("LinkedList get: " + (t3-t2)/1000 + "µs");

        // HashMap internals demo
        Map counts = new HashMap<>(16, 0.75f);
        counts.merge("java", 1, Integer::sum); // atomic merge
        counts.merge("java", 1, Integer::sum);
        System.out.println(counts); // {java=2}

        // Comparator - sort by length then alphabetically
        List words = new ArrayList<>(List.of("stream","lambda","var","record"));
        words.sort(Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));
        System.out.println(words);
    }
}

06 Lambdas, Streams, and Functional Java

Lambdas are syntactic sugar for functional interfaces. Method references (String::toUpperCase) improve readability.

Streams are lazy pipelines: intermediate ops (filter, map) build plan; terminal op (collect, forEach) triggers. Never mutate external state in streams—use collectors.

Collectors: groupingBy, partitioningBy, summarizingDouble. Parallel streams use ForkJoinPool.commonPool—only for CPU-bound, large data; otherwise overhead hurts.

StreamAnalytics.java
import java.util.*;
import java.util.stream.*;

public class StreamAnalytics {
    record Employee(String name, String dept, double salary) {}

    public static void main(String[] args) {
        List staff = List.of(
            new Employee("Ada","Eng",120_000),
            new Employee("Grace","Eng",135_000),
            new Employee("Linus","Ops",95_000),
            new Employee("Stephen","Eng",110_000),
            new Employee("Margaret","HR",85_000)
        );

        // filter + map + collect
        var highEarners = staff.stream()
            .filter(e -> e.salary() > 100_000)
            .map(Employee::name)
            .toList(); // Java 16

        // grouping and averaging
        Map avgByDept = staff.stream()
            .collect(Collectors.groupingBy(
                Employee::dept,
                Collectors.averagingDouble(Employee::salary)
            ));

        // summarizing
        DoubleSummaryStatistics stats = staff.stream()
            .collect(Collectors.summarizingDouble(Employee::salary));

        System.out.println("High earners: " + highEarners);
        System.out.println("Avg by dept: " + avgByDept);
        System.out.println("Salary stats: " + stats);
    }
}

07 File I/O and NIO.2

Forget java.io.File. Use java.nio.file.Path and Files. For small files: Files.readString()/writeString() (Java 11). For large: Files.lines(path) returns lazy Stream—must close via try-with-resources.

Files.walk() traverses directories depth-first. Use with filter for finding files. Always handle IOException properly—it's checked for a reason.

FileWalker.java
import java.io.IOException;
import java.nio.file.*;

public class FileWalker {
    public static void main(String[] args) throws IOException {
        Path start = Path.of(".");
        try (var stream = Files.walk(start, 3)) {
            long javaFiles = stream
                .filter(p -> p.toString().endsWith(".java"))
                .peek(System.out::println)
                .count();
            System.out.println("Found " + javaFiles + " java files");
        }
    }
}
CsvProcessor.java
import java.nio.file.*;
import java.io.IOException;

public class CsvProcessor {
    public static void main(String[] args) throws IOException {
        Path csv = Path.of("data.csv");
        // create sample
        Files.writeString(csv, "id,name,score\n1,Ada,95\n2,Grace,98\n");

        try (var lines = Files.lines(csv)) {
            double avg = lines.skip(1) // header
                .map(l -> l.split(","))
                .mapToInt(arr -> Integer.parseInt(arr[2]))
                .average()
                .orElse(0);
            System.out.println("Average score: " + avg);
        }
    }
}

08 Concurrency Essentials

Platform threads are OS threads—expensive (~1MB stack). Virtual threads (JDK 21) are JVM-managed, cheap (KBs). You can spawn millions.

Rule: For I/O-bound tasks (HTTP, DB), use virtual threads. For CPU-bound, use platform thread pool sized to cores.

Executors.newVirtualThreadPerTaskExecutor() creates a new virtual thread per task—no pooling needed. CompletableFuture composes async steps without blocking.

VirtualThreadWebFetcher.java
import java.net.URI;
import java.net.http.*;
import java.util.List;
import java.util.concurrent.*;

public class VirtualThreadWebFetcher {
    public static void main(String[] args) throws Exception {
        List urls = List.of(
            "https://example.com",
            "https://httpbin.org/delay/1",
            "https://httpbin.org/get"
        );

        HttpClient client = HttpClient.newHttpClient();

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List> futures = urls.stream()
                .map(url -> CompletableFuture.supplyAsync(() -> {
                    try {
                        var req = HttpRequest.newBuilder(URI.create(url)).GET().build();
                        return client.send(req, HttpResponse.BodyHandlers.ofString()).statusCode() + " " + url;
                    } catch (Exception e) { return "ERR " + url; }
                }, executor))
                .toList();

            futures.forEach(f -> System.out.println(f.join()));
        }
    }
}

09 Networking and Modern HTTP Client

java.net.http.HttpClient (Java 11) supports HTTP/1.1 and HTTP/2, sync and async, WebSocket. It's immutable and thread-safe—create one per app.

For JSON, JDK has no built-in parser. For production use Jackson or Gson, but for learning, parse minimally or treat as string. Pattern: build request, send, handle BodyHandler.

ApiClient.java
import java.net.URI;
import java.net.http.*;
import java.time.Duration;

public class ApiClient {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .version(HttpClient.Version.HTTP_2)
            .build();

        HttpRequest req = HttpRequest.newBuilder()
            .uri(URI.create("https://api.github.com/repos/openjdk/jdk"))
            .header("User-Agent", "JavaDeepDive")
            .GET()
            .build();

        HttpResponse res = client.send(req, HttpResponse.BodyHandlers.ofString());
        System.out.println("Status: " + res.statusCode());
        // crude JSON field extract
        String body = res.body();
        int idx = body.indexOf("\"stargazers_count\":");
        System.out.println(body.substring(idx, idx+30));
    }
}

10 Practical Projects

Five complete, runnable programs using only JDK 21. Copy, compile, run.

Project A: To-Do CLI with persistence

TodoCLI.java
import java.nio.file.*;
import java.util.*;

public class TodoCLI {
    static final Path FILE = Path.of("todos.txt");
    record Todo(int id, String task, boolean done) {}
    public static void main(String[] args) throws Exception {
        if (args.length == 0) { System.out.println("Usage: add/list/done "); return; }
        List lines = Files.exists(FILE) ? Files.readAllLines(FILE) : new ArrayList<>();
        List todos = new ArrayList<>();
        for (String l: lines) { var p=l.split("\\|",3); todos.add(new Todo(Integer.parseInt(p[0]), p[1], Boolean.parseBoolean(p[2]))); }

        switch(args[0]) {
            case "add" -> { int id = todos.stream().mapToInt(Todo::id).max().orElse(0)+1; todos.add(new Todo(id, String.join(" ", Arrays.copyOfRange(args,1,args.length)), false)); }
            case "list" -> todos.forEach(t -> System.out.printf("%d [%s] %s%n", t.id, t.done?"x":" ", t.task));
            case "done" -> { int id = Integer.parseInt(args[1]); todos.replaceAll(t -> t.id==id? new Todo(t.id,t.task,true):t); }
        }
        List out = todos.stream().map(t -> t.id+"|"+t.task+"|"+t.done).toList();
        Files.write(FILE, out);
    }
}

Project B: CSV Sales Analyzer

CsvSalesAnalyzer.java
import java.nio.file.*;
import java.util.*;
import java.util.stream.*;

public class CsvSalesAnalyzer {
    record Sale(String product, int qty, double price) { double total(){ return qty*price; } }
    public static void main(String[] args) throws Exception {
        Path csv = Path.of("sales.csv");
        if (!Files.exists(csv)) Files.writeString(csv, "product,qty,price\nCoffee,3,4.5\nTea,5,2.0\nCoffee,2,4.5\n");
        try (var lines = Files.lines(csv)) {
            Map revenue = lines.skip(1)
                .map(l -> l.split(","))
                .map(a -> new Sale(a[0], Integer.parseInt(a[1]), Double.parseDouble(a[2])))
                .collect(Collectors.groupingBy(Sale::product, Collectors.summingDouble(Sale::total)));
            revenue.forEach((k,v) -> System.out.printf("%s: $%.2f%n", k, v));
        }
    }
}

Project C: Chat Server & Client (virtual threads)

ChatServer.java
import java.io.*;
import java.net.*;
import java.util.concurrent.*;

public class ChatServer {
    static final ConcurrentHashMap clients = new ConcurrentHashMap<>();
    public static void main(String[] args) throws IOException {
        try (ServerSocket ss = new ServerSocket(8080)) {
            System.out.println("Chat on 8080");
            while (true) {
                Socket s = ss.accept();
                Thread.startVirtualThread(() -> handle(s));
            }
        }
    }
    static void handle(Socket s) {
        try (s; var in = new BufferedReader(new InputStreamReader(s.getInputStream()));
             var out = new PrintWriter(s.getOutputStream(), true)) {
            clients.put(s, out);
            out.println("Welcome Stephen!");
            String line;
            while ((line = in.readLine()) != null) {
                for (var w : clients.values()) w.println(line);
            }
        } catch (IOException ignored) {} finally { clients.remove(s); }
    }
}
ChatClient.java
import java.io.*;
import java.net.*;

public class ChatClient {
    public static void main(String[] args) throws IOException {
        try (Socket s = new Socket("localhost",8080);
             var in = new BufferedReader(new InputStreamReader(s.getInputStream()));
             var out = new PrintWriter(s.getOutputStream(), true);
             var sysIn = new BufferedReader(new InputStreamReader(System.in))) {
            Thread.startVirtualThread(() -> in.lines().forEach(System.out::println));
            String line;
            while ((line = sysIn.readLine()) != null) out.println(line);
        }
    }
}

Project D: Swing GUI Calculator

SwingCalculator.java
import javax.swing.*;
import java.awt.*;

public class SwingCalculator {
    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            JFrame f = new JFrame("Calc");
            f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            JTextField display = new JTextField(); display.setEditable(false); display.setFont(new Font("Monospaced",Font.BOLD,24));
            f.add(display, BorderLayout.NORTH);
            JPanel p = new JPanel(new GridLayout(4,4,5,5));
            String[] keys = {"7","8","9","/","4","5","6","*","1","2","3","-","0",".","=","+"};
            final double[] acc = {0}; final String[] op = {""};
            for (String k: keys) {
                JButton b = new JButton(k);
                b.addActionListener(e -> {
                    String t = e.getActionCommand();
                    if (t.matches("[0-9.]")) display.setText(display.getText()+t);
                    else if (t.equals("=")) { double v=Double.parseDouble(display.getText()); acc[0]=switch(op[0]){case "+"->acc[0]+v;case"-"->acc[0]-v;case"*"->acc[0]*v;case"/"->acc[0]/v;default->v;}; display.setText(String.valueOf(acc[0])); op[0]=""; }
                    else { acc[0]=Double.parseDouble(display.getText()); op[0]=t; display.setText(""); }
                });
                p.add(b);
            }
            f.add(p); f.setSize(300,400); f.setVisible(true);
        });
    }
}

Project E: Directory Watcher Auto-Backup

DirectoryWatcher.java
import java.nio.file.*;
import static java.nio.file.StandardWatchEventKinds.*;

public class DirectoryWatcher {
    public static void main(String[] args) throws Exception {
        Path dir = Path.of("watch"); Path backup = Path.of("backup");
        Files.createDirectories(dir); Files.createDirectories(backup);
        WatchService ws = FileSystems.getDefault().newWatchService();
        dir.register(ws, ENTRY_CREATE, ENTRY_MODIFY);
        System.out.println("Watching " + dir.toAbsolutePath());
        while (true) {
            WatchKey key = ws.take();
            for (WatchEvent ev : key.pollEvents()) {
                Path src = dir.resolve((Path)ev.context());
                Path dst = backup.resolve(src.getFileName() + ".bak");
                Files.copy(src, dst, StandardCopyOption.REPLACE_EXISTING);
                System.out.println("Backed up: " + src);
            }
            key.reset();
        }
    }
}

11 Interactive Playground

Quiz: Test Your Depth

Concept Mapper

Click a term to highlight related code examples in the handbook.

record sealed virtual thread stream switch NIO.2 HttpClient

Simulated Code Runner

These run in-browser with precomputed JDK 21 output—edit safely, then hit Run.

Runner 1: HelloWorld
Simulated JDK 21

Runner 2: Stream Filter