Learn Java 25

A two-day course on modern Java, based on Java 25 (LTS). No Java experience needed. Programming experience in any language helps.

Everything here runs on a plain JDK 25 install β€” no build tool, no framework, no boilerplate.

Why Java 25?

Java 25 is the current LTS release (September 2025), the successor to Java 21. It adds compact source files, java.lang.IO, module imports, flexible constructor bodies and scoped values.

Course outline

Introduction Setup Β· JShell
Day 1 types, methods, collections, boolean logic, control flow, files, HTTP
Day 2 streams, OOP, exceptions, modules, CLI tools, virtual threads, tests
Appendix Cheat sheet Β· Resources

Each chapter ends with β˜… Exercises. Type the samples instead of pasting them β€” reading compiler errors is how you learn fastest.

What you need

  • A JDK 25 build (Temurin, Corretto, Oracle OpenJDK)
  • VS Code with the Java extension pack, or IntelliJ IDEA Community
  • A terminal β€” every sample runs with java and jshell

Start with the setup β†’

Subsections of Learn Java 25

Course Introduction

Get a JDK 25 running, then meet JShell β€” the fastest way to try Java out.

Subsections of Course Introduction

Setup: JDK 25 & Editor

Install the JDK

You need the JDK (compiler + tools), not just a JRE. Pick any OpenJDK build: Eclipse Temurin, Amazon Corretto or Oracle OpenJDK.

# macOS
brew install --cask temurin@25

# Linux
sudo apt install openjdk-25-jdk

# Windows
winget install EclipseAdoptium.Temurin.25.JDK

# any platform, several versions side by side
sdk install java 25-tem      # sdkman.io

Verify:

java --version     # openjdk 25 …
javac --version
No javac?

You installed a JRE, or the JDK’s bin folder is not on your PATH.

Editor

  • VS Code β€” install Extension Pack for Java
  • IntelliJ IDEA Community β€” set Project SDK and language level to 25

Your first program

Hello.java:

void main() {
    IO.println("Hello, Java 25!");
}
java Hello.java      # runs directly, no compile step
New in Java 25

Compact source files and instance main methods (JEP 512) drop the class shell, static, and String[] args. The always-available java.lang.IO class provides println, print and readln. The classic form still works β€” see chapter 7.

Compiling by hand

javac Hello.java     # produces Hello.class
java Hello           # no file extension!

β˜… Exercises

  1. Install JDK 25 and print java --version.
  2. Write Profile.java printing three lines about yourself.
  3. Compile it with javac. What file appears, and how big is it?
  4. Remove a semicolon on purpose and read the compiler error: file, line, column.

The REPL: JShell

JShell is a Read–Eval–Print Loop shipped with the JDK. Use it whenever you want to try something out without creating a file.

jshell
jshell> 2 + 3 * 4
$1 ==> 14

jshell> var name = "World"
name ==> "World"

jshell> "Hello, " + name + "!"
$3 ==> "Hello, World!"

Semicolons are optional. Results without a variable get names like $1, which you can reuse.

Declarations work too

jshell> int square(int x) { return x * x; }
|  created method square(int)

jshell> square(12)
$5 ==> 144

jshell> record Point(int x, int y) {}
jshell> new Point(3, 4)
$7 ==> Point[x=3, y=4]

Redefining a method simply replaces it.

Commands

Command Does
/help list all commands
/vars /methods /types what you have defined
/list code so far, numbered
/edit 3 edit snippet 3
/save f.jsh /open f.jsh save / load a session
/imports the automatic imports
/reset start over
/exit quit

java.util, java.io, java.math, java.net, java.util.stream and java.util.function are imported for you:

jshell> List.of("a", "b").stream().map(String::toUpperCase).toList()
$1 ==> [A, B]

Shortcuts

  • Tab completes names: "text".to + Tab lists matching methods.
  • Shift+Tab, then v turns the expression you just typed into a variable declaration.

β˜… Exercises

  1. Compute how many seconds are in a year.
  2. Declare name and print a greeting with it.
  3. Define isEven(int) and test it with five values.
  4. Use Tab completion to list String methods starting with str.
  5. /save your session, /reset, then /open it again.

Day 1: Intro to Java

The language basics: types, methods, collections, logic, control flow, files and a first HTTP call.

Subsections of Day 1: Intro to Java

Why Java?

Java compiles to bytecode, which runs on the JVM β€” the same .class file works on Linux, macOS and Windows. A new release ships every six months, an LTS release every two years.

