Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Java  Module 16 – JVM internals, memory, GC and performance

Startup: AOT cache and native images

Speed up Java start-up with the JDK 25 AOT cache: train it, create it, measure five runs each, and see where GraalVM native images fit instead.

  • Advanced
  • 25 minutes
  • Examples run with Java 25.0.4.1 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 7 of 25 minutes free
Free: Where start-up time goes · Creating and using an AOT cache · Three ways to start faster, compared · Check yourself. With a pass: 3 in-depth parts and 3 interview answers, about 18 minutes.

What you will learn

  • Create and use an AOT cache to improve start-up
  • Measure start-up before and after with the same workload
  • Compare the AOT cache with native images at a high level

Before you start

Start-up time is the time from typing java to the first useful result. Before a Java program does its own work, the JVM loads, links and initialises every class it needs, often more than a thousand of them, and runs the program’s code in the interpreter until the JIT compiler has compiled the busy parts. The AOT cache (ahead-of-time cache) removes much of that work: a training run of the program records which classes it uses, and later runs find those classes already loaded and linked in one file. JDK 24 added it (JEP 483), JDK 25 made it a single option, -XX:AOTCacheOutput (JEP 514), and also stores method profiles in it (JEP 515). Native images, built with GraalVM’s native-image tool, go further: they compile the whole program ahead of time into an executable for one operating system and processor. This lesson measures the first and explains when the second is worth its costs.

A training run of app.jar writes an AOT cache through a second JVM; production runs map the cache if JDK, platform and class path match.app.jarthe application, as JAR files onlyTraining runjava -XX:AOTCacheOutput=app.aot -jar app.jarruns the real workload and notes every classloaded and linked, and the method profilesAssemblya second JVM reads the notesand writes the cacheapp.aotclasses already loaded and linked,plus method profilesProduction runsjava -XX:AOTCache=app.aot -jar app.jarmap the cache at start-upSame JDK release, operating system,processor and class path, or thecache is ignored with a warningwhen the run ends

Creating and using an AOT cache on JDK 25

Text description of the diagram

The diagram runs from top to bottom.

  1. app.jar: the application, packaged as JAR files only.
  2. The training run, java -XX:AOTCacheOutput=app.aot -jar app.jar, runs the real workload and notes every class that is loaded and linked, and the method profiles.
  3. Assembly: when the training run ends, a second JVM reads those notes and writes the cache.
  4. app.aot: the cache, with the classes already loaded and linked, plus the method profiles.
  5. Production runs, java -XX:AOTCache=app.aot -jar app.jar, map the cache into memory at start-up.

A dashed box beside the production runs states the rule: the same JDK release, operating system, processor and class path, or the JVM ignores the cache with a warning.

Where start-up time goes

A small command-line tool shows the work. It reads five invoices from XML, checks each tax registration number against its pattern, totals the amounts by state and prints a summary:

InvoiceTool.java Java · InvoiceTool.java
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.text.NumberFormat;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

/** A command-line tool that reads invoices from XML, checks their GSTIN, totals them by state and prints a summary. */
public class InvoiceTool {
    record Invoice(String gstin, String state, LocalDate date, BigDecimal amount) {}

    static final String XML = """
        <invoices>
          <invoice gstin="27AAPFU0939F1ZV" state="Maharashtra" date="2026-09-01" amount="11800.00"/>
          <invoice gstin="29AAGCB7383J1Z4" state="Karnataka" date="2026-09-02" amount="2360.50"/>
          <invoice gstin="27AAPFU0939F1ZV" state="Maharashtra" date="2026-09-05" amount="590.00"/>
          <invoice gstin="33AABCT1332L1ZZ" state="Tamil Nadu" date="2026-09-07" amount="47200.00"/>
          <invoice gstin="BADGSTIN" state="Kerala" date="2026-09-09" amount="100.00"/>
        </invoices>
        """;
    static final Pattern GSTIN = Pattern.compile("\\d{2}[A-Z]{5}\\d{4}[A-Z][1-9A-Z]Z[0-9A-Z]");

    public static void main(String[] args) throws Exception {
        Logger.getLogger("invoice").fine("starting");
        var doc = DocumentBuilderFactory.newInstance().newDocumentBuilder()
                .parse(new ByteArrayInputStream(XML.getBytes(StandardCharsets.UTF_8)));
        NodeList nodes = doc.getElementsByTagName("invoice");
        List<Invoice> invoices = new ArrayList<>();
        for (int i = 0; i < nodes.getLength(); i++) {
            Element e = (Element) nodes.item(i);
            invoices.add(new Invoice(e.getAttribute("gstin"), e.getAttribute("state"),
                    LocalDate.parse(e.getAttribute("date")), new BigDecimal(e.getAttribute("amount"))));
        }
        var valid = invoices.stream().filter(inv -> GSTIN.matcher(inv.gstin()).matches()).toList();
        Map<String, BigDecimal> byState = valid.stream().collect(Collectors.groupingBy(Invoice::state, TreeMap::new,
                Collectors.reducing(BigDecimal.ZERO, Invoice::amount, BigDecimal::add)));
        var rupees = NumberFormat.getCurrencyInstance(Locale.of("en", "IN"));
        long days = ChronoUnit.DAYS.between(valid.getFirst().date(), valid.getLast().date()) + 1;
        System.out.printf("%d invoices, %d with a valid GSTIN, issued over %d days%n", invoices.size(), valid.size(), days);
        byState.forEach((state, total) -> System.out.println("  " + state + ": " + rupees.format(total.setScale(2, RoundingMode.HALF_UP))));
    }
}

