Java 9 on kymmenen kertaa nopeampaa!

Törmäsin mielenkiintoiseen ilmiöön taannoin. Valmistelin esitystä suorituskyvystä ja Java muistimallista, ja ajoin eräänlaista benchmark sovellusta Dockerin avulla eri virtuaalikoneissa. Sovelluksen ideana on sisältää aika typerästi kirjoitettua mutta varsin tavanomaista Java-koodia. Silmukoita, ehtolauseita, merkkijonojen käsittelyä, laskentaa, kokoelmien käsittelyä, ja kellottaa paljonko kaikkeen menee aikaa. Tällä voidaan myös nähdä nykyaikaisen virtuaalikoneen itseään optimoiva vaikutus – kun ajo jatkuu, nopeus yleensä kasvaa. Tässä on sovelluksen mittaustuloksia Oracle Java 6 Docker kontissa ajettuna:

Screenshot 2016-10-16 13.19.54.png

Eli, yhden kierroksen aika on n. 84 sekuntia, laskien 82 sekuntiin jahka virtuaalikone vähän ”lämpenee” eli käytännössä jit-kääntää enemmän koodia ja tekee muita tarvittavia optimointeja.

 

Tässä on OpenJDK+Java 8 ajotulokset:

Screenshot 2016-10-16 13.23.31.png

Kuten tuloksista näkyy, uudempi virtuaalikone optimoi usein tehokkaammin. Tässä kierrosajat pyörivät n. 62 sekunnin pinnassa – Java 6 verraten on irronnut noin 20 sekuntia, tai 1/4 suoritusajasta. Paras kierrosaika oli jopa 52 sekuntia. Aika hyvä tulos!

Kokeillaanpa G1 roskankeruualgoritmilla:

Screenshot 2016-10-16 13.35.12.png

Oops! Vaikka G1 on teoriassa kauneinta ja uusinta mitä roskankeruualgoritmeihin tulee, se ei ole joka ongelmaan optimaalinen ratkaisu. Suoritusajat vaihtelevat 98 ja 104 sekunnin välillä ja ovat nousemaan päin. Tällä sovelluksella ja tämän koneen muistilla tässä tuli takapakkia, huonompi suorituskyky. Varmaan tästä syystä G1 ei ole vielä oletusalgoritmina vaan pitää erikseen kytkeä päälle lisäparametrilla -XX:+UseG1GC.

Java 9 julkaistaan vasta pitkällä ensi vuonna. Siitä on kuitenkin jo prereleaseja liikkeellä, ja jopa Docker virtuaalikuva. Tämän ansiosta on lasten leikkiä ajaa sama koodi Java 9:llä. Tulokset tässä:

Screenshot 2016-10-16 13.36.26.png

WUT? Sama koodi, tekee saman asian, sama koneympäristö ja resurssit. Suoritusajat vaihtelevat 9 ja 12 sekunnin  välillä. Karkeasti ottaen noin kymmenen kertaa nopeampaa kuin useimmat muut testiajot, ja yli viisi kertaa nopeampaa kuin paras tulos tähän asti.

Jotain on muuttunut. Mitä, en tiedä vielä. Epäilen että yksi tekijä voi olla Jigsaw moduulimallit. Toinen tekijä lienee, että on taas opittu tunnistamaan joku negatiivinen koodaustapa, ja optimoimaan sen suoritus. Tulokset tuskin ovat yleispäteviä, ne pätevät lähinnä tähän tyypilliseen koodiesimerkkiin mitä käytin, ja tähän ympäristöön. Docker välissä voi myös vaikuttaa jotain, tuskin kuitenkaan paljoa. Niin tai näin, koodi otti taas kerran hurjan tehokkuushypyn. Tätä herkkua olisi luvassa ensi vuonna.

Virtuaalikoneiden ihanuus on siinä, että nautit kaikista edistysaskelista, ilman koodimuutoksiakin. IBM nimesi juuri oman open source JDK 9 versionsa JIT-kääntäjän Testarossaksi, joten veikkaisin että sieltä on myös suurta hyvyyttä tulossa.

p.s. Docker on ihana keksintö!

p.p.s. Niin on Cathode terminaalikin :p

 

Mainokset

Java, optimointi, ja muistivuodot

Hiljattain tuli konsultoinnin yhteydessä pohdiskeltua tarkemmin miten huonosti käyttäytyviä Java-sovelluksia tuunataan. Javassa alustanahan on pari erityispiirrettä jonka vuoksi se toimii eri tavalla kuin esim. C++.