Version Year Highlights
8 2014 lambdas, streams, Optional, java.time
11 LTS 2018 HTTP client
17 LTS 2021 records, sealed classes, text blocks, switch expressions
21 LTS 2023 virtual threads, pattern matching for switch
25 LTS 2025 compact source files & IO, module imports, scoped values

Where it is used

Backends (Spring Boot, Quarkus), Android, big data (Kafka, Spark, Elasticsearch), and most developer tooling.

Java β‰  JavaScript

Unrelated languages. The name was a 1995 marketing decision.

Three acronyms

  • JDK β€” compiler, JShell, tools (what you install)
  • JRE β€” JVM + standard library
  • JVM β€” runs bytecode, compiles hot code to machine code (JIT), collects garbage

You never free memory manually.

Static typing

Types are checked at compile time:

int age = 30;
age = "thirty";     // compile error

var infers the type β€” it does not make Java dynamic:

var age = 30;                        // int
var names = new ArrayList<String>(); // ArrayList<String>

Conventions

Element Style Example
classes, records, interfaces UpperCamelCase BankAccount
methods, variables lowerCamelCase computeTotal
constants UPPER_SNAKE_CASE MAX_SIZE
packages all lowercase com.example.shop
file name = public class name BankAccount.java

Four spaces of indentation, opening brace on the same line.

class Account {
    private final String owner;

    Account(String owner) {
        this.owner = owner;
    }
}

Comments

// line

/* block */

/**
 * Javadoc β€” rendered to HTML docs.
 * @param amount deposit in cents
 * @return the new balance
 */

What Java does not have

  • free functions (every method belongs to a type)
  • multiple class inheritance (interfaces instead)
  • pointer arithmetic or manual memory management
  • operator overloading (+ on strings is the one exception)

β˜… Exercises

  1. Which Java version is active on your machine? Is it an LTS?
  2. Rewrite in proper Java style: max retry count (constant), user profile (class), load data (method).
  3. Explain JDK vs JRE vs JVM in your own words.
  4. Why does var x; not compile? Try it in JShell and read the error.

Basic Data Types

Variables

int count = 42;
double price = 19.99;
boolean active = true;
String name = "Ann";

var count2 = 42;        // same thing, type inferred
final double VAT = 0.19;  // constant

var works for local variables only β€” not fields, parameters or return types.

Tip

Make everything final that never changes. final var works too.

The eight primitives

Type Size Use Default
byte 8 bit βˆ’128…127 0
short 16 bit βˆ’32,768…32,767 0
int 32 bit default integer type 0
long 64 bit big integers, suffix L 0L
float 32 bit suffix f 0.0f
double 64 bit default decimal type 0.0
char 16 bit one character, single quotes 'οΏ½'
boolean – true / false false
long population = 8_500_000_000L;   // underscores for readability
char initial = 'A';

Arithmetic

7 / 2       // 3   ← integer division!
7 % 2       // 1
7 / 2.0     // 3.5

int max = Integer.MAX_VALUE;
max + 1                  // overflows silently
Math.addExact(max, 1)    // throws ArithmeticException

0.1 + 0.2   // 0.30000000000000004

For money use BigDecimal, or count cents in a long:

new BigDecimal("0.1").add(new BigDecimal("0.2"))   // 0.3
var n = 0;
n += 5; n -= 2; n *= 4; n++; n--;

Wrappers and autoboxing

Every primitive has an object twin: Integer, Long, Double, Boolean, Character. Collections need those, and conversion is automatic.

List<Integer> nums = new ArrayList<>();
nums.add(42);          // int β†’ Integer
int first = nums.get(0);
Warning

Wrappers can be null. Integer x = null; int y = x; throws a NullPointerException.

Strings

Strings are immutable β€” every method returns a new one.

var text = "Hello World";

text.length()             // 11
text.toUpperCase()
text.charAt(0)            // 'H'
text.substring(6)         // "World"
text.contains("World")    // true
text.indexOf("World")     // 6
text.replace("World", "Java")
text.split(" ")           // ["Hello", "World"]
"  edge  ".strip()
"   ".isBlank()           // true
"ab".repeat(3)            // "ababab"

Building strings

var s1 = name + " is " + age;
var s2 = "%s is %d".formatted(name, age);
var s3 = String.join(", ", "red", "green", "blue");
Format Meaning
%s any value as text
%d integer, %05d β†’ 00042
%.2f two decimals
%n newline

