Kihagyás

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:

import module java.base;

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):

synchronized (myLock) {
    callSlowRestApi(); // 1 másodpercig tart
}

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):

lock.lock();
try {
    callSlowRestApi();
} finally {
    lock.unlock();
}

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?

  1. 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.
  2. 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.