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

compiler:compile

compile

Compiles the main sources (src/main/java) into target/classes. Its default execution id is default-compile.

compiler:testCompile

test-compile

Compiles the test sources (src/test/java) into target/test-classes, with the main classes and the test-scope dependencies on the class path. Its default execution id is default-testCompile.

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

release (maven.compiler.release)

--release N

Compile as if for Java N: language level, class-file version and the public API of that release (the ct.sym description of the old class library).

source (maven.compiler.source)

-source N

Accept the language features of Java N only. The class library is the one of the JDK running javac.

target (maven.compiler.target)

-target N

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 release is set the plugin passes --release and ignores source and target (the [debug release 17] label shows which option was used; [debug target 17] shows a source/target build).

  • release needs a JDK that knows that release: running Maven on JDK 17 with release 21 fails with Fatal 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 --release option cannot be combined with options that change the platform. Adding --add-exports java.base/jdk.internal.misc=ALL-UNNAMED to compilerArgs with release 21 fails with exporting a package from system module java.base is not allowed with --release; code that needs such options has to fall back to source/target (and a matching JDK).

  • The old option names are on their way out in javac itself: JDK Enhancement Proposal 182 describes the policy of supporting only the last few -source and -target values, which is why JDK 26 calls 8 "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

release, source, target

maven.compiler.release, maven.compiler.source and maven.compiler.target (1.8 for the last two)

The Java level, see above.

encoding

encoding (default: ${project.build.sourceEncoding})

Source file encoding. Set project.build.sourceEncoding once; Maven 3.9 warns when it is missing.

compilerArgs

none (list of <arg>)

Extra javac arguments such as -Xlint:all, -Xmaxerrs, -Aoption=value for processors. A -J option only works with fork.

showWarnings, showDeprecation

maven.compiler.showWarnings (true), maven.compiler.showDeprecation (false)

Whether Maven prints the compiler’s warnings and the places where deprecated API is used.

failOnWarning, failOnError

maven.compiler.failOnWarning (false), maven.compiler.failOnError (true)

Turn warnings into a build failure (it passes -Werror), or let the build continue after errors.

parameters

maven.compiler.parameters (false)

Pass -parameters: store method parameter names in the class files.

enablePreview

maven.compiler.enablePreview (false)

Pass --enable-preview.

debug, debuglevel

maven.compiler.debug (true), maven.compiler.debuglevel

Pass -g or -g: with a list (lines,vars,source). The default is full debug info, which is why the log says debug.

proc, annotationProcessors, annotationProcessorPaths

maven.compiler.proc

Annotation processing, see below.

fork, executable, meminitial, maxmem

maven.compiler.fork (false), maven.compiler.executable, maven.compiler.meminitial, maven.compiler.maxmem

Run javac in a separate process, optionally another JDK’s javac, with its own heap.

jdkToolchain

none

The JDK requirements for a toolchain used by this plugin alone, see below.

useIncrementalCompilation

maven.compiler.useIncrementalCompilation (true)

Recompile everything when something changed, see below.

multiReleaseOutput

none

Put the classes in META-INF/versions/${release}; the build must also set Multi-Release: true in the JAR manifest. (Described by the help output; not run here.)

optimize

maven.compiler.optimize (false)

Deprecated and a no-op in javac.

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 a META-INF/services/javax.annotation.processing.Processor file is discovered by javac. Simple, but the processor’s own dependencies leak into the compile class path.

  • On the processor path. annotationProcessorPaths lists the processors by Maven coordinates; the plugin resolves them (and their transitive dependencies) into a private path that is handed to javac as --processor-path, and only that path is searched for processors. Versions can come from dependencyManagement when annotationProcessorPathsUseDepMgmt is 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:

  1. 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.

  2. Fork javac and point at another JDK’s compiler with fork and executable.

  3. 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.

  4. 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 version requirement uses Maven’s version range syntax, so [17,18) means "any 17.x" and matches 17.0.14. A bare 17 matched nothing: the run with <version>${maven.compiler.release}</version> and release 17 failed with Cannot 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. For release 17, 21 and 26 the plugin did not switch JDKs. That is the default useJdk mode, IfMatch, chosen for speed; set it to IfSame or Never to 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.

flowchart LR A["toolchains.xml (user, global or -t file)"] --> C["toolchain goal (validate phase)"] B["JDK discovery (select-jdk-toolchain goal)"] --> D["selected JDK in the build context"] C --> D D --> E["compiler plugin: forked javac"] D --> F["surefire plugin: test JVM"] D --> G["other toolchain-aware plugins"]

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

JAVA_HOME

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.

fork + executable

A quick local experiment.

A machine-specific path, no version check, and it only affects the compiler.

toolchain + toolchains.xml

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; jdkHome must be absolute; the version has to match exactly.

select-jdk-toolchain

Developer laptops where JDKs are installed in the standard places.

Requires plugin 3.3.0 or later; with the default IfMatch, a JDK that is newer than needed is used as-is.

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 jpms project of this page, with only the plugin version changed to 4.0.0-beta-5, compiled all three modules on Maven 4.0.0-rc-7. The plugin is quieter at the default log level: the Compiling N source files with javac [...] line (shown at INFO by 3.16.0) was not printed.

  • Dependency types decide class path versus module path. Maven 4 introduces the dependency types classpath-jar and modular-jar (and processor, classpath-processor, modular-processor for annotation processor paths), where jar keeps 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 and info.picocli:picocli declared 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 dependencyManagement entry for jar does not apply to another type.) Forcing the dependency onto the class path hides it from requires 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> of main or test. A project with src/main/java and src/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, and java -cp target/classes io.tidepool.s41.Hand printed generated. 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

maven.compiler.release

release attribute of <javac>

options.release of the JavaCompile task, or java.toolchain

compilerArgs

nested <compilerarg>

options.compilerArgs

fork + executable

fork="true" and executable

options.isFork and options.forkOptions.executable

annotationProcessorPaths

<compilerarg> with -processorpath

the annotationProcessor configuration

toolchains.xml and toolchain goal

none; set JAVA_HOME or executable

java { toolchain { ... } }, with provisioning

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).