Comparing strings

a == b          // reference identity β€” almost never what you want
a.equals(b)     // content
a.equalsIgnoreCase(b)
The classic beginner bug

Use equals for objects, == only for primitives and enum constants.

Text blocks

var json = """
    {
      "name": "Ann",
      "role": "developer"
    }
    """;

Indentation is stripped relative to the closing """; no escaping of quotes needed.

StringBuilder

var sb = new StringBuilder();
for (var i = 1; i <= 5; i++) sb.append(i).append(" ");
sb.toString().strip();   // "1 2 3 4 5"

Input and output

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

The classic way still works: System.out.println(...), System.err.println(...).

β˜… Exercises

  1. Store your name, birth year and height in suitable types and print one sentence.
  2. Compute your age in days (365 per year is fine).
  3. Write three versions of 5 / 2 that yield 2.5.
  4. Split "ann,miller,berlin" and print each part capitalized on its own line.
  5. Build a small HTML page with a text block and print it.
  6. Why is 0.1 + 0.2 == 0.3 false? How would you compare instead?

Methods

Return type, name, parameters, body:

int add(int a, int b) {
    return a + b;
}

void greet(String name) {          // void = returns nothing
    IO.println("Hello, " + name);
}

Arguments are copies

Java is always pass-by-value. For objects the reference is copied β€” you can mutate the object, but not reassign the caller’s variable.

void fill(List<String> list) {
    list.add("new");            // visible to the caller
    list = new ArrayList<>();   // local only
}

Overloading

Same name, different parameter lists:

int area(int side)                { return side * side; }
int area(int width, int height)   { return width * height; }
double area(double radius)        { return Math.PI * radius * radius; }

A different return type alone is not enough.

Varargs

int sum(int... numbers) {
    var total = 0;
    for (var n : numbers) total += n;
    return total;
}

sum();            // 0
sum(1, 2, 3);     // 6

Must be the last parameter.

Scope

A variable lives inside its block:

void demo() {
    var outer = 1;
    if (outer > 0) {
        var inner = 2;
    }
    IO.println(inner);   // error: does not exist here
}

static methods belong to the class, not an instance:

public class MathHelper {
    public static int square(int x) { return x * x; }
}

MathHelper.square(5);

Writing good methods

  • one job per method, and the name says it β€” if you need “and”, split it
  • verbs first: computeDiscount, loadCustomer, isValid
  • more than three parameters? use a record instead
  • return early instead of nesting
String grade(int points) {
    if (points < 0 || points > 100) return "invalid";
    return points >= 50 ? "pass" : "fail";
}

Recursion

long factorial(int n) {
    if (n <= 1) return 1;        // base case
    return n * factorial(n - 1);
}

Java has no tail-call optimisation β€” deep recursion throws StackOverflowError.

β˜… Exercises

  1. isPalindrome(String) β€” ignore case.
  2. Overload max three ways: two int, two double, any number of int.
  3. countVowels(String).
  4. Write Fibonacci recursively and iteratively; time both for n = 40 with System.nanoTime().
  5. formatName(String first, String last) β†’ "Miller, Ann", tolerating stray spaces and mixed case.

Arrays & Collections

Arrays

Fixed length, zero-based:

int[] numbers = new int[5];
String[] colors = {"red", "green", "blue"};

colors.length     // 3 β€” a field, not a method
colors[1]         // "green"
colors[3]         // ArrayIndexOutOfBoundsException

Arrays.sort(numbers);
Arrays.toString(numbers);

Arrays are rigid. In practice you use a List.

List

Ordered, resizable, duplicates allowed:

var names = new ArrayList<String>();
names.add("Ann");
names.add("Ben");

names.get(0)            // "Ann"
names.size()            // 2
names.contains("Ben")   // true
names.indexOf("Ben")    // 1
names.remove("Ben");
names.set(0, "Anne");
names.isEmpty()

List<String> means “list of strings” β€” the compiler keeps everything else out. On the right side the diamond <> is enough: List<String> names = new ArrayList<>();

var days = List.of("Mon", "Tue", "Wed");   // immutable
days.add("Thu");                            // UnsupportedOperationException

var copy = new ArrayList<>(days);           // mutable copy
Tip

Default to List.of(...). Immutable is the common case and prevents accidental changes.

for (var name : names) IO.println(name);

for (var i = 0; i < names.size(); i++) IO.println(i + ": " + names.get(i));

names.forEach(IO::println);

Set

No duplicates. HashSet (unordered), LinkedHashSet (insertion order), TreeSet (sorted).

var tags = new HashSet<String>();
tags.add("java");
tags.add("java");     // no effect
tags.size()           // 1

var a = Set.of(1, 2, 3, 4);
var b = Set.of(3, 4, 5);

var intersection = new HashSet<>(a); intersection.retainAll(b);   // [3, 4]
var union        = new HashSet<>(a); union.addAll(b);             // [1..5]
var difference   = new HashSet<>(a); difference.removeAll(b);     // [1, 2]

Map

Keys β†’ values, keys are unique.

var ages = new HashMap<String, Integer>();
ages.put("Ann", 30);
ages.put("Ann", 31);            // overwrites

ages.get("Ann")                 // 31
ages.get("Zoe")                 // null
ages.getOrDefault("Zoe", 0)     // 0
ages.containsKey("Ann")
ages.remove("Ann");

Useful patterns:

counts.merge("java", 1, Integer::sum);              // count things
counts.putIfAbsent("course", 1);
groups.computeIfAbsent("a", k -> new ArrayList<>()).add("Ann");

for (var e : ages.entrySet()) IO.println(e.getKey() + " " + e.getValue());
ages.forEach((name, age) -> IO.println(name + ": " + age));

var capitals = Map.of("Germany", "Berlin", "France", "Paris");   // immutable

Which one?

Need Type
ordered, duplicates ok ArrayList
no duplicates, fast lookup HashSet
no duplicates, sorted TreeSet
key β†’ value HashMap
key β†’ value, sorted TreeMap
queue / stack ArrayDeque

Records: bundling data

When values belong together, use a type β€” not a map:

record Person(String name, int age) {}

var ann = new Person("Ann", 30);
ann.name()      // "Ann"
IO.println(ann) // Person[name=Ann, age=30]

You get the constructor, accessors, equals, hashCode and toString for free β€” which also makes records good map keys:

record Coordinate(int x, int y) {}

var map = new HashMap<Coordinate, String>();
map.put(new Coordinate(1, 2), "treasure");
map.get(new Coordinate(1, 2));   // "treasure" β€” different object, equal content

More on records in chapter 11.

β˜… Exercises

  1. Sum, average, min and max of ten numbers, using a loop.
  2. Remove duplicates from a list β€” once with a Set, once by hand.
  3. Count letter frequency of a sentence in a Map<Character, Integer>.
  4. Build a phone book Map<String, String> with add, lookup and delete methods.
  5. Define record Item(String name, double price, int qty) and total a cart of five items.
  6. Turn a List<String> into a Map<Integer, List<String>> grouped by word length.

Boolean Logic

There is no “truthy”

A condition must be a boolean. Nothing else.

if (text) { … }              // error
if (!text.isEmpty()) { … }   // ok

if (count) { … }             // error
if (count != 0) { … }        // ok

Comparison

Operator Meaning
== != equal / not equal (identity for objects)
< > <= >= ordering
a.equals(b)              // content comparison
Objects.equals(a, b)     // null-safe on both sides

The wrapper trap β€” small values are cached:

Integer x = 127, y = 127;
Integer p = 128, q = 128;

x == y        // true  (cache βˆ’128…127)
p == q        // false
p.equals(q)   // true

Logical operators

age >= 18 && hasId      // and
age < 18 || hasId       // or
!hasId                  // not

&& and || short-circuit β€” the right side is skipped when the result is already known. That protects you:

if (name != null && name.length() > 3) { … }   // safe
if (name != null &  name.length() > 3) { … }   // NPE: & always evaluates both
a b a && b a || b !a
true true true true false
true false false true false
false true false true true
false false false false true

Ternary

var status = points >= 50 ? "pass" : "fail";

Fine for simple cases, never nest it β€” use switch instead.

Dealing with null

String name = null;
name.length();   // NullPointerException

Java tells you exactly what was null:

Cannot invoke "String.length()" because "name" is null

Strategies:

if (name != null && !name.isBlank()) { … }         // check
Objects.requireNonNullElse(name, "unknown");       // fallback
Objects.requireNonNull(name, "name is required");  // fail fast
Optional<String> found = find("Ann");              // see chapter 10
Tip

Never return null for a collection β€” return an empty one. It saves your callers hundreds of null checks.

Keep conditions readable

var isAdult    = c.age() >= 18;
var isDomestic = c.country().equals("US");
var isActive   = !c.blocked() && c.balance() > 0;

if (isAdult && isDomestic && isActive) { … }

De Morgan: !(a && b) ≑ !a || !b, and !(a || b) ≑ !a && !b.

β˜… Exercises

  1. boolean isLeapYear(int year) β€” divisible by 4, not by 100, unless by 400.
  2. Why is "a" == "a" often true, yet unreliable? Try it in JShell.
  3. Rewrite !(age < 18 || blocked) without the leading !.
  4. boolean isValidPassword(String) β€” 8+ chars, a digit, an uppercase letter; null is invalid but must not throw.
  5. What does 1 == 1.0 print, and why?

Loops & Control Flow

if / else

if (temp > 30) {
    IO.println("hot");
} else if (temp > 15) {
    IO.println("mild");
} else {
    IO.println("cold");
}

Braces are optional for a single statement β€” always write them anyway.

switch

Old form, needs break or it falls through:

switch (day) {
    case 1:
    case 7:
        IO.println("weekend");
        break;
    default:
        IO.println("weekday");
}

Arrow form β€” no fallthrough, and it produces a value:

var label = switch (day) {
    case 1, 7 -> "weekend";
    case 2, 3, 4, 5, 6 -> "weekday";
    default -> throw new IllegalArgumentException("bad day: " + day);
};

var text = switch (grade) {
    case 1 -> "excellent";
    default -> {
        var s = "grade " + grade;
        yield s.toUpperCase();     // yield inside a block
    }
};

Pattern matching

String describe(Object o) {
    return switch (o) {
        case Integer i when i > 100 -> "big number: " + i;
        case Integer i              -> "number: " + i;
        case String s               -> "text of %d chars".formatted(s.length());
        case int[] a                -> "array of " + a.length;
        case null                   -> "nothing";
        default                     -> "unknown";
    };
}

when adds a guard. Without case null, a null value throws.

Record patterns

sealed interface Shape permits Circle, Rect {}
record Circle(double radius) implements Shape {}
record Rect(double w, double h) implements Shape {}

double area(Shape s) {
    return switch (s) {
        case Circle(double r)    -> Math.PI * r * r;
        case Rect(double w, double h) -> w * h;
    };
}
Note

No default needed: because Shape is sealed, the compiler knows every case β€” and will flag this switch if you add another shape later.

instanceof with a pattern

if (o instanceof String s && s.length() > 3) {
    IO.println(s.toUpperCase());
}

Loops

for (var name : names) { … }                  // for-each: the default

for (var i = 0; i < 5; i++) { … }             // when you need the index
for (var i = 10; i > 0; i -= 2) { … }

while (rest > 1) { rest /= 2; }

do { answer = IO.readln("again? "); }         // runs at least once
while (!answer.equals("n"));

break and continue

for (var n : numbers) {
    if (n % 2 != 0) continue;   // skip
    if (n > 15) break;          // leave the loop
    IO.println(n);
}

Labels exist for nested loops, but extracting a method and return is usually cleaner:

outer:
for (…) { for (…) { if (…) break outer; } }

Putting it together

void main() {
    var secret = new java.util.Random().nextInt(1, 101);
    var tries = 0;

    while (true) {
        var input = IO.readln("Guess 1-100: ");
        int guess;
        try {
            guess = Integer.parseInt(input.strip());
        } catch (NumberFormatException e) {
            IO.println("Not a number.");
            continue;
        }

        tries++;
        switch (Integer.compare(guess, secret)) {
            case -1 -> IO.println("too low");
            case  1 -> IO.println("too high");
            default -> {
                IO.println("Got it in %d tries!".formatted(tries));
                return;
            }
        }
    }
}

β˜… Exercises

  1. Print a multiplication table from 1 to 10, aligned with "%4d".
  2. FizzBuzz for 1–100 β€” once with if/else, once with a switch expression.
  3. Print all primes up to n.
  4. Write String classify(Object o) handling Integer, Double, String, List and null.
  5. Add a Triangle to the shapes above. What does the compiler say about your old switch?
  6. Change the guessing game to stop after 7 wrong guesses.

Working With Java Programs

The classic structure

public class Calculator {

    public static void main(String[] args) {
        System.out.println("start");
    }
}
  • public class Calculator β€” file must be Calculator.java
  • static β€” the JVM can call it without an object
  • String[] args β€” command line arguments

The compact form (Java 25) does the same thing:

void main() {
    IO.println("start");
}

Both may contain fields and methods:

final double VAT = 0.19;

double gross(double net) { return net * (1 + VAT); }

void main() { IO.println(gross(100)); }
Note

Compact for scripts and exercises, classic for real projects. The rest of the language is identical.

Compile and run

java Calculator.java          # run a single file directly
javac Calculator.java         # produce Calculator.class
java Calculator

javac -d out $(find src -name "*.java")
java -cp out com.example.Calculator
void main(String[] args) {
    if (args.length < 2) {
        IO.println("usage: java Calculator.java <a> <b>");
        return;
    }
    var a = Integer.parseInt(args[0]);
    var b = Integer.parseInt(args[1]);
    IO.println(a + b);
}

Reading errors

Compile time β€” the program never runs:

Calculator.java:5: error: incompatible types: String cannot be converted to int
    int x = "hello";
            ^

Runtime β€” read the stack trace bottom-up:

Exception in thread "main" java.lang.ArithmeticException: / by zero
        at Calculator.divide(Calculator.java:12)
        at Calculator.main(Calculator.java:5)

Catching exceptions

try {
    var n = Integer.parseInt(input);
    IO.println(100 / n);
} catch (NumberFormatException | ArithmeticException e) {
    IO.println("bad input: " + e.getMessage());
} finally {
    IO.println("always runs");
}
Unchecked Checked
base class RuntimeException Exception
must be handled no yes β€” catch or throws
typical NullPointerException, IllegalArgumentException IOException, SQLException
String read(Path p) throws IOException {   // pass it up
    return Files.readString(p);
}

throw new IllegalArgumentException("age must not be negative: " + age);

Details in chapter 12.

Files

import java.nio.file.Files;
import java.nio.file.Path;

var path = Path.of("notes.txt");

Files.writeString(path, "line 1\nline 2\n");
Files.writeString(path, "line 3\n", StandardOpenOption.APPEND);

var content = Files.readString(path);
for (var line : Files.readAllLines(path)) IO.println(line);

Large files β€” stream them, and close the stream:

try (var lines = Files.lines(Path.of("big.log"))) {
    lines.filter(l -> l.contains("ERROR")).limit(10).forEach(IO::println);
}

try (...) closes everything automatically, even when an exception is thrown:

try (var writer = Files.newBufferedWriter(Path.of("out.txt"))) {
    writer.write("hello");
    writer.newLine();
}

Paths:

var p = Path.of("data", "2025", "report.csv");

Files.exists(p);  Files.isDirectory(p);  Files.size(p);
Files.createDirectories(p.getParent());
Files.deleteIfExists(p);
p.getFileName();  p.toAbsolutePath();

UTF-8 is the default since Java 18 β€” no charset arguments needed.

A complete program

import java.nio.file.*;

void main(String[] args) {
    if (args.length == 0) {
        IO.println("usage: java WordCount.java <file>");
        return;
    }
    try {
        var words = Files.readString(Path.of(args[0])).toLowerCase().split("\\W+");

        var counts = new java.util.HashMap<String, Integer>();
        for (var w : words) {
            if (!w.isBlank()) counts.merge(w, 1, Integer::sum);
        }

        counts.entrySet().stream()
            .sorted((a, b) -> b.getValue() - a.getValue())
            .limit(10)
            .forEach(e -> IO.println("%-15s %d".formatted(e.getKey(), e.getValue())));

    } catch (java.io.IOException e) {
        IO.println("cannot read file: " + e.getMessage());
    }
}

β˜… Exercises

  1. Run the word counter above on any text file.
  2. Add a second argument: how many words to print.
  3. Copy a file, adding line numbers.
  4. What happens on a missing file? Catch it and print something useful.
  5. Read a CSV of name;age;city into a list of records.
  6. Trigger a NullPointerException on purpose and read the stack trace.

APIs & HTTP

A web API answers HTTP requests with structured data, usually JSON.

Method Purpose Status Meaning
GET read 2xx success
POST create 3xx redirect
PUT/PATCH update 4xx your mistake (404, 401, 429)
DELETE delete 5xx server’s mistake

The built-in HTTP client

import java.net.URI;
import java.net.http.*;

void main() throws Exception {
    try (var client = HttpClient.newHttpClient()) {

        var request = HttpRequest.newBuilder()
            .uri(URI.create("https://api.github.com/repos/openjdk/jdk"))
            .header("Accept", "application/json")
            .GET()
            .build();

        var response = client.send(request, HttpResponse.BodyHandlers.ofString());

        IO.println(response.statusCode());
        IO.println(response.body());
    }
}

Configuration:

var client = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(10))
    .followRedirects(HttpClient.Redirect.NORMAL)
    .build();

var request = HttpRequest.newBuilder(URI.create(url))
    .timeout(Duration.ofSeconds(20))
    .header("User-Agent", "java-course/1.0")
    .build();

POST with a body:

var body = """
    {"title": "New task", "done": false}
    """;

HttpRequest.newBuilder()
    .uri(URI.create("https://example.test/api/tasks"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();
Warning

A 404 does not throw. Check response.statusCode() yourself.

if (response.statusCode() >= 400) {
    throw new IllegalStateException("API error %d".formatted(response.statusCode()));
}

JSON

Java has no JSON parser in the standard library. Use Jackson or Gson:

<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.18.2</version>
</dependency>
@JsonIgnoreProperties(ignoreUnknown = true)
record Repository(String name, String description, int stargazers_count) {}

var mapper = new ObjectMapper();
var repo = mapper.readValue(response.body(), Repository.class);
var json = mapper.writeValueAsString(repo);

Full example

void main() throws Exception {
    var zip = IO.readln("ZIP code: ").strip();

    try (var client = HttpClient.newHttpClient()) {
        var request = HttpRequest.newBuilder(
            URI.create("https://api.zippopotam.us/us/" + zip)).build();

        var response = client.send(request, HttpResponse.BodyHandlers.ofString());

        switch (response.statusCode()) {
            case 200 -> IO.println(response.body());
            case 404 -> IO.println("unknown ZIP code");
            default  -> IO.println("unexpected status " + response.statusCode());
        }
    }
}

Manners

  • respect rate limits β€” on 429, back off instead of retrying immediately
  • always set timeouts
  • keys go in environment variables: System.getenv("API_TOKEN")
  • one HttpClient per application, not per request

β˜… Exercises

  1. Call any public API; print the status code and the first 200 characters.
  2. Print all response headers (response.headers().map()).
  3. Fetch three URLs one after another and measure the total time β€” you will parallelise this in chapter 15.
  4. Retry up to three times with growing delays when you get a 429.
  5. Read a token from an environment variable and send it as an Authorization header. What should happen if the variable is missing?

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.

Java 25 Cheat Sheet

Program and output

void main() {                          // compact, Java 25
    IO.println("Hello");
    var name = IO.readln("Name? ");
}

public class Program {                 // classic
    public static void main(String[] args) {
        System.out.println("Hello");
    }
}
java Program.java     javac Program.java     java Program     jshell

Types

int i = 42;        long l = 42L;      double d = 3.14;    float f = 3.14f;
boolean b = true;  char c = 'A';      String s = "text";  var x = 42;
final double VAT = 0.19;

Strings

s.length()   s.isEmpty()   s.isBlank()   s.strip()   s.repeat(3)
s.toUpperCase()   s.substring(2, 5)   s.charAt(0)   s.indexOf("a")
s.contains("a")   s.startsWith("a")    s.replace("a", "b")   s.split(",")
s.equals(t)  s.equalsIgnoreCase(t)     s.compareTo(t)
"%s is %d".formatted(name, age)        String.join(", ", list)

var block = """
    multiline
    """;

Control flow

if (a > b) { … } else if (a == b) { … } else { … }

var t = cond ? "yes" : "no";

var text = switch (value) {
    case 1, 2 -> "small";
    case Integer i when i > 100 -> "big";
    case String str -> "text: " + str;
    case null -> "nothing";
    default -> { yield "other"; }
};

if (o instanceof String str && str.length() > 3) { … }

Loops

for (var e : list) { … }
for (var i = 0; i < n; i++) { … }
while (cond) { … }
do { … } while (cond);
break;   continue;

Collections

var list = new ArrayList<String>();
list.add("a");  list.get(0);  list.remove("a");  list.size();  list.contains("a");
var fixed = List.of("a", "b");

var set = new HashSet<String>();
var map = new HashMap<String, Integer>();
map.put("a", 1);   map.get("a");   map.getOrDefault("b", 0);
map.merge("a", 1, Integer::sum);
map.computeIfAbsent("k", k -> new ArrayList<>());
map.forEach((k, v) -> IO.println(k + "=" + v));

int[] arr = {1, 2, 3};   arr.length;   Arrays.sort(arr);

Methods

int add(int a, int b) { return a + b; }
void doIt() { … }
int sum(int... numbers) { … }
static int help(int x) { … }

Types you define

record Person(String name, int age) {
    Person { if (age < 0) throw new IllegalArgumentException(); }
    String initial() { return name.substring(0, 1); }
}

public class Account {
    private long balance;
    public Account(long start) { this.balance = start; }
    public long balance() { return balance; }
}

interface Payable {
    void pay(long cents);
    default String info() { return "payment"; }
}

sealed interface Shape permits Circle, Square {}
record Circle(double r) implements Shape {}
record Square(double a) implements Shape {}

enum Status { OPEN, DONE }

Exceptions

try {
    risky();
} catch (IOException | NumberFormatException e) {
    IO.println(e.getMessage());
} finally {
    cleanUp();
}

try (var reader = Files.newBufferedReader(path)) { … }

throw new IllegalArgumentException("message");
void m() throws IOException { … }

Lambdas and streams

Predicate<String> p = s -> s.length() > 3;
Function<String, Integer> f = String::length;

list.stream()
    .filter(x -> x > 0)
    .map(String::valueOf)
    .sorted()
    .distinct()
    .limit(10)
    .toList();

list.stream().count();          list.stream().anyMatch(p);
list.stream().findFirst();      // Optional
list.stream().mapToInt(Integer::intValue).sum();
list.stream().collect(groupingBy(Person::city));
list.stream().map(Person::name).collect(joining(", "));

opt.orElse("default");   opt.orElseThrow();   opt.ifPresent(IO::println);

Files

var p = Path.of("file.txt");
Files.readString(p);        Files.writeString(p, "content");
Files.readAllLines(p);      Files.lines(p);          // stream, close it
Files.exists(p);   Files.size(p);   Files.createDirectories(p.getParent());

Date and time

LocalDate.now()            LocalDate.of(2026, 8, 12)
LocalDateTime.now()        LocalTime.of(14, 30)
date.plusDays(7)           date.minusMonths(1)
date.format(DateTimeFormatter.ofPattern("MM/dd/yyyy"))
Period.between(a, b)       Duration.ofMinutes(90)

Concurrency

Thread.startVirtualThread(() -> …);

try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    var f = ex.submit(() -> compute());
    var result = f.get();
}

var counter = new AtomicInteger();
var map = new ConcurrentHashMap<String, Integer>();

New in Java 25

Feature What it means
compact source files + instance main void main() without a class
java.lang.IO IO.println, IO.readln, no import
module imports import module java.base;
flexible constructor bodies code before super(...)
scoped values context instead of ThreadLocal
structured concurrency (preview) StructuredTaskScope
primitive patterns (preview) case int i in switch

Commands

java --version          jshell
javadoc File.java       jar --create --file x.jar -C out .
mvn test                mvn package
./gradlew build         ./gradlew run
java --enable-preview --source 25 File.java

Further Resources

Official

  • Java 25 documentation β€” language spec, tools, migration notes
  • API docs β€” learning to search these quickly is a skill of its own
  • JEP index β€” every language feature has a proposal explaining the why
  • dev.java β€” tutorials from the OpenJDK team
  • inside.java β€” blog and podcast

Java 25 features

JEP Topic
512 compact source files and instance main methods
511 module import declarations
513 flexible constructor bodies
506 scoped values
505 structured concurrency (preview)
507 primitive types in patterns (preview)
502 stable values (preview)

Tools

Practice

  • Exercism Java track β€” exercises with human feedback
  • Advent of Code β€” great for streams and data structures
  • CodingBat β€” many small drills
  • Your own project β€” one CLI tool you actually use beats a hundred exercises

Books

  • Effective Java (Joshua Bloch) β€” best practices, read it once the basics stick
  • Java by Comparison β€” before/after snippets, ideal right after a beginner course
  • Modern Java in Action β€” streams, lambdas, functional style
  • Java Concurrency in Practice β€” older, but the concepts still hold

Community

  • Local Java User Groups β€” most cities have one, talks are usually free
  • Conference talks from JavaOne, Devoxx and JCon are on YouTube
  • Stack Overflow β€” always check the date; a lot of it describes Java 8
Tip

Check the publication year of anything you read. Java changed a lot since 8 β€” plenty of “this is how you do it” answers are now needlessly complicated.

About this course

All code samples are public domain (CC0). Tested with OpenJDK 25. Preview features are marked and need --enable-preview.