Programozási nyelvek – Java gyakorlat 1-2-3

ZH túlélőlap: párhuzamosság, szálak, szinkronizáció

Egy helyre összeszedve a 3 anyag lényege: fogalmak, lépések, kódminták, tipikus hibák, ZH-s gondolkodási séma, fogalomtár és feleletválasztós gyakorlás.

Tipp: használd a böngésző CTRL + F keresését, mert a fontos kulcsszavak külön is szerepelnek: Thread, Runnable, start, run, sleep, join, synchronized, monitor, wait, notify, notifyAll, deadlock, Lock, ReentrantLock, Condition, await, signal, signalAll, Semaphore, mutual exclusion, race condition, starvation

1. gyakorlat – a párhuzamos programozás alapjai

A párhuzamos programozás lényege, hogy egy nagyobb feladatot kisebb részfeladatokra bontunk, és ezeket több végrehajtási egység próbálja egyidőben vagy időben átfedve végrehajtani. Java oldalon a legfontosabb szó itt a szál (thread).

Párhuzamosság alapok – mit kell biztosan érteni?

Parallelism

Ténylegesen egyszerre fut több művelet, tipikusan több magon vagy processzoron.

Concurrency

Több feladattal foglalkozunk, de nem feltétlenül egyszerre futnak; váltogatva is haladhatnak.

Miért jó a párhuzamosság?

  • Gyorsíthatja a végrehajtást.
  • Természetesebb lehet a problémaleírás.
  • Nagy adatmennyiség feldolgozásánál kifejezetten hasznos.

Miért veszélyes?

  • Versenyhelyzetet (race condition) okozhat.
  • Holtpontot (deadlock) okozhat.
  • Éhezést (starvation) okozhat.
  • Nehéz fejben követni a futási sorrendet.
ZH-s mondat: attól, hogy több szálat indítasz, a program nem biztos, hogy arányosan gyorsabb lesz. Van szinkronizációs költség, vannak szekvenciális részek, és van hardverkorlát.

Thread osztály és Runnable interfész

Java-ban új szálat két klasszikus módon mutatnak be: Thread örökléssel vagy Runnable megvalósítással.

1. Thread osztályból származtatás

class ExampleThread extends Thread {
    @Override
    public void run() {
        System.out.println("Hello World - Thread");
    }
}

public class Main {
    public static void main(String[] args) {
        Thread t = new ExampleThread();
        t.start();
    }
}

2. Runnable interfész megvalósítása

class ExampleRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Hello World - Runnable");
    }
}

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(new ExampleRunnable());
        t.start();
    }
}

A legfontosabb különbség: start() vs run()

  • start() új szálat indít, és azon fut le a run().
  • run() közvetlen hívása nem indít új szálat; sima metódushívás marad.
Thread t = new ExampleThread();

t.run();   // NEM új szál, csak egy sima metódushívás
t.start(); // valóban új szál indul

Lépésről lépésre: hogyan gondolkodj?

  1. Döntsd el, mi fusson párhuzamosan.
  2. A végrehajtandó logikát tedd a run() metódusba.
  3. Hozz létre egy Thread objektumot.
  4. Indítsd el start()-tal.
  5. Ha meg kell várni a végét, használd a join()-t.

Miért szokták jobbnak tartani a Runnable megoldást?

  • Jobban szétválasztja a feladatot a szálkezeléstől.
  • Mivel Java-ban csak egyszeres öröklés van, nem foglalja el az öröklést.
  • Rugalmasabb, modernebb gondolkodásmód.

Hasznos Thread metódusok

class MethodThread extends Thread {
    MethodThread(String name) {
        super(name);
    }

    @Override
    public void run() {
        System.out.println("Current thread: " + Thread.currentThread());
        System.out.println("Id: " + this.threadId());
        System.out.println("Name: " + this.getName());
        System.out.println("Alive: " + this.isAlive());
        System.out.println("Daemon: " + this.isDaemon());
        System.out.println("Priority: " + this.getPriority());
    }
}

sleep(ms)

Ideiglenesen megállítja az aktuális szál futását. Gyakran dob InterruptedException-t.

join()

Egy másik szál befejeződésére vár. Nagyon gyakori ZH-s eszköz.

Thread t = new Thread(() -> {
    for (int i = 0; i < 5; i++) {
        System.out.println(i);
    }
});

