Modern Java (25) — jegyzet
Records + Sealed + Pattern matching
Ezt a hármat gyakran használjuk együtt.
Record: Java 16 óta final. Tuple-ként kell rá tekinteni. Implicit equals, hashCode, toString. Minden mező automatikusan final. Nincs extends, de implements van (nincs öröklés, de implementálhat interfészt).
Minden rekordnak van egy kanonikus konstruktora — ez veszi át az összes komponenst, ebben a sorrendben, ahogy a fejlécben deklaráltuk. Ha nem írunk külön ilyet, a fordító generál egyet.
public record Money(String currency, BigDecimal amount) {
public Money(String currency, BigDecimal amount) {
this.currency = currency;
this.amount = amount;
}
}
- Compact:
public Money { ... } - Canonical (teljes, explicit):
public Money(String currency, BigDecimal amount) { ... }
Lehetőség van compact konstruktor írására is — ilyenkor az értékadás automatikus (ez a kanonikus rövidített formája), nincs explicit mezőhozzárendelés a végén, ezt a fordító magától elvégzi. Itt a mezőket még nem érjük el this.-szel, mert azok ekkor még nincsenek beállítva — a paramétereket módosítjuk közvetlenül. Ide tehetünk validációt, normalizálást stb.:
public record Money(String currency, BigDecimal amount) {
public Money {
if (amount.signum() < 0) {
throw new IllegalArgumentException("Amount cannot be negative");
}
currency = currency.toUpperCase(); // NORMALIZÁLÁS: a paramétert módosítjuk
// itt NINCS this.currency = currency; this.amount = amount; — ez implicit megtörténik a blokk végén
}
}
Fontos: JPA entitásnak alkalmatlan a no-args konstruktor és a mutálhatatlanság hiánya miatt.
Sealed: Java 17 óta final. permits-szel soroljuk fel a leszármazottakat, azoknak kötelezően final-nak, sealed-nek vagy non-sealed-nek kell lenniük (record esetén ez automatikusan final, class esetén ki kell írni). A leszármazottnak azonos modulban/csomagban kell lennie a sealed típussal — ha egy fájlban vannak, a permits elhagyható. A célja, hogy közöljük a fordítóval a teljes esethalmazt.
public sealed interface LedgerCommand
permits DepositCommand, WithdrawCommand, TransferCommand {}
public record DepositCommand(AccountId account, BigDecimal amount) implements LedgerCommand {
public DepositCommand {
if (amount.signum() <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
}
}
public record WithdrawCommand(AccountId account, BigDecimal amount) implements LedgerCommand {}
public record TransferCommand(AccountId from, AccountId to, BigDecimal amount) implements LedgerCommand {}
Kiegészítés — a harmadik lehetőség, non-sealed: egy sealed leszármazott nem csak final vagy sealed lehet, hanem non-sealed is — ez kinyitja a hierarchiát, onnantól bárki, korlátozás nélkül örökölhet belőle. Ára van: amint van egy non-sealed ág, a fordító már nem tudja garantálni a teljes esethalmazt, tehát a switch csak akkor marad default nélkül teljes, ha az utolsó ág maga a non-sealed interfészre/osztályra illeszkedik (az mindent befog alatta). A legtöbb zárt üzleti domainmodellnél (mint a ledger parancsok) nincs is rá szükség — ha valaki indokolatlanul non-sealed-re nyit egy hierarchiát, az gyakran rossz jel: elveszik a sealed fő előnye.
Ennek nagy előnye a pattern matching. Az instanceof mintával Java 16 óta, a rekord-dekonstrukció Java 21 óta final. Java 22 óta kapott még when guard-ot és unnamed (_) mintát is.
Switch esetén null használatakor NPE dobódik, hacsak nincs explicit case null ág. Ha futásidőben jön egy olyan típus, amire a switch nem számított, MatchException dobódik.
String describe(LedgerCommand cmd) {
return switch (cmd) {
case DepositCommand(var acc, var amt) when amt.compareTo(BigDecimal.valueOf(10_000)) > 0 ->
"Large deposit to " + acc + ": " + amt;
case DepositCommand(var acc, var amt) ->
"Deposit to " + acc + ": " + amt;
case WithdrawCommand(var acc, var amt) ->
"Withdraw from " + acc + ": " + amt;
case TransferCommand(var from, var to, var amt) ->
"Transfer " + amt + " from " + from + " to " + to;
// nincs default — ha holnap jön egy negyedik altípus, ez FORDÍTÁSI hiba, nem futásidejű bug
};
}
Ami ezen nagyon jól látszik: a Rust-hoz hasonlóan már ki tudjuk bontani a mezőket (acc, amt — dekonstrukció), és a when-nel további feltételeket vizsgálhatunk.
A default ágat kerülni kell sealed hierarchiánál, különben a fordító nem jelzi explicit, hogy új típus jelent meg, amit kezelni kellene.
Bekerült továbbá 25-be a module import: ezzel egy egész modult be tudunk importálni az összes csomagjával együtt, egyetlen kifejezéssel:
Ez mindent importál ami alá tartozik, nem kell külön kiírni pl. az ArrayList-et, mert a java.base tartalmazza.
Flexible constructor bodies: már van lehetőségünk kódot futtatni a super()/this() hívás előtt is a konstruktorban — class esetén a super(), nem-kanonikus record-konstruktor esetén a this() hívás előtt. Ez tipikusan validációra jó, amit korábban csak külön statikus factory metódussal lehetett megoldani.
Compact source files + instance main (void main()): már lehetőség van egy .java fájlban class és static deklaráció nélkül elhelyezni egy void main() funkciót — ez jól jön, ha csak ki akarunk próbálni valamit, vagy "scriptet" írunk.
Preview vs final — rövid áttekintés, amit érdemes fejben tartani: - final: record, sealed, pattern matching (instanceof, switch, dekonstrukció, when, _), module import, flexible constructor bodies, compact source files, virtual threads, Scoped Values (JEP 506, Java 25 óta final), compact object headers (JEP 519, de alapból kikapcsolva). - preview: Structured Concurrency (StructuredTaskScope, 5. preview), primitive patterns, Stable Values / lazy constants. - még sehol nincs kiadva: Value Classes and Objects (JEP 401, Project Valhalla) — csak early-access build létezik, hivatalosan JDK 28-ra (2027) célozzák, preview-ként.
Virtual thread pinning: synchronized vs ReentrantLock
A probléma (Java 24 előtt): ha egy virtual thread synchronized blokkon belül csinál blokkoló hívást (pl. lassú REST hívás), a JVM nem tudja leválasztani a carrier threadről — a carrier "ragad (pinning)" a teljes blokkoló hívás idejére, mintha egy sima platform thread futtatná.
Nagyon egyszerű példa: képzelj el 2 carrier threadet (kb. 2 CPU mag), és 5 virtual threadet, mindegyik más-más lock objektumon fut (tehát nem is várnak egymásra):
Ha éppen 2 virtual thread van ebben a blokkban (2 különböző lockon), akkor mindkét carrier lefoglalva (pinned) marad az 1 másodperc alatt — a maradék 3 virtual thread emiatt egyáltalán nem tud futni, pedig semmi közük ezekhez a lockokhoz. A pinnelés tehát nem a lock miatt szerializál, hanem mert közben egy közös, szűkös erőforrást (carrier) feleslegesen leköt.
ReentrantLock-kal (Java 24 előtt ez volt a szokásos megoldás):
Itt a blokkoló hívás alatt a carrier azonnal felszabadul (a virtual thread unmountol), így a másik 3 virtual thread simán futhat eközben. Amikor a REST hívás visszatér, a virtual thread visszakerül egy (nem feltétlenül ugyanazon) szabad carrierre.
Java 24 óta (JEP 491): a synchronized ugyanúgy viselkedik, mint a ReentrantLock — a carrier felszabadul blokkoló hívás közben is, tehát a fenti probléma megszűnt, nyugodtan lehet synchronized-ot használni virtual threaden.
Ami megmaradt korlátozásként: natív hívás (JNI, java.lang.foreign) alatt ma is pinnel — ezt a JEP 491 nem oldotta meg, mert ott a JVM strukturálisan nem tud beavatkozni a mountolás/unmountolás folyamatába.
Diagnosztika: -Djdk.tracePinnedThreads=full induláskor, vagy a jdk.VirtualThreadPinned JFR esemény.
ScopedValue vs ThreadLocal
Megjelent a ScopedValue, amire már nagy szükség volt. Java 25 óta final (JEP 506), tehát nem kell hozzá --enable-preview.
Mi a baj a régi ThreadLocal-lal?
- Nem immutable — a hívási láncban bárki megváltoztathatja az értékét
set()-tel, ami nehezen nyomon követhetővé teszi, honnan jött a módosítás, és ez hibalehetőségeket rejt magában. - Memory leak veszélye — ha a
.remove()elmarad, az érték továbbra is benne marad, ami memory leakhez vezethet.
ScopedValue: ezzel szemben immutable — az értékét nem lehet megváltoztatni egy adott scope-on belül —, és a run() lezárása után automatikusan felszabadul, így a memory leak eleve nem állhat fenn.
Egyszerű példa a kettő használatára:
package com.mainargs;
import java.util.NoSuchElementException;
public class Main {
private static final ThreadLocal<String> TL_ID = new ThreadLocal<>();
private static final ScopedValue<String> SV_ID = ScopedValue.newInstance();
public static void main(String[] args) {
System.out.println("=== ThreadLocal ===");
TL_ID.set("req-1");
System.out.println("value = " + TL_ID.get());
mutateThreadLocal(); // bárhol, bárki csendben felülírhatja
System.out.println("value after mutation = " + TL_ID.get());
TL_ID.remove(); // ha ezt elfelejted, marad az érték -- ez a "leak" gyökere
System.out.println();
System.out.println("=== ScopedValue ===");
ScopedValue.where(SV_ID, "req-2").run(() -> {
System.out.println("value = " + SV_ID.get());
printFromDeepMethod(); // mélyen beágyazott hívás is látja, paraméter nélkül
});
// a run() lezárása UTÁN nincs kötés -- ezt nem lehet elfelejteni felszabadítani
try {
SV_ID.get();
} catch (NoSuchElementException e) {
System.out.println("SV.get() outside scope -> " + e.getClass().getSimpleName());
}
}
private static void mutateThreadLocal() {
TL_ID.set("SNEAKILY-CHANGED");
}
private static void printFromDeepMethod() {
// nincs átadva paraméterként, mégis eléri -- ez a ThreadLocal fő "jó" tulajdonsága is,
// csak itt immutable a scope alatt
System.out.println("deep method sees value = " + SV_ID.get());
}
}
Fontos árnyalat, amit érdemes tudni: a ThreadLocal nem törik el virtual threaden — az érték a virtual thread objektumhoz van kötve, nem a carrierhez, tehát carrier-váltáskor is megmarad. A tényleges probléma nem a korrektség, hanem a memória-overhead skálán (milliós virtual thread esetén minden per-thread objektum per-kérés objektummá válik), és hogy ne pool-ozz drága erőforrást ThreadLocal-lal virtual threadnél, mert ott nincs újrahasználat.
Compact object headers
Új funkció a JVM-en belül (JEP 519, Java 25 óta final, nem experimentális), ami akár 20-30%-os heap-csökkenést jelenthet sok kis objektum esetén (a JEP maga 10-20%-ot ír, Amazon 22%-ot mért), a fejlécet 12 byte-ról 8 byte-ra tömörítve.
Bekapcsolása: -XX:+UseCompactObjectHeaders. Alapból ki van kapcsolva — kompatibilitási okokból, arra az esetre, ha valami (pl. natív, JVMTI-alapú profilozó eszköz, vagy Unsafe-fel offsetet számoló kód) a régi fejléc-méretet feltételezi. A JEP hangsúlyozza, hogy a funkció önmagában alaposan tesztelt és stabil (pl. Amazon éles környezetben validálta) — a kockázat tehát nem a JVM korrektségében van, hanem a saját eszközparkodban lévő, header-feltételezésre épülő komponensekben.
Az OpenJDK mainline build is alapból kikapcsolva szállítja ezt — ez nem Oracle-specifikus döntés. Van azonban ellenpélda: a SAP a saját disztribúciójában (SapMachine) alapból bekapcsolva adja, ami mutatja, hogy ez disztribúció-szintű választás kérdése, nem kőbe vésett JVM-korlátozás.