Optimalizácia kódu na rýchlosť a zmenšenie pamäťovej náročnosti býva druhoradou záležitostí. Pozrieme sa, ako týmto problémom predchádzať.

Počas rokov som videl rôzny kód a pracoval na rôznych projektoch, nových alebo už dlhšie bežiacich. Optimalizovanie na rýchlosť a zmenšenie pamäťovej náročnosti väčšinou prichádza na rad až v prípade problémov s jej nedostatkom, alebo pri poklese výkonnosti. Vtedy hľadáme najjednoduchšie riešenie pri minimálnom dodatočnom zdržaní projektu. Nie vždy sa dá rýchlo a jednoducho odstrániť problém. Často si to vyžaduje zmenu architektúry a zmenu kódu na veľkom počte miest. Zmena architektúry je dosť bolestivá a náročná záležitosť. Prichádza na rad najčastejšie až na koniec. Tu je výhoda ak v projekte nie je veľká duplicita kódu, nakoľko sa tým zjednodušujú a urýchľujú zmeny.

V nasledujúcom článku by som poukázal na to ako už od začiatku písať efektívnejší a rýchlejší kód. Vyhneme sa tým počas projektu možným zdržaniam a optimalizáciám kódu. Časová náročnosť písania už efektívneho kódu je rovnaká ako písanie neefektívneho. Z tohto dôvodu je výhodnejšie písať optimalizovaný kód už od začiatku.

Základné odporúčania

Na identifikovanie problémových miest využívame profiler. Identifikuje nám miesta kde náš kód trávi najviac času. Aj malé zmeny pri veľkom počte opakovaní, vedia ovplyvniť výkonnosť. Podľa výsledkov profileru sú najčastejšie úzke miesta práca s reťazcami a dlhé cykly. Vyvarovať by sme sa mali používaniu premenných typu String. Miesto toho môžeme použiť číselnú hodnotu alebo enumeráciu. Manipulácia s reťazcami je komplikovanejšia a časovo náročnejšia. Preto je výhodnejšie ID objektov a typy objektov definovať číselnou hodnotu nielen v jave a aj v databázach.

String, StringBuilder a StringBuffer

String objekty využívame na uchovanie textových informácii. S reťazcami často manipulujeme, pridávame, meníme alebo mažeme. V prípade intenzívneho využitia týchto operácii v rámci cyklov nie je objekt String veľmi efektívny a rýchly. Pre tieto účely boli vytvorené špecializované triedy StringBuilder a StringBuffer. Obe triedy sú určené na intenzívnu zmenu reťazcov. StringBuffer je oproti triede StringBuilder synchronizovaný a tým aj pomalší. Zrýchlenie oproti triede String môže byť niekoľkonásobné, záleží od intenzity použitia.

...
String vystup = "";
for (String parameter : parametre) {
  if (vystup.length() > 0) {
    vystup += ",";
  }

  vystup += parameter;
}
...

Zmena na StringBuffer:

...
StringBuffer vystupBuffer = new StringBuffer();
for (String param : parametre) {
  if (vystupBuffer.length() > 0) {
    vystupBuffer.append(",");
  }
            
  vystupBuffer.append(param);
}
...

Pri inicializácii tried StringBuffer a StringBuilder je možné vopred určiť veľkosť. Pokiaľ vieme dopredu približnú veľkosť, môžeme sa tým vyhnúť postupnému rozširovaniu vnútorného bufferu.

Triedy StringBuffer a StringBuilder môžu zvýšiť výkonnosť aplikácie, ale ako bolo už spomenuté v úvode, pokiaľ sa dá, je vhodné sa vyhnúť použitiu reťazcov.

Kolekcie

Obdobná inicializácia počiatočnej veľkosti vnútorného bufferu platí aj pri niektorých druhoch kolekcii. Najvýraznejšie zvýšenie výkonnosti dosiahneme použitím vhodnej implementácie kolekcii. Z toho dôvodu je dobré poznať, ktorý druh kolekcie je na aký typ aplikácie vhodný. Ďalším rozšírením je identifikácia či je potrebná synchronizácia pri manipulácii s kolekciami. Nesynchronizované kolekcie sú oveľa rýchlejšie, preto pokiaľ nie je potrebné synchronizovať, použijeme kolekcie nesynchronizované. Stručný prehľad môžeme nájsť tu: www.meshplex.org/wiki/Java/Java_Collections

Synchronizácia

Synchronizovanie jednotlivých častí kódu by malo byť vykonané vždy len na nutnú časť kódu. Zlou praktikou je synchronizovanie veľkých častí kódu a celých metód napriek tomu, že synchronizáciu je potrebné vykonať len za určitých podmienok a na oveľa menšej časti. Nakoľko do synchronizovaných častí má vždy prístup len jeden proces a ostatné musia čakať na uvoľnenie, takto dosiahnuté zrýchlenie môže dramaticky ovplyvniť výkonnosť našej aplikácie.

public synchronized void pridajOsobu(Osoba osoba) {
  if (osoba.getVek() >= VEK_DOSPELOSTI) {
    if (osoba.getAdresa().getTypAdresa == TypAdresa.TRVALY_POBYT) {
      zoznamOsob.pridaj(osoba);
    }
  }
}

Úpravou synchronizovania len potrebnej časti a len v prípade, že hľadaná osoba spĺňa potrebné kritéria, dosiahneme zvýšenie priepustnosti systému.

public void pridajOsobu(Osoba osoba) {
  if (osoba.getVek() >= VEK_DOSPELOSTI) {
    if (osoba.getAdresa().getTypAdresa == TypAdresa.TRVALY_POBYT) {
      synchronized {
        zoznamOsob.pridaj(osoba);
      }

    }
  }
}

Optimalizácia cyklov

Pri prechádzaní kolekcii alebo spracovaní údajov v cykloch sa každé zrýchlenie ráta, nakoľko je násobené počtom opakovaní daného cyklu.

Inicializácia premenných mimo cyklus

Objekty inicializované vnútri cyklu presunieme pred daný cyklus a využívame tú istú inštanciu počas všetkých cyklov. Ušetríme čas procesora na mnohonásobnej inicializácii a zrušení objektov a ich následného upratania Garbage Collectorom.

...
for (Osoba osoba : zoznamOsob) {
  SimpleDateFormat simpleDateFormat = new SimpleDateFormat("dd.MM.yyyy");

  System.out.println( simpleDateFormat.format(osoba.getDatumNarodenia()) );
}
...

Po zmene:

...
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("dd.MM.yyyy");

for (Osoba osoba : zoznamOsob) {
  System.out.println( simpleDateFormat.format(osoba.getDatumNarodenia()) );
}

Príkazy continue a break

Príkazy continue a break nahrádzajú komplikovane definované podmienky. Jedná sa skôr o zlepšenie funkcionality a ich výkonnostný profit oproti klasickým aj keď zložitým podmienkam je dosť malý. V každom cykle by nemali byť definované viac ako dva príkazy continue či break a dĺžka takéhoto cyklu by nemala presiahnuť jednu obrazovku.

Vyhodnotenia pre príkaz continue bývajú spravidla hneď na začiatku cyklu (najvhodnejšie) prípadne v jeho priebehu. Príkazom continue prejdeme na ďalší krok cyklu.

for (Osoba osoba : zoznamOsob) {
  if (osoba.getPriezvisko.length() == 0) {
    // nasli sme osobu ktorej data su neni naplnene
    // napr. zla migracia, neuplne nacitanie, ...
    continue;
  }

  if (osoba.getMeno().startsWith(... ) ) {

  ...
  }
  if (osoba.getPriezvisko().startsWith(... ) ) {
  ...
  }
  if (osoba.getVek() ... ) {
  ...
  }
  // dalsie podmienky na osobu
  ...
}

Príkaz break môžeme využiť pri prehľadávaní záznamov, keď hľadáme ohraničené množstvo záznamov podľa zadaných parametrov a už dané množstvo je nájdené (napríklad záznam sa môže maximálne 1x nachádzať) a ďalšie prehľadávanie by nemalo zmysel, z toho dôvodu daný cyklus ukončíme.

Adresa trvalaAdresa = null;
for (Adresa adresa : zoznamAdries) {
  if (adresa.getTypAdresa() == TypAdresa.TRVALE_BYDLISKO) {
    trvalaAdresa = adresa;
    break; // dalej nehladame, moze byt len 1
  }
}

Použitie výnimiek a príkaz return

Podobný a rozšírený predchádzajúci prípad je, keď prechádzame viaceré kolekcie a hľadáme špecifický objekt. Po jeho nájdení už nemá význam kontrolovať ďalšie kolekcie a už v momente nájdenia môžeme vrátiť hľadaný objekt.

...
for (Adresa adresa : zoznamAdresySK) {
  if (adresa.getTypAdresa() == TypAdresa.TRVALE_BYDLISKO) {

    return adresa;
  }
}

for (Adresa adresaCR : zoznamAdresyCR) {
  if ((adresa.getTypAdresa() == TypAdresa.TRVALE_BYDLISKO) ||
    (adresa.getTypAdresa() == TypAdresa.PRECHODNE_BYDLISKO)) {
    return adresa;
  }
}
...

Tento spôsob rýchleho ukončenia metódy môže byť aplikovaný na rôzne validačné funkcie, ktoré obsahujú veľa podmienok a pomôžu nám navyše aj sprehľadniť kód.

Aj pri príkaze return platí pravidlo, že pokiaľ použijeme viac týchto príkazov v jednej metóde, dĺžka tejto metódy by nemala presiahnuť jednu obrazovku. Pri skorých return návratoch si treba dať pozor na chybové stavy, ktoré je potrebné ošetrovať výnimkami. Tie využijeme aj v prípadoch keď sú metódy volané v transakciách. Dodatočné úpravy na transakčné spracovanie sú často náročné na úpravy a skrývajú možné zdroje chýb.

Optimalizácia validačných funkcii

Vo funkciách kontrolujúcich biznis logiku a prípustné stavy objektov je mnohokrát pod sebou definovaných viacero podmienok, ktoré sa navzájom vylučujú, ale my ich napriek tomu testujeme všetky naraz. Z analýzy však vieme, že môže nastať vždy len jeden z daných prípadov.

...
if ( (ucet.getTypUctu() == TypUctu.VKLADNA_KNIZKA) && 
  (ucet.getRocnyObrat() > RocnyObrat.VIP_KLIENT) ) {
  ...
}

if ( (osoba.getVek() > 20) && 
  (ucet.getTypUctu() == TypUctu.TERMINOVANY_1ROK) ) {

  ...
}

if ( (ucet.getTypUctu() == TypUctu.OSOBNY) &&
  (ucet.getPocetTransakciiZaPoslednyMesiac() >= Transakcie.VIP_KLIENT) ) {
  ...
}
...

Na prvý pohľad nevinné podmienky môžu skrývať výkonnostné úskalia. Jedná sa o metódy getPocetTransakciiZaPoslednyMesiac() a getRocnyObrat(). Tieto metódy musia svoj výsledok vždy zisťovať z databázy. Prepísaním týchto podmienok na else if zrýchlime vykonanie testovania. Ďalšie zefektívnenie môže predstavovať preusporiadanie celých podmienok. Kritéria na usporiadanie: podmienky vyžadujúce najmenej náročných operácii (najrýchlejšie najskôr) a podmienky, ktoré môžu najčastejšie nastať.

Kód po úpravách:

...
if ( (osoba.getVek() > 20) && 
  (ucet.getTypUctu() == TypUctu.TERMINOVANY_1ROK) ) {
  ...
} else if ( (ucet.getTypUctu() == TypUctu.OSOBNY) &&
    (ucet.getPocetTransakciiZaPoslednyMesiac() >= Transakcie.VIP_KLIENT) ) {
  ...
} else if ( (ucet.getTypUctu() == TypUctu.VKLADNA_KNIZKA) && 

    (ucet.getRocnyObrat() > RocnyObrat.VIP_KLIENT) ) {
  ...
}
...

Skrátené vyhodnocovanie

Rýchlejšie vyhodnocovanie podmienok dosiahneme použitím operátorov skráteného vyhodnocovania „&&“ a „||“, namiesto plného vyhodnocovania „&“ a „|“. Pri skrátenom vyhodnocovaní sa nepokračuje ďalej, ak sa zistí aký bude výsledok logickej operácie. Podmienky sú rozdelené na jednotlivé logické časti a tie sa následne vyhodnocujú.

Plné vyhodnocovanie:

if ( (osoba.getVek() > 20) & (osoba.getVek() < 50) &

  (ucet.getPocetTransakciiZaPoslednyMesiac() >= Transakcie.VIP_KLIENT) ) {
  ...
}

Skrátené vyhodnocovanie:

if ( (osoba.getVek() > 20) && (osoba.getVek() < 50) &&
  (ucet.getPocetTransakciiZaPoslednyMesiac() >= Transakcie.VIP_KLIENT) ) {
  ...
}

V prípade, že vek je menší ako 20 rokov, tak ďalšie podmienky nebudú testované. Ušetrenie testovania druhej podmienky neprinesie až taký výrazný prínos ako netestovanie tretej. V tomto prípade ušetríme náročné zisťovanie počtu transakcii v databáze.

Na vyššie uvedenom príklade vidíme dôležitosť usporiadania jednotlivých časti podmienok. Rovnaké kritéria ako platili pre celé podmienky platia aj pre ich jednotlivé časti. Preto sme najskôr testovali vek a až nakoniec počet transakcii.

Pre úplnosť uvediem, že operácia „&&“ ma vyššiu prioritu (bude skôr vyhodnocovaná) ako „||“. Viac informácii na en.wikipedia.org/wiki/Short-circuit_evaluation

Cachovanie záznamov

Pri opakovanom čítaní rovnakých záznamov alebo číselníkov z databázy, je často krát časovo výhodné tieto záznamy uchovať aj v pamäti pre ich rýchlejší prístup. Existujú rôzne knižnice na prácu s cachovaním objektov a umožňujú širokú paletu nastavení. Jedná sa napríklad o maximálne množstvo objektov, automatické vymazanie objektov po istom čase z cache, alebo ich premiestnenie na disk, synchronizáciu s ďalšími aplikačnými servermi a mnohé ďalšie. Prehľad niektorých open source riešení je možné nájsť na java-source.net/open-source/cache-solutions.

Implementácia cachovania objektov vo viacvláknových aplikáciach alebo viacserverových systémoch, môže byť náročná na implementáciu nakoľko je potrebné zabezpečiť synchronizáciu objektov. Cachovať je vhodné tie záznamy, ktoré sa vôbec nemenia(napríklad číselníky) prípadne menia len minimálne.

Podobne ako pri cachovaní záznamov z databázy, sa cachujú aj pripojenia na externé systémy alebo databázy. Jedná sa o resource-pool alebo connection-pool. Vytvorenie samotného pripojenia je časovo náročné, preto je dobré tieto pripojenia zdieľať v pool-e. Aj tu existujú knižnice na rôzne druhy pool-ov, preto odporúčam nevymýšľať vlastné, ale použiť už overené. Na adrese java-source.net/open-source/connection-pools je možné nájsť prehľad open-source pool-ov.

Design pattern Singleton

Návrhový vzor Singleton sa často využíva v situáciách kedy potrebujeme zabezpečiť len jednu inštanciu danej triedy a tú následne sprístupňujeme ostatným triedam. V praxi Singleton môžeme využiť ako centrálny bod k databázovému prístupu (najčastejšie k poolu), cachovaným číselníkom alebo k súborovým zdrojom (property súborom).

Implementácia a využitie Singletonu vo viacvláknových aplikáciach a viacserverových systémoch je komplikovanejšie a nie je veľmi odporúčané.

Vytvorenie vlastnej cache

Na vytvorenie vlastnej jednoduchej cache môžeme využiť niektorú z implementácii hash-u. Pri použití viacvláknovej aplikácie je potrebné zvoliť synchronizovanú verziu a metódy na pridávanie a mazanie synchronizovať.

Optimalizovanie výstupov

Pri vypisovaní výstupov z našej aplikácie (logovanie) by sme sa mali vyvarovať použitiu metód System.out.println(). Nakoľko sa jedná o priamy neoptimalizovaný výstup, je rozumnejšie využiť bufferovaný výstup. Konkrétne ide o využitie niektorých zo štandardných logovacích knižníc. Poskytujú nielen väčšiu výkonnosť, ale aj možnosť oddeliť výstup debugovania, chybových a informačných hlásení do samostatných výstupných súborov. Jeden z možných logovacích frameworkov je Log4J.

try {
  Logger.getDebugLogger().error("Zaciatok transakcie");
  ...
} catch (SQLException sql) {
  Logger.getSQLLogger().error("Chyba pri vykonani SQL prikazu.", sql);
} catch (NullPointerException np) {
  Logger.getErrorLogger().error("Zly format vystupnych udajov.", np);
} finally {
  ...
  Logger.getDebugLogger().error("Koniec transakcie");
}

Lazy loading

Ďalšou možnosťou na znížené vyťaženia databázy a množstva prenášaných dát okrem cachingu je postupné načítavanie potrebných záznamov podľa potreby (Lazy loading). Tento prístup navyše šetrí pamäť. Ako príklad si môžeme uviesť zobrazovanie údajov objektu Kontrakt, kde načítame len najnutnejšie zobrazované údaje. Neskôr keď užívateľ bude chcieť vidieť detaily kontraktu alebo súborové prílohy, tak až v tomto momente načítame z databázy ďalšie údaje a zobrazíme ich obsah. Súčasné ORM frameworky, ako napríklad Hibernate, podporujú lazy loading.

Optimalizácia JVM a Garbage Collection

Zvýšenie výkonu našej aplikácie môžeme dosiahnuť aj zmenou nastavení garbage collectora. Väčšinou postačuje štandardné nastavenie a až v prípade problémov meníme toto nastavenie. Veľkosť pamäti pridelená JVM je veľkosť, ktorú musí garbage collector spravovať. Nakoľko sa toto prechádzanie, vyhľadávanie a uvoľňovanie voľných objektov deje na pozadí, nemusí byť vždy dostatok času na uvoľnenie týchto objektov. Vtedy collector môže výrazne spomaľovať aplikáciu. Jedná sa napríklad o prípady kedy v krátkom čase vytvárame a zároveň rušíme veľa objektov. Jedná sa napríklad o EJB aplikácie využívajúce intenzívne stateless sessions.

Riešením je nastavenie inej stratégie garbage collectora, kde si rozdelí pamäť na časti pre mladé a staršie objekty. Časť pre mladšie objekty prechádza častejšie ako časť pre staršie. Viac informácii o nastaveniach garbage collectora je možné nájsť tu java.sun.com/docs/hotspot/gc5.0/gc_tuning_5.html alebo pre IBM javu www.ibm.com/developerworks/java/library/j-ibmjava2/.