– Koska Java on dynaaminen alusta, sen kokonaisympäristö selviää vasta ajon aikana, ja voi mahdollisesti poiketa kehitysympäristöstä. Tarkoittaa että esim. log4j kirjastosta voi olla eri tavoin toimiva kirjasto ajon aikana. Kenties näin ei pitäisi olla mutta näin useinkin on.

– Tehokkuudesta vastaa pääosin virtuaalikone, eli ajoympäristö, ja se onkin uskomattoman hyvä optimoimaan. Java koodi benchmarkattuna C++ koodia vastaan on viime aikoina useasti jopa rökittänyt kilpailijansa suoritusnopeudessa. Mutta virtuaalikone tekee parhaiten työntä koodille joka on selkeää ja kikkailematonta, vähän kuin rautalangasta väännettyä. Yksi pahimpia hölmöilyjä mitä voi tehdä on lisätä koodiinsa kutsu System.gc() sinne sun tänne. Virtuaalikoneen idea on nimenomaan skaalata sovellus ympäristön mukaan aina parhaiten optimoiduksi. Mitä enemmän koodi pyrkii pakottamaan ja rajoittamaan sitä huonommin automaattioptimointi voi toimia.

– Kooditasolla ei ole mitään erityisiä optimointi-avainsanoja kuten esim. inline.. Koodin optimoinnilla on muutenkin hyvin vähän merkitystä sujuvuuteen – kunhan siellä ei ole hölmöilty. Pääosa koodin optimointia on siis hölmöilyjen purkamista.

– Automaattinen roskankeruu on tunnettu tehosyöppö, edellämainityt hölmöilyt ovat usein koodia joka aiheuttaa tarpeetonta roskankeruuta. Tarpeeton roskankeruu syö prosessoritehoa ja pysäyttää suorittavat säikeet, eli mitä enemmän roskankeruuta tehdään sitä enemmän varastetaan kellosyklejä suorittavilta osilta. Näin ollen roskankeruun minimoiminen on hyvä lähtökohta suorituskyvyn parantamiselle. Mutta huom. tarpeettoman roskankeruun.

Suorituskykyä parannetaan siis roskankeruuta vähentämällä – tarpeetonta sellaista. Miten tämä tapahtuu? Pidentämällä olioiden elinkaarta ja lisäämällä uudelleenkäyttöä, välttämällä kaikkea turhaa olioiden luontia ja tuhoamista.  Tähän on monia keinoja: staattiset muuttujat ja alustusblokit, objektipoolit, cachet, valmiiksi lasketut tulokset, jne.

Muistivuotoja ei pitäisi Javassa periaatteessa olla – automaattisen roskankeruun ansiosta. Javassa kun ohjelmoija ei itse varaa muistia eikä vapauta muistia vaan nämä piirteet on abstraktoitu virtuaalikoneen tehtäviksi. Mutta virtuaalikoneessakin voi olla bugeja, ja missä hyvänsä JNI palikoissa voi olla ja todella usein onkin muistivuoto-ongelmia. Ja jos ohjelma yksinkertaisesti varaa olioita muistiin jatkuvasti eikä milloinkaan päästä niistä irti niin eipä roskankeruukaan voi toimia ja toki siinä muisti aikanaan sitten loppuu. Jos haluat saada java-sovelluksen kaatumaan muistiongelmaan et tarvi tätä enempää:

        List<String> list = new ArrayList();
        while(true)
            list.add("HELLOWORLD" + list.toString());

Tässä on IBM:n Developerworks-sivuilta hienostuneempi esimerkki joka esittää paria vivahdetta:

import java.io.IOException;
import java.util.HashSet;
import java.util.Random;
import java.util.Vector;

public class LeakExample {
	static Vector myVector = new Vector();
	static HashSet pendingRequests = new HashSet();

	public void slowlyLeakingVector(int iter, int count) {
		for (int i=0; i<iter; i++) {
			for (int n=0; n<count; n++) {
				myVector.add(Integer.toString(n+i));
			}
			for (int n=count-1; n>0; n--) {
				// Oops, it should be n>=0
				myVector.removeElementAt(n);
			}
		}
	}

	public void leakingRequestLog(int iter) {
		Random requestQueue = new Random();
		for (int i=0; i<iter; i++) {
			int newRequest = requestQueue.nextInt();
			pendingRequests.add(new Integer(newRequest));
			// processed request, but forgot to remove it
			// from pending requests
		}
	}

	public void noLeak(int size) {
		HashSet tmpStore = new HashSet();
		for (int i=0; i<size; ++i) {
			String leakingUnit = new String("Object: " + i);
			tmpStore.add(leakingUnit);
		}
		// Though highest memory allocation happens in this
		// function, but all these objects get garbage
		// collected at the end of this method, so no leak.
	}

