Skip to content

Perf regression from 08cc62a6b60 (JarFileUrlKey): per-call value-object key pollutes the shared HashMap.getNode type profile and deoptimizes application hot code #51141

Description

@ghdcc

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!

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions