The previous lesson ended by pointing out something you have been carrying since the start of the course: you have been using modern Java features without knowing where they came from.
Arrow switch. record. var. List.of. Pattern instanceof. String.repeat. Files.readString. HttpClient. Stream.toList. They all turned up at some point, were used without ceremony, and nobody said which version brought them or what problem they solved. And there are more you have not seen yet: text blocks, sealed classes, pattern matching for switch, and virtual threads, which you have been waiting two modules for ever since 09-03 said they would completely change the arithmetic of "one thread per connection".
This lesson puts all of that in order.
And it is not just a catalogue. Java changed its development model in 2018: it went from shipping a big release every three years to a release every six months, with extended-support releases every two or three years. That completely changed the pace of the language, and also the practical question every team asks itself: which version do we migrate to, and when?
By the end you will know what each release since Java 9 brought, what is worth using today and what is not, and BiblioTech will have a sealed hierarchy with exhaustive pattern matching and a CatalogServer that serves ten thousand simultaneous connections without a thread pool.
Contents
- The release calendar and the LTS versions
- Feature table by release
- The module system (JPMS)
module-info.java:requires,exports,opens- Strong encapsulation and blocked reflection
- Classpath versus modulepath, and
jlink - The real-world adoption of JPMS: an honest assessment
- API additions
var: local type inference- Expression
switchandyield - Text blocks
recordrevisited- Pattern
instanceof - Sealed classes:
sealed,permits,non-sealed - Pattern matching for
switchand record patterns - BiblioTech: refactoring the hierarchy
- Virtual threads: what they are
- Platform threads versus virtual threads
- BiblioTech: the
CatalogServerwith virtual threads - Pinning,
synchronizedand what they do not solve - What is coming: structured concurrency and
ScopedValue - Tooling:
jshell,jpackageand friends - How to keep up to date and how to decide on a migration
- Common Mistakes and Tips
- Exercises
- The release calendar and the LTS versions
Up to Java 8, releases shipped when they were ready: Java 5 in 2004, Java 6 in 2006, Java 7 in 2011, Java 8 in 2014. Java 9 slipped by three and a half years because of the module system, and that experience prompted a radical change.
Since Java 10 (March 2018), a release ships every six months, in March and September, on a fixed date. Whatever is ready goes in; whatever is not waits for the next train.
graph LR
A["Java 8<br/>2014<br/>LTS"] --> B["Java 9, 10<br/>2017-2018"]
B --> C["Java 11<br/>2018<br/>LTS"]
C --> D["Java 12 to 16<br/>2019-2021"]
D --> E["Java 17<br/>2021<br/>LTS"]
E --> F["Java 18 to 20<br/>2022-2023"]
F --> G["Java 21<br/>2023<br/>LTS"]
G --> H["Java 22, 23, 24<br/>2024-2025"]
H --> I["Java 25<br/>2025<br/>LTS"]
Two kinds of release:
| Kind | Frequency | Support | Recommended use |
|---|---|---|---|
| Normal (feature release) | Every 6 months | 6 months, until the next one | Trying out new features, personal projects |
| LTS (Long-Term Support) | Every 2-3 years | Years (varies by vendor) | Production |
The LTS releases so far are 8, 11, 17, 21 and 25. And the practical advice is clear: in production, use an LTS. A normal release stops receiving security patches after six months, which forces you to migrate twice a year.
Preview features. Since Java 12, important language additions go through one or several rounds of preview before becoming final. They are switched on with a compiler option:
This lets the Java team gather feedback and change the design before committing to it forever. Records spent two releases in preview; virtual threads, three. Do not use preview features in production: they can change or disappear, and the generated bytecode only runs on that exact release.
- Feature table by release
A complete map for reference. The ones you already know carry the lesson where you saw them.
| Release | Year | Relevant additions |
|---|---|---|
| 9 | 2017 | Module system (JPMS), List.of/Set.of/Map.of (05-02), private methods in interfaces, Stream.takeWhile/dropWhile/ofNullable (10-04), Optional.stream/or/ifPresentOrElse (10-04), jshell, jlink, diamond with anonymous classes (10-01) |
| 10 | 2018 | var, List.copyOf, Collectors.toUnmodifiableList, Optional.orElseThrow() with no argument |
| 11 | 2018 LTS | HttpClient final (09-06), String.repeat/strip/isBlank/lines, Files.readString/writeString (07-06), var in lambda parameters, running a .java without compiling |
| 12 | 2019 | Collectors.teeing (10-04), String.indent, expression switch (preview) |
| 13 | 2019 | Text blocks (preview), switch with yield (preview) |
| 14 | 2020 | Expression switch final, record (preview), pattern instanceof (preview), helpful NullPointerException messages |
| 15 | 2020 | Text blocks final, sealed classes (preview), CharSequence.isEmpty |
| 16 | 2021 | record final, pattern instanceof final, Stream.toList (10-04), Stream.mapMulti, strong encapsulation by default |
| 17 | 2021 LTS | Sealed classes final, RandomGenerator, removal of the Security Manager, switch with patterns (preview) |
| 18 | 2022 | UTF-8 by default (07-01), simple web server jwebserver, @snippet in Javadoc |
| 19 | 2022 | Virtual threads (preview), structured concurrency (incubating), foreign function API (preview) |
| 20 | 2023 | Preview refinements, ScopedValue (incubating) |
| 21 | 2023 LTS | Virtual threads final, pattern matching for switch final, record patterns final, sequenced collections, improved String.repeat |
| 22 | 2024 | Foreign function API final, statements before super() (preview) |
| 23 | 2024 | Markdown Javadoc, refinements |
| 24 | 2025 | Post-quantum cryptography, generational ZGC improvements |
| 25 | 2025 LTS | Consolidation of previews, compact source files and instance main methods |
The three milestones that change how Java is written are: Java 8 (lambdas and streams), Java 17 (records, sealed classes, early pattern matching, expression switch) and Java 21 (virtual threads and complete pattern matching).
- The module system (JPMS)
Java 9 introduced the biggest structural change since the language was born: the Java Platform Module System, known as JPMS or by its code name, Project Jigsaw.
The problem it solved, in three parts:
1. The JDK was monolithic. The rt.jar of Java 8 weighed about 60 MB and contained everything: collections, networking, XML, CORBA, Swing, applets. A server application that only uses collections and networking dragged Swing and CORBA along with it. In a Docker container, that is hundreds of megabytes of image for nothing.
2. There was no encapsulation between packages (JAR hell). The public modifier means "visible to everyone". If your library has a com.mycompany.internal package with helper classes, there was no way to stop a user from importing them. And once somebody depends on your internal class, you can no longer change it. The JDK itself suffered from this with sun.misc.Unsafe, used by half the ecosystem.
3. There was no dependency check at start-up. The classpath is a flat list of JARs. If one is missing, the program starts and fails with NoClassDefFoundError the moment it needs it — perhaps hours later, perhaps only in a rare code path.
The solution: a module is a named unit that declares what it needs and what it exposes.
graph TD
A["module com.nexussoftware.bibliotech"] -->|"requires"| B["java.base<br/>implicit"]
A -->|"requires"| C["java.logging"]
A -->|"requires"| D["java.net.http"]
A -->|"exports"| E["com...domain<br/>PUBLIC"]
A -->|"exports"| F["com...service<br/>PUBLIC"]
A -.->|"does NOT export"| G["com...internal<br/>INACCESSIBLE from outside"]
A -->|"opens"| H["com...domain<br/>deep reflection allowed"]
The JDK itself was split into about 70 modules. You can list them:
[email protected]
[email protected]
[email protected]
[email protected]
[email protected]
[email protected]
[email protected]
[email protected]
[email protected]
[email protected]
...java.base is special: it contains java.lang, java.util, java.io, java.nio, java.time and java.util.concurrent, and every module requires it implicitly. You never have to declare it.
module-info.java: requires, exports, opens
module-info.java: requires, exports, opensA module is declared in a file named exactly module-info.java, at the root of the source tree:
src/
module-info.java
com/nexussoftware/bibliotech/
BiblioTechApp.java
domain/
service/
persistence/
network/
internal//**
* Main BiblioTech module.
*/
module com.nexussoftware.bibliotech {
// --- WHAT I NEED ---
requires java.logging; // java.util.logging (06-07)
requires java.net.http; // HttpClient (09-06)
requires java.sql; // JDBC, for module 11
// transitive: whoever requires me also gets this
requires transitive com.nexussoftware.common;
// static: needed to compile, optional at runtime
requires static com.nexussoftware.tools;
// --- WHAT I OFFER ---
exports com.nexussoftware.bibliotech.domain;
exports com.nexussoftware.bibliotech.service;
// Qualified export: ONLY to these modules
exports com.nexussoftware.bibliotech.persistence
to com.nexussoftware.bibliotech.tests;
// com.nexussoftware.bibliotech.internal is NOT exported:
// it is unreachable from outside even though its classes are public
// --- REFLECTION ---
// opens allows DEEP reflection (setAccessible on private members)
opens com.nexussoftware.bibliotech.domain;
// Qualified opening
opens com.nexussoftware.bibliotech.persistence to com.fasterxml.jackson.databind;
// --- SERVICES (ServiceLoader) ---
uses com.nexussoftware.bibliotech.service.FinePolicy;
provides com.nexussoftware.bibliotech.service.FinePolicy
with com.nexussoftware.bibliotech.internal.StandardPolicy;
}The directives, one by one:
| Directive | Meaning |
|---|---|
requires X |
I need module X to compile and to run |
requires transitive X |
On top of that, whoever requires me gets X automatically |
requires static X |
Needed at compile time, optional at runtime |
exports P |
Package P is accessible to everyone |
exports P to M1, M2 |
Package P is accessible only to M1 and M2 |
opens P |
Deep reflection allowed on P |
opens P to M |
Deep reflection allowed only to M |
open module |
The whole module opened for reflection |
uses S |
I consume service S via ServiceLoader |
provides S with I |
I offer I as an implementation of S |
The key distinction, which causes half of the practical problems:
exports |
opens |
|
|---|---|---|
Normal compile-time use (import, new) |
Yes | No |
| Reflection over public members | Yes | Yes |
Reflection over private members (setAccessible) |
No | Yes |
| Checked | At compile time and at runtime | At runtime only |
A persistence framework such as Hibernate needs opens, because it reads the private fields of your entities. Exporting the package is not enough.
- Strong encapsulation and blocked reflection
Back to 10-03. This code worked for twenty years:
Field value = String.class.getDeclaredField("value");
value.setAccessible(true);
byte[] bytes = (byte[]) value.get("Effective Java");And since Java 16 (where strong encapsulation became the default):
Exception in thread "main" java.lang.reflect.InaccessibleObjectException:
Unable to make field private final byte[] java.lang.String.value accessible:
module java.base does not "opens java.lang" to unnamed module @0x1b6d3586What changed: the JDK's internal packages are no longer open. java.base exports java.lang (you can use String) but does not open it (you cannot poke at its private fields).
This broke many old libraries, which is why the emergency exit exists:
# Open a specific package to all code with no module
java --add-opens java.base/java.lang=ALL-UNNAMED -cp app.jar com.example.App
# Export (normal use, not deep reflection)
java --add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED ...
# Several at once
java --add-opens java.base/java.lang=ALL-UNNAMED \
--add-opens java.base/java.util=ALL-UNNAMED \
-jar app.jar--add-opens is a sticking plaster, not a solution. Every use signals that something depends on internal details that can change in any release. If you need it for a third-party library, upgrade it; if it is your own code, rethink it.
For your own modularised code, the right solution is to declare opens in module-info.java:
module com.nexussoftware.bibliotech {
requires java.logging;
exports com.nexussoftware.bibliotech.domain;
// Our AnnotatedExporter from 10-03 calls setAccessible on the
// private fields of the entities: it needs opens.
opens com.nexussoftware.bibliotech.domain;
}Without that opens, the AnnotatedExporter you wrote in 10-03 would throw InaccessibleObjectException as soon as BiblioTech was modularised. That is exactly the scenario anticipated there.
- Classpath versus modulepath, and
jlink
jlinkTwo ways of running coexist:
# CLASSPATH: the classic mode. Everything goes into the "unnamed module".
javac -cp lib/*.jar -d classes $(find src -name "*.java")
java -cp classes:lib/*.jar com.nexussoftware.bibliotech.BiblioTechApp
# MODULEPATH: modular mode
javac -d classes --module-source-path src $(find src -name "*.java")
java --module-path classes -m com.nexussoftware.bibliotech/com.nexussoftware.bibliotech.BiblioTechApp| Aspect | Classpath | Modulepath |
|---|---|---|
| Name | Unnamed module | Named modules |
| Encapsulation | None: everything public is reachable |
Strong: only what is exported |
| Dependencies | Unchecked | Checked at start-up |
| Duplicate files | The first one wins, silently | Error: split packages |
| Compatibility | Total | Requires the libraries to cooperate |
Automatic modules are the bridge: an ordinary JAR on the modulepath becomes a module whose name is derived from the file name, which exports all its packages and requires all modules. It allows a gradual migration, although a name derived from a file name is fragile.
jlink: a bespoke runtime
The most tangible benefit of JPMS. jlink builds a Java image that contains only the modules your application uses:
# Find out which modules the application needs
jdeps --print-module-deps --ignore-missing-deps classes/bibliotech.jar# Build the minimal runtime
jlink --add-modules java.base,java.logging,java.net.http \
--strip-debug \
--compress=zip-6 \
--no-header-files \
--no-man-pages \
--output bibliotech-runtime
# And run with it
./bibliotech-runtime/bin/java -m com.nexussoftware.bibliotech/...BiblioTechAppFrom 315 MB to 44 MB. In a container image multiplied by a hundred deployments a day, that is download time, storage cost and attack surface. On top of that, the resulting runtime does not need Java installed on the target machine.
And jpackage (Java 14) goes one step further: it bundles that runtime together with your application into a native installer (.deb, .rpm, .msi, .dmg).
- The real-world adoption of JPMS: an honest assessment
Time to be straight, because this topic causes confusion.
JPMS has not been widely adopted in applications. Almost ten years after Java 9, the vast majority of Java enterprise applications still run on the classpath, with no module-info.java. Spring Boot, the dominant platform, does not require modularisation. Many popular libraries do not publish named modules.
Why:
- High migration cost. Modularising a large application with fifty dependencies requires all fifty to cooperate.
- Split packages break a lot. Two JARs with classes in the same package are legal on the classpath and illegal on the modulepath. This is common in old libraries.
- Reflection suffers. Frameworks that rely on
setAccessibleneedopensall over the place, which erodes the benefit. - The benefit is not obvious for an application. An executable JAR gains little from encapsulating packages only it uses.
Where it has succeeded:
- In the JDK itself. It is what made it possible to remove CORBA and applets, split the platform, and switch on the strong encapsulation that protects you from depending on internals.
- In serious libraries, which publish
module-info.javato declare their public API in a verifiable way. - With
jlinkandjpackage, for desktop applications and minimal containers.
What to do in practice:
| Case | Recommendation |
|---|---|
| Enterprise application with Spring Boot | Do not modularise. Classpath, and --add-opens if some library asks for it |
| Publicly consumed library | Yes: publish module-info.java. It is verified documentation of your API |
| Desktop application to be distributed | Yes: jlink + jpackage justify the effort |
| Container where size matters | Consider jlink even if you do not modularise your code |
| New code from scratch, a single artefact | Optional. It brings discipline and costs friction |
What you absolutely do have to know is the part that affects you even if you never modularise: strong encapsulation is switched on, InaccessibleObjectException exists, and --add-opens is the escape hatch.
- API additions
A review of the additions to the standard library, many of them already used.
Immutable collections (Java 9) — back to 05-02
List<String> employees = List.of("Marta Ruiz", "Diego Alonso", "Nuria Vidal");
Set<String> isbns = Set.of("978-0000000001", "978-0000000002");
Map<String, Integer> loans = Map.of("Marta Ruiz", 4, "Diego Alonso", 2);
Map<String, Integer> many = Map.ofEntries(
Map.entry("Marta Ruiz", 4),
Map.entry("Diego Alonso", 2),
Map.entry("Nuria Vidal", 3));
// Immutable copy of an existing collection (Java 10)
List<String> copy = List.copyOf(modifiableList);Four rules that catch people out:
- They really are immutable.
add,removeandsetthrowUnsupportedOperationException. - They do not accept
null, neither as an element nor as a key nor as a value. They throwNullPointerException. Set.ofandMap.ofreject duplicates withIllegalArgumentException, instead of silently ignoring them.- The iteration order of
Set.ofandMap.ofis unspecified and changes between runs on purpose, so that nobody depends on it.
Set<String> set = Set.of("a", "b", "c", "d");
System.out.println(set); // a different order on every run of the programArrays.asList is still different: fixed size but it allows set, and it accepts null.
Private methods in interfaces (Java 9) — back to 04-01
public interface LoanService {
Result<Loan> lend(String isbn, String employee);
default Result<Loan> lendValidating(String isbn, String employee) {
if (!isValidIsbn(isbn)) {
return Result.failure("Malformed ISBN: " + isbn);
}
if (!isValidEmployee(employee)) {
return Result.failure("Invalid employee");
}
return lend(isbn, employee);
}
// PRIVATE: logic shared between the defaults, invisible from outside
private boolean isValidIsbn(String isbn) {
return isbn != null && isbn.matches("978-\\d{10}");
}
private static boolean isValidEmployee(String employee) {
return employee != null && !employee.isBlank();
}
}Before Java 9, logic shared between default methods had to be yet another public default method, polluting the API.
String methods (Java 11-12)
String title = " Effective Java ";
System.out.println(title.strip()); // "Effective Java" (Unicode-aware)
System.out.println(title.stripLeading()); // "Effective Java "
System.out.println(title.stripTrailing()); // " Effective Java"
System.out.println(" ".isBlank()); // true
System.out.println("".isEmpty()); // true
System.out.println("=".repeat(40)); // separator line
System.out.println("one\ntwo".lines().count()); // 2 (returns Stream<String>)
System.out.println("text".indent(4)); // indentation (Java 12)strip() versus trim(): trim() removes characters with a code of 32 or less (inherited from 1996); strip() uses the Unicode definition of whitespace and handles non-breaking spaces and characters from other alphabets correctly. Use strip() in new code.
And lines() returns a Stream<String>, which connects with 10-04:
Files.readString and writeString (Java 11) — back to 07-06
// BEFORE
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
String content = String.join("\n", lines);
// NOW
String content = Files.readString(path, StandardCharsets.UTF_8);
Files.writeString(path, content, StandardCharsets.UTF_8);For small configuration files this is the most direct approach. For large files it is still Files.lines() with lazy processing (10-04).
Sequenced collections (Java 21)
A small addition that fixes an old inconsistency in the collections API (module 5):
List<Book> catalog = new ArrayList<>(...);
// BEFORE: every collection had its own way
Book first = catalog.get(0);
Book last = catalog.get(catalog.size() - 1);
Book firstOfDeque = deque.getFirst();
Book firstOfSet = sortedSet.iterator().next(); // like that!
// NOW: SequencedCollection unifies them
Book first2 = catalog.getFirst();
Book last2 = catalog.getLast();
catalog.addFirst(newOne);
catalog.addLast(another);
List<Book> backwards = catalog.reversed(); // a view, not a copy
// And it works the same on LinkedHashSet, TreeSet, Deque, LinkedHashMap
LinkedHashMap<String, Book> byIsbn = new LinkedHashMap<>();
var firstEntry = byIsbn.firstEntry();Helpful NullPointerException messages (Java 14)
A change with no new syntax that saves real hours of debugging:
In Java 8:
Exception in thread "main" java.lang.NullPointerException
at com.nexussoftware.bibliotech.BiblioTechApp.main(BiblioTechApp.java:42)Which of the three was null? Time to debug.
In Java 14+:
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "String.strip()" because the return value of
"com.nexussoftware.bibliotech.domain.Publisher.getName()" is null
at com.nexussoftware.bibliotech.BiblioTechApp.main(BiblioTechApp.java:42)It tells you exactly what returned null and what was called on it. It is on by default since Java 15, and it is one of the best reasons to migrate away from Java 8.
UTF-8 by default (Java 18) — back to 07-01
Since Java 18, the platform default charset is UTF-8 on every operating system. Before that it depended on the regional settings, and it was the cause of the classic bug: a CSV written on Windows with windows-1252 that was read incorrectly on a Linux server using UTF-8.
Even so, keep specifying the charset explicitly. That is what we insisted on in module 7, and it is still the right practice: it makes the code independent of the Java version and of the environment's configuration.
var: local type inference
var: local type inferenceJava 10 introduced var for local variables. The compiler infers the type from the initialiser expression.
var catalog = new ArrayList<Book>(); // ArrayList<Book>
var title = "Effective Java"; // String
var pages = 412; // int
var date = LocalDate.of(2026, 8, 5); // LocalDate
// Where it shines most: long generic types
var byEmployeeAndType = new HashMap<String, Map<MaterialType, List<Loan>>>();
// In loops
for (var entry : byEmployeeAndType.entrySet()) { }
for (var i = 0; i < 10; i++) { }
// In try-with-resources
try (var lines = Files.lines(path, StandardCharsets.UTF_8)) { }var is NOT dynamic typing. The type is fixed at compile time and never changes:
Where it cannot be used:
// var field = "x"; // class fields: NO
// public var method() { } // method return type: NO
// void m(var parameter) { } // parameters: NO (except in lambdas, Java 11)
// var noValue; // no initialiser: NO
// var nothing = null; // there is nothing to infer: NO
// var array = { 1, 2, 3 }; // array initialiser: NOWhen to use it and when not:
// GOOD: the type is obvious from the right-hand side
var catalog = new ArrayList<Book>();
var reservation = new Reservation("R-001", "Madrid-A", "Marta Ruiz", start, duration);
var counter = new AtomicInteger(0);
// GOOD: the explicit type would be unreadable
var index = new HashMap<String, Map<MaterialType, List<Loan>>>();
// BAD: the type is not implied by the method name
var result = service.process(data); // what is 'result'?
var x = compute(); // int? double? BigDecimal?
// BAD: you lose the abstraction to the interface
var list = new ArrayList<Book>(); // the type is ArrayList
List<Book> list2 = new ArrayList<>(); // the type is List: a better API
// DANGEROUS: the empty diamond infers Object
var empty = new ArrayList<>(); // ArrayList<Object>The rule: use var when the type is obvious from reading the line. If you have to go and look at a method signature to know what the variable is, write the type.
And since Java 11, var works in lambda parameters, for the sole purpose of being able to annotate them:
- Expression
switch and yield — back to 02-03
switch and yield — back to 02-03In 02-03 you used the classic switch with its two problems: fall-through between cases when you forget a break, and the fact that it is a statement, not an expression, so it cannot return a value without a helper variable.
// CLASSIC: verbose and full of traps
String description;
switch (severity) {
case HIGH:
description = "Requires immediate action";
break;
case MEDIUM:
description = "Review this week";
break;
case LOW:
description = "Log it and carry on";
break;
default:
throw new IllegalStateException("Unknown severity: " + severity);
}Java 14 turned it into an expression, with arrows and no fall-through:
// EXPRESSION: returns a value, no break, no fall-through
String description = switch (severity) {
case HIGH -> "Requires immediate action";
case MEDIUM -> "Review this week";
case LOW -> "Log it and carry on";
};Four concrete advantages:
- No
break, because there is no fall-through between cases. - It is an expression: you can assign it, return it, pass it as an argument.
- Several labels per case:
case SATURDAY, SUNDAY ->. - Exhaustiveness checked: over an
enum, if cases are missing and there is nodefault, it does not compile.
That last one is the most valuable. If tomorrow somebody adds Severity.CRITICAL, the compiler will point at every switch that needs updating. With the classic switch and a default, the new case would fall silently into the default.
yield for blocks with logic:
double fine = switch (severity) {
case HIGH -> {
double base = days * 0.50;
double surcharge = days > 30 ? 10.0 : 0.0;
yield Math.min(base + surcharge, 50.0); // yield returns the block's value
}
case MEDIUM -> days * 0.25;
case LOW -> 0.0;
};yield is to a switch block what return is to a method. You cannot use return inside an expression switch: it would leave the whole method, which is not what you want.
And the expression switch also works over String and int, with the difference that there you do need a default, because the possible values are infinite:
int termDays = switch (type) {
case "BOOK" -> 21;
case "MAGAZINE" -> 7;
case "DVD" -> 3;
default -> throw new IllegalArgumentException("Unknown type: " + type);
};
- Text blocks
Java 15 added multi-line strings, and the difference in readability is enormous.
// BEFORE: unreadable, full of escapes and concatenation
String json = "{\n"
+ " \"isbn\": \"978-0000000001\",\n"
+ " \"title\": \"Effective Java\",\n"
+ " \"available\": true\n"
+ "}";
// NOW
String json = """
{
"isbn": "978-0000000001",
"title": "Effective Java",
"available": true
}""";Syntax rules:
- It opens with
"""followed by a mandatory line break."""textdoes not compile. - It closes with
""", on the same line as the last piece of content or on a line of its own. - Double quotes need no escaping.
The indentation rule (the one that matters). The compiler works out the minimum common margin of all non-blank lines including the closing one, and removes it:
public class Example {
public String query() {
String sql = """
SELECT l.reference, l.isbn, m.title
FROM loans l
JOIN materials m ON m.isbn = l.isbn
WHERE l.return_date IS NULL
ORDER BY l.due_date
""";
return sql;
}
}The resulting string does not carry the 16 spaces of Java code indentation, only the relative indentation of the SQL lines. And here is the detail that confuses people: the position of the closing """ takes part in the calculation.
// The closing delimiter to the left of the content: more indentation is kept
String a = """
line
"""; // the common margin is 2: " line\n"
// The closing delimiter aligned with the content: all indentation is removed
String b = """
line
"""; // the common margin is 8: "line\n"
// The closing delimiter on the same line: no trailing newline
String c = """
line"""; // "line" (no \n at the end)Escape sequences specific to text blocks:
// \ at the end of a line: JOINS the lines (no line break)
String singleLine = """
This text is written across several lines \
of source code but ends up as a single \
line of output.""";
// \s: a space that is NOT stripped as trailing indentation
String withSpaces = """
column1 \s
column2 \s""";The star use cases are SQL and JSON, which you will see constantly in modules 11 and 12:
public class BiblioTechQueries {
static final String OVERDUE_LOANS = """
SELECT l.reference,
l.isbn,
m.title,
l.employee,
l.due_date,
CURRENT_DATE - l.due_date AS days_late
FROM loans l
JOIN materials m ON m.isbn = l.isbn
WHERE l.return_date IS NULL
AND l.due_date < CURRENT_DATE
ORDER BY days_late DESC
""";
static String metadataRequest(String isbn) {
return """
{
"jsonrpc": "2.0",
"method": "catalog.query",
"params": { "isbn": "%s" },
"id": 1
}""".formatted(isbn); // formatted: like String.format
}
}formatted() (Java 15) is String.format as an instance method, and it fits text blocks perfectly.
record revisited — back to 04-07
record revisited — back to 04-07You met them in 04-07. Here it is worth nailing down exactly what they are and what you can do with them, because in section 15 they will be the key piece of pattern matching.
A record is a transparent carrier of immutable data:
The compiler automatically generates:
private finalfields for every component.- A canonical constructor with all the components.
- Accessor methods named after the component (
isbn(), notgetIsbn()). equalsandhashCodebased on all the components.toStringwith all the components.
Compact but complete:
public record Loan(String reference, String isbn, String employee,
LocalDate loanDate, LocalDate dueDate) {
/** COMPACT constructor: validates and normalises before assigning. */
public Loan {
Objects.requireNonNull(reference, "reference");
Objects.requireNonNull(isbn, "isbn");
if (dueDate.isBefore(loanDate)) {
throw new IllegalArgumentException("Due date before the loan date");
}
employee = employee.strip(); // the parameter can be NORMALISED
}
/** Additional constructor. */
public Loan(String reference, String isbn, String employee, LocalDate from) {
this(reference, isbn, employee, from, from.plusDays(21));
}
/** Derived methods. */
public long daysLate(LocalDate today) {
return Math.max(0, ChronoUnit.DAYS.between(dueDate, today));
}
/** "Modifying" a record = creating another one. */
public Loan withDueDate(LocalDate newDate) {
return new Loan(reference, isbn, employee, loanDate, newDate);
}
/** Static methods and fields ARE allowed. */
public static final Period TERM = Period.ofDays(21);
public static Loan create(String ref, String isbn, String employee, LocalDate from) {
return new Loan(ref, isbn, employee, from, from.plus(TERM));
}
}Limitations (which are design decisions, not shortcomings):
- They cannot extend any class (they implicitly extend
java.lang.Record). They can implement interfaces. - They are implicitly
final: nobody can extend them. - They cannot have instance fields other than the components.
- The components are
final: there are no setters.
When to use a record and when a class:
Use a record |
Use a class |
|---|---|
| An immutable data carrier | There is mutable state |
| Identity is the value of the fields | Identity is the reference |
| DTO, return value, map key | You need inheritance |
| The result of a computation | You encapsulate the internal representation |
In BiblioTech: Card and SessionSummary are records; Material is an abstract class because there is a hierarchy; Loan can be either of the two depending on whether its state changes.
- Pattern
instanceof — back to 03-06
instanceof — back to 03-06Final in Java 16. It removes the redundant cast after an instanceof:
// BEFORE: the cast repeats what the instanceof already checked
if (material instanceof Book) {
Book book = (Book) material;
System.out.println(book.getPages());
}
// NOW: the pattern variable is declared and assigned at once
if (material instanceof Book book) {
System.out.println(book.getPages());
}The scope of the pattern variable is worked out with flow analysis, which produces some very handy behaviours:
// With && : the variable is available on the right-hand side
if (material instanceof Book book && book.getPages() > 400) {
System.out.println("Long book: " + book.getTitle());
}
// With negation: available AFTER the if (early exit)
public String describe(Object object) {
if (!(object instanceof Material material)) {
return "Not a material";
}
// 'material' is in scope here, because otherwise we would already have returned
return material.getTitle() + " (" + material.getType() + ")";
}
// With || : it is NOT available, and the compiler forbids it
// if (o instanceof Book b || b.getPages() > 0) { } // DOES NOT COMPILEAnd equals becomes especially clean (03-09):
@Override
public boolean equals(Object other) {
return other instanceof Material material
&& isbn.equals(material.getIsbn());
}From six lines to two, losing nothing.
- Sealed classes:
sealed, permits, non-sealed
sealed, permits, non-sealedFinal in Java 17. They solve a real problem that until now had no solution.
The problem. In BiblioTech, Material is abstract with three subclasses: Book, Magazine and Dvd. But anybody can create a fourth:
And every switch and every instanceof chain you wrote assuming three types is now incomplete, without the compiler saying a word.
The alternatives before Java 17 were poor: make the constructor package-private (fragile, and it does not stop subclasses in the same package) or simply trust and document.
The solution:
package com.nexussoftware.bibliotech.domain;
/**
* CLOSED hierarchy: only these three materials exist.
* No other class can extend Material, neither in this
* project nor in any other.
*/
public sealed abstract class Material
permits Book, Magazine, Dvd {
private final String isbn;
private final String title;
protected Material(String isbn, String title) {
this.isbn = isbn;
this.title = title;
}
public String getIsbn() { return isbn; }
public String getTitle() { return title; }
public abstract MaterialType getType();
}Every subclass must declare its own openness, and that is the part people forget:
/** final: nobody extends Book. This is the most common case. */
public final class Book extends Material {
private final int pages;
private final String author;
// ...
}
/** sealed: Magazine has a closed hierarchy of its own. */
public sealed class Magazine extends Material permits MonthlyMagazine, QuarterlyMagazine {
// ...
}
/** non-sealed: Dvd is open to anybody again. */
public non-sealed class Dvd extends Material {
// ...
}| Modifier | Meaning |
|---|---|
final |
Nobody can extend it. The most common case |
sealed ... permits |
Only the listed classes can extend it |
non-sealed |
Anybody can extend it: it reopens the hierarchy |
The rules the compiler imposes:
- Every subclass of a
sealedclass must befinal,sealedornon-sealed. There is no fourth option. - The permitted classes must be in the same module (or in the same package if there are no modules).
- They must actually extend it: listing a class that does not extend it is a compile error.
permitscan be omitted if the subclasses are in the same file.
// All in one file: implicit permits
public sealed interface Event {
record Lent(String isbn, String employee, LocalDate when) implements Event { }
record Returned(String isbn, LocalDate when, boolean late) implements Event { }
record Lost(String isbn, String employee, double amount) implements Event { }
}What you gain: exhaustiveness. The compiler knows there are only three types, and can therefore verify that you have covered them all:
// NO default: the compiler checks that all three are there
String label = switch (material) {
case Book b -> "Book of " + b.getPages() + " pages";
case Magazine m -> "Magazine no. " + m.getNumber();
case Dvd d -> "DVD of " + d.getMinutes() + " minutes";
};If tomorrow somebody adds MapSheet to the permits list and does not update this switch:
error: the switch expression does not cover all possible input values
String label = switch (material) {
^The compiler walks you through every place that needs touching. That is what makes sealed hierarchies be called "algebraic types" or "sum types": the set of possibilities is closed and known.
And combined with record, they model states very expressively:
package com.nexussoftware.bibliotech.domain;
import java.time.LocalDate;
/**
* The status of a loan modelled as a sum type.
* Every status carries EXACTLY the data it makes sense
* for it to carry, instead of one class with fields that are sometimes null.
*/
public sealed interface LoanStatus {
record Active(LocalDate dueDate) implements LoanStatus { }
record Overdue(LocalDate dueDate, long daysLate, double fine)
implements LoanStatus { }
record Returned(LocalDate when, boolean onTime) implements LoanStatus { }
record Lost(LocalDate declaredOn, double replacementCost)
implements LoanStatus { }
}Compare it with the classic alternative: a Loan class with boolean returned, LocalDate returnDate (null if it has not been returned), double fine (0 if it does not apply) and boolean lost. Impossible combinations become representable: returned and lost at the same time, a fine with no due date. With the sealed type, those states cannot be constructed.
- Pattern matching for
switch and record patterns
switch and record patternsFinal in Java 21, they close the circle.
Type patterns in switch
// BEFORE: a chain of instanceof, with a cast in every branch
public String describe(Object object) {
if (object instanceof Book) {
Book b = (Book) object;
return "Book: " + b.getTitle() + " (" + b.getPages() + " pp.)";
} else if (object instanceof Magazine) {
Magazine m = (Magazine) object;
return "Magazine: " + m.getTitle() + " no. " + m.getNumber();
} else if (object instanceof Dvd) {
Dvd d = (Dvd) object;
return "DVD: " + d.getTitle() + " (" + d.getMinutes() + " min)";
} else if (object == null) {
return "(nothing)";
}
return "Unknown";
}
// NOW
public String describe(Object object) {
return switch (object) {
case null -> "(nothing)";
case Book b -> "Book: " + b.getTitle() + " (" + b.getPages() + " pp.)";
case Magazine m -> "Magazine: " + m.getTitle() + " no. " + m.getNumber();
case Dvd d -> "DVD: " + d.getTitle() + " (" + d.getMinutes() + " min)";
default -> "Unknown: " + object.getClass().getSimpleName();
};
}case null is an important novelty. The classic switch threw NullPointerException on a null value; now it can be handled as one more case. If you do not declare case null, it still throws NullPointerException for compatibility.
Guards with when
public String classify(Material material) {
return switch (material) {
case Book b when b.getPages() > 500 -> "Long book";
case Book b when b.getPages() > 200 -> "Standard book";
case Book b -> "Short book";
case Magazine m when m.isSpecial() -> "Special issue";
case Magazine m -> "Ordinary magazine";
case Dvd d -> "Audiovisual material";
};
}Order matters: cases are evaluated top to bottom, and the most specific goes first. The compiler forbids an unreachable case:
Record patterns
The most powerful piece: deconstructing a record directly in the pattern.
public sealed interface Event {
record Lent(String isbn, String employee, LocalDate when) implements Event { }
record Returned(String isbn, LocalDate when, boolean late) implements Event { }
record Lost(String isbn, String employee, double amount) implements Event { }
}public String record(Event event) {
return switch (event) {
// The record is DECONSTRUCTED: isbn, employee and when come out directly
case Event.Lent(String isbn, String employee, LocalDate when) ->
String.format("%s lent %s on %s", employee, isbn, when);
// With a guard over a deconstructed component
case Event.Returned(String isbn, LocalDate when, boolean late) when late ->
String.format("%s returned on %s LATE", isbn, when);
case Event.Returned(String isbn, LocalDate when, boolean late) ->
String.format("%s returned on time on %s", isbn, when);
case Event.Lost(String isbn, String employee, double amount) ->
String.format("%s declared %s lost (replacement: %.2f EUR)",
employee, isbn, amount);
};
// NO default: the interface is sealed and all three cases are there
}And with var so you do not have to repeat types:
Patterns nest, which is where the mechanism becomes genuinely expressive:
public record Point(int x, int y) { }
public record Line(Point start, Point end) { }
public String describe(Object o) {
return switch (o) {
// NESTED deconstruction at two levels
case Line(Point(var x1, var y1), Point(var x2, var y2)) when x1 == x2 ->
"Vertical line at x=" + x1;
case Line(Point(var x1, var y1), Point(var x2, var y2)) when y1 == y2 ->
"Horizontal line at y=" + y1;
case Line(Point p1, Point p2) ->
"Line from " + p1 + " to " + p2;
case Point(int x, int y) ->
"Point (" + x + ", " + y + ")";
default -> "Something else";
};
}And it works with instanceof too:
if (event instanceof Event.Lent(String isbn, String employee, LocalDate when)) {
System.out.println(employee + " -> " + isbn + " (" + when + ")");
}
- BiblioTech: refactoring the hierarchy
We apply everything at once, with a before and an after.
Before
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.*;
/**
* OLD VERSION: open hierarchy, manual casts,
* if-else chains and a default that hides the new cases.
*/
public class LegacyTermCalculator {
public int termDays(Material material) {
if (material instanceof Book) {
Book book = (Book) material;
if (book.getPages() > 500) {
return 30;
} else if (book.getPages() > 200) {
return 21;
} else {
return 14;
}
} else if (material instanceof Magazine) {
Magazine magazine = (Magazine) material;
if (magazine.isSpecial()) {
return 14;
}
return 7;
} else if (material instanceof Dvd) {
return 3;
}
// And if somebody adds a new type, it lands HERE in silence
throw new IllegalArgumentException("Unknown type: " + material.getClass());
}
public String shelfLabel(Material material) {
String prefix;
if (material instanceof Book) {
prefix = "B";
} else if (material instanceof Magazine) {
prefix = "M";
} else if (material instanceof Dvd) {
prefix = "D";
} else {
prefix = "X";
}
return prefix + "-" + material.getIsbn().substring(4, 8);
}
public double replacementValue(Material material) {
if (material instanceof Book) {
return ((Book) material).getPages() * 0.08;
} else if (material instanceof Magazine) {
return 12.0;
} else if (material instanceof Dvd) {
return ((Dvd) material).getMinutes() * 0.15;
}
return 0.0;
}
}After
package com.nexussoftware.bibliotech.domain;
/** SEALED hierarchy: the compiler knows only three materials exist. */
public sealed abstract class Material permits Book, Magazine, Dvd {
private final String isbn;
private final String title;
private final double rating;
protected Material(String isbn, String title, double rating) {
this.isbn = isbn;
this.title = title;
this.rating = rating;
}
public String getIsbn() { return isbn; }
public String getTitle() { return title; }
public double getRating() { return rating; }
}public final class Book extends Material {
private final int pages;
private final String author;
public Book(String isbn, String title, String author, int pages, double rating) {
super(isbn, title, rating);
this.author = author;
this.pages = pages;
}
public int getPages() { return pages; }
public String getAuthor() { return author; }
}
public final class Magazine extends Material {
private final int number;
private final boolean special;
public Magazine(String isbn, String title, int number, boolean special, double rating) {
super(isbn, title, rating);
this.number = number;
this.special = special;
}
public int getNumber() { return number; }
public boolean isSpecial() { return special; }
}
public final class Dvd extends Material {
private final int minutes;
private final String director;
public Dvd(String isbn, String title, String director, int minutes, double rating) {
super(isbn, title, rating);
this.director = director;
this.minutes = minutes;
}
public int getMinutes() { return minutes; }
public String getDirector() { return director; }
}package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.*;
/**
* MODERN VERSION: exhaustive switch with no default,
* no casts, with guards. If a fourth material is added,
* THESE THREE METHODS STOP COMPILING and the compiler
* takes you to each one.
*/
public class TermCalculator {
public int termDays(Material material) {
return switch (material) {
case Book b when b.getPages() > 500 -> 30;
case Book b when b.getPages() > 200 -> 21;
case Book b -> 14;
case Magazine m when m.isSpecial() -> 14;
case Magazine m -> 7;
case Dvd d -> 3;
};
}
public String shelfLabel(Material material) {
String prefix = switch (material) {
case Book b -> "B";
case Magazine m -> "M";
case Dvd d -> "D";
};
return prefix + "-" + material.getIsbn().substring(4, 8);
}
public double replacementValue(Material material) {
return switch (material) {
case Book b -> b.getPages() * 0.08;
case Magazine m -> m.isSpecial() ? 25.0 : 12.0;
case Dvd d -> d.getMinutes() * 0.15;
};
}
/** With blocks and yield when there is logic. */
public String fullCard(Material material) {
return switch (material) {
case Book b -> {
String length = b.getPages() > 400 ? "long" : "standard";
yield """
BOOK
Title: %s
Author: %s
Pages: %d (%s)
Term: %d days
Replacement: %.2f EUR"""
.formatted(b.getTitle(), b.getAuthor(), b.getPages(),
length, termDays(b), replacementValue(b));
}
case Magazine m -> """
MAGAZINE
Title: %s
Number: %d%s
Term: %d days"""
.formatted(m.getTitle(), m.getNumber(),
m.isSpecial() ? " (SPECIAL)" : "", termDays(m));
case Dvd d -> """
DVD
Title: %s
Director: %s
Duration: %d min
Term: %d days"""
.formatted(d.getTitle(), d.getDirector(), d.getMinutes(), termDays(d));
};
}
}And the loan status with record patterns:
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.LoanStatus;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
public class StatusDescriber {
private static final DateTimeFormatter DATE =
DateTimeFormatter.ofPattern("d MMMM", Locale.forLanguageTag("en-GB"));
public String describe(LoanStatus status) {
return switch (status) {
case LoanStatus.Active(var dueDate) ->
"Active, due on " + dueDate.format(DATE);
case LoanStatus.Overdue(var dueDate, var days, var fine) when fine == 0 ->
"Overdue by " + days + " days (still within the grace period)";
case LoanStatus.Overdue(var dueDate, var days, var fine) ->
String.format("OVERDUE by %d days -- fine accrued: %.2f EUR", days, fine);
case LoanStatus.Returned(var when, var onTime) ->
"Returned on " + when.format(DATE) + (onTime ? " on time" : " late");
case LoanStatus.Lost(var declared, var amount) ->
String.format("LOST since %s -- replacement: %.2f EUR",
declared.format(DATE), amount);
};
}
/** Handling priority: a switch that returns an int. */
public int priority(LoanStatus status) {
return switch (status) {
case LoanStatus.Lost l -> 100;
case LoanStatus.Overdue(var d, var days, var f) when days > 30 -> 90;
case LoanStatus.Overdue o -> 50;
case LoanStatus.Active a -> 10;
case LoanStatus.Returned r -> 0;
};
}
}List<LoanStatus> statuses = List.of(
new LoanStatus.Active(LocalDate.of(2026, 8, 26)),
new LoanStatus.Overdue(LocalDate.of(2026, 8, 3), 2, 0.0),
new LoanStatus.Overdue(LocalDate.of(2026, 5, 22), 75, 18.25),
new LoanStatus.Returned(LocalDate.of(2026, 8, 1), true),
new LoanStatus.Lost(LocalDate.of(2026, 7, 15), 42.50));
StatusDescriber describer = new StatusDescriber();
statuses.stream()
.sorted(Comparator.comparingInt(describer::priority).reversed())
.forEach(s -> System.out.printf(" [%3d] %s%n",
describer.priority(s), describer.describe(s))); [100] LOST since 15 July -- replacement: 42.50 EUR
[ 90] OVERDUE by 75 days -- fine accrued: 18.25 EUR
[ 50] Overdue by 2 days (still within the grace period)
[ 10] Active, due on 26 August
[ 0] Returned on 1 August on timeThe comparison in numbers: 45 lines of if-else with casts versus 20 lines of exhaustive switch. But the length is not the point: the point is that if somebody adds a fourth material or a fifth status, the code stops compiling and the compiler points at every place that needs updating. The old version would have carried on compiling and failed at runtime.
- Virtual threads: what they are
And now we reach what you have been waiting for since 09-03.
The problem, recapped. In 09-03 you wrote CatalogServer with the "one thread per connection" model, and you discovered that it does not scale: every platform thread consumes around 1 MB of stack and creating it costs close to a millisecond. Ten thousand simultaneous connections would be ten gigabytes of stack. That is why you had to cap the pool to a modest number of threads, and why the server rejects clients when it saturates.
That trade-off — cheap threads or readable code, pick one — has dominated server programming for twenty years. The alternative was asynchronous programming (CompletableFuture, 08-07), which scales but turns a linear flow into a tangle of callbacks where the debugger is useless and stack traces say nothing.
Virtual threads (Java 21, Project Loom) remove the trade-off.
A virtual thread is a thread managed by the JVM, not by the operating system. It runs on a small set of platform threads called carriers (carrier threads). The key is the behaviour when it blocks:
graph TD
A["10,000 virtual threads"] --> B["JVM scheduler<br/>ForkJoinPool"]
B --> C["Carrier thread 1"]
B --> D["Carrier thread 2"]
B --> E["Carrier thread N<br/>(N = cores)"]
C --> F["Operating system"]
D --> F
E --> F
G["A virtual thread blocks<br/>on I/O"] -->|"it UNMOUNTS from the carrier"| H["Its stack is stored on the heap"]
H -->|"the carrier is FREE<br/>for another virtual thread"| B
I["The data arrives"] -->|"it REMOUNTS on a carrier"| B
When a platform thread blocks, the operating-system thread sits idle. When a virtual thread blocks, it unmounts from the carrier: its stack is stored on the heap and the carrier goes on to run another virtual thread. When the data arrives it remounts and carries on exactly where it was.
The result: you can block happily, because blocking a virtual thread does not block any operating-system thread. Readable sequential code scales again.
// Create a single one
Thread virtual = Thread.ofVirtual().name("worker-1").start(() -> {
System.out.println("I am a virtual thread: " + Thread.currentThread());
});
virtual.join();
// Not started
Thread notStarted = Thread.ofVirtual().unstarted(task);
// Virtual thread factory
ThreadFactory factory = Thread.ofVirtual().name("bibliotech-", 0).factory();
// THE MOST IMPORTANT ONE: an executor with one virtual thread PER TASK
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return "done";
});
}
} // close() waits for them all to finish: the executor is AutoCloseableNotice two details: ExecutorService is AutoCloseable since Java 19, so the try-with-resources from 06-06 replaces the shutdown() + awaitTermination() of 08-05. And Thread.sleep(Duration) accepts a Duration from 10-05.
The demonstration
package com.nexussoftware.bibliotech;
import java.time.Duration;
import java.util.concurrent.*;
import java.util.stream.IntStream;
public class VirtualThreadDemo {
private static final int TASKS = 10_000;
public static void main(String[] args) throws Exception {
System.out.println("Tasks: " + TASKS + ", each blocked for 1 second");
System.out.println("Cores: " + Runtime.getRuntime().availableProcessors());
System.out.println();
measure("Platform pool (200 threads)",
Executors.newFixedThreadPool(200));
measure("One VIRTUAL thread per task",
Executors.newVirtualThreadPerTaskExecutor());
// And THIS cannot be done with platform threads
System.out.println();
System.out.println("--- 1,000,000 virtual threads ---");
long start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000).forEach(i ->
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
}));
}
System.out.printf("One million tasks in %d ms%n",
System.currentTimeMillis() - start);
}
private static void measure(String label, ExecutorService executor) throws Exception {
long start = System.currentTimeMillis();
try (executor) {
for (int i = 0; i < TASKS; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // simulates an I/O wait
return null;
});
}
}
System.out.printf("%s -> %6d ms%n", label, System.currentTimeMillis() - start);
}
}Tasks: 10000, each blocked for 1 second
Cores: 8
Platform pool (200 threads) -> 50238 ms
One VIRTUAL thread per task -> 1104 ms
--- 1,000,000 virtual threads ---
One million tasks in 2871 msFifty seconds versus one. The pool of 200 threads processes 200 tasks at a time, so the 10,000 take 50 rounds of one second. Virtual threads run all 10,000 at once, and the total is the time of a single task plus the start-up.
And a million virtual threads in under three seconds. A million platform threads would be roughly a terabyte of stack: the JVM would not even start.
- Platform threads versus virtual threads
| Aspect | Platform thread | Virtual thread |
|---|---|---|
| Implementation | A wrapper around an OS thread | An object managed by the JVM |
| Creation cost | ~1 ms | ~1 µs (a thousand times less) |
| Memory (stack) | ~1 MB reserved | Hundreds of bytes, grows on the heap |
| Viable number | Thousands | Millions |
| Scheduling | Operating system, pre-emptive | JVM, cooperative at blocking points |
| On blocking | The OS thread stalls | It unmounts, freeing the carrier |
| Pooling | Essential | Counterproductive: they are disposable |
ThreadLocal |
Works, with care | Works, but discouraged with millions |
synchronized |
No problem | Can cause pinning |
| Priorities | Yes | No: they are ignored |
| Default name | Thread-N |
Empty (you have to give it one) |
| Good for | Compute-intensive work | I/O: network, files, databases |
Show up in jstack |
Yes | Only with an extended thread dump |
The four practical rules:
1. Do not pool virtual threads. Pools exist because creating a platform thread is expensive. Creating a virtual thread is almost free. Executors.newFixedThreadPool(200) with virtual threads would be absurd: it reintroduces the very limit that virtual threads remove.
// BAD: it contradicts the whole point
ExecutorService bad = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// GOOD: one per task
ExecutorService good = Executors.newVirtualThreadPerTaskExecutor();2. One virtual thread per task, always. Do not reuse them or hold on to them.
3. To limit concurrency, use a Semaphore, not the pool size. If the external service only copes with 50 simultaneous requests, that is a constraint of the resource, not of the threads:
Semaphore limit = new Semaphore(50);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (String isbn : isbns) {
executor.submit(() -> {
limit.acquire();
try {
return metadataClient.query(isbn);
} finally {
limit.release();
}
});
}
}4. They do not speed up computation. See section 20.
- BiblioTech: the
CatalogServer with virtual threads
CatalogServer with virtual threadsThe refactoring that 09-03 left pending.
Before (Java 17, capped pool)
package com.nexussoftware.bibliotech.network;
import java.io.IOException;
import java.net.*;
import java.util.concurrent.*;
import java.util.logging.Logger;
/**
* MODULE 9 VERSION: a capped pool.
*
* The pool limits us to 50 simultaneous clients. With 51 connections,
* the 51st waits in the queue; if the queue fills up, it is REJECTED.
* And you cannot raise it much: each thread is ~1 MB of stack.
*/
public class PooledCatalogServer {
private static final Logger LOG = Logger.getLogger(PooledCatalogServer.class.getName());
private static final int PORT = 9090;
private static final int MAX_CLIENTS = 50; // <-- the limit
private static final int QUEUE = 100;
private final ExecutorService pool = new ThreadPoolExecutor(
MAX_CLIENTS, MAX_CLIENTS,
60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(QUEUE),
new ThreadPoolExecutor.CallerRunsPolicy());
private volatile boolean running = true;
public void start() throws IOException {
try (ServerSocket server = new ServerSocket(PORT)) {
server.setSoTimeout(1000);
LOG.info("Server on port " + PORT + " (max. " + MAX_CLIENTS + " clients)");
while (running) {
try {
Socket client = server.accept();
pool.submit(() -> serve(client));
} catch (SocketTimeoutException e) {
// cycle to re-check 'running'
}
}
} finally {
pool.shutdown();
}
}
private void serve(Socket client) { /* BTCP/1 protocol */ }
}After (Java 21, virtual threads)
package com.nexussoftware.bibliotech.network;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* VIRTUAL THREAD VERSION.
*
* One virtual thread per connection, with no pool and no artificial limit.
* The code in serve() is SEQUENTIAL and BLOCKING, which is how it
* should be written, and it still scales to tens of thousands of clients.
*/
public class CatalogServer implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(CatalogServer.class.getName());
private static final int PORT = 9090;
private static final Duration IDLE_TIMEOUT = Duration.ofSeconds(30);
private static final int MAX_LINE = 4096;
/**
* One VIRTUAL thread per task. There is no longer a pool size to tune,
* nor a queue, nor a rejection policy.
*/
private final ExecutorService handlers = Executors.newVirtualThreadPerTaskExecutor();
private final AtomicLong totalConnections = new AtomicLong();
private final AtomicLong activeConnections = new AtomicLong();
private volatile boolean running = true;
private ServerSocket server;
public void start() throws IOException {
server = new ServerSocket(PORT);
server.setSoTimeout(1000);
LOG.info(() -> "CatalogServer listening on port " + PORT
+ " (one virtual thread per connection, no pool limit)");
// A dedicated virtual thread that reports the state
Thread.ofVirtual().name("monitor").start(this::monitor);
while (running) {
try {
Socket client = server.accept();
// One virtual thread PER CONNECTION. Creating it costs microseconds.
handlers.submit(() -> serve(client));
} catch (SocketTimeoutException e) {
// normal cycle
} catch (IOException e) {
if (running) {
LOG.log(Level.WARNING, "Error accepting a connection", e);
}
}
}
}
/**
* SEQUENTIAL and BLOCKING code: exactly the same as in 09-03.
* readLine() blocks, and that is fine: the virtual thread unmounts.
*/
private void serve(Socket client) {
long number = totalConnections.incrementAndGet();
activeConnections.incrementAndGet();
// Naming the thread makes traces and dumps readable
Thread.currentThread().setName("client-" + number);
try (client;
var in = new BufferedReader(new InputStreamReader(
client.getInputStream(), StandardCharsets.UTF_8));
var out = new PrintWriter(new OutputStreamWriter(
client.getOutputStream(), StandardCharsets.UTF_8), true)) {
client.setSoTimeout((int) IDLE_TIMEOUT.toMillis());
out.println("BTCP/1 WELCOME BiblioTech");
String line;
while ((line = readBoundedLine(in)) != null) {
String response = switch (line.split(" ", 2)[0].toUpperCase()) {
case "QUERY" -> query(line);
case "LEND" -> lend(line);
case "STATUS" -> "200 OK ACTIVE=" + activeConnections.get()
+ " TOTAL=" + totalConnections.get();
case "QUIT" -> "221 BYE";
default -> "400 BAD REQUEST";
};
out.println(response);
if (response.startsWith("221 BYE")) {
break;
}
}
} catch (SocketTimeoutException e) {
LOG.fine(() -> "Client " + number + " dropped for inactivity");
} catch (IOException e) {
LOG.log(Level.FINE, e, () -> "Error with client " + number);
} finally {
activeConnections.decrementAndGet();
}
}
private String readBoundedLine(BufferedReader in) throws IOException {
StringBuilder sb = new StringBuilder();
int c;
while ((c = in.read()) != -1) {
if (c == '\n') {
return sb.toString().strip();
}
if (sb.length() >= MAX_LINE) {
throw new IOException("Line too long: possible attack");
}
sb.append((char) c);
}
return sb.isEmpty() ? null : sb.toString().strip();
}
private void monitor() {
while (running) {
try {
Thread.sleep(Duration.ofSeconds(10));
LOG.info(() -> String.format("Connections: %d active, %d total",
activeConnections.get(), totalConnections.get()));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
private String query(String command) { return "200 OK ..."; }
private String lend(String command) { return "200 OK ..."; }
@Override
public void close() throws IOException {
running = false;
if (server != null) {
server.close();
}
handlers.close(); // waits for the clients in flight to finish
LOG.info("Server stopped");
}
public static void main(String[] args) throws IOException {
try (var server = new CatalogServer()) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try { server.close(); } catch (IOException ignored) { }
}));
server.start();
}
}
}What changed, exactly
| Aspect | With a pool (09-03) | With virtual threads |
|---|---|---|
| Maximum concurrency | 50 clients (pool size) | Tens of thousands |
| Configuration | Pool size, queue, rejection policy | None |
| On saturation | Queues and then rejects | Keeps accepting |
| Memory per client | ~1 MB | ~1 KB |
Code of serve() |
Identical | Identical |
| Stack traces | Readable | Readable |
| Step-by-step debugging | Works | Works |
And that is the central observation: the code in serve() has not changed. It is still sequential, blocking and readable. With CompletableFuture (08-07) you would have to turn it into a chain of thenCompose calls with no local variables between steps, with no try-with-resources spanning the whole conversation, and with stack traces that do not tell you where the flow was.
Virtual threads give you simple code back without paying for it in scalability. That is their entire value.
A note on module 8: everything you learned there is still necessary. Race conditions, volatile, synchronized, deadlocks, concurrent collections and atomic variables apply exactly the same to virtual threads. A virtual thread is a thread: two virtual threads touching the same ArrayList corrupt it just as well. What changes is how many you can have, not how they behave.
- Pinning,
synchronized and what they do not solve
synchronized and what they do not solvePinning
The virtual thread mechanism requires that they can be unmounted from the carrier when they block. There are two situations where they cannot:
- Inside a
synchronizedblock or method. - During a call into native code (JNI).
When that happens, the virtual thread is pinned to the carrier. If it also blocks, it blocks the carrier thread, which is an operating-system thread. With enough pinned threads, every carrier ends up busy and the system grinds to a halt.
// PROBLEM: blocking inside synchronized
public class ProblematicCache {
private final Map<String, Card> cache = new HashMap<>();
public synchronized Card get(String isbn) { // synchronized
return cache.computeIfAbsent(isbn, key -> {
return metadataClient.query(key); // BLOCKS on the network -> PINNING
});
}
}// SOLUTION: ReentrantLock, which DOES allow unmounting
public class CorrectCache {
private final Map<String, Card> cache = new ConcurrentHashMap<>();
private final ReentrantLock lock = new ReentrantLock();
public Card get(String isbn) {
Card existing = cache.get(isbn);
if (existing != null) {
return existing;
}
lock.lock(); // ReentrantLock (08-04): no pinning
try {
return cache.computeIfAbsent(isbn, metadataClient::query);
} finally {
lock.unlock();
}
}
}The rule for virtual threads: in code that blocks, prefer ReentrantLock over synchronized. The locks in java.util.concurrent.locks are designed to let the virtual thread unmount.
You can diagnose it with a JVM option:
Thread[#31,ForkJoinPool-1-worker-1,5,CarrierThreads]
com.nexussoftware.bibliotech.service.ProblematicCache.get(ProblematicCache.java:12)
<== monitors:1An important note on versions: Java 24 (JEP 491) removed synchronized pinning in most cases. If you work with Java 24 or later, the problem practically disappears. On Java 21 — today's reference LTS — it is still a real consideration.
What virtual threads do NOT solve
This is as important as what they do solve.
1. They do not speed up computation. A virtual thread uses a core exactly like a platform one. If your task is multiplying matrices, having a million virtual threads on eight cores does not make it faster; it only adds context switching.
// POINTLESS: the task never blocks
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> computeExpensiveHash(data)); // pure CPU
}
}For computation, keep using a platform pool sized to the cores, or parallelStream() (10-04).
2. They do not remove concurrency problems. Race conditions, deadlocks, memory visibility: the whole of module 8 still applies. In fact, with a million threads, contention problems show up sooner.
3. They do not fix an external bottleneck. Ten thousand virtual threads hammering a database with a pool of twenty connections do not give you ten thousand simultaneous queries: they give you twenty, and 9,980 waiting. External resources still need explicit limiting, with a Semaphore.
4. ThreadLocal is discouraged. With millions of threads, every ThreadLocal is multiplied by a million. On top of that, the habit of reusing a ThreadLocal across a pool makes no sense when every task has its own thread. That is what ScopedValue (section 21) is for.
5. They do not replace CompletableFuture for everything. For composing asynchronous flows with thenCombine and allOf (08-07), or for reactive streams with backpressure, CompletableFuture and the reactive libraries still have their place. Virtual threads shine in the "one task, one thread, sequential code" model.
| Scenario | Tool |
|---|---|
| Server with many blocking connections | Virtual threads |
| Many independent HTTP/DB calls | Virtual threads + Semaphore |
| Compute-intensive work | Platform pool, parallelStream |
| Asynchronous composition with dependencies | CompletableFuture (08-07) |
| Streams with backpressure | Reactive libraries |
- What is coming: structured concurrency and
ScopedValue
ScopedValueTwo features that are in preview in Java 21 and that complete the Loom model. Do not use them in production yet, but it is worth knowing about them.
Structured concurrency
The problem: when you launch several tasks and wait for their results, handling failures and cancellation by hand is error-prone. If one fails, who cancels the others? If the parent thread is interrupted, who cleans up?
// With StructuredTaskScope: the scope CONTAINS the subtasks
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Card> card = scope.fork(() -> metadataClient.query(isbn));
Subtask<byte[]> cover = scope.fork(() -> coverClient.download(isbn));
Subtask<List<String>> reviews = scope.fork(() -> reviewClient.find(isbn));
scope.join(); // waits for all three
scope.throwIfFailed(); // if any failed, it throws and CANCELS the rest
return new FullCard(card.get(), cover.get(), reviews.get());
} // close() guarantees that no subtask is still aliveThe guarantees: subtasks do not outlive the scope, one failure cancels its siblings, interruption propagates downwards, and stack traces reflect the parent-child relationship. It is to concurrency what try-with-resources is to resources.
ScopedValue
The replacement for ThreadLocal in the world of virtual threads:
public class BiblioTechContext {
// Immutable and scoped, unlike ThreadLocal
public static final ScopedValue<String> EMPLOYEE = ScopedValue.newInstance();
public void handleRequest(String employee, Runnable task) {
ScopedValue.where(EMPLOYEE, employee).run(task);
// Outside here, EMPLOYEE is not bound
}
public void somewhereDeepDown() {
if (EMPLOYEE.isBound()) {
LOG.info("Operation by " + EMPLOYEE.get());
}
}
}ThreadLocal |
ScopedValue |
|
|---|---|---|
| Mutability | Mutable via set() |
Immutable |
| Scope | The life of the thread | A delimited block |
| Clean-up | Manual (remove()), a source of leaks |
Automatic |
| Inheritance by subtasks | InheritableThreadLocal, with a copy |
Automatic and without copying |
| With millions of threads | Problematic | Designed for it |
- Tooling:
jshell, jpackage and friends
jshell, jpackage and friendsA quick note on the new JDK tools.
jshell (Java 9) — the REPL. It lets you try out code without creating a class or a main:
jshell> var catalog = new java.util.ArrayList<String>()
catalog ==> []
jshell> catalog.add("Effective Java")
$2 ==> true
jshell> catalog.stream().map(String::toUpperCase).toList()
$3 ==> [EFFECTIVE JAVA]
jshell> java.time.LocalDate.now().plusDays(21)
$4 ==> 2026-08-26
jshell> /exitIrreplaceable for checking the exact behaviour of an API without setting up a project. And very useful for verifying, on the spot, any doubts from this lesson.
Running a .java without compiling (Java 11):
It compiles in memory and runs. It makes Java usable for scripting.
jpackage (Java 14) — native installers:
jpackage --name BiblioTech \
--input dist/ \
--main-jar bibliotech.jar \
--main-class com.nexussoftware.bibliotech.BiblioTechApp \
--runtime-image bibliotech-runtime \
--type deb \
--app-version 3.2It produces a .deb, .rpm, .msi, .exe, .dmg or .pkg with the application and its runtime inside. The end user does not need Java installed.
jwebserver (Java 18) — a static file server for testing:
jdeps — analyses dependencies (you used it with jlink), and detects uses of internal APIs:
jcmd, jfr — diagnostics and flight recording. All of that is 10-07.
- How to keep up to date and how to decide on a migration
The sources
| Source | What it gives you |
|---|---|
| JEPs (openjdk.org/jeps) | The technical proposal for each feature, with motivation and rejected alternatives. The primary source |
| Release notes for the JDK | Incompatible changes, deprecations, removals |
| Inside.java | The official blog with articles and videos from the team |
| JDK Mission Control / JFR | For performance (10-07) |
| Javadoc for the release | With @since on every method |
A JEP is the unit of change in Java. The ones worth knowing from this lesson: JEP 261 (modules), JEP 286 (var), JEP 361 (expression switch), JEP 378 (text blocks), JEP 395 (records), JEP 409 (sealed classes), JEP 440/441 (record patterns and switch), JEP 444 (virtual threads).
Deciding which version to migrate to
Base rule: use the most recent LTS your dependencies support. Today that is Java 21, with Java 25 available.
The jump by source version:
| From | To | Difficulty | What to expect |
|---|---|---|---|
| 8 | 11 | High | Modules, internal API blocked, CORBA and JavaFX removed from the JDK, javax.xml.bind gone |
| 8 | 17 | High | All of the above plus the deprecated Security Manager and strong encapsulation |
| 11 | 17 | Medium | Strong encapsulation by default is the main stumbling block |
| 17 | 21 | Low | Very compatible; almost everything works unchanged |
| 21 | 25 | Low | Expected to be very compatible |
The jump from Java 8 to 11 is the hard one, and that is why so many projects are still on 8. The typical problems:
javax.xml.bind(JAXB) andjava.xml.wswere removed from the JDK. You have to add them as dependencies.- Libraries that use internal APIs (
sun.misc.Unsafe,sun.reflect) fail withInaccessibleObjectException. - Old tooling (old versions of Maven, Gradle, Lombok, ASM) does not understand the new bytecode.
-XX:+UseParallelOldGCand other JVM options disappeared.
A sensible migration plan:
# 1. Detect uses of internal APIs BEFORE touching anything
jdeps --jdk-internals --multi-release 21 bibliotech.jar
# 2. Compile with the new version but an old target
javac --release 11 ...
# 3. Run with the new JVM and the old code, watching the warnings
java --illegal-access=warn -jar bibliotech.jar # up to Java 16
# 4. Update dependencies ONE at a time, not all at once
# 5. Raise the compilation target
javac --release 21 ...
# 6. Modernise the code (optional and gradual):
# var, expression switch, records, text blocks, streamsAnd one methodological recommendation: separate the migration (make it compile and work on the new version) from the modernisation (using the new features). Doing both at once multiplies the risk and makes it impossible to know what broke what.
Common Mistakes and Tips
1. Using preview features in production. They need --enable-preview at compile time and at runtime, the bytecode only runs on that exact version, and the design can change.
2. Modularising an enterprise application "because it is the modern thing". JPMS gives little to an executable JAR and costs a lot of friction with dependencies. Modularise public libraries, not applications.
3. Confusing exports with opens. Exporting allows normal use; only opens allows setAccessible on private members. A persistence framework needs opens.
4. Overusing --add-opens. Every use is technical debt: you depend on internal details that can change. Use it as a temporary patch while you upgrade the library at fault.
5. Overusing var. var result = service.process(data); tells you nothing. Use it when the type is obvious on the line itself.
6. var list = new ArrayList<>(); infers ArrayList<Object>, almost never what you want. With var, the generic type goes on the right.
7. Putting a default in a switch over a sealed type. It destroys exhaustiveness: if a type is added tomorrow, the default swallows it silently instead of breaking the build. The absence of default is the feature, not an oversight.
8. Forgetting that the subclasses of a sealed class must be declared. final, sealed or non-sealed: there is no fourth option, and the error message is clear but easily forgotten.
9. Pooling virtual threads. It contradicts their purpose. One per task, and a Semaphore to limit access to external resources.
10. Blocking inside synchronized with virtual threads. It causes pinning and blocks a carrier thread. Use ReentrantLock on Java 21 (on Java 24+ the problem is largely solved).
11. Expecting virtual threads to speed up computation. They do not: they only stop you wasting operating-system threads while waiting on I/O.
12. Misaligning the closing """ of a text block. Its position takes part in the common indentation calculation. Align it with the content unless you want to keep a margin.
13. Forgetting that List.of and Map.of do not accept null and that the order of Set.of changes between runs on purpose.
Tip 1: learn the features through the problem they solve. sealed exists so the compiler can verify exhaustiveness. Virtual threads exist so blocking code scales. Memorising syntax without the why produces incorrect use.
Tip 2: adopt gradually. Start with what carries no risk: var where it helps, expression switch, text blocks for SQL and JSON, record for DTOs. Leave sealed and pattern matching for when you refactor a hierarchy.
Tip 3: use jshell to check. Five seconds in the REPL answers "what exactly does this return?" better than fifteen minutes of reading documentation.
Tip 4: separate migrating from modernising. First make it compile and pass the tests on the new version; then modernise module by module.
Tip 5: read the JEP for whatever you use. JEPs explain which alternatives were rejected and why, and that teaches more about language design than any tutorial.
Exercises
Exercise 1: modelling the catalogue with sealed types
Redesign the BiblioTech domain, taking advantage of everything in this lesson:
sealed interface CatalogItem permits Material, CatalogCollection, whereCatalogCollectiongroups several items (an item can contain others).sealed abstract class Material permits Book, Magazine, Dvd, AudioBook, withBookandDvdasfinal,Magazinesealed with two subtypes, andAudioBookasnon-sealed.- A
sealed interface SearchResultwith recordsFound,NotFoundandAmbiguous(List<Material> candidates). - A service with exhaustive
switchexpressions and nodefaultthat computes: loan term, replacement value, shelf label and a full card using text blocks. - A recursive
int countMaterials(CatalogItem)method that descends through the nested collections using record patterns. - Show that adding a fifth material breaks the build in all the right places.
Exercise 2: CatalogEnricher with virtual threads
In 09-06 you wrote CatalogEnricher with CompletableFuture, allOf and a Semaphore. Rewrite it with virtual threads and compare.
- A
VirtualEnricherthat queries metadata and downloads covers for N ISBNs usingExecutors.newVirtualThreadPerTaskExecutor(). - Sequential, blocking code inside each task, with no
thenComposeorthenCombine. - Limit the external service to 50 simultaneous requests with a
Semaphore, not with the pool size. - Handle individual failures without bringing down the batch, with a sealed
EnrichmentResulttype. - Measure and compare: a platform pool of 200, virtual threads, and
CompletableFuture, with 100, 1,000 and 10,000 ISBNs. - Demonstrate pinning: a version with
synchronizedaround the blocking call versus one withReentrantLock, run with-Djdk.tracePinnedThreads=full.
Exercise 3: a modernisation report
Write a ModernisationAnalyser tool that examines .java files and detects modernisation opportunities:
- Strings concatenated with
+across several lines → a text block. - A classic
switchwithbreak→ an expressionswitch. instanceoffollowed by a cast to the same type → patterninstanceof.- Classes with only
finalfields, a constructor, getters,equals,hashCodeandtoString→ arecord. new SimpleDateFormat/java.util.Date→java.time(10-05).Executors.newFixedThreadPoolwith blocking tasks → virtual threads.- Accumulator loops over collections → streams (10-04).
For each finding, report the file, the line, the Java version that enables it and a suggestion. Generate a report with groupingBy and text blocks. Apply it to the old BiblioTech classes.
Solutions
Solution 1
package com.nexussoftware.bibliotech.domain;
import java.util.List;
/**
* SEALED root of the catalogue. An item is either a concrete material
* or a collection that groups other items.
*/
public sealed interface CatalogItem permits Material, CatalogCollection {
String getIsbn();
String getTitle();
}package com.nexussoftware.bibliotech.domain;
import java.util.List;
import java.util.Objects;
/**
* A grouping of items. It can contain nested collections:
* it is a recursive structure (the Composite pattern, formalised in 12-02).
*/
public record CatalogCollection(String isbn, String title, List<CatalogItem> items)
implements CatalogItem {
public CatalogCollection {
Objects.requireNonNull(isbn, "isbn");
Objects.requireNonNull(title, "title");
items = List.copyOf(items); // immutable defensive copy
}
@Override public String getIsbn() { return isbn; }
@Override public String getTitle() { return title; }
}package com.nexussoftware.bibliotech.domain;
/**
* Material hierarchy, CLOSED to four types.
* Every subclass explicitly declares whether it can be extended.
*/
public sealed abstract class Material
implements CatalogItem
permits Book, Magazine, Dvd, AudioBook {
private final String isbn;
private final String title;
private final double rating;
protected Material(String isbn, String title, double rating) {
this.isbn = isbn;
this.title = title;
this.rating = rating;
}
@Override public String getIsbn() { return isbn; }
@Override public String getTitle() { return title; }
public double getRating() { return rating; }
}/** final: completely closed. */
public final class Book extends Material {
private final String author;
private final int pages;
public Book(String isbn, String title, String author, int pages, double rating) {
super(isbn, title, rating);
this.author = author;
this.pages = pages;
}
public String getAuthor() { return author; }
public int getPages() { return pages; }
}
/** sealed: its own closed hierarchy of two frequencies. */
public sealed class Magazine extends Material
permits MonthlyMagazine, QuarterlyMagazine {
private final int number;
protected Magazine(String isbn, String title, int number, double rating) {
super(isbn, title, rating);
this.number = number;
}
public int getNumber() { return number; }
}
public final class MonthlyMagazine extends Magazine {
private final java.time.Month month;
public MonthlyMagazine(String isbn, String title, int number,
java.time.Month month, double rating) {
super(isbn, title, number, rating);
this.month = month;
}
public java.time.Month getMonth() { return month; }
}
public final class QuarterlyMagazine extends Magazine {
private final int quarter;
public QuarterlyMagazine(String isbn, String title, int number,
int quarter, double rating) {
super(isbn, title, number, rating);
this.quarter = quarter;
}
public int getQuarter() { return quarter; }
}
/** final. */
public final class Dvd extends Material {
private final String director;
private final int minutes;
public Dvd(String isbn, String title, String director, int minutes, double rating) {
super(isbn, title, rating);
this.director = director;
this.minutes = minutes;
}
public String getDirector() { return director; }
public int getMinutes() { return minutes; }
}
/** non-sealed: DELIBERATELY open to extension by third parties. */
public non-sealed class AudioBook extends Material {
private final String narrator;
private final int minutes;
public AudioBook(String isbn, String title, String narrator, int minutes, double rating) {
super(isbn, title, rating);
this.narrator = narrator;
this.minutes = minutes;
}
public String getNarrator() { return narrator; }
public int getMinutes() { return minutes; }
}package com.nexussoftware.bibliotech.domain;
import java.util.List;
/** Sum type for the result of a search. */
public sealed interface SearchResult {
record Found(Material material) implements SearchResult { }
record NotFound(String query, List<String> suggestions) implements SearchResult { }
record Ambiguous(String query, List<Material> candidates) implements SearchResult { }
}The service:
package com.nexussoftware.bibliotech.service;
import com.nexussoftware.bibliotech.domain.*;
/**
* Every switch is EXHAUSTIVE and has no default.
* If a fifth material is added, none of them compiles.
*/
public class CatalogService {
public int termDays(Material material) {
return switch (material) {
case Book b when b.getPages() > 500 -> 30;
case Book b when b.getPages() > 200 -> 21;
case Book b -> 14;
case MonthlyMagazine m -> 7;
case QuarterlyMagazine q -> 14;
case Dvd d when d.getMinutes() > 180 -> 5;
case Dvd d -> 3;
case AudioBook a -> 21;
};
// Magazine is sealed: the compiler accepts covering its two subtypes
// instead of Magazine, and checks that both are there.
}
public double replacementValue(Material material) {
return switch (material) {
case Book b -> Math.max(15.0, b.getPages() * 0.08);
case MonthlyMagazine m -> 8.0;
case QuarterlyMagazine q -> 14.0;
case Dvd d -> Math.max(10.0, d.getMinutes() * 0.15);
case AudioBook a -> Math.max(20.0, a.getMinutes() * 0.10);
};
}
public String shelfLabel(CatalogItem item) {
String prefix = switch (item) {
case Book b -> "BOO";
case MonthlyMagazine m -> "MMG";
case QuarterlyMagazine q -> "QMG";
case Dvd d -> "DVD";
case AudioBook a -> "AUD";
case CatalogCollection c -> "COL";
};
return prefix + "-" + item.getIsbn().substring(4, 8);
}
/** A card with text blocks and formatted (Java 15). */
public String card(CatalogItem item) {
return switch (item) {
case Book b -> """
┌─ BOOK ──────────────────────────────
│ %s
│ Author: %s
│ Pages: %d
│ Shelf: %s
│ Term: %d days
│ Replacement: %.2f EUR
└─────────────────────────────────────"""
.formatted(b.getTitle(), b.getAuthor(), b.getPages(),
shelfLabel(b), termDays(b), replacementValue(b));
case MonthlyMagazine m -> """
┌─ MONTHLY MAGAZINE ──────────────────
│ %s no. %d (%s)
│ Shelf: %s
│ Term: %d days
└─────────────────────────────────────"""
.formatted(m.getTitle(), m.getNumber(), m.getMonth(),
shelfLabel(m), termDays(m));
case QuarterlyMagazine q -> """
┌─ QUARTERLY MAGAZINE ────────────────
│ %s no. %d (Q%d)
│ Shelf: %s
│ Term: %d days
└─────────────────────────────────────"""
.formatted(q.getTitle(), q.getNumber(), q.getQuarter(),
shelfLabel(q), termDays(q));
case Dvd d -> """
┌─ DVD ───────────────────────────────
│ %s
│ Director: %s
│ Duration: %d min
│ Term: %d days
└─────────────────────────────────────"""
.formatted(d.getTitle(), d.getDirector(), d.getMinutes(), termDays(d));
case AudioBook a -> """
┌─ AUDIOBOOK ─────────────────────────
│ %s
│ Narrator: %s
│ Duration: %d min
│ Term: %d days
└─────────────────────────────────────"""
.formatted(a.getTitle(), a.getNarrator(), a.getMinutes(), termDays(a));
case CatalogCollection(var isbn, var title, var items) -> """
┌─ COLLECTION ────────────────────────
│ %s
│ Items: %d direct, %d in total
│ Shelf: %s
└─────────────────────────────────────"""
.formatted(title, items.size(),
countMaterials(item), shelfLabel(item));
};
}
/**
* RECURSIVE with a record pattern: the collection is deconstructed
* and we descend through its items.
*/
public int countMaterials(CatalogItem item) {
return switch (item) {
case Material m -> 1;
case CatalogCollection(var isbn, var title, var items) ->
items.stream()
.mapToInt(this::countMaterials) // recursion
.sum();
};
}
/** Maximum nesting depth. */
public int depth(CatalogItem item) {
return switch (item) {
case Material m -> 0;
case CatalogCollection(var isbn, var title, var items) ->
1 + items.stream()
.mapToInt(this::depth)
.max().orElse(0);
};
}
/** The search result, also with an exhaustive switch. */
public String describeSearch(SearchResult result) {
return switch (result) {
case SearchResult.Found(Material m) ->
"Found: " + m.getTitle();
case SearchResult.NotFound(var query, var suggestions)
when suggestions.isEmpty() ->
"No results for \"" + query + "\"";
case SearchResult.NotFound(var query, var suggestions) ->
"No results for \"" + query + "\". Did you mean: "
+ String.join(", ", suggestions) + "?";
case SearchResult.Ambiguous(var query, var candidates) ->
candidates.size() + " matches for \"" + query + "\": "
+ candidates.stream().map(Material::getTitle)
.collect(java.util.stream.Collectors.joining(", "));
};
}
}A test run:
package com.nexussoftware.bibliotech;
import com.nexussoftware.bibliotech.domain.*;
import com.nexussoftware.bibliotech.service.CatalogService;
import java.time.Month;
import java.util.List;
public class SealedCatalogTest {
public static void main(String[] args) {
CatalogService service = new CatalogService();
Book effective = new Book("978-0000000001", "Effective Java", "Joshua Bloch", 412, 4.85);
Book patterns = new Book("978-0000000002", "Design Patterns", "GoF", 395, 4.60);
Book refactor = new Book("978-0000000003", "Refactoring", "M. Fowler", 448, 4.72);
MonthlyMagazine javaMag = new MonthlyMagazine("978-0000000010", "Java Magazine",
142, Month.AUGUST, 3.90);
Dvd springCourse = new Dvd("978-0000000020", "Spring Course", "N. Vidal", 240, 4.10);
AudioBook audio = new AudioBook("978-0000000030", "Clean Code (audio)",
"D. Alonso", 480, 4.30);
// NESTED collection
CatalogCollection essentials = new CatalogCollection("978-1000000001", "Java Essentials",
List.of(effective, patterns, refactor));
CatalogCollection multimedia = new CatalogCollection("978-1000000002", "Multimedia Training",
List.of(springCourse, audio));
CatalogCollection whole = new CatalogCollection("978-1000000000", "Nexus Technical Library",
List.of(essentials, multimedia, javaMag));
System.out.println(service.card(effective));
System.out.println(service.card(javaMag));
System.out.println(service.card(audio));
System.out.println(service.card(whole));
System.out.println();
System.out.println("Total materials: " + service.countMaterials(whole));
System.out.println("Depth: " + service.depth(whole));
System.out.println();
System.out.println(service.describeSearch(
new SearchResult.Found(effective)));
System.out.println(service.describeSearch(
new SearchResult.NotFound("kotlin", List.of())));
System.out.println(service.describeSearch(
new SearchResult.NotFound("javaa", List.of("Effective Java"))));
System.out.println(service.describeSearch(
new SearchResult.Ambiguous("java", List.of(effective, audio))));
}
}┌─ BOOK ──────────────────────────────
│ Effective Java
│ Author: Joshua Bloch
│ Pages: 412
│ Shelf: BOO-0000
│ Term: 21 days
│ Replacement: 32.96 EUR
└─────────────────────────────────────
┌─ MONTHLY MAGAZINE ──────────────────
│ Java Magazine no. 142 (AUGUST)
│ Shelf: MMG-0000
│ Term: 7 days
└─────────────────────────────────────
┌─ AUDIOBOOK ─────────────────────────
│ Clean Code (audio)
│ Narrator: D. Alonso
│ Duration: 480 min
│ Term: 21 days
└─────────────────────────────────────
┌─ COLLECTION ────────────────────────
│ Nexus Technical Library
│ Items: 3 direct, 6 in total
│ Shelf: COL-1000
└─────────────────────────────────────
Total materials: 6
Depth: 2
Found: Effective Java
No results for "kotlin"
No results for "javaa". Did you mean: Effective Java?
2 matches for "java": Effective Java, Clean Code (audio)And the exhaustiveness demonstration. On adding MapSheet to the permits list:
public sealed abstract class Material
implements CatalogItem
permits Book, Magazine, Dvd, AudioBook, MapSheet { }
public final class MapSheet extends Material { /* ... */ }CatalogService.java:14: error: the switch expression does not cover all possible input values
return switch (material) {
^
CatalogService.java:27: error: the switch expression does not cover all possible input values
return switch (material) {
^
CatalogService.java:37: error: the switch expression does not cover all possible input values
String prefix = switch (item) {
^
CatalogService.java:49: error: the switch expression does not cover all possible input values
return switch (item) {
^
4 errorsComments. Four observations.
Four compile errors are four places that had to be updated. With the old if-else version with a default, MapSheet would have fallen into the default of every method: zero term, zero replacement value, label "X", and nobody would have found out until a user complained. That is the entire value of sealed classes.
The compiler understands nested hierarchies. Magazine is sealed permits MonthlyMagazine, QuarterlyMagazine, and the switch covers both subtypes instead of Magazine. The compiler verifies that both are there: removing one breaks the build.
AudioBook is non-sealed on purpose, and it shows that openness is a per-branch decision. Somebody can write class Podcast extends AudioBook, and case AudioBook a will cover it.
The recursive countMaterials with a record pattern is the most expressive part of the exercise. Two cases, four lines, and it walks an arbitrarily nested structure. It is the Composite pattern (12-02) expressed as an algebraic type.
Solution 2
package com.nexussoftware.bibliotech.network;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Catalogue enricher with VIRTUAL THREADS.
* It replaces the CompletableFuture-based version from 09-06.
*/
public class VirtualEnricher implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(VirtualEnricher.class.getName());
/** Sum type for the result of each ISBN. */
public sealed interface EnrichmentResult {
String isbn();
record Complete(String isbn, String metadata, int coverBytes, long ms)
implements EnrichmentResult { }
record Partial(String isbn, String metadata, String coverFailureReason, long ms)
implements EnrichmentResult { }
record Failed(String isbn, String reason, long ms)
implements EnrichmentResult { }
}
/**
* A semaphore, NOT a pool size: the constraint belongs to the
* external service, not to our threads.
*/
private final Semaphore externalLimit;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
private final MetadataClient metadata;
private final CoverClient covers;
public VirtualEnricher(MetadataClient metadata, CoverClient covers,
int simultaneousRequests) {
this.metadata = metadata;
this.covers = covers;
this.externalLimit = new Semaphore(simultaneousRequests);
}
public List<EnrichmentResult> enrich(List<String> isbns) {
List<Future<EnrichmentResult>> futures = new ArrayList<>(isbns.size());
// ALL of them are launched: one virtual thread per ISBN, be it 100 or 100,000
for (String isbn : isbns) {
futures.add(executor.submit(() -> processOne(isbn)));
}
List<EnrichmentResult> results = new ArrayList<>(isbns.size());
for (int i = 0; i < futures.size(); i++) {
try {
results.add(futures.get(i).get());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (ExecutionException e) {
results.add(new EnrichmentResult.Failed(
isbns.get(i), "unhandled exception: " + e.getCause(), 0));
}
}
return results;
}
/**
* SEQUENTIAL, BLOCKING CODE. Not a single thenCompose, not a
* thenCombine, not a callback. It reads top to bottom.
*/
private EnrichmentResult processOne(String isbn) {
long start = System.nanoTime();
Thread.currentThread().setName("enrich-" + isbn);
try {
// 1. Metadata: it blocks, and that is fine
String data;
externalLimit.acquire();
try {
data = metadata.query(isbn); // BLOCKING call
} finally {
externalLimit.release();
}
// 2. Cover: it can fail without invalidating the result
int bytes;
try {
externalLimit.acquire();
try {
bytes = covers.download(isbn).length;
} finally {
externalLimit.release();
}
} catch (Exception e) {
return new EnrichmentResult.Partial(
isbn, data, e.getClass().getSimpleName(), ms(start));
}
return new EnrichmentResult.Complete(isbn, data, bytes, ms(start));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new EnrichmentResult.Failed(isbn, "interrupted", ms(start));
} catch (Exception e) {
LOG.log(Level.FINE, e, () -> "Failure enriching " + isbn);
return new EnrichmentResult.Failed(
isbn, e.getClass().getSimpleName() + ": " + e.getMessage(), ms(start));
}
}
private long ms(long startNanos) {
return (System.nanoTime() - startNanos) / 1_000_000;
}
/** Report with an exhaustive switch over the sealed type. */
public String report(List<EnrichmentResult> results) {
Map<String, Long> byType = results.stream()
.collect(java.util.stream.Collectors.groupingBy(
r -> switch (r) {
case EnrichmentResult.Complete c -> "complete";
case EnrichmentResult.Partial p -> "partial";
case EnrichmentResult.Failed f -> "failed";
},
java.util.TreeMap::new,
java.util.stream.Collectors.counting()));
double averageMs = results.stream()
.mapToLong(r -> switch (r) {
case EnrichmentResult.Complete c -> c.ms();
case EnrichmentResult.Partial p -> p.ms();
case EnrichmentResult.Failed f -> f.ms();
})
.average().orElse(0);
return """
Enrichment of %d ISBNs
%s
Average time per ISBN: %.1f ms"""
.formatted(results.size(), byType, averageMs);
}
@Override
public void close() {
executor.close(); // ExecutorService is AutoCloseable since Java 19
}
}The comparative test bench:
package com.nexussoftware.bibliotech;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.IntStream;
public class EnrichmentComparison {
/** Simulates a network call: a 50 ms wait. */
private static String simulatedQuery(String isbn) throws InterruptedException {
Thread.sleep(Duration.ofMillis(50));
return "{\"isbn\":\"" + isbn + "\"}";
}
public static void main(String[] args) throws Exception {
System.out.printf("%-10s %14s %14s %14s%n",
"ISBN", "PLATFORM (200)", "VIRTUAL", "COMPL.FUTURE");
System.out.println("-".repeat(56));
for (int n : new int[] { 100, 1_000, 10_000 }) {
List<String> isbns = generate(n);
long platform = measure(() -> withPlatformPool(isbns));
long virtual = measure(() -> withVirtualThreads(isbns));
long futures = measure(() -> withCompletableFuture(isbns));
System.out.printf("%-10d %11d ms %11d ms %11d ms%n",
n, platform, virtual, futures);
}
System.out.println();
demonstratePinning();
}
private static List<String> generate(int n) {
return IntStream.rangeClosed(1, n)
.mapToObj(i -> String.format("978-%010d", i))
.toList();
}
private static long measure(Runnable task) {
long start = System.currentTimeMillis();
task.run();
return System.currentTimeMillis() - start;
}
private static void withPlatformPool(List<String> isbns) {
try (var executor = Executors.newFixedThreadPool(200)) {
List<Future<String>> futures = isbns.stream()
.map(isbn -> executor.submit(() -> simulatedQuery(isbn)))
.toList();
futures.forEach(EnrichmentComparison::get);
}
}
private static void withVirtualThreads(List<String> isbns) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = isbns.stream()
.map(isbn -> executor.submit(() -> simulatedQuery(isbn)))
.toList();
futures.forEach(EnrichmentComparison::get);
}
}
private static void withCompletableFuture(List<String> isbns) {
try (var executor = Executors.newFixedThreadPool(200)) {
List<CompletableFuture<String>> futures = isbns.stream()
.map(isbn -> CompletableFuture.supplyAsync(() -> {
try {
return simulatedQuery(isbn);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}, executor))
.toList();
CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();
}
}
private static <T> T get(Future<T> f) {
try {
return f.get();
} catch (Exception e) {
return null;
}
}
// ------------------------------------------------------------------
// PINNING demonstration
// ------------------------------------------------------------------
static class WithSynchronized {
private final Map<String, String> cache = new HashMap<>();
/** synchronized + blocking -> PINNING of the carrier thread. */
public synchronized String get(String isbn) throws InterruptedException {
String v = cache.get(isbn);
if (v == null) {
v = simulatedQuery(isbn);
cache.put(isbn, v);
}
return v;
}
}
static class WithReentrantLock {
private final Map<String, String> cache = new ConcurrentHashMap<>();
private final ReentrantLock lock = new ReentrantLock();
/** ReentrantLock lets the virtual thread unmount. */
public String get(String isbn) throws InterruptedException {
String v = cache.get(isbn);
if (v != null) {
return v;
}
lock.lock();
try {
v = cache.get(isbn);
if (v == null) {
v = simulatedQuery(isbn);
cache.put(isbn, v);
}
return v;
} finally {
lock.unlock();
}
}
}
private static void demonstratePinning() {
System.out.println("--- Pinning (run with -Djdk.tracePinnedThreads=full) ---");
List<String> isbns = generate(500);
WithSynchronized withSync = new WithSynchronized();
long t1 = measure(() -> {
try (var e = Executors.newVirtualThreadPerTaskExecutor()) {
isbns.forEach(isbn -> e.submit(() -> withSync.get(isbn)));
}
});
WithReentrantLock withLock = new WithReentrantLock();
long t2 = measure(() -> {
try (var e = Executors.newVirtualThreadPerTaskExecutor()) {
isbns.forEach(isbn -> e.submit(() -> withLock.get(isbn)));
}
});
System.out.printf(" synchronized: %5d ms%n", t1);
System.out.printf(" ReentrantLock: %5d ms%n", t2);
}
}ISBN PLATFORM (200) VIRTUAL COMPL.FUTURE
--------------------------------------------------------
100 62 ms 58 ms 64 ms
1000 306 ms 71 ms 301 ms
10000 2571 ms 139 ms 2559 ms
--- Pinning (run with -Djdk.tracePinnedThreads=full) ---
synchronized: 25142 ms
ReentrantLock: 25089 msComments. Five observations.
With 100 ISBNs the three are equivalent, because 100 fit in a pool of 200. The difference appears when the desired concurrency exceeds the pool.
With 10,000, virtual threads are 18× faster. The pool of 200 does 50 rounds of 50 ms; the virtual ones do it all at once. CompletableFuture over the same pool of 200 has exactly the same limit: asynchrony does not remove the pool bottleneck, it only stops the threads waiting idle.
The code in processOne is sequential. Compare it with the 09-06 version: there you had thenCompose, thenCombine, exceptionally and allOf, and the intermediate variables had to travel inside the lambdas. Here there is one try, two calls and a return. And it scales just the same.
The two pinning cases take the same time here, and that is instructive: the synchronized covers the whole method, so it serialises the 500 tasks with or without pinning — 500 × 50 ms = 25 seconds in both cases. Pinning shows up mainly when many carriers are busy; with -Djdk.tracePinnedThreads=full on Java 21 you will see the <== monitors:1 traces in the synchronized version and none in the ReentrantLock one. The real lesson is that the problem here is not pinning but an oversized critical section, which is a module 8 problem and not a Loom one.
The Semaphore instead of the pool size is the design piece. The metadata service copes with 50 simultaneous requests; that is a property of the service, not of the client. Expressing it with a semaphore lets you have 10,000 virtual threads of which only 50 are inside the service at a time, with the remaining 9,950 waiting without consuming a single operating-system thread.
Solution 3
package com.nexussoftware.bibliotech.tools;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Detects modernisation opportunities in Java code.
*
* This is a regular-expression analysis: fast and approximate.
* A real analyser would work on the AST (javax.lang.model, 10-02).
*/
public class ModernisationAnalyser {
/** Sealed type for the finding categories. */
public sealed interface Finding {
Path file();
int line();
int javaVersion();
String suggestion();
record MultilineConcatenation(Path file, int line) implements Finding {
public int javaVersion() { return 15; }
public String suggestion() { return "Use a text block \"\"\"...\"\"\""; }
}
record ClassicSwitch(Path file, int line) implements Finding {
public int javaVersion() { return 14; }
public String suggestion() { return "Use an expression switch with ->"; }
}
record CastAfterInstanceof(Path file, int line, String type) implements Finding {
public int javaVersion() { return 16; }
public String suggestion() { return "Use instanceof " + type + " variable"; }
}
record LegacyDateApi(Path file, int line, String className) implements Finding {
public int javaVersion() { return 8; }
public String suggestion() {
return switch (className) {
case "SimpleDateFormat" -> "Use DateTimeFormatter (immutable and thread-safe)";
case "Calendar" -> "Use LocalDate / LocalDateTime";
case "Date" -> "Use Instant or LocalDate as appropriate";
default -> "Migrate to java.time";
};
}
}
record PlatformPool(Path file, int line) implements Finding {
public int javaVersion() { return 21; }
public String suggestion() {
return "If the tasks block, use Executors.newVirtualThreadPerTaskExecutor()";
}
}
record AccumulatorLoop(Path file, int line) implements Finding {
public int javaVersion() { return 8; }
public String suggestion() { return "Consider stream().filter(...).map(...).toList()"; }
}
record RecordCandidate(Path file, int line, String className) implements Finding {
public int javaVersion() { return 16; }
public String suggestion() { return "Class " + className + " could be a record"; }
}
}
// --- Patterns ---
private static final Pattern CONCATENATION = Pattern.compile("^\\s*\\+\\s*\"");
private static final Pattern CLASSIC_SWITCH = Pattern.compile("\\bcase\\s+[^>]+:\\s*$");
private static final Pattern INSTANCEOF_NO_PATTERN =
Pattern.compile("instanceof\\s+(\\w+)\\s*\\)\\s*\\{?\\s*$");
private static final Pattern CAST = Pattern.compile("\\(\\s*(\\w+)\\s*\\)\\s*\\w+");
private static final Pattern LEGACY_DATES =
Pattern.compile("\\bnew\\s+(SimpleDateFormat|Date|GregorianCalendar)\\b"
+ "|\\bCalendar\\.getInstance\\(\\)");
private static final Pattern FIXED_POOL =
Pattern.compile("Executors\\.newFixedThreadPool|Executors\\.newCachedThreadPool");
private static final Pattern LIST_DECLARATION =
Pattern.compile("(List|Set|Map)<[^>]+>\\s+\\w+\\s*=\\s*new\\s+(ArrayList|HashSet|HashMap)");
private static final Pattern FOR_EACH = Pattern.compile("\\bfor\\s*\\(\\s*\\w+.*:\\s*\\w+");
public List<Finding> analyse(Path root) throws IOException {
try (Stream<Path> files = Files.walk(root)) {
return files
.filter(Files::isRegularFile)
.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.getFileName().toString().equals("module-info.java"))
.flatMap(this::analyseFile)
.toList();
}
}
private Stream<Finding> analyseFile(Path file) {
try {
List<String> lines = Files.readAllLines(file, StandardCharsets.UTF_8);
List<Finding> findings = new ArrayList<>();
String possibleInstanceofType = null;
int instanceofLine = 0;
int declarationLines = 0;
int declarationStart = 0;
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
int number = i + 1;
if (CONCATENATION.matcher(line).find()) {
findings.add(new Finding.MultilineConcatenation(file, number));
}
if (CLASSIC_SWITCH.matcher(line).find()) {
findings.add(new Finding.ClassicSwitch(file, number));
}
if (LEGACY_DATES.matcher(line).find()) {
var m = LEGACY_DATES.matcher(line);
m.find();
String className = m.group(1) != null ? m.group(1) : "Calendar";
findings.add(new Finding.LegacyDateApi(file, number, className));
}
if (FIXED_POOL.matcher(line).find()) {
findings.add(new Finding.PlatformPool(file, number));
}
// instanceof on one line, a cast to the same type on the next
var mi = INSTANCEOF_NO_PATTERN.matcher(line);
if (mi.find()) {
possibleInstanceofType = mi.group(1);
instanceofLine = number;
} else if (possibleInstanceofType != null) {
var mc = CAST.matcher(line);
if (mc.find() && possibleInstanceofType.equals(mc.group(1))) {
findings.add(new Finding.CastAfterInstanceof(
file, instanceofLine, possibleInstanceofType));
}
possibleInstanceofType = null;
}
// A collection declaration followed by a for-each loop: a stream candidate
if (LIST_DECLARATION.matcher(line).find()) {
declarationLines = 1;
declarationStart = number;
} else if (declarationLines > 0 && declarationLines < 4) {
if (FOR_EACH.matcher(line).find()) {
findings.add(new Finding.AccumulatorLoop(file, declarationStart));
declarationLines = 0;
} else {
declarationLines++;
}
} else {
declarationLines = 0;
}
}
detectRecordCandidate(file, lines).ifPresent(findings::add);
return findings.stream();
} catch (IOException e) {
System.err.println("Could not read " + file + ": " + e.getMessage());
return Stream.empty();
}
}
/** Heuristic: only final fields, getters, equals, hashCode and toString. */
private Optional<Finding> detectRecordCandidate(Path file, List<String> lines) {
String content = String.join("\n", lines);
boolean hasSetters = content.contains("public void set");
boolean hasNonFinalFields = Pattern
.compile("private\\s+(?!final)\\w+\\s+\\w+\\s*;").matcher(content).find();
boolean hasEquals = content.contains("public boolean equals(Object");
boolean hasHashCode = content.contains("public int hashCode()");
boolean extendsSomething = Pattern.compile("class\\s+\\w+\\s+extends").matcher(content).find();
if (!hasSetters && !hasNonFinalFields && hasEquals && hasHashCode && !extendsSomething) {
String name = file.getFileName().toString().replace(".java", "");
int line = 1 + (int) lines.stream()
.takeWhile(l -> !l.contains("class " + name))
.count();
return Optional.of(new Finding.RecordCandidate(file, line, name));
}
return Optional.empty();
}
// ------------------------------------------------------------------
public String report(List<Finding> findings, int targetVersion) {
Map<String, List<Finding>> byType = findings.stream()
.collect(Collectors.groupingBy(f -> f.getClass().getSimpleName(),
TreeMap::new, Collectors.toList()));
Map<Integer, Long> byVersion = findings.stream()
.collect(Collectors.groupingBy(Finding::javaVersion,
TreeMap::new, Collectors.counting()));
long applicable = findings.stream()
.filter(f -> f.javaVersion() <= targetVersion)
.count();
StringBuilder sb = new StringBuilder();
sb.append("""
════════════════════════════════════════════════════
MODERNISATION REPORT -- target: Java %d
════════════════════════════════════════════════════
Total findings: %d
Applicable today: %d
Require migrating: %d
""".formatted(targetVersion, findings.size(),
applicable, findings.size() - applicable));
sb.append("\nBY MINIMUM VERSION REQUIRED\n");
byVersion.forEach((v, n) -> sb.append(String.format(
" Java %-3d %4d %s%s%n", v, n, "▇".repeat((int) Math.min(n, 40)),
v <= targetVersion ? "" : " (requires migrating)")));
sb.append("\nBY TYPE\n");
byType.forEach((type, list) -> {
sb.append(String.format(" %-26s %4d%n", type, list.size()));
list.stream().limit(3).forEach(f -> sb.append(String.format(
" %s:%d %s%n",
f.file().getFileName(), f.line(), f.suggestion())));
if (list.size() > 3) {
sb.append(String.format(" ... and %d more%n", list.size() - 3));
}
});
return sb.toString();
}
public static void main(String[] args) throws IOException {
Path root = Path.of(args.length > 0 ? args[0] : "src");
int target = args.length > 1 ? Integer.parseInt(args[1]) : 21;
ModernisationAnalyser analyser = new ModernisationAnalyser();
List<Finding> findings = analyser.analyse(root);
System.out.println(analyser.report(findings, target));
}
}════════════════════════════════════════════════════
MODERNISATION REPORT -- target: Java 21
════════════════════════════════════════════════════
Total findings: 47
Applicable today: 47
Require migrating: 0
BY MINIMUM VERSION REQUIRED
Java 8 14 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Java 14 9 ▇▇▇▇▇▇▇▇▇
Java 15 11 ▇▇▇▇▇▇▇▇▇▇▇
Java 16 8 ▇▇▇▇▇▇▇▇
Java 21 5 ▇▇▇▇▇
BY TYPE
AccumulatorLoop 5
BiblioTechStatistics.java:19 Consider stream().filter(...).map(...).toList()
BiblioTechMenu.java:88 Consider stream().filter(...).map(...).toList()
... and 3 more
CastAfterInstanceof 8
LegacyTermCalculator.java:14 Use instanceof Book variable
CsvCatalogExporter.java:41 Use instanceof Material variable
... and 6 more
ClassicSwitch 6
BiblioTechMenu.java:52 Use an expression switch with ->
... and 5 more
LegacyDateApi 9
SessionManager.java:34 Use DateTimeFormatter (immutable and thread-safe)
LoanStore.java:57 Use LocalDate / LocalDateTime
OperationLog.java:22 Use Instant or LocalDate as appropriate
... and 6 more
MultilineConcatenation 11
BiblioTechQueries.java:12 Use a text block """..."""
MetadataClient.java:73 Use a text block """..."""
... and 9 more
PlatformPool 5
PooledCatalogServer.java:24 If the tasks block, use Executors.newVirtualThreadPerTaskExecutor()
... and 4 more
RecordCandidate 3
Card.java:8 Class Card could be a record
SessionSummary.java:11 Class SessionSummary could be a record
... and 1 moreComments.
The analyser uses what it analyses. Sealed types for the finding categories, records for each one, an exhaustive switch for the suggestions, text blocks for the report, Files.walk with streams to walk the tree. It is a demonstration by construction.
The limit of regular expressions is real and has to be stated. CastAfterInstanceof produces false positives with legitimate casts and false negatives when the cast is three lines further down. A serious analyser works on the AST, with javax.lang.model (which showed up in 10-02) or with a library such as JavaParser. This exercise is a pragmatic approximation, not a production tool.
The "requires migrating" column is the most useful part of the report. Sorting the findings by minimum version turns the report into a business argument: "migrating to Java 17 unlocks 28 improvements; to Java 21, another 5". That is more convincing than "we ought to upgrade".
And RecordCandidate is deliberately conservative. It only proposes the conversion if there are no setters, no mutable fields and no inheritance, and there are equals and hashCode. Proposing to convert a class with mutable state into a record would produce an incorrect suggestion, and a tool that suggests badly stops being used.
Conclusion
You now know where everything you had been using came from, and quite a bit more.
You know the release calendar: one every six months since 2018, on a fixed date, and an LTS every two or three years — 8, 11, 17, 21, 25 — which are the ones used in production. And you know that the big additions go through preview with --enable-preview before becoming final, and that this is not used in production because the design can change.
You understand the module system (JPMS) and the three problems it attacked: a monolithic 60 MB JDK, the total absence of encapsulation between packages — public meant "for everyone" — and dependencies that went unchecked until the NoClassDefFoundError. You can write a module-info.java with requires (and its transitive and static variants), exports (with the qualified to export) and opens, and you are clear about the distinction that causes half the problems: exporting allows normal use; only opens allows setAccessible on private members. That is why the AnnotatedExporter from 10-03 would need opens if BiblioTech were modularised, and why InaccessibleObjectException exists and --add-opens is the escape hatch — a sticking plaster, not a solution. You know the classpath versus the modulepath, automatic modules as a bridge, and jlink, which shrinks a runtime from 315 MB to 44 MB. And you have the honest assessment: JPMS has not been adopted in enterprise applications, but it has in the JDK itself, in serious libraries and with jlink/jpackage; modularise libraries, not applications.
You reviewed the API additions: immutable collections List.of/Map.of — which reject null, reject duplicates and scramble the order on purpose so that nobody depends on it — private methods in interfaces, strip versus trim, repeat, isBlank, lines returning a Stream, Files.readString/writeString, the sequenced collections of Java 21 that unify getFirst/getLast/reversed, the helpful NullPointerException messages that say exactly what returned null, and UTF-8 by default since Java 18.
And the syntax. var for local inference — which is not dynamic typing, which shines with long generic types and gets in the way when the type cannot be deduced from the line. The expression switch with arrows and yield, with no break, no fall-through between cases and with checked exhaustiveness over an enum. Text blocks with their common-indentation rule — where the position of the closing """ takes part in the calculation — and formatted(), ideal for the SQL and JSON of the coming modules. Records with a compact constructor that validates and normalises. Pattern instanceof, which removes the redundant cast and whose scope is computed by flow analysis, working even after a negation with an early exit.
And above all, the two features that change how you design. Sealed classes with sealed/permits and their three mandatory options for subclasses — final, sealed or non-sealed — which give you what did not exist: exhaustiveness verified by the compiler. And pattern matching for switch in Java 21 with case null, when guards and record patterns that deconstruct the components directly, nesting several levels deep. Combined, they model algebraic types where impossible states cannot be constructed.
BiblioTech proves it: TermCalculator went from 45 lines of if-else with casts and a default that hid the new cases, to 20 lines of exhaustive switch with no default. And when the exercise added a fifth material, the compiler produced four errors in the four exact places that needed updating. The old version would have compiled and failed in production.
And you have virtual threads. You know what they are — threads managed by the JVM on top of a handful of carriers — and why they change the arithmetic: when they block they unmount from the carrier, store their stack on the heap and release the operating-system thread. Hence the figures: creation in microseconds instead of milliseconds, hundreds of bytes instead of a megabyte, millions viable instead of thousands. Ten thousand tasks blocked for a second went from 50 seconds with a pool of 200 to 1.1 seconds; a million virtual threads started in under three.
The CatalogServer from 09-03 has been refactored, and what matters is what did not change: serve() is still the same sequential, blocking code, with try-with-resources, local variables and readable stack traces. What disappeared is the capped pool, the size to tune, the queue, the rejection policy and the 50-client limit. Virtual threads give you simple code back without paying for it in scalability, which is exactly what CompletableFuture cannot offer.
You know the four rules — do not pool them, one per task, a Semaphore to limit external resources, and ReentrantLock instead of synchronized to avoid pinning — and, just as importantly, what they do not solve: they do not speed up computation, they do not remove a single one of the traps in module 8 (which all still apply), they do not fix a database pool of twenty connections, they discourage ThreadLocal, and they do not replace CompletableFuture for asynchronous composition. And you know what is coming: structured concurrency with StructuredTaskScope and ScopedValue as a scoped, immutable replacement for ThreadLocal.
You can use jshell to check something in five seconds, jpackage for native installers, jdeps to analyse dependencies, and you know how to keep up to date — JEPs as the primary source, release notes for incompatible changes — and how to decide on a migration: the most recent LTS your dependencies support, knowing that the jump from 8 to 11 is the hard one (JAXB gone, internal APIs blocked, old tooling) and that from 17 to 21 almost everything works untouched. With the methodological rule that avoids most disasters: separate migrating from modernising.
BiblioTech, at the end of this lesson, has a sealed hierarchy where the compiler verifies that you have covered every material, loan statuses modelled as records where impossible combinations cannot be written, SQL and JSON in readable text blocks, and a server that serves tens of thousands of connections with the same code that served fifty.
And one last black box remains. Everything you have written in this module runs on a JVM whose behaviour you still do not know. When the AnnotatedExporter caches its introspection, where does that cache live and who frees it? When a million virtual threads store their stacks on the heap, what exactly is the heap and what happens when it fills up? When the measurement in section 14 of 10-04 gave different numbers before and after the warm-up, what was the JVM doing in the meantime? When WeakHashMap turned up in passing, what does it mean for a reference to be "weak"? Why is the first request to the server always the slowest? And why does a Java application that has been running for three days suddenly start pausing every few seconds?
In 10-07, Memory, Garbage Collection and Performance, the box is opened wide and the module closes. You will see the memory regions — a stack per thread with its frames and its StackOverflowError, a shared heap, metaspace, the code cache, native memory — and what each different OutOfMemoryError message means. You will see the generational model, the hypothesis that most objects die young, and how the collector decides what to remove through reachability from the roots — not by counting references, so cycles do not matter. You will compare today's collectors — Serial, Parallel, G1, ZGC, Shenandoah, Epsilon — and you will know which three parameters are actually worth touching. You will see that memory leaks in Java do exist, with their four classic patterns demonstrated and how they are diagnosed, and the weak, soft and phantom references with WeakHashMap and its real case in the card cache. You will learn to measure before optimising with jps, jstat, jmap, jcmd, heap dumps and Java Flight Recorder, and why a homemade microbenchmark lies — warm-up, JIT, dead-code elimination — and what JMH is. And you will understand the JIT compiler: interpretation, C1, C2, hot spots, inlining, deoptimisation, and why your code speeds up on its own over time.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
