Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
336 changes: 336 additions & 0 deletions GROOVY-12281-assessment.html

Large diffs are not rendered by default.

113 changes: 113 additions & 0 deletions Groovy12281LoaderSpike.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/*
* 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.
*/

import java.lang.ref.WeakReference;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;

/**
* GROOVY-12281 acceptance spike (manual, not CI): does a dropped Groovy
* runtime's class loader become collectable under each global ClassValue mode?
* This reproduces the container topology from GROOVY-12142: a Groovy copy is
* loaded in a child loader, runs a script whose dynamic dispatch creates
* ClassValue associations on immortal platform classes (String, Integer,
* ArrayList, ...), and is then dropped.
*
* <pre>
* java -Xmx256m Groovy12281LoaderSpike.java build/libs/groovy-6.0.0-SNAPSHOT.jar true # expect PINNED
* java -Xmx256m Groovy12281LoaderSpike.java build/libs/groovy-6.0.0-SNAPSHOT.jar soft # expect UNPINNED
* java -Xmx256m Groovy12281LoaderSpike.java build/libs/groovy-6.0.0-SNAPSHOT.jar false # expect UNPINNED (map control)
* </pre>
*
* Soft references are only guaranteed cleared before OOME, so the spike
* applies allocation pressure to its own heap after dropping the loader;
* "collectedBeforePressure" records whether plain GCs sufficed.
*/
public final class Groovy12281LoaderSpike {

private Groovy12281LoaderSpike() {
}

public static void main(String[] args) throws Exception {
Path jar = Path.of(args[0]).toAbsolutePath();
String mode = args.length > 1 ? args[1] : "true";
// Set before any child class initializes; the child copy's
// GroovyClassValueFactory reads it during class initialization.
System.setProperty("groovy.use.classvalue", mode);

WeakReference<ClassLoader> loaderRef = loadRunAndDrop(jar);

gc(10);
boolean collectedBeforePressure = loaderRef.get() == null;

applySoftClearingPressure();
gc(20);
boolean collected = loaderRef.get() == null;

System.out.println("mode=" + mode
+ " collectedBeforePressure=" + collectedBeforePressure
+ " collectedAfterPressure=" + collected);
System.out.println(collected ? "UNPINNED" : "PINNED");
}

private static WeakReference<ClassLoader> loadRunAndDrop(Path jar) throws Exception {
URLClassLoader child = new URLClassLoader("groovy-under-test",
new URL[]{jar.toUri().toURL()}, ClassLoader.getPlatformClassLoader());
Class<?> shellClass = Class.forName("groovy.lang.GroovyShell", true, child);
Object shell = shellClass.getConstructor().newInstance();
Object result = shellClass.getMethod("evaluate", String.class).invoke(shell,
// platform-receiver-heavy dispatch: String, Integer, Range, ArrayList, GString
"def s = 'abc'.reverse()\n"
+ "def total = (1..5).collect { it * 2 }.sum()\n"
+ "def m = [a: 1, b: 2]\n"
+ "\"${s}:${total}:${m.a + m.b}\".toString()");
if (!"cba:30:3".equals(result)) {
throw new IllegalStateException("unexpected script result: " + result);
}
System.out.println("script result: " + result + " (child Groovy active)");
child.close();
return new WeakReference<>(child);
}

private static void gc(int rounds) throws InterruptedException {
for (int i = 0; i < rounds; i++) {
System.gc();
Thread.sleep(50);
}
}

/**
* Allocates until OutOfMemoryError, forcing the collector to clear all
* soft references first (JLS guarantee), then releases everything.
*/
private static void applySoftClearingPressure() {
List<byte[]> hog = new ArrayList<>();
try {
while (true) {
hog.add(new byte[1 << 20]);
}
} catch (OutOfMemoryError expected) {
hog.clear();
}
System.out.println("soft-clearing pressure applied");
}
}
108 changes: 108 additions & 0 deletions Groovy12281PerfSpike.groovy
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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.
*/

/*
* GROOVY-12281 indicative perf spike (manual, not CI; treat as indicative —
* the JMH suite remains the heavyweight gate). Measures the global
* ClassValue mode's cost on the paths soft mode touches:
* - micro: ClassInfo.getClassInfo hot loop (the extra SoftReference deref)
* - macro: dynamic dispatch loops (String-heavy and POGO)
*
* Run per mode, fresh JVM each, idle machine, e.g.:
* for m in true soft false; do
* java -Xms512m -Xmx512m -cp build/libs/groovy-6.0.0-SNAPSHOT.jar \
* -Dgroovy.use.classvalue=$m groovy.ui.GroovyMain Groovy12281PerfSpike.groovy
* done
*/

import groovy.transform.CompileStatic
import org.codehaus.groovy.reflection.ClassInfo

@CompileStatic
class Micro {
static long hotLoop(int iters) {
long acc = 0
for (int i = 0; i < iters; i++) {
acc += System.identityHashCode(ClassInfo.getClassInfo(String))
}
acc
}

static long mixedLoop(int iters) {
long acc = 0
Class[] keys = [String, Integer, ArrayList, LinkedHashMap, Micro] as Class[]
for (int i = 0; i < iters; i++) {
acc += System.identityHashCode(ClassInfo.getClassInfo(keys[i % keys.length]))
}
acc
}
}

class Pogo {
int value
def bump(int n) { value += n; value }
}

def stringLoop = { int iters ->
def s = 'abcdef'
def acc = 0
for (int i = 0; i < iters; i++) {
acc += s.reverse().size() + "x${i & 7}".size()
}
acc
}

def pogoLoop = { int iters ->
def p = new Pogo()
def acc = 0
for (int i = 0; i < iters; i++) {
acc += p.bump(1) - p.value + i
}
acc
}

static List<Double> medianTimes(int rounds, int iters, Closure work) {
def times = []
for (int r = 0; r < rounds; r++) {
def t0 = System.nanoTime()
work(iters)
times << (System.nanoTime() - t0) / (double) iters
}
times.sort()
times
}

def mode = System.getProperty('groovy.use.classvalue', 'true')
int microIters = 20_000_000
int macroIters = 200_000
int rounds = 7

// warmup
Micro.hotLoop(microIters); Micro.mixedLoop(microIters)
stringLoop(macroIters); pogoLoop(macroIters)

def report = { String label, List<Double> t ->
printf('%s mode=%s median=%.2f ns/op (min=%.2f max=%.2f)%n',
label, mode, t[t.size().intdiv(2)], t.first(), t.last())
}

report('micro.getClassInfo(String) ', medianTimes(rounds, microIters, Micro.&hotLoop))
report('micro.getClassInfo(mixed) ', medianTimes(rounds, microIters, Micro.&mixedLoop))
report('macro.dispatch(String-heavy)', medianTimes(rounds, macroIters, stringLoop))
report('macro.dispatch(POGO) ', medianTimes(rounds, macroIters, pogoLoop))
7 changes: 7 additions & 0 deletions build-logic/src/main/groovy/org.apache.groovy-tested.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ tasks.withType(Test).configureEach {
if (closurePack) {
systemProperty 'groovy.target.closure.pack', closurePack
}
// Forward the global ClassValue mode (GROOVY-12142/GROOVY-12281: false = weak-key map,
// soft = soft values with resurrection) to the test JVM so whole suites can be run
// against a non-default mode, e.g. -Pgroovy.use.classvalue=soft. Set only when present.
def classValueMode = findProperty('groovy.use.classvalue') ?: System.getProperty('groovy.use.classvalue')
if (classValueMode) {
systemProperty 'groovy.use.classvalue', classValueMode
}
def testdb = System.properties['groovy.testdb.props']
if (testdb) {
systemProperty 'groovy.testdb.props', testdb
Expand Down
86 changes: 86 additions & 0 deletions src/main/java/org/apache/groovy/runtime/indy/IndyInvalidation.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
import groovy.lang.MetaClass;
import groovy.lang.MetaClassImpl;
import groovy.lang.MetaClassRegistryChangeEvent;
import groovy.transform.Internal;
import org.apache.groovy.util.SystemUtil;
import org.apache.groovy.util.concurrent.ManagedIdentityConcurrentMap;
import org.codehaus.groovy.reflection.ClassInfo;
import org.codehaus.groovy.runtime.NullObject;
import org.codehaus.groovy.util.ManagedReference;
Expand Down Expand Up @@ -72,8 +74,22 @@
* Production guards: {@code IndyInterface.applyMopSwitchPoints}; tests may use
* {@link #guardWithMopSwitchPoints}.
*
* <h2>Layering</h2>
* This class is the <em>policy</em> half of a two-level subsystem:
* {@link SwitchPointInvalidator} is the mechanism (one domain's SwitchPoint
* lifecycle plus the process-wide live registry) and makes no policy
* decisions; this class decides invalidation width, labels the reasons, owns
* reclaim anchoring and — when the global ClassValue store reclaims values
* (GROOVY-12281 soft mode) — per-Class domain continuity. The only other
* supported consumer of the mechanism is {@link ClassInfo}, which owns domain
* instances and performs the local operations (allocate, invalidate, detach)
* directly. No other code should construct or invalidate
* {@link SwitchPointInvalidator} instances; both classes are internal and may
* change incompatibly.
*
* @since 6.0.0
*/
@Internal
public final class IndyInvalidation {

private static final Logger LOG = Logger.getLogger(IndyInvalidation.class.getName());
Expand Down Expand Up @@ -135,6 +151,76 @@ public void finalizeReference() {
}
}

// -------------------------------------------------------------------------
// Soft ClassValue mode: per-Class domain continuity (GROOVY-12281)
// -------------------------------------------------------------------------

/**
* Per-Class domain map used only when {@code groovy.use.classvalue=soft}.
* In soft mode a ClassInfo instance is replaceable — it can be soft-collected
* and a successor created for the same class — while POJO direct-dispatch
* guards capture only the SwitchPoint's internal invoker, never the
* ClassInfo. Keying the domain by the {@code Class} (weak identity keys,
* strong domain values) makes domain identity survive instance turnover:
* the successor adopts its predecessor's domain, so an invalidation applied
* through the successor deterministically retires guards linked under the
* predecessor, with no dependency on lazy reference-queue processing.
* <p>
* The map holds no classes alive (weak keys) and no foreign loaders
* (values reference nothing owner-ward); entries for dead classes are
* purged by the map, and their domains retired by the Class-keyed reclaim
* ({@link #anchorClassDomainToClass}) so the live-SwitchPoint registry
* cannot accumulate zombies.
*/
private static final ManagedIdentityConcurrentMap<Class<?>, SwitchPointInvalidator> CLASS_DOMAINS =
new ManagedIdentityConcurrentMap<>();

/**
* Returns the canonical domain for {@code type}, seeding the per-Class map
* with {@code candidate} on first touch. Soft-mode only (callers gate).
*
* @param type the class whose domain is requested (must not be {@code null})
* @param candidate the caller-owned domain to install if none is mapped yet
* @return the canonical per-Class domain
*/
public static SwitchPointInvalidator classDomainFor(final Class<?> type, final SwitchPointInvalidator candidate) {
return CLASS_DOMAINS.getOrPut(type, candidate);
}

/**
* Anchors a reclaim reference keyed to the {@code Class} rather than a
* ClassInfo instance: soft mode retires a domain only when the class dies
* (no receiver can reach an installed guard afterwards), because ClassInfo
* instances are replaceable and their successors adopt the same domain.
*
* @param owner the class owning the domain (must not be {@code null})
* @param domain the class-level domain (must not be {@code null})
*/
public static void anchorClassDomainToClass(final Class<?> owner, final SwitchPointInvalidator domain) {
domain.setReclaimAnchor(new ClassDomainReclaim(owner, domain));
}

/**
* Weak reference to a domain's owning {@code Class} whose collection
* retires the domain (soft ClassValue mode). Mirrors {@link DomainReclaim}
* with the owner lifetime moved from the replaceable ClassInfo instance to
* the stable per-Class identity.
*/
private static final class ClassDomainReclaim extends ManagedReference<Class<?>> {
private final SwitchPointInvalidator domain;

ClassDomainReclaim(final Class<?> owner, final SwitchPointInvalidator domain) {
super(ReferenceBundle.getWeakBundle(), owner);
this.domain = domain;
}

@Override
public void finalizeReference() {
domain.invalidate();
super.finalizeReference();
}
}

// -------------------------------------------------------------------------
// Width: exact class
// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
*/
package org.apache.groovy.runtime.indy;

import groovy.transform.Internal;

import java.lang.invoke.SwitchPoint;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
Expand Down Expand Up @@ -53,8 +55,21 @@
* not the SwitchPoint object, so the registry entry is what keeps a
* still-installed guard retirable until its domain is explicitly retired.
*
* <h2>Layering</h2>
* This is the <em>mechanism</em> half of the MOP invalidation subsystem and
* makes no policy decisions. It has exactly two supported consumers:
* {@link org.codehaus.groovy.reflection.ClassInfo}, which owns domain
* instances (allocation, local invalidate, detach), and
* {@link IndyInvalidation}, which owns invalidation width policy, reclaim
* anchoring and per-Class domain continuity. On its own this class guarantees
* only the single-domain lifecycle documented above (single-use SwitchPoints;
* registration precedes publication); guarantees about domain identity across
* ClassInfo recreation exist only under {@link IndyInvalidation}'s
* management. It is not an extension point and may change incompatibly.
*
* @since 6.0.0
*/
@Internal
public final class SwitchPointInvalidator {

/**
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/org/codehaus/groovy/reflection/CachedClass.java
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,8 @@ private void updateSetNewMopMethods(List<MetaMethod> arr) {
}
else
classInfo.newMetaMethods = classInfo.dgmMetaMethods;
// GROOVY-12281 soft mode: MOP-array writes make the ClassInfo non-reclaimable
classInfo.updateReclaimability();
}

/**
Expand Down Expand Up @@ -622,6 +624,8 @@ private void updateAddNewMopMethods(List<MetaMethod> arr) {
res.addAll(Arrays.asList(classInfo.newMetaMethods));
res.addAll(arr);
classInfo.newMetaMethods = res.toArray(MetaMethod.EMPTY_ARRAY);
// GROOVY-12281 soft mode: MOP-array writes make the ClassInfo non-reclaimable
classInfo.updateReclaimability();
var theClass = classInfo.getCachedClass().getTheClass();
if (theClass == Closure.class || theClass == Class.class) {
ClosureMetaClass.resetCachedMetaClasses();
Expand Down
Loading
Loading