Related to #46401, #46063, #46015 and commit 08cc62a ("Simplify
JarFileUrlKey to prevent issues with Cortex XDR agent"). Those issues fixed a
DNS / URL.getHostAddress slowdown; this is a different, new side effect
introduced by that fix
Affected versions: any release containing 08cc62a6b60, i.e. 3.4.8+ /
3.5.4+ / 4.0.0+. Not present in 3.3.x or 3.4.7−.
Environment: nested fat jar launched via java -jar (JarLauncher /
LaunchedClassLoader). Reproduced on JDK 25 HotSpot; the mechanism is
JVM-general, not OS-specific.
Summary
08cc62a replaced the String-based JarFileUrlKey (cached via a
SoftReference<Map<URL,String>>) with a value object allocated on every
UrlJarFiles.Cache.get/putIfAbsent, and changed the cache from
Map<String, JarFile> to Map<JarFileUrlKey, JarFile>. The DNS-avoidance goal
is met, but there is an unintended JIT side effect that slows down application
code that never touches the loader.
Mechanism
HashMap.getNode / Object.equals is a single JDK method whose C2 type profile
is shared process-wide across all callers. Because the loader resolves
jar:nested: URLs repeatedly at runtime (not only at startup), JarFileUrlKey
now dominates the type profile observed at that shared call site. The site goes
megamorphic, so C2 stops devirtualizing/inlining equals() / hashCode() —
degrading unrelated application HashMap<String,…> / HashMap<Integer,…>
lookups on hot paths. Before the commit the cache key was a String, the same
type applications commonly use, so the shared profile stayed mono/bimorphic.
Observed impact
A CPU-bound workload in a Spring Boot fat jar runs measurably slower after the
commit, with identical inputs and outputs. Profiling attributes the whole delta
to HashMap.getNode, whose self time grows ~14× on byte-for-byte identical
application bytecode.
Note: enabling JFR (-XX:StartFlightRecording) hides the gap — it makes the
slow run faster. Profile without JFR (e.g. async-profiler event=wall, or
-XX:+PrintInlining).
Running the same 4.0.x app with an extracted / flat classpath
(-Djarmode=tools … extract, or java -cp) fully removes the regression;
-XX:+PrintInlining shows String-dominated profiles before the commit vs
JarFileUrlKey-dominated after it.
Minimal reproducer
Self-contained project (attached). A minimal @SpringBootApplication whose hot
loop does nothing but HashMap.get() on Integer / String keys; the
application bytecode is identical across Spring Boot versions.
App.java:
package repro;
import java.util.HashMap;
import java.util.Map;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
@SpringBootApplication
public class App implements ApplicationListener<ApplicationReadyEvent> {
static final int OUTER = 64;
static final String[] KEYS = { "a", "b", "c", "d", "e" };
static final Map<Integer, Map<String, Integer>> DATA = new HashMap<>();
static {
for (int i = 0; i < OUTER; i++) {
Map<String, Integer> inner = new HashMap<>();
for (String k : KEYS) {
inner.put(k, i);
}
DATA.put(i, inner);
}
}
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
long iterations = Long.getLong("repro.iterations", 40_000_000L);
long sum = 0;
long t0 = System.nanoTime();
for (long i = 0; i < iterations; i++) {
Map<String, Integer> inner = DATA.get((int) (i % OUTER)); // HashMap.get(Integer)
sum += inner.get(KEYS[(int) (i % KEYS.length)]); // HashMap.get(String)
}
long ms = (System.nanoTime() - t0) / 1_000_000L;
System.out.println("REPRO iterations=" + iterations + " sum=" + sum + " elapsedMs=" + ms);
System.exit(0);
}
}
pom.xml uses spring-boot-starter-parent + spring-boot-starter +
spring-boot-maven-plugin. Build one jar per version, then run the same jar
three ways:
# build (set the parent version, package twice)
mvn -q clean package && cp target/app.jar app-sb402.jar # parent 4.0.2
mvn -q clean package && cp target/app.jar app-sb332.jar # parent 3.3.2
# run
java -jar app-sb332.jar # 3.3.2 fat jar (before commit)
java -jar app-sb402.jar # 4.0.2 fat jar (after commit)
java -Djarmode=tools -jar app-sb402.jar extract --destination extracted
java -jar extracted/app.jar # 4.0.2, flat classpath
Results (JDK 25, 40M iterations, median of 4 reps, rotating order)
| Condition |
median |
vs 3.3.2 |
| SB 3.3.2 fat jar (before commit) |
~207 ms |
ref |
| SB 4.0.2 fat jar (after commit) |
~516 ms |
+149 % |
| SB 4.0.2 extracted (flat classpath) |
~215 ms |
+4 % |
The regression appears only in the fat-jar run; extracting the same 4.0.2 app
removes it entirely. The microbenchmark magnifies the effect (100 %
HashMap.get); a real app with mixed work sees a smaller but real slowdown.
README.md
Note: I'm not a JVM/loader internals specialist and used an LLM to help
investigate and write up this report. I've verified the measurements and the
reproducer myself; please double-check the mechanism analysis.
Thanks for your help!
Related to #46401, #46063, #46015 and commit 08cc62a ("Simplify
JarFileUrlKey to prevent issues with Cortex XDR agent"). Those issues fixed a
DNS /
URL.getHostAddressslowdown; this is a different, new side effectintroduced by that fix
Affected versions: any release containing
08cc62a6b60, i.e. 3.4.8+ /3.5.4+ / 4.0.0+. Not present in 3.3.x or 3.4.7−.
Environment: nested fat jar launched via
java -jar(JarLauncher/LaunchedClassLoader). Reproduced on JDK 25 HotSpot; the mechanism isJVM-general, not OS-specific.
Summary
08cc62a replaced the
String-basedJarFileUrlKey(cached via aSoftReference<Map<URL,String>>) with a value object allocated on everyUrlJarFiles.Cache.get/putIfAbsent, and changed the cache fromMap<String, JarFile>toMap<JarFileUrlKey, JarFile>. The DNS-avoidance goalis met, but there is an unintended JIT side effect that slows down application
code that never touches the loader.
Mechanism
HashMap.getNode/Object.equalsis a single JDK method whose C2 type profileis shared process-wide across all callers. Because the loader resolves
jar:nested:URLs repeatedly at runtime (not only at startup),JarFileUrlKeynow dominates the type profile observed at that shared call site. The site goes
megamorphic, so C2 stops devirtualizing/inlining
equals()/hashCode()—degrading unrelated application
HashMap<String,…>/HashMap<Integer,…>lookups on hot paths. Before the commit the cache key was a
String, the sametype applications commonly use, so the shared profile stayed mono/bimorphic.
Observed impact
A CPU-bound workload in a Spring Boot fat jar runs measurably slower after the
commit, with identical inputs and outputs. Profiling attributes the whole delta
to
HashMap.getNode, whose self time grows ~14× on byte-for-byte identicalapplication bytecode.
Running the same 4.0.x app with an extracted / flat classpath
(
-Djarmode=tools … extract, orjava -cp) fully removes the regression;-XX:+PrintInliningshowsString-dominated profiles before the commit vsJarFileUrlKey-dominated after it.Minimal reproducer
Self-contained project (attached). A minimal
@SpringBootApplicationwhose hotloop does nothing but
HashMap.get()onInteger/Stringkeys; theapplication bytecode is identical across Spring Boot versions.
App.java:
pom.xml uses
spring-boot-starter-parent+spring-boot-starter+spring-boot-maven-plugin. Build one jar per version, then run the same jarthree ways:
Results (JDK 25, 40M iterations, median of 4 reps, rotating order)
The regression appears only in the fat-jar run; extracting the same 4.0.2 app
removes it entirely. The microbenchmark magnifies the effect (100 %
HashMap.get); a real app with mixed work sees a smaller but real slowdown.README.md
Note: I'm not a JVM/loader internals specialist and used an LLM to help
investigate and write up this report. I've verified the measurements and the
reproducer myself; please double-check the mechanism analysis.
Thanks for your help!