Java haladó témák

Fő kategória: Java.

Az alábbi témák megértése nélkül lehet Java-ban programozni, ám bizonyos mélyebb összefüggések megértése érdekében érdemes ezekkel is megismerkednünk.

Szemétgyűjtő (garbage collector)

A Java előtti világ egyik legnagyobb rákfenéje a memóriakezelés volt. A programozóknak kellett foglalkozniuk a memória lefoglalásával és felszabadításával, ez utóbbi viszont igen gyakran elmaradt vagy nem megfelelően történt (pl. az osztályt ugyan felszabadították, a hivatkozott osztályt viszont nem). Nagyobb programok esetén szinte törvényszerű volt a memóriaszivárgás megjelenése, melyet igen nehéz volt felderíteni. A másik probléma a memória határainak megsértése volt: olyan helyről próbáltunk olvasni, ami nem hozzánk tartozott, melynek következménye Windows-on a vörös kereszt elszállás volt, Linuxon meg a "segmentation fault, core dumped" jól ismert hibaüzenet; mindkettő kifejezetten bosszantó.

A Java-ban alapvetően új alapokra helyezték a memóriakezelést. A legfontosabb újítás az volt, hogy a memóriaterület felszabadítása átkerült a fejlesztőtől a Java virtuális gépbe. Ha már nincs szükség egy objektumra, elég csak megszüntetni a hivatkozást, és a Java környezet gondoskodik a hely felszabadításáról. De mielőtt megnézzük, hogy ez hogyan is történik, lássunk egy általános áttekintést a memróiakezelésről. Alapvetően a memóriának két területét különböztetjük meg:

  • Stack: ide kerülnek a primitív változók (pl. az int típusúak), valamint maguk a hivatkozások. (Ne feledjük: ha készítünk egy objektum példányt, akkor valójában két helyen foglalunk memóriát: egyrészt maga a lefoglalt objektum, amiről szó lesz, másrészt a hivatkozás maga, ami ár bájt.) A stack jelentése halom, azaz "egymásra" pakoljuk az adatokat. Ennek a rekurzív függvényhívásoknál van igazán jelentősége, ezáltal mindegyik hívás a saját memóriaterületén dolgozik. Általában ennek a memóriaterületnek a mérete elhanyagolható.
  • Heap: ide kerülnek maguk az objektumok. Jelentése kupac, ami arra utal, hogy az adatok össze-vissza vannak benne. Normál esetben ennek a mérete sok nágységrenddel nagyobb mint a stack-é, és tipikusan itt történnek a problémák.

A Java-ban a memória felszabadításáért az ún. Garbage Collector (azaz szemétgyűjtő) felel. Ez időről időre lefut, és felszabadítja azokat a memóriaterületeket, amelyeket olyan objektumok foglalnak, amelyekre már nincs hivatkozás, azaz a stack-ből közvetve sem érhető el. Ez a műveket meglepően bonyolult, és a következőket kellett figyelembe venni a megalkotóinak:

  • Már az sem nyilvánvaló, hogy egy objektum elérhető-e. Lehet számlálót alkalmazni, viszont attól, hogy a számláló értéke pozitív, nem jelenti azt, hogy ne lehetne felszabadítani, pl. ha két, egyébkén nem elérhető objektum egymásra hivatkozik, vagy akár van egy hosszabb körkörös hivatkozás. Ezek felderítése nem egyszerű, különösen ha figyelembe vesszük azt, hogy különösen erőforrás igényes megvalósítás nem megengedett, nehogy túlzottan lelassítsa a főprogramot.
  • Figyelembe kell venni a hatékonyságot, és egyensúlyt kell teremteni aközött, hogy felszabadul a memória, és hogy mennyi processzoridő megy el ezzel. Ha ugyanis folyamatosan futna a szemétgyűjtő, akkor ugyan állandóan a lehetséges minimum közelében lenne a lefoglalt memória, ugyanakkor alig haladna a fő program. Ha viszont nem futna elég gyakran, akkor ideiglenesen hatalmasra duzzadhatna a felhasznált memória.
  • Célszerű úgy időzíteni a takarítást, hogy ne fusson akkor erőforrás igényes feladat.
  • Nem feltétlenül kell tökéletes munkát végezni; elég, ha a memória nagy része felszabadul. A szemétkezelő kihasználja azt a tapasztalati tényt, hogy minél régebb óta a memóriában van és elérhető egy objektum, annál kisebb valószínűséggel fog felszabadulni. Tehát a nemrég lefoglalt memóriaterületeket nagyobb valószínűséggel ellenőrzi mint a régieket. Ezt hívjuk egyébként nursery-nek és old space-nek.
  • A programozó adhat tippet arra, hogy induljon el a szemétszedés (System.gc();), de ez nem garantál semmit. A Java virtuális gép akár tökéletesen figyelmen kívül is hagyhatja, de dönthet úgy is, hogy ilyen esetben ténylegesen mindig elindul a memória felszabadítás.

A fentieket érdemes figyelembe vennünk a fejlesztéskor vagy hibakereséskor, különösen ha különösen memóriaigényes programról van szó.

Osztálybetöltés (class loading)

A Java osztálybetöltés szintén olyan, amellyel normál esetben nem találkozunk, de érdemes tudnunk legalább a létezéséről. Ahogy a nevéből is következtethetünk, ez tölti be az osztályokat a memóriába. A rendszerben valójában 3 osztálybetöltő van:

  • Rendszer osztálybetöltő (bootstrap class loader): magát az alaprendszert tölti be, tehát a Java alaposztályait, mint pl. az rt.jar (az rt is a runtime rövidítése), ill. a többi, a rendszer lib könyvtárában levő komponens. Ez egyébként értelemszerűen natív megvalósítású (valaminek be kell tölteni a Java-t, mielőtt Java programokat futtathatunk).
  • Alkalmazás osztálybetöltő (application class loader): ez tölti be a memóriába az indítandó alkalmazás osztályait.
  • Kiterjesztés osztálybetöltő (extension class loader): a programból használt könyvtárak osztályait tölti be, melyeket az ún. classpath által meghatározott helyeken (könyvtárakban vagy jar fájlokban) keres. Ezt egyrészt beállíthatjuk $CLASSPATH környezeti változó segítségével másrészt paraméterül átadhatjuk a -classpath vagy -cp paraméterek segítségével.

Ha nem sikerül megtalálni a hivatkozott osztályt, akkor a jól ismert ClassNotFoundException kivételt kapjuk. Az osztálybetöltő optimalizálhat, és csak akkor tölti be az osztályt, ha arra tényleg szükség van. A hivatkozások hivatkozásait is betöltheti, rekurzívan. Az osztálybetöltés tehát nem triviális folyamat.

Az osztálybetöltés egy hierarchikus folyamat, mindegyik osztálybetöltőnek van szülője. Először mindig a szülő próbálja meg betölteni, és ha az nem járt sikerrel, akkor próbálkozik az adott szintű osztálybetöltő. Először a rendszerosztályok töltése történik, utána a saját osztályoké, végül a kiterjesztéseké.

Mi magunk is írhatunk saját osztálybetöltőt, melyhez a ClassLoader osztályt kell felüldefiniálnunk, de rendkívül ritka az, amikor valóban szükségünk van saját osztálybetöltőre.

Az osztálybetöltés megértésében nekem sokat segített ez a leírás: https://www.baeldung.com/java-classloaders.

Profiling

Számos (igazából számtalan) esetben előfordulhat az, hogy egy nehezen felderíthető hiba (pl. memóriaszivárgás, gyanús belassulások) jelentkezésekor a dolgok mélyére kell néznünk. Valójában erről szól a profiling: amikor megnézzük, hogy ténylegesen mennyi memória van lefoglalva, mi mire hivatkozik stb.

Sok eszköz létezik erre a célra, részben olyanok, amelyek a Java telepítésnek a részei, részben külső ingyenes eszközök, részben fizetős kereskedelmi termékek.

A Java rendszer eszközkészlete

Lássuk először azt, hogy mit nyújt maga a Java! Rákereshetünk a JDK Tools and Utilities kulcsszóval.

  • jps: kiírja a Java programok process ID-jait (pid), melyek szükségesek az alábbi programokhoz. (Windows operációs rendszeren egyébként a Task Manager kiírja, a tasklist paranccsal ki tudjuk íratni, Linux alatt pedig a ps -ef | grep java a célravezető parancs.)
  • jstack [pid]: segítségével a pillanatnyi stack állapotot tudjuk lekérdezi.
  • jmap [pid]: a pillanatnyi memória állapotot tudjuk kimenteni, pl. jmap -dump:file=[filename].bin [PID]. A későbbi elemzéseknek ez az alapja.
  • jinfo [pid]: rendszerparaméterek kiírása.
  • hprof: Heap/CPU profiling tool
    • java -agentlib:hprof=cpu=times [myclass]: kiírja, hogy mi mennyi ideig tartott
    • java -agentlib:hprof=heap=dump,format=b [myclass]: heap dump
  • jhat [dumpfile]: dump fájl elemző. A http://localhost:7000/ URL-en böngészővel lehet nézni a tartalmat
  • jsadebugd - debug daemon; akár core fájlt is lehet vele elemezni.
  • jcmd: integrált Java lekérdező. Példák:
    • jcmd: kiírja a pillanatnyilag futó Java alkalmazásokat és PID-eket, meg hogy hogyan indítottuk
    • jcmd -h: segítség
    • jcmd [pid] help: mit lehet végrehajtani az adott PID-ű Java programmal
    • jcmd [pid] Thread.print: ugyanaz, mint a jstack
    • jcmd [pid] VM.uptime: kiírja, mióta fut
    • jcmd [pid] VM.flags: kiírja, milyen paraméterekkel fut (pl. memória méretek stb.)
    • jcmd [pid] VM.system_properties: kiírja a rendszerparamétereket (egyfajta belső környezeti változók; ugyanaz mint jinfo)
    • jcmd [pid] VM.command_line: kiírja, milyen paraméterekkel lett meghívva.
    • jcmd [pid] VM.version: kiírja a Java verzióját.
    • jcmd [pid] GC.class_historgram: kiírja, hogy melyik osztály mennyi memóriát foglal, hány példánya van.
    • jcmd [pid] GC.heap_dump [fájlnév]: a heap tartalmát fájlba írja (a fájl ott keletkezik, ahol a Java programot indították; ugyanaz mint a jmap).
    • jcmd [pid] GC.run: lefuttatja a garbage collectort.
    • jcmd [pid] GC.run_finalization: lefuttatja finalizationt.
  • jconsole: memória elemző

Külső eszközök

A legnépszerűbb memória elemző programok az alábbiak:

  • Eclipse Memory Analyzer (MAT): ingyenesen letölthető innen: https://www.eclipse.org/mat/. Néhány lehetőség:
    • Overview: áttekintést nyújt a heap-ről, és adhat olyan elsődleges információt, ami alapján el tudunk indulni.
    • Leak Suspects: felsorolja az általa gyanúsnak gondolt részeket. Ezzel még nem biztos, hogy megtaláljuk a probléma okát, de egy pillantást mindenképpen érdemes rá vetni.
    • Historgram: azt mutatja meg, hogy melyik osztály felszabadításakor mennyi memória szabadulna fel. Először egy hozzávetőleges (amúgy meglepően pontos) előzetes becslést ad, de meg lehet neki mondani azt is, hogy precízen számolja ki.
    • Dominator Tree: azt mondjuk, hogy X dominálja Y-t, ha minden út a gyökértől Y-ig X-en keresztül vezet. Ezt is érdemes megnézni, sok esetben segíthet.
  • VisualVM: csatlakozni lehet vele Java processzekhez, áttekintés (pl. JVM argumentumok, rendszertulajdonságok), monitorozás (CPU, memória, osztályok, szálak), szálak állapota részletesen, mintavételezés (melyik szál mennyi ideig fut).
  • JVM Monitor Eclipse plugin: a JVM Explorer View-t kell megnyitni; hasonló statisztikák (először nem működött; a %TMP%\hsperfdata_* könyvtárat ki kellett törölni).
  • jprofiler: fizetős, sokat tud.

Java HotSpot

A Java programok Java Virtuális Gépben (Java Virtual Machine, JVM) futnak. Ez egy absztrakt számítógép, melyet számos rendszeren megvalósítottak. Azt mondhatjuk, ahol létezik JVM megvalósítás, ott tudunk Java programot futtatni. A JVM-et sok platformra elkészítették, ám korántsem a lehetséges összesre; pl. az írás pillanatában az Orange PI PC-men nem tudok Java programot futtatni.

Java HotSpotnak hívjuk a JVM megvalósítást. Jelenleg két HotSpot megvalósítás létezik: a kliens és a szerver. A kliens a gyors indulásra és a kis memóriafogyasztásra optimalizált, míg a szerver mód a gyors kiszolgálásra, az indulási sebesség és a memóriahasználat kárára. A -client ill. a -server parancsokkal lehet beállítani a megfelelő módot. Más módban érdemes fejleszteni és üzemeltetni; erre célszerű odafigyelni.

Javadoc

TODO

A kivételkezelés továbbfejlesztése

A kivételkezelés megalkotása a Java-ban igen jól sikerült, és idővel ezt tovább is fejlesztették. Alább két, valójában alap nyelvi elemet nézünk meg.

Egyszerre több kivétel elkapása

A kivételek kezelése általában egyformán történik, függetlenül a tényleges kivételtől: többnyire kiírjuk a hívási láncot, majd visszatérünk. A 7-es Java-ban jelent meg annak a lehetősége, hogy ugyanabban a blokkban több kivételt is elkapjunk. A lehetséges kivétel típusokat | jellel kell elválasztva felsorolni. A Standard Java oldalon bemutatott példában ezt:

try {
    [...]
} catch (MyExceptionA e) {
    System.out.println("Call myFunction(" + i + ") finished with exception A");
    e.printStackTrace();
} catch (MyExceptionB e) {
    System.out.println("Call myFunction(" + i + ") finished with exception B");
    e.printStackTrace();
} finally {
    [...]
}

átírhatnánk erre:

try {
    [...]
} catch (MyExceptionA | MyExceptionB e) {
    System.out.println("Call myFunction(" + i + ") finished with exception");
    e.printStackTrace();
} finally {
    [...]
}

Az eredmény tömörebb és olvashatóbb kód, lényegében alig van hatással a funkcionalitásra.

A try erőforrásokkal

A 7-es Java verzióban jelent meg ez a lehetőség (angolul try with resources): a try-nak paraméterül át tudunk adni egy erőforrást, amit automatikusan lezár a végén. Hogy megértsük ennek a jelentőségét, vegyük példaként a beolvasást hálózatról! Az ember azt gondolná, hogy egy olyan elterjedt programozási nyelvben, mint a Java, biztos létezik egy egysoros megoldás. Ez sajnos nincs így; a legegyszerűbb valahogy a következőképpen néz ki:

Socket socket = new Socket("djxmmx.net", 17);
Scanner in = new Scanner(socket.getInputStream());
System.out.println("Server response: " + in.nextLine());
in.close();
socket.close();

A fenti kód beolvas valamit a djxmmx.net:17 oldalról, ami egy véletlenszerű idézet, majd kiírja a konzolra, végül lezárja a kapcsolatot. Jó lenne azt írni, hogy még így is majdnem olyan egyszerű, mint a faék, de sajnos a Java-ban valahogy semmi sem az, másrészt nem is igaz. A probléma az, hogy az első sor IOException kivételt dob, ami ellenőrzött kivétel, le kell kezelni. Az egyik lehetőség az, hogy az eljárás fejlécébe beleírjuk az imént megtanult módon azt, hogy throws IOException, és ez működik is, de nem az igazi. Ugyanis ez esetben a hívónak kell lekezelni a kivételt. Ha meg nem kezeli le, akkor a teljes hívási láncban ott kell, hogy legyen a fenti throws, és még a main() függvényben is ez ott fog éktelenkedni.

Kezeljük hát le helyben! Gondolhatjuk, hogy nem lehet az olyan bonyolult, tegyük az egészet egy try-catch blokkba. Gyorsan rájövünk arra, hogy a socket-et két esetben is le kell zárni: akkor is, ha kivétel váltódott, és akkor is, ha nem, de ez sem lehet túl bonyolult, mivel azt tanultuk, hogy a finally mindenképpen lefut, elég oda tenni a lezárást. Persze ahogy haladunk a sűrűjébe, úgy derülnek ki "dolgok". Egyrészt ahhoz, hogy a finally blokk is lássa a socket változót, azt ki kell emelni. Valamint a Java fordító rájön arra, hogy lehet, hogy nem kapott még kezdeti értéket, azt is le kell kezelni. Ill. maga a close() is válthat ki kivételt, igazából még annak a külön kezelését sem ússzuk meg. És ugyanezt el kell végezni a Scanner-rel is. Az eredmény már nem is annyira egyszerű!

Socket socket = null;
try {
    socket = new Socket("djxmmx.net", 17);
    Scanner in = null;
    try {
        in = new Scanner(socket.getInputStream());
        System.out.println("Server response: " + in.nextLine());
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        if (in != null) {
            in.close();
        }
    }
} catch (IOException e) {
    e.printStackTrace();
} finally {
    if (socket != null) {
        try {
            socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

Amit itt látunk, az az ún. "boilerplate" kódra egy nagyon jó példa: valójában egyetlen dolgot szeretnénk leprogramozni, de ehhez 17 sort kellett írnunk, melynek nagy része lényegében semmi hasznosat nem csinál.

Itt jön képbe a try erőforrásokkal lehetőség: a fenti Socket példányt a try paramétereként adjuk át, és az gondoskodik a végén a lezárásról. A lényeg az, hogy a paraméterben létrehozott objektum implementálja a Closeable interfészt; a Socket ilyen. Ráadásul a close() által dobott IOException-nel sem kell törődnünk. Lássuk a szintaxist a fenti példát folytatva:

try (Socket socket = new Socket("djxmmx.net", 17)) {
    try (Scanner in = new Scanner(socket.getInputStream())) {
        System.out.println("Server response: " + in.nextLine());
    }
} catch (IOException e) {
    e.printStackTrace();
}

A kód hossza és összetettsége nagyjából megfelel az első változatnak, de már tartalmazza a kivételkezelést is. Lássuk az egészet futtatható formában! (Emlékeztetőül: legalább 7-es Java-ra van szükség az alábbi kód futtatásához. A futtatáshoz hálózati kapcsolatra van szükségünk.)

import java.io.*;
import java.net.Socket;
import java.util.Scanner;
 
public class TryWithResource {
    public static void main(String[] args) {
        try {
            readWebpageThrowingException();
        } catch (IOException e) {
            e.printStackTrace();
        }
        readWebpageWithExceptionHandling();
        readWebpageWithTryWithResource();
    }
 
    static void readWebpageThrowingException() throws IOException {
        Socket socket = new Socket("djxmmx.net", 17);
        Scanner in = new Scanner(socket.getInputStream());
        System.out.println("Server response: " + in.nextLine());
        in.close();
        socket.close();
    }
 
    static void readWebpageWithExceptionHandling() {
        Socket socket = null;
        try {
            socket = new Socket("djxmmx.net", 17);
            Scanner in = null;
            try {
                in = new Scanner(socket.getInputStream());
                System.out.println("Server response: " + in.nextLine());
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                if (in != null) {
                    in.close();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (socket != null) {
                try {
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
 
    static void readWebpageWithTryWithResource() {
        try (Socket socket = new Socket("djxmmx.net", 17)) {
            try (Scanner in = new Scanner(socket.getInputStream())) {
                System.out.println("Server response: " + in.nextLine());
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License