t.start();
t.join(); // a main thread megvárja t befejeződését
System.out.println("A szál már lefutott.");
Tipikus hiba: elindítasz több szálat, de nem várod meg őket join()-nal, ezért a main túl korán ér véget vagy túl korán ír ki eredményt.

Szálállapotok

A Java Thread.State szempontból ezeket érdemes tudni:

  • NEW – létrehoztad a szálat, de még nem indítottad el.
  • RUNNABLE – fut vagy futásra kész.
  • BLOCKED – zárra vár, nem tud belépni a kritikus szakaszba.
  • WAITING – valamilyen jelzésre vár, pl. wait(), join().
  • TIMED_WAITING – időzített várakozásban van, pl. sleep().
  • TERMINATED – már befejeződött.
Thread t = new Thread(() -> {
    try {
        Thread.sleep(2000);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
});

System.out.println(t.getState()); // NEW

t.start();
System.out.println(t.getState()); // valószínűleg RUNNABLE vagy TIMED_WAITING

BLOCKED vs WAITING – ezt gyakran keverik

  • BLOCKED: zárra vár.
  • WAITING: feltétel teljesülésére / értesítésre vár.

Párhuzamosság teljesítményjavításra

Klasszikus ötlet: egy nagy bemenetet több részre bontasz, minden szál a saját részét dolgozza fel, majd a részeredményeket összegzed.

Példa: szavak gyakoriságának számolása

  1. Beolvasod a teljes szöveget.
  2. Felosztod több részre.
  3. Minden szál a saját részén számol.
  4. Minden szál egy lokális HashMap-be gyűjt.
  5. A végén a lokális térképeket összefésülöd.
class CounterTask implements Runnable {
    private final List<String> part;
    private final Map<String, Integer> localMap = new HashMap<>();

    public CounterTask(List<String> part) {
        this.part = part;
    }

    @Override
    public void run() {
        for (String word : part) {
            localMap.put(word, localMap.getOrDefault(word, 0) + 1);
        }
    }

    public Map<String, Integer> getLocalMap() {
        return localMap;
    }
}
Miért jó a lokális HashMap? Mert így a szálak munka közben nem ugyanazt a közös adatszerkezetet írják, ezért kevesebb szinkronizáció kell.

2. gyakorlat – kölcsönös kizárás, monitor, wait/notify

Itt jönnek az igazi veszélyek: ugyanazt a közös adatot több szál egyszerre olvassa és írja. Emiatt kell a szinkronizáció.

Kölcsönös kizárás és race condition

Ha több szál ugyanahhoz az erőforráshoz fér hozzá, akkor biztosítani kell, hogy egyszerre csak a megfelelő számú szál használja azt. Ez a kölcsönös kizárás.

class Counter {
    private int value = 0;

    public void increment() {
        value++; // ez NEM atomi művelet
    }

    public int getValue() {
        return value;
    }
}

A value++ valójában több lépésből áll: beolvasás, növelés, visszaírás. Ha két szál egyszerre végzi, elveszhet egy növelés.

Javítás synchronized-del

class Counter {
    private int value = 0;

    public synchronized void increment() {
        value++;
    }

    public synchronized int getValue() {
        return value;
    }
}

Monitor Java-ban

A monitor olyan mechanizmus, amely egy közös erőforráshoz ellenőrzött hozzáférést ad. Java-ban a synchronized kulcsszóval használjuk az objektum implicit zárját.

Synchronized blokk

public class SynchronizedBlock {
    private final Object lock = new Object();

    public void perform() {
        synchronized (lock) {
            // kritikus szakasz
        }
    }
}

Synchronized metódus

public class SynchronizedMethod {
    public synchronized void perform() {
        // kritikus szakasz
    }
}

Mikor melyiket?

  • Synchronized metódus: ha az egész metódus kritikus szakasz.
  • Synchronized blokk: ha csak egy rész kritikus, vagy külön zárobjektum kell.
Finom különbség: az instance metódus synchronized esetén a zár a this, statikus synchronized metódus esetén pedig az osztályobjektum.

Holtpont (deadlock)

Deadlock akkor alakul ki, amikor két vagy több szál egymásra vár olyan módon, hogy egyik sem tud továbbhaladni.

class DeadlockExample {
    private final Object lockA = new Object();
    private final Object lockB = new Object();

    public void methodA() {
        synchronized (lockA) {
            synchronized (lockB) {
                System.out.println("A");
            }
        }
    }

    public void methodB() {
        synchronized (lockB) {
            synchronized (lockA) {
                System.out.println("B");
            }
        }
    }
}

Mi a baj?

  • Az egyik szál megszerzi lockA-t, majd vár lockB-re.
  • A másik megszerzi lockB-t, majd vár lockA-ra.
  • Mindkettő áll.

Hogyan védekezz ellene?

  • Mindig ugyanabban a sorrendben szerezd meg a zárakat.
  • Minimalizáld a több zár egyidejű használatát.
  • Használj átgondolt zárolási stratégiát.

Kapcsolódó fogalom: starvation (éhezés), amikor egy szál nem feltétlenül holtpont miatt, hanem igazságtalan ütemezés miatt szinte sosem jut erőforráshoz.

A szálak várakoztatása és felébresztése: wait(), notify(), notifyAll()

Ha egy szál belépett a monitorba, de a folytatáshoz szükséges feltétel még nem teljesül, akkor nem blokkolnia kell a többieket, hanem várakoznia.

  • wait(): a szál várakozó állapotba kerül, és elengedi a monitor zárját.
  • notify(): egy várakozó szálat felébreszt.
  • notifyAll(): az összes várakozó szálat felébreszti.

Nagyon fontos szabályok

  • Ezeket csak monitoron belül, tipikusan synchronized blokkban vagy metódusban szabad hívni.
  • A wait() után a szál csak akkor tud továbbmenni, ha újra megszerzi a zárat.
  • A feltételt while-lal ellenőrizzük, nem if-fel.
synchronized (lock) {
    while (!feltetel) {
        lock.wait();
    }

    // itt már teljesül a feltétel
}
Miért while? Mert felébresztés után újra ellenőrizni kell a feltételt. Lehet spurious wakeup, vagy másik szál elvihette az erőforrást előled.

Termelő–fogyasztó probléma monitorral

Van egy közös puffer. A termelő beletesz elemeket, a fogyasztó kiveszi. Ha tele a puffer, a termelő vár. Ha üres, a fogyasztó vár.

class Buffer {
    private final Queue<Integer> queue = new LinkedList<>();
    private final int capacity;

    public Buffer(int capacity) {
        this.capacity = capacity;
    }

    public synchronized void put(int value) throws InterruptedException {
        while (queue.size() == capacity) {
            wait();
        }

        queue.add(value);
        notifyAll();
    }

    public synchronized int take() throws InterruptedException {
        while (queue.isEmpty()) {
            wait();
        }

        int value = queue.remove();
        notifyAll();
        return value;
    }
}

Mit figyelj meg benne?

  1. Mindkét metódus synchronized.
  2. Várakozásnál while van.
  3. Állapotváltozás után notifyAll() van.
  4. A puffer a közös kritikus erőforrás.

3. gyakorlat – Lock, Condition, Semaphore

Ugyanazokat a problémákat oldjuk meg, mint korábban, csak rugalmasabb és explicitebb eszközökkel.

Lock és Condition interfészek

A monitoros megközelítés néha túl merev. A Lock és a Condition több kontrollt ad.

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class Example {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();

    public void doWork() throws InterruptedException {
        lock.lock();
        try {
            while (!feltetel()) {
                condition.await();
            }

            // kritikus szakasz
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    private boolean feltetel() {
        return true;
    }
}

Megfeleltetések

  • synchronizedlock.lock() és lock.unlock()
  • wait()condition.await()
  • notify()condition.signal()
  • notifyAll()condition.signalAll()
Aranyszabály: ha lock.lock() van, akkor szinte biztosan kell try/finally, hogy az unlock() biztosan lefusson.

Termelő–fogyasztó Lock + Condition megoldással

Itt külön feltételváltozókat is használhatsz, például egyet a "nem üres" és egyet a "nem tele" állapotra. Ez tisztább és hatékonyabb lehet.

class Buffer {
    private final Queue<Integer> queue = new LinkedList<>();
    private final int capacity;
    private final Lock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    public Buffer(int capacity) {
        this.capacity = capacity;
    }

    public void put(int value) throws InterruptedException {
        lock.lock();
        try {
            while (queue.size() == capacity) {
                notFull.await();
            }

            queue.add(value);
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    public int take() throws InterruptedException {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                notEmpty.await();
            }

            int value = queue.remove();
            notFull.signal();
            return value;
        } finally {
            lock.unlock();
        }
    }
}

Miért kényelmesebb ez?

  • Nem mindenkit kell felébreszteni mindig.
  • Külön várakozási sorok lehetnek külön feltételekre.
  • Olvashatóbb sok bonyolult feladatnál.

Olvasók–írók probléma

A cél: több olvasó olvashasson egyszerre, de író csak egyedül írhasson. Írás közben ne olvasson senki, olvasás közben pedig író ne módosítson.

Alapelv

  • Olvasók egymással kompatibilisek.
  • Író senkivel sem kompatibilis.
  • A megoldás lehet olvasópreferáló vagy írópreferáló.
class Book {
    private final Lock lock = new ReentrantLock();
    private int readers = 0;
    private boolean writing = false;

    public void read() {
        lock.lock();
        try {
            while (writing) {
                // await kellene egy teljes megoldásban
            }
            readers++;
        } finally {
            lock.unlock();
        }

        try {
            System.out.println("Olvasás...");
        } finally {
            lock.lock();
            try {
                readers--;
            } finally {
                lock.unlock();
            }
        }
    }
}

ZH-n nem mindig a teljes kész kód a lényeg, hanem hogy meg tudd indokolni az együttfutási szabályokat.

Semaphore osztály

A szemafor egy számlálóalapú szinkronizációs eszköz. A benne tárolt érték azt mutatja, hány "belépés" vagy hozzáférés engedélyezett még.

Bináris szemafor

Értéke tipikusan 0 vagy 1. Gyakorlatilag kölcsönös kizárásra használható.

Számláló szemafor

Több hozzáférést enged egyszerre, például 3 vagy 10 szálnak.

import java.util.concurrent.Semaphore;

class SharedResource {
    private final Semaphore semaphore = new Semaphore(1);

    public void use() throws InterruptedException {
        semaphore.acquire();
        try {
            System.out.println("Kritikus szakasz");
        } finally {
            semaphore.release();
        }
    }
}

Mit jelent a két fő művelet?

  • acquire() – engedélyt kér, ha nincs, vár.
  • release() – visszaad egy engedélyt.

Étkező filozófusok probléma

Klasszikus szinkronizációs feladat. Minden filozófusnak két villa kell az evéshez, de a villák közösek. Ha mindenki ugyanabban a rossz sorrendben vesz fel villát, deadlock lehet.

A probléma lényege

  • Mindenki verseng a szomszédos erőforrásokért.
  • A rossz zárolási sorrend holtpontot okozhat.
  • A cél: ne legyen se deadlock, se starvation.

Tipikus megoldási ötletek

  • Egy filozófus fordított sorrendben vesz fel villát.
  • Legfeljebb 4 filozófus próbálkozhat egyszerre.
  • Sorszámozott villák, mindig kisebb sorszámút vesszük fel előbb.

Parkolóház számláló szemaforral

Ez tökéletes számláló szemafor példa. Ha a parkolóban 10 hely van, akkor a szemafor kezdőértéke 10. Minden autó belépéskor acquire()-ol, kilépéskor release()-el.

class ParkingLot {
    private final Semaphore places;

    public ParkingLot(int capacity) {
        this.places = new Semaphore(capacity);
    }

    public void enter() throws InterruptedException {
        places.acquire();
        System.out.println("Autó beállt");
    }

    public void leave() {
        System.out.println("Autó kiállt");
        places.release();
    }
}

Miért jó példa?

  • Az erőforrások száma jól modellezhető egész számmal.
  • Nem csak 1 szál mehet be, hanem korlátozott számú.
  • Nagyon könnyű fejben elképzelni.

ZH gondolkodási menet – hogyan állj neki egy gyakorlati feladatnak?

  1. Azonosítsd a közös erőforrást. Mi az, amit több szál használ?
  2. Azonosítsd a veszélyt. Race condition? Várakozás? Deadlock? Korlátozott kapacitás?
  3. Válassz eszközt.
    • Egyszerű kölcsönös kizárás: synchronized vagy bináris szemafor
    • Feltételes várakozás: wait/notify vagy Condition
    • Korlátozott számú belépés: számláló Semaphore
  4. Írd meg a kritikus szakaszt.
  5. Várakozási feltétel esetén while-t használj.
  6. Locknál mindig legyen finally + unlock.
  7. A végén gondold át: lehet-e holtpont, éhezés, elvesző értesítés, túl korai kiírás?
ZH mini-checklist: start? join? synchronized/lock? while a wait körül? notifyAll vagy signal? unlock finally-ban?

Fogalomtár

Thread
Végrehajtási szál egy folyamaton belül.
Runnable
Interfész, amelynek run() metódusa tartalmazza a végrehajtandó feladatot.
start()
Új szálat indít.
run()
A szál feladatát leíró metódus; önmagában meghívva nem indít új szálat.
join()
Megvárja egy másik szál befejeződését.
sleep()
Időzített várakozásba teszi az aktuális szálat.
Mutual exclusion / kölcsönös kizárás
Egyszerre csak a megfelelő számú szál férhet hozzá a kritikus erőforráshoz.
Race condition
A futási sorrend befolyásolja a helyes eredményt; tipikusan hibás viselkedéshez vezet.
Critical section / kritikus szakasz
A kód azon része, ahol közös erőforrást használunk.
Monitor
Szinkronizációs mechanizmus, amely kontrollálja a közös erőforráshoz való hozzáférést.
synchronized
Java kulcsszó monitoralapú kölcsönös kizáráshoz.
wait()
Várakozó állapotba teszi a szálat és elengedi a monitor zárját.
notify()
Egy várakozó szálat ébreszt.
notifyAll()
Az összes várakozó szálat ébreszti.
Lock
Expliciten kezelt zár, például ReentrantLock.
Condition
Feltételváltozó Lock mellett; külön várakozási sorokat tesz lehetővé.
await()
Condition melletti várakozás.
signal()
Egy várakozó szálat ébreszt a Condition sorából.
signalAll()
Az összes várakozó szálat ébreszti a Condition sorából.
Semaphore
Engedélyek számával dolgozó szinkronizációs eszköz.
Binary semaphore
0/1 jellegű szemafor, kölcsönös kizárásra is használható.
Counting semaphore
Több egyidejű hozzáférést engedő szemafor.
Deadlock
Egymásra váró szálak miatt a rendszer nem halad tovább.
Starvation
Egy szál tartósan nem jut erőforráshoz.
BLOCKED
A szál zárra vár.
WAITING
A szál értesítésre vagy feltételre vár.
TIMED_WAITING
Időzített várakozásban lévő szál.

Lehetséges feleletválasztós kérdések

1. Melyik állítás igaz?

A) A run() mindig új szálat indít.
B) A start() új szálat indít.
C) A join() lezárja a szálat.
D) A sleep() felébreszt egy másik szálat.

Megoldás: B

2. Mire való a synchronized?

A) Új szál létrehozására
B) Kölcsönös kizárás biztosítására
C) Szál megszakítására
D) Memóriafoglalásra

Megoldás: B

3. Melyik állapot jellemző Thread.sleep() után?

A) BLOCKED
B) RUNNABLE
C) TIMED_WAITING
D) TERMINATED

Megoldás: C

4. Melyik a helyes párosítás?

A) wait() ↔ Lock
B) await() ↔ Condition
C) notifyAll() ↔ Semaphore
D) release() ↔ Thread

Megoldás: B

5. Melyik a deadlock tipikus oka?

A) Túl sok print utasítás
B) Egymásra váró szálak és zárak
C) Túl gyors CPU
D) Túl kevés osztály

Megoldás: B

6. Miért szokás while-t használni wait() körül?

A) Mert az if tiltott Java-ban
B) Mert felébredés után újra ellenőrizni kell a feltételt
C) Mert a while gyorsabb
D) Mert csak így működik a notify()

Megoldás: B

7. Mire jó a számláló szemafor?

A) Pontosan egy szál belépésére
B) Több, de korlátozott számú szál egyidejű belépésére
C) Szálak törlésére
D) Kód újrafordítására

Megoldás: B

8. Melyik állítás hamis?

A) A join() várakozást okozhat.
B) A Semaphore.acquire() várhat engedélyre.
C) A notifyAll() automatikusan felszabadítja az összes zárat.
D) A Lock használatánál kell unlock().

Megoldás: C