Featured image of post Making Maven Builds Faster

Making Maven Builds Faster

A post about the Maven Build Cache Extension and the Maven Daemon.

Photo by Alexander Redl on Unsplash

Java Builds Are Slow, Right?

There is a prejudice from the past that Java builds are slow. That is not necessarily true anymore. Today, with the improvements in build tools like Gradle or Maven we can expect significant improvements in build times. But let’s admit it: even for a medium-sized project, a full build takes too long.

Using Gradle, we have the Gradle Daemon, we have incremental builds, and Gradle Build Cache to make our daily work more streamlined.

But what can we do, if we are building with Maven? 🤔 Luckily, we have Maven Build Cache Extension and Maven Daemon to the rescue!

Let’s see how we can use them to make our Maven workflows faster! Shall we?

Maven Build Cache Extension

Maven Build Cache Extension is a relatively new player. It can be used to cache already-built code to avoid unnecessary recompilations. It shines with multi-module Maven projects - and to take it to the next level, you should incorporate it into your CI pipelines with a remote cache setup (not covered in this post, sorry).

How Does It Work?

In a nutshell, the extension speeds up builds by caching compiled module outputs. It generates keys using specific parameters and employs source code fingerprinting. This ensures that the cache is only invalidated when there are code changes, allowing you to use the cached output for unchanged code and significantly reducing build times.

Enabling The Extension

Please note, that to use the extension, you need Maven 3.9.0 or later. If you are using an older version, upgrade it first. Last to mention, I used JDK 17 as my Java runtime.

First things first, enable the extension in extensions.xml, in your project’s .mvn directory:

1
2
3
4
5
6
7
8
<extensions xmlns="http://maven.apache.org/EXTENSIONS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/EXTENSIONS/1.0.0 http://maven.apache.org/xsd/core-extensions-1.0.0.xsd">
  <extension>
    <groupId>org.apache.maven.extensions</groupId>
    <artifactId>maven-build-cache-extension</artifactId>
    <version>1.0.1</version>
  </extension>
</extensions>

Note: at the time of writing, the latest version of the extension is 1.0.1.

Experimenting With The Extension

First Run: Build Without The Extension

Now, let’s see how it works. I decided to start experimenting with a JHipster sample project with a React frontend. You can find it here: jhipster-sample-application-react

I checked out the project and ran a full build (mvn clean install) without the extension enabled. The first run took 1m 9s on my MacBook Pro. Even if I do not change anything in the source code, the next run will take almost the same amount of time:

Run Nr.Time
1st run1m 9s
2nd run57s
3rd run1m 4s

See? It’s around one minute. Every time. 😞

Second Run: the First Build With The Extension

Let’s see how the Maven Build Cache extension can help with that. After adding the extension to the extensions.xml file, I ran the build again.

1
2
3
4
5
6
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  01:14 min
[INFO] Finished at: 2023-11-12T16:05:04+01:00
[INFO] ------------------------------------------------------------------------

As you can see, the first run took even a bit longer, but don’t worry, the build cache was not yet populated. The next run will be faster. If you check the .m2/build-cache directory, that should be something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
tree ~/.m2/build-cache

/Users/tamas/.m2/build-cache
└── v1
    └── io.github.jhipster.sample
        └── jhipster-sample-application-react
            └── 8c08b80db633cc90
                └── local
                    ├── buildinfo.xml
                    ├── jhipster-sample-application-react-build_generated-sources.zip
                    ├── jhipster-sample-application-react-build_generated-test-sources.zip
                    └── jhipster-sample-application-react.jar

The results of the builds are stored here. The 8c08b80db633cc90 directory is the cache key, and the local directory contains the actual artifacts.

Third Run: Utilizing the Build Cache

So far, so good! Now, let’s see how much faster the next run will be.

1
2
3
4
5
6
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  13.027 s
[INFO] Finished at: 2023-11-12T16:19:36+01:00
[INFO] ------------------------------------------------------------------------

💪 Yes! The build time went down from one minute to 13 seconds. That’s a huge improvement! 🎉

If you peek into the build-cache directory again, you will see that the cache key is the pretty same as before (8c08b80db633cc90):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
 tree ~/.m2/build-cache
/Users/tamas/.m2/build-cache
└── v1
    └── io.github.jhipster.sample
        └── jhipster-sample-application-react
            └── 8c08b80db633cc90
                └── local
                    ├── buildinfo.xml
                    ├── jhipster-sample-application-react-build_generated-sources.zip
                    ├── jhipster-sample-application-react-build_generated-test-sources.zip
                    └── jhipster-sample-application-react.jar

Let’s change something in the code and see what happens: I applied a tiny change in the class named PublicUserResource.java.

Added the final keyword to the constructor parameter:

1
2
3
public PublicUserResource(final UserService userService) {
  this.userService = userService;
}

