De nieuwe AOT-cache-functie van Java bevindt zich in een problematische staat
Inleiding
De AOT (ahead-of-time) cache is de nieuwste aanpak van het JDK om de opstart- en warm-uptijden van Java-applicaties te verbeteren. Het idee is simpel: je voert een trainingsrun uit van je applicatie, waarbij de JVM registreert wat de applicatie doet, zoals welke klassen worden geladen en gekoppeld en welke methoden worden aangeroepen. Wanneer de applicatie later wordt gestart, kan de JVM een groot deel van het initialisatiewerk overslaan omdat dit al is gedaan en alle klassen direct beschikbaar zijn in een geladen en gekoppelde staat.
In theorie is dit een gratis boost in opstartprestaties zonder dat er iets in je applicatie hoeft te worden gewijzigd. In de praktijk brengt het gebruik hiervan voor een echte applicatie die aan gebruikers wordt gedistribueerd — zoals wij doen bij XPipe — veel valkuilen met zich mee. In het beste geval wordt de AOT-cache simpelweg niet toegepast vanwege incompatibiliteiten en win je niets. In het slechtste geval kan je JVM onverwacht crashen als je niet voorzichtig bent. In feite is het een gratis opstartboost, maar alleen onder bepaalde voorwaarden, en inclusief gratis crashes.
Hopelijk voorkomt dit bericht dat jullie in dezelfde problemen lopen als wij tijdens het uitrollen van deze functie. Aan het einde vind je de build-scripts die wij gebruiken voor onze applicaties, gebaseerd op alles wat we in de loop van de tijd hebben geleerd. Let op dat dit bericht geen prestatiemetingen of vergelijkingen bevat; dat is een geheel ander verhaal en dit bericht is al te lang voor wat het behandelt.
De concepten
Wie bekend is met de JDK AOT-concepten, kan deze sectie overslaan. Voor anderen volgt hier een kort overzicht van alle relevante onderdelen die meespelen bij AOT-optimalisaties.
Tijdlijn
De AOT-cache is bij elke JDK-release sinds de introductie uitgebreid, waarbij elke stap is vastgelegd in een JEP (JDK Enhancement Proposal):
- JDK 24: JEP 483: Ahead-of-Time Class Loading & Linking introduceerde de AOT-cache zelf.
- JDK 25: JEP 514: Ahead-of-Time Command-Line Ergonomics vereenvoudigde de creatie van de cache tot één stap met
-XX:AOTCacheOutput, en JEP 515: Ahead-of-Time Method Profiling voegde methodeprofielen toe aan de cache zodat de JIT-compiler sneller kan opwarmen. - JDK 26: JEP 516: Ahead-of-Time Object Caching with Any GC maakte gecachte objecten bruikbaar met elke garbage collector.
- JDK 28: JEP 544: Ahead-of-Time Code Compilation zal ook gecompileerde code aan de cache toevoegen.
Class-data sharing (CDS)
De AOT-cache is niet vanaf nul opgebouwd. Het is een natuurlijke evolutie van class-data sharing (CDS), een HotSpot-functie die bestaat sinds JDK 5. CDS slaat geparseerde klasse-metadata op in een archiefbestand dat de JVM bij het opstarten direct in het geheugen kan mappen. Dit werd in de loop der tijd uitgebreid met application class-data sharing in JDK 10, een standaard CDS-archief voor JDK-klassen dat wordt meegeleverd met elke JDK sinds JDK 12, en dynamische CDS-archieven in JDK 13. De AOT-cache maakt gebruik van dezelfde infrastructuur, wat verklaart waarom je op veel plaatsen nog steeds CDS-termen ziet zoals "shared archive" of de cds log-tag.
AOT class linking
AOT-cache en AOT class linking worden vaak door elkaar gebruikt, maar ze zijn niet hetzelfde. De AOT-cache is het bestand, dat verschillende soorten gegevens kan bevatten: geladen klassedata (het deel overgenomen van CDS), methodeprofielen, gecachte Java-objecten, bepaalde native code en vooraf gekoppelde klassen. AOT class linking is slechts één van deze optimalisaties. Het wordt automatisch ingeschakeld bij het maken van een AOT-cache (-XX:+AOTClassLinking) en slaat klassen op in een reeds gekoppelde staat, zodat de JVM dit werk bij het opstarten kan overslaan. Het is raadzaam om class linking in te schakelen, anders mis je de winst in opstartprestaties.
GraalVM Native Image
De AOT-cache moet ook niet verward worden met GraalVM Native Image. Native Image compileert je hele applicatie vooraf naar een standalone native executable die zonder JVM draait. Om dat te doen, moet het een "closed-world assumption" maken: alle code die tijdens runtime kan worden uitgevoerd, moet bekend zijn tijdens de build-tijd. Dynamische functies zoals reflectie, dynamisch klassenladen of resources moeten daarom expliciet worden geconfigureerd. In ruil daarvoor krijg je een zeer snelle opstart en een laag geheugengebruik.
De AOT-cache hanteert een andere aanpak. Je applicatie draait nog steeds op een normale JVM met de JIT-compiler en behoudt alle dynamische mogelijkheden van Java, dus je hoeft niets in je applicatie te wijzigen of te configureren. De cache versnelt enkel het werk dat de JVM sowieso moet doen. Als de cache niet gebruikt kan worden, valt de JVM gewoon terug op het uitvoeren van deze taken tijdens runtime. De verbeteringen in opstarttijd zijn daarom kleiner dan bij Native Image, maar je hebt ook geen last van de beperkingen.
CPU-incompatibiliteiten
In theorie klinkt het idee van de AOT-cache als iets dat alleen de klassedata zou beïnvloeden en geen native compilatie zou uitvoeren. Want als dat wel zo zou zijn, zou je in allerlei problemen lopen als de CPU-functies van het trainingssysteem en het werkelijke systeem verschillen, zelfs als de CPU-architectuur hetzelfde is.
In de praktijk blijkt dat de AOT-cache met class linking toch kleine delen native code genereert. Je merkt de gevolgen hiervan als je trainingssysteem iets ondersteunt zoals AVX, maar het systeem waarop de app draait dat niet doet (of niet op hetzelfde niveau, zoals geen ondersteuning voor AVX-512, wat overeenkomt met het -XX:UseAVX=3 niveau van de JDK). In dat geval zal je JVM crashen met:
A fatal error has been detected by the Java Runtime Environment:
SIGILL (0x4) at pc=0x00007fcc85ae2613, pid=4054, tid=4147
JRE version: OpenJDK Runtime Environment Zulu25.32+21-CA (25.0.2+10) (build 25.0.2+10-LTS)
Java VM: OpenJDK 64-Bit Server VM Zulu25.32+21-CA (25.0.2+10-LTS, mixed mode, sharing, tiered, compressed oops, compact obj headers, g1 gc, linux-amd64)
Problematic frame:
v ~AdapterBlob 0x00007fcc85ae2613
of
# A fatal error has been detected by the Java Runtime Environment:
#
# EXCEPTION_ILLEGAL_INSTRUCTION (0xc000001d) at pc=0x000001d00d9f6c2b, pid=39700, tid=37844
#
# JRE version: OpenJDK Runtime Environment Zulu25.30+17-CA (25.0.1+8) (build 25.0.1+8-LTS)
# Java VM: OpenJDK 64-Bit Server VM Zulu25.30+17-CA (25.0.1+8-LTS, mixed mode, sharing, tiered, compressed oops, compact obj headers, g1 gc, windows-amd64)
# Problematic frame:
# v ~AdapterBlob 0x000001d00d9f6c2b
Dit betekent bijvoorbeeld dat je een applicatie met een AOT-cache die is getraind op een 13e generatie Intel Core CPU niet kunt draaien op een doelsysteem met een 12e generatie Intel Core CPU, omdat deze verschillende niveaus van AVX-ondersteuning hebben. Als je alleen nieuwe CPU's hebt en je app levert aan een klant die nog op oudere hardware draait, krijg je een ontevreden klant die denkt dat je geen basistesten uitvoert voor je software.
Adapter caches
Het blijkt dat er adapter caches worden gegenereerd, die in JDK 25 zijn toegevoegd (JDK-8350209: Preserve adapters in AOT cache). Adapters zijn kleine stukjes machinecode die schakelen tussen geïnterpreteerde en gecompileerde Java-aanroepen. Ze worden gegenereerd voor de CPU-functies van de machine die de cache bouwt, zonder rekening te houden met het feit dat de applicatie later op een ander systeem kan draaien.
Zodra een systeem dat een van de in de gecachte adapters gebruikte CPU-functies mist een van deze adapters aanroept, crasht de JVM. Dit is niet alleen een probleem voor desktopapplicaties; zelfs op backend-servers wordt de AOT-cache vaak gemaakt in een CI-pipeline of container-image build, en vervolgens uitgerold naar machines waarvan de CPU's verschillen van de build-machine.
Het uitschakelen van adapter caching
Je kunt de AOT-adapter caches uitschakelen door de argumenten -XX:+UnlockDiagnosticVMOptions -XX:-AOTAdapterCaching mee te geven aan je AOT-cache generatie-run. Deze optie wordt nergens uitgelegd of gedocumenteerd; je moet de JDK-broncode lezen om dit te ontdekken. Het verwarrende is dat deze optie standaard als false is gedeclareerd, maar automatisch op true wordt gezet door de implementatie als de cache AOT-gelinkte klassen bevat.
Het doorgeven van deze argumenten is in feite een vereiste als je de AOT-cache gebruikt, anders zullen je gebruikers last krijgen van crashes. Dit probleem is uiteindelijk gemeld als JDK-8388703. JDK 27 zal in plaats daarvan de CPU-functies van de trainingsmachine opslaan voor het native-code gedeelte van de AOT-cache en de gecachte adapters alleen gebruiken bij een exacte match van CPU-functies.
Wat de AOT-codecache daadwerkelijk is
Om het nog verwarrender te maken, betekent de term "AOT code cache" twee verschillende dingen. In de JDK-broncode en logs is de AOT-codecache (AOTCodeCache) het deel van de AOT-cache dat native code bevat. Dit bestaat sinds JDK 25 en bevat momenteel de hierboven beschreven adapters. Wanneer je logberichten ziet zoals AOT Code Cache is not used: AOT Class Linking is not used of AOT Code Cache disabled: cpu features are incompatible, gaat het dus meestal over de adapters, niet over gecompileerde Java-code. Het is ook niet hetzelfde als de reguliere code-cache waarin de JIT-compiler tijdens runtime gecompileerde code opslaat.
AOT-codecompilatie in JDK 28
JDK 28 zal een extra functie introduceren, JEP 544: Ahead-of-Time Code Compilation, die ook een native-codecompilatie voor je cache zal uitvoeren, met meer code dan alleen de adapters. Volgens de JEP zal de AOT-code niet worden geladen als de CPU-functies niet overeenkomen, wat in ieder geval crashes zal voorkomen.
Dit vereist echter een exacte match: op x86 moeten alle CPU-functies, behalve hyper-threading, identiek zijn. Dit maakt de functie vrij nutteloos voor applicaties die aan gebruikers worden gedistribueerd, omdat de kans klein is dat de CPU van een gebruiker exact overeenkomt met die van de build-machine. Wat nodig is, is een soort compatibiliteitsschakelaar die alle optionele CPU-functies uitschakelt.
Logs analyseren
Bij het werken met de AOT-cache is het JVM-argument -Xlog:aot essentieel. Het print details bij het genereren van een AOT-cache en laat zien of de cache correct wordt geladen bij het opstarten. Er zijn echter enkele valkuilen:
Twee runs in één log
JEP 514 introduceerde een eenvoudigere workflow. In plaats van twee stappen, heb je nu slechts één aanroep van je applicatie nodig om de AOT-cache te bouwen. Technisch gezien voert de JVM echter nog steeds twee aparte runs uit; het verbergt dit alleen voor jou. Hierdoor krijg je logs van twee aanroepingen als één, terwijl alleen de tweede run (de assembly run) vertelt wat er daadwerkelijk in de cache terechtkomt.
Verwarrende logberichten
Bovendien missen de logberichten bijna alle relevante informatie en is er nauwelijks documentatie. Bijvoorbeeld, de run die de AOT-cache assembleert, logt: [info][aot] full module graph: disabled
Het is niet gedocumenteerd wat een "full module graph" is, waarom het is uitgeschakeld of wat de implicaties hiervan zijn. Bovendien worden deze regels aan het begin van elke assembly run geprint, zelfs in een perfect werkende setup. Of de full module graph en AOT class linking daadwerkelijk hebben gewerkt, is pas aan het einde van de log zichtbaar, in regels zoals Full module graph = enabled en instance classes = 9033, aot-linked = 9033.
Fragiele class linking
AOT class linking is het meest fragiele deel van de AOT-cache. Het vereist dat de volledige module-setup van je applicatie in de cache wordt opgeslagen (de full module graph), en veel omstandigheden schakelen dit ongemerkt uit. Als dat gebeurt, wordt de AOT-cache nog steeds gemaakt en gebruikt, maar moeten de klassen tijdens runtime opnieuw worden gelinkt, waardoor je een deel van de opstartverbetering verliest.
Module-opties
In JDK 24 was het niet eens toegestaan om --add-opens te specificeren voor reflectie; --add-opens, --add-exports en --add-reads werden allemaal als incompatibel beschouwd, wat de full module graph en daarmee AOT class linking uitschakelde. Dit is in JDK 25 hersteld.
GraalVM
Bij het gebruik van GraalVM loop je tegen meer problemen aan. GraalVM bevat extra modules zoals jdk.internal.vm.ci voor geavanceerde JIT-functies via JVMCI. Deze module is aanwezig tijdens runtime, maar wordt niet automatisch toegevoegd aan de module graph wanneer GraalVM zijn native JIT-compiler library gebruikt. Je moet daarom --add-modules jdk.internal.vm.ci toevoegen aan je AOT-trainingsruns om een mismatch in de module graph te voorkomen.
Waarschuwingen voor final field mutation
In JDK 26 krijg je waarschuwingen wanneer een final veld via reflectie wordt gewijzigd (JEP 500: Prepare to Make Final Mean Final). Om deze waarschuwingen te verwijderen, moet je --enable-final-field-mutation=.... gebruiken. Echter, deze optie schakelt de optimized module handling opnieuw uit, wat weer leidt tot het verlies van class linking.
Incubating modules
Verschillende module-configuraties schakelen optimalisaties uit. Als je bijvoorbeeld enige incubating module van de JDK of JavaFX gebruikt, is er geen AOT class linking mogelijk. Als de module graph een incubating module bevat, wordt de boot layer niet gearchiveerd, wat de full module graph en class linking uitschakelt.
Impact op benchmarks
Al deze problemen maken vergelijkingen ongelooflijk moeilijk. Hoe kan een normale ontwikkelaar de winst van deze functie evalueren als ze zo voorzichtig moeten zijn dat sommige optimalisaties niet ongemerkt worden uitgeschakeld?
Er is bovendien geen manier om de build te laten falen als class linking wordt uitgeschakeld. Als je wilt dat je CI-pipeline faalt wanneer AOT class linking om wat voor reden dan ook wordt uitgeschakeld, is dat momenteel niet mogelijk.
Waar bevindt zich mijn AOT-cache?
Voorheen, bij CDS, was het voor de JVM nooit een probleem om de data te vinden. Bij AOT zijn er nu meerdere onafhankelijke JVM-runs: de trainingsruns, de cache assembly run en de werkelijke run op het doelsysteem.
De trainings- en assembly-runs krijgen de cachelocatie via -XX:AOTCacheOutput, maar voor de werkelijke run van je applicatie moet je de JVM vertellen waar de cache-data zich bevindt met -XX:AOTCache. Er is geen standaardwaarde of automatische zoekfunctie.
Relatieve paden
Dit is lastig met relatieve bestandspaden. Absolute paden hebben geen zin omdat je applicatie op willekeurige locaties op het doelsysteem kan staan. Je kunt een relatief pad opgeven voor -XX:AOTCache, maar dit is relatief aan de working directory, niet aan je executable. Iedereen die je app in een andere working directory start, zal de AOT-cache breken.
jpackage installatie-layouts
Bij het genereren van een runtime image via jlink en jpackage moet je dealen met verschillende installatie-layouts per platform. Je moet de AOT-cache locaties specificeren in het .cfg bestand van jpackage als volgt:
- Windows:
java-options=-XX:AOTCache=$ROOTDIR/runtime/bin/server/cache.aot - Linux:
java-options=-XX:AOTCache=$APPDIR/../../lib/runtime/lib/server/cache.aot - macOS:
java-options=-XX:AOTCache=$ROOTDIR/Contents/runtime/Contents/Home/lib/server/cache.aot
Dynamische generatie
De AOT-cache kan ook dynamisch later worden gegenereerd op het doelsysteem, maar dit is momenteel niet eenvoudig vanwege leesbare installatiemappen, het gebrek aan een standaardpad en de beperkingen van relatieve paden.
Problemen met EDR
Een agent die inhaakt op class loading via JVMTI (een ClassFileLoadHook) voorkomt dat de AOT-cache wordt geladen als deze AOT-gelinkte klassen bevat.
In de praktijk introduceert dit onverwachte problemen. Als zo'n agent bij het opstarten is gekoppeld, kan de AOT-cache niet worden gebruikt en krijg je een foutmelding:
[error][aot] AOT cache has aot-linked classes. It cannot be used when JVMTI ClassFileLoadHook is in use.
[error][aot] An error has occurred while processing the AOT cache.
[error][aot] Unable to map shared spaces
Dit is geen fatale fout, maar omdat deze fouten naar stdout worden geprint en niet naar stderr, kan dit de output van je applicatie breken. Bijvoorbeeld, SentinelOne koppelt een agent aan elke gestarte Java-applicatie. Als een gebruiker afhankelijk is van de output van je programma (bijv. API_KEY=$(myapp generate-key)), zal dit falen omdat de output nu meerdere regels bevat met AOT-fouten bovenaan. Je moet je app in productie-builds effectief draaien met -Xlog:disable of -Xlog:disable -Xlog:all=warning:stderr.
Alles samenvoegen
Hieronder volgt een vereenvoudigde versie van de Gradle-task die wij gebruiken om de AOT-cache te maken. Deze draait na jlink en jpackage en voegt de AOT-cache toe aan het image voordat dit in installers wordt verpakt.
def distDir = "${project.layout.buildDirectory.get()}/dist"
tasks.register('trainAot', DefaultTask) {
doLast {
// De applicatie moet zich tijdens een trainingsrun anders gedragen
// bijv. geen dialogen blokkeren bij GUI-apps voor onbeheerde runs
def properties = [
"-D" + propertyName("dataDir") + "=$distDir/local_train",
"-D" + propertyName("aotTrainMode") + "=true",
]
def jvmName = System.getProperty("java.vendor.version")
def graal = jvmName != null && jvmName.toLowerCase().contains("graal")
def imageDir
def command
def cacheOutput
def cfgFile
def cfgCache
if (os.isWindows()) {
imageDir = "$distDir/${productName}"
command = ['cmd', '/c', "$imageDir/runtime/bin/${jpackageExecutableName}.bat"]
cacheOutput = './server/cache.aot'
cfgFile = "$imageDir/app/${jpackageExecutableName}.cfg"
cfgCache = '$ROOTDIR/runtime/bin/server/cache.aot'
} else if (os.isLinux()) {
imageDir = "$distDir/${productName}"
command = ["$imageDir/lib/runtime/bin/${jpackageExecutableName}"]
cacheOutput = './lib/runtime/lib/server/cache.aot'
cfgFile = "$imageDir/lib/app/${jpackageExecutableName}.cfg"
cfgCache = '$APPDIR/../../lib/runtime/lib/server/cache.aot'
} else {
imageDir = "$distDir/${productName}.app"
command = ["$imageDir/Contents/runtime/Contents/Home/bin/${jpackageExecutableName}"]
cacheOutput = './Contents/runtime/Contents/Home/lib/server/cache.aot'
cfgFile = "$imageDir/Contents/app/${jpackageExecutableName}.cfg"
cfgCache = '$ROOTDIR/Contents/runtime/Contents/Home/lib/server/cache.aot'
}
def jvmOptions = [
"-XX:+UnlockDiagnosticVMOptions",
"-XX:-AOTAdapterCaching",
"-XX:+AOTClassLinking",
"-XX:AOTCacheOutput=$cacheOutput",
"-Xlog:aot",
]
if (graal) {
jvmOptions += ["--add-modules", "jdk.internal.vm.ci"]
}
execOps.exec {
commandLine((command + properties).collect { it.toString() })
workingDir imageDir
// Gebruik de CDS_JVM_OPTS variabele van de jlink launcher scripts
environment "CDS_JVM_OPTS", jvmOptions.join(" ")
}
def cfg = file(cfgFile)
cfg.text = cfg.text + "java-options=-XX:AOTCache=$cfgCache\njava-options=-Xlog:disable\n"
}
}
De trainingsrun gebruikt een specifieke training mode property en een aparte datadirectorie, zodat het niet interfereert met bestaande applicatiedata. In deze modus doorloopt de applicatie een vaste workload (bijv. het openen van alle UI-pagina's) en sluit zichzelf daarna af.
Conclusie
Kortom, het correct laten werken van de AOT-cache zonder nadelen is veel moeilijker dan het zou moeten zijn. De functie is fragiel, er is bijna geen documentatie buiten de JEP's, en de logs roepen vaak meer vragen op dan ze beantwoorden. Veel van de kennis in dit bericht kon alleen worden verkregen door de JDK-broncode te lezen.
Dit roept de vraag op: telt een JEP überhaupt als documentatie? De JEP's zijn korte samenvattingen voor voorgestelde functies, gericht op het "happy path" om mensen te overtuigen dat een functie nodig is, niet om deze uitgebreid te documenteren.
De functie voelt bovendien niet goed doordacht aan voor de uitdagingen van echte applicaties. Het bouwen van de AOT-functie bovenop CDS heeft waarschijnlijk niet geholpen, aangezien deze functies in gebruikscase ver uit elkaar zijn gegroeid. Het feit dat het crash-probleem met CPU-functies het JDK 25-release heeft gehaald, toont een gebrek aan planning aan. Het is geen bug, maar een ontwerpfout waarbij men vergat dat bij AOT-generatie de CPU-functies van de build- en doelsystemen kunnen verschillen.
Groetjes,