neděle 31. prosince 2006

Java 6 – co vězí za zrychlením

O tom, že je Java 6, co do výkonu, opět dále, nemůže být pochyb, to dokazují například testy Davida Dagastine. Mě osobně trochu výkonnost Javy 6 nedala spát, takže jsem se jal hledat informace, co za tím vším je. Posledních několik dní jsem tak strávil, krom studia potřebné teorie, měřením výkonu nových synchronizačních technik JVM.

Type Checking Verifier

Začnu tím, co jsem nijak netestoval, nicméně vliv na výkon to má zcela určitě. Každá JVM má v sobě verifikátor byte kódu, který dělá dvě základní věci, typovou kontrolu a odvození typu. Proč odvození typu? Byte kód samozřejmě obsahuje typové informace (parametry metod, návratové typy), ale neobsahuje je pro lokální proměnné metod. Verifikátor je tak zodpovědný za jejich odvození.

Právě odvození typů je úkol, který je pomalý, paměťově náročný a celkem komplikovaný. Od Javy 6.0 byl verifikátor upraven tak, že odvození typů se přesunulo z odpovědnosti JVM na kompilátor. Děje se tedy ve chvíli kompilace do byte kódu. Díky tomuto rozdělení došlo k dvojnásobnému zrychlení práce verifikátoru.

Nový verifikátor, Type Checking Verifier, může samozřejmě běžet ve původním módu s odvozením typů, pokud byte kód neobsahuje potřebné typové informace a nebo tyto informace nejsou korektní. Pokud tedy testujete svůj kód pod Javou 6, nezapomeňte jej také zkompilovat kompilátorem z Javy 6, jinak se vliv nového verifikátoru neprojeví.

Zdroje

Optimalizace v oblasti synchronizace - teorie

Velký kus práce byl ovšem udělán na HotSpotu resp. JITu (kompilátor do nativního kódu). To se projevuje především v oblasti synchronizovaného přístupu. Synchonizovaným přístupem je myšleno použití klíčového slova Javy synchronized. Ve spojení s objektem, přes který se synchronizuje, dochází k takzvanému vzájemnému vyloučení (mutual exclusion). Nad kritickou sekcí, ohraničenou blokem synchronized a daným objektem, operuje vždy pouze a jedině jedno vlákno.

Objekt (Monitor), nad kterým se synchronizuje, obsahuje takzvaný zámek. Z hlediska JVM spadají zámky do dvou kategorií contended a uncontended (přeložit by se to dalo jako soupeřivé a nesoupeřivé, ale zůstanu u anglické terminologie).

contended
Jedná se o zámky, u kterých velice často dochází k souběhu vláken a k jejich "soupeření". Těmto zámkům lze také říkat horké. V aplikacích jsou to typicky zámky nad částmi kódu, které jsou hojně souběžně přistupované. Typicky například pool databázových připojení.
uncontended
Jedná se o zámky, na kterých nedochází k souběhu takřka vůbec. Tedy nedochází k tomu, že při pokusu o získání zámku, drží zámek jiné vlákno.

Valná většina zámků je podle dostupných informací uncontended. JVM má dvě rozdílné metody, pro získání contended a uncontended zámků. Pro získání contended zámku používá JVM takzvaný "fast path" způsob a pro získání uncontended zámku takzvaný "slow path" způsob. Rozdíl mezi "fast path" a "slow path" je v tom, že při "fast path" stačí použít dvě instrukce pro získání nebo uvolnění zámku (jednoduchý příznak owner, který nabývá hodnot null nebo not null). Oproti tomu "slow path" je mnohem složitější, protože se musí postarat o obsluhu (uspání/vzbuzení) vláken.

Jak jsem si řekli, většina zámků je uncontended, proto je důležitá rychlost "fast path" způsobu. "Fast path" sice používá jednu nebo dvě instrukce, ovšem instrukce atomické vzhledem k paměti, díky tomu dochází k CAS zpoždění (CAS latency). Proto jakékoliv zrychlení v oblasti uncontended zámků se počítá.

Java 6 má tři nové způsoby jak urychlit práci s uncontended zámky a tím pádem zrychlit běh většiny programů. Protože jak si ukážeme dále, uncontended zámků máte v programu mnoho a ani o tom možná nevíte.

Odstranění zámků (Lock elision)

V jednoduchosti je síla, mnoho uncontended zámků totiž ani nemusí být nutných, protože se synchronizovaný objekt nedostane mimo viditelnost daného vlákna (thread stack). Řekněme, že máme následující kód.

     
public String doSomething() {
    Object lock = new Object();
    synchronized(lock){
      //do something
      return result;           
    }
}    
    
    

