How to Speed up Maven Builds

Sergei Chernov

As a Maven project grows in size, the question of build speed and test execution time becomes more important. One of the most obvious ways to optimize is to switch to another build system (e.g., Gradle). However, before making the switch (which will inevitably bring new challenges—we’ll discuss this at the end), let’s first explore what we can do to speed things up while staying on Maven. Many of these tips will be useful regardless, even if you eventually decide to migrate.

We’ll start with the basics, discuss caches and build scans, and then take a deeper look under Maven’s hood to identify and fix bottlenecks. We’ll also compare it in detail with Gradle. Each tip alone might not make a huge difference, but combined, they can significantly boost performance!

In this article, we’ll focus mainly on optimizing compilation.

Checking the JDK

If your engineers are using MacBooks with Apple processors (or Windows on ARM), it’s worth checking whether an x64 JDK is running in emulation mode. While technologies like Rosetta provide convenience, they also introduce noticeable additional CPU load. You can check this in the console with the command mvn -v:

Check the value of “arch”

You can automate this check and show a warning message (or even fail the build) on local environments that you can’t control. This can be useful if you want to solve this issue for your team of developers.

A ready-to-use solution for this is jvm-arch-maven-extension – depending on configuration it will print warning message or fail the build:

<extensions>
    <extension>
        <!-- https://github.com/seregamorph/jvm-arch-maven-extension -->
        <groupId>com.github.seregamorph</groupId>
        <artifactId>jvm-arch-maven-extension</artifactId>
        <version>0.1</version>
    </extension>
</extensions>

Read the project documentation to find insights if you would like to implement your own build monitoring or validation.

Another important factor when choosing a JDK is that, on average, each new JDK release is faster than the previous one. This includes improvements in javac performance. The difference is especially noticeable when upgrading across multiple major versions. So, if you’re still using an older JDK, this is another good reason to update.

The JDK vendor also matters—performance can vary significantly. For example, Oracle JDK is not the fastest option. There’s a common recommendation to use the same JDK distribution as in production, but the choice is up to you.

Kotlin

If your project uses Kotlin, it’s worth upgrading to version 2.x. JetBrains has done a great work rewriting the compiler, and with K2 compilation will be noticeably faster—even if your modules contain a mix of Java and Kotlin.

For a safer upgrade, it’s best to follow these three steps:

  1. First, update to Kotlin 1.9.25 (the latest 1.x version).
  2. Then, switch to Kotlin 2.0.21 (the latest 2.0.x version) while still using the old compiler mode.
  3. Finally, enable the K2 compiler.

The compiler version mode (which is different from the Kotlin plugin version) is set using the language-version parameter:

Build Caches and Build Scans

One of the biggest performance issues with Maven is the lack of a built-in caching mechanism. And it doesn’t look like this will change in upcoming versions. However, there are external solutions available.

Apache provides the Maven Build Cache Extension, which supports both local and remote caches (mainly for CI/CD). The main limitation of this solution is that it caches Maven Lifecycle Phases. This works well for medium-sized projects without heavy customization. If your project doesn’t rely on custom plugins or advanced features like selective test execution, it’s worth considering.

A typical Apache Maven Build Cache Extension setup consists of two files:

  • .mvn/extensions.xml
  • maven-build-cache-config.xml

Alternatively, instead of defining the extension in .mvn/extensions.xml, you can declare it in the root pom.xml, as done in the Jetty project (the config file is available here):

It turns out that Maven is quite flexible when it comes to customizing its behavior. You can even create your own extensions or plugins for build caching, distributed testing, and selective test execution.

For example, here’s my project for caching maven-surefire-plugin and maven-failsafe-plugin: https://github.com/seregamorph/maven-surefire-cached

Develocity (Formerly Gradle Enterprise)

Another option is Develocity from Gradle, a closed-source solution. It adds caching to Maven via the Develocity Maven Extension, which works differently from Apache’s approach. Instead of caching Maven Lifecycle Phases, it caches each plugin goal, making it more granular. This caching mechanism is similar to what Gradle uses.

  • Remote caching requires a paid license for Develocity.
  • Local caching and build scans are available for free, with build scans stored on Gradle’s cloud servers.
  • If security policies prevent using build scans, they can be disabled while keeping local caching enabled.

The setup also requires two files:

By default Develocity will ask to submit build scan information to the cloud service. You should consult with your security team if that’s acceptable. If not you can disable submitting build scan in the extension configuration.

Let’s add the extension to the Netty project and compare the build speed.
Compared to the default time (2m11s), the build is almost twice as fast.
After the build finishes, the console will show a build scan link, something like this: 🔗 https://gradle.com/s/lh7h42es3riko
There, you can check detailed execution stats, including cache performance.

About build Scans

Develocity provides detailed information about the project build, including resource usage, cache efficiency, and timelines. This makes it much easier to diagnose issues and identify bottlenecks.
Here’s an example of build scans from Spring projects:
🔗 https://ge.spring.io/scans

Parallel Builds