18 Příspěvků v diskuzi

  1. Priklad s continue je hodne nestastny, lepsi (=citelnejsi) reseni by bylo pouziti else vetve a pripadny dalsi refactoring (nejspise vyjmuti lese vetve do samostatne privatni metody). Continue lze snadno prehlednout a travit pak ctenim kodu zbytecne mnoho casu.

    A spise obecnejsi poznamka: mikrooptimalizace jsou trojsecny mec. Vazne s nimi opatrne.

  2. Jak vyskočit ze dvou for-ů, moc lidí to nezná:
    nejakeJmeno:{
    for(Stat stat : Zemekoule){
    for(Clovek clovek : stat){
    if(clovek.neco==necemu){
    break nejakeJmeno;
    }
    }
    }
    }

  3. Zrovna to zkrácené vyhodnocení se nedávno řešilo v java konferenci a není to tak jasné. Pokud není použito zkrácené vyhodnocení, může JVM provádět oba výrazy současně, takže pokud se ty dva výrazy neovlivňují, tak na víceprocesorových systémech bude většinou rychlejší plné vyhodnocení. Samozřejmě pokud některý z výrazů čte z databáze tak je situace jiná. Spíš je dobré se zamyslet, jestli není často potřeba vyhodnocovat oba výrazy a pak je nejspíš rychlejší varianta s plným vyhodnocením.

  4. hrp: vies k tomu dat nejaky link? by som sa na to paralene spracovanie neskratene vyhodnocovanie pozrel blizsie
    vdaka

  5. Marek: to „breakovanie“ cyklov pomocou labelov je uzitocne pri viacerych (2 a viac) vnorenych cykloch
    prave vcera som videl triedu kde boli tieto breaky dost vyuzivane, avsak v metode ktora bola cca 700 riadkov dlha
    to uz bolo zmatocne a neprehladne

  6. Peter: to jsem uvedl kvůli tomu, že Java nemá příkaz goto (ale v .class souborech je dostupné), protože goto je programátorsky „špatné“, jediné ospravedlnitelné použití goto je právě vyskočení z vnořených cyklů[1]. Jinak by se muselo vyskakovat pomocí nastaveného boolean návěští, které by muselo být testované při vyskočení z každého vnořeného cyklu. Zdroj[1]: říkal to Doc. Ing. Miroslav Virius, CSc., určitě to bude v nějaké jeho knize o programování nebo skriptech.

  7. > Continue lze snadno prehlednout.
    To se mi jeste nestalo a neverim ze je to vubec mozny. Staci pouzivat syntax highlighting psat kratky metody.

    > Pokud není použito zkrácené vyhodnocení, může JVM provádět oba výrazy současně.
    To neni az tak pravda. Nesmi se ovlivnovat a Java to musi poznat, coz vubec neni trivialni. Rozdeleni na vice jader se musi vyplatit coz neni jen tak. Vyznam to ma jen kdyz se neda jinak nic paralelne delat coz u servru nenastava. Prvni cast muze hodit vyjimku a v tom pripade se druhy afaik nesmi provest.

  8. htp: Z té diskuse snad ale jasně vyplynulo, že tohle je přesně typ optimalizace, kterou prakticky nikdy (v Javě vůbec nikdy) nemá dělat programátor. Pokud je to paralelní zpracování opravdu tak výhodné, má tu optimalizaci udělat kompilátor nebo běhové prostředí. Pokud je ten kód opravdu takové výkonnostní úzké hrdlo, aby s ejím zabýval programátor, nemá stejně smysl takovouhle optimalizaci psát v Javě (protože netušíte, co s tím udělá kompilátor, JVM a procesor), ale napíšete to v asembleru pro konkrétní procesor – protože na jiném procesoru už se to může chovat úplně jinak a „optimalizace“ kód naopak zpomalí.

  9. Logovani je take potreba optimalizovat. Tedy pouze debug a trace logy, pri kterych dochazi k volani funkci a skladani retezcu, by mely byt uzavreny v podmince

    if (LOG.isTraceEnabled()) {
    LOG.trace(„Pouziva se: “ + obj1 + “ misto “ + obj2);
    }

  10. Ondřej Medek: lepší než takhle skládat řetězce a testovat úroveň logování je použít nějakou moderní logovací knihovnu, která to udělá za vás – třeba SLF4J + LogBack.

  11. to Marian: „pekny clanok na studium zakladov. vdaka“ – ano, je to pěkná kompilace. Některé věty jsou jen přeložené.

  12. to Ondrej Medek:

    pozri si telo metody trace, tvoj priklad ne neoptimalny, priamo v trace sa testuje level a v pripade, ze by si mal trace logovanie, tak by sa vykonal Tvoj if a to iste v metode trace druhy krat:

    public void trace(Object message) {
    if (repository.isDisabled(Level.TRACE_INT)) {
    return;
    }

    if (Level.TRACE.isGreaterOrEqual(this.getEffectiveLevel())) {
    forcedLog(FQCN, Level.TRACE, message, null);
    }
    }

  13. ad Logovani:
    Jde o to, zamezit tomu skladani retezcu:

    LOG.trace(„Pouziva se: “ + obj1 + “ misto “ + obj2);

    sklada vola metody obj1.toString() a obj2.toString() a pak slozi retezec dohromady. Za obj.toString() se muze skryvat narocna operace. Pokud je level TRACE disabled, pak je to vse zbytecne.

    Pisu, ze volani
    if (LOG.isTraceEnabled())
    je dobre jen pro TRACe a DEBUG level, pro vyssi to nema moc smysl, protoze se predpoklada, ze WARN a ERROR logovani se provadi jen zridka a vetsinou je zapnute.

    BTW. Jedna se o doporuceni od JBossu.

    Filip Jirsák: ano nektere logovaci knihovny umi treba
    LOG.trace(„Pouziva se: {0} misto {1}“, obj1, obj2);
    takze se nemusi psat ona podminka. Ale nemam s tim dobre zkusenosti, protoze se muze udelat chyba ve formatovacim retezci a pak program spadne na chybu pri logovani. A to zvysuje naroky na testovani. (Coz je mozna spise chyba slf4j, ze takovou chybu by mela zachytit a jen zalogovat).

Odpovědět