You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by pa...@apache.org on 2026/02/27 10:07:57 UTC
(groovy) 01/04: GROOVY-10307: add JMH benchmarks for Grails-like invokedynamic pain points
This is an automated email from the ASF dual-hosted git repository.
paulk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/groovy.git
commit eaa44781eea29c736645df746e08f7e6efb3377d
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu Feb 26 12:20:37 2026 -0500
GROOVY-10307: add JMH benchmarks for Grails-like invokedynamic pain points
Add 43 JMH benchmarks across 4 files targeting the metaclass invalidation
patterns that cause performance regression in Grails applications under
invokedynamic. These complement the general-purpose benchmarks from #2381
by exercising the specific dynamic dispatch patterns identified in #2374
and #2377.
New benchmark files:
MetaclassChangeBench (11 benchmarks)
- ExpandoMetaClass method addition during method dispatch
- Metaclass replacement cycles
- Multi-class invalidation cascade (change on ServiceA invalidates ServiceB/C)
- Burst-then-steady-state (simulates Grails startup then request handling)
- Property access and closure dispatch under metaclass churn
CategoryBench (10 benchmarks)
- use(Category) blocks inside vs outside loops
- Nested and simultaneous multi-category scopes
- Collateral invalidation damage on non-category call sites
- Category method shadowing existing methods
DynamicDispatchBench (12 benchmarks)
- methodMissing with single/rotating names (dynamic finders)
- propertyMissing read/write (Grails params/session)
- GroovyInterceptable invokeMethod interception (transactional services)
- ExpandoMetaClass-injected method calls mixed with real methods
- def-typed monomorphic and polymorphic dispatch
GrailsLikePatternsBench (10 benchmarks)
- Service chain: validation, CRUD, collection processing
- Controller action: param binding, service call, model/view rendering
- Domain validation with dynamic property access (this."$field")
- Configuration DSL with nested @DelegatesTo closures
- Markup builder with nested tag/closure rendering
- Full request cycle simulation with and without metaclass churn
Run with: ./gradlew perf:jmh -PbenchInclude=perf
---
.../org/apache/groovy/perf/CategoryBench.groovy | 303 +++++++++++++
.../apache/groovy/perf/DynamicDispatchBench.groovy | 341 ++++++++++++++
.../groovy/perf/GrailsLikePatternsBench.groovy | 488 +++++++++++++++++++++
.../apache/groovy/perf/MetaclassChangeBench.groovy | 310 +++++++++++++
4 files changed, 1442 insertions(+)
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/CategoryBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/CategoryBench.groovy
new file mode 100644
index 0000000000..4e55ba339e
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/CategoryBench.groovy
@@ -0,0 +1,303 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.perf
+
+import org.openjdk.jmh.annotations.*
+import org.openjdk.jmh.infra.Blackhole
+
+import java.util.concurrent.TimeUnit
+
+/**
+ * Tests the performance of Groovy category usage patterns. Categories
+ * are a key metaclass mechanism used heavily in Grails and other Groovy
+ * frameworks: each {@code use(Category)} block temporarily modifies
+ * method dispatch for the current thread.
+ *
+ * Every entry into and exit from a {@code use} block triggers
+ * {@code invalidateSwitchPoints()}, causing global SwitchPoint
+ * invalidation. In tight loops or frequently called code, this
+ * creates significant overhead as all invokedynamic call sites must
+ * re-link after each category scope change.
+ *
+ * Grails uses categories for date utilities, collection enhancements,
+ * validation helpers, and domain class extensions.
+ */
+@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@State(Scope.Thread)
+class CategoryBench {
+ static final int ITERATIONS = 100_000
+
+ // Category that adds methods to String
+ static class StringCategory {
+ static String reverse(String self) {
+ new StringBuilder(self).reverse().toString()
+ }
+
+ static String shout(String self) {
+ self.toUpperCase() + '!'
+ }
+
+ static boolean isPalindrome(String self) {
+ String reversed = new StringBuilder(self).reverse().toString()
+ self == reversed
+ }
+ }
+
+ // Category that adds methods to Integer
+ static class MathCategory {
+ static int doubled(Integer self) {
+ self * 2
+ }
+
+ static boolean isEven(Integer self) {
+ self % 2 == 0
+ }
+
+ static int factorial(Integer self) {
+ (1..self).inject(1) { acc, val -> acc * val }
+ }
+ }
+
+ // Category that adds methods to List
+ static class CollectionCategory {
+ static int sumAll(List self) {
+ self.sum() ?: 0
+ }
+
+ static List doubled(List self) {
+ self.collect { it * 2 }
+ }
+ }
+
+ String testString
+ List<Integer> testList
+
+ @Setup(Level.Trial)
+ void setup() {
+ testString = "hello"
+ testList = (1..10).toList()
+ }
+
+ // ===== BASELINE (no categories) =====
+
+ /**
+ * Baseline: direct method calls without any category usage.
+ * Establishes the cost of normal method dispatch for comparison.
+ */
+ @Benchmark
+ void baselineDirectCalls(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += testString.length()
+ }
+ bh.consume(sum)
+ }
+
+ // ===== SINGLE CATEGORY =====
+
+ /**
+ * Single category block wrapping many calls. The category scope
+ * is entered once and all calls happen inside it. This is the
+ * most efficient category usage pattern - one enter/exit pair
+ * for many method invocations.
+ */
+ @Benchmark
+ void singleCategoryWrappingLoop(Blackhole bh) {
+ int sum = 0
+ use(StringCategory) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += testString.shout().length()
+ }
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Category block entered on every iteration - the worst case.
+ * Each iteration enters and exits the category scope, triggering
+ * two SwitchPoint invalidations per iteration.
+ *
+ * This pattern appears in Grails when category-enhanced methods
+ * are called from within request-scoped code that repeatedly
+ * enters category scope.
+ */
+ @Benchmark
+ void categoryInLoop(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ use(StringCategory) {
+ sum += testString.shout().length()
+ }
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Category enter/exit at moderate frequency - every 100 calls.
+ * Simulates code where category scope is entered per-batch
+ * rather than per-call.
+ */
+ @Benchmark
+ void categoryPerBatch(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS / 100; i++) {
+ use(StringCategory) {
+ for (int j = 0; j < 100; j++) {
+ sum += testString.shout().length()
+ }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ // ===== NESTED CATEGORIES =====
+
+ /**
+ * Nested category scopes - multiple categories active at once.
+ * Each nesting level adds another enter/exit invalidation pair.
+ * Grails applications often have multiple category layers active
+ * simultaneously (e.g., date utilities inside collection utilities
+ * inside validation helpers).
+ */
+ @Benchmark
+ void nestedCategories(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ use(StringCategory) {
+ use(MathCategory) {
+ sum += testString.shout().length() + i.doubled()
+ }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Nested categories with the outer scope wrapping the loop.
+ * Only the inner category enters/exits per iteration.
+ */
+ @Benchmark
+ void nestedCategoryOuterWrapping(Blackhole bh) {
+ int sum = 0
+ use(StringCategory) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ use(MathCategory) {
+ sum += testString.shout().length() + i.doubled()
+ }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ // ===== MULTIPLE SIMULTANEOUS CATEGORIES =====
+
+ /**
+ * Multiple categories applied simultaneously via use(Cat1, Cat2).
+ * Single enter/exit but with more method resolution complexity.
+ */
+ @Benchmark
+ void multipleCategoriesSimultaneous(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ use(StringCategory, MathCategory) {
+ sum += testString.shout().length() + i.doubled()
+ }
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Three categories simultaneously - heavier resolution load.
+ */
+ @Benchmark
+ void threeCategoriesSimultaneous(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ use(StringCategory, MathCategory, CollectionCategory) {
+ sum += testString.shout().length() + i.doubled() + testList.sumAll()
+ }
+ }
+ bh.consume(sum)
+ }
+
+ // ===== CATEGORY WITH OUTSIDE CALLS =====
+
+ /**
+ * Method calls both inside and outside category scope.
+ * The outside calls exercise call sites that were invalidated
+ * when the category scope was entered/exited. This measures
+ * the collateral damage of category usage on non-category code.
+ */
+ @Benchmark
+ void categoryWithOutsideCalls(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ // Call outside category scope
+ sum += testString.length()
+
+ // Enter/exit category scope (triggers invalidation)
+ use(StringCategory) {
+ sum += testString.shout().length()
+ }
+
+ // Call outside again - call site was invalidated by use() above
+ sum += testString.length()
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Baseline for category-with-outside-calls: same work without
+ * the category block. Shows how much the category enter/exit
+ * overhead costs for the surrounding non-category calls.
+ */
+ @Benchmark
+ void baselineEquivalentWithoutCategory(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += testString.length()
+ sum += testString.toUpperCase().length() + 1 // same work as shout()
+ sum += testString.length()
+ }
+ bh.consume(sum)
+ }
+
+ // ===== CATEGORY METHOD RESOLUTION =====
+
+ /**
+ * Category method that shadows an existing method.
+ * Tests the overhead of category method resolution when the
+ * category method name matches a method already on the class.
+ */
+ @Benchmark
+ void categoryShadowingExistingMethod(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ use(StringCategory) {
+ // reverse() exists on String AND in StringCategory
+ sum += testString.reverse().length()
+ }
+ }
+ bh.consume(sum)
+ }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/DynamicDispatchBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/DynamicDispatchBench.groovy
new file mode 100644
index 0000000000..55479b8c7f
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/DynamicDispatchBench.groovy
@@ -0,0 +1,341 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.perf
+
+import groovy.lang.GroovySystem
+
+import org.openjdk.jmh.annotations.*
+import org.openjdk.jmh.infra.Blackhole
+
+import java.util.concurrent.TimeUnit
+
+/**
+ * Tests the performance of Groovy's dynamic method dispatch mechanisms:
+ * {@code methodMissing}, {@code propertyMissing}, {@code invokeMethod},
+ * and {@link GroovyInterceptable}. These are the building blocks of
+ * Grails' convention-based programming model.
+ *
+ * Grails uses these patterns extensively:
+ * <ul>
+ * <li>{@code methodMissing} - dynamic finders (findByName, findAllByAge)</li>
+ * <li>{@code propertyMissing} - dynamic property injection (params, session)</li>
+ * <li>{@code invokeMethod} - method interception for transactions, security</li>
+ * <li>ExpandoMetaClass runtime injection - adding methods at framework startup</li>
+ * </ul>
+ *
+ * These patterns interact with the invokedynamic call site cache differently
+ * than normal method calls: methodMissing/propertyMissing cause cache misses
+ * on every distinct method name, while invokeMethod intercepts all calls
+ * regardless of caching.
+ */
+@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@State(Scope.Thread)
+class DynamicDispatchBench {
+ static final int ITERATIONS = 100_000
+
+ // Class with methodMissing - like Grails domain class dynamic finders
+ static class DynamicFinder {
+ Map storage = [:]
+
+ def methodMissing(String name, args) {
+ if (name.startsWith('findBy')) {
+ String field = name.substring(6).toLowerCase()
+ return storage[field]
+ }
+ if (name.startsWith('saveTo')) {
+ String field = name.substring(6).toLowerCase()
+ storage[field] = args[0]
+ return args[0]
+ }
+ throw new MissingMethodException(name, DynamicFinder, args)
+ }
+ }
+
+ // Class with propertyMissing - like Grails controller params/session
+ static class DynamicProperties {
+ Map attributes = [name: "test", age: 25, active: true, role: "admin"]
+
+ def propertyMissing(String name) {
+ attributes[name]
+ }
+
+ def propertyMissing(String name, value) {
+ attributes[name] = value
+ }
+ }
+
+ // Class with invokeMethod override - like Grails interceptors
+ static class MethodInterceptor implements GroovyInterceptable {
+ int callCount = 0
+ int realValue = 42
+
+ def invokeMethod(String name, args) {
+ callCount++
+ def metaMethod = metaClass.getMetaMethod(name, args)
+ if (metaMethod) {
+ return metaMethod.invoke(this, args)
+ }
+ return null
+ }
+
+ int compute() { realValue * 2 }
+ String describe() { "value=$realValue" }
+ }
+
+ // Plain class for baseline comparison
+ static class PlainService {
+ int value = 42
+ int compute() { value * 2 }
+ }
+
+ DynamicFinder finder
+ DynamicProperties props
+ MethodInterceptor interceptor
+ PlainService plain
+
+ @Setup(Level.Iteration)
+ void setup() {
+ GroovySystem.metaClassRegistry.removeMetaClass(DynamicFinder)
+ GroovySystem.metaClassRegistry.removeMetaClass(DynamicProperties)
+ GroovySystem.metaClassRegistry.removeMetaClass(MethodInterceptor)
+ GroovySystem.metaClassRegistry.removeMetaClass(PlainService)
+ // Inject expando method once per iteration for injected-method benchmarks
+ PlainService.metaClass.injectedMethod = { -> delegate.value * 3 }
+ finder = new DynamicFinder()
+ finder.storage = [name: "Alice", age: 30, city: "Springfield"]
+ props = new DynamicProperties()
+ interceptor = new MethodInterceptor()
+ plain = new PlainService()
+ }
+
+ // ===== BASELINE =====
+
+ /**
+ * Baseline: normal method calls on a plain class.
+ * Control for all dynamic dispatch variants.
+ */
+ @Benchmark
+ void baselinePlainMethodCalls(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += plain.compute()
+ }
+ bh.consume(sum)
+ }
+
+ // ===== methodMissing =====
+
+ /**
+ * Single dynamic finder name called repeatedly.
+ * The call site sees the same method name every time, but
+ * methodMissing must still handle it since there is no real
+ * method to cache.
+ */
+ @Benchmark
+ void methodMissingSingleName(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ bh.consume(finder.findByName())
+ }
+ }
+
+ /**
+ * Rotating dynamic finder names - exercises call site cache with
+ * multiple missing method names at the same call site.
+ * Simulates Grails code calling different dynamic finders in
+ * sequence: findByName, findByAge, findByCity.
+ */
+ @Benchmark
+ void methodMissingRotatingNames(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ switch (i % 3) {
+ case 0: bh.consume(finder.findByName()); break
+ case 1: bh.consume(finder.findByAge()); break
+ case 2: bh.consume(finder.findByCity()); break
+ }
+ }
+ }
+
+ /**
+ * methodMissing for write operations - save pattern.
+ */
+ @Benchmark
+ void methodMissingSavePattern(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ bh.consume(finder.saveToName("name_$i"))
+ }
+ }
+
+ /**
+ * Mix of methodMissing and real method calls.
+ * Simulates Grails service code that mixes dynamic finders
+ * with normal method calls on the same object.
+ */
+ @Benchmark
+ void methodMissingMixedWithReal(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ if (i % 2 == 0) {
+ bh.consume(finder.findByName())
+ } else {
+ bh.consume(finder.storage.size())
+ }
+ }
+ }
+
+ // ===== propertyMissing =====
+
+ /**
+ * Single dynamic property access repeated.
+ */
+ @Benchmark
+ void propertyMissingSingleName(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ bh.consume(props.name)
+ }
+ }
+
+ /**
+ * Rotating dynamic property names - multiple property accesses
+ * at the same call site location.
+ */
+ @Benchmark
+ void propertyMissingRotatingNames(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ switch (i % 4) {
+ case 0: bh.consume(props.name); break
+ case 1: bh.consume(props.age); break
+ case 2: bh.consume(props.active); break
+ case 3: bh.consume(props.role); break
+ }
+ }
+ }
+
+ /**
+ * Dynamic property read/write cycle.
+ */
+ @Benchmark
+ void propertyMissingReadWrite(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ props.name = "user_$i"
+ bh.consume(props.name)
+ }
+ }
+
+ // ===== invokeMethod (GroovyInterceptable) =====
+
+ /**
+ * Method calls through invokeMethod interception.
+ * Every call, even to existing methods, goes through invokeMethod.
+ * This is the pattern used by Grails for transactional services
+ * and security interceptors.
+ */
+ @Benchmark
+ void invokeMethodInterception(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += interceptor.compute()
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Alternating method calls through invokeMethod.
+ * Different method names at the same interception point.
+ */
+ @Benchmark
+ void invokeMethodAlternating(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ if (i % 2 == 0) {
+ bh.consume(interceptor.compute())
+ } else {
+ bh.consume(interceptor.describe())
+ }
+ }
+ }
+
+ // ===== EXPANDO METACLASS RUNTIME INJECTION =====
+
+ /**
+ * Calling a method that was injected at runtime via ExpandoMetaClass.
+ * Grails injects many methods at startup (save, delete, validate,
+ * dynamic finders) that are then called frequently during request
+ * processing.
+ */
+ @Benchmark
+ void expandoInjectedMethodCall(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += plain.injectedMethod()
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Mix of real and expando-injected method calls.
+ * This is the typical Grails runtime pattern: domain classes have
+ * both compiled methods and dynamically injected GORM methods.
+ */
+ @Benchmark
+ void mixedRealAndInjectedCalls(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ if (i % 2 == 0) {
+ sum += plain.compute() // real method
+ } else {
+ sum += plain.injectedMethod() // injected method
+ }
+ }
+ bh.consume(sum)
+ }
+
+ // ===== DYNAMIC DISPATCH ON def-TYPED REFERENCES =====
+
+ /**
+ * Method calls on {@code def}-typed variable - the compiler
+ * cannot statically resolve the method, forcing full dynamic
+ * dispatch through invokedynamic on every call.
+ */
+ @Benchmark
+ void defTypedDispatch(Blackhole bh) {
+ def service = plain
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += service.compute()
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Polymorphic dispatch through {@code def}-typed variable.
+ * Different receiver types flow through the same call site,
+ * testing the LRU cache effectiveness.
+ */
+ @Benchmark
+ void defTypedPolymorphicDispatch(Blackhole bh) {
+ Object[] services = [plain, interceptor]
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += services[i % 2].compute()
+ }
+ bh.consume(sum)
+ }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/GrailsLikePatternsBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/GrailsLikePatternsBench.groovy
new file mode 100644
index 0000000000..3928bfd73f
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/GrailsLikePatternsBench.groovy
@@ -0,0 +1,488 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.perf
+
+import groovy.lang.GroovySystem
+
+import org.openjdk.jmh.annotations.*
+import org.openjdk.jmh.infra.Blackhole
+
+import java.util.concurrent.TimeUnit
+
+/**
+ * Tests composite patterns that simulate real Grails application behavior.
+ * These benchmarks combine multiple Groovy features (closures, dynamic
+ * dispatch, metaclass modifications, property access, delegation) in
+ * patterns that mirror actual Grails framework usage.
+ *
+ * Unlike the focused single-feature benchmarks, these exercise the
+ * interaction effects between features - particularly how metaclass
+ * changes in one component cascade to affect call sites in other
+ * components through global SwitchPoint invalidation.
+ *
+ * Each benchmark simulates a specific Grails application pattern:
+ * <ul>
+ * <li>Service layer method chains with dependency injection</li>
+ * <li>Domain class CRUD with property access and validation</li>
+ * <li>Controller action dispatch with parameter binding</li>
+ * <li>Configuration DSL with nested delegation</li>
+ * <li>View rendering with builder patterns</li>
+ * </ul>
+ */
+@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@State(Scope.Thread)
+class GrailsLikePatternsBench {
+ static final int ITERATIONS = 100_000
+
+ // ===== DOMAIN CLASS SIMULATION =====
+
+ static class DomainObject {
+ Long id
+ String name
+ String email
+ int version = 0
+ Map errors = [:]
+ Map constraints = [name: [nullable: false, maxSize: 255], email: [nullable: false, email: true]]
+
+ boolean validate() {
+ errors.clear()
+ constraints.each { field, rules ->
+ def value = this."$field"
+ if (!rules.nullable && value == null) {
+ errors[field] = 'nullable'
+ }
+ if (rules.maxSize && value?.toString()?.length() > rules.maxSize) {
+ errors[field] = 'maxSize.exceeded'
+ }
+ }
+ errors.isEmpty()
+ }
+
+ DomainObject save() {
+ if (validate()) {
+ version++
+ if (id == null) id = System.nanoTime()
+ }
+ this
+ }
+
+ Map toMap() {
+ [id: id, name: name, email: email, version: version]
+ }
+ }
+
+ // ===== SERVICE SIMULATION =====
+
+ static class ValidationService {
+ boolean validateEmail(String email) {
+ email != null && email.contains('@') && email.contains('.')
+ }
+
+ boolean validateName(String name) {
+ name != null && name.length() >= 2 && name.length() <= 255
+ }
+ }
+
+ static class DomainService {
+ ValidationService validationService
+
+ DomainObject create(Map params) {
+ def obj = new DomainObject()
+ params.each { key, value ->
+ obj."$key" = value
+ }
+ if (validationService.validateName(obj.name) &&
+ validationService.validateEmail(obj.email)) {
+ obj.save()
+ }
+ obj
+ }
+
+ List<Map> list(List<DomainObject> objects) {
+ objects.findAll { it.id != null }.collect { it.toMap() }
+ }
+ }
+
+ // ===== CONTROLLER SIMULATION =====
+
+ static class ControllerContext {
+ Map params = [:]
+ Map model = [:]
+ String viewName
+ List flash = []
+
+ void render(Map args) {
+ viewName = args.view ?: 'default'
+ if (args.model) model.putAll(args.model)
+ }
+ }
+
+ // ===== CONFIGURATION DSL SIMULATION =====
+
+ static class ConfigBuilder {
+ Map config = [:]
+
+ void dataSource(@DelegatesTo(DataSourceConfig) Closure cl) {
+ def dsc = new DataSourceConfig()
+ cl.delegate = dsc
+ cl.resolveStrategy = Closure.DELEGATE_FIRST
+ cl()
+ config.dataSource = dsc.toMap()
+ }
+
+ void server(@DelegatesTo(ServerConfig) Closure cl) {
+ def sc = new ServerConfig()
+ cl.delegate = sc
+ cl.resolveStrategy = Closure.DELEGATE_FIRST
+ cl()
+ config.server = sc.toMap()
+ }
+ }
+
+ static class DataSourceConfig {
+ String url = 'jdbc:h2:mem:default'
+ String driverClassName = 'org.h2.Driver'
+ String username = 'sa'
+ String password = ''
+ Map pool = [maxActive: 10]
+
+ Map toMap() { [url: url, driverClassName: driverClassName, username: username, pool: pool] }
+ }
+
+ static class ServerConfig {
+ int port = 8080
+ String host = 'localhost'
+ Map ssl = [enabled: false]
+
+ Map toMap() { [port: port, host: host, ssl: ssl] }
+ }
+
+ // ===== BUILDER / VIEW SIMULATION =====
+
+ static class MarkupContext {
+ StringBuilder buffer = new StringBuilder()
+ int depth = 0
+
+ void tag(String name, Map attrs = [:], Closure body = null) {
+ buffer.append(' ' * depth).append("<$name")
+ attrs.each { k, v -> buffer.append(" $k=\"$v\"") }
+ if (body) {
+ buffer.append('>')
+ depth++
+ body.delegate = this
+ body.resolveStrategy = Closure.DELEGATE_FIRST
+ body()
+ depth--
+ buffer.append("</$name>")
+ } else {
+ buffer.append('/>')
+ }
+ }
+
+ void text(String content) {
+ buffer.append(content)
+ }
+
+ String render() { buffer.toString() }
+ }
+
+ ValidationService validationService
+ DomainService domainService
+ List<DomainObject> sampleData
+
+ @Setup(Level.Iteration)
+ void setup() {
+ GroovySystem.metaClassRegistry.removeMetaClass(DomainObject)
+ GroovySystem.metaClassRegistry.removeMetaClass(DomainService)
+ GroovySystem.metaClassRegistry.removeMetaClass(ValidationService)
+ GroovySystem.metaClassRegistry.removeMetaClass(ControllerContext)
+ GroovySystem.metaClassRegistry.removeMetaClass(ConfigBuilder)
+
+ validationService = new ValidationService()
+ domainService = new DomainService(validationService: validationService)
+
+ sampleData = (1..20).collect { i ->
+ new DomainObject(name: "User$i", email: "user${i}@example.com").save()
+ }
+ }
+
+ // ===== SERVICE CHAIN PATTERNS =====
+
+ /**
+ * Service method chain - simulates a Grails service calling
+ * another service, which accesses domain objects. Multiple layers
+ * of dynamic dispatch through Groovy property access and method
+ * calls.
+ */
+ @Benchmark
+ void serviceChainCreateAndList(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ def obj = domainService.create(
+ name: "User${i % 100}",
+ email: "user${i % 100}@example.com"
+ )
+ bh.consume(obj.id)
+ }
+ }
+
+ /**
+ * Service chain with collection processing - findAll, collect,
+ * inject patterns typical of Grails service layer.
+ */
+ @Benchmark
+ void serviceChainWithCollections(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS / 10; i++) {
+ def listed = domainService.list(sampleData)
+ bh.consume(listed.size())
+ }
+ }
+
+ // ===== CONTROLLER ACTION PATTERNS =====
+
+ /**
+ * Controller-like action dispatch - simulates a Grails controller
+ * handling a request: reading params, calling service, building
+ * model, rendering view.
+ */
+ @Benchmark
+ void controllerActionPattern(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ def ctx = new ControllerContext()
+ ctx.params = [name: "User${i % 100}", email: "u${i % 100}@test.com"]
+
+ // Simulate action body
+ def obj = domainService.create(ctx.params)
+ if (obj.errors.isEmpty()) {
+ ctx.render(view: 'show', model: [item: obj.toMap()])
+ } else {
+ ctx.flash << "Validation failed"
+ ctx.render(view: 'create', model: [item: obj, errors: obj.errors])
+ }
+ bh.consume(ctx.viewName)
+ }
+ }
+
+ /**
+ * Controller action pattern with metaclass changes.
+ * Simulates a Grails app where framework components are still
+ * being initialized (metaclass modifications) while requests
+ * are already being served.
+ */
+ @Benchmark
+ void controllerActionDuringMetaclassChurn(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ def ctx = new ControllerContext()
+ ctx.params = [name: "User${i % 100}", email: "u${i % 100}@test.com"]
+
+ def obj = domainService.create(ctx.params)
+ ctx.render(view: 'show', model: [item: obj.toMap()])
+ bh.consume(ctx.viewName)
+
+ // Periodic metaclass changes (framework initialization)
+ if (i % 1000 == 0) {
+ DomainObject.metaClass."helper${i % 5}" = { -> delegate.name }
+ }
+ }
+ }
+
+ // ===== DOMAIN VALIDATION PATTERNS =====
+
+ /**
+ * Domain object validation cycle - create, validate, check errors.
+ * Exercises dynamic property access (this."$field"), map operations,
+ * and closure iteration - all through invokedynamic.
+ */
+ @Benchmark
+ void domainValidationCycle(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ def obj = new DomainObject(
+ name: (i % 10 == 0) ? null : "User$i",
+ email: (i % 7 == 0) ? null : "user${i}@test.com"
+ )
+ boolean valid = obj.validate()
+ bh.consume(valid)
+ if (!valid) {
+ bh.consume(obj.errors.size())
+ }
+ }
+ }
+
+ // ===== CONFIGURATION DSL PATTERNS =====
+
+ /**
+ * Configuration DSL - simulates Grails application.groovy style
+ * configuration with nested closures and delegation.
+ * Each closure uses DELEGATE_FIRST strategy, which requires
+ * dynamic method resolution.
+ */
+ @Benchmark
+ void configurationDsl(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS; i++) {
+ def builder = new ConfigBuilder()
+ builder.dataSource {
+ url = "jdbc:h2:mem:db${i % 10}"
+ driverClassName = 'org.h2.Driver'
+ username = 'sa'
+ password = ''
+ pool = [maxActive: 20 + (i % 10)]
+ }
+ builder.server {
+ port = 8080 + (i % 100)
+ host = 'localhost'
+ ssl = [enabled: i % 2 == 0]
+ }
+ bh.consume(builder.config.size())
+ }
+ }
+
+ // ===== BUILDER / VIEW RENDERING PATTERNS =====
+
+ /**
+ * Markup builder pattern - simulates GSP/Groovy template rendering
+ * with nested closure delegation. Each tag() call uses a closure
+ * with DELEGATE_FIRST, requiring dynamic method resolution at
+ * each nesting level.
+ */
+ @Benchmark
+ void markupBuilderPattern(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS / 10; i++) {
+ def markup = new MarkupContext()
+ markup.tag('div', [class: 'container']) {
+ tag('h1') { text("Item ${i}") }
+ tag('ul') {
+ for (int j = 0; j < 5; j++) {
+ tag('li', [class: j % 2 == 0 ? 'even' : 'odd']) {
+ text("Entry $j")
+ }
+ }
+ }
+ tag('footer') { text('End') }
+ }
+ bh.consume(markup.render())
+ }
+ }
+
+ // ===== DYNAMIC PROPERTY MAP ACCESS =====
+
+ /**
+ * Dynamic property access pattern - accessing properties by
+ * name string (this."$fieldName"). Common in Grails data binding,
+ * GORM field access, and controller parameter processing.
+ */
+ @Benchmark
+ void dynamicPropertyByName(Blackhole bh) {
+ String[] fields = ['name', 'email', 'version']
+ def obj = sampleData[0]
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ def val = obj."${fields[i % 3]}"
+ sum += val?.toString()?.length() ?: 0
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Dynamic property access with metaclass churn.
+ * Combines the dynamic property pattern with metaclass changes
+ * happening elsewhere in the application.
+ */
+ @Benchmark
+ void dynamicPropertyDuringMetaclassChurn(Blackhole bh) {
+ String[] fields = ['name', 'email', 'version']
+ def obj = sampleData[0]
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ def val = obj."${fields[i % 3]}"
+ sum += val?.toString()?.length() ?: 0
+ if (i % 1000 == 0) {
+ // Metaclass change on a different class affects this call site too
+ ValidationService.metaClass."helper${i % 3}" = { -> 'help' }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ // ===== COMPOSITE: FULL REQUEST CYCLE =====
+
+ /**
+ * Full simulated request cycle combining controller dispatch,
+ * service calls, domain validation, and view rendering.
+ * This is the closest approximation to what a real Grails
+ * request handler exercises.
+ */
+ @Benchmark
+ void fullRequestCycleSimulation(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS / 10; i++) {
+ // Controller receives request
+ def ctx = new ControllerContext()
+ ctx.params = [name: "User${i % 50}", email: "user${i % 50}@test.com"]
+
+ // Service layer processes
+ def obj = domainService.create(ctx.params)
+
+ // Build response model
+ if (obj.errors.isEmpty()) {
+ def markup = new MarkupContext()
+ markup.tag('div') {
+ tag('span', [class: 'name']) { text(obj.name) }
+ tag('span', [class: 'email']) { text(obj.email) }
+ }
+ ctx.render(view: 'show', model: [html: markup.render()])
+ } else {
+ ctx.render(view: 'edit', model: [errors: obj.errors])
+ }
+ bh.consume(ctx.model)
+ }
+ }
+
+ /**
+ * Full request cycle with metaclass churn - the worst-case
+ * Grails scenario where framework initialization overlaps with
+ * request handling, causing constant SwitchPoint invalidation.
+ */
+ @Benchmark
+ void fullRequestCycleDuringMetaclassChurn(Blackhole bh) {
+ for (int i = 0; i < ITERATIONS / 10; i++) {
+ def ctx = new ControllerContext()
+ ctx.params = [name: "User${i % 50}", email: "user${i % 50}@test.com"]
+
+ def obj = domainService.create(ctx.params)
+
+ if (obj.errors.isEmpty()) {
+ def markup = new MarkupContext()
+ markup.tag('div') {
+ tag('span') { text(obj.name) }
+ }
+ ctx.render(view: 'show', model: [html: markup.render()])
+ } else {
+ ctx.render(view: 'edit', model: [errors: obj.errors])
+ }
+ bh.consume(ctx.model)
+
+ // Metaclass churn from framework components
+ if (i % 100 == 0) {
+ DomainObject.metaClass."grailsHelper${i % 5}" = { -> delegate.name }
+ }
+ }
+ }
+}
diff --git a/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/MetaclassChangeBench.groovy b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/MetaclassChangeBench.groovy
new file mode 100644
index 0000000000..6ed9381cc7
--- /dev/null
+++ b/subprojects/performance/src/jmh/groovy/org/apache/groovy/perf/MetaclassChangeBench.groovy
@@ -0,0 +1,310 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.groovy.perf
+
+import groovy.lang.ExpandoMetaClass
+import groovy.lang.GroovySystem
+
+import org.openjdk.jmh.annotations.*
+import org.openjdk.jmh.infra.Blackhole
+
+import java.util.concurrent.TimeUnit
+
+/**
+ * Tests the performance impact of metaclass modifications on invokedynamic
+ * call sites. These benchmarks exercise the key pain point identified in
+ * GROOVY-10307: when any metaclass changes, the global SwitchPoint is
+ * invalidated, causing ALL call sites across the application to fall back
+ * and re-link their method handles.
+ *
+ * In Grails applications, metaclass modifications happen frequently during
+ * framework startup (loading controllers, services, domain classes) and
+ * during request processing (dynamic finders, runtime mixins). This causes
+ * severe performance degradation under invokedynamic because every metaclass
+ * change triggers a global invalidation cascade.
+ *
+ * Compare baseline benchmarks (no metaclass changes) against the metaclass
+ * modification variants to measure the invalidation overhead.
+ */
+@Warmup(iterations = 3, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(2)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+@State(Scope.Thread)
+class MetaclassChangeBench {
+ static final int ITERATIONS = 100_000
+
+ // Helper classes for benchmarks - each represents a different
+ // component that might have its metaclass modified
+ static class ServiceA {
+ int value = 42
+ int compute() { value * 2 }
+ }
+
+ static class ServiceB {
+ String name = "test"
+ int nameLength() { name.length() }
+ }
+
+ static class ServiceC {
+ List items = [1, 2, 3]
+ int itemCount() { items.size() }
+ }
+
+ ServiceA serviceA
+ ServiceB serviceB
+ ServiceC serviceC
+
+ @Setup(Level.Iteration)
+ void setup() {
+ // Reset metaclasses to avoid accumulation across iterations
+ GroovySystem.metaClassRegistry.removeMetaClass(ServiceA)
+ GroovySystem.metaClassRegistry.removeMetaClass(ServiceB)
+ GroovySystem.metaClassRegistry.removeMetaClass(ServiceC)
+ serviceA = new ServiceA()
+ serviceB = new ServiceB()
+ serviceC = new ServiceC()
+ }
+
+ // ===== BASELINE (no metaclass changes) =====
+
+ /**
+ * Baseline: method calls with no metaclass changes.
+ * Establishes the cost of normal invokedynamic dispatch when
+ * call sites remain stable. Compare against metaclass-modifying
+ * benchmarks to measure invalidation overhead.
+ */
+ @Benchmark
+ void baselineNoMetaclassChanges(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += serviceA.compute()
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Baseline: multi-class method calls with no metaclass changes.
+ * Control for {@link #multiClassMetaclassChurn}.
+ */
+ @Benchmark
+ void baselineMultiClassNoChanges(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += serviceA.compute()
+ sum += serviceB.nameLength()
+ sum += serviceC.itemCount()
+ }
+ bh.consume(sum)
+ }
+
+ // ===== EXPANDO METACLASS MODIFICATIONS =====
+
+ /**
+ * ExpandoMetaClass method addition interleaved with method calls.
+ * Every 1000 calls, a method is added to the metaclass, triggering
+ * SwitchPoint invalidation.
+ *
+ * This simulates the Grails startup pattern where metaclasses are
+ * modified as controllers, services, and domain classes are loaded
+ * while other call sites are already active.
+ */
+ @Benchmark
+ void expandoMethodAddition(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += serviceA.compute()
+ if (i % 1000 == 0) {
+ // Add method via ExpandoMetaClass - triggers invalidateSwitchPoints()
+ // Reuse a small set of names to avoid unbounded metaclass growth
+ ServiceA.metaClass."dynamic${i % 5}" = { -> i }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Frequent metaclass changes - every 100 calls instead of 1000.
+ * Simulates frameworks that modify metaclasses more aggressively
+ * during request processing.
+ */
+ @Benchmark
+ void frequentExpandoChanges(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += serviceA.compute()
+ if (i % 100 == 0) {
+ ServiceA.metaClass."frequent${i % 5}" = { -> i }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ // ===== METACLASS REPLACEMENT =====
+
+ /**
+ * Repeated metaclass replacement - the most extreme invalidation
+ * pattern. Replacing the entire metaclass triggers a full
+ * invalidation cycle each time.
+ */
+ @Benchmark
+ void metaclassReplacement(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += serviceA.compute()
+ if (i % 1000 == 0) {
+ def mc = new ExpandoMetaClass(ServiceA, false, true)
+ mc.initialize()
+ ServiceA.metaClass = mc
+ }
+ }
+ bh.consume(sum)
+ }
+
+ // ===== MULTI-CLASS INVALIDATION CASCADE =====
+
+ /**
+ * Multi-class metaclass modification - simulates Grails loading
+ * multiple components, each triggering metaclass changes that
+ * invalidate call sites for ALL classes, not just the modified one.
+ *
+ * This is the core Grails pain point: changing ServiceA's metaclass
+ * invalidates call sites for ServiceB and ServiceC too.
+ */
+ @Benchmark
+ void multiClassMetaclassChurn(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += serviceA.compute()
+ sum += serviceB.nameLength()
+ sum += serviceC.itemCount()
+
+ if (i % 1000 == 0) {
+ // Rotate metaclass changes across different classes
+ switch (i % 3000) {
+ case 0:
+ ServiceA.metaClass."dynamic${i % 3}" = { -> i }
+ break
+ case 1000:
+ ServiceB.metaClass."dynamic${i % 3}" = { -> i }
+ break
+ case 2000:
+ ServiceC.metaClass."dynamic${i % 3}" = { -> i }
+ break
+ }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ // ===== BURST THEN STEADY STATE =====
+
+ /**
+ * Burst metaclass changes followed by steady-state calls.
+ * Simulates Grails application startup (many metaclass changes
+ * during bootstrap) followed by request handling (stable dispatch).
+ * Measures how quickly call sites recover after invalidation stops.
+ */
+ @Benchmark
+ void burstThenSteadyState(Blackhole bh) {
+ // Phase 1: Burst of metaclass changes (startup/bootstrap)
+ for (int i = 0; i < 50; i++) {
+ ServiceA.metaClass."startup${i % 10}" = { -> i }
+ }
+
+ // Phase 2: Steady-state method calls (request handling)
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += serviceA.compute()
+ }
+ bh.consume(sum)
+ }
+
+ // ===== PROPERTY ACCESS UNDER METACLASS CHURN =====
+
+ /**
+ * Property access interleaved with metaclass changes.
+ * Property get/set dispatches through invokedynamic and is also
+ * invalidated by SwitchPoint changes. Grails uses extensive
+ * property access for domain class fields, controller parameters,
+ * and service injection.
+ */
+ @Benchmark
+ void propertyAccessDuringMetaclassChurn(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ serviceA.value = i
+ sum += serviceA.value
+ if (i % 1000 == 0) {
+ ServiceA.metaClass."prop${i % 5}" = { -> i }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Baseline: property access with no metaclass changes.
+ * Control for {@link #propertyAccessDuringMetaclassChurn}.
+ */
+ @Benchmark
+ void baselinePropertyAccessNoChanges(Blackhole bh) {
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ serviceA.value = i
+ sum += serviceA.value
+ }
+ bh.consume(sum)
+ }
+
+ // ===== CLOSURE DISPATCH UNDER METACLASS CHURN =====
+
+ /**
+ * Closure dispatch during metaclass changes.
+ * Closure call sites are also invalidated by SwitchPoint changes.
+ * Grails uses closures extensively in GORM criteria queries,
+ * controller actions, and configuration DSLs.
+ */
+ @Benchmark
+ void closureDispatchDuringMetaclassChurn(Blackhole bh) {
+ Closure compute = { int x -> x * 2 }
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += compute(i)
+ if (i % 1000 == 0) {
+ ServiceA.metaClass."cl${i % 5}" = { -> i }
+ }
+ }
+ bh.consume(sum)
+ }
+
+ /**
+ * Baseline: closure dispatch with no metaclass changes.
+ * Control for {@link #closureDispatchDuringMetaclassChurn}.
+ */
+ @Benchmark
+ void baselineClosureDispatchNoChanges(Blackhole bh) {
+ Closure compute = { int x -> x * 2 }
+ int sum = 0
+ for (int i = 0; i < ITERATIONS; i++) {
+ sum += compute(i)
+ }
+ bh.consume(sum)
+ }
+}