Search code examples
javafilesizejava-modulejlinkjpackage

How to reduce the size of my executable made via `jpackage`


I have a jpackage build script that takes one of my projects (all have the exact same structure) and creates an executable. This script works and produces an installer, which I can run to create my executable.

However, my created executable is too large. For a simple Microsoft Paint clone, it is roughly ~70MB large, so that is DISK SPACE. I didn't necessarily have any expectations, but I need that number to go down. Image of disk size Here is the module-info.java for the project.

module PaintModule
{

   requires java.base;
   requires java.desktop;

}

And here is (a snippet of) the build script.

   private void createExecutable() throws Exception
   {
   
      System.out.println("STARTING TO RUN JPACKAGE");
   
      final var jpackageCommand =
         java.util.spi.ToolProvider
            .findFirst("jpackage")
            .orElseThrow()
            ;
   
      final String executableName = prompt("Enter your executable name");
      final String description = prompt("Enter the description for your executable");
   
      jpackageCommand
         .run
         (
            System.out,
            System.err,
            "--verbose",
            "--type", "msi",
            "--name", STR."\{executableName}",
            "--install-dir", STR."davidalayachew_applications/\{executableName}",
            "--vendor", "David Alayachew",
            "--module-path", STR."\{this.projectDirectory.resolve(Path.of("run", "executable", "jar")).toString()}",
            "--module", STR."\{this.moduleName}/\{this.packageName}.Main",
            "--win-console",
            "--win-dir-chooser",
            "--win-shortcut",
            "--win-shortcut-prompt",
            "--java-options", "--enable-preview",
            "--dest", STR."\{this.projectDirectory.resolve(Path.of("run", "executable", "installer")).toString()}",
            "--description", STR."\{description}"
         )
         ;
   
   }

As you can see, my project is modular. And I am running on Java 22. I'd like to make the file size as low as possible using the basic tools that come with the standard library (jpackage, jlink, etc.). Any insight on how to make this smaller?

Also, there are no resources or anything for this project. It's all basic Swing code. And the code itself is not even 100KB. And I turned on verbose logging for my jar creation command -- nothing but my .class files and a manifest are being bundled into the jar. And to be clear, I am putting the modular jar location as my module path. At one point, I thought the fact that I was building my installer from the jar was the problem, but the jar in question is 37KB. Doubtful.

What can I do to bring this number down? Best case scenario would be <10MB, but I will take whatever drops that number down. I don't need an installer or any of that other stuff, I just want this application to run on a windows machine.

And, I doubt it helps, but here is the full build script, in case you want to run it yourself.


import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;
import javax.swing.JFileChooser;
import javax.swing.JOptionPane;
import javax.tools.JavaCompiler;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;

public class CREATE_EXECUTABLE_FROM_PROJECT
{

   /** I am expecting there to be an environment variable on the machine for this key. If not, add it. */
   private static final int CURRENT_JAVA_VERSION = Integer.parseInt(System.getenv("CURRENT_JAVA_VERSION"));

   /** This is the folder that all of my projects are in. */
   private final Path workingDirectory = Path.of("C:", "Users", "david", "_WORKSPACE", "_PROGRAMMING", "_JAVA").toAbsolutePath();
   private final Path projectDirectory;
   private final Path sourceCodeDirectory;
   private final String projectName;
   private final String moduleName;
   private final String packageName;

   public CREATE_EXECUTABLE_FROM_PROJECT() throws Exception
   {
   
      final JFileChooser projectChooser = new JFileChooser(this.workingDirectory.toFile());
      projectChooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
      projectChooser.setDialogTitle("Choose a project folder");
   
      var userResponse = projectChooser.showOpenDialog(null);
   
      if (JFileChooser.APPROVE_OPTION != userResponse)
      {
      
         throw new IllegalArgumentException("User did not press approve, so taking no action.");
         
      }
   
      this.projectDirectory = projectChooser.getSelectedFile().toPath().toAbsolutePath();
   
      this.sourceCodeDirectory =
         this
            .projectDirectory
            .resolve("src")
            .resolve("main")
            .resolve("java")
            .toAbsolutePath()
            ;
   
      this.projectName = this.projectDirectory.getFileName().toString();
      this.moduleName = this.projectName + "Module";
      this.packageName = this.projectName + "Package";
   
   }

