Day 2: Intermediate Java

Streams, object orientation, modules, tooling and concurrency.

Subsections of Day 2: Intermediate Java

More Java Ideas

Converting types

double d = 42;             // widening: automatic
int y = (int) 3.99;        // narrowing: cast, truncates → 3
int r = (int) Math.round(3.99);   // 4
int small = (int) bigLong; // may overflow silently

Text ↔ number:

Integer.parseInt("42")
Double.parseDouble("3.14")
Boolean.parseBoolean("true")

String.valueOf(42)
Integer.toString(42)

Integer.parseInt("abc")    // NumberFormatException
Integer.parseInt("ff", 16)   // 255
Integer.toBinaryString(10)   // "1010"
Integer.toHexString(255)     // "ff"
0b1010    0xFF               // literals

Strings, part 2

"a;b;c".split(";")
String.join("|", parts)
"line1\nline2".lines().toList()
"abc".compareTo("abd")       // negative
"Hello".toCharArray()

"%-10s|".formatted("left")      // "left      |"
"%,.2f".formatted(1234567.891)  // "1,234,567.89"
"%08.3f".formatted(3.14159)     // "0003.142"
String.format(Locale.US, "%,.2f", 1999.5);

Enums

A type with a fixed set of values:

enum Status { OPEN, IN_PROGRESS, DONE }

s == Status.OPEN        // == is correct for enums
s.name()                // "OPEN"
Status.valueOf("DONE")
Status.values()

They can carry data and behaviour:

enum Planet {
    EARTH(5.97e24, 6.371e6),
    MARS(6.42e23, 3.390e6);

    private final double mass, radius;

    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
    }

    double gravity() { return 6.67e-11 * mass / (radius * radius); }
}

In a switch the compiler checks completeness — no default needed.

Date and time

java.time types are immutable.

var today = LocalDate.now();
var birthday = LocalDate.of(1995, 4, 23);
var now = LocalDateTime.now();
var zoned = ZonedDateTime.now(ZoneId.of("America/New_York"));

today.plusDays(30)
today.minusMonths(2)
today.getDayOfWeek()
today.isBefore(birthday)

Period.between(birthday, today).getYears()
Duration.ofHours(3).plusMinutes(45).toMinutes()   // 225

var fmt = DateTimeFormatter.ofPattern("MM/dd/yyyy");
today.format(fmt)
LocalDate.parse("2026-12-24")     // ISO needs no formatter
Warning

Do not use java.util.Date, Calendar or SimpleDateFormat in new code.

Randomness

var r = RandomGenerator.getDefault();

r.nextInt(1, 7)       // a die
r.nextDouble()
r.nextBoolean()
r.ints(5, 1, 50).sorted().forEach(IO::println);

Collections.shuffle(cards);

Regular expressions

var email = Pattern.compile("^[\\w.+-]+@[\\w-]+\\.[a-z]{2,}$");
email.matcher("ann@example.com").matches();

var date = Pattern.compile("(\\d{2})/(\\d{2})/(\\d{4})");
var m = date.matcher("due 12/24/2026");
if (m.find()) IO.println(m.group(3) + "-" + m.group(1));

"a1b2".replaceAll("\\d", "#")   // "a#b#"
"a,b;c".split("[,;]")
"Test".matches("[A-Z]\\w+")

Backslashes must be doubled in Java strings: \d"\\d".

Handy helpers

Math.abs  Math.max  Math.min  Math.pow  Math.sqrt
Math.round  Math.floor  Math.ceil  Math.floorDiv

Objects.equals(a, b)      Objects.hash(a, b, c)
Objects.requireNonNull(x) Objects.toString(x, "empty")

List.copyOf(list)         Collections.sort(list)
Collections.reverse(list) Collections.unmodifiableList(list)

★ Exercises

  1. Format an amount as "$1,234.56".
  2. Write enum Weekday with an isWeekend() method.
  3. How many days until your next birthday?
  4. Validate US ZIP codes with a regex.
  5. Roll a die 10,000 times and print the frequency of each face.
  6. Convert "2026-08-12T14:30:00" into "Aug 12, 2026, 2:30 PM".

Lambdas & Streams