Indian invoices

The invoices are Indian: each registration number is a GSTIN, a business’s registration number for GST, and the totals are formatted for the en-IN locale, with the ₹ sign and Indian digit grouping, as the tool’s output shows.

To print four lines, it uses XML parsing, regular expressions, streams, records, decimal arithmetic and currency formatting, and each of these brings its classes, well over a thousand in all. The JDK already ships a class data sharing (CDS) archive: about 1,400 of its own most common classes, listed in its lib/classlist file and stored in their parsed form in a 16 MB file, lib/server/classes.jsa, which the JVM maps into memory at start-up instead of reading those classes one by one. The tool’s other classes, from the XML parser to its own InvoiceTool, are still read, parsed, verified and linked in every run, and the code that initialises them runs every time too.

Creating and using an AOT cache

Since JDK 25, one extra option on a normal run creates the cache. The cache is tied to the class path, so the tool is packaged as a JAR first:

A training run that writes the cache, then five runs without and five with it Python · startup_session.py
from pathlib import Path

from session import run
from startup import show, times

# The tool as a JAR, then a training run that writes an AOT cache (JDK 25, JEP 514), then five timed runs of the
# tool without the cache and five with it.
run("""
javac -d out InvoiceTool.java
jar --create --file app.jar --main-class InvoiceTool -C out .
java -jar app.jar
java -XX:AOTCacheOutput=app.aot -jar app.jar > /dev/null
""")
print(f"# The cache: {Path('app.aot').stat().st_size / 1e6:.1f} MB")
print("# Five runs each, wall-clock time from start to exit")
show("java -jar app.jar", times("java -jar app.jar"))
show("... -XX:AOTCache=app.aot", times("java -XX:AOTCache=app.aot -jar app.jar"))

Output

# JDK in use: openjdk version "25.0.4.1" LTS
$ javac -d out InvoiceTool.java
$ jar --create --file app.jar --main-class InvoiceTool -C out .
$ java -jar app.jar
5 invoices, 4 with a valid GSTIN, issued over 7 days
  Karnataka: ₹2,360.50
  Maharashtra: ₹12,390.00
  Tamil Nadu: ₹47,200.00
$ java -XX:AOTCacheOutput=app.aot -jar app.jar > /dev/null
Picked up JAVA_TOOL_OPTIONS: -Djava.class.path=app.jar -XX:AOTCacheOutput=app.aot -XX:AOTConfiguration=app.aot.config -XX:AOTMode=create
# The cache: 17.0 MB
# Five runs each, wall-clock time from start to exit
java -jar app.jar              768   813   750   576   766   median   766 ms
... -XX:AOTCache=app.aot       714   424   246   212   373   median   373 ms

This output changes from run to run: Times change from run to run, and more on a busy machine; the size of the cache can change slightly.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 startup_session.py

-XX:AOTCacheOutput=app.aot turns the run into a training run. The tool runs as usual, and the JVM notes every class it loads and links. When the tool ends, the JVM starts a second JVM that reads those notes and writes the cache: the line Picked up JAVA_TOOL_OPTIONS comes from that second JVM, which received its instructions through an environment variable. Later runs use the cache with -XX:AOTCache=app.aot.

In the recorded runs, the cache was 17 MB, and the median start-up fell from 766 ms to 373 ms, about half. Each figure is the median of five runs, measured from starting the process to its exit, on a computer that was busy with other work, which is why single runs vary by a few hundred milliseconds. The cache is also specific: JEP 483 lists the same JDK release, the same operating system and processor, and the same class path, made of JAR files only, as conditions for using it. With -XX:AOTMode=on, a JVM that cannot use its cache stops with an error instead of starting without it.

The training run should do what production does, because the cache can only hold what that run loaded and, since JDK 25, how its methods behaved; JEP 515 states plainly that the benefit depends on the training run resembling production. A training run that only prints a help text produces a small cache that covers little of a real start-up. The gain also grows with the number of classes a program loads beyond the JDK’s own archive: JEP 483 reports Spring PetClinic, which loads about 21,000 classes, starting in 2.604 seconds instead of 4.486 with a 130 MB cache.

Three ways to start faster, compared

The JDK's CDS archive, an AOT cache and a GraalVM native image
Criterion CDS archive of the JDKAOT cache (JDK 25)Native image (GraalVM)
What it holds The JDK's own common classes, parsedYour application's classes, loaded and linked, plus method profilesThe whole program compiled to machine code
Extra build step None: it ships with the JDKOne training runA native-image build of the application
What still runs at start-up Loading of every other classInitialisation and the JIT warm-upLittle: no class loading or JIT warm-up
Main restriction NoneThe same JDK, platform and class path in every runDynamic features need metadata at build time
Output runs on Any platform of that JDKThe platform it was trained onOne operating system and processor

The JDK’s archive is always there. An AOT cache asks one extra run and keeps everything else about the JVM, including its JIT compiler. A native image changes the deployment: GraalVM’s documentation describes it as starting in milliseconds without warm-up, and as needing configuration for reflection, dynamic proxies and resources.

Check yourself

Check yourself

4 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 4 On JDK 25, which command trains and writes an AOT cache for app.jar?

    Choose one answer.

    Show the answer to question 1

    Answer: java -XX:AOTCacheOutput=app.aot -jar app.jar

    -XX:AOTCacheOutput turns a normal run into a training run, and the JVM writes the cache when it ends (JEP 514). -XX:AOTCache reads an existing cache in later runs.

  2. Question 2 of 4 Which class path can an AOT cache be used with?

    Choose one answer.

    Show the answer to question 2

    Answer: JAR files only, the same ones as in the training run

    JEP 483 requires JAR files, because directories cannot be checked efficiently, and a class path consistent with the training run's; later runs may only add entries at the end.

  3. Question 3 of 4 You want to know whether an AOT cache helps your tool. Which measurement is the most trustworthy?

    Choose one answer.

    Show the answer to question 3

    Answer: The median of several runs from process start to exit, with and without the cache, on the same workload

    Single runs vary, more so on a busy machine. The median of several runs of each, with the same input, compares like with like.

  4. Question 4 of 4 What does a GraalVM native image require that an AOT cache does not? Choose every one that applies.

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • Metadata for reflection, dynamic proxies and resources that its static analysis cannot see
    • A separate build for each operating system and processor

    A native image is compiled under a closed-world assumption into an executable for one platform. It needs no JVM at run time and no training run, but dynamic features need metadata at build time.

Measuring start-up honestly: classes, a tiny program and warm-up Pro: opens with a Pro, Premium or Ultimate pass

A start-up figure means something only with what was measured and how. This part counts where the classes come from, shows a program for which a cache changes nothing, and measures the method profiles that JDK 25 adds to the cache.

In this part:

  • Where each class came from, with and without the cache
  • A program too small to gain
  • Method profiles and the first seconds of work

5 runnable examples (Java, Python) · about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Stale caches, wrong training runs, and what they cost Pro: opens with a Pro, Premium or Ultimate pass

An AOT cache is an optimisation, so a JVM that cannot use it starts anyway. This part rebuilds the JAR to make the cache stale, measures what that costs, makes the failure fatal on purpose, and shows a newer JDK refusing the cache. Then it is your turn, with a cache that the JVM accepts but that was trained on the wrong command.

In this part:

  • A rebuilt JAR, a stale cache, and a strict mode
  • Building the cache where it cannot go stale
  • Your turn: a cache trained on the wrong command

3 runnable examples (Java, Python) · about 5 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

AOT cache or native image: a decision with its costs Pro: opens with a Pro, Premium or Ultimate pass

The two techniques answer the same complaint, slow start-up, with very different bills. This part sets out what each one costs, where each fits, and what the next JDK releases change.

In this part:

  • What a native image changes, and what it costs
  • Choosing for four kinds of programs, with the trade-offs
  • What the next releases add

about 3 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    What is the AOT cache in JDK 25, and how do you create and use one?

    The AOT (ahead-of-time) cache is a file that holds an application's classes already loaded and linked, plus, since JDK 25, method profiles, so that later runs skip that work at start-up. You create it with a training run of the application: java -XX:AOTCacheOutput=app.aot -jar app.jar runs the program as usual, and when it ends the JVM writes the cache (JEP 514). Production runs use it with java -XX:AOTCache=app.aot -jar app.jar. The cache only works with the same JDK release, operating system, processor and class path, made of JAR files, as the training run; otherwise the JVM warns and starts without it. The gain depends on the program: large applications that load many classes gain most, and a tiny program may gain nothing measurable.

  2. Mid to senior

    Your team added an AOT cache to a service's container image, but production start-up did not improve at all. What would you check, and in what order?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Senior to staff

    A Java serverless function takes 1.8 s to start and 200 ms to handle a request. Compare an AOT cache and a GraalVM native image for it, and say what you would measure before deciding.

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    After a change to the build pipeline, a command-line tool shipped with an AOT cache starts more slowly than the same tool with no cache at all. Explain how that can happen, and how you would make the pipeline safe against it.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Start-up is class loading, linking, initialisation and interpreted code before the JIT compiler catches up.
  • On JDK 25, java -XX:AOTCacheOutput=app.aot trains and writes an AOT cache, and -XX:AOTCache=app.aot uses it.
  • Measure start-up as a median of several runs from process start to exit, with the same workload before and after.
  • A native image starts faster still, but it is a different build with different rules.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.