← Back to list

Only for the really addicted to Java: JLink in a Buildpack Image, and other Spring Boot performance…

There is a line, there is always a line that separates the sober ones from the truly addicted…

Leonardo Moreira · 2024-07-02 00:04 · 8 claps · 21.9 min read
#jlink #java #dockerfiles #buildpack #spring-boot
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

Only for the really addicted to Java: JLink in a Buildpack Image, and other Spring Boot performance tricks

There is a line, there is always a line that separates the sober ones from the truly addicted…

If you’re a true developer, you’ve probably written some code or done something just for the sake of it. I truly believe this is one of those cases, but I must confess, it’s as cool as it took my sleep off. In my previous long, almost infinite, article, I discussed strategies for creating a well-optimized and efficiently practiced Docker image for your Spring Boot application, and applied the same principles to a Go project. I demonstrated how you can significantly reduce the size of your Java image using Alpaquita buildpack, making it nearly as compact as my Go image, despite Java and Spring Boot requiring more dependencies. This approach not only saves money for your brand new startup when pushing images to your repository, but also enhances application performance by up to thirty percent on application start and throughput. Eveything with only a few lines of code…

And this is where I could have stopped… should have stopped… but I didn’t, and that led me to this article — a result of a few sleepless nights and some valuable lessons learned. In this article, I’ll delve into JLink, JDeps, Maven lifecycles, plugins, Spring-Plugin lifecycles, deployment steps, Buildpacks (Again), Dockerfiles (Again) and how to improve the startup of your Spring Boot application and its performance. I hope you find it as enjoyable to read as it was for me to explore.

JLink

It’s repeated countless times by developers of other languages that Java is quite heavy and slow. Generally, these comments come from people who have never worked with Java, but every piece of common sense has a grain of truth. The JVM carries a lot of baggage, and for those working with default settings, it does generate large-sized images filled with unnecessary components. This can lead to size problems, security issues, and performance degradation.

JLink is a command-line tool designed to address these problems. With JLink, you can create a custom runtime environment containing only the modules needed for your application. This results in smaller Docker images, which are consequently more secure, and have faster benchmarks for your application, as its environment carries less baggage.

Since Java 9, the distinction between the JDK and JRE has been eliminated. Instead, Java introduced modular runtime images, making the JDK the only tool where you can customize what you need for your application. As most people do not take advantage of this customization, you often find a lot of unnecessary debug modules in production environments, making applications slower and more insecure. In the past, it was common to use JRE images to deploy our apps on, some vendors still have (applying some generic JLink magic), but the fact is that most production evironments have a full moduled JDK.

The present distinction between JRE and JDK images is purely historical, a consequence of an implementation decision made late in the development of the JDK 1.2 release and never revisited. The new image structure eliminates this distinction: A JDK image is simply a run-time image that happens to contain the full set of development tools and other items historically found in the JDK.

We can compare the default size of a Java 17 Corretto JDK, which is 335 MB, with the size of a JRE generated using JLink that includes only the modules my application needs with 53MB. If this simple HelloWorld application were in production, the environment would contain a lot of useless components.

Default size of JDK corretto

Default size of JDK corretto

custom “JRE”

custom “JRE”

jlink --module-path $JAVA_HOME/jmods --add-modules java.base,java.logging --output customSmallSizedJRE

The command above generated a runtime environment containing only the java.base and logging modules, which were the only ones needed for my application, where --module-path is the directory where my modules are, --add-modules are the modules I want on my image and --output is the generated environment I need to run my application, the one I would copy to production for example.

For this small, non-real example, it worked well. But you must be asking yourself: “How on earth would I know exactly which dependencies I need for my Spring Boot monolith?” And you are right; it certainly needs much more than just java.base. This is where we stumble upon JDeps.

JDephs

JDeps is a command tool used to analize the dependencies of a Java application in order to help the process of creating custom runtime images.

For example, I have this useless Spring Boot application created using Spring Init, where I tested some Redis and Memcached code, on a straightforward controller.

-> mvn clean install

-> jdeps misc-tests-0.0.1-SNAPSHOT.jar
   com.leonardo.tests.misctests                       -> java.lang                                          java.base
   com.leonardo.tests.misctests                       -> org.springframework.boot                           not found
   com.leonardo.tests.misctests                       -> org.springframework.boot.autoconfigure             not found
   com.leonardo.tests.misctests                       -> org.springframework.context                        not found
   com.leonardo.tests.misctests.controller            -> com.leonardo.tests.misctests.infrastructure.service.cache misc-tests-0.0.1-SNAPSHOT.jar
   com.leonardo.tests.misctests.controller            -> com.leonardo.tests.misctests.orm.entity            misc-tests-0.0.1-SNAPSHOT.jar
   com.leonardo.tests.misctests.controller            -> com.leonardo.tests.misctests.orm.repository        misc-tests-0.0.1-SNAPSHOT.jar
   com.leonardo.tests.misctests.controller            -> java.lang                                          java.base
   com.leonardo.tests.misctests.controller            -> java.lang.invoke                                   java.base
   com.leonardo.tests.misctests.controller            -> java.util                                          java.base
   com.leonardo.tests.misctests.controller            -> java.util.function                                 java.base
   com.leonardo.tests.misctests.controller            -> org.slf4j                                          not found
   com.leonardo.tests.misctests.controller            -> org.springframework.cache.annotation               not found
   com.leonardo.tests.misctests.controller            -> org.springframework.web.bind.annotation            not found
   ...
-> jdeps -s  misc-tests-0.0.1-SNAPSHOT.jar 
  misc-tests-0.0.1-SNAPSHOT.jar -> java.base
  misc-tests-0.0.1-SNAPSHOT.jar -> not found

An ordinary clean and install gave me this jar of 70MB, where I runned JDeps and listed all the dependecies of my JAR, -s, gave-me the summary of it, showing that I need only ‘java.base’ module. Must be wrong right? A single module for a full Spring Boot application?