Lambdas

A lambda is an unnamed function. It fits anywhere a functional interface — an interface with exactly one method — is expected.

Runnable task = () -> IO.println("running");
Predicate<String> isLong = s -> s.length() > 5;
BinaryOperator<Integer> add = (a, b) -> a + b;

Function<String, String> pretty = s -> {
    var t = s.strip().toLowerCase();
    return t.substring(0, 1).toUpperCase() + t.substring(1);
};
Interface Shape Used by
Predicate<T> T → boolean filter
Function<T,R> T → R map
Consumer<T> T → void forEach
Supplier<T> () → T lazy values
UnaryOperator<T> T → T replaceAll
Comparator<T> (T,T) → int sorted

Method references

s -> s.toUpperCase()        String::toUpperCase
s -> IO.println(s)          IO::println
s -> Integer.parseInt(s)    Integer::parseInt
() -> new ArrayList<>()     ArrayList::new
x -> obj.handle(x)          obj::handle

Streams

Source → any number of intermediate steps → exactly one terminal operation.

var result = names.stream()
    .filter(n -> n.length() > 3)
    .map(String::toUpperCase)
    .sorted()
    .toList();

Intermediate steps are lazy: nothing happens without a terminal operation, and a stream is single-use.

list.stream()
Arrays.stream(array)
Stream.of("a", "b")
IntStream.range(0, 10)          IntStream.rangeClosed(1, 10)
Files.lines(path)
Stream.iterate(1, x -> x * 2).limit(10)
Stream.generate(Math::random).limit(5)

Intermediate: filter map flatMap distinct sorted limit skip peek takeWhile dropWhile

Terminal: toList collect forEach count anyMatch allMatch noneMatch findFirst min max reduce

Number streams

IntStream.rangeClosed(1, 100).sum();            // 5050
ages.stream().mapToInt(Integer::intValue).average().orElse(0);

var stats = ages.stream().mapToInt(Integer::intValue).summaryStatistics();
// min, max, sum, count, average

Collectors

import static java.util.stream.Collectors.*;

record Person(String name, int age, String city) {}

people.stream().collect(groupingBy(Person::city));
people.stream().collect(groupingBy(Person::city, counting()));
people.stream().collect(groupingBy(Person::city, mapping(Person::name, toList())));
people.stream().collect(partitioningBy(p -> p.age() >= 30));
people.stream().collect(toMap(Person::name, Person::age));
people.stream().map(Person::name).collect(joining(", ", "[", "]"));
people.stream().collect(averagingInt(Person::age));

Sorting:

people.stream()
      .sorted(Comparator.comparingInt(Person::age)
                        .thenComparing(Person::name)
                        .reversed());

Optional

A container that may hold a value — it makes “might be missing” visible in the type.

Optional<Person> found = people.stream()
    .filter(p -> p.name().equals("Ann"))
    .findFirst();

found.isPresent()
found.orElse(fallback)
found.orElseGet(() -> loadDefault())
found.orElseThrow(() -> new IllegalStateException("not found"))
found.map(Person::name).orElse("unknown")
found.ifPresent(p -> IO.println(p.name()));
found.ifPresentOrElse(p -> , () -> );
Tip

Use Optional as a return type. Not as a field, parameter or collection element. And get() without checking is as dangerous as an unchecked null.

Gatherers (Java 24+)

Custom intermediate operations:

import java.util.stream.Gatherers;

readings.stream().gather(Gatherers.windowSliding(3)).forEach(IO::println);
readings.stream().gather(Gatherers.windowFixed(2)).toList();

When not to use a stream

  • a plain for loop is shorter or clearer
  • you need break in the middle (though takeWhile often fits)
  • your forEach mutates outside state — that is a smell
  • parallelStream() only pays off for large, side-effect-free workloads: measure

★ Exercises

  1. From 20 numbers, keep the even ones, square them and sum — one chain.
  2. Sort words by length, then alphabetically.
  3. Group words by first letter into Map<Character, List<String>>.
  4. Count word frequency into Map<String, Long>.
  5. Write Optional<Person> oldest(List<Person>). What does it return for an empty list?
  6. Generate the first 15 Fibonacci numbers with Stream.iterate.
  7. Rewrite the word counter from chapter 7 using streams.

Object Oriented Java

Classes

public class Account {

    private final String owner;      // state
    private long cents;

    public Account(String owner, long opening) {   // constructor
        this.owner = owner;
        this.cents = opening;
    }

    public void deposit(long amount) {             // behaviour
        if (amount <= 0) throw new IllegalArgumentException("must be positive");
        cents += amount;
    }

    public long balance() { return cents; }
}

var account = new Account("Ann", 10_000);
account.deposit(5_000);
Modifier Visible in
private the same class
(none) the same package
protected package + subclasses
public everywhere

Fields private, methods as narrow as possible.

public Account(String owner) {
    this(owner, 0);        // delegate to the other constructor
}

Flexible constructor bodies (Java 25)

Statements before this(...) / super(...) are now allowed, so you can validate before an object half-exists:

public Positive(int value) {
    if (value <= 0) throw new IllegalArgumentException("must be positive");
    this.value = value;
}

Records

public record Address(String street, String zip, String city) {}

You get the constructor, accessors, equals, hashCode and toString; all fields are final.

public record Address(String street, String zip, String city) {

    public Address {                                 // compact constructor
        Objects.requireNonNull(street);
        if (!zip.matches("\\d{5}")) throw new IllegalArgumentException("bad zip: " + zip);
        city = city.strip();                         // parameters may be adjusted
    }

    public String oneLine() { return "%s, %s %s".formatted(street, zip, city); }

    public static Address fromCsv(String line) {     // static factory
        var p = line.split(";");
        return new Address(p[0], p[1], p[2]);
    }
}

Records are immutable — “changing” means creating a new one:

public record Person(String name, int age) {
    public Person withAge(int newAge) { return new Person(name, newAge); }
}
Record or class?

Record when the object is its data (DTOs, value objects). Class when it has mutable state or identity — two accounts with the same balance are not the same account.

Interfaces

public interface PaymentMethod {
    boolean pay(long cents);

    default String describe() { return getClass().getSimpleName(); }   // default impl

    static PaymentMethod standard() { return new Invoice(); }          // static method
}

public class CreditCard implements PaymentMethod {
    @Override
    public boolean pay(long cents) {  }
}

A class can implement any number of interfaces. Declare variables by the interface:

List<String> names = new ArrayList<>();
void process(List<String> input) {  }

Inheritance

public abstract class Vehicle {
    protected final String plate;

    protected Vehicle(String plate) { this.plate = plate; }

    public abstract int topSpeed();               // subclasses must implement

    public String describe() { return "vehicle " + plate; }
}

public class Car extends Vehicle {
    private final int hp;

    public Car(String plate, int hp) {
        super(plate);
        this.hp = hp;
    }

    @Override public int topSpeed() { return 50 + hp; }
    @Override public String describe() { return super.describe() + " with " + hp + " hp"; }
}
  • abstract — cannot be instantiated
  • final — class cannot be extended, method cannot be overridden
  • @Override — optional, but lets the compiler check you
Composition over inheritance

“A car is a vehicle” → inheritance. “A car has an engine” → a field. When unsure, prefer interfaces plus composition.

Sealed types

sealed fixes the set of subtypes, so the compiler knows every case:

public sealed interface Event permits SignUp, Order, SignOut {}

public record SignUp(String user) implements Event {}
public record Order(String user, long cents) implements Event {}
public record SignOut(String user) implements Event {}

String log(Event e) {
    return switch (e) {
        case SignUp(String u)          -> u + " signed up";
        case Order(String u, long c)   -> "%s ordered %d cents".formatted(u, c);
        case SignOut(String u)         -> u + " left";
    };   // no default needed
}

sealed interface + record + pattern matching is the modern way to say “a value is one of these things”.

equals and hashCode

Records do this for you. Plain classes do not:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Account a)) return false;
    return owner.equals(a.owner);
}

@Override
public int hashCode() { return Objects.hash(owner); }
Warning

Override equals → override hashCode, or HashMap and HashSet will misbehave.

★ Exercises

  1. Write ShoppingCart with add, remove and total. The internal list must not be mutable from outside.
  2. record Temperature(double celsius) with fahrenheit(), kelvin() and validation against absolute zero.
  3. sealed interface PaymentMethod with Cash, Card, Voucher, plus a fee(long) method using pattern matching.
  4. An interface with a default method and two implementations (email, SMS).
  5. Why is new Person("Ann", 30).equals(new Person("Ann", 30)) true for a record but false for a plain class?
  6. Model a small library: Book, Loan, Member. Decide deliberately what is a record.

Exceptions

Throwable
├── Error                    ← JVM problems, do not catch
│   ├── OutOfMemoryError
│   └── StackOverflowError
└── Exception
    ├── RuntimeException     ← unchecked
    │   ├── NullPointerException
    │   ├── IllegalArgumentException
    │   ├── IllegalStateException
    │   └── NumberFormatException
    └── IOException          ← checked

Checked exceptions describe expected trouble from the outside world; the caller must handle or declare them. Unchecked ones usually mean a bug.

try {
    var text = Files.readString(path);
} catch (IOException e) {
    IO.println("cannot read: " + e.getMessage());
}

String read(Path p) throws IOException {   // or pass it up
    return Files.readString(p);
}

try / catch / finally

try {
    risky();
} catch (NumberFormatException e) {   // specific first
    
} catch (RuntimeException e) {        // general later
    
} finally {
    // always runs
}
e.getMessage()        e.getCause()
e.getStackTrace()     e.printStackTrace()   // debugging only

try-with-resources

Anything AutoCloseable is closed for you, in reverse order, even on failure:

try (var in  = Files.newBufferedReader(source);
     var out = Files.newBufferedWriter(target)) {

    String line;
    while ((line = in.readLine()) != null) {
        out.write(line.toUpperCase());
        out.newLine();
    }
}

Your own exceptions

public class OverdraftException extends RuntimeException {

    private final long missing;

    public OverdraftException(long missing) {
        super("short by %d cents".formatted(missing));
        this.missing = missing;
    }

    public long missing() { return missing; }
}

Always pass the cause along — otherwise you lose the information you need later:

catch (IOException e) {
    throw new SupplierUnavailableException("lookup failed", e);
}

Preconditions

Objects.requireNonNull(from, "from is required");
if (amount <= 0) throw new IllegalArgumentException("amount must be positive: " + amount);
Situation Exception
invalid argument IllegalArgumentException
wrong object state IllegalStateException
argument was null NullPointerException via requireNonNull
not implemented yet UnsupportedOperationException
business rule broken your own type

Common mistakes

// swallowing
try { risky(); } catch (Exception e) { }        // never

// control flow
try { while (true) IO.println(list.get(i++)); }
catch (IndexOutOfBoundsException e) { }         // use a for-each loop

// useless messages
throw new IllegalArgumentException("error");    // say what and which value

Catch where you can actually react — usually high up, not in every helper method.

Cleanup without catching:

try {
    process();
} finally {
    releaseLock();     // exception still propagates
}

★ Exercises

  1. int divide(int a, int b) throwing a helpful ArithmeticException when b == 0.
  2. Write InvalidInputException and use it in a validator.
  3. A method returning an empty list instead of throwing when a file is missing — when is that good, when dangerous?
  4. <T> T withRetry(Supplier<T> task, int attempts) — retry on exception, rethrow the last one.
  5. Create a wrapped exception (cause + wrapper) and print both messages.
  6. What does a method return when try has return 1 and finally has return 2? Try it — then never write it.

Packages, Modules & Libraries

Packages

A package is a namespace and a directory, named after your reversed domain:

src/com/example/shop/Cart.java
src/com/example/shop/pricing/Discount.java
package com.example.shop;

import com.example.shop.pricing.Discount;
import java.util.List;
import java.util.List;              // one class
import java.util.*;                 // whole package (rare in projects)
import static java.lang.Math.PI;    // static member

java.lang (with String, Math, IO) is always imported.

Module imports (Java 25)

One import pulls in every exported package of a module:

import module java.base;

void main() {
    var list = new ArrayList<String>();   // java.util
    var path = Path.of("data.txt");       // java.nio.file
}

In compact source files java.base is imported automatically.

