- name
- java
- description
- Use when writing, reviewing or refactoring modern Java (21+, 25 LTS) — records and sealed interfaces as algebraic data types, exhaustive pattern-matching switch over instanceof ladders, virtual-thread concurrency, structured concurrency and ScopedValue replacing ThreadLocal, Stream and Optional pipelines, compact-constructor validation, parametrized JDBC, and the Maven/Gradle build surface. NOT Spring framework wiring (that is `spring-boot`).
- tags
- ["java","jvm","records","virtual-threads","sealed-types"]
- recommends
- ["spring-boot","secure-coding","deployment"]
- origin
- risco
# Modern Java (21+)
Write, review, and refactor Java the way the language is shaped today, not the way it
was in 2014.
Targets **Java 21+ with Java 25 as the current LTS** (released 2025-09-16, supported to
2033; Java 26 is the current non-LTS, 2026-03-17). "Modern" here means: records and sealed
interfaces instead of JavaBeans and type hierarchies, exhaustive pattern-matching `switch`
instead of `instanceof`+cast ladders, virtual threads instead of hand-tuned pools, and
`ScopedValue` instead of `ThreadLocal`. If you are reaching for a telescoping constructor,
a getter/setter bean, or a manual `ExecutorService` sized to a core count, stop.
## When to use / When NOT to use
**Use when:**
- Authoring, reviewing, or refactoring any `.java` file.
- Modeling a domain: record vs class, sealed hierarchy vs enum, where immutability belongs.
- Replacing `instanceof`+cast chains or visitor boilerplate with a pattern-matching `switch`.
- Concurrency that blocks on I/O at scale: migrating thread pools / reactive chains to
virtual threads, fan-out with structured concurrency, `ThreadLocal` -> `ScopedValue`.
- Stream / Optional / Collector pipelines, and deciding when a plain loop beats a stream.
- Setting up or fixing a `pom.xml` / `build.gradle(.kts)` for a current JDK (toolchain,
`--release`, preview flags, JUnit 5).
**When NOT to use (delegate):**
- **Spring** controllers, beans, starters, autoconfiguration, `@Transactional` -> `spring-boot`.
This skill is the plain-JDK layer underneath; framework wiring is out of scope by topic.
- **Kotlin** on the JVM (Android or server) -> `kotlin-android`. Java and Kotlin interop but
are different languages; coroutines and Kotlin null-safety belong there.
- **Language-agnostic threat modeling / OWASP / authz** -> `secure-coding`. This skill keeps
only Java-specific controls (parametrized JDBC, compact-constructor validation, sane deser).
- **Containerfile / CI pipeline / shipping** -> `deployment`. This skill keeps only a
jlink/jpackage note and the JDK base-image choice.
- **PostgreSQL schema / index / query tuning** -> `postgresdb`. This skill covers JDBC
parametrization from the Java side only.
- Recording per-project conventions in a workspace wiki -> `harness` (see "Project grounding").
Records/sealed/pattern-matching design, virtual-thread concurrency, and the build surface
live **here** — there is no separate "java-concurrency" or "java-build" skill.
## Decision rules
Apply these on every Java edit:
1. **Record by default for data.** If a type just carries values, make it a `record`; it
gets `equals`/`hashCode`/`toString` and is immutable for free. Class only when you need
mutable identity or inheritance.
2. **Sealed + exhaustive `switch` over `instanceof` ladders.** A closed set of cases is a
`sealed interface`; the compiler then proves your `switch` is total. An `instanceof`+cast
ladder is an unchecked manual version of this.
3. **Immutable and `final` by default.** Fields `final`, collections wrapped/copied; mutate
only where you have measured a reason. Shared mutable state is the bug you debug at 3am.
4. **`Optional` is a return type, never a field or parameter.** It signals "maybe absent" to a
caller; an `Optional` field just adds a null-of-null. For absent collections, return empty.
5. **Virtual threads for blocking I/O, platform threads for CPU work.** Loom wins when threads
spend their life parked on sockets/JDBC; it does nothing for a tight CPU loop.
6. **One virtual thread per task. Never pool them.** They are cheap (millions are fine);
pooling them re-introduces the limit they exist to remove.
7. **Validate in the compact constructor.** A `record`'s invariants belong in its compact
constructor so an invalid instance cannot exist — not in a separate `validate()` you can forget.
8. **Parametrize every JDBC query.** `PreparedStatement` with `?`, bind values; never
concatenate user input into SQL.
9. **Stream for transforms, loop for side-effects.** A `map`/`filter`/`collect` pipeline is
clear; a `forEach` that mutates external state is a loop wearing a costume — write the loop.
10. **Treat compiler warnings as failures.** Build with `-Xlint:all -Werror`; an ignored
`unchecked` or `deprecation` warning is a deferred bug.
## Data modeling
A `record` is the default data carrier — immutable, no setters, validated in its compact
constructor.
```java
// Good: invalid Money cannot exist; value semantics for free.
public record Money(long cents, Currency currency) {
public Money { // compact constructor
if (cents < 0) throw new IllegalArgumentException("cents < 0: " + cents);
Objects.requireNonNull(currency, "currency");
}
public Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
return new Money(cents + other.cents, currency);
}
}
```
```java
// Bad: telescoping constructors, mutable identity, hand-written equals you will get wrong.
public final class Money {
private long cents; private Currency currency;
public Money() {}
public Money(long cents) { this.cents = cents; }
public Money(long cents, Currency currency) { this.cents = cents; this.currency = currency; }
public void setCents(long c) { this.cents = c; } // now any caller can break invariants
public long getCents() { return cents; }
// ... 40 lines of getters/setters/equals/hashCode ...
}
```
A **sealed interface + record cases is an algebraic data type** — a closed set the compiler
knows about:
```java
public sealed interface PaymentResult permits Captured, Declined, Pending {}
public record Captured(String id, Money amount) implements PaymentResult {}
public record Declined(String reason, int code) implements PaymentResult {}
public record Pending(String id, Instant retryAfter) implements PaymentResult {}
```
**Enum vs sealed:** use an `enum` when cases are a fixed set of *constants with no per-case
data* (`Status.ACTIVE`); use a sealed interface when each case *carries different fields*
(above). Reaching for an enum plus a parallel "payload" map means you wanted a sealed type.
## Pattern matching
`instanceof` binds the pattern variable directly — no cast:
```java
if (event instanceof Captured c) { // Good: c is in scope, already typed
ledger.record(c.id(), c.amount());
}
```
An exhaustive `switch` over a sealed type with **record deconstruction** and `when` guards
replaces the whole ladder — and **omit `default` when the sealed set is covered** so adding a
new case becomes a compile error you must handle:
```java
// Good: total over the sealed type; deconstruction patterns; guard; null branch.
String describe(PaymentResult r) {
return switch (r) {
case Captured(var id, var amt) when amt.cents() == 0 -> "free capture " + id;
case Captured(var id, var amt) -> "captured " + amt.cents() + "c " + id;
case Declined(var reason, var code) -> "declined(" + code + "): " + reason;
case Pending(var id, var __) -> "pending " + id;
case null -> "no result";
// no default: compiler proves exhaustiveness; a new permits-case won't compile until handled
};
}
```
```java
// Bad: instanceof + cast ladder. No exhaustiveness check; a new case silently falls through.
String describe(PaymentResult r) {
if (r instanceof Captured) { Captured c = (Captured) r; return "captured " + c.amount(); }
else if (r instanceof Declined) { Declined d = (Declined) r; return "declined " + d.reason(); }
else return "?"; // silently wrong when Pending is added
}
```
Java 25 adds **primitive type patterns** in `instanceof` and `switch` (e.g. `case int i`),
extending exhaustiveness to primitives.
## Concurrency: virtual threads
For blocking I/O, run **one virtual thread per task** inside a per-task executor closed with
try-with-resources (the close call blocks until every submitted task finishes):
```java
// Good: 500 blocking fetches, one virtual thread each. No pool sizing, no reactive ceremony.
List<String> fetchAll(List<URI> urls, HttpClient http) throws InterruptedException {
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = urls.stream()
.map(u -> exec.submit(() -> http.send(
HttpRequest.newBuilder(u).build(), BodyHandlers.ofString()).body()))
.toList();
return futures.stream().map(f -> {
try { return f.get(); } catch (Exception e) { throw new CompletionException(e); }
}).toList();
} // close() joins all tasks
}
```
```java
// Bad: a fixed platform-thread pool caps you at N concurrent blocking calls and starves.
var exec = Executors.newFixedThreadPool(16); // 484 of 500 fetches wait on a thread
```
**JEP 491 (JDK 24) removed `synchronized` pinning** — a virtual thread inside a `synchronized`
block no longer pins its carrier, so legacy `synchronized` code is safe on Loom now. For *long*
holds prefer `ReentrantLock` anyway (fairness, tryLock, interruptibility).
**Structured concurrency** (`StructuredTaskScope`) ties subtasks to a scope so one failure
cancels the siblings — but it is **still preview in Java 25 (JEP 505, 5th preview)**, so it
needs `--enable-preview`. **`ScopedValue` is FINAL in Java 25 (JEP 506)** and is the immutable
replacement for `ThreadLocal` when sharing context across virtual threads:
```java
// Good: immutable, explicitly scoped, propagates into child tasks; nothing to clean up.
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
ScopedValue.where(REQUEST_ID, id).run(() -> handle(req)); // REQUEST_ID.get() inside
```
A `ThreadLocal` set on the caller does **not** appear in the tasks you submit to an executor,
and on millions of virtual threads its mutable per-thread copies leak memory — that symptom is
exactly what `ScopedValue` fixes. Deep dive (scopes, joiners, pinning diagnosis, when platform
threads still win) -> `references/concurrency.md`.
## Streams, Optional, Collectors (essentials)
Use `stream().toList()` (Java 16+, immutable) for the common case; `Collectors.groupingBy`
and `teeing` cover most aggregation:
```java
Map<Currency, Long> totalByCcy = payments.stream()
.collect(Collectors.groupingBy(Payment::currency,
Collectors.summingLong(p -> p.amount().cents())));
```
`Optional` discipline: chain `map`/`filter`/`orElseGet`/`orElseThrow`; never `.get()` without a
prior `isPresent`, never an `Optional` field. Do **not** stream when a loop is clearer — a
`forEach` with side-effects, an early `break`/`return`, or index-coupled logic is a loop.
Full Collector catalog, custom collectors, `mapMulti`, Gatherers (Java 24+), and parallel-stream
caveats -> `references/streams.md`.
## Builds
Pin the language level with the compiler plugin's `<release>`, run JUnit 5 via Surefire.
**Maven 4 is not yet GA (4.0.0-rc-5, Nov 2025) — use Maven 3.9.x for production**:
```xml
<properties><maven.compiler.release>25</maven.compiler.release></properties>
<build><plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration><release>25</release>
<compilerArgs><arg>-Xlint:all</arg><arg>-Werror</arg></compilerArgs></configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
</plugin>
</plugins></build>
```
Gradle 9.x (current 9.5.1, requires Java 17+ to run, prefers the configuration cache) uses a
**toolchain block** so the build picks the right JDK regardless of the runner's default:
```kotlin
plugins { java }
java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }
tasks.test { useJUnitPlatform() }
```
Full `pom.xml` / `build.gradle.kts` skeletons, multi-module, preview-flag wiring, JUnit 5 +
AssertJ + Mockito, jlink/jpackage -> `references/builds.md`.
## Errors & null
- **Never return `null`.** Return `Optional<T>` for a maybe-value, an empty collection for "none".
- **try-with-resources** for anything `AutoCloseable`; do not write a `finally { close() }` by hand.
- **Chain exceptions** — `throw new ServiceException("loading user " + id, cause)` — so the
stack trace keeps the root. Swallowing (`catch (Exception e) {}`) destroys the evidence.
- **Checked vs unchecked:** checked (`extends Exception`) for recoverable, expected conditions
the caller must handle; unchecked (`extends RuntimeException`) for programming bugs / invariant
violations. Do not wrap everything in a checked exception "to be safe" — it just gets rethrown.
## Anti-patterns / rationalizations -> STOP
| Rationalization | Reality / Do instead |
| --- | --- |
| "I'll add setters to the record for convenience" | Then it isn't immutable. Build a new instance; validate in the compact constructor. |
| "instanceof+cast is fine, it's only three cases" | No exhaustiveness; case four silently falls through. Sealed + `switch`. |
| "pool the virtual threads to be safe" | Pooling re-adds the limit Loom removes. One virtual thread per task. |
| "ThreadLocal carries my request context" | It doesn't cross submit() and leaks on millions of vthreads. `ScopedValue`. |
| "Optional field so callers handle absence" | Optional is a return type. A field is null-of-null; use the value or a default. |
| "raw `List` is shorter than `List<Payment>`" | Raw types disable generics checks. Always parametrize. |
| "catch and ignore, it can't happen here" | Then assert it; a swallowed exception erases the bug. Log + rethrow or handle. |
| "fmt the id into the SQL string, it's trusted" | Trust nothing at the boundary. `PreparedStatement` with `?`. |
| "wrap it in a checked exception to force handling" | Most callers just rethrow. Unchecked for bugs; checked only when recovery is real. |
| "`new Thread(r).start()` per request" | On platform threads that's a few thousand max. Virtual-thread-per-task executor. |
| "default branch keeps the switch safe" | A default hides the new case. Omit it on a sealed type so it won't compile. |
| "warnings are just noise" | `unchecked`/`deprecation` are deferred bugs. `-Xlint:all -Werror`. |
## Quick reference
| Task | Idiom / command |
| --- | --- |
| Build + test (Maven) | `./mvnw -q verify` (or `mvn -q verify`) |
| Build + test (Gradle) | `./gradlew check` |
| Data carrier | `record Money(long cents, Currency ccy) { Money { ... } }` |
| Closed case set | `sealed interface R permits A, B {}` + `record A(...) implements R {}` |
| Exhaustive match | `switch (r) { case A(var x) -> ...; case B b -> ...; }` (no `default`) |
在 GitHub 查看