	public static void main(String[] args) throws IOException {
		LeakExample javaLeaks = new LeakExample();
		for (int i=0; true; i++) {
			try { // sleep to slow down leaking process
				Thread.sleep(1000);
			} catch (InterruptedException e) { /* do nothing */ }
			System.out.println("Iteration: " + i);
			javaLeaks.slowlyLeakingVector(1000,10);
			javaLeaks.leakingRequestLog(5000);
			javaLeaks.noLeak(100000);
		}
	}
}

Miten tällaisia roskankeruuseen tai muistivuotoon liittyviä ongelmia sitten voi jahdata? Aika monellakin tapaa, Java tuo mukanaan jo useita työkaluja joiden avulla pääsee näkemään tarkemmin mitä virtuaalikoneen sisällä tapahtuu. Näistä on monesta kursseillakin asiaa, mutta viime aikoina olen perehtynyt enemmän visualvm työkaluun joka on jdk 6  uudemmissa versioissa sekä jdk 7:ssa vakiovarusteena. Sen saa ladatuksi myös erillisenä verkosta. VisualVM sisältää mahdollisuuden profiloida prosessorin käyttöä tai muistin käyttöä, ja sillä voi ottaa snapshotteja joita voi analysoida kaikessa rauhassa myöhemmin. Näin voi kiinnittää huomiota pisteisiin jossa käytetään paljon aikaa, tai jossa luodaan paljon olioita.

Miten em. muistivuoto sitten Javasta löytyy? Helposti. Otetaan snapshot tilanteesta, ja toinen snapshot jonkin aikaa myöhemmin. Muistivuodon tunnusmerkki ei ole että olio varaa paljon muistia. Muistivuodon tunnusmerkki on kasvu, josta kertoo kahden snapshotin delta, ero. Ja visualvm pystyy ottamaan kaksi snapshottia ja vertailemaan niitä, jolloin voidaan pistää oliot kasvun mukaan järjestykseen. Kovasti kasvava olio ei ole välttämättä signaali muistivuodosta, voi olla että se on vain kovasti kasvava olio 😉 Mutta jos ohjelmassa on muistivuoto, näiden joukosta se löytyy. Ellei se ole esim. virtuaalikoneen tai JNI moduulin bugi – koska nämä ovat java heapin ulkopuolella toimivia, ei niihin javan sisäiset välineet pure.

Yksi muistivuodoksi nimetty ongelma on se kun vastaanottaa ihastuttavan OutOfPermGenSpace -poikkeuksen. Tämä ei ole tosiasiassa varsinainen muistivuoto vaan ilmiö joka on joissain versioissa ilmentynyt mm. Tomcat ja JBOSS palvelimilla. Se aiheutuu kun serverille tehdään hot deploy operaatioita uudelleen ja uudelleen. Riippuen hot deploy toteutuksesta, java asentaessaan softan uuden version saattaa päätyä käyttämään enemmän ja enemmän permgen-aluetta kunnes se loppuu kesken. Tähän voi reagoida joko lisäämällä permgen alueen viipaletta (joka lykkää ongelman esiintymistä vähän pidemmälle), tai buuttaamalla serverin ihan oikeasti aika ajoin, esim. aina neljän hot deployn jälkeen, tai tarkistamalla että oma serveri tukee hot deployta ilman näitä oireita.

Yksi itseä kiehtova asia on rinnakkaisuuden jatkuva lisääntyminen. Omassa Xoom tabletissani on pari prosessoriydintä, samoin puhelimessani. Pöytäkoneissa on jo 2, 4, 8, ja pian 64 ydintä. Lisäboostia saa tekemällä asiat rinnakkain, silloin kun se on mahdollista, ja tähän tarvitaan tietysti osaamista jo suunnittelun alusta alkaen aina toteutustekniikoihin asti. Miten rinnakkaisessa maailmassa hallitaan jaettuja resursseja? Kiinnostavat projektit kuten Akka ja Scala vastaavat että Actor frameworkeillä ja postilaatikoilla. Java sanoo että synkronoinnilla ja lukituksilla. NIO tarjoaa sokettien osalta mukavan selector mallin jossa säikeitä tarvitaan vain kaksi, ja asiakkaita voi silti olla vaikka tuhat. Tässä ollaan vielä lastenkengissä, ja 2010 luku on rinnakkaisuuden vuosiluku, sanokaa minun sanoneen.

Tämä kirjoitelma syntyi Tehokas Java-kurssin tiimoilta, eli jos suorituskyvyn säätö Java-alustalla herättää kiinnostusta ja tässä artikkelissa tuli jotain uutta, niin kannattaa kurkata http://www.tieturi.fi/java alta ko kurssi esille.