Snabba upp Javakoden

JMH

Relativt vanligt förekommande är att optimering av kod för maximal prestanda, minimal resursåtgång etc hamnar i skymundan. Flertalet applikationer/Microservices har helt enkelt inget behov av att prestera på absolut max. Så länge rimliga svarstider upprätthålls är användarna generellt nöjda. I vissa lägen är det dock kritiskt att den lösning som tagits fram verkligen presterar på topp. Tänk er till exempel vid realtidberäkningar av sensordata från ett föremål i rörelse, eller i en svärm av Microservices som utbyter tidskritiskinformation rörande aktuella börskurser.

Nåväl, i det fall man de facto har ett prestandaproblem på halsen så gäller det att hålla tungan rätt i mun och angripa saken metodiskt. Det gäller att utesluta störande omgivningsfaktorer, databas-kopplingar, antivirusprogram osv. Om man till sist landar i att det sannolikt rör sig om en mindre lyckad implementation i Javakoden, vad gör man då?

Det är här Java Microbenchmark Harness (JMH) kommer till undsättning! Ett Open Source verktyg som Oracle valt att lägga fritt tillgängligt via OpenJDK.

Komma igång

Det rekommenderade sättet för att komma igång är att använda Maven, men det finns givetvis stöd för andra byggverktyg såsom Gradle. Antingen skapar man ett helt nytt Maven projekt eller så lägger man till beroendena till JMH i sitt existerande projekt.

Nedan följer det kommando du använder i din favoritterminal (t.ex. Git Bash) för att skapa ett helt nytt Benchmark-projekt.

mvn archetype:generate \
    -DinteractiveMode=false \
    -DarchetypeGroupId=org.openjdk.jmh \
    -DarchetypeArtifactId=jmh-java-benchmark-archetype \
    -DgroupId=se.exertusit.demo \
    -DartifactId=jmh-example \
    -Dversion=1.0

Notera att det även finns stöd för Groovy, Kotlin samt Scala archetypes för den som kör något av de språken på JVMen. Mer utförlig dokumentation kring uppsättning och mycket mer finns på JMHs hemsida.

Köra tester

Efter att ha skapat projektet med kommandot ovan finns en komplett struktur och en MyBenchmark-klass i src/main/java/se/exertusit/demo. Klassen är till en början bara ett skal och det går bra att ha flera Benchmark-klasser om man vill.

Förutsatt att ni fortfarande har er terminal öppen så gör nu följande:

cd jmh-example
mvn clean install

I klassiskt Maven-manéer så spottas en mängd intressant bygginformation ut innan den slutligen avslutar med “BUILD SUCCESS”.

För att köra testerna (som just nu bara är ett och inte gör något) används kommandot:

java -jar target/benchmarks.jar

Anledningen till att köra testerna via terminalen istället för via en IDE är för att minimera utomstående påverkan på testexekveringen. Allra helst körs dem på en isolerad nod då många av oss minns Linus Thorvalds episka demo när han skulle visa hur bra det gick att kompilera om Linuxkärnan samtidigt som man spelade Quake. (Spoiler: Det gick sisådär.)

Skriva tester

JMH är helt annotationsstyrt. Out-of-the-box ser vår testklass ut såhär:

package se.exertusit.jmh;

import org.openjdk.jmh.annotations.Benchmark;

public class MyBenchmark {
    @Benchmark
    public void testMethod() {
        // This is a demo/sample template for building your JMH benchmarks. Edit as needed.
        // Put your benchmark code here.
    }
}

Inte mycket spännande som händer här alltså. Värt att notera är @Benchmark-annotationen som berättar för JMH att testMethod() är en metod som ska exekveras när vi kör benchmarking. Exakt samma koncept som @Test för den som jobbat med JUnit.

Några bra att veta JMH parametrar, inklusive dess default-värden:

Parameter Värde Kommentar
Forks 10 Antalet separata exekveringsmiljöer som JMH ska köra igång.
Warmup cycles 20 Cykler som ej mäts, ger JVMen en chans att optimera koden.
Measurement 20 Antalet mätiterationer för varje test-metod.
BenchmarkMode Mode.Throughput Vad som ska mätas, throughput innebär antal exekveringar/sekund.


För att överrida dessa värden lägger man bara till motsvarande annotationer med nya värden:

@Fork(1)
@Warmup(iterations = 2)
@Measurement(iterations = 2)
@BenchmarkMode(Mode.AverageTime)

Uppgiften

Allt klart så här långt hoppas vi. Låt oss nu göra det hela lite roligare genom ett konkret fall!

Alice och Bob har fått i uppgift att ur en serie av nummer (0 - 100 000 000) sortera ut alla tal som är jämnt delbara med ett givet tal. För varje tal som matchar kriteriet ska detta sedan delas med ett annat fördefinierat tal och läggas till en totalsumma. Funktionen ska ta så lite tid som möjligt då Eva på redovisning behöver veta resultatet omedelbart vid varje körning.

Koden

Alice och Bob kan inte enas om huruvida en klassisk for-loop eller en het lambda-stream ger bäst prestanda för att lösa uppgiften. Eftersom Eva efterfrågade omedelbart resultat så har de ställt in Benchmarken på AverageTime, d.v.s. snitt-tiden det tar för funktionen att exekveras (lägst siffra vinner).

Kodsnuttarna ser ut som följer:

@Benchmark
public void performanceWithForLoop(Blackhole blackhole) {
    int sum = 0;
    for (int value : range) {
        if ((value % moduloNum) == 0) {
            sum += value/divNum;
        }
    }
     blackhole.consume(sum);
}

@Benchmark
public void performanceWithStream(Blackhole blackhole) {
    int sum = range.stream().filter(number -> (number % moduloNum)==0)
      .mapToInt(i -> i = i/divNum)
      .sum();
    blackhole.consume(sum);
}

Blackhole är en klass som ingår i JMH och automatiskt skickas in i metoden om den är angiven som en parameter. Syftet med Blackhole är att säkra att vi faktiskt läser det värde som räknas fram. Det är av yttersta vikt att värdet antingen läses eller returneras då JVMen annars mycket väl kan anse koden som “död” och helt sonika optimera bort den i kompileringen.

Resultatet

Vem blir då vinnaren? Alice med sin for-loop eller Bob med sitt lambda-uttryck?

Flertalet testkörningar påvisar alla samma sak; en knapp seger för den klassiska for-loopen. Inte så konstigt egentligen då lambda-uttryck är en efterhandskonstruktion som bygger vidare på tidigare funktioner i Java. Man kan givetvis tänka sig att situationen ändras om man skruvar på olika parametrar, t.ex. använder flera cores (parellelStream), en annan nummerserie, eller om uppgiften får ytterligare krav som gör att nästlade loopar/filtreringar krävs etc.

Idag koras hur som helst Alice till vinnaren och hon skiner av lycka.

Resultatet från en av många körningar:

Benchmark                               Mode  Cnt  Score   Error  Units
LambdaBenchmark.performanceWithForLoop  avgt    2  0,288           s/op
LambdaBenchmark.performanceWithStream   avgt    2  0,290           s/op

Avslutande ord

Hade detta varit faktiskt produktionskod hade logiken legat gömd bakom ett interface och haft två olika implementationer. Vi har här förenklat det hela för att underlätta läsbarheten. Komplett källkod för exemplet finns att tillgå på Gitlab.

Som kuriosa kan även nämnas att Oracle själva använde JMH när de optimerade funktioner i Java 9.

Avslutningsvis vill vi påminna om att det sällan är värt att lägga allt krut på optimering i början, men i det fall ni faktiskt har prestandaproblem och har lyckats ringa in problemrymden till koden så är JMH ett ypperligt verktyg för att säkra att er nya implementation faktiskt löser uppgiften snabbare.

Om optimeringar är något som intresserar dig så kontakta oss via formuläret nedan!

Vi har mottagit ditt meddelande och återkommer inom kort.
Hoppsan! Något gick dessvärre fel, vänligen verifiera att du inte är en robot eller ladda om sidan och försök igen.
Vi vill göra dig uppmärksam på att vi behandlar dina uppgifter i strikt enlighet med vår Integritetspolicy. Allt för att du ska känna dig trygg i att vi värnar om din integritet.