Repository navigation
Conversation
…inkage cost Keep a separate class per matcher so the JIT can specialize by matcher type; avoid replacing these with a shared class holding a mask field as it slows down matching.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What Does This Do
Adds benchmarks for constructing and executing equivalent class-match and Byte Buddy matcher graphs, and uses them to guide improvements to matcher construction and execution. The changes reduce first-use lambda linkage, keep common name checks visible to the JIT, and avoid layers of predicate dispatch when scanning declared members.
The benchmarks cover common instrumentation class selectors, direct advice method selectors, and a large graph of 1,191 selectors extracted from dd-trace-java. The public matcher API and existing selection semantics are preserved.
Motivation
Instrumentation builds matcher graphs during startup and then applies them repeatedly to candidate classes and methods. These paths need separate measurements: a matcher that executes efficiently can still be expensive to construct for the first time, while a convenient chain of predicates can introduce dispatch overhead for every member examined.
ColdMatcherConstructionBenchmarkmeasures the first construction of each graph in a fresh JVM.MatchingApiBenchmarkmeasures warmed execution over unloaded classes from Spring, Jackson, Reactor, Tomcat, and Apache HttpClient jars, together with generated controller and selection fixtures.Direct method benchmarks exercise exact-name, name-set, and regular-method-plus-name selection over Spring Web and Reactor methods.
Together, these benchmarks guide the following changes:
Reduce first-use lambda linkage. Common access, annotation, class, field, method, parameter, return-type, and type-name predicates use explicit implementations in place of lambdas or method references. Constructing these matchers no longer requires linking those lambda call sites. Stateless predicates, including constructor, static-initializer, and no-parameter checks, can be reused. Access predicates retain a separate implementation class for each check so the JIT can specialize their execution; they are not collapsed into one implementation that reads a configurable mask. Parameter and return matchers retain the existing descriptor-based checks and first-parameter fast path.
Keep method-name checks at the front of composed matchers. Exact-name and name-predicate matchers override
andto retain the name check in their concrete implementation and combine the remaining constraints behind it. For example,method("execute").access(PUBLIC).noParameters()checks the name before evaluating access and parameters, without putting the name predicate behind another generic conjunction. A name miss short-circuits before the remaining criteria are dispatched.Collapse method matcher wrappers into their base implementations. Bare and constrained exact-name, name-predicate, and regular-method matchers share their respective implementation classes, using an optional remaining matcher. This reduces the number of receiver types at hot predicate call sites and lets the JIT see the common name or regular-method check directly.
Specialize declared-member scans for known matcher shapes.
declaresrecognizes exact field names, optionally with a type, and exact or predicate-based method names. It selects loops that check those criteria directly instead of invoking a generic member predicate on every entry. Remaining method constraints run only after the name matches. The loops continue searching after a partial match, and arbitrary matcher compositions retain the generic fallback, so all criteria must still match the same member.Skip methods without annotations of interest. For
declares(method().annotatedWith(...)), a specialized loop checks the parsed annotation array first. Most methods have no annotations of interest, so they can be skipped before checking the method name or invoking the annotation predicate. Candidates still have to satisfy the regular-method and annotation criteria.Simplify annotation registration. Registration now explicitly updates the annotation-name and descriptor-key mappings, skipping names already registered. A batch copies the published annotation snapshot once, adds only new entries, and publishes the completed snapshot under the existing lock. This removes the
computeIfAbsentmethod-reference path and avoids reinserting existing mappings while preserving copy-on-write publication for readers.Additional Notes
The final implementation was measured against Byte Buddy on native ARM64 Zulu Java 8, 11, 17, 21, and 25 on an Apple M1 Max. Ranges span those JDKs.
Warmed class matching
"Union" applies the complete OR graph to each class and stops at the first matching branch. "Sweep" evaluates every branch on each class.
DD_TRACE contains 1,191 selectors extracted from dd-trace-java, with hierarchy and classloader gates removed. Without those gates, selectors with no member name, annotation, or type constraint, such as
declares(constructor()), match nearly every class. The 74 such selectors are omitted, so the union matches 4,794 of the 10,047 corpus classes (48%) instead of 94%.Byte Buddy's DD_TRACE scores vary more than other results, with a relative error of 13–21%. Its per-iteration times swing widely, most likely due to garbage collection, since it allocates megabytes per class. Speedups computed from median iterations agree: 14.2–15.7× for the union and 13.9–17.4× for the sweep.
A DD_TRACE sweep using class-match allocated approximately 590 bytes per class, versus 4.2–5.3 MB for Byte Buddy. A DD_TRACE union match allocated approximately 454 bytes, versus 2.4–2.8 MB. REAL_WORLD and CLASS_SELECTION measured effectively zero allocation for ClassMatcher, versus approximately 18–22 KB for Byte Buddy. Direct method matching was 2.8–5.4× faster in isolation and 2.4–2.8× faster with shared profiling.
Cold construction
Each workload was constructed in two forms: the full graph including its OR union, and the branches alone as independent selectors. Differences are ClassMatcher minus Byte Buddy:
ClassMatcher is faster to construct for CLASS_SELECTION and slower for REAL_WORLD and DD_TRACE. The largest gap is DD_TRACE branches on Java 25 (11.35 vs 9.41 ms).
ClassMatcher allocated less during construction on every tested JDK for these workloads, in both forms. For REAL_WORLD the saving was 6–11%, and for CLASS_SELECTION 16–27%. Full-graph DD_TRACE allocated 57–61% less, but most of that difference comes from how Byte Buddy builds the union: it copies a growing array as each branch is appended, allocating 12.2–12.7 MB for the full graph versus 2.1–2.5 MB for its branches alone. Without the union, ClassMatcher allocated only 0–10% less for DD_TRACE.
Cold timings include first-use matcher class initialization and lambda linkage, excluding JVM process startup. Cold allocation includes initialization and iteration infrastructure overhead, rather than representing steady-state graph size.
Methodology
Cold measurements used 30 fresh JVM forks per case, zero warmup, and one single-shot construction per fork. Warmed matching used 3 forks, each with 5 × 1-second warmup iterations and 10 × 1-second measurement iterations. Runs were serial, used one benchmark thread and a fixed 512 MiB heap, and included GC profiling. Across the full isolated class-execution matrix, median JMH relative error was 0.81%. Every case was below 4.4% except Byte Buddy on DD_TRACE (13–19%, described above).
Benchmark setup checks agreement between the APIs for class graphs, individual branches, and direct method patterns. Fixtures add expected hits and deliberate near misses. Regression tests cover chained constraints and short-circuiting, matching all criteria on the same member, continuing after partial matches, and missing
CharSequencename lookups.The class results above use the ISOLATED profile. Shared method results use a synthetic graph mix rather than production frequency weights. Graph construction and metadata parsing happen before warmed matching measurements. The extracted DD_TRACE graph excludes hierarchy traversal and classloader gates. These are comparisons of the final implementation with Byte Buddy, not measurements of the gain from each individual change or of end-to-end application performance.
Reproduction details: JMH 1.36, Byte Buddy 1.18.16, macOS 26.7.1. Zulu JDK builds were
1.8.0_512-b08,11.0.33+9-LTS,17.0.21.0.3+1-LTS,21.0.13.0.3+1-LTS, and25.0.5.0.3+1-LTS. Build with./gradlew :class-match:jmhJar :class-match:sampleBytecode; the benchmark Javadocs document invocation, and the saved manifest records every collection command.Contributor Checklist
Jira ticket: