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.

Memória kezelés a Java-ban

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 memóriakezelé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: az egyik maga a lefoglalt objektum, a másik pedig a hivatkozás maga, ami pá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ó. Szükség esetén a -Xss kapcsolóval tudjuk beállítani, pl. -Xss16m. A JVM belül különbséget tesz natív stack (a nem Java kód tack-je) és a JVM stack között.
  • 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 nagyságrenddel nagyobb mint a stack-é, és tipikusan itt történnek a problémák. Méretét szükség esetén a -Xms (minimum) és -Xmx (maximum) paraméterekkel tudjuk beállítani.

A Java 8 előtti időkben volt egy másik memóriaterület is, melynek neve method area (metódus terület) volt. Ennek két része volt: a permanent generation (az állandó adatok kerülte ide, pl. konstansok, osztálydefiníciók, struktúrák, metódusok stb.) és a code cache (a lefordított kód került ide). Gyakran előfordult, hogy a permanent generation memóriaterület betelt, akkor egy java.lang.OutOfMemoryError: PermGen space kivétel keletkezett. Ezt a memóriaterületet a -XX:PermSize és -XX:MaxPermSize paraméterekkel lehetett beállítani. Ez a Java 8-ban ebben a formában megszűnt, az ún. metaspace része lett. Ez utóbbi a heap-ben található, a mérete szükség esetén dinamikusan változik, így lecsökken az OutOfMemory hiba valószínűsége.

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ént 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 nursery méretét a -Xns paraméterrel tudjuk beállítani (ritkán állítjuk).
  • 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 memória felszabadítására többféle stratégia létezik:
    • Megszakításos (stop the world, STW): ez teljes mértékben felfüggeszti a program futását. Hatékony és precíz, viszont teljesítmény problémák jelentkezhetnek.
    • Növekményes (incremental): körültekintő tervezéssel történik.
    • Konkurens (concurrent): párhuzamosan futó folyamat, itt locking mechanizmus szükséges.

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 az alapértelmezettnek számító, Oracle által karbantartott 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

Amint arról már volt szó, a Java program forrása tulajdonképpen két részből áll: magából a kódból és a megjegyzésekből. Ez utóbbinak nincs szigorúan kötött formája, tkp. bármi lehet. Azonban ez dokumentációként is szolgál, és bevezettek egy szabványt, aminek segítségével az API dokumentációt le tudjuk generálni. Ezt hívjuk Javadoc-nak, melyről itt olvashatunk bővebben: https://www.oracle.com/technetwork/java/javase/documentation/index-137868.html.

Lássunk egy példát!

package javadoc;
 
/**
 * This class implements safe mathematical functions, which considers boundaries.
 */
public class SafeMath {
 
    /**
    * This function adds two integer numbers and returns the result.
    *
    * @param a The first term of the addition.
    * @param b The second term of the addition.
    * @return  The sum of the terms.
    * @throws ArithmeticException The result does not fit into int.
    */
    public static int add(int a, int b) throws ArithmeticException {
        int result = a + b;
        if ((a > 0 && b > 0 && result < 0) || (a < 0 && b < 0 && result > 0)) {
            throw new ArithmeticException("The result does not fit into int.");
        }
        return result;
    }
 
    /**
    * This function multiplies the two numbers using the {@link SafeMath#add} function. Returns the product.
    *
    * @param a The first factor of the multiplication.
    * @param b The second factor of the multiplication.
    * @return  The product of the terms.
    * @throws ArithmeticException The result does not fit into int.
    * @see SafeMath#add
    */
    public static int multiply(int a, int b) throws ArithmeticException {
        int result = 0;
        if (a > 0) {
            for (int i = 0; i < a; i++) {
                result = add(result, b);
            }
        } else {
            for (int i = a; i < 0; i++) {
                result = add(result, b);
            }
        }
        return result;
    }
 
    /**
     * Main functions which performs some tests.
     *
     * @param args Not used.
     */
    public static void main(String[] args) {
        System.out.println(add(2, 3));
        System.out.println(multiply(2, 3));
        System.out.println(multiply(2, -3));
        System.out.println(multiply(-2, 3));
        System.out.println(multiply(-2, -3));
        System.out.println(multiply(-1_000_000, 1_000_000));
    }
 
}

A példa egy osztályt és két függvény definiál: az egyik megvalósítása az összeadást úgy, hogy figyeli a túlcsordulást, a másik pedig a szorzást az imént definiált összeadásra visszavezetve. De nem is az a lényeg, amit csinál (egyébként van benne egy is "csalás" is: az ArithmeticException valójában nem ellenőrzött kivétel), hanem a hozzá fűzött megjegyzések. A következőkre láthatunk példát:

  • Osztály szintű dokumentáció.
  • Függvény szintű dokumentáció.
  • A paraméterek leírása.
  • A visszatérési érték specifikálása.
  • Kivétel specifikálása.
  • Kereszthivatkozás más metódusra.

A Javadoc természetesen több lehetőséget is nyújt. Valamint HTML formázó tag-eket is bele tudunk tenni. A leírásnak nem célja a Javadoc részletes ismertetése, így mindenre itt nem látunk példát.

Viszont lássuk, az így létrehozott dokumentáció mire jó?

  • Ha megnyitjuk a forrást egy integrált fejlesztői környezetben, pl. Eclipse-ben, akkor ha pl. az add() hívás fölé visszük a kurzort, akkor jól formázottan megjeleníti az API dokumentációt, amit a megjegyzések alapján generál.
  • Ha a forrás az src könyvtárban van, és az alatt a csomag szerepel (az Eclipse szabvány felépítése), akkor adjuk ki a következő parancsot: javadoc -sourcepath src -d document javadoc. A document könyvtárba legenerál egy HTML oldalstruktúrát, ami az API dokumentácit tartalmazza. A belépési pont az index.html.

A megjegyzések hozzáfűzését nem érdemes egyébként túlzásba vinni, de a rendszer külső interfészeit mindenképpen érdemes ily módon dokumentálni.

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();
        }
    }
}

Hatékony Java

A leírások nagy része a programozás technikájáról szól: mit hogyan lehet megvalósítani. Ez viszont az éremnek csak az egyik oldala, arról nem sok szó esett, hogy hogyan érdemes. Ugyanazt a problémát számos módon meg tudjuk oldani; az igazi programozás művészet nem is maga a megvalósítás, hanem az, hogy a lehető leghatékonyabban valósítsuk azt meg. A témában számos mű született; most Joshua Bloch Effective Java című könyvének harmadik kiadása alapján mutatom be legfontosabb tudnivalókat.

Objektumok létrehozása és törlése

  • Konstruktorok helyett fontoljuk meg a statikus gyár metódusok (static factory method) használatát! Technikailag rejtsük el a konstruktort, és hozzunk létre - akár paraméterezhető - statikus függvényeket, amelyek az adott típusú objektumokat adnak vissza paraméterül. Ennek a technikának számos előnye van: javítja az olvashatóságot (a konstruktornak nincs neve, a gyár metódusnak akármilyen nevet adhatunk); többször visszaadhatjuk ugyanazt a példányt (így működik egyébként a String); a ténylegesen visszaadott osztály lehet tetszőleges leszármazott, ami lehet többféle is, függően a paramétertől; a visszatérési típusnak nem feltétlenül kell léteznie a megvalósítás pillanatában (gondoljunk csak a JDBC meghajtóra; a technika azokkal az adatbázisokkal is működik, amelyek a JDBC megvalósításakor még nem is léteztek). Persze hátrányai is vannak: a konstruktor elrejtésével nem lehet őket példányosítani, ill. nehezebb megtalálni azt, hogy hogyan példányosítsunk. Ez utóbbit elnevezési konvenciókkal tudjuk enyhíteni. Tipikus példák: from(), of, valueOf(), instance(), getInstance() (ez az egyke (singleton) minta tipikus megvalósítása), create(), newInstance() (ha azt szeretnénk kihangsúlyozni, hogy mindenképpen új példányt szeretnénk létrehozni), getType stb.
  • Sok konstruktor paraméter esetén fontoljuk meg az építő minta használatát! Ha egy osztály attribútumainak a száma elég nagy, és várhatóan nőni fog, akkor a hagyományos konstruktor technika oda vezethet, hogy a tucatnyi paramétert tartalmazó konstruktor mellé újat kell létrehozni, ha újabb attribútum kerül a képbe. Ezt teleszkóp konstruktor mintának nevezzük, ami a végeláthatatlan konstruktor lista miatt aránytalanul hosszú semmitmondó kódot eredményez. Az építő technikával fokozatosan építjük ki az objektumot, egyesével beállítva az attribútumokat. Technikailag a kérdéses osztályon belül hozzunk létre egy publikus statikus osztályt Builder névvel, ami tartalmazza ugyanazokat a mezőket mint a fő osztály, megfelelő beállítókkal (azaz setterekkel, de set prefix nélkül), valamint tartalmaz egy build() metódust, ami létrehozza a tulajdonképpeni fő osztály példányt. A főosztálynak a konstruktora már az összes attribútumot tartalmazza, viszont az legyen privát, így ha új attribútumot kell adnunk az osztályhoz, akkor nem lesz kompatibilitási kényszer, és elég az egy szem privát konstruktort módosítani.
  • Az egyke (singleton) tulajdonságot privát konstruktor vagy felsorolás típus segítségével kényszerítsük ki! Az egyke talán az egyik leggyakrabban alkalmazott programtervezési minta, és egyszerűsége ellenére igen komoly problémák merültek fel vele kapcsolatban az idők folyamán. Kétféle módon szokás megvalósítani: publikus statikus final adatmezővel (melynek konvencionális elnevezése instance), vagy privát statikus adatmezővel és publikus statikus metódussal (melynek konvencionális elnevezése getInstance()). Az első az egyszerűbb, a másik viszont rugalmasabb, ugyanis később könnyebben módosíthatunk az interfészen. Ha ez utóbbi nem szempont, akkor az előbbit érdemes választani. Mindkét esetben gondoskodnunk kell arról, hogy a konstruktor privát legyen, hogy ne lehessen kívülről példányosítani. Ha az egyke osztály szerializálható, akkor újabb probléma merülhet fel: ha szerializáljuk, majd deszerializáljuk, akkor létre jön egy újabb példány. Ennek elkerülése érdekében adjuk hozzá a private Object readResolve {return instance;} függvényt. Egyébként még ekkor is marad egy pont, amivel nem tudunk mit kezdeni: a reflection segítségével sajnos még így is lehet példányosítani.
  • A példányosíthatatlanságot privát konstruktorral kényszerítsük ki! A Java megköveteli, hogy mindent osztályba kell tennünk. Viszont gyakran előfordul, hogy adott témában létrehozunk utility függvényeket, melyeket - jobb híján - egy Java osztályba teszünk, statikus metódusokként. Általában mást ebbe az osztálya nem szoktunk tenni, de ne feledkezzünk meg arról, hogy kerülnek oda láthatatlan dolgok is! Az egyik ilyen az alapértelmezett konstruktor, ami paraméter nélküli és publikus. Valójában nem helyes, ha egy ilyen osztályt példányosítunk, és a példányokon keresztül hívjuk a függvényeket, ezért ezt a lehetőséget tiltsuk le azzal, hogy létrehozunk egy privát konstruktort, és mi magunk sem példányosítjuk az osztályt.
  • Részesítsük előnyben a függőség befecskendezése (dependency injection DI) módszert az erőforrások kézi bedrótozása helyett! Csökkenti a tesztlehetőséget az, ha egy adott osztály által használt más osztályt abban az osztályban hozzuk létre, melyben használni szeretnénk. A Dependency Injection (DI) minta lényege az, hogy a függőségek kívülről jönnek. Léteznek DI keresztrendszerek (mint pl. a Spring), de gyakorlatilag DI-nak tekinthető az is, ha az osztály konstruktorának adjuk át a szükséges osztályokat. A standard Java-ban a külső függőségek megadásának ez a javasolt módja.
  • Kerüljük el a felesleges objektumok létrehozását! Az objektumok létrehozása, majd a memóriából való törlése drága művelet; kerüljük, ha csak lehet. Használjuk ugyanazt a példányt többször. Pl. ne cikluson belül hozzuk létre, majd töröljük, hanem cikluson kívül. Vagy egy gyakran meghívott függvény esetén a mindig fix értékkel rendelkező lokális változót (pl. egy fix reguláris kifejezést) tegyük ki osztály szintre. Ha csak lehet, használjunk primitív típusokat. Ha pl. elszámolunk egytől egymilliárdig, akkor ahhoz primitív típust használva elég 4 bájt, míg ha a csomagolt Integer-t használjuk, akkor egyesével létrehozza mind az egymilliárd objektumot, ami egyrészt iszonyatosan lassú, másrészt óriási memória területet fogyaszt.
  • Szüntessük meg az elavult osztályok referenciáit! Aki programozott C++-ban, azt tudja, mennyire körültekintően kell bánni ott a memóriakezelésnél, és kezdetben szerintem a legtöbb egykori C++ fejlesztő meglepődött a Java fejlett memóriakezelésén. Viszonyt gyorsan hozzászoktunk a kényelemhez, és talán elfelejtettük, hogy a memóriakezelés azért ott van, csak annak nagy részét levette a Java vállunkról. Ugyanakkor ha törölni szeretnénk egy objektumot, akkor érdemes a Java-ban is explicit null értékre állítani azt, egyébként könnyen memóriaszivárgás lépet fel. Most ne olyan egyszerű esetekre gondoljunk, amikor csak egy szem objektumunk van, hanem mondjuk egy gyűjteményre, amely tartalmaz tízezer hivtkozást, valójában egyikre sincs szükségünk, és valójában a gyűjteményre sem, de mivel nem nulláztunk ki, az esetleg tartósan foglalja a helyet a memóriában.
  • Kerüljük a véglegesítők (finalizer) és tisztítók (cleaner) használatát! A finalize() az a metódus, amit a Java garbage collector (szemétgyűjtő) meghív akkor, amikor törli az objektumot. A Java 9-ben megjelent a Cleaner osztály ill. technológia, ami a finalizer továbbgondolása. Nem szabad viszont úgy tekinteni ezekre a technológiákra, mint a C++ destructor Java analógiája: míg a C++-ban a destructor a memória felszabadítás normál módja, addig ugyanezért a Java-ban a garbage collector a felelős. Nincs arra garancia, hogy a szemétgyűjtő mikor fut le, ráadásul ezek a struktúrák használatának jelentős az erőforrás igénye. Normál esetben erőforrás felszabadítást tennénk egy C++ destructorba, és ezzel a logikával ugyanezt tennénk a java finalizerbe vagy cleanerbe is. Az erőforrásokat másképp szabadítsuk fel, pl. használjuk a try-with-resources technológiát, vagy mi magunk valósítsuk meg az AutoClosable interfészt, ezzel kiváltva azt, hogy a klienseink használják az imént említett technológiát.
  • Részesítsük előnyben a try-with-resources struktúrát a try-finally struktúrával szemben! Azon túl, hogy a kód sokkal rövidebb és olvashatóbb, van még egy további praktikus előnye is. A problémát az okozza, hogy nemcsak a tényleges művelet (tehát pl. egy olvasás) válthat ki kivételt, hanem a close() is. Ha nem kapjuk el a kivételt (tehát a struktúra ez: try {…} finally {…} }, és maga a függvény is kivételt dobhat}), akkor ha az alap művelet és a close is kivételt vált, az alapművelet kivétele nem jelenik meg, mert elnyomja a másik. Ha pedig azt szeretnénk, hogy a függvényünk ne dobjon kivételt, hanem mi magunk szeretnénk lekezelni, akkor kell külön {{try … catch … finally az alapműveletnek, de a finally-n belül is kell egy try .. catch. AMit tovább bonyolít az, ha több erőforrást használunk. A try-with-resources mindezeket rendesen lekezeli, és több erőforrással is működik.

Az Object osztály metódusai

  • Az equals() felülírásakor vegyük figyelembe a szabályokat! Először gondoljuk át, hogy meg kell-e valósítani ezt egyáltalán! Néhány eset, amikor nem kell: az összes objektum természetszerűleg egyedi (pl. Thread); kerüljük a "logikai egyenlőség" megvalósítását (pl. ugyanazt jelenti-e két reguláris kifejezés); az ősosztály equals() megvalósítása megfelelő; az osztály privát (mivel ez esetben sohasem fog kívülről meghívódni). Amikor biztosan meg kell valósítani: ha az osztályt értékek tárolására hoztuk létre. A megvalósításnak a következő tulajdonságokkal kell rendelkeznie: reflexív, szimmetrikus, tranzitív, konzisztens, x != null. A reflexív tulajdonság talán a legnyilvánvalóbb: minden objektum egyenlő saját magával, és szinte csak szándékosan lehet ezt elrontani. A szimmetria már nem nyilvánvaló: pl. ha megvalósítunk egy olyan String osztályt, ami az összehasonlításnál nem veszi figyelembe a kisbetűt és nagybetűt, akkor egy String objektummal összehasonlítva mást adhat eredményül, mintha a Stirng oldalról közelítenénk. A tranzitivitás azt jelenti, hogy ha A==B és B==C, akkor A==C. Ezt szintén könnyű elrontani, pl. öröklődésnél: ha az A és a C objektum osztálya egy színes pont, a B osztályé pedig egy pont, akkor a (2, 3, piros) == (2, 3), a (2, 3) == (2, 3, zöld), viszont (2, 3, piros) != (2, 3, zöld). A konzisztencia azt jelenti, hogy ugyanannak a két objektumnak az összehasonlítása mindig ugyanazt adja eredményül. Pl. ha egy URL osztály az IP alapján hasonlít, akkor később esetlen ugyanarra az URL-re más IP-t kapunk. A null összehasonlítást úgy tudjuk elrontani, ha kivételt dob az equals(). Ezen kívül figyeljünk arra is, hogy az equals(Object)-et írjuk felül, és ne az equals(MyClass)-t, mert ez nehezen felderíthető hibákhoz vezet. Emiatt használjuk mindig a @Override annotációt! Ez esetben ugyanis már fordítási hibát eredményez, ha rossz függvényt valósítunk meg. És olyat még véletlenül se csináljunk, hogy minkét formát megvalósítjuk, mert a belőlünk származó osztályoknak hamis biztonságot adunk a @Override annotáció használatával. Alapvetően 3 lehetőségünk van: mi magunk valósítjuk meg a függvényt; az IDE-vel generáltatjuk (ez az én személyes preferenciám); használjuk a @AutoValue annotációt.
  • Mindig írjuk felül a hashCode()-ot, ha felülírjuk az equals()-t!
  • Mindig írjuk felül a toString()-et!
  • Megfontoltan írjuk felül a clone())-t!
  • Fontoljuk meg a Comparable használatát!

Osztályok és interfészek

  • Minimalizáljuk az osztályoknak és azok részeinek a láthatóságát!
  • Publikus osztályok esetén publikus mezők helyett használjuk lekérdező metódusokat!
  • Minimalizáljuk a változtathatóságot!
  • Részesítsük előnyben a kompozíciót az öröklődéssel szemben!
  • Tervezzük meg és dokumentáljuk az öröklődést, vagy tiltsuk meg!
  • Részesítsük előnyben az interfészeket az absztrakt osztályokkal szemben!
  • Az interfészeket tervezzük az utókor számára!
  • Típusok definiálásához csak interfészeket használjunk!
  • Használjunk osztályhierarchiát megjelölt osztályok helyett!
  • Nem statikus belső osztály helyett használjunk statikusat!
  • A források csak egy legfelső osztályt tartalmazzanak!

Generikus programozás

  • Ne használjunk nyers (raw) típust!
  • Szüntessük meg az ellenőrizetlen konverzióra vonatkozó figyelmeztetéseket!
  • Tömbök helyett használjunk listákat!
  • Használjunk generikus típusokat!
  • Használjunk generikus metódusokat!
  • Határoljuk be a típust az API rugalmasságának növelése érdekében!
  • Legyünk megfontoltak a generikus típusok és a változó hosszúságú argumentum lista (vararg) kombinálása során!
  • Fontoljuk meg a típusbiztos heterogén konténerek használatát!

Felsorolások és annotációk

  • Használjuk a felsorolás típust egész konstansok helyett!
  • A felsorolás sorszáma helyett használjuk annak mezőértékét!
  • Használjuk az EnumSet-et bit mező helyett!
  • Használjuk az EnumMap-et a sorrendi indexelés helyett!
  • Emuláljuk a kiterjeszthető felsorolást interfészekkel!
  • Használjunk annotációt névminta helyett!
  • Használjuk következetesen az Override annotációt!
  • Használjunk jelölő interfészt típusok létrehozásához!

Lambda kifejezések és folyamok

  • Részesítsük előnyben a lambdát az anonymous osztállyal szemben!
  • Részesítsük előnyben a metódus referenciát a lambdával szemben!
  • Használjuk a szabványos funkcionális interfészeket!
  • Használjuk a folyamokat megfontoltan!
  • A folyamokban a függvények legyenek mellékhatásmentesek!
  • Visszatéréskor részesítsük előnyben a hagyományos gyűjtemény típusokat a folyamokkal szemben!
  • Legyünk figyelmesek, amikor párhuzamosítjuk a folyamokat!

Metódusok

  • Ellenőrizzük a paraméterek érvényességét!
  • Szükség esetén készítsünk biztonsági másolatot!
  • A metódusok szignatúráját körültekintően tervezzük meg!
  • Használjuk a túlterhelést (overloading) megfontoltan!
  • Használjuk a varargs-ot megfontoltan!
  • Térjünk vissza üres gyűjteményekkel vagy tömbökkel null helyett!
  • Használjuk az opcionális típust megfontoltan!
  • Fűzzünk dokumentációs megjegyzést minden kiajánlott API elemhez!

Általános programozás

  • Minimalizáljuk a lokális változók hatókörét!
  • Részesítsük előnyben a for-each ciklust a hagyományos for ciklussal szemben!
  • Ismerjük és használjuk a könyvtárakat!
  • Kerüljük a float és double használatát ha pontos válaszra van szükség!
  • Részesítsük előnyben a primitív típusokat a dobozolt típusokkal szemben!
  • Kerüljük a string használatát, ha más típus megfelelőbb!
  • Figyeljünk a string összefűzés performanciájára!
  • Az objektumokra az interfészeiken keresztül hivatkozzunk!
  • Részesítsük előnyben az interfészeket a reflexióval szemben!
  • Használjuk megfontoltan a natív metódusokat!
  • Optimalizáljunk megfontoltan!
  • Ragaszkodjunk az általánosan elfogadott elnevezési konvenciókhoz!

Kivételkezelés

  • A kivételeket csak kivételes körülmények esetén használjuk!
  • Használjuk az ellenőrzött kivételeket olyan esetekben, melyeket le lehet kezelni, és futásidejű kivételeket a programozási hibák esetén!
  • Kerüljük az ellenőrzött kivételek felesleges használatát!
  • Használjunk szabványos kivételeket!
  • Az absztrakciós szintnek megfelelő kivételt dobjunk!
  • Dokumentáljuk az összes kivételt!
  • A részletes üzenet tartalmazza a hibával kapcsolatos fontos információkat!
  • Törekedjünk a hiba atomicitásra!
  • Ne hagyjuk figyelmen kívül a kivételt!

Párhuzamosítás

  • Szinkronizáljuk a megosztott módosítható adatokhoz való hozzáférést!
  • Kerüljük a túlzott szinkronizálást!
  • Részesítsük előnyben a végrehajtókat (executor), a feladatokat (task) és a folyamokat (stream) a szálakkal (thread) szemben!
  • Részesítsük előnyben a párhuzamosítással kapcsolatos segédosztályokat a wait-notify struktúrával szemben!
  • Dokumentáljuk a szálbiztonságot (thread safety)!
  • A lusta (lazy) inicializálást használjuk megfontoltan!
  • Ne függjünk a feladatütemezőtől!

Szerializáció

  • Részesítsük előnyben az alternatívákat a Java szerializációval szemben!
  • A Serializable interfészt nagy körültekintéssel valósítsuk meg!
  • Fontoljuk meg a saját szerializációs forma használatát!
  • A readObject metódust defenzíven valósítsuk meg!
  • A példány vezérléshez a felsorolás típust részesítsük előnyben a readResolve-val szemben!
  • Fontoljuk meg a szerializációs proxy-kat a szerializált példányokkal szemben!
Unless otherwise stated, the content of this page is licensed under Creative Commons Attribution-ShareAlike 3.0 License