Skip to content

Bundling Java apps

probonopd edited this page Sep 15, 2019 · 8 revisions

Bundling a subset of Java using AdoptOpenJDK Openj9 jlink

As of 2019, we can use https://adoptopenjdk.net/ to produce a subset of Java just enough to run our payload application.

The following illustrates how to do this for an application that is provided by upstream without a JRE.

wget -c https://github.com/AdoptOpenJDK/openjdk11-binaries/releases/download/jdk-11.0.3%2B7_openj9-0.14.3/OpenJDK11U-jdk_x64_linux_openj9_11.0.3_7_openj9-0.14.3.tar.gz
tar xf OpenJDK11U-jdk_x64_linux_openj9_11.0.3_7_openj9-0.14.3.tar.gz

wget -c https://github.com/bengtmartensson/harctoolboxbundle/releases/download/ci-build/IrScrutinizer-1.4.3-SNAPSHOT-x86_64.AppImage

sudo mount ./IrScrutinizer-1.4.3-SNAPSHOT-x86_64.AppImage /mnt
sudo cp -r /mnt/ AppDir && sudo chown -R $USER AppDir
sudo umount /mnt 

#
# Find out which subset of the Java classes need to be bundled
#

./jdk-11.0.3+7/bin/jdeps  --list-deps AppDir/usr/share/irscrutinizer/IrScrutinizer-jar-with-dependencies.jar

   java.base
   java.datatransfer
   java.desktop
   java.logging
   java.xml

# For other applications, it may be necessary to use --module-path, see
# https://medium.com/azulsystems/using-jlink-to-build-java-runtimes-for-non-modular-applications-9568c5e70ef4

#
# Create our custom subset of Java
#

./jdk-11.0.3+7/bin/jlink --no-header-files --no-man-pages --compress=2 --strip-debug --add-modules java.base,java.datatransfer,java.desktop,java.logging,java.xml --output usr

#
# Copy it into the AppDir and re-create the AppImage
#

cp -Rf usr AppDir/

wget -c https://github.com/AppImage/AppImageKit/releases/download/12/appimagetool-x86_64.AppImage
chmod +x appimagetool-x86_64.AppImage 
./appimagetool-x86_64.AppImage AppDir/

# Launcher script needs to be adjusted a bit, make sure to run
# java -jar -Xquickstart
# because otherwise Java optimizes for long-running processes,
# which means a slower start (roughly 5 rather than 3 seconds)
# https://www.eclipse.org/openj9/docs/xquickstart/

# From 76 MB to 37 MB

# TBD: Use Shared Classes Cache?

Issues

me@host:~$ ./jdk-11.0.3+7/bin/jdeps  --list-deps  squashfs-root/usr/bin/MediathekView.jar
Warning: split package: javax.transaction.xa jrt:/java.transaction.xa squashfs-root/usr/bin/MediathekView.jar
   JDK removed internal API/apple.laf
   JDK removed internal API/com.apple.laf
   JDK removed internal API/com.sun.java.swing.plaf.windows
   JDK removed internal API/sun.awt.windows
   JDK removed internal API/sun.swing
   JDK removed internal API/sun.swing.plaf.synth
   java.base/sun.security.action
   java.compiler
   java.datatransfer
   java.desktop/sun.awt
   java.desktop/sun.awt.image
   java.desktop/sun.awt.shell
   java.desktop/sun.swing
   java.instrument
   java.logging
   java.management
   java.naming
   java.prefs
   java.rmi
   java.scripting
   java.security.jgss
   java.security.sasl
   java.sql
   java.xml
   jdk.jfr
   jdk.jsobject
   jdk.unsupported
   jdk.unsupported.desktop
   jdk.xml.dom

me@host:~$ ./jdk-11.0.3+7/bin/jlink --no-header-files --no-man-pages --compress=2 --strip-debug --add-modules java.base/sun.security.action,java.compiler,java.datatransfer,java.desktop/sun.awt,java.desktop/sun.awt.image,java.desktop/sun.awt.shell,java.desktop/sun.swing,java.instrument,java.logging,java.management,java.naming,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql,java.xml,jdk.jfr,jdk.jsobject,jdk.unsupported,jdk.unsupported.desktop,jdk.xml.dom --output usr
Error: Module java.desktop/sun.awt.image not found

What to do?


Option 1: Using javapackager / jlink (JDK9+, 64 bit only)

The preferred way of packaging JavaFX and Java desktop applications in Java 9+ is the javapackager. This guide assumes you already know how to use it (e.g. for .dmg or .exe packaging).

For self-contained applications, the Java Packager for JDK 9 packages applications with a JDK 9 runtime image generated by the jlink tool.

Step 1:

Generate Java application image using java packager. Use the -native image option, which (on Linux) produces the application directory containing both, your application and the JVM. There is also an Ant library to invoke javapackager (example build.xml) as well as a variety of community-based plugins for other build tools such as Maven or Gradle.

