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/08/31 21:24:50 UTC
(groovy) branch GROOVY_5_1_X updated (ee824008c5 -> 4ba6d0f825)
This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a change to branch GROOVY_5_1_X
in repository https://gitbox.apache.org/repos/asf/groovy.git
from ee824008c5 GROOVY-12316: compose the parser DFA cache threshold with the GC canary
new 0884769b3f GROOVY-12318: bound the parser DFA cache by size
new 4ba6d0f825 GROOVY-12318: hold the parser DFA cache graph softly so idle runtime copies release it
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.../apache/groovy/parser/antlr4/AstBuilder.java | 41 ++---
.../antlr4/internal/atnmanager/AtnManager.java | 203 +++++++++++++++------
.../internal/atnmanager/LexerAtnManager.java | 7 +-
.../internal/atnmanager/ParserAtnManager.java | 9 +-
src/test/groovy/bugs/Groovy12142.groovy | 132 +++++++++++++-
5 files changed, 304 insertions(+), 88 deletions(-)
(groovy) 02/02: GROOVY-12318: hold the parser DFA cache graph softly so idle runtime copies release it
Posted by pa...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch GROOVY_5_1_X
in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 4ba6d0f825de1cde7b116d5670f7a70c78f666ce
Author: Paul King <pa...@asert.com.au>
AuthorDate: Tue Sep 1 02:21:36 2026 +1000
GROOVY-12318: hold the parser DFA cache graph softly so idle runtime copies release it
The 5.1.1 groovydoc regression in multi-module builds (2.5x wall time,
observed as 463 full GCs against 5.1.0's 2 on the same Grails workload)
was not parse speed: a Gradle daemon caches one copy of the Groovy
runtime per distinct ant classpath — eleven copies over one Grails
groovydoc run — and GROOVY-12142's clear-on-next-parse canary never
fires for a copy that has finished its tasks. Every idle copy's fully
warmed cache (hundreds of MB of DFA states, ATN configs and prediction
contexts hanging off the generated parser's static ATN) stayed strongly
reachable for the life of the daemon, the live set climbed to the heap
ceiling, and the daemon collected instead of parsing. With clearing
disabled the same workload runs out of a 5G heap outright.
Move the mutable prediction caches off the static ATN onto a private
ATN owned by the softly referenced AtnWrapper. Reclamation is then
structural: active parsers keep the wrapper strongly reachable through
their interpreters, and an idle copy's whole cache graph is softly
reachable, so the collector frees it directly — no observation on any
parse path, no cleaner thread to pin the loader. On the Grails
reproduction wall time returns to the 5.1.0 level (273s vs 259s, from
638s) and full GC all but disappears (1 pause vs 463).
An over-limit cache is now replaced, not cleared in place: parses in
flight finish undisturbed on the retired ATN, which dies with them, so
parsing no longer serialises against the fair write lock and the locks
are retained only for binary compatibility.
The groovy.antlr4.cache.size default also scales with the heap (~32
states per MB, floored at the previous 20,000, capped at 500,000):
the fixed default sat far below real per-task working sets — a
multi-module documentation pass wants ~179k states where a 512MB heap
must stay near 20k — so parse-heavy daemons kept re-warming a cache
they could easily afford. A 5G daemon now keeps that pass warm; the
explicit property, the parse-counter threshold and the negative
never-drop escape hatch keep their meanings.
---
.../apache/groovy/parser/antlr4/AstBuilder.java | 41 ++--
.../antlr4/internal/atnmanager/AtnManager.java | 218 ++++++++++-----------
.../internal/atnmanager/LexerAtnManager.java | 7 +-
.../internal/atnmanager/ParserAtnManager.java | 9 +-
src/test/groovy/bugs/Groovy12142.groovy | 75 ++++++-
5 files changed, 206 insertions(+), 144 deletions(-)
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
index 74d8c062e3..b69f15b1ac 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/AstBuilder.java
@@ -40,7 +40,6 @@ import org.antlr.v4.runtime.misc.ParseCancellationException;
import org.antlr.v4.runtime.tree.ParseTree;
import org.antlr.v4.runtime.tree.TerminalNode;
import org.apache.groovy.parser.antlr4.internal.DescriptiveErrorStrategy;
-import org.apache.groovy.parser.antlr4.internal.atnmanager.AtnManager;
import org.apache.groovy.parser.antlr4.util.StringUtils;
import org.apache.groovy.util.Maps;
import org.apache.groovy.util.SystemUtil;
@@ -206,30 +205,26 @@ public class AstBuilder extends GroovyParserBaseVisitor<Object> {
GroovyParserRuleContext result;
try {
- // parsing have to wait util clearing is complete.
- AtnManager.READ_LOCK.lock();
- try {
- final TokenStream tokenStream = parser.getInputStream();
- if (SLL_THRESHOLD >= 0 && tokenStream.size() > SLL_THRESHOLD) {
- // The more tokens to parse, the more possibility SLL will fail and the more parsing time will waste.
- // The option `groovy.antlr4.sll.threshold` could be tuned for better parsing performance, but it is disabled by default.
- // If the token count is greater than `groovy.antlr4.sll.threshold`, use LL directly.
- result = buildCST(PredictionMode.LL);
- } else {
- try {
- result = buildCST(PredictionMode.SLL);
- } catch (Throwable t) {
- // if some syntax error occurred in the lexer, no need to retry the powerful LL mode
- if (t instanceof GroovySyntaxError && GroovySyntaxError.LEXER == ((GroovySyntaxError) t).getSource()) {
- throw t;
- }
-
- tokenStream.seek(0);
- result = buildCST(PredictionMode.LL);
+ // an over-limit DFA cache is replaced rather than cleared in place, so parsing
+ // proceeds on its own ATN without coordinating with other parsers
+ final TokenStream tokenStream = parser.getInputStream();
+ if (SLL_THRESHOLD >= 0 && tokenStream.size() > SLL_THRESHOLD) {
+ // The more tokens to parse, the more possibility SLL will fail and the more parsing time will waste.
+ // The option `groovy.antlr4.sll.threshold` could be tuned for better parsing performance, but it is disabled by default.
+ // If the token count is greater than `groovy.antlr4.sll.threshold`, use LL directly.
+ result = buildCST(PredictionMode.LL);
+ } else {
+ try {
+ result = buildCST(PredictionMode.SLL);
+ } catch (Throwable t) {
+ // if some syntax error occurred in the lexer, no need to retry the powerful LL mode
+ if (t instanceof GroovySyntaxError && GroovySyntaxError.LEXER == ((GroovySyntaxError) t).getSource()) {
+ throw t;
}
+
+ tokenStream.seek(0);
+ result = buildCST(PredictionMode.LL);
}
- } finally {
- AtnManager.READ_LOCK.unlock();
}
} catch (Throwable t) {
throw convertException(t);
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/AtnManager.java b/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/AtnManager.java
index a12e7c4a31..adb816119e 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/AtnManager.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/AtnManager.java
@@ -30,138 +30,162 @@ import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* Manage ATN to avoid memory leak.
* <p>
- * Two independent mechanisms drop the shared DFA cache, and they compose:
+ * The mutable prediction caches (DFA states, prediction contexts, LL(1) table) live on a
+ * <em>private</em> ATN owned by a softly referenced {@link AtnWrapper}, not on the
+ * generated parser's static ATN. That single decision provides the memory-pressure
+ * response: while parsers are active the wrapper is strongly reachable through their
+ * interpreters, and once this copy of the runtime goes idle the whole cache graph is only
+ * softly reachable, so the collector reclaims it directly — no cleaner thread (which
+ * would pin the defining class loader for the life of the JVM, GROOVY-12142), and no
+ * clear-on-next-parse canary (which never fires for an idle copy: a build daemon caches
+ * one runtime copy per distinct classpath, and every idle copy's fully warmed cache
+ * stayed strongly reachable until the daemon spent more time collecting than parsing).
+ * <p>
+ * Two deterministic mechanisms bound the cache while a copy is active, and they compose:
* <ul>
- * <li>a <em>GC canary</em> — the softly referenced {@link AtnWrapper}. Its collection
- * signals memory pressure, so the cache is dropped when the next parse observes the
- * cleared reference. Active unless clearing is switched off entirely.</li>
- * <li>a <em>parse counter</em> — clears every {@code groovy.antlr4.cache.threshold}
- * parses, bounding the cache deterministically rather than waiting for the collector.
- * Off by default.</li>
+ * <li>a <em>size ceiling</em> — {@code groovy.antlr4.cache.size} DFA states retained
+ * across the shared ATN; exceeding it replaces the wrapper with a fresh one. The
+ * default scales with the heap so a large daemon keeps a warm working set while a
+ * small JVM stays protected.</li>
+ * <li>a <em>parse counter</em> — replaces the wrapper every
+ * {@code groovy.antlr4.cache.threshold} parses, for workloads that want a fixed
+ * cadence. Off by default.</li>
* </ul>
- * The two used to be mutually exclusive: setting a threshold silently switched the canary
- * off, so a value chosen to bound growth removed the only mechanism that responded to
- * actual memory pressure and could make matters worse (GROOVY-12142).
+ * A replaced wrapper is never mutated: parses in flight finish undisturbed on the old
+ * ATN, which becomes collectable as they complete. Nothing is cleared in place, so
+ * parsing no longer serialises against a write lock.
*/
public abstract class AtnManager {
+ // Retained for binary compatibility with earlier releases in which parsing held the
+ // read side while an in-place cache clear held the write side. An over-limit cache is
+ // now replaced rather than cleared, so nothing acquires these locks any more.
private static final ReentrantReadWriteLock RRWL = new ReentrantReadWriteLock(true);
- private static final ReentrantReadWriteLock.WriteLock WRITE_LOCK = RRWL.writeLock();
public static final ReentrantReadWriteLock.ReadLock READ_LOCK = RRWL.readLock();
+
private static final String DFA_CACHE_THRESHOLD_OPT = "groovy.antlr4.cache.threshold";
/**
- * Parses between deterministic clears when {@value #DFA_CACHE_THRESHOLD_OPT} is not set.
- * <p>
- * The GC canary alone is not sufficient out of the box: it is only observed on the parse
- * path, so under sustained pressure the cache can grow unchecked between observations —
- * a long-lived daemon parsing concurrently across many modules then spends its time in GC
- * rather than parsing. A deterministic ceiling bounds the cache regardless of collector
- * timing. Set the property to {@code 0} to restore canary-only behaviour, or to a negative
- * value to disable clearing altogether.
- * <p>
- * Off by default: a blind parse counter cannot tell a large cache from a small warm one, so
- * it clears regardless and taxes builds that were never in trouble. {@link
- * #DFA_CACHE_SIZE_LIMIT_OPT} bounds the cache by what it actually holds instead. This
- * remains available for workloads that want a fixed, predictable clearing cadence.
+ * Parses between deterministic replacements when {@value #DFA_CACHE_THRESHOLD_OPT} is not
+ * set. Off by default: a blind parse counter cannot tell a large cache from a small warm
+ * one, so it drops warm state regardless and taxes builds that were never in trouble.
+ * {@link #DFA_CACHE_SIZE_LIMIT_OPT} bounds the cache by what it actually holds instead.
+ * Set the property to a positive value for a fixed, predictable cadence, or to a negative
+ * value to switch off dropping altogether (the caches then live on the static ATN and are
+ * never released).
*/
private static final long DFA_CACHE_THRESHOLD_DEFAULT = 0L;
private static final long DFA_CACHE_THRESHOLD;
- private static final boolean GC_CANARY_ENABLED;
+ private static final boolean CACHE_DROPPING_ENABLED;
private static final String DFA_CACHE_SIZE_LIMIT_OPT = "groovy.antlr4.cache.size";
/**
- * DFA states retained across the shared ATN before the cache is dropped.
+ * DFA states retained across the shared ATN before the wrapper is replaced.
* <p>
- * This is the default ceiling because it is the only one of the three mechanisms that costs
- * nothing when the cache is small: a project that never reaches the limit never clears, so
- * it keeps a fully warm cache, while a long-lived daemon parsing at scale stays bounded.
- * Unlike the GC canary it does not depend on {@code SoftReference} policy, so it behaves
- * identically on HotSpot and on SubstrateVM, where that policy differs; and unlike the
- * removed cleaner thread it starts nothing, so it cannot pin a container's class loader
- * (GROOVY-12142).
+ * The default scales with the heap: parsing at scale wants its working set — a
+ * multi-module documentation or compile pass reaches the high tens of thousands of states
+ * (~4KB per state all-in, prediction contexts and configs tracking states at stable
+ * ratios) — while a small JVM must be protected from the cache alone filling it. The
+ * scale is ~32 states per MB of max heap (≈ an eighth of the heap at the ~4KB/state
+ * observed cost), floored at 20,000 so tiny heaps keep a usable cache, and capped at
+ * 500,000. A 512MB heap lands on the floor; 2GB allows ~64k states; a 5GB build daemon
+ * allows ~164k, which keeps a full multi-module documentation pass warm.
* <p>
- * Calibration: parsing 6849 Groovy sources with no ceiling grows the cache to ~179,000
- * states / ~13.5M ATNConfigs. States track configs at a stable ~1:75, so this limit bounds
- * the cache at roughly 1.5M configs. Set the property to {@code 0} or a negative value to
- * disable the size ceiling.
+ * Unlike GC-driven release the ceiling does not depend on {@code SoftReference} policy,
+ * so it behaves identically on HotSpot and on SubstrateVM, where that policy differs.
+ * Set the property to a positive value to fix the ceiling, or to {@code 0} or a negative
+ * value to disable it.
*/
- private static final long DFA_CACHE_SIZE_LIMIT_DEFAULT = 20_000L;
+ private static final long DFA_CACHE_SIZE_LIMIT_DEFAULT;
private static final long DFA_CACHE_SIZE_LIMIT;
- private SoftReference<AtnWrapper> atnWrapperSoftReference;
+ private volatile SoftReference<AtnWrapper> atnWrapperSoftReference;
static {
long t = SystemUtil.getLongSafe(DFA_CACHE_THRESHOLD_OPT, DFA_CACHE_THRESHOLD_DEFAULT);
- // A negative threshold has always meant "never clear the DFA cache". Preserve that
- // escape hatch explicitly: it now switches off both mechanisms, because the canary
- // is no longer implied by a zero threshold.
- GC_CANARY_ENABLED = gcCanaryEnabled(t);
+ // A negative threshold has always meant "never drop the DFA cache". Preserve that
+ // escape hatch explicitly: it switches off both the counter and the size ceiling.
+ CACHE_DROPPING_ENABLED = gcCanaryEnabled(t);
DFA_CACHE_THRESHOLD = counterThreshold(t);
+ DFA_CACHE_SIZE_LIMIT_DEFAULT = heapScaledSizeLimit();
long limit = SystemUtil.getLongSafe(DFA_CACHE_SIZE_LIMIT_OPT, DFA_CACHE_SIZE_LIMIT_DEFAULT);
DFA_CACHE_SIZE_LIMIT = Math.max(limit, 0L);
}
+ /** Default size ceiling for the current JVM: ~32 states per MB of max heap, in [20k, 500k]. */
+ static long heapScaledSizeLimit() {
+ long maxBytes = Runtime.getRuntime().maxMemory();
+ if (maxBytes == Long.MAX_VALUE) return 500_000L;
+ long scaled = (maxBytes >> 20) * 32;
+ return Math.max(20_000L, Math.min(500_000L, scaled));
+ }
+
/** Whether the cache is bounded by how much it holds. Zero disables the ceiling. */
private static boolean isSizeLimitEnabled() {
return 0 != DFA_CACHE_SIZE_LIMIT;
}
/**
- * Whether the GC canary is active for the given raw threshold. Only an explicit
- * negative value ("never clear") switches it off; in particular a positive threshold
- * leaves it on, so the two mechanisms compose.
+ * Whether cache dropping is active for the given raw threshold. Only an explicit
+ * negative value ("never drop") switches it off; in particular a positive threshold
+ * leaves the size ceiling on, so the two mechanisms compose. (The name reflects the
+ * former GC-canary mechanism this policy gated; the softly referenced wrapper now
+ * plays that role structurally.)
*/
static boolean gcCanaryEnabled(final long rawThreshold) {
return rawThreshold >= 0;
}
- /** Parses between deterministic clears for the given raw threshold; 0 means off. */
+ /** Parses between deterministic replacements for the given raw threshold; 0 means off. */
static long counterThreshold(final long rawThreshold) {
return Math.max(rawThreshold, 0L);
}
/**
- * Whether the parse counter is active. Zero (the default) leaves the GC canary as the
- * only mechanism; any positive value adds a deterministic ceiling on top of it.
+ * Whether the parse counter is active. Zero (the default) leaves the size ceiling as the
+ * only deterministic mechanism; any positive value adds a fixed cadence on top of it.
*/
private static boolean isThresholdCleanupEnabled() {
return 0 != DFA_CACHE_THRESHOLD;
}
+ /** Whether this manager's caches may be dropped at all (global policy and per-manager switch). */
+ final boolean droppingEnabled() {
+ return CACHE_DROPPING_ENABLED && shouldClearDfaCache();
+ }
+
public ATN getATN() {
- return getAtnWrapper().checkAndClear();
+ return getAtnWrapper().checkAndReplace();
}
protected abstract AtnWrapper createAtnWrapper();
protected AtnWrapper getAtnWrapper() {
- return getAtnWrapper(true);
- }
-
- private AtnWrapper getAtnWrapper(final boolean useSoftRef) {
- if (!useSoftRef) {
- return createAtnWrapper();
+ SoftReference<AtnWrapper> ref = atnWrapperSoftReference;
+ AtnWrapper atnWrapper = (null == ref) ? null : ref.get();
+ if (null == atnWrapper) {
+ synchronized (this) {
+ ref = atnWrapperSoftReference;
+ atnWrapper = (null == ref) ? null : ref.get();
+ if (null == atnWrapper) {
+ atnWrapper = createAtnWrapper();
+ atnWrapperSoftReference = new SoftReference<>(atnWrapper);
+ }
+ }
}
+ return atnWrapper;
+ }
- AtnWrapper atnWrapper;
+ /**
+ * Install a fresh wrapper if {@code observed} is still current. Parses in flight keep
+ * the old ATN through their interpreters and finish undisturbed; the retired cache
+ * graph becomes collectable as they complete. The identity check makes concurrent
+ * observations of the same crossing collapse into a single replacement.
+ */
+ private void replaceAtnWrapper(final AtnWrapper observed) {
synchronized (this) {
- if (null == atnWrapperSoftReference) {
- atnWrapper = createAtnWrapper();
- atnWrapperSoftReference = new SoftReference<>(atnWrapper);
- } else if (null == (atnWrapper = atnWrapperSoftReference.get())) {
- // The softly referenced wrapper is a GC canary: its collection
- // signals memory pressure, so drop the shared DFA cache along
- // with allocating the replacement. Detected here on the parse
- // path rather than by a reference-queue thread — a cleanup
- // thread per manager can never terminate and so pins the
- // defining class loader for the life of the JVM, leaking every
- // container redeployment (GROOVY-12142).
- atnWrapper = createAtnWrapper();
- if (shouldClearDfaCache() && GC_CANARY_ENABLED) {
- atnWrapper.clearDFA();
- }
- atnWrapperSoftReference = new SoftReference<>(atnWrapper);
+ SoftReference<AtnWrapper> ref = atnWrapperSoftReference;
+ if (null != ref && ref.get() == observed) {
+ atnWrapperSoftReference = new SoftReference<>(createAtnWrapper());
}
}
- return atnWrapper;
}
protected abstract boolean shouldClearDfaCache();
@@ -169,33 +193,30 @@ public abstract class AtnManager {
protected class AtnWrapper {
private final ATN atn;
private final AtomicLong counter = new AtomicLong(0);
- private final AtomicBoolean clearing = new AtomicBoolean();
+ private final AtomicBoolean replacing = new AtomicBoolean();
public AtnWrapper(ATN atn) {
this.atn = atn;
}
- public ATN checkAndClear() {
- if (!shouldClearDfaCache()) {
+ public ATN checkAndReplace() {
+ if (!droppingEnabled()) {
return atn;
}
if (isThresholdCleanupEnabled() && 0 == counter.incrementAndGet() % DFA_CACHE_THRESHOLD) {
- clearDFA();
+ replaceAtnWrapper(this);
return atn;
}
- // Exactly one thread clears per crossing. Without the guard every concurrent
- // parser sees the same over-limit count and queues its own clear on the fair
- // write lock, so a single crossing becomes a herd of clear-and-rebuild cycles
- // that blocks every reader and churns the whole cache repeatedly.
+ // Exactly one thread retires a wrapper per crossing: without the guard every
+ // concurrent parser sees the same over-limit count and each installs its own
+ // fresh wrapper, discarding the warmth the previous replacement just started
+ // to accumulate.
if (isSizeLimitEnabled() && dfaStateCount() > DFA_CACHE_SIZE_LIMIT
- && clearing.compareAndSet(false, true)) {
- try {
- clearDfaIfStillOverLimit();
- } finally {
- clearing.set(false);
- }
+ && replacing.compareAndSet(false, true)) {
+ // not reset: this wrapper is retired for good once replaced
+ replaceAtnWrapper(this);
}
return atn;
@@ -215,26 +236,5 @@ public abstract class AtnManager {
}
return n;
}
-
- private void clearDfaIfStillOverLimit() {
- WRITE_LOCK.lock();
- try {
- // re-check: a clear may have completed while this thread queued for the lock
- if (dfaStateCount() > DFA_CACHE_SIZE_LIMIT) {
- atn.clearDFA();
- }
- } finally {
- WRITE_LOCK.unlock();
- }
- }
-
- public void clearDFA() {
- WRITE_LOCK.lock();
- try {
- atn.clearDFA();
- } finally {
- WRITE_LOCK.unlock();
- }
- }
}
}
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/LexerAtnManager.java b/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/LexerAtnManager.java
index fb93e40a4c..a9dcba8e92 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/LexerAtnManager.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/LexerAtnManager.java
@@ -18,6 +18,7 @@
*/
package org.apache.groovy.parser.antlr4.internal.atnmanager;
+import org.antlr.v4.runtime.atn.ATNDeserializer;
import org.apache.groovy.parser.antlr4.GroovyLangLexer;
import org.apache.groovy.util.SystemUtil;
@@ -35,7 +36,11 @@ public class LexerAtnManager extends AtnManager {
@Override
protected AtnWrapper createAtnWrapper() {
- return new AtnWrapper(GroovyLangLexer._ATN);
+ // Dropping is opt-in for the lexer (its DFA stays in the hundreds of states), so by
+ // default the wrapper shares the static ATN and the warm lexer cache persists.
+ return new AtnWrapper(droppingEnabled()
+ ? new ATNDeserializer().deserialize(GroovyLangLexer._serializedATN)
+ : GroovyLangLexer._ATN);
}
@Override
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/ParserAtnManager.java b/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/ParserAtnManager.java
index 56e82dff7f..2c5083d0bf 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/ParserAtnManager.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/ParserAtnManager.java
@@ -18,6 +18,7 @@
*/
package org.apache.groovy.parser.antlr4.internal.atnmanager;
+import org.antlr.v4.runtime.atn.ATNDeserializer;
import org.apache.groovy.parser.antlr4.GroovyLangParser;
/**
@@ -28,7 +29,13 @@ public class ParserAtnManager extends AtnManager {
@Override
protected AtnWrapper createAtnWrapper() {
- return new AtnWrapper(GroovyLangParser._ATN);
+ // The wrapper must own a private ATN: its DFA/context caches are released by the
+ // wrapper becoming unreachable, which the generated parser's static ATN never is.
+ // When dropping is disabled the static ATN is used directly so its caches persist
+ // for the life of the class (the documented "never drop" behaviour).
+ return new AtnWrapper(droppingEnabled()
+ ? new ATNDeserializer().deserialize(GroovyLangParser._serializedATN)
+ : GroovyLangParser._ATN);
}
@Override
diff --git a/src/test/groovy/bugs/Groovy12142.groovy b/src/test/groovy/bugs/Groovy12142.groovy
index 57cefe64d7..1e3d0fb52b 100644
--- a/src/test/groovy/bugs/Groovy12142.groovy
+++ b/src/test/groovy/bugs/Groovy12142.groovy
@@ -82,13 +82,37 @@ final class Groovy12142 {
assertTrue(populated > 0, 'precondition: parsing must populate the parser DFA cache')
forceSoftReferenceClearing()
- new GroovyShell().parse('1') // the clear happens on the next parse
+ // no parse in between: the caches live on the softly referenced wrapper's private
+ // ATN, so collection releases them directly — an idle runtime copy included
int afterClear = parserDfaStateCount()
assertTrue(afterClear < populated,
"parser DFA cache must shrink after soft references are cleared, was $populated now $afterClear")
}
+ /**
+ * The whole cache graph must be collectable while the runtime copy is idle — no
+ * parse may be needed to release it. A build daemon holds one runtime copy per
+ * distinct classpath; a copy whose tasks are done never parses again, and with a
+ * clear-on-next-parse design its fully warmed cache stayed strongly reachable for
+ * the life of the daemon (multiplied per copy, the heap filled with dead caches).
+ */
+ @Test
+ void testIdleCopyCacheGraphIsCollectableWithoutAParse() {
+ (1..12).each { n -> parseVariedSource(n) }
+ def atnRef = new WeakReference(currentParserWrapperAtn())
+ assertTrue(atnRef.get() != null, 'precondition: a live wrapper ATN must exist')
+
+ clearParserWrapperSoftReference()
+ boolean collected = false
+ for (int i = 0; i < 100 && !collected; i++) {
+ System.gc()
+ collected = atnRef.get() == null
+ if (!collected) Thread.sleep(10)
+ }
+ assertTrue(collected, 'an idle copy must not strongly retain its parser DFA cache graph')
+ }
+
/**
* End-to-end guard for the composition, in a forked JVM because the threshold is read
* into a static final at class-init. With a threshold set but not yet reached, the only
@@ -118,7 +142,7 @@ final class Groovy12142 {
if (populated <= 0) System.exit(2)
forceSoftReferenceClearing()
- new GroovyShell().parse('1') // the canary is observed on the next parse
+ // the softly referenced wrapper owns the caches, so collection released them
int afterClear = parserDfaStateCount()
System.err.println("populated=$populated afterClear=$afterClear")
@@ -162,14 +186,19 @@ final class Groovy12142 {
static class SizeCeilingProbe {
static void main(String[] args) {
long limit = 200L
- // Build the cache past the ceiling with varied sources...
- for (int i = 0; i < 20; i++) parseVariedSource(i)
- int peak = parserDfaStateCount()
+ // Build the cache past the ceiling with varied sources, sampling as it grows:
+ // an over-limit wrapper is replaced (not cleared in place) when the next parse
+ // observes the crossing, so only a sample taken between parses can see the peak.
+ int peak = 0
+ for (int i = 0; i < 20; i++) {
+ parseVariedSource(i)
+ peak = Math.max(peak, parserDfaStateCount())
+ }
if (peak <= limit) System.exit(2) // scaffolding never crossed the ceiling
- // ...then parse a trivial script. The ceiling is checked before each parse, so the
- // clear lands first and a trivial script cannot refill what was dropped. (A varied
- // source refills to the same fixed point in one parse, which would mask it.)
+ // ...then parse a trivial script. Its parser observes the crossing and retires the
+ // wrapper, and a trivial script cannot refill what was dropped. (A varied source
+ // refills to the same fixed point in one parse, which would mask it.)
int low = peak
for (int i = 0; i < 5; i++) {
new GroovyShell().parse('1')
@@ -195,13 +224,39 @@ final class Groovy12142 {
(long) f.get(null)
}
- /** Counts live states in the shared parser DFA cache. */
+ /**
+ * Counts live states in the shared parser DFA cache. Reads the manager's current
+ * wrapper without creating one: the caches live on the wrapper's private ATN (the
+ * generated parser's static ATN stays cold), and an absent wrapper is an empty cache.
+ */
private static int parserDfaStateCount() {
- def atn = org.apache.groovy.parser.antlr4.GroovyParser._ATN
+ def atn = currentParserWrapperAtn()
+ if (atn == null) return 0
def dfas = atn.decisionToDFA.findAll { it != null }
dfas.isEmpty() ? 0 : (int) dfas.sum { it.states.size() }
}
+ /** The parser manager's current wrapper ATN, or null when none is live. */
+ private static Object currentParserWrapperAtn() {
+ def wrapper = parserWrapperSoftReference()?.get()
+ if (wrapper == null) return null
+ def atnField = wrapper.getClass().getDeclaredField('atn')
+ atnField.accessible = true
+ atnField.get(wrapper)
+ }
+
+ private static void clearParserWrapperSoftReference() {
+ parserWrapperSoftReference()?.clear()
+ }
+
+ private static java.lang.ref.SoftReference parserWrapperSoftReference() {
+ def manager = Class.forName('org.apache.groovy.parser.antlr4.internal.atnmanager.ParserAtnManager').INSTANCE
+ def refField = Class.forName('org.apache.groovy.parser.antlr4.internal.atnmanager.AtnManager')
+ .getDeclaredField('atnWrapperSoftReference')
+ refField.accessible = true
+ (java.lang.ref.SoftReference) refField.get(manager)
+ }
+
private static void parseVariedSource(int n) {
new GroovyShell().parse """
class Varied$n {
(groovy) 01/02: GROOVY-12318: bound the parser DFA cache by size
Posted by pa...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
paulk-asert pushed a commit to branch GROOVY_5_1_X
in repository https://gitbox.apache.org/repos/asf/groovy.git
commit 0884769b3ffa7520c76d2b3bed4aad9c2fe9d586
Author: Paul King <pa...@asert.com.au>
AuthorDate: Sat Aug 29 14:26:50 2026 +1000
GROOVY-12318: bound the parser DFA cache by size
The shared parser DFA cache had no ceiling out of the box. The GC canary
is only observed on the parse path, so under sustained pressure the cache
grows unchecked between observations: a long-lived daemon parsing across
many modules ends up spending its time collecting rather than parsing.
On a Grails groovydoc build (12 cores, 5G heap) the groovydoc tasks took
1784s against 98s on the last release without the regression, and at a
tight heap the same growth surfaces as OutOfMemoryError instead.
Bound the cache by what it actually holds. groovy.antlr4.cache.size gives
a ceiling on DFA states retained across the shared ATN, defaulting to
20000; exceeding it drops the cache. Calibration: parsing 6849 real Groovy
sources with no ceiling grows the cache to ~179,000 states / ~13.5M
ATNConfigs, and states track configs at a stable ~1:75, so the default
bounds it at roughly 1.5M configs. The Grails groovydoc tasks take 128s
with this ceiling; a multi-module parse at a constrained heap needs 44M
where both 5.1.0 and 5.1.1 need 64-65M.
Size is the default ceiling rather than the existing parse counter because
it costs nothing while the cache is small: a project that never reaches
the limit never clears and keeps a fully warm cache. The counter clears on
a fixed cadence whether or not there is anything worth dropping, which
costs ~87% on parse-only workloads against ~13% for the ceiling, so it
reverts to opt-in (threshold default back to 0). Unlike the GC canary the
ceiling does not depend on SoftReference policy, so it behaves the same on
SubstrateVM where that policy differs; and unlike the cleaner thread
removed by GROOVY-12142 it starts nothing, so it cannot pin a container's
class loader.
Only one thread clears per crossing, guarded by a CAS. Without the guard
every concurrent parser sees the same over-limit count and queues its own
clear on the fair write lock, turning a single crossing into a herd of
clear-and-rebuild cycles that blocks every reader; that churned the cache
badly enough to exhaust a 5G heap across parallel groovydoc tasks while
passing every single-threaded benchmark.
---
.../antlr4/internal/atnmanager/AtnManager.java | 93 +++++++++++++++++++++-
src/test/groovy/bugs/Groovy12142.groovy | 69 ++++++++++++++++
2 files changed, 158 insertions(+), 4 deletions(-)
diff --git a/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/AtnManager.java b/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/AtnManager.java
index 08c5c2e6f9..a12e7c4a31 100644
--- a/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/AtnManager.java
+++ b/src/main/java/org/apache/groovy/parser/antlr4/internal/atnmanager/AtnManager.java
@@ -19,9 +19,11 @@
package org.apache.groovy.parser.antlr4.internal.atnmanager;
import org.antlr.v4.runtime.atn.ATN;
+import org.antlr.v4.runtime.dfa.DFA;
import org.apache.groovy.util.SystemUtil;
import java.lang.ref.SoftReference;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantReadWriteLock;
@@ -46,17 +48,60 @@ public abstract class AtnManager {
private static final ReentrantReadWriteLock.WriteLock WRITE_LOCK = RRWL.writeLock();
public static final ReentrantReadWriteLock.ReadLock READ_LOCK = RRWL.readLock();
private static final String DFA_CACHE_THRESHOLD_OPT = "groovy.antlr4.cache.threshold";
+ /**
+ * Parses between deterministic clears when {@value #DFA_CACHE_THRESHOLD_OPT} is not set.
+ * <p>
+ * The GC canary alone is not sufficient out of the box: it is only observed on the parse
+ * path, so under sustained pressure the cache can grow unchecked between observations —
+ * a long-lived daemon parsing concurrently across many modules then spends its time in GC
+ * rather than parsing. A deterministic ceiling bounds the cache regardless of collector
+ * timing. Set the property to {@code 0} to restore canary-only behaviour, or to a negative
+ * value to disable clearing altogether.
+ * <p>
+ * Off by default: a blind parse counter cannot tell a large cache from a small warm one, so
+ * it clears regardless and taxes builds that were never in trouble. {@link
+ * #DFA_CACHE_SIZE_LIMIT_OPT} bounds the cache by what it actually holds instead. This
+ * remains available for workloads that want a fixed, predictable clearing cadence.
+ */
+ private static final long DFA_CACHE_THRESHOLD_DEFAULT = 0L;
private static final long DFA_CACHE_THRESHOLD;
private static final boolean GC_CANARY_ENABLED;
+ private static final String DFA_CACHE_SIZE_LIMIT_OPT = "groovy.antlr4.cache.size";
+ /**
+ * DFA states retained across the shared ATN before the cache is dropped.
+ * <p>
+ * This is the default ceiling because it is the only one of the three mechanisms that costs
+ * nothing when the cache is small: a project that never reaches the limit never clears, so
+ * it keeps a fully warm cache, while a long-lived daemon parsing at scale stays bounded.
+ * Unlike the GC canary it does not depend on {@code SoftReference} policy, so it behaves
+ * identically on HotSpot and on SubstrateVM, where that policy differs; and unlike the
+ * removed cleaner thread it starts nothing, so it cannot pin a container's class loader
+ * (GROOVY-12142).
+ * <p>
+ * Calibration: parsing 6849 Groovy sources with no ceiling grows the cache to ~179,000
+ * states / ~13.5M ATNConfigs. States track configs at a stable ~1:75, so this limit bounds
+ * the cache at roughly 1.5M configs. Set the property to {@code 0} or a negative value to
+ * disable the size ceiling.
+ */
+ private static final long DFA_CACHE_SIZE_LIMIT_DEFAULT = 20_000L;
+ private static final long DFA_CACHE_SIZE_LIMIT;
private SoftReference<AtnWrapper> atnWrapperSoftReference;
static {
- long t = SystemUtil.getLongSafe(DFA_CACHE_THRESHOLD_OPT, 0L);
+ long t = SystemUtil.getLongSafe(DFA_CACHE_THRESHOLD_OPT, DFA_CACHE_THRESHOLD_DEFAULT);
// A negative threshold has always meant "never clear the DFA cache". Preserve that
// escape hatch explicitly: it now switches off both mechanisms, because the canary
// is no longer implied by a zero threshold.
GC_CANARY_ENABLED = gcCanaryEnabled(t);
DFA_CACHE_THRESHOLD = counterThreshold(t);
+
+ long limit = SystemUtil.getLongSafe(DFA_CACHE_SIZE_LIMIT_OPT, DFA_CACHE_SIZE_LIMIT_DEFAULT);
+ DFA_CACHE_SIZE_LIMIT = Math.max(limit, 0L);
+ }
+
+ /** Whether the cache is bounded by how much it holds. Zero disables the ceiling. */
+ private static boolean isSizeLimitEnabled() {
+ return 0 != DFA_CACHE_SIZE_LIMIT;
}
/**
@@ -124,25 +169,65 @@ public abstract class AtnManager {
protected class AtnWrapper {
private final ATN atn;
private final AtomicLong counter = new AtomicLong(0);
+ private final AtomicBoolean clearing = new AtomicBoolean();
public AtnWrapper(ATN atn) {
this.atn = atn;
}
public ATN checkAndClear() {
- if (!shouldClearDfaCache() || !isThresholdCleanupEnabled()) {
+ if (!shouldClearDfaCache()) {
return atn;
}
- if (0 != counter.incrementAndGet() % DFA_CACHE_THRESHOLD) {
+ if (isThresholdCleanupEnabled() && 0 == counter.incrementAndGet() % DFA_CACHE_THRESHOLD) {
+ clearDFA();
return atn;
}
- clearDFA();
+ // Exactly one thread clears per crossing. Without the guard every concurrent
+ // parser sees the same over-limit count and queues its own clear on the fair
+ // write lock, so a single crossing becomes a herd of clear-and-rebuild cycles
+ // that blocks every reader and churns the whole cache repeatedly.
+ if (isSizeLimitEnabled() && dfaStateCount() > DFA_CACHE_SIZE_LIMIT
+ && clearing.compareAndSet(false, true)) {
+ try {
+ clearDfaIfStillOverLimit();
+ } finally {
+ clearing.set(false);
+ }
+ }
return atn;
}
+ /**
+ * States currently retained across the shared ATN's decision DFAs. Cheap enough to read
+ * on every parse: one size read per decision, against a whole source unit's parsing.
+ */
+ private long dfaStateCount() {
+ long n = 0;
+ DFA[] decisionToDFA = atn.decisionToDFA;
+ if (null != decisionToDFA) {
+ for (DFA dfa : decisionToDFA) {
+ if (null != dfa) n += dfa.states.size();
+ }
+ }
+ return n;
+ }
+
+ private void clearDfaIfStillOverLimit() {
+ WRITE_LOCK.lock();
+ try {
+ // re-check: a clear may have completed while this thread queued for the lock
+ if (dfaStateCount() > DFA_CACHE_SIZE_LIMIT) {
+ atn.clearDFA();
+ }
+ } finally {
+ WRITE_LOCK.unlock();
+ }
+ }
+
public void clearDFA() {
WRITE_LOCK.lock();
try {
diff --git a/src/test/groovy/bugs/Groovy12142.groovy b/src/test/groovy/bugs/Groovy12142.groovy
index ded22c9016..57cefe64d7 100644
--- a/src/test/groovy/bugs/Groovy12142.groovy
+++ b/src/test/groovy/bugs/Groovy12142.groovy
@@ -126,6 +126,75 @@ final class Groovy12142 {
}
}
+ /**
+ * Out of the box the DFA cache must have a ceiling. The GC canary alone is only observed on
+ * the parse path, so under sustained pressure the cache grows unchecked between
+ * observations and the JVM spends its time collecting rather than parsing.
+ */
+ @Test
+ void testDfaCacheHasADefaultCeiling() {
+ assertTrue(defaultSizeLimit() > 0L,
+ "the DFA cache must be bounded by default, was ${defaultSizeLimit()}")
+ // the size ceiling is the default; the parse counter stays opt-in
+ assertEquals(0L, defaultThreshold(), 'the blind parse counter must not be on by default')
+ // ...and the canary must remain live alongside it
+ assertTrue(gcCanaryEnabled(defaultThreshold()), 'the default must keep the GC canary on')
+ }
+
+ /** The size ceiling must actually bound the cache, not merely be configured. */
+ @Test
+ void testSizeCeilingBoundsTheCache() {
+ def javaBin = new File(System.getProperty('java.home'), 'bin/java').absolutePath
+ def proc = [javaBin, '-Xmx512m', '-Dgroovy.antlr4.cache.size=200',
+ '-cp', System.getProperty('java.class.path'),
+ 'bugs.Groovy12142$SizeCeilingProbe'].execute()
+ def out = new StringBuilder(), err = new StringBuilder()
+ proc.waitForProcessOutput(out, err)
+ assertEquals(0, proc.exitValue(),
+ "size ceiling must bound the parser DFA cache (exit=${proc.exitValue()})\nout: $out\nerr: $err")
+ }
+
+ /**
+ * Forked with a deliberately small ceiling so it is crossed quickly. The cache must be
+ * dropped when it exceeds the limit, and must not run away.
+ * Exit 0 = bounded and cleared, 1 = never cleared, 2 = grew far past the limit.
+ */
+ static class SizeCeilingProbe {
+ static void main(String[] args) {
+ long limit = 200L
+ // Build the cache past the ceiling with varied sources...
+ for (int i = 0; i < 20; i++) parseVariedSource(i)
+ int peak = parserDfaStateCount()
+ if (peak <= limit) System.exit(2) // scaffolding never crossed the ceiling
+
+ // ...then parse a trivial script. The ceiling is checked before each parse, so the
+ // clear lands first and a trivial script cannot refill what was dropped. (A varied
+ // source refills to the same fixed point in one parse, which would mask it.)
+ int low = peak
+ for (int i = 0; i < 5; i++) {
+ new GroovyShell().parse('1')
+ low = Math.min(low, parserDfaStateCount())
+ }
+ System.err.println("limit=$limit peak=$peak low=$low")
+ System.exit(low < peak ? 0 : 1)
+ }
+ }
+
+ private static long defaultThreshold() {
+ atnManagerLongField('DFA_CACHE_THRESHOLD_DEFAULT')
+ }
+
+ private static long defaultSizeLimit() {
+ atnManagerLongField('DFA_CACHE_SIZE_LIMIT_DEFAULT')
+ }
+
+ private static long atnManagerLongField(String name) {
+ def f = Class.forName('org.apache.groovy.parser.antlr4.internal.atnmanager.AtnManager')
+ .getDeclaredField(name)
+ f.accessible = true
+ (long) f.get(null)
+ }
+
/** Counts live states in the shared parser DFA cache. */
private static int parserDfaStateCount() {
def atn = org.apache.groovy.parser.antlr4.GroovyParser._ATN