Blok je sice synchronizovaný, ale synchronizace se děje přes objekt, který má každé vlákno vlastní. Navíc jsou všechny proměnné lokální a proto je synchronizace zbytečná, vlákna se nemohou vzájemně ovlivnit. Pokud se ptáte, proč by někdo takovouto zbytečnou synchronizaci používal, tak odpovím reálným kódem.

     
public String doSomething() {
    StringBuffer s = new StringBuffer();
    s.append("foo");
    s.append("hoo");
    s.append("bar");
    return s.toString();
}    
    
    

Tento kód je z hlediska synchronizace ekvivalentem předchozího kódu. Hodně core Java tříd je thread safe, tedy obsahují nutnou synchronizaci, aby bylo jejich použití ve vícevláknovém prostředí bezpečné. Právě synchronizace StringBufferu je v tomto případě zbytečnou režií, která nemá v tomto konkrétním případě smysl. Opět zopakuji, objekt s bude mít každé vlákno vlastní, čili vzájemně se nemohou ovlivnit.

JVM od verze 6 umí detekovat, že reference na objekt s nikdy neopustí metodu doSomething. Tím pádem může JVM za běhu zbytečnou synchronizaci odstranit. K tomu, aby JVM rozhodla jestli může synchronizaci odstranit, dělá analýzu útěku. Tenhle termín jsem si nevymyslel, protože jsem asi tři dny v kuse sledoval Prison break, ale protože v originále se to nazývá Escape analysis.

Analýza útěku umožňuje JVM nejenom odstranit zbytečnou synchronizaci, ale i úžasnou optimalizaci v podobě stack alokace objektů. Alokace objektů na heapu je totiž mnohem pomalejší než alokace přímo na stacku. Java všechny objekty alokuje na heapu, pouze primitivní datové typy a reference na objekty leží na stacku.

Díky analýze útěku může JVM optimalizovat vytváření objektů, které neopustí lokální rozsah, že je bude alokovat na stacku. Záměrně používám slovo může, i když Java umí analýzu útěku a umí jí využít pro eliminaci zbytečných synchronizací, stack alokací bohužel zatím neumí.

Lock coarsening

Lock coarsening by se dal volně přeložit jako zhrubnutí zámku. Jde o snížení režie na zamykání a odemykání v případě, že je možné dané části uzavřít do jednoho zámku. Pro ilustraci si pomůžeme lehkou modifikací předchozího příkladu.


public void doSomething(StringBuffer s) {
    s.append("foo");
    s.append("hoo");
    s.append("bar");
}    
    

Každé volání metody append způsobí zamčení a odemčení zámku na objektu s. JVM může kód optimalizovat tak, že nebude zamykat a odemykat pro každé volání metody append, ale uvnitř metody doSomething udělá jeden větší zámek přes všechny tři volání. Tím pádem se sníží náklady na synchronizační režii.

Lock coarsening může mít jeden vedlejší efekt. Sice se sníží synchronizační režie, ale může se zvýšit sekvenční část programu a tím pádem se zmenší část, kterou je možné vykonávat paralelně, pokud se z uncontended zámku stane contended zámek. To může mít neblahý vliv na paralelismus viz Amdahluv zákon (sekce paralelizace).

Lock biasing

Lock biasing je technika, která opět využívá faktu, že zámek je většinou držen pouze jedním vláknem. Lock biasing je v podstatě rezervování zámku. Když vlákno poprvé obdrží zámek, dojde k nastavení speciálního flagu. Při další žádosti o zámek, se nejdříve zkontroluje hodnota flagu společně s identifikátorem daného vláknu a pokud je thread vlastníkem, flag se nastaví. Tím je zámek aktivní.

Tomuto způsobu zamykání se také říká "ultra fast path", protože eliminuje CAS latency "fast path" způsobu. Lock biasing je v Jave 6 stndardně zapnutý, v Jave 5 (od verze 5.0_06) lze toto chování aktivovat parametrem -XX:+UseBiasedLocking. Vliv biased locku se projeví především na víceprocesorových systémech, kde by se jinak musela použít lock verze CAS instrukce, která by způsobila serializaci zpracování.

Reálný dopad na výkon

Pro otestování těchto vlastností jsem si napsal mikro benchmark, kterým jsem ověřil dopad odstranění zámků (Lock elision) na výkon. No nebudu vás napínat, dopadlo to přesně podle teorie. Díky runtime odstranění zámků byl výsledek stejný jako v případě, že tam zámky (synchornizace) nebyly. Pokud se o výsledky zajímáte, můžete pokračovat na detailní popis testu a naměřené hodnoty mikro benchmarku.

Zdroje