Blog

Building a “universal” Java application for macOS

On the architecture of Apple computers

Since 2020, Apple computers come in two flavours: those powered by Intel CPUs, and those powered by “Apple Silicon” M1 (soon M2) CPUs. Because those CPUs use different instruction sets (x86_64 for Intel CPUs; arm64 or aarch64 for Apple CPUs), programs compiled for one kind of CPU cannot, in principle, run on the other kind of CPU.

I wrote “in principle” because in reality, Apple uses a system called Rosetta that does allow programs compiled for the x86_64 architecture to run on arm64 CPUs – it’s a neat trick they had also used several years before, during the PowerPC-to-Intel transition, when it was possible to run programs compiled for PowerPC on (then new) Intel-based Macs.

Two things however should be noted about the Rosetta emulation thing. First, it only works in one direction, to run x86_64 binaries on arm64 machines – as far as I know, it does not allow to run arm64 binaries on x86_64 machines. Second, despite Apple’s claims that the technology is performant enough that users should not notice any difference when a program is run under emulation compared to when it is run on its proper target architecture, the truth is (unsurprisingly) that emulation does incur some performance penalties. Such penalties can be very much noticeable, and developers are advised to provide arm64-targeting binaries.

It is of course possible, for each application, to provide two variants: one for Intel-based Macs and one for Apple-based Macs, and to let users pick the appropriate variant for their machines. From a user perspective however, a better solution is to use another Apple trick: universal binaries.

Universal binaries, sometimes called “fat binaries”, are standard Mach-O binaries that happen to contain executable code for different architectures, for example for both x86_64 and arm64. When the executable is run on a Intel-based machine, the x86_64 code is executed; on a Apple-based machine, the arm64 code is executed. This allows to distribute a single binary that will run natively no matter the type of machine it is run on. (This is another trick that was also used during the PowerPC-to-Intel transition.)

The case of Java applications

Applications written in Java are typically compiled into “byte code” that targets a Java Virtual Machine (JVM) rather than a real CPU. Thus, such applications are normally oblivious of the architecture of the real computer they run on, as long as a Java Virtual Machine is available for that architecture.

Things do no work so well, however, when you combine Rosetta with a Java Virtual Machine – that is, when all you have to run your Java application on a M1-powered computer is a Java virtual machine intended for the x86_64 architecture. It does work, but the performance penalty for the double emulation layer (with Rosetta translating the x86_64 code of the virtual machine into arm64 code, which then has to execute the Java byte code​1) can hit really hard, which is not really surprising. So, Java applications should really be run with a Java virtual machine intended for the real architecture of the computer, and not rely at all on Rosetta.

Java virtual machines, or more formally Java Runtime Environments (JREs), are available for the arm64 architecture on macOS, so this shouldn’t be a problem. But since one cannot assume that such a JRE will be present on the user’s system, and users on macOS (and Windows for that matter) typically don’t expect to have to install a JRE themselves, it is more or less customary to distribute a Java application bundled with its own JRE so that the application can run “out of the box” once the user has downloaded it.2 This is then the subject of this note: How to package a self-sufficient Java application that could run natively on both x86_64 and arm64 Apple computers?

Building a “universal” JRE

The first step is to obtain a Java Runtime Environment that is itself made of universal binaries, targeting both x86_64 and arm64. As far as I know, no such JREs are already available for download. All providers of JREs, such as Adoptium or Azul, only provide JREs for a single architecture (either x86_64 or arm64). We thus need to build a “universal” variant of the JRE.

One method would be to build the entire JRE from the OpenJDK source code, somehow tweaking the build system to ask the compiler to generate universal binaries (-arch x86_64 -arch arm64). I have not investigated this, though it should most likely be possible.

Instead, a much easier and faster method is to simply combine a pre-built x86_64 JRE and a pre-built arm64 JRE into a single “universal” JRE. It’s actually as simple as it sounds and only requires the lipo tool that is provided with the XCode Command Line Tools. Among other tasks, lipo can take two single-architecture binaries and produce one dual-architecture binary:

$ lipo -create -output universal_binary x86_binary arm_binary

All we need to do then is to download both the x86_64 and arm64 variants of the same JRE (same supplier, same build), extract their contents side-by-side, and iterate over the files from one of them (say, the arm64 variant):

That’s it! At the end, we get a “universal” JRE that can run Java applications natively regardless of whether we are on an Intel-powered or M1-powered computer.

The following script contains the entire procedure:

#!/bin/bash

die() {
    echo "${0##*/}: $@" >&2
    exit 1
}

