Középhaladó Olvasási idő: ~7 perc

Thread alapok

Thread vs Runnable, életciklus és daemon szálak

Thread alapok

Definíció

A Java thread egy önálló végrehajtási útvonal, amelyet a JVM kezel, és a legtöbb esetben egy operációs rendszer szálra képez le. A Thread objektum csak a Java oldali fogantyú; a tényleges viselkedést az ütemező, a Java Memory Model, a blokkoló rendszerhívások, a safepointok és a megosztott memória állapota határozza meg. Éles rendszerekben ezért a thread alapok nem merülnek ki a start() hívásban: érteni kell az életciklust, a megszakítást, a hibakezelést, a daemon viselkedést és azt is, mennyibe kerül egy feladatot platform szálhoz kötni.

A concurrency arról szól, hogy a program több egysége egymástól függetlenül tudjon haladni. A parallelism pedig arról, hogy ezek fizikailag egyszerre fussanak több magon. Egy szolgáltatás lehet erősen konkurens akkor is, ha kevéssé párhuzamos, például mert a szálak nagy része I/O-ra, lockokra vagy backpressure-re vár. Senior szinten a thread primitívek használata mindig kapacitás-, diagnosztikai és leállítási kérdés is.

Alapfogalmak

Az első fontos különbség a Thread és a Runnable között van. A Thread örökítése összeköti a feladatot és a futtatási mechanizmust, míg a Runnable szétválasztja a „mit kell csinálni” és a „hogyan fusson” kérdését. Emiatt a valós rendszerekben jellemzően Runnable vagy Callable feladatokat adunk executornak, nem pedig Thread-et örökítünk. A Callable akkor fontos, ha eredményt vagy kivételt is vissza kell adni a háttérmunkából.

A Java jól definiált thread életciklust mutat: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED. A RUNNABLE kicsit félrevezető: HotSpot alatt ez azt jelenti, hogy a szál futásra kész, nem feltétlenül azt, hogy éppen CPU-n fut. Lehet, hogy az OS ütemezőre vagy natív I/O-ra vár. A BLOCKED többnyire azt jelzi, hogy a szál egy synchronized monitorra vár. A WAITING és TIMED_WAITING tipikusan Object.wait(), Thread.sleep(), LockSupport.park() vagy Thread.join() miatt jelenik meg.

A daemon thread olyan háttérszál, amely nem tartja életben a JVM-et. Ha már csak daemon szálak maradnak, a JVM bármikor kiléphet, még akkor is, ha ezek éppen logot írnak, metrikát flush-ölnek vagy állapotot frissítenek. Ezért daemon szál háttérsegédekhez jó, de olyan feladathoz veszélyes, amelynek garantáltan be kell fejeződnie. A shutdown hook sem helyettesíti a korrekt lifecycle-kezelést; az utolsó mentőöv, nem pedig üzleti workflow.

Az interrupt a Java együttműködő megszakítási mechanizmusa. Az interrupt() nem állítja le erőszakkal a szálat. Beállít egy interrupt flaget, és bizonyos blokkoló hívások InterruptedException-t dobnak. A jó kód vagy továbbadja ezt, vagy visszaállítja az interrupt státuszt Thread.currentThread().interrupt() hívással. Az interrupt elnyelése miatt a poolok nem állnak le rendesen, a cancellation pedig megbízhatatlanná válik.

Gyakorlati használat

Nyers threadet akkor érdemes használni, ha alapfogalmat tanítasz, alacsony szintű API-val integrálsz, vagy egy kis, jól körülhatárolt háttérmunkásnak egyértelmű tulajdonosa van. Még ekkor is adj beszédes nevet a szálnak, állíts be UncaughtExceptionHandler-t, és tedd egyértelművé a leállítási viselkedést. A név nélküli thread később thread dumpban és profilerben diagnosztikai adóvá válik.

Úgy tervezd a feladatokat, hogy együttműködően meg tudjanak állni. Hosszú ciklusokban rendszeresen ellenőrizd a Thread.currentThread().isInterrupted() értékét. Blokkoló kódban használj interruptbarát API-kat. Ha cleanup szükséges, kapd el az InterruptedException-t, állítsd vissza a flaget, zárd le az erőforrásokat, majd gyorsan térj vissza. Az „ignoráld és menj tovább” ritkán helyes stratégia.

A ThreadLocal külön odafigyelést igényel. Hasznos lehet request correlationre vagy threadenkénti cache-re, de poolozott szálaknál az érték túléli a logikai kérést, ha nem törlöd explicit módon. Ez classloader leakhez, nehezen reprodukálható tesztszennyezéshez és memória-visszatartáshoz vezethet. Úgy kezeld, mint manuálisan menedzselt erőforrást: szűk scope-ban állítsd be, és finally blokkban töröld.

Kód példák

Thread worker = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        try {
            System.out.println("processing batch");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    System.out.println("worker stopped cleanly");
}, "inventory-sync-1");

worker.setUncaughtExceptionHandler((t, ex) ->
    System.err.println(t.getName() + " failed: " + ex.getMessage()));
worker.start();
// később
worker.interrupt();
worker.join();

A lényeg nem a lambda rövidsége, hanem az interrupt policy: a ciklus figyeli a flaget, a sleep megszakítása visszaáll a flagbe, a join() pedig megvárja a rendezett leállást.

class HeartbeatService {
    private final AtomicBoolean running = new AtomicBoolean();
    private Thread thread;

    void start() {
        if (!running.compareAndSet(false, true)) {
            return;
        }
        thread = new Thread(this::runLoop, "heartbeat-daemon");
        thread.setDaemon(true);
        thread.start();
    }

