A summary of my lessons learned converting an existing JavaFX application to a native, AOT compiled, real-world application using the Gluon Client Maven plugin which is based on the GraalVM native-image toolchain.

1. Overview

First of all I would like to thank the team at Gluon which made all the following possible. Without their continuous, hard work it would not be possible at all to compile a modern JavaFX application into a native platform application. My thanks of course also include all the people involved in the GraalVM project which laid the foundation for the work done for JavaFX by Gluon.

This article summarizes the experiences which I collected while converting one of my existing Java applications to a native application using the Gluon Client Maven plugin. This is not one of the many demos you can find on the internet, but an application that is still relatively small and manageable but otherwise a real-world application with a lot of external dependencies and technical challenges. It was not always an easy ride but in the end it worked out nicely.

Actually this exercise was just the prelude to the real goal which is to also run this, and other similar applications, on mobile platforms like iOS and Android. (My main target is Android though, just because I own an Android phone and tablet but not any iOS device.) Now that the first step has been taken, I now eagerly await the general availability of the Android support of course.

000 mac DAeCAirspaceValidator
Figure 1. Screenshot of the application on the Mac.

2. Requirements

Just follow the instructions given on the project page at GitHub. https://github.com/gluonhq/client-maven-plugin Further documentation and some samples can be reached from this entry page too. There is also a Gradle plugin, but at the moment the focus of the development seems to be on the Maven plugin.

3. Build infrastructure

The GraalVM native-image tool is very memory hungry. When your build times get longer and longer, you are probably running out of memory. My initial hello-world builds were mostly finished within 2 - 4 minutes but when I started to do bigger builds the times went up to 7, 22 and finally more than an hour. This turned out to be caused by the insuffcient amount of RAM in my old MacBook Pro, which only had 8 GB of RAM. Now I use a Mac mini with 16 GB of RAM and my build times, even for larger projects, are back in the 3 minutes range. So, using a development machine with enough memory is essential and having even 32 GB of RAM available certainly does not hurt.

Having a fast multi-core CPU also does not hurt. I have seen CPU utilizations of up to 1200%, which is probably the best you can get from a 6 core CPU with hyperthreading.

4. Build configuration

4.1. Options

It is advisable to use the following native-image options for the build:

  • -ea

  • --verbose

The option -ea enables assertions in the resulting image and this is very helpful. It is not as easy as in Java to debug a native image and therefore it is helpful to use a lot of assertions in your code to be notified as early as possible about potential problems, e.g., resources which have not been loaded.

The option --verbose makes the output of the build process more verbose and this helps in case something goes wrong. As a build takes a while, it makes sense to always use this option so that you do not have to repeat the build in case something goes wrong and you don’t know why.

4.2. System properties

When calling native-image you can define system properties but these are only visible to the VM during the build process but not later at run-time of the native application. This can cause some confusion because for classes which are initialized at build-time, these system properties would be defined, whereas for classes which are initialized at run-time they wouldn’t.

A concept, how they can be made visible at run-time too, is explained here https://github.com/oracle/graal/issues/779 but this does not seem to work anymore because classes are now initialized at run-time by default and not at build-time as in previous versions. In order to circumvent this problem I created a separate class for this and defined via the appropriate command line option --initialize-at-build-time that this particular class should be initialized at build time. This did the trick and it works now.

Don’t try to be too smart when writing this class. Only write primitive code because otherwise native-image will refuse to initialize this class at build-time.

5. GraalVM/native-image limitations and issues

GraalVM native-image still has several limitations which may bite you in real-world projects. So I strongly advise you to read the following document which summarizes most of these limitations.

The ones I stumbled over most often where:

  • Reflection configuration (Everywhere)

  • Method Handles not supported (Log4J, NSMenuFX)
    (This issue seems to be mostly fixed now but NSMenuFX still does not work for other reasons.)

  • Serialization not supported (Disk cache)
    (This issue seems to be fixed with GraalVM 21.0.0)

  • Soft-References not working as expected (RAM cache)
    (This issue seems to be fixed now: https://github.com/oracle/graal/issues/2145)

  • Only a single (default) locale
    (This issue is supposed to be fixed in version 21.1.0 (20. April 2021): https://github.com/oracle/graal/issues/911#issuecomment-745209431)

  • Media not supported on all platforms

I’ll go into more details in the following sections.

5.1. Reflection

The use of reflection is ubiquitous in the Java world which poses a problem for any AOT (ahead of time) compilation of Java code because which classes are accessed via reflection is not always known at build time. Some uses can be detected automatically but for others a list of classes must be provided by the user at build time.

One way to make this task less tedious and error prone, is to use the tracing agent.

This agent collects relevant data by analyzing the software when executed via a standard Java virtual machine. It’s a pity though that the output of this agent cannot yet be integrated directly into the configuration of the client-maven-plugin.

(This issue seems to be mostly fixed now because you can use the tracing agent via the client-maven-plugin.)

5.2. Resources

Resources can be delt with in a similar way as reflection. The nice thing is that you can specify which resources to load via wild cards. In my case it was enough to specify the following resource list:

<resourcesList>
    <list>.*\\.properties$</list>
    <list>.*\\.vert$</list>
    <list>.*\\.wav$</list>
    <list>.*\\.json$</list>
    <list>.*\\.COF$</list>
</resourcesList>

A special case of this are language resource bundles which are also properties but have to be specified in a separate list. It would be very tedious if you would have to explicitly differentiate between general properties and language bundles but in my case I found it to be ok to keep the properties wild card in the resource list and separately add the language bundles to the bundles list like this.

<bundlesList>
    <list>com.mycompany.myproject.Main</list>
    <list>com.mycompany.myproject.airspaces.Airspaces</list>
    <list>com.mycompany.myproject.maps.Maps</list>
    <list>controlsfx</list>
</bundlesList>

5.3. Method handles

According to the documentation, method handles are not supported.

This has severe consequences for several libraries and frameworks.

5.3.1. Logging

Logging frameworks are notorious users of all kind of reflection magic (I still don’t understand why) which falls onto your feet when you use native-image. The worst of all is Log4J.

I finally had to completely abandon Log4J (and in retrospect I wonder why I have ever used it at all). This switch was made easy for me by the fact that I have consistently used the SLF4J facade throughout all my software, so the only necessary change was the configuration of the logging framework and rewriting my own JFX logging handler. I finally ended up using the standard Java logging because that is supported out of the box with native-image. The simple variant of SLF4J also worked but it would have been more complicated to rewrite my JFX logging handler.

One problem remains though. I simply can’t get the FileHandler working. See: https://github.com/gluonhq/client-maven-plugin/issues/125

5.3.2. NSMenuFX

Another library I used was NSMenuFX to get a decent system menu integration for the Mac, which JavaFX does not provide by default, but it failed with native-image. After a lot of research (thanks José https://github.com/gluonhq/substrate/issues/118 ) I finally learned that this is also due to the internal use of method handles.

So I first created an issue https://github.com/codecentric/NSMenuFX/issues/31 on GitHub and finally fixed the problem myself and created a pull-request, which has now been integrated into the latest release of NSMenuFX.

However, my frustration grew again when I finally realized that this was all in vain and NSMenuFX still did not work because the system menu bar is in general not yet supported. This isn’t nice for the Mac version but as my real goal is the Android version it is not such a big problem because on Android I won’t need the system menu bar anyway.

5.4. Serialization

I used Java serialization for a temporary disk cache but serialization is currently not supported. So I now have to live without disk cache. (The issue was not serious enough to justify a switch to another fast serialization technique.)

5.5. Soft references

(This issue seems to be fixed now: https://github.com/oracle/graal/issues/2145)

I used a temporary RAM cache in my code which was based on Javas soft-references. The result was that my native code felt slow and was not very responsive and I was actually very disappointed. Finally I found out that this happened because my cache was almost always empty and so my software had to load everything from disk over and over again. GraalVMs native-image handles references differently than the Java VM does, which has the effect that all soft-references are always immediately cleared and thus became useless to me.

There is only one small sentence in the documentation which hints at this deviation.

I learned from Laurent Bourgès that the MarlinFX renderer uses soft-references by default to hold its own renderer context. It should therefore be tuned for GraalVM native-image to use hard references instead: -Dprism.marlin.useRef=hard

5.6. Single locale

A severe, not very well documented, limitation of native-image is the fact that currently only one locale is supported. You have to decide at build time which locale you want to use for your application. If you want to support more than one locale you have to build separate versions of your application. One for each supported locale.

This is already a pain but it gets worse if you look at the possible side effects this can have. In fact you cannot even parse a simple string value which does not adhere to the conventions of your chosen built-in locale.

(This issue is supposed to be fixed in version 21.1.0 (20. April 2021): https://github.com/oracle/graal/issues/911#issuecomment-745209431)

6. JavaFX/Substrate limitations and issues

The JavaFX part of the native image creation currently also has some limitations.

6.1. System menus

The system menu bar is currently not supported (see above).

6.2. AWT

AWT is currently not supported. This would not be such a big deal if some features of JavaFX did not depend on it.

  • javafx.application.HostServices.showDocument (fails on Mac)

Some other uses of AWT do work, e.g., image reading and writing. In order to save a JavaFX image it has to be converted to an AWT BufferedImage first, so that it can then be saved via ImageIO. That works although it is part of AWT.

It would probably be a good idea in general to make JavaFX completely independent from AWT.

6.3. Audio

Playing AudioClips currently does not seem to work because the glib-lite library is missing.

6.4. Image size

The size of the created executable file currently seems to be quite large. In my case, of a still quite small application, the size is already 100 MB, which is more than the whole .app bundle created by jpackage, which has only 73.8 MB if I bundle everything or only 58.8 MB if I use the Maven shade plugin with the option minimizeJar switched on.

If jlink would put a bit more effort into it, the size of the .app bundle could even be further reduced substantially by more selectively loading code and resources and not just doing so on a whole module basis.

(This issue can be solved via UPX. See: https://upx.github.io/)

6.5. Performance

The performance of the community editon of native-image sometimes seems to be much worse than the standard VM with HotSpot due to some missing code optimizations. See: https://github.com/bourgesl/perfFX

7. Special cases

7.1. SQLite

It took me some time to get SQLite working but in the end all I had to do is to add the following items to the POM.

<jniList>
    <list>org.sqlite.core.DB</list>
    <list>org.sqlite.core.NativeDB</list>
    <list>org.sqlite.BusyHandler</list>
    <list>org.sqlite.Function</list>
    <list>org.sqlite.ProgressHandler</list>
    <list>org.sqlite.Function$Aggregate</list>
    <list>org.sqlite.Function$Window</list>
    <list>org.sqlite.core.DB$ProgressObserver</list>
</jniList>
<resourcesList>
    <list>org/sqlite/native/Mac/${os.arch}/.*</list> <!-- Only for SQLite -->
</resourcesList>

The last entry is tricky. The path contains the platform specifc shared library of the native part of SQLite. (Change Mac to the right one for your platform. Just ${os.name} does not work.)

8. Open issues

8.1. Fully or partially blank panes

When something goes wrong during the initialization of a view, I often have the situation that I am just confronted with a blank stage or pane without any error message or stack trace. It is then very difficult to track down what the actual cause of the problem is. I mostly have this problem when initializing views via FXML.

8.2. FXML

The use of FXML is a PITA. All classes are loaded via reflection and so must be present in the final reflection list. Some classes are already included in this list by default, others (most ?) must be added manually. I finally adopted the habbit to just copy the import section of each FXML file because there you already have a list of all classes used by this file if this file was created by SceneBuilder which luckily does not use the wildcard notation.

In order to make this task at least a little bit less cumbersome, I have written a tool for myself to collect this information. I have published it on GitHub, just in case someone has the same need for such a tool like I had. It is not perfect but it helps a little. https://github.com/mipastgt/JFXToolsAndDemos#fxml-checker

Another annoying problem is that sometimes it is not sufficient to just put the class you want to load into this list. E.g., if you want to load a ProgressBar and have put this class into the refection list, you will still get the following error: ProgressBar Property "progress" does not exist or is read-only. The reason is that the property "progress" is defined in the super-class of ProgressBar and so you have to specify ProgressIndicator as well.

8.3. UnsatisfiedLinkErrors

Some native libraries seem to be missing from substrate and so you will get UnsatisfiedLinkErrors.

  • java.util.logging.FileHandler
    See: https://github.com/gluonhq/client-maven-plugin/issues/125

  • com.sun.imageio.plugins.jpeg.JPEGImageReader
    symbol: Java_com_sun_imageio_plugins_jpeg_JPEGImageReader_initJPEGImageReader or Java_com_sun_imageio_plugins_jpeg_JPEGImageReader_initJPEGImageReader__

  • no jfxwebkit in java.library.path

8.4. Misleading error messages

Very often the error messages you get are very misleading. At a first glance an error message like java.lang.IllegalArgumentException: Unable to coerce CENTER to class javafx.geometry.HPos. is very cunfusing because CENTER definitely is a valid member of HPos. The actual reason for this error message is that javafx.geometry.HPos is just missing in the reflection list. Error messages should give a more precise hint on the real cause for an error.

8.5. JAXB

For me JAXB is the workhorse for dealing with XML files but this seems to be a hard problem for GraalVM/native-image.

I got this working in a separte test program for some GPX files by following the hints in the above link. I can now read and write such files. (At least the ones I have tested, but that is another issue.)

However, this involves the use of the tracing agent which is currently not supported by the Client-Maven-Plugin and when I tried to transfer the results of the agent manually I got stuck because there is currently also no proxy list support.

Until now I have not found a solution for this and thus cannot read or write any XML files in my real software, which limits its usability quite a bit.

(This issue seems to be mostly fixed now because you can use the tracing agent via the client-maven-plugin.)

9. Java VM vs. GraalVM/native-image comparison

This is a subjective comparison of a standard Java VM (Oracle OpenJDK 14 EA) versus the GraalVM/native-image community edition (20.0.0 utilized by GluonHQ/substrate via Client-Maven-Plugin).

Table 1. Table Java VM vs. GraalVM/native-image comparison
Feature Java VM GraalVM/native-image

Works on Mobile
(iOS, Android)

-

+

Development experience

+

-

Feature completeness

+

0

Startup time

0

+

Warmup time

0

+

Peak performance

+

0

Bundle size

0

- (pure), + (with UPX)

Some remarks on the table:

  • The startup time of the Java VM could be further reduced if AppCDS would also work for reduced runtime images created via jlink. The current advantage of AOT compilation could be reduced in this respect.

  • Also the warmup time of the Java VM could be further reduced via profile guided optimization.

Taking all this into account, the real driver to use GraalVM/native-image is the promise that it will allow the use of the latest standard Java/JavaFX on mobile devices too and thus make it possible to cover the mobile, embedded and desktop sector with a single code base. For a pure desktop environment its usefulness is currently still questionable due to various limitations and the development overhead, but we are making progress.

10. Conclusion

This is only a snapshot of my experiences so far in getting a real-world JavaFX application compiled into a native image. If I have missed something important or you think you can help me with one of the open issues, just drop me a line or create an issue here.

Once you have circumvented all the mentioned problems, the resulting binary seems to be quite stable and the performance is also relatively good. So, I am looking forward to do the next step and compile the whole application as an Android app.