version=$(ls OpenJDK*-jre_x64_mac_hotspot_*.tar.gz | sed -E "s,OpenJDK.+-jre_x64_mac_hotspot_(.+).tar.gz,\1,")
[ -n "$version" ] || die "Cannot identify JRE version"

[ -f OpenJDK*-jre_aarch64_mac_hotspot_$version.tar.gz ] || die "Missing corresponding arm64 JRE"

echo "Extracting native JREs..."
rm -rf x86_64 arm64
mkdir x86_64 arm64
(cd x86_64
 tar xf ../OpenJDK*-jre_x64_mac_hotspot_$version.tar.gz)
(cd arm64
 tar xf ../OpenJDK*-jre_aarch64_mac_hotspot_$version.tar.gz)

echo "Creating universal JRE..."
rm -rf universal
mkdir universal
find arm64 -type f | while read arm_file ; do
    noarch_file=${arm_file#arm64/}
    mkdir -p universal/${noarch_file%/*}
    if file $arm_file | grep "Mach-O.\+arm64" ; then
        # Create universal binary from both x86_64 and arm64
        lipo -create -output universal/$noarch_file x86_64/$noarch_file $arm_file
        if file $arm_file | grep executable ; then
            chmod 755 universal/$noarch_file
        fi
    else
        # Not a file with binary code, copy it as it is
        cp $arm_file universal/$noarch_file
    fi
done

echo "Packaging the JRE..."
(cd universal/jdk*/Contents
 rm -rf Info.plist MacOS _CodeSignature
 mv Home jre)
jar --create --file jre.os-x-$version.jar -C universal/jdk*/Contents .

It has been tested with the JRE 11 from Adoptium.

Setting up a launcher

Now that we have a universal JRE that we can bundle with our Java application, what’s left to do is to setup a small “launcher” that will load the Java virtual machine and execute our Java program.

We’ll assume our application is packaged as follows:

$ find MyApplication.app
MyApplication.app
MyApplication.app/Contents
MyApplication.app/Contents/Info.plist
MyApplication.app/Contents/jre
MyApplication.app/Contents/jre/bin
MyApplication.app/Contents/jre/bin/java
[ … Other files from the JRE omitted for brievity … ]
MyApplication.app/Contents/MacOS
MyApplication.app/Contents/MacOS/MyApplication
MyApplication.app/Contents/jars
MyApplication.app/Contents/jars/my-application.jar
MyApplication.app/Contents/jars/some-dependency.jar
MyApplication.app/Contents/jars/some-other-dependency.jar

That is, the bundle contains a jre/ directory that itself contains the universal JRE we have prepared above, a MacOS directory that by convention contains the executable file that is the “entry point” of our application (that’s the launcher we need to setup; that file is referenced in the Info.plist file under the key named CFBundleExecutable), and a jars directory that contains the archives with the Java byte code. The bundle could of course also contain any kind of additional files that would be needed by our applications, such as data or configuration files, icons, help files, etc.

Let’s also assume the main class of our Java program is called org.incenp.myapplication.Launcher and is located in the my-application.jar archive.

A naive launcher

A very naive (too naive, as we’ll see in a minute) launcher in MacOS/Application would be a shell script as follows:

#!/bin/zsh

cd "$(dirname $0)/.."

./jre/bin/java \
  -cp jars/my-application.jar:jars/some-dependency.jar:jars/some-dependency.jar \
  org.incenp.myapplication.Launcher

This script is very simple as it basically does two things: 1) moving to the Contents directory (one directory above the MacOS directory containing the launcher script), and 2) running the bundled java executable with the appropriate class path and main class name.

Unfortunately, while it superficially seems to work, it actually does not ensure our Java application will run natively. For some reasons, the launcher script and all processes started from it may very well end up running under Rosetta emulation. It’s unclear to me why this can happen (and even more unclear why it doesn’t happen systematically; sometimes it works as expected, sometimes it doesn’t), but according to an Apple developer this is actually not surprising as it is apparently well known that using a shell script as the executable entry point for an application bundle is asking for unnecessary troubles. Apple developers strongly recommend that the entry point should always be a compiled binary rather than an interpreted script. So much for our simple launcher.

A still simple C launcher

Let’s thus replace the above script by a very simple C program:

#include <stdlib.h>
#include <unistd.h>
#include <limits.h>
#include <string.h>
#include <err.h>
#include <mach-o/dyld.h>

/* Remove the last n components of a pathname. The pathname
 * is modified in place. Returns 0 if the requested number
 * of components have been removed, -1 otherwise. */