    private void runLoop() {
        while (running.get() && !Thread.currentThread().isInterrupted()) {
            try {
                sendHeartbeat();
                Thread.sleep(1_000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    }

    void stop() throws InterruptedException {
        running.set(false);
        if (thread != null) {
            thread.interrupt();
            thread.join(2_000);
        }
    }

    private void sendHeartbeat() {}
}

Ez a példa daemon segédszálat mutat explicit leállítási logikával. A daemon flag segít, hogy ne tartsa életben a JVM-et, de a szolgáltatás ettől még ad stop() metódust, mert a graceful shutdownot nem szabad kizárólag a daemon szemantikára bízni.

Trade-offok

A nyers threadek nagy kontrollt és tiszta mentális modellt adnak, de rosszul skálázódnak általános munkavégzési egységként. Minden platform thread stack memóriát fogyaszt, növeli az ütemezési költséget és context switch overheaddel jár. A „thread per request” megközelítés kis terhelésnél működhet, de csúcsterhelésnél inkább memória- és latency-problémákhoz vezet, nem lineáris lassuláshoz.

A daemon thread csökkenti a leállítási súrlódást best-effort feladatoknál, viszont növeli a csendes adatvesztés kockázatát, ha kritikus állapotot kezel. Az interrupt könnyű és jól kombinálható megszakítási jel, de csak akkor, ha a saját és a használt kód tiszteletben tartja. A ThreadLocal kényelmes, de rejtett kapcsolatot teremt a logikai kérés és a fizikai szál között.

A gyakorlati szabály: a nyers thread legyen tulajdonosi boundary, ne általános task-dispatch mechanizmus. Amint admission control, pooling, eredménykezelés vagy strukturált shutdown kell, lépj tovább a concurrency API-k felé.

Gyakori hibák

A klasszikus hiba a run() meghívása start() helyett: ekkor a kód szinkron módon, a hívó szálon fut, és nem jön létre konkurencia. Ugyanilyen gyakori félreértés, hogy a sleep() elengedné a lockot; nem engedi. Ha egy thread synchronized blokkban alszik, továbbra is tartja a monitort és blokkolhat másokat.

Az interrupt kezelés az a terület, ahol sok egyébként jó kódbázis elbukik. Ha elkapod az InterruptedException-t és csak logolsz, akkor törlöd az interrupt státuszt, és a pool shutdown könnyen beragad. Hasonlóan veszélyes a Thread.interrupted() használata, ha valójában isInterrupted() kellett volna, mert a statikus metódus ki is üríti a flaget.

Tipikus production anti-pattern az is, amikor valaki daemonra állít egy threadet csak azért, mert a JVM „nem akar kilépni”. Ilyenkor a valódi hiba szinte mindig a menedzseletlen életciklus. A daemon flag elfedi a tünetet, de gyengíti a completion garanciát. Kerüld a kontrollálatlan thread létrehozást ciklusokban is; terhelési csúcsnál a rendszer több időt tölt ütemezéssel, mint hasznos munkával.

Senior szintű meglátások

JVM szinten a thread állapot nemcsak a Java API-ban látszik, hanem diagnosztikai eszközökben is, például jstack, Java Flight Recorder vagy async profiler kimenetében. Senior szinten fontos megtanulni a tüneteket összekötni a dumpokkal: sok BLOCKED thread monitor contentionre utal, sok TIMED_WAITING backoffra vagy sleep-alapú pollingra, sok magas CPU-t fogyasztó RUNNABLE thread pedig spin loopra vagy natív hívásra.

A thread életciklus összefügg a safepointokkal és a stop-the-world eseményekkel is. Egy thread kódszinten ártalmatlannak tűnhet, mégis ronthatja a GC vagy a safepoint belépés viselkedését, ha problémás natív kódot futtat vagy nagy, szoros ciklusban dolgozik. Ha megérted, hogy minden Java thread a teljes VM koordinációs rendszer része, akkor jobban érthetővé válik, miért nem „ingyenes” még egy szál hozzáadása.

A modern Java virtuális szálakat is ad, de ettől az alapelvek nem tűnnek el. Az interrupt, az együttműködő leállítás, a thread naming, az uncaught exception policy és a safe publication továbbra is fontos. A különbség az, hogy sok workloadnál olcsóbbá válik a blokkolás, miközben pinning problémák továbbra is előfordulhatnak synchronized vagy natív műveletek körül. Aki érti a platform thread alapjait, gyorsabban és biztonságosabban tud Loomra váltani.

Szószedet

  • Thread: JVM által kezelt végrehajtási útvonal saját veremmel.
  • Runnable: Visszatérési érték nélküli feladatszerződés.
  • Callable: Eredményt adó vagy kivételt dobó feladatszerződés.
  • Daemon thread: Háttérszál, amely nem tartja életben a JVM-et.
  • Interrupt: Együttműködő megszakítási jel thread állapot formájában.
  • ThreadLocal: Threadenkénti tároló, amelyet poolozott környezetben explicit takarítani kell.
  • Thread dump: Diagnosztikai pillanatkép a szálakról és stackjeikről.
  • Safepoint: JVM koordinációs pont GC-hez és egyéb VM műveletekhez.

Gyorsreferencia

  • Inkább Runnable/Callable, mint Thread öröklés.
  • Konkurens futtatáshoz start() kell, nem run().
  • Adj üzleti szerep alapú threadnevet: order-writer-1.
  • Hosszú életű szálaknál kezeld az uncaught kivételeket.
  • Az interruptot kezeld cancellation kérésként; add tovább vagy állítsd vissza.
  • A sleep() megállítja a végrehajtást, de nem engedi el a lockot.
  • A join() megvárja egy másik thread befejezését, és megszakítható.
  • Daemon threadet csak best-effort háttérmunkára használj.
  • ThreadLocal értéket finally blokkban töröld, ha a thread újrahasznosul.
  • Általános task dispatchre inkább executorokat használj.

🎮 Játékok

10 kérdés