Obtaining heap dump on OutOfMemoryError with Quarkus native

Starting with GraalVM for JDK 21, native executables can run with the -XX:+HeapDumpOnOutOfMemoryError option to generate a heap dump when a java.lang.OutOfMemoryError is thrown. In this blog post we will explore how to use the flag, we will inspect what a GraalVM Native Image heap dump looks like and how it compares with one produced by HotSpot.

Note: The heap of GraalVM Native Image executables has both read-only and read-write segments. The read-only part is referred to as the "image heap" and contains the data pre-initialized during the image build to help speed up start-up time. The read-write part is where allocations at runtime are made. Therefore, heap dumps generated at runtime will contain content from both the "image heap" and the read-write heap.

To see this flag in action, we need to manufacture a situation where a Quarkus application runs out of memory. One easy way to achieve this is to configure the application with a garbage collector that doesn’t do any memory reclamation, i.e. the Epsilon GC. Once the Quarkus application is running with Epsilon GC, apply some load and within a short space of time it will run out of memory and produce a heap dump.

Let’s do this using a Quarkus application that simply responds to an HTTP endpoint request as a starting point. The sample application can be downloaded from code.quarkus.io using a browser or via the command line:

$ wget "https://code.quarkus.io/d?S=io.quarkus.platform%3A3.8&cn=code.quarkus.io" -O code.zip
$ unzip code.zip
$ cd code-with-quarkus

Next, build a Quarkus native executable with GraalVM for JDK 21 configuring it to use Epsilon GC:

$ ./mvnw package -DskipTests -Dnative -Dquarkus.native.additional-build-args=--gc=epsilon

Note: The GC selection needs to be done at build time for Quarkus native applications.

While the Quarkus native executable is being produced you will be able to observe that GC is indeed configured to be EpsilonGC:

[INFO] --- quarkus-maven-plugin:3.9.3:build (default) @ getting-started-reactive ---
...
[1/8] Initializing...
 Java version: 21.0.2+13, vendor version: GraalVM CE 21.0.2+13.1
 Graal compiler: optimization level: 2, target machine: compatibility
 C compiler: gcc (redhat, x86_64, 13.2.1)
 Garbage collector: Epsilon GC (max heap size: 80% of RAM)

Once the build completes, start Quarkus with -XX:+HeapDumpOnOutOfMemoryError -XX:+ExitOnOutOfMemoryError. The latter forces the application to shutdown when an OutOfMemoryError occurs rather than leave the process in an indeterminate state:

$ getting-started-reactive-1.0.0-SNAPSHOT-runner -XX:+HeapDumpOnOutOfMemoryError -XX:+ExitOnOutOfMemoryError -Xmx64m
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2024-04-12 09:58:00,855 INFO  [io.quarkus] (main) getting-started-reactive 1.0.0-SNAPSHOT native (powered by Quarkus 3.9.3) started in 0.030s. Listening on: http://0.0.0.0:8080
2024-04-12 09:58:00,855 INFO  [io.quarkus] (main) Profile prod activated.
2024-04-12 09:58:00,855 INFO  [io.quarkus] (main) Installed features: [cdi, rest, smallrye-context-propagation, vertx]

Next, apply some load to the HTTP endpoint exposed by the Quarkus application, for example using curl in a bash loop:

$ while true; do curl http://localhost:8080/hello; done

Switching to the Quarkus console, an OutOfMemoryError would be observed along with some messages indicating that a heap dump is being produced:

2024-04-12 09:58:00,855 INFO  [io.quarkus] (main) getting-started-reactive 1.0.0-SNAPSHOT native (powered by Quarkus 3.9.3) started in 0.030s. Listening on: http://0.0.0.0:8080
2024-04-12 09:58:00,855 INFO  [io.quarkus] (main) Profile prod activated.
2024-04-12 09:58:00,855 INFO  [io.quarkus] (main) Installed features: [cdi, rest, smallrye-context-propagation, vertx]
Dumping heap to svm-heapdump-2213-OOME.hprof ...
Heap dump file created [98454651 bytes in 0.733 secs]
Terminating due to java.lang.OutOfMemoryError: Garbage-collected heap size exceeded.