   public static void main(final String[] args) throws Exception
   {
   
      enum ExecutionOption
      {
      
         COMPILE_CODE,
         RUN_CODE,
         CREATE_JAR,
         RUN_JAR,
         CREATE_EXECUTABLE,
         ;
      
      }
   
      final CREATE_EXECUTABLE_FROM_PROJECT exeCreator;
   
      CHOOSE_PROJECT:
      {
      
         try
         {
         
            exeCreator = new CREATE_EXECUTABLE_FROM_PROJECT();
         
         }
         
         catch (final Exception exception)
         {
         
            System.out.println(exception.getMessage());
            return;
         
         }
      
      }
   
      final java.util.EnumMap<ExecutionOption, Boolean> map = new java.util.EnumMap<>(ExecutionOption.class);
   
      CHOOSE_EXECUTION_OPTIONS:
      {
      
         final var list = new javax.swing.JPanel(new java.awt.GridLayout(0, 1));
         
         final var instructions =
            new javax.swing.JLabel("<html>Choose your execution options.<br>They will occur in numerical order.</html>");
         
         list.add(instructions);
      
         for (var option : ExecutionOption.values())
         {
         
            final String label = "<html>" + (option.ordinal() + 1) + "\t" + option.name().replaceAll("_", " ") + "</html>";
         
            final var checkBox = new javax.swing.JCheckBox(label,  true);
         
            map.put(option, true);
         
            checkBox.addActionListener(_ -> map.put(option, checkBox.isSelected()));
         
            list.add(checkBox);
         
         }
      
         final var userResponse =
            JOptionPane 
            .showConfirmDialog
            (
               null, 
               list, 
               "Choose your execution options.",
               JOptionPane.OK_CANCEL_OPTION
            )
            ;
      
         if (JOptionPane.OK_OPTION != userResponse || map.entrySet().stream().noneMatch(entry -> entry.getValue()))
         {
         
            System.out.println("User did not select an execution option.");
         
            return;
         
         }
      
      }
   
      CONFIRM_BEFORE_RUNNING:
      {
      
         final int userResponse =
            JOptionPane
            .showConfirmDialog
            (
               null,
               STR.
                  """
                  <html>
                     Are you sure you want to create an executable for \{exeCreator.projectName}?<br>
                     projectDirectory = \{exeCreator.projectDirectory}<br>
                     projectName = \{exeCreator.projectName}<br>
                     moduleName = \{exeCreator.moduleName}<br>
                     packageName = \{exeCreator.packageName}
                  </html>
                  """
            )
            ;
      
         if (userResponse != JOptionPane.YES_OPTION)
         {
         
            return;
         
         }
      
      }
      
      EXECUTE_OPTIONS:
      for (var entry : map.entrySet())
      {
      
         if (false == entry.getValue())
         {
         
            continue EXECUTE_OPTIONS;
         
         }
      
         var option = entry.getKey();
         
         switch (option)
         {
         
            case COMPILE_CODE       -> exeCreator.compileCode();
            case RUN_CODE           -> exeCreator.runCode();
            case CREATE_JAR         -> exeCreator.createJar();
            case RUN_JAR            -> exeCreator.runJar();
            case CREATE_EXECUTABLE  -> exeCreator.createExecutable();
         
         }
      
      }
   
   }

   private static String prompt(final String question)
   {
   
      String response = "";
   
      do
      {
      
         response = JOptionPane.showInputDialog(null, question);
      
      }
      
      while (JOptionPane.YES_OPTION != JOptionPane.showConfirmDialog(null, STR."Please confirm that this is correct -- {\{response}}"));
   
      return response;
   
   }

   private void compileCode() throws Exception
   {
   
      System.out.println("STARTING TO COMPILE MODULAR SOURCE CODE");
   
      final JavaCompiler javac = ToolProvider.getSystemJavaCompiler();
   
      Objects.requireNonNull(javac);
   
      final StandardJavaFileManager standardJavaFileManager = javac.getStandardFileManager(null, null, null);
   
      final var compilerOptions =
         List
            .of
            (
               // STR."--module-source-path=\{mainModulePath}",
               STR."--module-source-path=\{this.sourceCodeDirectory.toString()}",
               STR."--module-path=\{this.workingDirectory.toString()}",
               STR."--module=\{this.moduleName}",
               "--add-modules=com.formdev.flatlaf",
               "--enable-preview",
               "--source=" + CURRENT_JAVA_VERSION,
               "-d", STR."\{this.projectDirectory.resolve("classes").toString()}"
            )
            ;
   
      final var compilationTask = javac.getTask(null, null, null, compilerOptions, null, null);
   
      compilationTask.call();
   
   }

