Summary
ScriptedConnectorBase.runScriptOnConnector compiles the script text into a new class on every call, even when the text has not changed. The engine's GroovyClassLoader, which lives as long as the connector configuration, keeps a reference to every such class. In the reproducer, 5,000 calls with the same one-line script left 5,000 classes in that loader's class cache, and Metaspace grew from 7.9 MB to 50.8 MB. Each call took several milliseconds, almost all of it compilation, while running an already compiled class takes ~3 µs.
Line references are to 1abfe74. The counts and timings come from a throwaway harness; everything else comes from reading the code.
Details
runScriptOnConnector (L342-L354) wraps the text in a GroovyCodeSource named "Script" + System.currentTimeMillis() + ".groovy". It then calls getGroovyScriptEngine().getGroovyClassLoader().parseClass(codeSource, false).
- With
false as the second argument, parseClass never populates the loader's source cache, so every call compiles. The time-based name only makes the class names unique; it is not what causes the recompilation.
- Groovy 2.4.21 defines each generated class in a throwaway
InnerLoader, but doParseClass then registers it in the engine loader's class cache (setClassCacheEntry). That entry keeps the class and its InnerLoader alive for as long as the engine loader lives.
- The engine and its loader are created once per configuration (L793-L808).
ScriptedConfiguration is a StatefulConfiguration, so the framework keeps one instance for the lifetime of the connector facade. Only release() drops the engine (L669).
parseClass holds the loader's sourceCache monitor while it compiles, so concurrent calls compile one at a time.
A caller that runs a connector script on a schedule therefore grows Metaspace for as long as the facade stays in use (the framework calls release() only when the facade is disposed or evicted as idle), and pays a full compilation on every call.
Reproducer
- Create a
ScriptedConfiguration with an empty script root and init a ScriptedPoolableConnector with it.
- Call
runScriptOnConnector(new ScriptContext("Groovy", "return a + 1", [a: 41]), null) 5,000 times.
- After every 1,000 calls, run
System.gc() three times. Then read getGroovyScriptEngine().getGroovyClassLoader().getLoadedClasses().length and the used size of the Metaspace pool.
Results at 1abfe74 (JDK 26, macOS). The last column comes from a separate run with -XX:SoftRefLRUPolicyMSPerMB=0, which clears soft references on every GC, so it shows only what is strongly held:
| Calls |
Classes in the engine's loader |
Metaspace |
Metaspace, soft refs cleared |
| 0 |
0 |
7.9 MB |
7.9 MB |
| 1,000 |
1,000 |
21.0 MB |
17.8 MB |
| 2,000 |
2,000 |
28.6 MB |
22.2 MB |
| 3,000 |
3,000 |
36.0 MB |
26.5 MB |
| 4,000 |
4,000 |
43.4 MB |
30.7 MB |
| 5,000 |
5,000 |
50.8 MB |
34.9 MB |
The first block also loads Groovy's own runtime classes. After it, Metaspace grows by about 4.3 KB per call with soft references cleared and about 7.4 KB per call without; the difference is held only through soft references. The average time per call over a block of 1,000 calls ranged from 2.6 to 10 ms across our runs, varying with JIT and GC, and almost all of it is compilation. Running a class compiled once (InvokerHelper.createScript(cls, binding).run(), measured standalone, outside the connector) took 3.1 µs per call.
Proposed fix
- Cache the compiled script classes per configuration in a bounded LRU map, keyed by a digest of the script text (e.g. SHA-256). A repeated script then costs a map lookup plus
createScript.
- Compile each new script through a short-lived child
GroovyClassLoader of the engine's loader with the same CompilerConfiguration (the GroovyClassLoader(GroovyClassLoader) constructor inherits it), so that setClassCacheEntry lands in the child's cache rather than the engine's. When an entry is evicted, nothing else strongly references its class, and the class can be unloaded. Without the engine's configuration the script would lose the connector's default imports.
- Clear the cache in
release().
Behavioral differences: static fields and metaclass changes of the script class, and classes declared in the script, survive between calls with the same text, and the class name becomes stable. Today every call gets a fresh class. @Field variables are instance fields, and each call still gets its own Script instance and Binding.
Summary
ScriptedConnectorBase.runScriptOnConnectorcompiles the script text into a new class on every call, even when the text has not changed. The engine'sGroovyClassLoader, which lives as long as the connector configuration, keeps a reference to every such class. In the reproducer, 5,000 calls with the same one-line script left 5,000 classes in that loader's class cache, and Metaspace grew from 7.9 MB to 50.8 MB. Each call took several milliseconds, almost all of it compilation, while running an already compiled class takes ~3 µs.Line references are to 1abfe74. The counts and timings come from a throwaway harness; everything else comes from reading the code.
Details
runScriptOnConnector(L342-L354) wraps the text in aGroovyCodeSourcenamed"Script" + System.currentTimeMillis() + ".groovy". It then callsgetGroovyScriptEngine().getGroovyClassLoader().parseClass(codeSource, false).falseas the second argument,parseClassnever populates the loader's source cache, so every call compiles. The time-based name only makes the class names unique; it is not what causes the recompilation.InnerLoader, butdoParseClassthen registers it in the engine loader's class cache (setClassCacheEntry). That entry keeps the class and itsInnerLoaderalive for as long as the engine loader lives.ScriptedConfigurationis aStatefulConfiguration, so the framework keeps one instance for the lifetime of the connector facade. Onlyrelease()drops the engine (L669).parseClassholds the loader'ssourceCachemonitor while it compiles, so concurrent calls compile one at a time.A caller that runs a connector script on a schedule therefore grows Metaspace for as long as the facade stays in use (the framework calls
release()only when the facade is disposed or evicted as idle), and pays a full compilation on every call.Reproducer
ScriptedConfigurationwith an empty script root andinitaScriptedPoolableConnectorwith it.runScriptOnConnector(new ScriptContext("Groovy", "return a + 1", [a: 41]), null)5,000 times.System.gc()three times. Then readgetGroovyScriptEngine().getGroovyClassLoader().getLoadedClasses().lengthand the used size of theMetaspacepool.Results at 1abfe74 (JDK 26, macOS). The last column comes from a separate run with
-XX:SoftRefLRUPolicyMSPerMB=0, which clears soft references on every GC, so it shows only what is strongly held:The first block also loads Groovy's own runtime classes. After it, Metaspace grows by about 4.3 KB per call with soft references cleared and about 7.4 KB per call without; the difference is held only through soft references. The average time per call over a block of 1,000 calls ranged from 2.6 to 10 ms across our runs, varying with JIT and GC, and almost all of it is compilation. Running a class compiled once (
InvokerHelper.createScript(cls, binding).run(), measured standalone, outside the connector) took 3.1 µs per call.Proposed fix
createScript.GroovyClassLoaderof the engine's loader with the sameCompilerConfiguration(theGroovyClassLoader(GroovyClassLoader)constructor inherits it), so thatsetClassCacheEntrylands in the child's cache rather than the engine's. When an entry is evicted, nothing else strongly references its class, and the class can be unloaded. Without the engine's configuration the script would lose the connector's default imports.release().Behavioral differences: static fields and metaclass changes of the script class, and classes declared in the script, survive between calls with the same text, and the class name becomes stable. Today every call gets a fresh class.
@Fieldvariables are instance fields, and each call still gets its ownScriptinstance andBinding.