Now let’s invoke mvn compile:

1
2
3
4
5
6
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  44.811 s
[INFO] Finished at: 2023-11-12T16:40:26+01:00
[INFO] ------------------------------------------------------------------------

As you can see, the build took 44 seconds. Let’s see what happened in the cache directory:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
tree ~/.m2/build-cache
/Users/tamas/.m2/build-cache
└── v1
    └── io.github.jhipster.sample
        └── jhipster-sample-application-react
            ├── 8c08b80db633cc90
            │   └── local
            │       ├── buildinfo.xml
            │       ├── jhipster-sample-application-react-build_generated-sources.zip
            │       ├── jhipster-sample-application-react-build_generated-test-sources.zip
            │       └── jhipster-sample-application-react.jar
            └── a0479d94d95b81e9
                └── local
                    └── buildinfo.xml

Now, we have two cache entries, one for the original build, and one for the build with the updated source code. Note that the a0479d94d95b81e9 cache key does not have any artifacts because I’ve only ran the compile goal.

A quick experiment: what happens if I revert the change I’ve made and run mvn compile again?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
[INFO] Local build found by checksum 8c08b80db633cc90
[INFO] Found cached build, restoring io.github.jhipster.sample:jhipster-sample-application-react from cache by checksum 8c08b80db633cc90
[INFO] Skipping plugin execution (cached): enforcer:enforce
[INFO] Skipping plugin execution (cached): enforcer:enforce
[INFO] Skipping plugin execution (cached): resources:copy-resources
[INFO] Skipping plugin execution (cached): properties:read-project-properties
[INFO] Skipping plugin execution (cached): jacoco:prepare-agent
[INFO] Skipping plugin execution (cached): spotless:apply
[INFO] Skipping plugin execution (cached): checksum:files
[INFO] Skipping plugin execution (cached): antrun:run
[INFO] Skipping plugin execution (cached): frontend:install-node-and-npm
[INFO] Skipping plugin execution (cached): frontend:npm
[INFO] Skipping plugin execution (cached): frontend:npm
[INFO] Skipping plugin execution (cached): resources:resources
[INFO] Skipping plugin execution (cached): compiler:compile
[INFO] Skipping plugin execution (cached): checksum:files
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time:  12.714 s
[INFO] Finished at: 2023-11-12T18:00:40+01:00
[INFO] ------------------------------------------------------------------------

The build was around 12 seconds. The reason is that the extension found a build in the cache that matches the current source code (with the key of 8c08b80db633cc90), so it used the cached build instead of recompiling the module. But still, the 12 seconds is a bit disappointing, right? No worries, we’ll get back to this later.

Certainly, this experiment is far from a decent benchmark, but it shows the potential of the build cache. Let’s move on to the next tool.

Maven Daemon (mvnd)

The Maven Daemon project is a nice companion to the Maven Build Cache Extension - but it does a great job on its own as well.

Consider it a drop-in replacement for the mvn command, but expect a great performance improvement compared to the vanilla Maven build. The project borrows the idea from Gradle about doing the actual build on a separate thread (that’s the daemon). Removing the JVM startup time from the equation, mvnd can save us a lot of time. Also, the mvnd client executable is built using GraalVM, so it uses a lot less memory and starts faster.

Install Maven Daemon

Personally, I prefer SDKMAN! to install and manage my JDKs and build tools. It’s a great tool, and I highly recommend it. So installing the Maven Daemon is as easy as running the following command:

1
sdk install mvnd

That’s it - now you can use mvnd instead of mvn to do your builds. 🎉 It accepts the same commands and parameters as the original mvn command, so you can consider it as a drop-in replacement.

Benchmark Time

Now, experimenting with the JHipster sample was quite fun, but let’s switch to one of the sample projects I have on my laptop. It has two modules and a relatively small amount of code base with around 25 classes. The build times are much shorter, so I can run a proper benchmark with my build experiments.

Benchmark Setup

I used hyperfine to run my benchmarks. It does a pretty decent job, very easy to use, so I highly recommend it. You can install it with brew install hyperfine on macOS, but it’s quite straightforward to install on other platforms as well: head over to the hyperfine GitHub page and download the actual release for your platform.

Measuring the Build Times

Build Without Any Change in the Source Code ♻️

I used the following command to measure the build times:

1
2
3
4
5
6
7
hyperfine 'mvnd compile' \
'mvnd compile -Dmaven.build.cache.enabled=false' \
'mvn compile' \
'mvn compile -Dmaven.build.cache.enabled=false' \
--warmup 3 \
--runs 100 \
--export-markdown benchmark.md

I benchmarked both mvnd and mvn with and without the build cache enabled. I ran 100 iterations and did an extra 3 runs per measurement to make sure that the JVM was warmed up properly. Finally, I exported the results to a Markdown file, so I can include it in this post.

Here are the results:

BenchmarkMean [ms]Min [ms]Max [ms]Relative
mvnd compile + build cache204.0 ± 74.1185.3922.61.00
mvnd compile262.5 ± 31.4245.3556.11.29 ± 0.49
mvn compile + build cache1089.8 ± 78.11042.51837.25.34 ± 1.98
mvn compile1318.6 ± 79.41259.92030.86.46 ± 2.38

Without a doubt, Maeven Daemon is the champion here: the build times went down from around 1 second to a tad more than 200 milliseconds. Consider the build cache of the sprinkles on top of the cake here. 🍰

For the same, repeated builds over and over again,

  • the build cache extension can give you around a 20% of improvement
  • incorporating the Maven Daemon with the build cache extension can give you around 6.5 times faster “builds”.

So, you may ask: can I expect the same improvement during my day-to-day work? Well, to be honest: I don’t think so. 🤔 For a small project, like I’ve used for this benchmark, the 800 ms improvement I got with removing JVM startup time from the equation is a huge improvement. But for larger projects, the JVM startup time is not that significant like here, so you can expect smaller improvements.

At the other hand, think about it: how often do you run a build without changing anything in the source code? I guess, not that often. Having a well-populated build cache with different versions of your code, you can save some time if need to hop back and forth between different commits or branches in your project.

Build With a Change in the Source Code ✏️

To force a (re)compilation of a small part of the codebase I created a pretty useless enum called Settings. This is what it looks like:

1
2
3
public enum Settings {
    SETTING_1
}

Next, I created a Python script to increment the postfix of the only member of the enum. I know, it’s a bit hacky, but it does the job. 😅

Here is the script:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import re

java_source = "/.../Settings.java"

with open(
    java_source,
    "r",
) as f:
    content = f.read()

pattern = r"SETTING_\d+"
matches = re.findall(pattern, content)
last_match = matches[-1]
last_number = int(last_match.split("_")[-1])
new_number = last_number + 1
new_match = f"SETTING_{new_number}"

new_content = content.replace(last_match, new_match)

with open(
    java_source,
    "w",
) as f:
    f.write(new_content)

Don’t worry, execution of the script is not part of the benchmark, since it will be executed as a prepare step before the actual benchmark. I used the following command to measure the build times:

1
2
3
4
5
6
7
8
hyperfine --prepare 'python kick_enum.py' \
'mvnd compile' \
'mvnd compile -Dmaven.build.cache.enabled=false' \
'mvn compile' \
'mvn compile -Dmaven.build.cache.enabled=false' \
--warmup 3 \
--runs 100 \
--export-markdown benchmark.md

Note the --prepare parameter here.

And the results are:

CommandMean [ms]Min [ms]Max [ms]Relative
mvnd compile + build cache213.3 ± 81.1187.2971.11.00
mvnd compile352.1 ± 67.1322.61003.11.65 ± 0.70
mvn compile + build cache1163.4 ± 201.71000.12741.55.45 ± 2.28
mvn compile1730.8 ± 60.61618.51885.18.11 ± 3.10

Final Thoughts

As you may already noticed, neither the Maven Build Cache Extension nor the Maven Daemon is a silver bullet. You still cannot run all the unit tests on the fly, whenever change something in your code - like in Pester, or jest - eventually, Java is not an interpreted language.

So, why bother with these tools? Well, they have their place in your toolbox. The Maven Daemon is pretty straightforward: it starts your builds faster and make them more streamlined. That’s it. It’s a great tool, and I highly recommend it. What about the build cache?

Let’s say you are hunting for a pesky bug, and not sure when it was introduced. You may already heard about Git’s bisect feature. That’s a great opportunity to leverage the build cache: you can run your tests against different commits without waiting for the build to finish - assuming you have built that revision before on your machine. That’s only one example, but here are two more:

  • As I alredy mentioned, build cache truly shines with CI pipelines, where team members oftentimes request the same builds with partially or fully identical code bases. Here, the build cache can be indeed a huge time saver.

  • I can imagine a scenario where centrally managed dev containers are used in a team. In this case, a pre-warmed build cache can be built nightly and distributed to the team members. Furthermore, imagine if your peers are working in different time zones. You could start your day with all the changes your colleagues made during the night already pre-built and cached on your machine. 😎

Certainly you can ask why not to use Gradle instead of Maven? Well, that’s a valid question. Of course, if you are starting a new project, you have the opportunity to choose your build tool. But if you inherited a legacy project - as I did during in my career like 90% of the time -, you may not have this luxury. In this case, the Maven Build Cache Extension and the Maven Daemon can be a great opportunity to modernize your build process with a minimal effort.

I hope you learned something new today. 🙏 See you next time! 👋

comments powered by Disqus
Built with Hugo
Theme Stack designed by Jimmy