Maven Compiling & Toolchains
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Apache Maven 3.10.0 and the Maven Compiler Plugin 3.16.0. Every example was run on JDK 26 on
2026-10-16, in scratch copies of the tidepool project (a parent POM with the tidepool-model, tidepool-engine and
tidepool-cli modules). The JDKs installed on the machine were Oracle JDK 26, JetBrains Runtime 21.0.10 and JetBrains
Runtime 17.0.14; the last two are what the toolchain examples select. Where behaviour differs, the same command was also run
on Maven 3.9.16 and on Maven 4.0.0-rc-7 (a release candidate, not a final release), and the difference is stated. Paths in
the console output were shortened to /work/..., the home directory to /Users/me, and long output was filtered with
grep or head, which is said each time. The machine’s default locale is Spanish, so the few JVM messages that depend on it
were re-run with -Duser.language=en and are shown in English.
Compiling is the first thing every Java build does, and it is also where most "works on my machine" differences in a JDK
upgrade show up: which language level, which class library, which JDK actually runs javac. Maven answers these with two
cooperating pieces, both configured in the POM: the Compiler Plugin, which calls javac, and the Toolchains Plugin plus a
toolchains.xml file, which tell Maven which JDK to use when it is not the one running Maven. The lifecycle phases that
call the compiler are explained in Maven Build
Lifecycle, and plugin configuration in general in
Plugins & Their Configuration. The
language itself, including its modules, is covered in
Java Packages & Modules.
The compiler plugin and its two goals
The jar, war, ejb and maven-plugin packagings bind two goals of maven-compiler-plugin to the default lifecycle:
| Goal | Default phase | What it does |
|---|---|---|
|
|
Compiles the main sources ( |
|
|
Compiles the test sources ( |
The version of the plugin used when the POM does not name one comes from the lifecycle bindings built into Maven, so it
differs between Maven releases. A POM with no <build> section at all, compiled with each Maven:
$ mvn -B clean compile # Maven 3.9.16
[INFO] --- clean:3.2.0:clean (default-clean) @ tidepool-bare ---
[INFO] --- resources:3.4.0:resources (default-resources) @ tidepool-bare ---
[WARNING] Using platform encoding (UTF-8 actually) to copy filtered resources, i.e. build is platform dependent!
[INFO] --- compiler:3.15.0:compile (default-compile) @ tidepool-bare ---
[WARNING] File encoding has not been set, using platform encoding UTF-8, i.e. build is platform dependent!
[INFO] Compiling 1 source file with javac [debug target 1.8] to target/classes
[WARNING] bootstrap class path is not set in conjunction with -source 8
[WARNING] source value 8 is obsolete and will be removed in a future release
[WARNING] target value 8 is obsolete and will be removed in a future release
[WARNING] To suppress warnings about obsolete options, use -Xlint:-options.
$ mvn -B clean compile # Maven 3.10.0
[INFO] --- clean:3.5.0:clean (default-clean) @ tidepool-bare ---
[INFO] --- resources:3.5.0:resources (default-resources) @ tidepool-bare ---
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-bare ---
[INFO] Compiling 1 source file with javac [debug target 1.8] to target/classes
[WARNING] bootstrap class path is not set in conjunction with -source 8
[WARNING] source value 8 is obsolete and will be removed in a future release
[WARNING] target value 8 is obsolete and will be removed in a future release
[WARNING] To suppress warnings about obsolete options, use -Xlint:-options.
(Output filtered with grep to the plugin lines, the warnings and the compile line.) Three things are worth reading in it.
Maven 3.9.16 binds Compiler Plugin 3.15.0 and Maven 3.10.0 binds 3.16.0; Maven 4.0.0-rc-7 also binds 3.16.0 (and prints
Version not locked for default bindings plugins for the three plugins, a reminder to pin them in pluginManagement, as the
tidepool parent does). Maven 3.9.16 warns that the source encoding was never set, because its super POM does not define
project.build.sourceEncoding; Maven 3.10.0 and 4.0.0-rc-7 do define it (UTF-8, and also
project.build.outputTimestamp, checked with help:effective-pom), so the warning disappears. Finally, with nothing
configured the plugin compiles for Java 8 (debug target 1.8), and JDK 26 says so in four warnings: a build that does not
state its Java level is not portable and gets noisier with every JDK.
Choosing the Java level: release, source and target
The plugin offers three parameters for "which Java am I writing", and the difference between them is the most important thing on this page.
| Parameter (user property) | javac option |
Meaning |
|---|---|---|
|
|
Compile as if for Java N: language level, class-file version and the public API of that release (the
|
|
|
Accept the language features of Java N only. The class library is the one of the JDK running |
|
|
Generate class files that run on Java N. Again the class library is the running JDK’s. |
The plugin’s own documentation lists the defaults for source and target as 1.8 (the default moved from 1.5 to 1.6 in
3.8.0, to 1.7 in 3.9.0 and to 1.8 in 3.11.0) and gives release no default, which is why the bare project above targets
Java 8.
Why release is the one to use
source and target alone are a trap, because they do not stop you from calling methods that did not exist in the older
Java. The next example is a one-class project that calls List.getFirst(), added in Java 21, while claiming to build for
Java 17:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
package io.tidepool.rel;
import java.util.List;
public class Rel {
public static void main(String[] args) {
List<String> stations = List.of("SF", "BOS", "HNL");
System.out.println(stations.getFirst());
}
}
This POM fragment and class are this page’s own lab setup, not an example from the Maven documentation.
$ mvn -B clean compile
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-rel ---
[INFO] Compiling 1 source file with javac [debug target 17] to target/classes
[WARNING] location of system modules is not set in conjunction with -source 17
[INFO] BUILD SUCCESS
$ java -cp target/classes io.tidepool.rel.Rel # JDK 26
SF
$ java -cp target/classes io.tidepool.rel.Rel # JetBrains Runtime 17.0.14
Exception in thread "main" java.lang.NoSuchMethodError: 'java.lang.Object java.util.List.getFirst()'
at io.tidepool.rel.Rel.main(Rel.java:8)
The build is green, the class files are Java 17 class files, and the program fails on a real Java 17. The compiler’s
warning (location of system modules is not set in conjunction with -source 17) was the only hint. Now the same sources
with release set (here on the command line, -Dmaven.compiler.release=17, which wins over the properties in the POM):
$ mvn -B clean compile -Dmaven.compiler.release=17
[INFO] Compiling 1 source file with javac [debug release 17] to target/classes
[ERROR] COMPILATION ERROR :
[ERROR] .../src/main/java/io/tidepool/rel/Rel.java:[8,36] cannot find symbol
[ERROR] symbol: method getFirst()
[ERROR] location: variable stations of type java.util.List<java.lang.String>
[ERROR] BUILD FAILURE
(Output filtered.) With --release 17, javac consults a description of the Java 17 class library and the missing method is
a compile error, on the developer’s laptop instead of in production. Replacing the two properties with
<maven.compiler.release>21</maven.compiler.release> makes the project build, and running those classes on Java 17 then fails
with the other classic message, this time at class loading:
$ java -cp target/classes io.tidepool.rel.Rel # JetBrains Runtime 17.0.14
Error: LinkageError occurred while loading main class io.tidepool.rel.Rel
java.lang.UnsupportedClassVersionError: io/tidepool/rel/Rel has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 61.0
Class-file version 65 is Java 21. That is the failure mode release exists to turn into an explicit choice: set it to the
oldest Java your users run. The rules that follow from the experiments:
-
When
releaseis set the plugin passes--releaseand ignoressourceandtarget(the[debug release 17]label shows which option was used;[debug target 17]shows asource/targetbuild). -
releaseneeds a JDK that knows that release: running Maven on JDK 17 withrelease21 fails withFatal error compiling: error: release version 21 not supported, and asking JDK 26 for release 27 fails with the same message for 27 (both runs below). -
The
--releaseoption cannot be combined with options that change the platform. Adding--add-exports java.base/jdk.internal.misc=ALL-UNNAMEDtocompilerArgswith release 21 fails withexporting a package from system module java.base is not allowed with --release; code that needs such options has to fall back tosource/target(and a matching JDK). -
The old option names are on their way out in
javacitself: JDK Enhancement Proposal 182 describes the policy of supporting only the last few-sourceand-targetvalues, which is why JDK 26 calls8"obsolete".
$ JAVA_HOME=<JetBrains Runtime 17.0.14> mvn -B clean compile # release 21 in the POM
[INFO] Compiling 4 source files with javac [debug release 21] to target/classes
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.16.0:compile (default-compile) on project tidepool-model: Fatal error compiling: error: release version 21 not supported -> [Help 1]
$ mvn -B clean compile -Dmaven.compiler.release=27 # JDK 26
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.16.0:compile (default-compile) on project tidepool-model: Fatal error compiling: error: release version 27 not supported -> [Help 1]
The tidepool parent sets the level once, as a property, so that every module and every plugin that reads it agree:
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
Test code at another level
compiler:testCompile has its own testRelease, testSource and testTarget parameters (maven.compiler.testRelease,
maven.compiler.testSource, maven.compiler.testTarget), read from the goal’s help output. They exist for the rare project that compiles its main code for
Java 8 but writes its tests with a newer language level.
Skip them unless you have that need; the extra level is one more thing to keep in line with the runtime that executes the tests
(see Testing with Surefire).
The parameters worth knowing
The complete list is printed by the plugin’s own help goal (Maven 3.10.0, mvn compiler:help -Dgoal=compile -Ddetail, which
lists some 50 parameters); the following are the ones a project usually touches. Defaults are those reported by that command.
| Parameter | User property (default) | Purpose |
|---|---|---|
|
|
The Java level, see above. |
|
|
Source file encoding. Set |
|
none (list of |
Extra |
|
|
Whether Maven prints the compiler’s warnings and the places where deprecated API is used. |
|
|
Turn warnings into a build failure (it passes |
|
|
Pass |
|
|
Pass |
|
|
Pass |
|
|
Annotation processing, see below. |
|
|
Run |
|
none |
The JDK requirements for a toolchain used by this plugin alone, see below. |
|
|
Recompile everything when something changed, see below. |
|
none |
Put the classes in |
|
|
Deprecated and a no-op in |
Warnings, lint and -Werror
By default javac prints almost nothing about questionable code, and Maven passes that on. A scratch class with a raw type
and an unchecked call (Legacy.java in tidepool-model) compiles in silence:
$ mvn -B -pl tidepool-model clean compile
[INFO] Compiling 5 source files with javac [debug release 21] to target/classes
[INFO] BUILD SUCCESS
Turning on the lint categories and the deprecation locations is a matter of plugin configuration, here in the parent’s
pluginManagement so that every module gets it:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.16.0</version>
<configuration>
<showDeprecation>true</showDeprecation>
<compilerArgs>
<arg>-Xlint:all</arg>
</compilerArgs>
</configuration>
</plugin>
$ mvn -B -pl tidepool-model clean compile
[INFO] Compiling 5 source files with javac [debug deprecation release 21] to target/classes
[WARNING] .../tidepool-model/src/main/java/io/tidepool/model/Legacy.java:[13,19] found raw type: java.util.List
[WARNING] .../tidepool-model/src/main/java/io/tidepool/model/Legacy.java:[14,9] found raw type: java.util.List
[WARNING] .../tidepool-model/src/main/java/io/tidepool/model/Legacy.java:[14,26] found raw type: java.util.ArrayList
[WARNING] .../tidepool-model/src/main/java/io/tidepool/model/Legacy.java:[15,18] unchecked call to add(E) as a member of the raw type java.util.List
[INFO] BUILD SUCCESS
(Output filtered.) The warnings can then be made fatal, either permanently with <failOnWarning>true</failOnWarning> or for
one run, which is what a CI job does:
$ mvn -B -pl tidepool-model clean compile -Dmaven.compiler.failOnWarning=true
[WARNING] COMPILATION WARNING :
[WARNING] .../Legacy.java:[13,19] found raw type: java.util.List
...
[ERROR] COMPILATION ERROR :
[ERROR] .../tidepool-model/src/main/java/io/tidepool/model/Legacy.java: warnings found and -Werror specified
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.16.0:compile (default-compile) on project tidepool-model: Compilation failure
(Output filtered; the ... line stands for the three warnings that repeat the previous listing.) The same -Xlint:...
syntax and its categories are described in the javac manual.
Parameter names with -parameters
Frameworks that read method parameter names by reflection (Spring MVC, Jackson’s parameter-names module, Picocli in some
modes) need the MethodParameters attribute in the class files, and javac only writes it for -parameters. The Maven
property is maven.compiler.parameters. javap on TideEngine.predict(Station, Instant):
$ mvn -B -q clean compile # then: javap -v -cp ... TideEngine | grep -A4 MethodParameters
(no output: the attribute is absent)
$ mvn -B -q clean compile -Dmaven.compiler.parameters=true
#114 = Utf8 MethodParameters
...
public static io.tidepool.model.Height predict(io.tidepool.model.Station, java.time.Instant);
...
MethodParameters:
Name Flags
station
time
(The second listing is the filtered tail of javap -v.) In a POM it is <parameters>true</parameters> in the plugin
configuration. Java records are the exception that needs nothing: javac always keeps the names of a record’s canonical
constructor parameters.
Preview features
Preview language features are only accepted for the release that ships them, and the plugin passes that restriction on from
javac:
$ mvn -B -pl tidepool-model clean compile -Dmaven.compiler.enablePreview=true
[INFO] Compiling 4 source files with javac [debug preview release 21] to target/classes
[ERROR] COMPILATION ERROR :
[ERROR] invalid source release 21 with --enable-preview
[ERROR] BUILD FAILURE
$ mvn -B -pl tidepool-model clean compile -Dmaven.compiler.enablePreview=true -Dmaven.compiler.release=26
[INFO] Compiling 4 source files with javac [debug preview release 26] to target/classes
[INFO] BUILD SUCCESS
(Output filtered.) The same JDK, and the --enable-preview option, must then be given to whatever runs the code; classes that
actually use a preview feature will not load on a JVM started without it. That requirement reaches the test runner:
configure Surefire’s argLine with --enable-preview as well.
Annotation processors
An annotation processor is a compiler plug-in that reads annotations and generates files (sources, resources, metadata). Two
ways exist to give javac its processors, and the Compiler Plugin supports both:
-
On the class path. A
provided-scope dependency that contains aMETA-INF/services/javax.annotation.processing.Processorfile is discovered byjavac. Simple, but the processor’s own dependencies leak into the compile class path. -
On the processor path.
annotationProcessorPathslists the processors by Maven coordinates; the plugin resolves them (and their transitive dependencies) into a private path that is handed tojavacas--processor-path, and only that path is searched for processors. Versions can come fromdependencyManagementwhenannotationProcessorPathsUseDepMgmtis true. This is the recommended form.
Picocli ships a processor, picocli-codegen, that writes GraalVM native-image configuration for the annotated command class
(Main in tidepool-cli). Using the class-path form on JDK 26, with nothing else configured, produced no output at all:
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli-codegen</artifactId>
<version>${picocli.version}</version>
<scope>provided</scope>
</dependency>
$ mvn -B -pl tidepool-cli -am clean compile # JDK 26
[INFO] Compiling 1 source file with javac [debug release 21] to target/classes
[INFO] BUILD SUCCESS
$ find tidepool-cli/target -type f -not -name '*.class' -not -path '*maven-status*'
(no output)
(Output filtered.) The processor was on the class path and did not run: starting with JDK 23, javac no longer runs
processors it merely discovers there, unless asked with -proc:full or told where they are with a processor path. On
JDK 21 the same build still ran the processor, but printed this notice, which was the early warning:
$ JAVA_HOME=<JetBrains Runtime 21.0.10> mvn -B -pl tidepool-cli -am clean compile
[INFO] Annotation processing is enabled because one or more processors were found
on the class path. A future release of javac may disable annotation processing
unless at least one processor is specified by name (-processor), or a search
path is specified (--processor-path, --processor-module-path), or annotation
processing is enabled explicitly (-proc:only, -proc:full).
Use -proc:none to disable annotation processing.
[INFO] BUILD SUCCESS
(Output filtered to the notice and the result.) Asking for processing explicitly, with the Maven property for -proc, makes
JDK 26 run the processor:
$ mvn -B -pl tidepool-cli -am clean compile -Dmaven.compiler.proc=full
[INFO] BUILD SUCCESS
$ find tidepool-cli/target -type f -not -name '*.class' -not -path '*maven-status*'
tidepool-cli/target/classes/META-INF/native-image/picocli-generated/resource-config.json
tidepool-cli/target/classes/META-INF/native-image/picocli-generated/reflect-config.json
tidepool-cli/target/classes/META-INF/native-image/picocli-generated/proxy-config.json
The recommended form is the processor path. It has no provided dependency, so nothing leaks onto the compile class path, and
it works on every JDK without a notice, because naming a path counts as asking for processing:
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>info.picocli</groupId>
<artifactId>picocli-codegen</artifactId>
<version>${picocli.version}</version>
</path>
</annotationProcessorPaths>
<compilerArgs>
<arg>-Aproject=${project.groupId}/${project.artifactId}</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
$ mvn -B -pl tidepool-cli -am clean compile
[INFO] BUILD SUCCESS
$ find tidepool-cli/target -type f -not -name '*.class' -not -path '*maven-status*'
tidepool-cli/target/classes/META-INF/native-image/picocli-generated/io.tidepool/tidepool-cli/resource-config.json
tidepool-cli/target/classes/META-INF/native-image/picocli-generated/io.tidepool/tidepool-cli/reflect-config.json
tidepool-cli/target/classes/META-INF/native-image/picocli-generated/io.tidepool/tidepool-cli/proxy-config.json
(Output filtered.) The -A argument is an option for the processor, not for javac: Picocli’s project option puts the
files in a directory named after the project, which is what GraalVM expects when several JARs each contribute their own
configuration. The related parameters are annotationProcessors (names of processor classes to run, -processor),
proc (none, only or full, -proc) and generatedSourcesDirectory (default
target/generated-sources/annotations).
Incremental compilation
The plugin decides before every run whether javac has to be called at all, and says so. Running compile twice, then
changing a source file, then removing one:
$ mvn -B -pl tidepool-model clean compile
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 4 source files with javac [debug release 21] to target/classes
$ mvn -B -pl tidepool-model compile
[INFO] Nothing to compile - all classes are up to date.
$ touch tidepool-model/src/main/java/io/tidepool/model/Station.java ; mvn -B -pl tidepool-model compile
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 4 source files with javac [debug release 21] to target/classes
$ rm tidepool-model/src/main/java/io/tidepool/model/TideEvent.java ; mvn -B -pl tidepool-model compile
[INFO] Recompiling the module because of added or removed source files.
[INFO] Compiling 3 source files with javac [debug release 21] to target/classes
$ ls tidepool-model/target/classes/io/tidepool/model/
Constituent.class
Height.class
Station.class
(Output filtered to the plugin’s messages.) With useIncrementalCompilation true, the default, the plugin recompiles the
whole module as soon as a source file was added, removed or changed, or one of the JARs the module depends on changed in
this build run (the log then says Recompiling the module because of changed dependency., seen in the multi-module runs
above). Note in the last run that the class file of the removed source disappeared too: the plugin deletes what an earlier
run created for sources that no longer exist, which is the reason a compile without clean is usually safe.
The false setting is the surprising one. The plugin documentation spells it out: it compiles only the files newer than their
class files and does not recompile classes that use a changed class, which can leave them referring to methods that no longer
exist, and fail at run time. Its description calls the setting "not recommended", and it is not offered here as a speed-up; the
build-time features that do help are in
Build Performance: Cache, Parallel
Builds & mvnd. The classic incremental bug has the same root as in Ant (see
Compiling Java with Ant): the build tool compares
timestamps, not API signatures, so mvn clean remains the first thing to try when stale classes are suspected.
Java modules (module-info.java)
A module-info.java file in src/main/java turns the project into a Java Platform Module System module, and the plugin
reacts by compiling it in module mode: dependencies that are modules, or JARs javac can treat as
automatic modules, go on the module path instead of the class path. The log label changes from [debug release 21] to
[debug release 21 module-path]. The three tidepool modules, with exports and requires that mirror the Maven
dependencies, compile like this:
// tidepool-model/src/main/java/module-info.java
module io.tidepool.model {
exports io.tidepool.model;
}
// tidepool-engine/src/main/java/module-info.java
module io.tidepool.engine {
requires transitive io.tidepool.model;
requires org.apache.commons.csv;
exports io.tidepool.engine;
}
// tidepool-cli/src/main/java/module-info.java
module io.tidepool.cli {
requires io.tidepool.engine;
requires info.picocli;
opens io.tidepool.cli to info.picocli;
}
$ mvn -B clean compile
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-model ---
[INFO] Compiling 5 source files with javac [debug release 21] to target/classes
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-engine ---
[INFO] Compiling 3 source files with javac [debug release 21 module-path] to target/classes
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-cli ---
[INFO] Compiling 2 source files with javac [debug release 21 module-path] to target/classes
[INFO] BUILD SUCCESS
(Output filtered to the plugin lines.) commons-csv and Picocli are not modules in the Maven sense, but they declare an
Automatic-Module-Name in their manifests (org.apache.commons.csv, info.picocli), which is what requires names. A JAR
without that attribute would get a module name derived from its file name, which is fragile and a reason for library authors
to set it. opens io.tidepool.cli to info.picocli is what lets Picocli reflect on the command class at run time.
The compile errors of a module graph are different from class-path errors, and every one of them below was produced by
breaking one module-info.java of the project above:
# engine forgets "requires org.apache.commons.csv"
[ERROR] .../StationReader.java:[9,26] package org.apache.commons.csv is not visible
[ERROR] (package org.apache.commons.csv is declared in the unnamed module, but module io.tidepool.engine does not read it)
# engine requires a module that does not exist (typo)
[ERROR] .../tidepool-engine/src/main/java/module-info.java:[3,32] module not found: org.apache.commons.csvv
# model forgets "exports io.tidepool.model"
[ERROR] .../StationReader.java:[3,19] package io.tidepool.model is not visible
[ERROR] (package io.tidepool.model is declared in module io.tidepool.model, which does not export it)
(Output filtered.) The first message is the confusing one: Commons CSV is on the class path ("the unnamed module" here), and a
named module cannot read the unnamed module, so a typo-free requires is the missing piece. Packaged as JARs, the modules run
from the module path; dependency:copy-dependencies collected the module JARs and the dependencies into a directory
(mods/), and java ran the CLI by module name, using Maven only to build the JARs:
$ java -p mods -m io.tidepool.cli/io.tidepool.cli.Main data/stations.csv BOS
Boston: -0.662 m
Tests of a modular project are a topic of their own (the Surefire plugin patches the test classes into the module under
test); see Testing with Surefire, and for
module-info.java itself the plugin’s own page.
Compiling with a different JDK
Maven runs on one JDK, the one found through JAVA_HOME (see
Installing Maven), and by default the compiler runs inside
that JVM. There are four ways to use another JDK for compiling. In increasing order of how well they scale:
-
Run Maven itself on the other JDK (
JAVA_HOME). Simplest, and the right choice for a one-off check; the whole build, tests included, then uses that JDK, and every developer and CI job has to make the same choice. -
Fork
javacand point at another JDK’s compiler withforkandexecutable. -
Declare the JDK as a toolchain and select it in the POM, so that the same command works on every machine with a
toolchains.xml. -
Let the Toolchains Plugin discover installed JDKs and select one by version (since Toolchains Plugin 3.3.0).
Through JAVA_HOME
$ JAVA_HOME=<JetBrains Runtime 17.0.14> mvn -v
Apache Maven 3.10.0 (c43a36b8d67be7e0805a411bc0898af1a51f5472)
Java version: 17.0.14, vendor: JetBrains s.r.o., runtime: /Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home
$ JAVA_HOME=<JetBrains Runtime 17.0.14> mvn -B -pl tidepool-model clean compile -Dmaven.compiler.release=17
[INFO] Compiling 4 source files with javac [debug release 17] to target/classes
[INFO] BUILD SUCCESS
(mvn -v output filtered to two lines.) Maven 3.10.0 ran fine on that JDK 17; Maven 4.0.0-rc-7 needs Java 17 or newer to
run, and, as the Maven 4 release notes stress, only to run: the code can still be compiled for older releases with the same
plugin configuration.
Forking and the executable
$ mvn -B -pl tidepool-model clean compile -Dmaven.compiler.release=17 -Dmaven.compiler.fork=true \
-Dmaven.compiler.executable=/Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home/bin/javac
[INFO] Compiling 4 source files with javac [forked debug release 17] to target/classes
[INFO] BUILD SUCCESS
(Output filtered; javap on the result reports class-file major version 61, Java 17.) The label forked appears, and the JVM
that runs Maven is untouched. The drawback is that the JDK path is a property you must supply on every machine, with no
version check; the toolchain is exactly that, made declarative.
Toolchains: toolchains.xml
A toolchain is a named tool installed on the machine, with attributes Maven can match against. The only type the standard
plugins use is jdk. The definitions live in toolchains.xml, which is not part of the project (the paths differ per
machine): the user file ~/.m2/toolchains.xml and the global file ${maven.home}/conf/toolchains.xml, both overridable on
the command line with -t/--toolchains and -gt/--global-toolchains. The file used in these examples was kept in the work
directory and passed with -t, so that ~/.m2 was untouched. With the three JDKs of this machine (the home directory is
shown as /Users/me):
<?xml version="1.0" encoding="UTF-8"?>
<toolchains xmlns="http://maven.apache.org/TOOLCHAINS/1.1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/TOOLCHAINS/1.1.0 https://maven.apache.org/xsd/toolchains-1.1.0.xsd">
<toolchain>
<type>jdk</type>
<provides>
<version>26</version>
<vendor>oracle</vendor>
</provides>
<configuration>
<jdkHome>/Library/Java/JavaVirtualMachines/jdk-26.jdk/Contents/Home</jdkHome>
</configuration>
</toolchain>
<toolchain>
<type>jdk</type>
<provides>
<version>21</version>
<vendor>jetbrains</vendor>
</provides>
<configuration>
<jdkHome>/Users/me/Library/Java/JavaVirtualMachines/jbr-21.0.10/Contents/Home</jdkHome>
</configuration>
</toolchain>
<toolchain>
<type>jdk</type>
<provides>
<version>17</version>
<vendor>jetbrains</vendor>
</provides>
<configuration>
<jdkHome>/Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home</jdkHome>
</configuration>
</toolchain>
</toolchains>
provides holds free-form key/value pairs (version, vendor, id and anything else you invent) that a project matches
with its requirements, and configuration/jdkHome is the directory that holds bin/javac. Two details cost time in practice.
jdkHome has to be an absolute path: a ${user.home} placeholder is not expanded, and the plugin then resolves it relative
to the project, which the failure shows:
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-toolchains-plugin:3.3.0:toolchain (default) on project tidepool-parent: Misconfigured toolchains. Non-existing JDK home configuration at /work/tc/${user.home}/Library/Java/JavaVirtualMachines/jbr-21.0.10/Contents/Home
(Run with the first, unmodified version of the file; note that the plugin checks every jdk entry, including the 21 it was
not asked for.) The other detail is the version value: a requirement 17 matches only a provides of exactly 17, not
17.0.14. This is why the file above says 17 while the discovered toolchains below report 17.0.14.
Requiring a toolchain in the POM
The project asks for a JDK by its requirements, with the toolchain goal of the Toolchains Plugin. It checks that a matching
definition exists, and stores the choice in the build context, from where the Compiler Plugin (and Surefire, Javadoc and other
toolchain-aware plugins) pick it up. The goal is bound by the plugin to the validate phase:
<plugin>
<artifactId>maven-toolchains-plugin</artifactId>
<version>3.3.0</version>
<executions>
<execution>
<goals>
<goal>toolchain</goal>
</goals>
</execution>
</executions>
<configuration>
<toolchains>
<jdk>
<version>17</version>
</jdk>
</toolchains>
</configuration>
</plugin>
The tidepool parent in this experiment also had <maven.compiler.release>17</maven.compiler.release>. Maven 3.10.0, with the
file passed by -t:
$ mvn -B -t ../toolchains.xml clean compile
[INFO] --- toolchains:3.3.0:toolchain (default) @ tidepool-model ---
[INFO] Required toolchain: jdk [ version='17' ]
[INFO] Found matching toolchain for type jdk: JDK[/Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home]
[INFO] --- resources:3.5.0:resources (default-resources) @ tidepool-model ---
[INFO] --- compiler:3.16.0:compile (default-compile) @ tidepool-model ---
[INFO] Toolchain in maven-compiler-plugin: JDK[/Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home]
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 4 source files with javac [forked debug release 17] to target/classes
...
[INFO] Reactor Summary for tidepool-parent 1.0.0-SNAPSHOT:
[INFO] tidepool-parent .................................... SUCCESS [ 0.433 s]
[INFO] tidepool-model ..................................... SUCCESS [ 1.616 s]
[INFO] tidepool-engine .................................... SUCCESS [ 0.793 s]
[INFO] tidepool-cli ....................................... SUCCESS [ 0.640 s]
(Output filtered; the ... line stands for the same sequence in the other modules; mvn ran on JDK 26 throughout.) Three
lines carry the story. Found matching toolchain is the Toolchains Plugin; Toolchain in maven-compiler-plugin is the Compiler
Plugin noticing the choice; and forked in the compile line shows how it is used, as a separate javac process from the
selected JDK. The class files are version 61. The same file and POM give the same result on Maven 3.9.16 and on
Maven 4.0.0-rc-7, so a project can try Maven 4 without touching its toolchains.
Surefire follows the same selection, which matters because tests must run on the JDK they were compiled for. With a JUnit
test that prints System.getProperty("java.version") in tidepool-model:
$ mvn -B -pl tidepool-model -t ../toolchains.xml clean test
[INFO] Toolchain in maven-compiler-plugin: JDK[/Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home]
[INFO] Compiling 4 source files with javac [forked debug release 17] to target/classes
[INFO] Toolchain in maven-compiler-plugin: JDK[/Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home]
[INFO] Compiling 1 source file with javac [forked debug release 17] to target/test-classes
[INFO] --- surefire:3.6.0:test (default-test) @ tidepool-model ---
[INFO] Toolchain in maven-surefire-plugin: JDK[/Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home]
tests run on Java 17.0.14
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.074 s -- in io.tidepool.model.JdkTest
(Output filtered.) The requirement must not be satisfiable by guesswork, so when it is not, the build stops before compiling:
# a requirement no definition provides (version 11)
[INFO] Required toolchain: jdk [ version='11' ]
[ERROR] No toolchain matched from 3 found for type jdk
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-toolchains-plugin:3.3.0:toolchain (default) on project tidepool-model: Cannot find matching toolchain definitions for the following toolchain types:
[ERROR] jdk [ version='11' ]
[ERROR] Please make sure you define the required toolchains in your ~/.m2/toolchains.xml file.
# no toolchains.xml found at all
[INFO] Required toolchain: jdk [ version='17' ]
[ERROR] No toolchain found for type jdk
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-toolchains-plugin:3.3.0:toolchain (default) on project tidepool-model: Cannot find matching toolchain definitions for the following toolchain types:
[ERROR] jdk [ version='17' ]
[ERROR] Please make sure you define the required toolchains in your ~/.m2/toolchains.xml file.
(Output filtered.)
A quicker alternative for one plugin only is the Compiler Plugin’s own jdkToolchain parameter, a list of the same
requirements inside its <configuration> (<jdkToolchain><version>17</version></jdkToolchain>). According to its description
it overrules the toolchain selected by the Toolchains Plugin and needs Maven 3.3.1 or later. It was read from the plugin help
and not run here.
Discovering JDKs: Toolchains Plugin 3.3.0
Writing toolchains.xml by hand on every machine is the weak point of the classic approach. Toolchains Plugin 3.3.0 adds three
goals (the plugin now has five) that find JDKs by themselves. The first lists what it found, with no toolchains.xml and no
project configuration:
$ mvn -B org.apache.maven.plugins:maven-toolchains-plugin:3.3.0:display-discovered-jdk-toolchains
[INFO] Found 3 possible jdks: [/Library/Java/JavaVirtualMachines/jdk-26.jdk/Contents/Home, /Users/me/Library/Java/JavaVirtualMachines/jbr-21.0.10/Contents/Home, /Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home]
[INFO] Discovered 3 JDK toolchains:
[INFO] - /Users/me/Library/Java/JavaVirtualMachines/jbr-21.0.10/Contents/Home
[INFO] provides:
[INFO] version: 21.0.10
[INFO] runtime.name: OpenJDK Runtime Environment
[INFO] runtime.version: 21.0.10+7-b1163.110
[INFO] vendor: JetBrains s.r.o.
[INFO] vendor.version: JBR-21.0.10+7-1163.110-nomod
[INFO] lts: true
[INFO] - /Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home
...
[INFO] - /Library/Java/JavaVirtualMachines/jdk-26.jdk/Contents/Home
[INFO] provides:
[INFO] version: 26
[INFO] runtime.name: Java(TM) SE Runtime Environment
[INFO] runtime.version: 26+35-2893
[INFO] vendor: Oracle Corporation
[INFO] current: true
(Output filtered; the ... line stands for the JDK 17 entry, shaped like the JDK 21 one.) It found all three JDKs, including
the two in the per-user directory. The second goal, generate-jdk-toolchains-xml (-Dtoolchain.file=..., otherwise standard
output), writes the same information as a toolchains.xml fragment, with every discovered attribute in provides and the
jdkHome of each; that is a starting point when a file is needed by tooling that does not run the plugin. The third goal is the
one used in a POM:
<plugin>
<artifactId>maven-toolchains-plugin</artifactId>
<version>3.3.0</version>
<executions>
<execution>
<goals>
<goal>select-jdk-toolchain</goal>
</goals>
</execution>
</executions>
<configuration>
<version>[17,18)</version>
</configuration>
</plugin>
$ mvn -B -pl tidepool-model clean compile -Dmaven.compiler.release=17 # no toolchains.xml anywhere
[INFO] --- toolchains:3.3.0:select-jdk-toolchain (default) @ tidepool-model ---
[INFO] Discovered matching JDK toolchain: JDK[/Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home]
[INFO] Selected JDK toolchain: JDK[/Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home]
[INFO] Toolchain in maven-compiler-plugin: JDK[/Users/me/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home]
[INFO] Compiling 4 source files with javac [forked debug release 17] to target/classes
[INFO] BUILD SUCCESS
(Output filtered.) The goal’s parameters are version, vendor, runtimeName, runtimeVersion and env (the toolchain
properties, with user properties toolchain.jdk.version and so on), plus how to choose among several matches: the
comparator (default lts,current,env,version,vendor, prefer an LTS JDK, then the current one, and so on) and useJdk.
Three behaviours were seen directly.
-
The
versionrequirement uses Maven’s version range syntax, so[17,18)means "any 17.x" and matches17.0.14. A bare17matched nothing: the run with<version>${maven.compiler.release}</version>andrelease17 failed withCannot find matching toolchain definitions for the following toolchain types:{version=17}. -
An open range such as
[${maven.compiler.release},)("at least this release") is satisfied by the JDK that is already running Maven, and then no external toolchain is used:Not using an external toolchain as the current JDK 'JDK[/Library/Java/JavaVirtualMachines/jdk-26.jdk/Contents/Home]' matches the requirements.Forrelease17, 21 and 26 the plugin did not switch JDKs. That is the defaultuseJdkmode,IfMatch, chosen for speed; set it toIfSameorNeverto force the separate JDK. -
The discovery only exists in Toolchains Plugin 3.3.0 and later, so older plugin versions still need the classic
toolchains.xml.
The diagram shows the data flow of a toolchain build: either source records the JDK that satisfies the project’s requirement in the build context, and the plugins that know about toolchains launch their tools from it.
Choosing between the approaches
| Approach | Use when | Watch out for |
|---|---|---|
|
A one-off check or CI jobs that already run in a matrix of JDK images. |
Everything runs on that JDK, including Maven and the tests; nothing in the POM records the intent. |
|
A quick local experiment. |
A machine-specific path, no version check, and it only affects the compiler. |
|
The build must use a given JDK on every machine, and the plugins (tests, Javadoc) must agree with the compiler. |
Each machine needs the file; |
|
Developer laptops where JDKs are installed in the standard places. |
Requires plugin 3.3.0 or later; with the default |
Maven 4 (release candidate)
The Maven 4 line is exercised here with Maven 4.0.0-rc-7, a release candidate, and the Compiler Plugin 4.0.0-beta-5, a pre-release; the final Maven 4 and the stable 4.x compiler plugin may differ from what follows. The default binding in Maven 4.0.0-rc-7 is still the 3.x plugin (3.16.0, shown above), so a project moves to the 4.x plugin by naming its version.
-
The same configuration works. The
jpmsproject of this page, with only the plugin version changed to4.0.0-beta-5, compiled all three modules on Maven 4.0.0-rc-7. The plugin is quieter at the default log level: theCompiling N source files with javac [...]line (shown atINFOby 3.16.0) was not printed. -
Dependency types decide class path versus module path. Maven 4 introduces the dependency types
classpath-jarandmodular-jar(andprocessor,classpath-processor,modular-processorfor annotation processor paths), wherejarkeeps the heuristic of letting the plugin decide. The release notes state that, at the time of writing, only Compiler Plugin 4.0.0-beta-3 and later honour them. With the tidepool CLI module andinfo.picocli:picoclideclared with an explicit<version>:<type>jar</type> -> BUILD SUCCESS <type>modular-jar</type> -> BUILD SUCCESS <type>classpath-jar</type> -> [ERROR] module not found: info.picocli [ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:4.0.0-beta-5:compile (default-compile) on project tidepool-cli: Cannot compile io.tidepool:tidepool-cli:jar:1.0.0-SNAPSHOT main classes.(Output filtered; the version had to be written explicitly because a
dependencyManagemententry forjardoes not apply to another type.) Forcing the dependency onto the class path hides it fromrequires info.picocli, which is the point of the explicit types. -
Several source directories without build-helper. The Maven 4 model version 4.1.0 adds a
<sources>element under<build>, with one<source>per directory and a<scope>ofmainortest. A project withsrc/main/javaandsrc/gen/java(a hand-written class using a generated one) compiled with Compiler Plugin 4.0.0-beta-5 on Maven 4.0.0-rc-7, andjava -cp target/classes io.tidepool.s41.Handprintedgenerated. The release notes describe the new element as the way to set up multi-release and Java module source hierarchies as well; those two uses are described, not run here. -
Maven 4 in this area is otherwise about the build model rather than compilation: see What’s New in Maven 4 and Migrating to Maven 4.
The same in Ant and Gradle
| Maven | Ant | Gradle |
|---|---|---|
|
|
|
|
nested |
|
|
|
|
|
|
the |
|
none; set |
|
The details are in Compiling Java with Ant, Gradle Java Projects and Gradle Toolchains; the shared comparison is in Comparing Ant, Maven and Gradle. Gradle differs most in the toolchain: the declaration is in the build file, and Gradle can download a missing JDK, while Maven needs the JDK installed and described on each machine (or discovered, as above).