Multiple graphical tools exist out there to inspect heap dumps, such as VisualVM, Eclipse Memory Analyzer (MAT), etc., but some new ones are also available that you can run in the command line to get a quick picture of the heap dump, for example Java Object Layout (JOL):

$ java -Xmx256m -DprintFirst=6 -jar jol-cli.jar heapdump-stats svm-heapdump-2213-OOME.hprof
Heap Dump: svm-heapdump-2213-OOME.hprof
Read progress: DONE

Hotspot Layout Simulation (JDK 21, Current VM: 12-byte object headers, 4-byte references, 8-byte aligned objects, 8-byte aligned array bases)

=== Class Histogram

Table is sorted by "INSTANCES".
Printing first 6 lines. Use -DprintFirst=# to override.

       INSTANCES            SIZE        SUM SIZE    CLASS
------------------------------------------------------------------------------------------------
         132,330              24       3,175,920    java.lang.String
          50,277              40       2,011,080    io.vertx.core.http.impl.headers.HeadersMultiMap$MapEntry
          44,852              40       1,794,080    com.oracle.svm.core.monitor.JavaMonitor
          38,755              32       1,240,160    java.util.HashMap$Node
          33,776              24         810,624    byte[5]
          31,728              48       1,522,944    java.util.HashMap
             ...             ...             ...    ...
         899,336       9,438,056      48,193,360    <other>
------------------------------------------------------------------------------------------------
       1,231,054       9,438,264      58,748,168    <total>

=== Class Histogram

Table is sorted by "SIZE".
Printing first 6 lines. Use -DprintFirst=# to override.

       INSTANCES            SIZE        SUM SIZE    CLASS
------------------------------------------------------------------------------------------------
               1       3,480,544       3,480,544    byte[3480528]
               1       3,236,728       3,236,728    byte[3236705]
               1         642,648         642,648    byte[642626]
               1         289,824         289,824    byte[289808]
               1         173,664         173,664    byte[173645]
               1         157,728         157,728    byte[157710]
             ...             ...             ...    ...
       1,231,048       1,457,128      50,767,032    <other>
------------------------------------------------------------------------------------------------
       1,231,054       9,438,264      58,748,168    <total>

=== Class Histogram

Table is sorted by "SUM SIZE".
Printing first 6 lines. Use -DprintFirst=# to override.

       INSTANCES            SIZE        SUM SIZE    CLASS
------------------------------------------------------------------------------------------------
               1       3,480,544       3,480,544    byte[3480528]
               1       3,236,728       3,236,728    byte[3236705]
         132,330              24       3,175,920    java.lang.String
          50,277              40       2,011,080    io.vertx.core.http.impl.headers.HeadersMultiMap$MapEntry
          10,054             184       1,849,936    io.quarkus.resteasy.reactive.server.runtime.QuarkusResteasyReactiveRequestContext
          44,852              40       1,794,080    com.oracle.svm.core.monitor.JavaMonitor
             ...             ...             ...    ...
         993,539       2,720,704      43,199,880    <other>
------------------------------------------------------------------------------------------------
       1,231,054       9,438,264      58,748,168    <total>

The presence of SubstrateVM, the VM that powers native images built with GraalVM, can be clearly observed because of the instances of com.oracle.svm.core.monitor.JavaMonitor present in the heap dump. What would the heap dump look like if we repeat exactly the same exercise but instead we use Quarkus JVM mode? Let’s see:

Rebuild the Quarkus app for JVM mode and run it with Epsilon GC:

$ mvnw package -DskipTests
$ java -XX:+HeapDumpOnOutOfMemoryError -XX:+ExitOnOutOfMemoryError -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xmx64m -jar quarkus-run.jar
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2024-04-12 10:02:37,945 INFO  [io.quarkus] (main) getting-started-reactive 1.0.0-SNAPSHOT on JVM (powered by Quarkus 3.9.3) started in 1.692s. Listening on: http://0.0.0.0:8080
2024-04-12 10:02:37,961 INFO  [io.quarkus] (main) Profile prod activated.
2024-04-12 10:02:37,962 INFO  [io.quarkus] (main) Installed features: [cdi, rest, smallrye-context-propagation, vertx]