It is wrong! Spring creates a fat JAR where all its dependencies are nested inside, and JDeps does not analyze them. JDeps only analyzes the dependencies required at startup. To handle this, we need to unpack the JAR, and then run jdeps with some pontual parameters.

-> mkdir unpacked
->cd unpacked/
->jar -xvf ../misc-tests-0.0.1-SNAPSHOT.jar
-> jdeps --ignore-missing-deps -q --recursive --multi-release 17 --print-module-deps --class-path 'BOOT-INF/lib/*' ../misc-tests-0.0.1-SNAPSHOT.jar
  • **--ignore-missing-deps**: It’s crucial to perform this analysis because any dependency that isn’t included in the fat JAR (like those marked as “provided”) would otherwise cause the analysis to halt with an exception.
  • **-q (or --quiet)**: Not so verbose.
  • **--recursive**: Run the analysis recursively..
  • **--multi-release 17**: uses the multi-release JAR feature. Ensures that JDeps correctly handles any classes compiled for Java 17 or earlier versions that might be present in the JAR.
  • **--print-module-deps**: Print only modules and not all the verbosity.
  • **--class-path 'BOOT-INF/lib/*'*: This specifies the classpath that JDeps should use when resolving dependencies. In this case, it includes all JAR files (`.jar) found in theBOOT-INF/lib/.`This is the key step that makes JDeps analize full dependencies.
  • **../misc-tests-0.0.1-SNAPSHOT.jar**: Our Jar.

The response should look like this, and this is exactly the modules that we need on our custom JRE.

java.base,java.compiler,java.desktop,java.instrument,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql.rowset,jdk.jfr,jdk.management,jdk.net,jdk.unsupported

Now we are able to create a custom environment to run our application. Note that it is bigger than the first one, but still, smaller than the default JDK, and the difference is pretty relevant.

jlink --module-path $JAVA_HOME/jmods --add-modules java.base,java.compiler,java.desktop,java.instrument,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql.rowset,jdk.jfr,jdk.management,jdk.net,jdk.unsupported --output customSmallSizedJRE2

And we can decrease even more removing man-pages and doing some compression.

jlink --add-modules java.base,java.compiler,java.desktop,java.instrument,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql.rowset,jdk.jfr,jdk.management,jdk.net,jdk.unsupported --strip-debug --compress 2 --no-header-files --no-man-pages --output custom-jre/

JLink, JDeps and Dockerfile

With that in mind, we now know how to create our custom JRE and build our Java image in the Dockerfile for this application. At this post, I talked about multistage builds on Go, and going to do that on Java as well.

# Stage 1: Build stage
FROM maven:3.8.4-openjdk-17-slim AS build
WORKDIR /app
# Copying pom.xml first for efficient dependency caching
COPY pom.xml .
RUN mvn dependency:go-offline
# Copying the source code and building the application
COPY src ./src
# Build application
RUN mvn clean package -DskipTests
# Extract the jar file
RUN jar xf target/misc-tests-0.0.1-SNAPSHOT.jar
# Get modules to deps file
RUN jdeps --ignore-missing-deps -q --recursive --multi-release 17 --print-module-deps --class-path 'BOOT-INF/lib/*' target/misc-tests-0.0.1-SNAPSHOT.jar > deps.info && cat deps.info
# Create custom JRE
RUN jlink --module-path $JAVA_HOME/jmods --add-modules $(cat deps.info) --compress 2 --no-header-files --no-man-pages --output custom-jre/

FROM ubuntu:jammy AS runner

#Set the JAVA_HOME environment variable
ENV JAVA_HOME=/usr/java
ENV PATH=$JAVA_HOME/bin:$PATH
# Copy the custom JRE from the build stage
COPY --from=build /app/custom-jre $JAVA_HOME
WORKDIR /app
# Copy the application jar file
COPY --from=build /app/target/misc-tests-0.0.1-SNAPSHOT.jar /app/app.jar
# Run the application
CMD ["java", "-jar", "./app.jar"]

Building it with full logs to allow us to see what exactly our DockerFile is performing, and its size with a docker image ls. Quite small for Java.

docker build --progress=plain -t misc-from-dockerfile . --no-cache
misc-from-dockerfile                latest                     52b28161ce98   2 minutes ago    138MB

With a docker run we can see application working fine.

docker run -p 8080:8080 --rm misc-from-dockerfile

#Check the structure
docker run -it --rm misc-from-dockerfile /bin/bash

Well, I would say that this article would have fulfilled its purpose if I did not spent my entire previous article telling you to stop writting DockerFiles and how they suck right?

JLink, JDeps with Maven and Buildpacks

As I told you before, there are a lot of fragilities on writing our own Dockerfiles, and how buildpacks are a powerful tool against this complexity. For our happines is perfect possible to have JLink and JDeps running on Maven and Spring Boot Maven plugin to creating images from Buildpacks, the only thing is that it is not so intuitive, but… Who cares… Right?

First we must go through some topics.

Maven Lifecycles.

Long story short: Maven concept is around lifecycle, which are a set of predefined steps that compose the build and distribute process. Most common are clean lifecycle and the default one that goes until deployment. Each lifecyle is composed of small phases.

Maven Phases.

Phases are predefenit actions performed on each lifecycle. One lifecycle is composed of many phases.

Default Lifecycle phases:

  • validate: Ensures the project is correct and all necessary information is available.
  • initialize: Initializes the build state, such as setting properties.
  • generate-sources: Generates any source code needed for compilation.
  • process-sources: Processes the generated source code, if necessary.
  • generate-resources: Generates any resources needed for the project.
  • process-resources: Copies and processes the resources into the destination directory, ready for packaging.
  • compile: Compiles the source code of the project.
  • process-classes: Post-processes the compiled classes if necessary.
  • generate-test-sources: Generates any test source code needed for testing.
  • process-test-sources: Processes the test source code.
  • generate-test-resources: Generates any test resources needed for testing.
  • process-test-resources: Copies and processes the test resources into the test destination directory.
  • test-compile: Compiles the test source code.
  • process-test-classes: Post-processes the compiled test classes if necessary.
  • test: Runs the tests using a suitable unit testing framework.
  • prepare-package: Prepares the package before the actual packaging, typically used to perform necessary pre-packaging steps.
  • package: Packages the compiled code into its distributable format, such as a JAR or WAR.
  • pre-integration-test: Performs actions required before integration tests are executed, such as setting up the environment.
  • integration-test: Processes and deploys the package if necessary into an environment where integration tests can be run.
  • post-integration-test: Performs actions required after integration tests have been executed, such as cleaning up the environment.
  • verify: Runs any checks to verify the package is valid and meets quality standards.
  • install: Installs the package into the local repository for use as a dependency in other local projects.
  • deploy: Copies the final package to the remote repository for sharing with other developers and projects.

Subset of it here:

  • validate: Validates that the project is correct and all necessary information is available.
  • compile: Compiles the source code of the project.
  • test: Tests the compiled source code using a suitable unit testing framework.
  • package: Packages the compiled code in its distributable format, such as a JAR.
  • verify: Runs any checks to verify the package is valid and meets quality standards.
  • install: Installs the package into the local repository for use as a dependency in other local projects.
  • deploy: Copies the final package to the remote repository for sharing with other developers and projects.

Clean Lifecyle phases:

  • pre-clean: Executes processes needed before the actual project cleaning.
  • clean: Removes all files generated by the previous build.
  • post-clean: Executes processes needed after the project cleaning.

*from the doc, so no astonishing information in here. As I told before, maven works on a set of predefined steps, and if you hit mvn install, you are going through everything from validate to install, or hit, mvn package, everything from validate to package, *at this very prefefined sequence.

Maven Plugins and Goals

Goals are specific tasks on phases, can be executed on phases or directly called. Each phase is associated with one or more goals.

Spring Boot Maven Plugin

Back here on my last article, I talked about spring-boot-maven-plugin to create our own buildpack images. We have some specific goals like mvn spring-boot:run, and mvn spring-boot:build-image when working with Spring projects. For build-image, the important thing here is to understand that it is bounded to mvn package phase, with an additional goals of repackaging and build-image from spring-boot. Something like this:

  • validate (P): Validates the project is correct and all necessary information is available.
  • initialize (P): Initializes build state, such as setting properties.
  • generate-resources (P): Generates resources to be included in the package.
  • process-resources (P): Copies and processes resources into the destination directory.
  • compile (P): Compiles the source code of the project.
  • process-classes (P): Post-processes the compiled code, such as bytecode enhancement.
  • prepare-package (P): Performs any operations necessary to prepare the package before packaging.
  • package (P): Packages the compiled code into a distributable format, such as a JAR or WAR.
  • repackage (G): Repackages the JAR/WAR to be executable (specific to spring-boot-maven-plugin).
  • build-image (G): Builds a Docker image using the repackaged JAR/WAR (specific to spring-boot-maven-plugin).

So, our process to custom those steps in order to run JDeps and JLink must respect these boundaries. With all this teory covered let’s get our hands dirty. In my last medium article I stoped at this point, Where I built my image using alpaquita distribution with glibc and manually configured JLINK with the modules I already knew I would need.

<build>
  <plugins>
   <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
     <image>
      <builder>bellsoft/buildpacks.builder:glibc</builder>
      <env>
       <BP_JVM_VERSION>17</BP_JVM_VERSION>
       <BP_JVM_JLINK_ENABLED>true</BP_JVM_JLINK_ENABLED> // MAY CAUSE SOME ISSUES
       <BP_JVM_JLINK_ARGS>--add-modules jdk.management.agent,java.base,java.logging,java.xml,jdk.unsupported,java.sql,java.naming,java.desktop,java.management,java.security.jgss,java.instrument --compress=2 --no-header-files --no-man-pages --strip-debug</BP_JVM_JLINK_ARGS>
      </env>
     </image>
     <layers>
      <enabled>true</enabled>
     </layers>
    </configuration>
   </plugin>
  </plugins>
 </build>

The main problem here is that it’s neither safe, appropriate, nor desirable to have those dynamic modules hardcoded in our pom.xml file. What if someone adds a dependency that requires another module? Should the entire team rely on a Confluence page to remind them to run JDeps and update the pom.xml? The alternative is deploying your application and waiting for production errors due to missing modules when the application starts. This is not that twelve factor, right? We need to perform the same actions here at the pom.xml as we did on our Dockerfile. We need to extrac the jar, execute jdeps and add this module analisys to buildpack environment set on spring-boot-maven-plugin. Easier writing than doing, but this is how it looks like.

<build>
  <plugins>
   <plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>exec-maven-plugin</artifactId>
    <version>3.0.0</version>
    <executions>
     <execution>
      <id>extract-jar</id>
      <phase>verify</phase>
      <goals>
       <goal>exec</goal>
      </goals>
      <configuration>
       <executable>unzip</executable>
       <arguments>
        <argument>-o</argument>
        <argument>${project.build.directory}/${project.build.finalName}.jar</argument>
        <argument>-d</argument>
        <argument>${project.build.directory}/extracted</argument>
       </arguments>
      </configuration>
     </execution>
     <execution>
      <id>run-jdeps</id>
      <phase>verify</phase>
      <goals>
       <goal>exec</goal>
      </goals>
      <configuration>
       <executable>jdeps</executable>
       <arguments>
        <argument>--ignore-missing-deps</argument>
        <argument>-q</argument>
        <argument>--recursive</argument>
        <argument>--multi-release</argument>
        <argument>${java.version}</argument>
        <argument>--print-module-deps</argument>
        <argument>--class-path</argument>
        <argument>${project.build.directory}/extracted/BOOT-INF/lib/*</argument>
        <argument>${project.build.directory}/${project.build.finalName}.jar</argument>
       </arguments>
       <outputFile>${project.build.directory}/jdeps-output.txt</outputFile>
      </configuration>
     </execution>
     <execution>
      <phase>verify</phase>
      <goals>
       <goal>exec</goal>
      </goals>
      <configuration>
       <executable>sh</executable>
       <arguments>
        <argument>-c</argument>
        <argument>echo "$(echo -n 'BP_JVM_JLINK_ARGS='; cat ${project.build.directory}/jdeps-output.txt)" > ${project.build.directory}/jdeps-output.txt</argument>
       </arguments>
      </configuration>
     </execution>
    </executions>
   </plugin>
   <plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>properties-maven-plugin</artifactId>
    <version>1.0.0</version>
    <executions>
     <execution>
      <phase>verify</phase>
      <goals>
       <goal>read-project-properties</goal>
      </goals>
      <configuration>
       <files>
        <file>${project.build.directory}/jdeps-output.txt</file>
       </files>
      </configuration>
     </execution>
    </executions>
   </plugin>
   <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-deploy-plugin</artifactId>
    <configuration>
     <skip>true</skip>
    </configuration>
   </plugin>
   <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
     <image>
      <builder>bellsoft/buildpacks.builder:glibc</builder>
      <env>
       <BP_JVM_VERSION>${java.version}</BP_JVM_VERSION>
       <BP_JVM_JLINK_ENABLED>true</BP_JVM_JLINK_ENABLED>
       <BP_JVM_JLINK_ARGS>--add-modules ${BP_JVM_JLINK_ARGS} --compress=2 --no-header-files --no-man-pages --strip-debug</BP_JVM_JLINK_ARGS>
      </env>
     </image>
     <layers>
      <enabled>true</enabled>
     </layers>
    </configuration>
    <executions>
     <execution>
      <phase>deploy</phase>
      <goals>
       <goal>build-image</goal>
      </goals>
     </execution>
    </executions>
   </plugin>
  </plugins>
 </build>

First, I had to choose a proper phase to run all those goals of mine. Another change I wanted to make is removing build-image from the package phase and placing it in the deploy phase. In my context, I thought it would be more associated with deployment than simply packaging into a JAR file. In order of placing build-image to deploy phase I had to overwrite deploy default goal, that is basically deploy it into a nexus server, and set spring-boot to this phase.

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-deploy-plugin</artifactId>
    <configuration>
       <skip>true</skip>
    </configuration>
</plugin>
<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
      ...     
    </configuration>
    <executions>
     <execution>
      <phase>deploy</phase>
      <goals>
       <goal>build-image</goal>
      </goals>
     </execution>
    </executions>
</plugin>

I choosed verify to perform all those execution tasks because, If any of it fail I would not like to keep going with install and deploy phases, another thing that made verify atractive is the fact that I needed some artifacts created on package phase on my goals.

Starting on this like, you can find the subset of goals I implemented with execution maven plugin, used to execut process separated from JVM.

Extracting the jar using unzip:

<execution>
    <id>extract-jar</id>
    <phase>verify</phase>
    <goals>
       <goal>exec</goal>
    </goals>
    <configuration>
       <executable>unzip</executable>
       <arguments>
          <argument>-o</argument>
          <argument>${project.build.directory}/${project.build.finalName}.jar</argument>
          <argument>-d</argument>
          <argument>${project.build.directory}/extracted</argument>
       </arguments>
    </configuration>
</execution>

Running jdeps and storing output on jeeps-output.txt inside target folder:

<execution>
      <id>run-jdeps</id>
      <phase>verify</phase>
      <goals>
       <goal>exec</goal>
      </goals>
      <configuration>
       <executable>jdeps</executable>
       <arguments>
        <argument>--ignore-missing-deps</argument>
        <argument>-q</argument>
        <argument>--recursive</argument>
        <argument>--multi-release</argument>
        <argument>${java.version}</argument>
        <argument>--print-module-deps</argument>
        <argument>--class-path</argument>
        <argument>${project.build.directory}/extracted/BOOT-INF/lib/*</argument>
        <argument>${project.build.directory}/${project.build.finalName}.jar</argument>
       </arguments>
       <outputFile>${project.build.directory}/jdeps-output.txt</outputFile>
      </configuration>
     </execution>

Editing the file jdeps-output.txt

<execution>
    <id>property-file</id>
    <phase>verify</phase>
    <goals>
       <goal>exec</goal>
    </goals>
    <configuration>
       <executable>sh</executable>
       <arguments>
          <argument>-c</argument>
          <argument>echo "$(echo -n 'BP_JVM_JLINK_ARGS='; cat ${project.build.directory}/jdeps-output.txt)" > ${project.build.directory}/jdeps-output.txt</argument>
       </arguments>
    </configuration>
</execution>

From:

java.base,java.compiler,java.desktop,java.instrument,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql.rowset,jdk.jfr,jdk.management,jdk.net,jdk.unsupported

To:

BP_JVM_JLINK_ARGS=java.base,java.compiler,java.desktop,java.instrument,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql.rowset,jdk.jfr,jdk.management,jdk.net,jdk.unsupported

The main purpose in here is to use another plugin to load properties from this file and make them available on other stage of my build, I can assure you that was the most trick part of this article. This process was done with another plugin:

<plugin>
    <groupId>org.codehaus.mojo</groupId>
    <artifactId>properties-maven-plugin</artifactId>
    <version>1.0.0</version>
    <executions>
       <execution>
          <phase>verify</phase>
          <goals>
             <goal>read-project-properties</goal>
          </goals>
          <configuration>
             <files>
                <file>${project.build.directory}/jdeps-output.txt</file>
             </files>
          </configuration>
       </execution>
    </executions>
</plugin>

With all that set up, there is some important disclaimers to be done:

  • Maven lifecycles now must be respected. So if you just hit directly the mvn spring-boot:build-image goal all the previous mandatory steps to have the module won’t be executed, and you will get an exception of bad BP_JVM_JLINK_ARGS

  • Since you are attached with phases (not a problem to me) the best way to deploy your application into a image is mvn clean deploy.
[INFO] 
[INFO] --- exec-maven-plugin:3.0.0:exec (run-jdeps) @ misc-tests ---
[INFO] 
[INFO] --- exec-maven-plugin:3.0.0:exec (property-file) @ misc-tests ---
[INFO] 
[INFO] --- properties-maven-plugin:1.0.0:read-project-properties (default) @ misc-tests ---
[INFO] 
[INFO] --- maven-install-plugin:3.1.2:install (default-install) @ misc-tests ---
[INFO] Installing /home/leonardo/Projetos/leonardo/misc-tests/pom.xml to /home/leonardo/.m2/repository/com/leonardo/tests/misc-tests/0.0.1-SNAPSHOT/misc-tests-0.0.1-SNAPSHOT.pom
[INFO] Installing /home/leonardo/Projetos/leonardo/misc-tests/target/misc-tests-0.0.1-SNAPSHOT.jar to /home/leonardo/.m2/repository/com/leonardo/tests/misc-tests/0.0.1-SNAPSHOT/misc-tests-0.0.1-SNAPSHOT.jar
[INFO] 
[INFO] --- maven-deploy-plugin:3.1.2:deploy (default-deploy) @ misc-tests ---
[INFO] Skipping artifact deployment
[INFO] 
[INFO] >>> spring-boot-maven-plugin:3.3.0:build-image (default) > package @ misc-tests >>>
[INFO] 
[INFO] --- maven-resources-plugin:3.3.1:resources (default-resources) @ misc-tests ---
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO] 
[INFO] --- maven-compiler-plugin:3.13.0:compile (default-compile) @ misc-tests ---
[INFO] Nothing to compile - all classes are up to date.
[WARNING] Overwriting artifact's file from /home/leonardo/Projetos/leonardo/misc-tests/target/misc-tests-0.0.1-SNAPSHOT.jar to /home/leonardo/Projetos/leonardo/misc-tests/target/classes
[INFO] 
[INFO] --- maven-resources-plugin:3.3.1:testResources (default-testResources) @ misc-tests ---
[INFO] skip non existing resourceDirectory /home/leonardo/Projetos/leonardo/misc-tests/src/test/resources
[INFO] 
[INFO] --- maven-compiler-plugin:3.13.0:testCompile (default-testCompile) @ misc-tests ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 1 source file with javac [debug parameters release 17] to target/test-classes
[INFO] 
[INFO] --- maven-surefire-plugin:3.2.5:test (default-test) @ misc-tests ---
[INFO] Skipping execution of surefire because it has already been run for this configuration
[INFO] 
[INFO] --- maven-jar-plugin:3.4.1:jar (default-jar) @ misc-tests ---
[INFO] 
[INFO] --- spring-boot-maven-plugin:3.3.0:repackage (repackage) @ misc-tests ---
[INFO] Replacing main artifact /home/leonardo/Projetos/leonardo/misc-tests/target/misc-tests-0.0.1-SNAPSHOT.jar with repackaged archive, adding nested dependencies in BOOT-INF/.
[INFO] The original artifact has been renamed to /home/leonardo/Projetos/leonardo/misc-tests/target/misc-tests-0.0.1-SNAPSHOT.jar.original
[INFO] 
[INFO] <<< spring-boot-maven-plugin:3.3.0:build-image (default) < package @ misc-tests <<<
[INFO] 
[INFO] 
[INFO] --- spring-boot-maven-plugin:3.3.0:build-image (default) @ misc-tests ---
[INFO] Building image 'docker.io/library/misc-tests:0.0.1-SNAPSHOT'
[INFO] 
[INFO]  > Pulling builder image 'docker.io/bellsoft/buildpacks.builder:glibc' 100%
[INFO]  > Pulled builder image 'bellsoft/buildpacks.builder@sha256:9a645edb575217695be4cd29c2d587eac16c093fd3270d73491c7a1c48a517df'
[INFO]  > Pulling run image 'docker.io/bellsoft/buildpacks.alpaquita-run:glibc' 100%
[INFO]  > Pulled run image 'bellsoft/buildpacks.alpaquita-run@sha256:f2e740c8a04a9e6cc78f3b1f831a13d2547e46fc7684083ee6c2e39f5c7a7a6d'
[INFO]  > Executing lifecycle version v0.19.7
[INFO]  > Using build cache volume 'pack-cache-3a46dbb14076.build'
[INFO] 
[INFO]  > Running creator
[INFO]     [creator]     ===> ANALYZING
[INFO]     [creator]     Image with name "docker.io/library/misc-tests:0.0.1-SNAPSHOT" not found
[INFO]     [creator]     ===> DETECTING
[INFO]     [creator]     target distro name/version labels not found, reading /etc/os-release file
[INFO]     [creator]     target distro name/version labels not found, reading /etc/os-release file
[INFO]     [creator]     4 of 7 buildpacks participating
[INFO]     [creator]     bellsoft/buildpacks/liberica     1.1.0
[INFO]     [creator]     paketo-buildpacks/syft           1.47.0
[INFO]     [creator]     paketo-buildpacks/executable-jar 6.10.0
[INFO]     [creator]     paketo-buildpacks/spring-boot    5.30.0
[INFO]     [creator]     ===> RESTORING
[INFO]     [creator]     Restoring metadata for "bellsoft/buildpacks/liberica:jdk" from cache
[INFO]     [creator]     Restoring metadata for "paketo-buildpacks/syft:syft" from cache
[INFO]     [creator]     Restoring data for "bellsoft/buildpacks/liberica:jdk" from cache
[INFO]     [creator]     Restoring data for "paketo-buildpacks/syft:syft" from cache
[INFO]     [creator]     Restoring data for SBOM from cache
[INFO]     [creator]     ===> BUILDING
[INFO]     [creator]     target distro name/version labels not found, reading /etc/os-release file
[INFO]     [creator]     
[INFO]     [creator]     Paketo Buildpack for BellSoft Liberica JDK Lite and Native Image Kit 1.1.0
[INFO]     [creator]       https://github.com/bell-sw/buildpacks/liberica
[INFO]     [creator]       Build Configuration:
[INFO]     [creator]         $BP_JVM_JLINK_ARGS           --add-modules java.base,java.compiler,java.desktop,java.instrument,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.security.sasl,java.sql.rowset,jdk.jfr,jdk.management,jdk.net,jdk.unsupported --compress=2 --no-header-files --no-man-pages --strip-debug  configure custom link arguments (--output must be omitted)
[INFO]     [creator]         $BP_JVM_JLINK_ENABLED        true                                                                                                                                                                                                                                                                                  enables running jlink tool to generate custom JRE
[INFO]     [creator]         $BP_JVM_TYPE                 JRE                                                                                                                                                                                                                                                                                   the JVM type - JDK or JRE
[INFO]     [creator]         $BP_JVM_VERSION              17                                                                                                                                                                                                                                                                                    the Java version
[INFO]     [creator]       Launch Configuration:
[INFO]     [creator]         $BPL_DEBUG_ENABLED           false                                                                                                                                                                                                                                                                                 enables Java remote debugging support
[INFO]     [creator]         $BPL_DEBUG_PORT              8000                                                                                                                                                                                                                                                                                  configure the remote debugging port
[INFO]     [creator]         $BPL_DEBUG_SUSPEND           false                                                                                                                                                                                                                                                                                 configure whether to suspend execution until a debugger has attached
[INFO]     [creator]         $BPL_HEAP_DUMP_PATH                                                                                                                                                                                                                                                                                                write heap dumps on error to this path
[INFO]     [creator]         $BPL_JAVA_NMT_ENABLED        true                                                                                                                                                                                                                                                                                  enables Java Native Memory Tracking (NMT)
[INFO]     [creator]         $BPL_JAVA_NMT_LEVEL          summary                                                                                                                                                                                                                                                                               configure level of NMT, summary or detail
[INFO]     [creator]         $BPL_JFR_ARGS                                                                                                                                                                                                                                                                                                      configure custom Java Flight Recording (JFR) arguments
[INFO]     [creator]         $BPL_JFR_ENABLED             false                                                                                                                                                                                                                                                                                 enables Java Flight Recording (JFR)
[INFO]     [creator]         $BPL_JMX_ENABLED             false                                                                                                                                                                                                                                                                                 enables Java Management Extensions (JMX)
[INFO]     [creator]         $BPL_JMX_PORT                5000                                                                                                                                                                                                                                                                                  configure the JMX port
[INFO]     [creator]         $BPL_JVM_HEAD_ROOM           0                                                                                                                                                                                                                                                                                     the headroom in memory calculation
[INFO]     [creator]         $BPL_JVM_LOADED_CLASS_COUNT  35% of classes                                                                                                                                                                                                                                                                        the number of loaded classes in memory calculation
[INFO]     [creator]         $BPL_JVM_THREAD_COUNT        250                                                                                                                                                                                                                                                                                   the number of threads in memory calculation
[INFO]     [creator]         $JAVA_TOOL_OPTIONS                                                                                                                                                                                                                                                                                                 the JVM launch flags
[INFO]     [creator]         Using Java version 17 from BP_JVM_VERSION
[INFO]     [creator]       BellSoft Liberica JDK Lite (glibc) 17.0.11: Reusing cached layer
[INFO]     [creator]       JLink: Contributing to layer
[INFO]     [creator]         Adding 147 container CA certificates to JVM truststore
[INFO]     [creator]         Writing env.launch/BPI_APPLICATION_PATH.default
[INFO]     [creator]         Writing env.launch/BPI_JVM_CACERTS.default
[INFO]     [creator]         Writing env.launch/BPI_JVM_CLASS_COUNT.default
[INFO]     [creator]         Writing env.launch/BPI_JVM_SECURITY_PROVIDERS.default
[INFO]     [creator]         Writing env.launch/JAVA_HOME.default
[INFO]     [creator]         Writing env.launch/JAVA_TOOL_OPTIONS.append
[INFO]     [creator]         Writing env.launch/JAVA_TOOL_OPTIONS.delim
[INFO]     [creator]         Writing env.launch/MALLOC_ARENA_MAX.default
[INFO]     [creator]       Launch Helper: Contributing to layer
[INFO]     [creator]         Creating /layers/bellsoft_buildpacks_liberica/helper/exec.d/java-opts
[INFO]     [creator]         Creating /layers/bellsoft_buildpacks_liberica/helper/exec.d/jvm-heap
[INFO]     [creator]         Creating /layers/bellsoft_buildpacks_liberica/helper/exec.d/link-local-dns
[INFO]     [creator]         Creating /layers/bellsoft_buildpacks_liberica/helper/exec.d/memory-calculator
[INFO]     [creator]         Creating /layers/bellsoft_buildpacks_liberica/helper/exec.d/security-providers-configurer
[INFO]     [creator]         Creating /layers/bellsoft_buildpacks_liberica/helper/exec.d/jmx
[INFO]     [creator]         Creating /layers/bellsoft_buildpacks_liberica/helper/exec.d/jfr
[INFO]     [creator]         Creating /layers/bellsoft_buildpacks_liberica/helper/exec.d/openssl-certificate-loader
[INFO]     [creator]         Creating /layers/bellsoft_buildpacks_liberica/helper/exec.d/security-providers-classpath-9
[INFO]     [creator]         Creating /layers/bellsoft_buildpacks_liberica/helper/exec.d/debug-9
[INFO]     [creator]         Creating /layers/bellsoft_buildpacks_liberica/helper/exec.d/nmt
[INFO]     [creator]       Java Security Properties: Contributing to layer
[INFO]     [creator]         Writing env.launch/JAVA_SECURITY_PROPERTIES.default
[INFO]     [creator]         Writing env.launch/JAVA_TOOL_OPTIONS.append
[INFO]     [creator]         Writing env.launch/JAVA_TOOL_OPTIONS.delim
[INFO]     [creator]     
[INFO]     [creator]     Paketo Buildpack for Syft 1.47.0
[INFO]     [creator]       https://github.com/paketo-buildpacks/syft
[INFO]     [creator]     
[INFO]     [creator]     Paketo Buildpack for Executable JAR 6.10.0
[INFO]     [creator]       https://github.com/paketo-buildpacks/executable-jar
[INFO]     [creator]       Command "packages" is deprecated, use `syft scan` instead
[INFO]     [creator]       Class Path: Contributing to layer
[INFO]     [creator]         Writing env/CLASSPATH.delim
[INFO]     [creator]         Writing env/CLASSPATH.prepend
[INFO]     [creator]       Process types:
[INFO]     [creator]         executable-jar: java org.springframework.boot.loader.launch.JarLauncher (direct)
[INFO]     [creator]         task:           java org.springframework.boot.loader.launch.JarLauncher (direct)
[INFO]     [creator]         web:            java org.springframework.boot.loader.launch.JarLauncher (direct)
[INFO]     [creator]     
[INFO]     [creator]     Paketo Buildpack for Spring Boot 5.30.0
[INFO]     [creator]       https://github.com/paketo-buildpacks/spring-boot
[INFO]     [creator]       Build Configuration:
[INFO]     [creator]         $BPL_JVM_CDS_ENABLED                 false  whether to enable CDS optimizations at runtime
[INFO]     [creator]         $BPL_SPRING_AOT_ENABLED              false  whether to enable Spring AOT at runtime
[INFO]     [creator]         $BP_JVM_CDS_ENABLED                  false  whether to enable CDS & perform JVM training run
[INFO]     [creator]         $BP_SPRING_AOT_ENABLED               false  whether to enable Spring AOT
[INFO]     [creator]         $BP_SPRING_CLOUD_BINDINGS_DISABLED   false  whether to contribute Spring Boot cloud bindings support
[INFO]     [creator]         $BP_SPRING_CLOUD_BINDINGS_VERSION    1      default version of Spring Cloud Bindings library to contribute
[INFO]     [creator]       Launch Configuration:
[INFO]     [creator]         $BPL_SPRING_CLOUD_BINDINGS_DISABLED  false  whether to auto-configure Spring Boot environment properties from bindings
[INFO]     [creator]         $BPL_SPRING_CLOUD_BINDINGS_ENABLED   true   Deprecated - whether to auto-configure Spring Boot environment properties from bindings
[INFO]     [creator]       Creating slices from layers index
[INFO]     [creator]         dependencies (65.5 MB)
[INFO]     [creator]         spring-boot-loader (450.6 KB)
[INFO]     [creator]         snapshot-dependencies (0.0 B)
[INFO]     [creator]         application (155.1 KB)
[INFO]     [creator]       Spring Cloud Bindings 2.0.3: Contributing to layer
[INFO]     [creator]         Downloading from https://repo1.maven.org/maven2/org/springframework/cloud/spring-cloud-bindings/2.0.3/spring-cloud-bindings-2.0.3.jar
[INFO]     [creator]         Verifying checksum
[INFO]     [creator]         Copying to /layers/paketo-buildpacks_spring-boot/spring-cloud-bindings
[INFO]     [creator]       Web Application Type: Contributing to layer
[INFO]     [creator]         Servlet web application detected
[INFO]     [creator]         Writing env.launch/BPL_JVM_THREAD_COUNT.default
[INFO]     [creator]       Launch Helper: Contributing to layer
[INFO]     [creator]         Creating /layers/paketo-buildpacks_spring-boot/helper/exec.d/spring-cloud-bindings
[INFO]     [creator]       4 application slices
[INFO]     [creator]       Image labels:
[INFO]     [creator]         org.opencontainers.image.title
[INFO]     [creator]         org.opencontainers.image.version
[INFO]     [creator]         org.springframework.boot.version
[INFO]     [creator]     ===> EXPORTING
[INFO]     [creator]     Adding layer 'bellsoft/buildpacks/liberica:JLink'
[INFO]     [creator]     Adding layer 'bellsoft/buildpacks/liberica:helper'
[INFO]     [creator]     Adding layer 'bellsoft/buildpacks/liberica:java-security-properties'
[INFO]     [creator]     Adding layer 'paketo-buildpacks/executable-jar:classpath'
[INFO]     [creator]     Adding layer 'paketo-buildpacks/spring-boot:helper'
[INFO]     [creator]     Adding layer 'paketo-buildpacks/spring-boot:spring-cloud-bindings'
[INFO]     [creator]     Adding layer 'paketo-buildpacks/spring-boot:web-application-type'
[INFO]     [creator]     Adding layer 'buildpacksio/lifecycle:launch.sbom'
[INFO]     [creator]     Adding 5/5 app layer(s)
[INFO]     [creator]     Adding layer 'buildpacksio/lifecycle:launcher'
[INFO]     [creator]     Adding layer 'buildpacksio/lifecycle:config'
[INFO]     [creator]     Adding layer 'buildpacksio/lifecycle:process-types'
[INFO]     [creator]     Adding label 'io.buildpacks.lifecycle.metadata'
[INFO]     [creator]     Adding label 'io.buildpacks.build.metadata'
[INFO]     [creator]     Adding label 'io.buildpacks.project.metadata'
[INFO]     [creator]     Adding label 'org.opencontainers.image.title'
[INFO]     [creator]     Adding label 'org.opencontainers.image.version'
[INFO]     [creator]     Adding label 'org.springframework.boot.version'
[INFO]     [creator]     Setting default process type 'web'
[INFO]     [creator]     Saving docker.io/library/misc-tests:0.0.1-SNAPSHOT...
[INFO]     [creator]     *** Images (b4eecbab53af):
[INFO]     [creator]           docker.io/library/misc-tests:0.0.1-SNAPSHOT
[INFO]     [creator]     Reusing cache layer 'bellsoft/buildpacks/liberica:jdk'
[INFO]     [creator]     Reusing cache layer 'paketo-buildpacks/syft:syft'
[INFO]     [creator]     Reusing cache layer 'paketo-buildpacks/spring-boot:spring-cloud-bindings'
[INFO]     [creator]     Reusing cache layer 'buildpacksio/lifecycle:cache.sbom'
[INFO] 
[INFO] Successfully built image 'docker.io/library/misc-tests:0.0.1-SNAPSHOT'
[INFO] 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  59.882 s
[INFO] Finished at: 2024-07-01T15:11:03-03:00
[INFO] ------------------------------------------------------------------------

Here it is our image on docker image ls:

misc-tests 0.0.1-SNAPSHOT b4eecbab53af 44 years ago 173MB

Quite big since it was some random tests of mine with memcache, redis, aspects and other random stuff. :)

docker run -p 8080:8080 --rm b4eecbab53af

Everything going well here

Everything going well here

In order to prove my point of the size the JDK into our image, we can just do a simple test disabling JLink on pom.xml, and after a deploy you are able to see a 28mb bigger image, 16% bigger image. On this simple article may sound silly, but imagine that at scale of multiples deploys a day, on an uncountable amount of microservices of your company.

<BP_JVM_JLINK_ENABLED>false</BP_JVM_JLINK_ENABLED>
misc-tests                          0.0.1-SNAPSHOT             f790f2de9ce7   44 years ago    201MB

Another thing that should not be ignored is that, your JVM without useless modules, or debug stuff, should be at least ten percent faster than an default one, that may represents better memory managing and quicker garbage collection process, for example. Since we are talking about performance, here it is some few tricks to get better performance from spring-boot applications.

Spring Boot performance tricks

Thinking better, almost everything I have already wrote can be labeled as Spring Boot perfomance tricks, if you want, and I think you should, you can have a look here, and here, before keep going.

Bean Lazy Initialization

May not apply to everyone or every case. Enabling lazy initialization in your project can speed up startup since not every bean is created initially, but it may slow down the first runs of any process. Another pitfall is potential errors in production if your project lacks thorough automated testing. This one requires little bit of caution since, even an simple data load on application starting will be impacted.

spring:
  main:
    lazy-initialization: true

Lazy JPA Repository Initialization

The same applies here: this approach should be used carefully. By default, repository beans are loaded synchronously at application startup in the default bootstrap mode. However, setting them to lazy instantiation means they are instantiated on first use. It’s important to note that a lot of query validation, mapping, and other JPA configurations happen during application startup. Use lazy instantiation only if you fully understand its implications and if your application has comprehensive integration tests in place.

spring:
  main:
    lazy-initialization: false
  data:
    jpa:
      repositories:
        bootstrap-mode: lazy

Virtual Threads

Virtual Threads are a complex topic deserving of a dedicated article, and there are a lot of ones. They represent a response from old and boring Spring and Java to newer languages like Golang. Virtual Threads enable applications to avoid dependency on platform threads by utilizing lighter, more dynamic virtual threads. These threads can handle other tasks when faced with I/O blocking processes. This explanation is quite basic, so I encourage you to explore more detailed sources. However, it’s important to note that there are no silver bullets here. Virtual Threads come with their own set of challenges; for instance, applications with numerous sequential I/O blocking tasks may perform worse compared to using a single platform thread. This is because virtual threads may incur overhead when they need to unmount and remount themselves. Ultimately, understanding and benchmarking are crucial for integrating Virtual Threads effectively into your applications.

spring:
  threads:
    virtual:
      enabled: true

Spring Startup Report

Startup Report is a powerful tool to identify bottlenecks on your startup-process. Just by adding a single dependency on your project you are able to get a well detailed report on your application localhost, http://127.0.0.1:8080/startup-report, with detail of how long each bean took to be instantiated at the process of startup.

<dependency>
 <groupId>com.maciejwalkowiak.spring</groupId>
 <artifactId>spring-boot-startup-report</artifactId>
 <version>0.2.0</version>
 <optional>true</optional>
</dependency>

For example, let’s have a loot at the graph with the default spring for initialization:

We are able to see that the most heavy process on startup of my application is Repository scanning, Entity Manager instantiation, and cache service beans creation. We can see as well how painful actuator endpoints could be to an application, for some usecases like Lambdas and Google Cloud functions could makes sence just to disable those endpoints.

With a simple lazy on repositories is clear how startup-process turns out to be a lot more quicker since there is no Repository beans being instantiated on MarketplaceController.

Almost one seconds is the difference betwen lazy mode, that can be surprising how much time and resource hibernate can take from us. This is powerful information even for you to decide not to use any ORM a all, for example.

Started MiscTestsApplication in 7.068 seconds (process running for 7.358)

Started MiscTestsApplication in 8.712 seconds (process running for 9.08)

Final thoughts

In these days of containerization, cloud computing, and tight budgets, it is crucial to make the most with minimal resources. Customizing your JDK to create smaller images for deployment, while ensuring high throughput, is a win-win situation. With a small set of configurations, you can optimize your applications to the fullest. This is why I’ve covered some buildpack patterns and Maven theory, so you can decide the best phases and scenarios to apply these resources. Finally, I’ve provided some uncommon tips for Spring Boot, with the disclaimer that they may or may not be appropriate for your use case. Use them wisely.

Thanks for reading, and I hope this article helps you as much as it helped me.

References

https://buildpacks.io/

https://buildpacks.io/

https://github.com/maciejwalkowiak/spring-boot-startup-report?tab=readme-ov-file

https://docs.oracle.com/en/java/javase/11/tools/jlink.html

https://docs.oracle.com/en/java/javase/11/tools/jdeps.html


메타데이터
post_id
77b84b31a907
slug
only-for-the-really-addicted-to-java-jlink-in-a-buildpack-image-and-other-spring-boot-performance-77b84b31a907
url
https://medium.com/@lm.moreira/only-for-the-really-addicted-to-java-jlink-in-a-buildpack-image-and-other-spring-boot-performance-77b84b31a907
canonical_url
https://medium.com/@lm.moreira/only-for-the-really-addicted-to-java-jlink-in-a-buildpack-image-and-other-spring-boot-performance-77b84b31a907
author_url
https://medium.com/@lm.moreira
status
ok
fetched_at
2026-07-23 12:06:44