By default, Maven builds a project using a single thread, but it also supports multi-threaded builds using the -T parameter. You can specify a fixed number of threads, or use a core count multiplier C to determine the number of threads based on the available CPU cores:

# 6 threads
mvn clean package -T6

# same number of threads as CPU cores
mvn clean package -T1C

It’s important to note that not every project supports multi-threaded builds correctly. For example, if tests use fixed port numbers, conflicts will occur. In such cases, refactoring the tests to use dynamic or random ports can significantly improve build speed.

Another downside is that the build log will become mixed for all parallel tasks, which can make diagnosing issues more difficult.

You can set these parameters by default in the .mvn/maven.config file, so you don’t need to specify them each time in the command line:

Let’s run the build one more time with caches disabled. This will help us better identify the bottlenecks in parallelization.

mvn clean install -DskipTests=true -Dgradle.cache.local.enabled=false -T6

In the timeline diagram above, you can see the distribution of work across different threads—at some points, 5 threads are active simultaneously, while at other times, only one is active. This represents our bottleneck and the first candidates for optimization.

Takari Timeline Extension

Before we discuss how to improve parallelism, there’s another way to visualize bottlenecks in parallel builds, especially if strict security policies prevent uploading build scans to an external cloud. The open-source Takari timeline extension can help there.

It’s easy to integrate into the project:

<extensions>
    <extension>
        <!-- https://github.com/takari/maven-timeline -->
        <groupId>io.takari.maven</groupId>
        <artifactId>maven-timeline</artifactId>
        <version>2.0.1</version>
    </extension>
</extensions>

After any execution of maven command in the root target directory you can find HTML report with the timeline of task execution like this:

On the timeline you can distinguish tasks with low parallelism (build bottleneck) and good parallelism. Now let’s try to discover Maven under the hood, how it works and how distributes tasks between cores.

How Parallelism Works in Maven?

The key here is multi-modularity. A single-module project offers almost no space for optimization in terms of parallelism. It’s the presence of multiple modules that creates opportunities for improving build times through parallel execution.

For the modules on the diagram, parallelism is possible between those that do not depend on each other. For example, feature1-impl can be built in parallel with feature2-impl. Let’s take a look under the hood of Maven to understand how it handles parallel builds.

We’ll focus on a single module that declares dependencies on other modules and libraries from the repository (upstream dependencies), as well as modules that depend on the current one (downstream dependencies).

Build lifecycles are simplified

The execution of this dependency tree is managed by MultiThreadedBuilder, which has a very simple contract:

Before starting the build of the current module, the scheduler waits for the complete build (all phases) of all modules it depends on (upstream dependencies), regardless of the dependency scope. The same rule applies in the opposite direction—any module that depends on the current one will wait for all build phases to complete before it starts its own build. All phases within a single module are executed sequentially by a single thread.

In other words, even if our hypothetical module core depends on test-utils in the <scope>test</scope>, Maven will not start building core until it has built and run the tests for test-utils. This greatly reduces parallelism, and the multi-core processor remains underutilized.

Maven: no parallelism for modules with Dependencies on each other

Compare with Gradle

In this sense, Gradle performs much better. In Gradle, the dependency tree is built not between modules (Project) but between tasks (Task). This allows Gradle to parallelize tasks more effectively, resulting in faster builds (though at the cost of higher memory consumption).
Additionally, Gradle doesn’t wait for the compilation and test execution to complete before it assembles artifacts (like jar or war files). This further optimizes the build process and reduces the overall build time compared to Maven, which waits for all phases of a module to complete before moving forward.

Removing Unnecessary Dependencies

The fewer dependencies a module has, the sooner Maven can build it (and the sooner it will be available for other modules that depend on it). To manage and remove unnecessary dependencies, you can use the built-in dependencies plugin in Maven.

For example, you can use the following command to list all the dependencies of a module:

mvn dependency:analyze

...
[WARNING] Unused declared dependencies found:
[WARNING]    org.springframework.boot:spring-boot-starter-web:jar:2.4.1:compile
[WARNING]    org.springframework.boot:spring-boot-starter-data-jpa:jar:2.4.1:compile
[WARNING]    org.hibernate.validator:hibernate-validator:jar:6.1.6.Final:compile
[WARNING]    com.h2database:h2:jar:1.4.200:runtime
[WARNING]    com.fasterxml.jackson.core:jackson-databind:jar:2.11.3:compile
[WARNING]    com.fasterxml.jackson.datatype:jackson-datatype-jsr310:jar:2.11.3:compile
[WARNING]    org.springframework.boot:spring-boot-starter-test:jar:2.4.1:test
When removing unnecessary dependencies, the module’s build shifts to the left on the timeline.

It is important to be cautious when removing dependencies and not rely too heavily on the plugin’s advice. It’s quite possible that the removed dependency should remain in the final build. However, the lower it is in the dependency tree (most likely, your project has a module like app that depends on all others—make sure it’s there), the better the parallelism.

Splitting Large Modules (Independent Parts)

It makes sense to look at the structure of the largest modules in the project—these are likely to have more dependencies, and there’s a chance to find independent groups of classes. By splitting them into separate modules, we can likely reduce the dependencies of each one.

After splitting, Maven will be able to build the modules independently and in parallel

You can achieve a significant speedup by breaking down several of the largest such modules. The text above describes how to find the first candidates for optimization (bottlenecks without parallelism).

Splitting Large Modules (Dependent Parts)

If you actively use interfaces and Dependency Injection (or SPI via ServiceLoader), large modules can be split into dependent parts: -api (with interfaces) and -impl (implementations). This approach generally promotes better module interface design by hiding implementation details. Additionally, it significantly reduces the number of required dependencies.

Most modules that depended on the large split module will now depend only on its -api part. The exception is the app modules and integration test modules, which still require the -impl modules.

Moving Code Generation to Libraries

There are two main types of code generation:

  1. From static descriptors like protobuf or avro files, SQL (e.g., jOOQ), OpenAPI from YAML.
  2. Generated from the source code itself – Apache Feign, OpenAPI and others.

We are primarily talking about the first type, as it is not tied to the current code and can be easily removed. Essentially, such code generation depends only on the tool itself and its “schema.”

In general, having code generation directly in the project is convenient, as everything is in one place—the schemas, source files, and generated code. But it creates a number of problems: firstly, you cannot simply open the project in IDEA and compile it directly through the IDE—it requires running Maven commands. After that, any application or test run from the IDE will trigger a full rebuild because the development environment doesn’t know how to properly handle an incremental build. Furthermore, once the code is generated, it will eventually become outdated—for example, when schema updates occur, you might encounter unclear bugs that can only be resolved by regenerating the code. Either way, the generated code also needs to be compiled, which consumes resources. If schemas or SQL don’t change very often, it makes sense to move everything into a library and include it as a dependency.

If You Really Want to Keep Code Generation

In the case of switching to Gradle, this can work more efficiently due to caches. Additionally, in IDEA, build operations are delegated to Gradle by default, so the build will automatically decide whether regeneration is necessary or not.
If staying with Maven, you can reconfigure the code generation from target/generated-sources/java to src/main/java, meaning you will include this code in version control and update it every time the schema changes. Initially, this approach may seem strange, but it turns out to be a good compromise that works well for relatively large projects.

Once again, I want to emphasize why moving code generation is important:
You will gain the ability to switch from a Maven build (locally) to a fully incremental build in IDEA—and it is definitely faster almost all the time.

Stop Publishing Unnecessary Artifacts

If you’re developing a library, it’s useful to publish sources.jar. However, if you’re building an application, you shouldn’t deploy every module separately, especially with -javadoc, -sources, -test-sources, and -test-jar (instead, publish only the final artifact, ideally as a multi-layered Docker image to save space). These extra operations not only consume a lot of storage in repositories but also significantly increase build time.

Even if you’re not publishing them, check if such configurations exist in your project (again, unless you’re building a library)—you might want to remove them:

  • maven-source-plugin (jar-no-fork goal)
  • maven-javadoc-plugin (jar goal)

Persistent CI/CD Build Agents

For CI/CD build agents, consider using persistent instances that are reused across multiple builds. This can significantly reduce build times by avoiding unnecessary dependency downloads from the repository. Additionally, previous build caches can remain stored in the local filesystem.

However, balance is key—keeping agents alive too long may increase operational costs and make it harder to diagnose issues that could be easily solved by restarting a fresh agent.

Use Maven Profiles

If certain build steps aren’t always necessary, make them optional using Maven profiles. If only a small percentage of developers need specific steps (e.g., code generation or validation), avoid enabling them by default. You’ll need to balance simplicity vs. optimization—decide what should run by default without extra parameters.

Use profiles for CI/CD steps or provide helper scripts so developers don’t have to memorize how to enable/disable specific tasks.

Why Not Just Switch to Gradle?

Despite its advantages, Gradle has drawbacks:

  • Higher memory and disk usage—cache directories can quickly grow in size on large projects.
  • Imperative build scripts—while flexible, they can become hard to maintain and may break on major updates.
  • Pipeline migration—switching to Gradle requires rebuilding all CI/CD pipelines and retraining engineers.
  • Cache issues—even with Develocity, diagnosing build problems (like “poisoned” caches) can be frustrating.
  • Gradle daemon quirks—can sometimes hang or even refuse to stop using gradle --stop.

Surprisingly, Maven can sometimes be faster, as Gradle’s configuration phase can take a long time—Maven might finish recompiling in the same period (this statement is mostly related to compilation time).

Conclusion

In the long run, mature projects tend to migrate to Gradle (mainly for test optimization). However, many actively developed projects still work perfectly fine with Maven. It’s far from obsolete—it continues to evolve and maintains a steady market share among Java projects.

Total
0
Shares
Previous Post

The Role of Quarkus in the Modern Java Ecosystem

Next Post

Java 24: A Story of Code, Conflict, and Conquer 

Related Posts