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

Synchronization

Synchronized, zárak, ReentrantLock és ReadWriteLock

Synchronization

Definíció

A synchronization azoknak a technikáknak az összessége, amelyek biztonságossá teszik a megosztott, módosítható állapot használatát több szál között. Java-ban ez nem csak annyit jelent, hogy „két thread ne lépjen be egyszerre ugyanabba a kódba”, hanem azt is, hogy jól definiált láthatósági garanciák jöjjenek létre, több mezőből álló invariánsok együtt maradjanak érvényesek, és a Java Memory Model szerinti happens-before kapcsolatok fennálljanak. A helyesen szinkronizált programban az olvasó thread olyan állapotot lát, amely egy érvényes végrehajtási sorrendből származhatott volna, nem pedig félig kész írások véletlen keverékét.

JVM szinten a synchronization monitor műveleteken, memóriabarriereken, objektum fejlécekben tárolt lock metaadaton és magasabb szintű zár implementációkon keresztül valósul meg. A helyes használat ezért egyszerre nyelvi, könyvtári és teljesítményérzékeny kérdés.

Alapfogalmak

A synchronized kulcsszó intrinsic lockot ad. Egy synchronized blokkba való belépés monitor megszerzését, kilépése monitor elengedését jelenti, akkor is, ha a blokk kivétellel hagyódik el. Ez kölcsönös kizárást és memóriahatást is biztosít: egy sikeres monitor release happens-before kapcsolatban áll ugyanazon monitor későbbi sikeres acquire műveletével. Emiatt a synchronized egyszerre oldja meg az atomicity és a visibility problémát az adott állapotnál.

Az intrinsic lock reentrant, vagyis ugyanaz a thread többször is beléphet ugyanabba a monitorba anélkül, hogy önmagát deadlockolná. Minden objektum lehet monitor, de publikus objektumokra, this-re, boxolt értékekre vagy string literálokra lockolni veszélyes, mert külső kód véletlenül ugyanazt a monitort használhatja. Production kódban gyakori a private final lock objektum.

A wait(), notify() és notifyAll() a monitor wait setjén dolgozik. Meghívásukhoz a threadnek birtokolnia kell a monitort. A wait() atomi módon elengedi a monitort és felfüggeszti a szálat értesítésig, interruptig, timeoutig vagy spurious wakeupig. Mivel a spurious wakeup megengedett, a helyes minta mindig az, hogy ciklusban várunk, amíg a feltétel hamis. A notify() egy várakozót ébreszt, a notifyAll() az összeset; utóbbi több feltétel esetén általában biztonságosabb.

Az explicit lockok, például a ReentrantLock, ugyanezt a magötletet adják több kontrollal. Elérhető időkorlátos lockolás, interruptálható lockolás, opcionális fairness és több Condition sor. A ReadWriteLock több párhuzamos olvasót enged, miközben az író kizárólagos hozzáférést kap. Ez csak akkor segít, ha az olvasás gyakori, elég hosszú ahhoz, hogy megtérüljön a koordinációs költség, és tényleg van contention. Írás-intenzív vagy nagyon rövid kritikus szakaszoknál könnyen lassabb lehet egy sima mutexnél.

Gyakorlati használat

A synchronized jó választás kis, lokális invariánsokra, ahol az egyszerű mutual exclusion elég. Ideális egy kisebb objektumgráf védelmére, lazy initializationhöz vagy wait/notifyAll alapú condition queue-hoz. Nagy előnye az olvashatóság: a lock lexikálisan látszik, a kilépés automatikus, code review-ban könnyű követni.

A ReentrantLock akkor jó, ha olyan képesség kell, amit az intrinsic lock nem ad. Például tryLock() deadlock-kerüléshez, lockInterruptibly() cancellation-aware várakozáshoz, vagy több Condition, hogy különböző várakozók ne ébresszék egymást feleslegesen. Az ár a fegyelem: ha elfelejted az unlock() hívást finally blokkban, az correctness bug.

A ReadWriteLock használatát mindig mérés előzze meg. Sok csapat azért választja, mert a neve skálázhatónak hangzik, de a valós nyereség az olvasás/írás aránytól, a kritikus szakasz hosszától, a cache viselkedéstől és az írók starvation kockázatától függ. Gyakran egy immutable snapshot vagy ConcurrentHashMap egyszerre egyszerűbb és gyorsabb.

Kód példák

class Counter {
    private final Object lock = new Object();
    private int value;

    int incrementAndGet() {
        synchronized (lock) {
            value++;
            return value;
        }
    }

    int get() {
        synchronized (lock) {
            return value;
        }
    }
}

Itt ugyanaz a monitor védi az írást és az olvasást is. Ez fontos, mert a nem szinkronizált olvasás akkor is láthat elavult értéket, ha minden írás szinkronizált.

class BoundedBuffer<T> {
    private final Queue<T> queue = new ArrayDeque<>();
    private final int capacity;

    BoundedBuffer(int capacity) {
        this.capacity = capacity;
    }

    public synchronized void put(T item) throws InterruptedException {
        while (queue.size() == capacity) {
            wait();
        }
        queue.add(item);
        notifyAll();
    }

    public synchronized T take() throws InterruptedException {
        while (queue.isEmpty()) {
            wait();
        }
        T item = queue.remove();
        notifyAll();
        return item;
    }
}

A kulcs a guardolt ciklus. Az if helyett while kell, különben spurious wakeup vagy versenyhelyzet esetén hibás állapotból folytatódhat a végrehajtás.

class AccountService {
    private final Lock lock = new ReentrantLock();

    void transfer(Account from, Account to, long amount) throws InterruptedException {
        if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
            try {
                from.debit(amount);
                to.credit(amount);
            } finally {
                lock.unlock();
            }
        } else {
            throw new IllegalStateException("Could not acquire transfer lock");
        }
    }
}

A tryLock nem végtelen blokkolást, hanem kontrollált hibautat ad. Ez éles rendszerekben sokszor fontosabb, mint az, hogy a thread mindenképp kivárja a lockot.

Trade-offok

Az intrinsic lock tömör, normál vezérlési úton nehéz rosszul használni, és a JVM erősen optimalizálja. Szűk kritikus szakaszokra általában ez a jó alapértelmezés. A gyengéje a korlátozott kifejezőerő: nincs időkorlátos acquire, nincs non-blocking acquire, egy implicit condition queue van, és a diagnosztikai lehetőségek is szűkebbek.

A ReentrantLock rugalmasabb és jobban illik haladó koordinációs helyzetekhez, cserébe több boilerplate-et és több hibalehetőséget hoz. A fair lock csökkentheti a starvationt, de gyakran throughput-vesztést okoz, mert visszafogja a hasznos bargingot. A ReadWriteLock növelheti az olvasási párhuzamosságot, viszont növeli a bookkeepinget, ronthatja a cache lokalitást és bonyolíthatja az upgrade/downgrade helyzeteket.

A legmélyebb trade-off nem a szintaxis, hanem a contention stratégia. Ha sok thread ugyanazért a hot lockért versenyez, a valódi megoldás gyakran sharding, immutability, batching, actor-szerű tulajdonlás vagy konkurens adatszerkezet. Egy jobb lock API nem ment meg egy rossz ownership modellt.

Gyakori hibák

Az első tipikus hiba, hogy az írásokat szinkronizálják, az olvasásokat viszont nem. Ez megakadályozhatja az elvesző frissítést, de a visibility problémát nem oldja meg. Ugyancsak klasszikus, amikor rossz objektumon szinkronizálnak: ha az egyik metódus this-re, a másik private lockra lockol, akkor valójában nincs egységes védelem.

Monitor metódusoknál sok fejlesztő wait()-et vagy notify()-t hív synchronized blokk nélkül, ami IllegalMonitorStateException-t eredményez. Ennél alattomosabb az if használata while helyett wait() körül, ami spurious wakeup vagy versengő fogyasztók esetén törik el. Ugyanilyen visszatérő hiba a notify() használata akkor, amikor ugyanazon monitor több logikai feltételt multiplexel.

Explicit lockoknál az unlock() elfelejtése finally blokkban katasztrofális. Problémás az is, ha valaki lock alatt lassú I/O-t, távoli hívást vagy potenciálisan blokkoló logolást végez. A hosszú kritikus szakasz felerősíti a contentiont, és egy ártalmatlan terhelési tüskét könnyen latency-incidenssé alakít. ReadWriteLock esetén gyakori félreértés az író starvation alábecsülése vagy az a hit, hogy a read lock mindig biztonságosan upgradelhető write lockra.

Senior szintű meglátások

HotSpot alatt a lock viselkedése dinamikus. Contention nélküli lockok olcsók; contention esetén a lock „felfúvódhat”, parking léphet be. A JVM egyes lockokat el is tud tüntetni escape analysis segítségével, vagy összevonhat szomszédos lock régiókat. Emiatt a synchronizationről szóló mikrobenchmarkok könnyen félrevezetők, ha nem valós contention és escape mintákat modelleznek.

A happens-before a kulcs mentális modell. A synchronized, a volatile, a thread start, a thread join és bizonyos concurrency utility-k befejezése mind ordering edge-et hoz létre. Senior szinten nem azt kérdezzük, hogy „van-e lock valahol”, hanem azt, hogy melyik thread publikálja az adatot, melyik fogyasztja, és mi az az ordering edge, amely összeköti őket. Ha ez nincs meg, a kód évekig működhet, majd más CPU-n, más JIT fázisban vagy nagyobb terhelésnél eltörik.

A diagnosztika is része a synchronization szakértelemnek. Productionben a lockproblémát thread dumppal, JFR lock profilinggal, contention metrikákkal és ownership review-val vizsgáljuk. Ha a lock túl sokat véd, csökkentsd a shared mutable surface-t. Ha fairness kell, értsd meg, milyen starvation jel indokolta. Ha deadlock lehetséges, tervezz lock orderinget vagy időkorlátos acquire-t ahelyett, hogy reménykedsz.

Szószedet

  • Intrinsic lock: A synchronized által használt beépített monitorzár.
  • Monitor: JVM szinkronizációs konstrukció, amely egy objektumhoz kapcsolódik.
  • Reentrancy: Ugyanaz a thread újra megszerezheti ugyanazt a lockot.
  • Happens-before: Olyan sorrendi kapcsolat, amely láthatóságot és rendezést garantál.
  • Spurious wakeup: Legális felébresztés wait() után explicit értesítés nélkül.
  • Condition: Lockhoz társított explicit várakozási sor.
  • Fair lock: Olyan lock, amely megpróbálja tiszteletben tartani a várakozási sorrendet.
  • Contention: Több thread versenye ugyanazért a szinkronizációs erőforrásért.

Gyorsreferencia

  • A synchronized mutual exclusiont és visibilityt ad.
  • Minden shared invariánst egy jól definiált lock védjen.
  • Inkább private final lock objektum, mint publikus lock target.
  • wait() körül while, ne if.
  • wait/notify/notifyAll csak a monitor birtokában hívható.
  • Több feltételnél általában notifyAll() a biztonságosabb.
  • Explicit locknál mindig finally blokkban oldj fel.
  • tryLock vagy lockInterruptibly kell, ha a végtelen várakozás nem elfogadható.
  • ReadWriteLock-ot mérj, ne feltételezd automatikusan gyorsabbnak.
  • Ha nagy a contention, vizsgáld felül az adat-tulajdonlást, ne csak a lock típusát.

🎮 Játékok

10 kérdés