The module system

module-info.java declares what a module needs and exposes:

module com.example.shop {
    requires java.net.http;
    requires transitive java.sql;

    exports com.example.shop;
    exports com.example.shop.api;
    // com.example.shop.internal stays private, even for public classes
}

Worth it for libraries and large applications; unnecessary for small programs.

Standard library map

Package Contents
java.lang String, Math, Thread, IO
java.util collections, Optional, Random
java.util.stream streams and collectors
java.time dates and times
java.nio.file files and paths
java.net.http HTTP client
java.util.concurrent threads, executors, locks
java.util.regex regular expressions
java.math BigDecimal, BigInteger

External libraries

By hand it is painful:

javac -cp libs/jackson-databind-2.18.2.jar -d out src/*.java
java  -cp out:libs/* com.example.Main

So every project uses a build tool.

Maven

<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.example</groupId>
  <artifactId>shop</artifactId>
  <version>1.0.0</version>

  <properties>
    <maven.compiler.release>25</maven.compiler.release>
  </properties>

  <dependencies>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.18.2</version>
    </dependency>
  </dependencies>
</project>
mvn compile   mvn test   mvn package   mvn dependency:tree
src/main/java        production code
src/main/resources   config, text files
src/test/java        tests
target/              build output (do not commit)

Gradle

plugins { application }

java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }

repositories { mavenCentral() }

dependencies {
    implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2")
    testImplementation("org.junit.jupiter:junit-jupiter:5.11.4")
}

application { mainClass = "com.example.shop.Main" }
./gradlew build   ./gradlew run   ./gradlew test
Tip

Maven is rigid but identical everywhere — a good default. Gradle is more flexible and faster on big projects, at the price of learning a small language.

Packaging

jar --create --file shop.jar --main-class com.example.shop.Main -C out .
java -jar shop.jar

A trimmed runtime image with only the modules you need:

jlink --add-modules java.base,java.net.http --output runtime --strip-debug
./runtime/bin/java -cp out com.example.shop.Main

Libraries worth knowing

Purpose Library
JSON Jackson, Gson
tests JUnit 5, AssertJ, Mockito
logging SLF4J + Logback
web Spring Boot, Quarkus, Javalin, Helidon
database JDBC, jOOQ, Hibernate
CLI parsing picocli

★ Exercises

  1. Create a Maven project and get a “hello world” running with mvn package.
  2. Move your chapter 7 solutions into a package and fix the imports.
  3. Add Jackson and round-trip a record to JSON and back.
  4. Write a module-info.java. What happens if you leave out a requires?
  5. Try import module java.base; — which imports can you delete?
  6. Build an executable JAR and run it with java -jar.

Command Line Tools

Reading input

void main() {
    var name = IO.readln("Your name? ");
    IO.println("Hello, " + name + "!");
}

IO.readln returns null at end of input (Ctrl+D, or redirected files) — check for it.

The classic way:

var scanner = new Scanner(System.in);
var n = scanner.nextInt();
scanner.nextLine();          // consume the rest of the line!
var text = scanner.nextLine();
Warning

nextInt() leaves the newline behind, so the next nextLine() returns empty. Safer: read whole lines and parse them yourself.

int readInt(String prompt, int min, int max) {
    while (true) {
        var input = IO.readln(prompt);
        if (input == null) throw new IllegalStateException("input closed");
        try {
            var n = Integer.parseInt(input.strip());
            if (n < min || n > max) { IO.println("out of range"); continue; }
            return n;
        } catch (NumberFormatException e) {
            IO.println("not a number");
        }
    }
}

Parsing arguments

void main(String[] args) {
    String file = null;
    var verbose = false;
    var limit = 10;

    for (var i = 0; i < args.length; i++) {
        switch (args[i]) {
            case "-v", "--verbose" -> verbose = true;
            case "-n", "--limit"   -> limit = Integer.parseInt(args[++i]);
            case "-h", "--help"    -> { help(); return; }
            default -> {
                if (args[i].startsWith("-")) { IO.println("unknown option"); return; }
                file = args[i];
            }
        }
    }
    if (file == null) { help(); return; }
    run(file, limit, verbose);
}

void help() {
    IO.println("""
        usage: java Tool.java [options] <file>

          -n, --limit N   number of results (default 10)
          -v, --verbose   verbose output
          -h, --help      this help
        """);
}

For anything bigger, use picocli:

@Command(name = "count", mixinStandardHelpOptions = true, version = "1.0")
public class Count implements Runnable {

    @Parameters(index = "0", description = "file to read")
    private Path file;

    @Option(names = {"-n", "--limit"}) private int limit = 10;

    @Override public void run() {  }

    public static void main(String[] args) {
        System.exit(new CommandLine(new Count()).execute(args));
    }
}

Formatting output

IO.println("%-20s %8s %6s".formatted("Item", "Price", "Qty"));
IO.println("-".repeat(36));
items.forEach(i -> IO.println("%-20s %8.2f %6d".formatted(i.name(), i.price(), i.qty())));
void progress(int done, int total) {
    var width = 30;
    var filled = done * width / total;
    System.out.print("\r[%s%s] %3d%%".formatted(
        "#".repeat(filled), " ".repeat(width - filled), done * 100 / total));
}
final String RED   = "\u001B[31m";
final String GREEN = "\u001B[32m";
final String RESET = "\u001B[0m";

IO.println(GREEN + "OK" + RESET);

Streams and exit codes

System.out.println("normal output");   // stdout, can be piped
System.err.println("error message");   // stderr, stays visible

System.exit(0);   // success
System.exit(1);   // failure  scripts and CI depend on this
java Tool.java data.txt | sort | head -5
java Tool.java data.txt 2> errors.log

Environment and properties

System.getenv("HOME")
System.getProperty("user.name")
System.getProperty("os.name")
System.getProperty("mode", "production")   // java -Dmode=test 

A complete tool

import java.nio.file.*;

void main(String[] args) {
    if (args.length == 0) {
        System.err.println("usage: java Search.java <pattern> [file…]");
        System.exit(2);
    }

    var pattern = args[0].toLowerCase();
    var hits = 0;

    for (var i = 1; i < args.length; i++) {
        var path = Path.of(args[i]);
        try (var lines = Files.lines(path)) {
            var n = new int[]{0};
            hits += (int) lines.peek(l -> n[0]++)
                .filter(l -> l.toLowerCase().contains(pattern))
                .peek(l -> IO.println("%s:%d: %s".formatted(path, n[0], l.strip())))
                .count();
        } catch (java.io.IOException e) {
            System.err.println("cannot read " + path);
        }
    }

    IO.println("%d hits".formatted(hits));
    System.exit(hits > 0 ? 0 : 1);
}

Making it executable

On macOS and Linux, with no .java extension:

#!/usr/bin/env java --source 25

void main() {
    IO.println("running as a script!");
}
chmod +x tool
./tool

★ Exercises

  1. An interactive calculator: the user types 3 + 4, you answer, until they type exit.
  2. A simple wc: count lines, words and characters of a file.
  3. Add -l, -w, -c options to it.
  4. A quiz with five questions and a percentage score at the end.
  5. Print a price list as an aligned table with a totals row.
  6. Return exit code 2 for bad arguments and 1 for “nothing found”. Check with echo $?.

Virtual Threads & Concurrency

A classic platform thread maps to an OS thread: ~1 MB of stack, expensive to create, so they were pooled and rationed.

var t = new Thread(() -> IO.println("in parallel"));
t.start();
t.join();

Virtual threads

Managed by the JVM, not the OS. They cost almost nothing, and when one blocks it releases its carrier thread.

Thread.startVirtualThread(() -> {
    Thread.sleep(Duration.ofSeconds(1));
    IO.println("done");
});

The programming model stays the simplest one there is: write blocking code.

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var i = 0; i < 10_000; i++) {
        var n = i;
        executor.submit(() -> { Thread.sleep(Duration.ofMillis(100)); return n; });
    }
}   // close() waits for all tasks
Note

Virtual threads help waiting work (HTTP, database, files). For computing work use a pool sized to your CPU cores.

Executors and futures

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {

    Future<Integer> future = executor.submit(() -> expensive());
    var result = future.get();          // blocks until ready
}

var tasks = List.<Callable<String>>of(
    () -> fetch("https://example.test/a"),
    () -> fetch("https://example.test/b"));

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var f : executor.invokeAll(tasks)) IO.println(f.get());
}

Structured concurrency (preview in 25)

Subtasks that belong together should live and die together: if one fails, the others are cancelled.

// run with: java --enable-preview --source 25 File.java
import java.util.concurrent.StructuredTaskScope;

record Profile(String details, String orders) {}

Profile loadProfile(String id) throws Exception {
    try (var scope = StructuredTaskScope.open()) {

        var details = scope.fork(() -> loadDetails(id));
        var orders  = scope.fork(() -> loadOrders(id));

        scope.join();      // waits for both, cancels on failure

        return new Profile(details.get(), orders.get());
    }
}

No orphaned threads, no forgotten cleanup, readable stack traces.

Scoped values (Java 25)

ScopedValue replaces ThreadLocal for passing context (user, request id, tenant) down the call stack. Immutable and cleaned up automatically.

final static ScopedValue<String> USER = ScopedValue.newInstance();

void handleRequest(String user) {
    ScopedValue.where(USER, user).run(() -> {
        checkPermissions();
        writeAuditLog();      // sees USER without a parameter
    });
}

void writeAuditLog() {
    IO.println("action by " + USER.get());
}

Shared state is the hard part

var counter = new int[1];

try (var e = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var i = 0; i < 1000; i++) e.submit(() -> counter[0]++);   // broken: not atomic
}
IO.println(counter[0]);   // almost never 1000

Fixes:

var counter = new AtomicInteger();          // 1. atomic types
counter.incrementAndGet();

var map = new ConcurrentHashMap<String, Integer>();   // 2. concurrent collections
map.merge("a", 1, Integer::sum);

synchronized (lock) { balance++; }           // 3. locking

var total = list.parallelStream().mapToInt(this::score).sum();   // 4. share nothing
Tip

Immutable objects (records!) are thread-safe by construction. Less shared mutable state means less synchronisation — and fewer bugs that only show up in production.

Example: many HTTP calls at once

void main() throws Exception {
    var urls = List.of("https://example.com", "https://example.org", "https://example.net");
    var start = System.nanoTime();

    try (var client = HttpClient.newHttpClient();
         var executor = Executors.newVirtualThreadPerTaskExecutor()) {

        var results = executor.invokeAll(urls.stream()
            .map(url -> (Callable<String>) () -> {
                var response = client.send(
                    HttpRequest.newBuilder(URI.create(url)).build(),
                    HttpResponse.BodyHandlers.ofString());
                return "%s → %d".formatted(url, response.statusCode());
            })
            .toList());

        for (var r : results) IO.println(r.get());
    }

    IO.println("took %d ms".formatted((System.nanoTime() - start) / 1_000_000));
}

Also worth knowing

Tool Purpose
CompletableFuture chaining async steps
CountDownLatch wait for n tasks
Semaphore limit concurrent access
BlockingQueue producer/consumer
ReentrantLock more flexible than synchronized

★ Exercises

  1. Start 100,000 virtual threads that sleep 100 ms. How long does it take? Try the same with platform threads.
  2. Reproduce the broken counter and fix it three different ways.
  3. Fetch ten pages in parallel, printing status and body size.
  4. Limit concurrent requests to three with a Semaphore.
  5. Use ScopedValue to add a request id to every log line without passing it around.
  6. Try StructuredTaskScope with --enable-preview: two tasks, one throws. What happens to the other?

Tests & Web Frameworks

A test is code that checks other code. The payoff is not in writing it — it is in changing code later without guessing what you broke.

JUnit 5

<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>5.11.4</version>
  <scope>test</scope>
</dependency>

Tests live in src/test/java, mirroring the package structure.

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class DiscountTest {

    @Test
    void tenPercentOffOneHundred() {
        var discount = new Discount(0.10);          // arrange

        var result = discount.applyTo(10_000);      // act

        assertEquals(9_000, result);                // assert
    }

    @Test
    void negativeDiscountIsRejected() {
        assertThrows(IllegalArgumentException.class, () -> new Discount(-0.1));
    }
}
mvn test

One behaviour per test, and the method name states the rule — not the method being called.

Assertions

assertEquals(expected, actual);
assertEquals(3.14, value, 0.001);      // delta for doubles
assertTrue(x);   assertFalse(x);
assertNull(x);   assertNotNull(x);
assertArrayEquals(new int[]{1, 2}, result);

assertThrows(IllegalArgumentException.class, () -> method());
assertDoesNotThrow(() -> method());

assertAll("person",
    () -> assertEquals("Ann", p.name()),
    () -> assertEquals(30, p.age()));

AssertJ reads better:

assertThat(result).isEqualTo(9_000);
assertThat(names).hasSize(3).contains("Ann").doesNotContain("Zoe");
assertThatThrownBy(() -> new Discount(-1))
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessageContaining("negative");

Lifecycle

@BeforeEach void setUp()      { cart = new ShoppingCart(); }
@AfterEach  void tearDown()   {  }
@BeforeAll  static void once() {  }
@AfterAll   static void done() {  }

Parameterised tests

@ParameterizedTest
@ValueSource(ints = {2, 4, 6, 100})
void evenNumbers(int n) {
    assertTrue(Numbers.isEven(n));
}

@ParameterizedTest
@CsvSource({"2020, true", "1900, false", "2000, true", "2023, false"})
void leapYears(int year, boolean expected) {
    assertEquals(expected, Calendars.isLeapYear(year));
}

Other useful annotations: @DisplayName, @Disabled, @Nested, @Tag, @Timeout.

Test doubles

@Test
void orderIsStored() {
    var repo = mock(OrderRepository.class);
    when(repo.nextId()).thenReturn(42L);

    new OrderService(repo).place(new Order("Ann", 1000));

    verify(repo).save(any(Order.class));
}
Tip

Mock sparingly — the more you mock, the more you test your assumptions instead of your code. Pure logic needs no mocks at all.

What to test

  • edge cases: empty, null, 0, negative, maximum
  • error paths: is the right exception thrown?
  • every business rule, one test each
  • every bug you find: first the failing test, then the fix

Skip getters, setters and generated record code.

Web frameworks

Javalin — minimal

void main() {
    var app = Javalin.create().start(7070);

    app.get("/hello", ctx -> ctx.result("Hello World"));
    app.get("/person/{name}", ctx -> ctx.json(new Person(ctx.pathParam("name"), 30)));
    app.post("/person", ctx -> {
        var p = ctx.bodyAsClass(Person.class);
        ctx.status(201).json(p);
    });
}

Spring Boot — the industry default

@RestController
public class PersonController {

    private final PersonRepository repo;

    public PersonController(PersonRepository repo) { this.repo = repo; }

    @GetMapping("/people")
    public List<Person> all() { return repo.findAll(); }

    @GetMapping("/people/{id}")
    public Person one(@PathVariable Long id) {
        return repo.findById(id).orElseThrow(() -> new NotFoundException(id));
    }

    @PostMapping("/people")
    @ResponseStatus(HttpStatus.CREATED)
    public Person create(@RequestBody @Valid Person p) { return repo.save(p); }
}

Generate a project skeleton at start.spring.io.

No framework at all

void main() throws Exception {
    var server = HttpServer.create(new InetSocketAddress(8080), 0);

    server.createContext("/hello", exchange -> {
        var body = "Hello World".getBytes();
        exchange.sendResponseHeaders(200, body.length);
        try (var os = exchange.getResponseBody()) { os.write(body); }
    });

    server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
    server.start();
}

Where to go next

  • build something you actually want to use
  • connect Spring Boot or Quarkus to a database
  • run your tests in CI (GitHub Actions: mvn verify)
  • read the JDK sources — they ship with the JDK and are surprisingly readable

See Resources.

★ Exercises

  1. Test isPalindrome from chapter 3 — including empty string, null and punctuation.
  2. Write a parameterised leap-year test with at least six cases.
  3. Write a failing test for a method that does not exist yet, then implement it.
  4. Fully test the ShoppingCart from chapter 11.
  5. Start the built-in HTTP server and call it with curl.
  6. Add a /time endpoint returning the current time as JSON.