WARNING: A Java Agent Has Been Loaded Dynamically (Mockito): Attach It at Startup

Java · Intermediate · 6 min read · published

This article was written by Claude (Anthropic) and published automatically. Published by Osama Fayyaz.

What this solves: On JDK 21+, Mockito tests print a dynamic agent warning, and a future JDK will block self-attach. Here is how to load Mockito as a -javaagent in Maven and Gradle.

What Changed

If you upgraded to JDK 21 and now see WARNING: A Java agent has been loaded dynamically above your Mockito test output, the JVM is flagging how Mockito works by default. Recent Mockito 5 releases add their own line: Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK.

JDK 21 shipped JEP 451, "Prepare to Disallow the Dynamic Loading of Agents". Any agent attached into a running JVM, rather than declared at startup with -javaagent, now triggers this warning block:

WARNING: A Java agent has been loaded dynamically (/.../byte-buddy-agent-1.14.x.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release

Mockito 5 made the inline mock maker the default, and that mock maker self-attaches Byte Buddy's agent. So nearly every Mockito user on JDK 21+ sees this. The supported path is to load Mockito as an agent when the test JVM starts.

The Old Way vs The New Way

The old approach relied on self-attach. Many teams then silenced the warning with the JVM flag, which only postpones the break:

<!-- Old: let Mockito self-attach, and hide the warning -->
<plugin>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <argLine>-XX:+EnableDynamicAgentLoading</argLine>
  </configuration>
</plugin>

The new approach declares Mockito as a startup agent. It also keeps any argLine that other plugins, such as JaCoCo, have already set:

<properties>
  <!-- fallback so @{argLine} never reaches the JVM literally -->
  <argLine></argLine>
</properties>

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-dependency-plugin</artifactId>
  <executions>
    <execution><goals><goal>properties</goal></goals></execution>
  </executions>
</plugin>
<plugin>
  <artifactId>maven-surefire-plugin</artifactId>
  <configuration>
    <argLine>@{argLine} -javaagent:${org.mockito:mockito-core:jar}</argLine>
  </configuration>
</plugin>

In Gradle (Kotlin DSL), the same setup looks like this:

val mockitoAgent = configurations.create("mockitoAgent")

dependencies {
    testImplementation("org.mockito:mockito-core:5.14.2")
    mockitoAgent("org.mockito:mockito-core:5.14.2") { isTransitive = false }
}

tasks.test {
    jvmArgs("-javaagent:${mockitoAgent.asPath}")
}

Why It Was Added

The JVM's integrity guarantees break down when a library can attach itself into a running process and rewrite already-loaded classes. Those guarantees include final meaning final, encapsulation holding, and code not being silently retransformed. Any dependency could quietly use the Attach API to instrument the application it lives in.

JEP 451 makes that capability opt-in. The application owner, not a transitive library, decides which agents run.

Mockito was collateral damage. It self-attached for a legitimate reason: mocking final classes and static methods. But it did so with exactly the mechanism the JDK is closing. The warning is advance notice. Once a future JDK disallows dynamic loading by default, self-attach will fail. Your tests will then break with Mockito initialization errors instead of warnings.

How It Works Underneath

The inline mock maker needs a java.lang.instrument.Instrumentation instance so it can retransform classes and inject mock-dispatch code. There are two ways to obtain one:

  1. Self-attach (old). On first mock creation, Byte Buddy uses the Attach API to attach to the current JVM. Because self-attach is blocked by default, it often spawns a helper process to do this. The agent's agentmain then hands back an Instrumentation. At that moment the JVM records that an agent loaded after startup and prints the warning.
  2. Startup agent (new). The mockito-core jar's manifest declares an agent entry point. With -javaagent:mockito-core.jar, the JVM calls that premain before your tests run, and Mockito captures the Instrumentation. When the first mock is created, Mockito sees it already has instrumentation and skips self-attach completely.
sequenceDiagram
    participant JVM as Test JVM
    participant M as Mockito inline mock maker
    participant BB as Byte Buddy agent
    Note over JVM: Startup
    alt -javaagent:mockito-core.jar given
        JVM->>M: premain(args, Instrumentation)
        M->>M: store Instrumentation
    end
    Note over JVM: First mock(Foo.class)
    M->>M: Instrumentation already present?
    alt yes
        M->>JVM: retransform Foo (no warning)
    else no
        M->>BB: ByteBuddyAgent.install()
        BB->>JVM: Attach API, load agent at runtime
        JVM-->>JVM: JEP 451 records dynamic load, prints WARNING
        BB-->>M: Instrumentation
        M->>JVM: retransform Foo
    end

The -XX:+EnableDynamicAgentLoading flag only toggles the left side of that check. It hides the warning, and once the default flips it would be needed just to keep self-attach working. It does not move you onto the supported path.

Should You Adopt It Yet

Yes, now. This is a test-only build change with no effect on production code. It is the path Mockito's own documentation recommends.

The cost is a few lines per build, plus dealing with these known gotchas:

Who can wait? Teams pinned to JDK 17 see no warning. Even so, make the change before you upgrade JDK so it isn't mixed into the upgrade.

Migration Notes

  1. Find the silencers. Grep for EnableDynamicAgentLoading, jdk.attach.allowAttachSelf, and mockito-inline. The mockito-inline artifact is obsolete in Mockito 5 because inline mocking is now the default. Replace it with mockito-core.
  2. Check other agents. Grep for byte-buddy-agent and ByteBuddyAgent.install in test utilities. Other tools that self-attach at runtime need the same treatment.
  3. Preserve existing argLine. Search POMs for <argLine>. A hard-coded value silently drops JaCoCo's agent, and coverage falls to 0%. Always prefix the value with @{argLine}.
  4. Multi-module builds. Put the dependency:properties execution in <build><plugins>, not only in <pluginManagement>. Apply the surefire argLine only in modules that actually depend on Mockito. Otherwise ${org.mockito:mockito-core:jar} never resolves.
  5. Verify. Run your tests with -Djdk.instrument.traceUsage. Any remaining dynamic load prints a stack trace showing exactly who is still self-attaching.
  6. Keep versions in sync. In Gradle, keep the mockitoAgent version aligned with testImplementation, ideally through a version catalog or BOM. A mismatched agent jar and API jar fails in confusing ways.

Key takeaway: Don't silence the dynamic-agent warning with -XX:+EnableDynamicAgentLoading. Pass mockito-core as an explicit -javaagent in your test task so it keeps working when the JDK disallows self-attach.

Real-world challenge

In a multi-module Maven project you follow the Mockito docs: add maven-dependency-plugin's `properties` goal and set surefire's argLine to `@{argLine} -javaagent:${org.mockito:mockito-core:jar}`. The `api` module passes. The `billing` module's forked test JVM dies immediately with `Error opening zip file or JAR manifest missing : ${org.mockito:mockito-core:jar}`. Both modules inherit from the same parent POM.

Diagnosis. The literal ${org.mockito:mockito-core:jar} reached the JVM command line, so the property was never defined in billing. That property only exists after dependency:properties runs in that module. Check the parent POM. The dependency plugin's execution is almost certainly declared under <pluginManagement>, which only configures a plugin and never activates it. api passes because it redeclares the plugin in its own <plugins>. billing does not.

Run mvn help:effective-pom -pl billing and confirm maven-dependency-plugin is missing from the active <build><plugins>.

Fix. Move the execution into the parent's <build><plugins> so every module gets it:

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-dependency-plugin</artifactId>
      <executions>
        <execution>
          <goals><goal>properties</goal></goals>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

The property only resolves in modules that actually depend on mockito-core. Either put the surefire argLine in a profile, or set it only in modules that use Mockito. If you skip this, modules without Mockito will hit the same error.