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)
+    }
+}