Your resulting java application image should look like this:

./libpackager.so          # runtime library generated by javapackager
./YourApp                 # binary generated by javapackager
./app/                    # your actual java application
./app/your-app.jar
./app/some-lib.jar
./app/some-resource.xml
./runtime/                # contains the JVM
./runtime/...

Step 2 (optional):

ℹ️ This step is only needed for non-jigsaw apps if you whish to reduce the size of your embedded JVM. If your project happens to be jigsaw compatible, the JVM will automatically be stripped down to only the modules needed by your application.

Otherwise you can try to reduce the footprint of your JVM manually. The --limit-modules flag doesn't seem to work at the moment of writing this. Don't know if it's a bug or a feature. If you know exactly what modules you depend on (you can use the jdeps tool to analyze module dependencies), you can instead use jlink manually to create a customized version of the JVM.

In this example jlink is used to fully replace the runtime/ directory of the generated java application image from the previous step.

⚠️ Make sure to invoke jlink with the --strip-native-commands argument, since you don't want to distribute a JVM that can be started from third party processes. This increases security, reduces the size of your image and fits the strategy of AppImages to isolate applications from each other.

Step 3:

Now just copy the full java application image to YourApplication.AppDir and create a symlink from AppRun to YourApp (make sure to use relative paths!).

You also need a desktop file as well as an application icon at the root of your AppDir. The resulting AppDir should look like this:

YourApplication.AppDir/AppRun               # relative symlink to YourApp
YourApplication.AppDir/libpackager.so
YourApplication.AppDir/YourApp
YourApplication.AppDir/YourApp.desktop      # desktop file
YourApplication.AppDir/YourApp.png          # application icon
YourApplication.AppDir/app/
YourApplication.AppDir/app/your-app.jar
YourApplication.AppDir/app/some-lib.jar
YourApplication.AppDir/app/some-resource.xml
YourApplication.AppDir/runtime/
YourApplication.AppDir/runtime/...

This is the very minimal AppDir containing all necessary files. Though, you might also want to include a usr/share/applications and usr/share/icons directory with its respective contents to improve desktop integration. See AppDir for details.

This is an example of step 3.

Side note: The binary created by javapackager will not run, if it resides inside a path that contains bin, presumably due to some security checks. So other than normal AppImages you must not store it in usr/bin/.

Step 4:

Now, that your AppDir is ready, you can create the actual AppImage. Download appimagetool and invoke:

appimagetool-x86_64.AppImage YourApplication.AppDir YourApplication.AppImage

Option 2: Bundling JRE manually

Here is what I did for my project. It is not a perfect solution, but it works for me. Please feel free to improve and/or generalize this information, but it should be a good starting point.

Step 1 - Gather the JRE(s) you need for your project

I couldn't find a standalone version of the OpenJRE to download, but I used the Open JRE from Ubuntu and it works on every linux distribution I tested, following the testing guidelines given by appimage (old debian, etc.)

The command cp -rL /usr/lib/jvm/java-1.8.0-openjdk-amd64/jre linux-jre did the trick for me. -r is for recursive. -L replaces sym links with the link targets.

It would almost certainly be better to unpack the java runtime .deb file rather than using the method I outlined above, but I spent a little time trying to get that to work and couldn't. I think you may need to merge the contents of more than one deb together to get the full jre. If you get that working, please let me know or edit it in.

If you are going to release a 64 bit and 32 bit version, you need to do this on a 32 bit linux distribution as well.

Anyway, save that JRE for later.

Step 2 - Write your launcher script

Here's mine. It's simple and direct. We're going to launch this using appimage's built in launcher script, so it doesn't need to do anything fancy:

 #!/bin/bash
 
 DIR="$(dirname "$(readlink -f "$0")")"
 cd $DIR
 ./jre/bin/java -jar <any jvm arguments you need> target.jar "$@" &
 disown
 exit 0

The first thing it does it change the working directory to the directory of this script. My program expects that and you can't really change the working directory in java, so I do it here.

Then I launch java, calling my program, passing the commandline arguments with $@. Notice the & at the end of the command. This means "run in the background".

Then we disown the java process, meaning we can exit this script without terminating the jvm and our program.

Then we exit. Done!

Step 3 - Setup your Desktop File

Here's mine:

[Desktop Entry]
Name=Hypnos
Exec=hypnos %F
Icon=hypnos
Type=Application
Categories=Audio;AudioVideo;
Comment=Music Player and Library
MimeType=inode/directory;audio/flac;
Name[en]=Hypnos
Terminal=false
StartupNotify=true
NoDisplay=false

Save this as [program name].desktop and keep it for the next step.

Step 4 - Setup your AppDir

Here's how mine is structured. I'm using my actual file names because it's easier and more clear than typing [program name] all the time.

