Waarom je Maven build te traag is en hoe je dat oplost
Ken je die XKCD-strip? Die waarin twee programmeurs tijdens het bouwen van hun project een zwaardgevecht houden omdat de build toch nog wel even duurt? Ik zie bij elke presentatie over dit onderwerp de handen omhooggaan. Ja, we herkennen het allemaal. Die frustratie van wachten terwijl je compiler zijn werk doet. Die koffiepauze die eigenlijk niet nodig was. Die 20 minuten per dag die je kwijt bent aan builds. Het is niet alleen irritant, het kost ook gewoon geld.
Waarom dit ertoe doet
Het bedrijf Gradle onderzocht dit en kwam met een harde conclusie: elk team dat investeert in efficiënte builds ziet een significante “return on investment”. Oftewel: dat verdient zich dik terug. De meest succesvolle softwareteams ter wereld hebben één ding gemeen: een efficiënte build-infrastructuur.
Toch hoor ik vaak “We hebben geen tijd om aan de build te werken.” Of “De product owner wil dat we features bouwen.” Maar wacht eens. Als je ontwikkelaars dagelijks 20 minuten wachten op builds, wat kost dat je organisatie per jaar? En wat als je dat kunt terugbrengen naar 2 minuten?
Meet voordat je optimaliseert
Hier gaat het fout bij veel teams. Ze googelen “Maven build sneller” en implementeren de eerste oplossing die ze tegenkomen. Of ChatGPT geeft een tip en hop, kopieer-plakken maar. Dat is niet hoe je als engineer te werk gaat.
Je begint met meten. Niet die ene regel onderaan je build output die zegt “BUILD SUCCESS – Total time: 15:912 s”. Die vertelt je alleen dat je ruim 15 seconden hebt zitten wachten, maar het vertelt je niet waarom. Je hebt een gedetailleerde analyse nodig. Welke module kost de meeste tijd? Welke plugin is de boosdoener? Welke fase in je lifecycle vreet CPU? Vanuit die analyse kun je dan gericht maatregelen nemen die bijdragen aan een efficiënte en snelle build.
Voor die analyse gebruik ik de OpenTelemetry Maven extension. Je kent OpenTelemetry misschien al van je productie-monitoring. Dezelfde techniek werkt ook voor builds. Je installeert de extensie via een extensions.xml bestand in je project root. Draai je build met één extra parameter: -Dotel.traces.exporter=otlp. Open Jaeger. En daar zie je het: een visuele breakdown van waar je build z’n tijd aan besteedt. Er zijn ook simpelere alternatieven, die beschrijf ik in mijn blog “Measure Your Maven Build”.
Een voorbeeld van de visuele breakdown die OpenTelemetry je kan geven:
In dit voorbeeld bouwt Maven een klein project met slechts 4 modules. Het is duidelijk: de derde module kost het meeste tijd. Even openklikken en je ziet ook waar die tijd voor nodig is: de Maven Surefire Plugin, die de unit tests draait.
Techniek 1: parallelle tests
In dit voorbeeld zien we dat Surefire een hele grote test suite sequentieel uitvoert. Dat is het standaard gedrag van Surefire: alle tests één voor één. Terwijl je machine 8, 10, misschien 16 cores heeft. Dat kan dus slimmer!
Er zijn twee manieren om tests parallel te draaien. Via Surefire zelf configureer je de parallel parameter. Kies uit methods, classes, of “alles”. Het werkt prima voor JUnit 4 en TestNG. Nadeel: alle tests in een module moeten thread-safe zijn. Geen uitzonderingen mogelijk.
Via JUnit Jupiter heb je meer controle. Maak een junit-platform.properties bestand. Of gebruik annotaties per testklasse. Nu kun je selectief bepalen welke tests parallel mogen en welke niet.
Maar let op. Je tests moeten dus thread-safe zijn. Als je singletons gebruikt of shared state, gaat het mis. En paradoxaal genoeg kan parallelle test uitvoer je build ook vertragen, als één module alle CPU opeist.
In het voorbeeldproject ging de build van iets meer dan 15 seconden naar net onder de 10 seconden. Dat is 33% eraf, zonder één regel productiecode aan te raken. Maar misschien ben je nog niet tevreden. Dan ga je dus opnieuw meten en analyseren. En dan is die duidelijke piek qua tijd ineens weg. De meeste modules duren ongeveer even lang om te bouwen.
Techniek 2: Maven Daemon
Nu wordt het interessant. Je hebt dus meerdere modules. Die worden nu één voor één gebouwd. Maar je hebt 10 cores. Wat doen de andere 9? Slack draaien, waarschijnlijk.
Maven heeft de -T flag voor parallelle builds. Maar probeer dat eens. Je output wordt onleesbaar. Stacktraces van verschillende modules door elkaar. Succes met troubleshooten.
De Maven Daemon lost dit op. Het is een achtergrondproces dat drie dingen doet. Eén: het houdt je JVM warm. Normaal start Maven elke keer een nieuwe JVM op. Dat kost seconden. De JIT compiler doet z’n werk. Code wordt geoptimaliseerd. En dan gooi je al die optimalisaties weer weg. De Maven Daemon draait continu, dus de optimalisaties van de JIT blijven bewaard.
Twee: plugins blijven in het geheugen. De Kotlin compiler laden? Een paar seconden. De Scala compiler? Nog langer. Maven Daemon laadt ze één keer, en houdt ze in geheugen voor je volgende build.
Drie: het Smart Builder algoritme. Veel slimmer dan het standaardgedrag van Maven. Zodra een thread vrij komt, krijgt die meteen een nieuwe module. Dus niet wachten tot een hele batch modules klaar is.
Installeer Maven Daemon. Train je vingers om mvnd te typen in plaats van mvn. That’s it. Op het Shiro project ging de build zo van 36 naar 17 seconden.
Ook hier is het opletten. Je plugins moeten thread-safe zijn. De standaard plugins van Maven zijn dat allemaal. Maar oude of third-party plugins soms niet.
De Maven Daemon geeft zelf uitvoer over welke modules nu nog een bottleneck vormen voor de build. We zien geen duidelijke uitschieters meer.
Techniek 3: voed de daemon
Kan het dan toch nog sneller? Jazeker! De Maven Daemon wordt pas echt krachtig met meer modules. Maar let op, hier wordt het spannend. Veel projecten volgen een vrij standaard aanpak: één module voor je domain, één voor alle adapters, één voor alle services. Klinkt logisch, en wordt dan ook veel toe…
Maar je build heeft weinig te parallelliseren, met slechts een handvol modules. Wat als je veel verder gaat? Elke service interface in een eigen module. Elke service-implementatie in een aparte module. Test utilities in aparte modules.
In mijn demo ging ik van 5 naar 15 modules. Dezelfde code. Dezelfde tests. Alleen anders georganiseerd. Resultaat: van 17 naar 10 seconden.
Waarom werkt dit? Kleine modules compilen snel. Alleen plugins die nodig zijn voor die specifieke module worden uitgevoerd. Alleen de dependencies die voor die module nodig zijn, worden geladen. Je kunt specifieke delen van je applicatie builden met mvnd -pl :module -am. En je daemon heeft eindelijk genoeg werk om je CPU cores bezig te houden.
Maar dit is geen silver bullet. De navigatie door je code wordt complexer. Je POM-files vermenigvuldigen. En voor libraries is dit vaak ongeschikt. Vraag je gebruikers niet om 15 dependencies toe te voegen in plaats van 1 of 2. De vraag is: weegt de snelheidswinst op tegen de complexiteit? Dat hangt van je context af.
Techniek 4: build cache
Ten slotte: de snelste build is de build die niet draait.
Maven Build Cache extension doet precies dat. Het slaat build-resultaten op. Bij de volgende run checkt het: is er iets veranderd? Nee? Pak het resultaat uit de cache. Het is slim. Het normaliseert versienummers. Werkt over branches heen. Kijkt naar POM-wijzigingen, source code, dependencies. Neemt een fingerprint van je bestanden in plaats van timestamps te vertrouwen.
In mijn demo: van 17 naar 0,4 seconden voor een volledige build zonder wijzigingen. Wijzigt één bestand? Alleen dat bestand en afhankelijke modules worden opnieuw gebouwd. Kost hooguit 1 of 2 seconden.
Je kunt de cache zelfs delen. Met je collega’s, met je build server. Stel je voor: Azure DevOps bouwt je project. Jij wilt lokaal iets testen. Je leent gewoon de resultaten van de server.
Maar cache invalidatie is moeilijk, dat is algemeen bekend. Je moet configureren welke bestanden relevant zijn. Platforms kunnen verschillen. De cache van een Linux build werkt misschien niet op Windows. Dit vraagt investering. Tijd om te configureren. Te testen. Te tunen. Maar de winst kan enorm zijn.
Het draait om de aanpak
Ik heb meerdere technieken níet behandeld. Logging uitschakelen tijdens tests. Virtual threads gebruiken voor nog meer parallelisme. Virusscanner configuratie aanpassen. Upgraden naar nieuwere Java-versies. Maar dat is niet het punt.
Het punt is dit: begin met meten. Analyseer waar je bottleneck zit. Pas dan de juiste techniek toe. Meet opnieuw. Herhaal. Geen cargo cult programming. Niet blind kopiëren van Stack Overflow. Gewoon goede engineering.
Want die zwaardgevechten tijdens compilaties? Leuk voor de XKCD-strip. Maar jij hebt betere dingen te doen.
