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:
|
|
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 run | 1m 9s |
2nd run | 57s |
3rd run | 1m 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.
|
|
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:
|
|
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.
|
|
💪 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
):
|
|
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:
|
|
Now let’s invoke mvn compile
:
|
|
As you can see, the build took 44 seconds. Let’s see what happened in the cache directory:
|
|
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?
|
|
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:
|
|
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:
|
|
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:
Benchmark | Mean [ms] | Min [ms] | Max [ms] | Relative |
---|---|---|---|---|
mvnd compile + build cache | 204.0 ± 74.1 | 185.3 | 922.6 | 1.00 |
mvnd compile | 262.5 ± 31.4 | 245.3 | 556.1 | 1.29 ± 0.49 |
mvn compile + build cache | 1089.8 ± 78.1 | 1042.5 | 1837.2 | 5.34 ± 1.98 |
mvn compile | 1318.6 ± 79.4 | 1259.9 | 2030.8 | 6.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:
|
|
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:
|
|
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:
|
|
Note the --prepare
parameter here.
And the results are:
Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
---|---|---|---|---|
mvnd compile + build cache | 213.3 ± 81.1 | 187.2 | 971.1 | 1.00 |
mvnd compile | 352.1 ± 67.1 | 322.6 | 1003.1 | 1.65 ± 0.70 |
mvn compile + build cache | 1163.4 ± 201.7 | 1000.1 | 2741.5 | 5.45 ± 2.28 |
mvn compile | 1730.8 ± 60.6 | 1618.5 | 1885.1 | 8.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! 👋