Hypnos.AppDir/
    usr/
       bin/
          jre/ <-- our jre folder from step 1 
          hypnos  <-- our launch script from step 2
          hypnos.jar <-- your program's jar file
          <Whatever other resources your program uses>
          <this is your program's main directory>      
    hypnos.desktop
    Hypnos.png
    AppRun <-- provided by app image

Step 5 - Create the appimage

Use the appimage tool to build your app image with a command that looks like this:

./appimagetool-x86_64.AppImage Hypnos.AppDir Hypnos.AppImage

If everything works, voila! You have an appimage with an embedded JRE. If you're having trouble, join the IRC channel and look for me (JoshuaD) and I'll see what I can do to help you.

Addendum 1 - Renaming the java process

If you use the above method, your program will run, but in ps and in the taskmananger, it will be named java rather than hypnos, which is annoying.

To fix this, rename your embedded jre/bin/java to jre/bin/hypnos. Yes it's a hack, but no one else is using this JRE and it seems to work perfectly for me. You'll have to update your launch script (step 2) as well.

I looked for a long time for much more clever solutions and had nothing work. I have been very satisfied with this simple solution.

Addendum 2 - Packaging for 32bit as well

You need to get a 32bit JRE, and then use the appimagetool option --runtime-file runtime-i686. You can get the updated runtime file from the appimage project.

Addendum 2 - Ant Build Example

Here is very simplified version of my ant build file, which may be helpful to you:

<project name="Hypnos Music Player" default="compile" basedir=".">	
	<property name="src" location="src"/>
	<property name="build" location="bin"/>
	<property name="stage" location="stage" />

	<property name="dist" location="distribution/" />
	<property name="temp" location="temp" />
	<property name="packaging" location="packaging/" />

	<property name="jarFile" location="${stage}/hypnos.jar" />
	<property name="appImageTool" location="${packaging}/appimagetool-x86_64.AppImage" />

	<buildnumber file="${packaging}/build.num"/>

	<path id="class.path">
		<fileset dir="${stage}/lib">
			<include name="**/*.jar" />
		</fileset>
		<pathelement location="${jarFile}" />
	</path>

	<target name="init">
		<tstamp />
		<mkdir dir="${build}"/>
	</target>

	<target name="compile" depends="init" description="compile the source">
		<javac fork="yes" target="1.8" source ="1.8" includeantruntime="false" srcdir="." destdir="${build}">
			<classpath refid="class.path" />
		</javac>
	</target>

	<target name="jar" depends="compile" description="Create a jar.">
		<jar destfile="${jarFile}" basedir="${build}">
			<manifest>
				<attribute name="Main-Class" value="net.joshuad.hypnos.Hypnos" />
				<attribute name="Class-Path" value="... items removed for brevity ... " />
			</manifest>
		</jar>
	</target>

	<target name="dist-nix-64bit" depends="jar" description="Make an AppImage for 64 bit Linux">
		<sequential>
			<delete dir="${temp}" />
			<mkdir dir="${temp}/" />

			<copy todir="${temp}/Hypnos.AppDir" >
				<fileset dir="packaging/Hypnos.AppDir" />
			</copy>	

			<copy todir="${temp}/Hypnos.AppDir/usr/bin" >
				<fileset dir="stage" >
					<exclude name="**/bin/**" />
				</fileset>
			</copy>

			<copy todir="${temp}/Hypnos.AppDir/usr/bin/jre" >
				<fileset dir="${packaging}/jres/linux-64bit" />
			</copy>

			<exec executable="${appImageTool}">
				<arg value="${temp}/Hypnos.AppDir" />
				<arg value="${dist}/Hypnos-nix-64bit.AppImage" />
			</exec>
			<delete dir="${temp}" />
		</sequential>
	</target>

	<target name="dist-nix-32bit" depends="jar" description="Make an AppImage for 32 bit Linux">
		<sequential>
			<delete dir="${temp}" />
			<mkdir dir="${temp}/" />

			<copy todir="${temp}/Hypnos.AppDir" >
				<fileset dir="packaging/Hypnos.AppDir" />
			</copy>	

			<copy todir="${temp}/Hypnos.AppDir/usr/bin" >
				<fileset dir="stage" >
					<exclude name="**/bin/**" />
				</fileset>
			</copy>

			<copy todir="${temp}/Hypnos.AppDir/usr/bin/jre" >
				<fileset dir="${packaging}/jres/linux-32bit" />
			</copy>

			<exec executable="${appImageTool}">
				<arg value="${temp}/Hypnos.AppDir" />
				<arg value="--runtime-file" />
				<arg value="${packaging}/runtime-i686" />
				<arg value="${dist}/Hypnos-nix-32bit.AppImage" />
			</exec>
			<delete dir="${temp}" />
		</sequential>
	</target>
</project>
Clone this wiki locally