After applying the same load, you would observe the heap dump being generated in JVM mode as well:

2024-04-12 10:02:37,945 INFO  [io.quarkus] (main) getting-started-reactive 1.0.0-SNAPSHOT on JVM (powered by Quarkus 3.9.3) started in 1.692s. Listening on: http://0.0.0.0:8080
2024-04-12 10:02:37,961 INFO  [io.quarkus] (main) Profile prod activated.
2024-04-12 10:02:37,962 INFO  [io.quarkus] (main) Installed features: [cdi, rest, smallrye-context-propagation, vertx]
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid2841.hprof ...
Heap dump file created [97383542 bytes in 1.232 secs]
Terminating due to java.lang.OutOfMemoryError: Java heap space

And this is what the heap dump looks like with JOL:

java -Xmx256m -DprintFirst=6 -jar jol-cli.jar heapdump-stats java_pid2841.hprof
Heap Dump: java_pid2841.hprof
Read progress: DONE

Hotspot Layout Simulation (JDK 21, Current VM: 12-byte object headers, 4-byte references, 8-byte aligned objects, 8-byte aligned array bases)

=== Class Histogram

Table is sorted by "INSTANCES".
Printing first 6 lines. Use -DprintFirst=# to override.

       INSTANCES            SIZE        SUM SIZE    CLASS
------------------------------------------------------------------------------------------------
          91,335              24       2,192,040    java.lang.String
          35,946              40       1,437,840    io.vertx.core.http.impl.headers.HeadersMultiMap$MapEntry
          34,942              32       1,118,144    java.util.HashMap$Node
          22,998              24         551,952    byte[5]
          22,789              48       1,093,872    java.util.HashMap
          22,381              32         716,192    java.util.concurrent.ConcurrentHashMap$Node
             ...             ...             ...    ...
         716,986      19,427,976      59,367,272    <other>
------------------------------------------------------------------------------------------------
         947,377      19,428,176      66,477,312    <total>

=== Class Histogram

Table is sorted by "SIZE".
Printing first 6 lines. Use -DprintFirst=# to override.

       INSTANCES            SIZE        SUM SIZE    CLASS
------------------------------------------------------------------------------------------------
               1         972,120         972,120    int[243026]
               1         416,136         416,136    int[104030]
               1         282,056         282,056    int[70510]
               1         237,608         237,608    byte[237587]
               1         131,920         131,920    int[32976]
               1         129,672         129,672    int[32414]
             ...             ...             ...    ...
         947,371      17,258,664      64,307,800    <other>
------------------------------------------------------------------------------------------------
         947,377      19,428,176      66,477,312    <total>

=== Class Histogram

Table is sorted by "SUM SIZE".
Printing first 6 lines. Use -DprintFirst=# to override.

       INSTANCES            SIZE        SUM SIZE    CLASS
------------------------------------------------------------------------------------------------
          91,335              24       2,192,040    java.lang.String
           7,189             232       1,667,848    io.quarkus.resteasy.reactive.server.runtime.QuarkusResteasyReactiveRequestContext
          35,946              40       1,437,840    io.vertx.core.http.impl.headers.HeadersMultiMap$MapEntry
          15,528              80       1,242,240    java.util.HashMap$Node[16]
          14,380              80       1,150,400    io.vertx.core.http.impl.headers.HeadersMultiMap$MapEntry[16]
          34,942              32       1,118,144    java.util.HashMap$Node
             ...             ...             ...    ...
         748,057      19,427,688      57,668,800    <other>
------------------------------------------------------------------------------------------------
         947,377      19,428,176      66,477,312    <total>

As expected, no SubstrateVM classes are present in this heap dump, leaving only Quarkus, Vert.x and OpenJDK types in the heap dump.