   private void runCode() throws Exception
   {
   
      System.out.println("STARTING TO RUN MODULAR SOURCE CODE");
   
      final ProcessBuilder processBuilder =
         new
            ProcessBuilder
            (
               "java",
               "--enable-preview",
               STR."--patch-module=\{this.moduleName}=src/main/resources",
               "--module-path=classes;..",
               STR."--module=\{this.moduleName}/\{this.packageName}.Main"
            )
            .inheritIO()
            .directory(this.projectDirectory.toFile())
            ;
   
      final Process process = processBuilder.start();
   
      final int exitCode = process.waitFor();
   
      System.out.println("exitCode = " + exitCode);
   
      if (exitCode != 0)
      {
      
         throw new IllegalArgumentException("Failure");
      
      }
   
   }

   private void createJar() throws Exception
   {
   
      System.out.println("STARTING TO BUILD A MODULAR JAR");
   
      final var jarCommand =
         java.util.spi.ToolProvider
            .findFirst("jar")
            .orElseThrow()
            ;
   
      final Path pathToJar = this.projectDirectory.resolve(Path.of("run", "executable", "jar", this.projectName));
   
      final Path pathToResources = this.projectDirectory.resolve(Path.of("src", "main", "resources"));
      
      final Path pathToModuleClassFiles = this.projectDirectory.resolve("classes").resolve(this.moduleName);
   
      jarCommand
         .run
         (
            System.out,
            System.err,
            "--verbose",
            "--create",
            STR."--file=\{pathToJar.toString()}.jar",
            STR."--main-class=\{this.packageName}.Main",
            "-C", STR."\{pathToModuleClassFiles.toString()}", ".",
            "-C", STR."\{pathToResources.toString()}", "."
         )
         ;
   
   }

   private void runJar() throws Exception
   {
   
      System.out.println("STARTING TO RUN A MODULAR JAR");
   
      final ProcessBuilder processBuilder =
         new
            ProcessBuilder
            (
               "java",
               "--enable-preview",
               "--module-path=run/executable/jar",
               STR."--module=\{this.moduleName}/\{this.packageName}.Main"
            )
            .inheritIO()
            .directory(this.projectDirectory.toFile())
            ;
   
      final Process process = processBuilder.start();
   
      final int exitCode = process.waitFor();
   
      System.out.println("exitCode = " + exitCode);
   
   }

   private void createExecutable() throws Exception
   {
   
      System.out.println("STARTING TO RUN JPACKAGE");
   
      final var jpackageCommand =
         java.util.spi.ToolProvider
            .findFirst("jpackage")
            .orElseThrow()
            ;
   
      final String executableName = prompt("Enter your executable name");
      final String description = prompt("Enter the description for your executable");
   
      jpackageCommand
         .run
         (
            System.out,
            System.err,
            "--verbose",
            "--type", "msi",
            "--name", STR."\{executableName}",
            "--install-dir", STR."davidalayachew_applications/\{executableName}",
            "--vendor", "David Alayachew",
            "--module-path", STR."\{this.projectDirectory.resolve(Path.of("run", "executable", "jar")).toString()}",
            "--module", STR."\{this.moduleName}/\{this.packageName}.Main",
            "--win-console",
            "--win-dir-chooser",
            "--win-shortcut",
            "--win-shortcut-prompt",
            "--java-options", "--enable-preview",
            "--dest", STR."\{this.projectDirectory.resolve(Path.of("run", "executable", "installer")).toString()}",
            "--description", STR."\{description}"
         )
         ;
   
   }

}


Solution

  • java.base and java.desktop are big modules. java.base is about 21 MB in Windows, and about 24 MB in Linux. java.desktop is about 12 MB in Windows, and about 14 MB in Linux. So, the modules alone constitute 33–38 MB.

    In addition to those, there are native libraries that need to be added, approximately 20–30 MB total. Much of that is jvm.so or jvm.dll.

    So, an application image that uses java.desktop is already at least 50 MB, not including the application classes and resources. (Those numbers will vary with different Java versions and platforms.)

    You might get slightly better results by adding --jlink-options --compress=zip-9 to your jpackage command, but in practice, I find that it doesn’t make much of a difference. (The default is zip-6 anyway.) Older JDKs may only allow --compress=2.

    That might seem unruly, but most large non-Java applications are the same way. Sometimes they use libraries already installed on the system, but often they don’t, in which case their size will be comparable to a Java application image.

    As much as I like the idea of lightweight executables, 70 MB is probably normal for a modern application.

    Update: As mentioned in a comment, you can also do --jlink-options "--strip-debug --compress=zip-9", to reduce the size a little more. However, this will make stack traces far less useful. It’s also worth noting that jpackage includes a lot of size-reducing options by default, so you may want to add to those defaults using --jlink-options "--compress=zip-9 --strip-native-commands --strip-debug --no-man-pages --no-header-files".