static int
remove_last_components(char buffer *, unsigned n)
{
    char *last_slash = NULL;

    while ( n-- > 0 ) {
        if ( (last_slash = strrchr(buffer, '/')) )
            *last_slash = '\0';
    }

    return last_slash ? 0 : -1;
}

int
main(int argc, char **argv)
{
    char app_path[PATH_MAX]
    uint32_t path_size = PATH_MAX;
    int ret;

    (void) argc;
    (void) argv;

    /* Get the path to the "Contents" directory. */
    if ( _NSGetExecutablePath(app_path, &path_size) == -1 )
        err(EXIT_FAILURE, "Cannot get application directory");
    if ( remove_last_component(app_path, 2) == -1 )
        errx(EXIT_FAILURE, "Cannot get application directory");

    /* Move to that directory. */
    if ( chdir(app_path) == -1 )
        err(EXIT_FAILURE, "Cannot change current directory");

    /* Run the java program. */
    ret = system("./jre/bin/java "
                 "-cp jars/my-application.jar:"
                     "jars/some-dependency.jar:"
                     "jars/some-dependency.jar "
                 "org.incenp.myapplication.Launcher");

    return ret;
}

A more refined version would probably use a function of the exec() family rather than system(), so as to avoid spawning a new process, but for the purpose of this note system() will do – this keeps the C code very simple as a more-or-less direct translation of the shell script above.

Let’s compile this file as a universal binary:

$ clang -o MyApplication -arch x86_64 -arch arm64 launcher.c

Put the resulting MyApplication binary under the MacOS directory in the application bundle (replacing our too naive launcher script).

The last thing to do is to add the following key in the Info.plist dictionary, to be really sure that on M1, macOS will execute the arm64 code of our launcher:

<key>LSRequiresNativeExecution<key>
<true />

That’s it! Now, trying to run MyApplication.app on a M1 machine should always result on the arm64 JVM being used, removing any performance penalty due to Rosetta emulation.

A more complex launcher

The simple C launcher above works well, save for a little (or not so little, depending on what your application does) detail: the Java application started with that launcher will be unable to receive Apple events, a problem that will manifests itself by the fact that some event handlers set up with the java.awt.Desktop API will not work.

If your Java application doesn’t make use of java.awt.Desktop, you can still stick with that launcher and simply ignore the problem. If you do need your application to respond to Apple events however, you need a more elaborate launcher.

The key point for java.awt.Desktop to work as expected is that the Java virtual machine must run in a dedicated thread, while the first thread of the launcher runs an infinite loop in which it is available to receive the events sent by the operating system.

This raises the complexity of the launcher significantly, since we can no longer rely on executing the bundled java program: doing that with system() would mean that the Java virtual machine would run in a different process than the one to which the operating system sends the events; using exec() would keep the virtual machine in the same process but would automatically terminate the events-receiving thread. Instead, we need to “manually” load the Java library and start the virtual machine directly from the C code.

Here’s a rudimentary example of such a launcher:

#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <err.h>
#include <dlfcn.h>
#include <pthread.h>

#include <mach-o/dyld.h>
#include <CoreFoundation/CoreFoundation.h>

#include <jni.h>

typedef jint (JNICALL CreateJavaVM_t)(JavaVM **, void **, void *);

static char app_dir[PATH_MAX];

/* Dummy callback for the main thread loop. */
static void
dummy_callback(void *info) { }

static char *
get_application_directory(char *buffer, uint32_t len)
{
    char *last_slash = NULL;
    int n = 2;

    if ( ! _NSGetExecutablePath(buffer, &len) ) {
        while ( n-- > 2 ) {
            if ( (last_slash = strrchr(buffer, '/')) )
                *last_slash = '\0';
        }
    }

    return last_slash ? buffer : NULL;
}

/* Execute the main method of our application. */
static int
start_java_main(JNIEnv *env)
{
    jclass main_class;
    jmethodID main_method;
    jobjectArray main_args;

    if ( ! (main_class = (*env)->FindClass(env, "org.incenp.myapplication.Launcher")) )
        return -1;

    if ( ! (main_method = (*env)->GetStaticMethodID(env, main_class, "main",
                                                    "([Ljava/lang/String;)V")) )
        return -1;

    main_args = (*env)->NewObjectArray(env, 0,
                                       (*env)->FindClass(env, "java/lang/String"),
                                       (*env)->NewStringUTF(env, ""));

    (*env)->CallStaticVoidMethod(env, main_class, main_method, main_args);

    return 0;
}

/* Load and start the Java virtual machine. */
static void *
start_jvm(void *arg)
{
    char lib_path[PATH_MAX];
    void *lib;
    JavaVMInitArgs jvm_args;
    JavaVMOption jvm_opts[1];
    JavaVM *jvm;
    JNIEnv *env;
    CreateJavaVM_t *create_java_vm;

    (void) arg;

    /* Load the Java library in the bundled JRE. */
    snprintf(lib_path, PATH_MAX, "%s/jre/lib/jli/libjli.dylib", app_dir);
    if ( ! (lib = dlopen(lib_path, RTLD_LAZY)) )
        errx(EXIT_FAILURE, "Cannot load Java library: %s", dlerror());

    if ( ! (create_java_vm = (CreateJavaVM_t *)dlsym(lib, "JNI_CreateJavaVM")) )
        errx(EXIT_FAILURE, "Cannot find JNI_CreateJavaVM: %s", dlerror());

    /* Prepare options for the JVM. */
    jvm_opts[0].optionString = "-Dclass.path=jars/my-application.jar"
                                           ":jars/some-dependency.jar"
                                           ":jars/some-other-dependency.jar";
    jvm_args.version = JNI_VERSION_1_2;
    jvm_args.ignoreUnrecognized = JNI_TRUE;
    jvm_args.options = jvm_opts;
    jvm_args.nOptions = 1;

    if ( create_java_vm(&jvm, (void **)&env, &jvm_args) == JNI_ERR )
        errx(EXIT_FAILURE, "Cannot create Java virtual machine");

    if ( start_java_main(env) != 0 ) {
        (*jvm)->DestroyJavaVM(jvm);
        errx(EXIT_FAILURE, "Cannot start Java main method");
    }

    if ( (*env)->ExceptionCheck(env) ) {
        (*env)->ExceptionDescribe(env);
        (*env)->ExceptionClear(env);
    }

    (*jvm)->DetachCurrentThread(jvm);
    (*jvm)->DestroyJavaVM(jvm);

    /* Calling exit() here will terminate both this JVM thread and the
     * infinite loop in the main thread. */
    exit(EXIT_SUCCESS);
}

int
main(int argc, char **argv)
{
    pthread_t jvm_thread;
    pthread_attr_t jvm_thread_attr;
    CFRunLoopSourceContext loop_context;
    CFRunLoopSourceRef loop_ref;

    (void) argc;
    (void) argv;

    if ( ! get_application_directory(app_dir, PATH_MAX) )
        errx(EXIT_FAILURE, "Cannot get application directory");

    if ( chdir(app_dir) == -1 )
        err(EXIT_FAILURE, "Cannot change current directory");

    /* Start the thread where the JVM will run. */
    pthread_attr_init(&jvm_thread_attr);
    pthread_attr_setscope(&jvm_thread_attr, PTHREAD_SCOPE_SYSTEM);
    pthread_attr_setdetachstate(&jvm_thread_attr, PTHREAD_CREATE_DETACHED);
    if ( pthread_create(&jvm_thread, &jvm_thread_attr, start_jvm, NULL) != 0 )
        err(EXIT_FAILURE, "Cannot start JVM thread");
    pthread_attr_destroy(&jvm_thread_attr);

    /* Run a dummy loop in the main thread. */
    memset(&loop_context, 0, sizeof(loop_context));
    loop_context.perform = &dummy_callback;
    loop_ref = CFRunLoopSourceCreate(NULL, 0, &loop_context);
    CFRunLoopAddSource(CFRunLoopGetCurrent(), loop_ref, kCFRunLoopCommonModes);
    CFRunLoopRun();

    return EXIT_SUCCESS;
}

Compile the launcher for both x86_64 and arm64 architecture. You’ll need the include files from the Java Development Kit.

$ clang -arch x86_64 -arch arm64 \
  -I<path_to_the_jdk>/include -I<path_to_the_jdk>/include/darwin \
  -framework Cocoa -o MyApplication launcher.c

Place the resulting MyApplication binary in the MacOS directory of the application bundle.

And this time, this is really it. We have a self-sufficient Java application that will run natively on either Intel-powered or M1-powered Apple computers and that will be able to react correctly to desktop events.

  1. or, if Just-In-Time compilation is used, to translate that Java byte code into x86_64 code that Rosette again needs to translate into arm64 code…
  2. This is less of a problem on GNU/Linux, where the package for a Java application can depend on a JRE package, so that installing the Java application will automatically install a JRE as well.

You can add a comment by replying to this message on the Fediverse.