When running some (e.g. 8) processes in parallel certain classloaders are never GCed, leading to an accumulation of those class loaders and ultimatively resource
This veraPDFClassLoaderExample.zip
shows (when launched with --add-opens java.base/java.lang=ALL-UNNAMED) how classloaders accumulate to in the end 584 non-clearable class loaders, output:
GC round 0 done
GC round 1 done
GC round 2 done
GC round 3 done
baseline Rhino loaders = 0
Analyse of 26 files took 4658ms
GC round 0 done
GC round 1 done
GC round 2 done
GC round 3 done
org.mozilla.javascript.DefiningClassLoader after GC = 584 (retained = 584)
=== Rhino DefiningClassLoaders bound per thread ===
pool-1-thread-1 73
pool-1-thread-2 73
pool-1-thread-3 73
pool-1-thread-4 73
pool-1-thread-5 73
pool-1-thread-6 73
pool-1-thread-7 73
pool-1-thread-8 73
----
threads holding loaders : 8
sum of per-thread counts: 584
distinct loaders total : 584
Process finished with exit code 0
The main logic living in src/test/java/ResourcesTest.java
/**
* Classloaders are freed only when GC runs and nothing references them.
*/
private static void forceGc() throws InterruptedException {
for (int i = 0; i < 4; i++) {
System.gc(); // only a hint -> loop + short waits
System.runFinalization();
Thread.sleep(500);
System.out.println("GC round " + i + " done");
}
}
/**
* Counts loaders of the given concrete class name via VM.classloader_stats.
*/
private static long countLoaders(String loaderClassName) {
try {
MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();
ObjectName dc = new ObjectName("com.sun.management:type=DiagnosticCommand");
// The operation is the camel-cased jcmd name. It's "vmClassloaderStats"
// (lowercase 'l' -- "classloader" is one token). Discover it so we don't
// depend on exact casing across JDK versions.
String op = null;
for (var info : mbs.getMBeanInfo(dc).getOperations()) {
if (info.getName().toLowerCase().contains("classloaderstats")) {
op = info.getName();
break;
}
}
if (op == null) {
throw new IllegalStateException("VM.classloader_stats operation not found");
}
String out = (String) mbs.invoke(
dc, op,
new Object[]{new String[0]}, // no command arguments
new String[]{"[Ljava.lang.String;"}); // arg type: String[]
return out.lines()
.filter(line -> line.contains(loaderClassName))
.count();
} catch (Exception e) {
throw new IllegalStateException("Could not query classloader stats", e);
}
}
static void dumpHeap(String path, boolean liveOnly) throws Exception {
Path filePath = Paths.get(path);
if (Files.exists(filePath)) {
Files.delete(filePath);
}
HotSpotDiagnosticMXBean bean = ManagementFactory.newPlatformMXBeanProxy(
ManagementFactory.getPlatformMBeanServer(),
"com.sun.management:type=HotSpotDiagnostic",
HotSpotDiagnosticMXBean.class);
bean.dumpHeap(path, liveOnly); // liveOnly=true runs a GC first
}
private static void validateAllDocumentsInPath(String path) throws Exception {
List<Path> pdfs;
try (var s = Files.list(Paths.get(path))) {
pdfs = s.filter(p -> p.toString().endsWith(".pdf")).toList();
}
var threadPool = Executors.newFixedThreadPool(8);
var tasks = pdfs.stream()
.map(p -> (Callable<Void>) () -> validateFile(p))
.toList();
long startVirtualThreads = System.currentTimeMillis();
threadPool.invokeAll(tasks);
System.out.println("\nAnalyse of " + pdfs.size() + " files took " + (System.currentTimeMillis() - startVirtualThreads) + "ms\n");
}
private static Void validateFile(Path p) throws IOException {
try {
var stream = Files.readAllBytes(p);
// Default validator config
final ValidatorConfig validatorConfig = ValidatorFactory.defaultConfig();
// Default features config
final FeatureExtractorConfig featureConfig = FeatureFactory.defaultConfig();
// Default plugins config
final PluginsCollectionConfig pluginsConfig = PluginsCollectionConfig.defaultConfig();
// Default fixer config
final MetadataFixerConfig fixerConfig = FixerFactory.defaultConfig();
// Tasks configuring
final EnumSet<TaskType> tasks = EnumSet.noneOf(TaskType.class);
tasks.add(TaskType.VALIDATE);
// tasks.add(TaskType.EXTRACT_FEATURES);
// tasks.add(TaskType.FIX_METADATA);
// Creating processor config
final ProcessorConfig processorConfig = ProcessorFactory.fromValues(validatorConfig, featureConfig, pluginsConfig,
fixerConfig, tasks
);
// Creating processor and output stream.
final InputStream inputStream = new ByteArrayInputStream(stream);
try (ItemProcessor processor = ProcessorFactory.createProcessor(processorConfig)) {
// Generating list of files for processing
// starting the processor
ItemDetails itemDetails = ItemDetails.fromValues(p.getFileName().toString());
inputStream.mark(Integer.MAX_VALUE);
processor.process(itemDetails, inputStream);
inputStream.reset();
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* For each live thread, counts the distinct Rhino DefiningClassLoaders reachable
* from that thread's ThreadLocal values. Prints a per-thread tally and a total.
* <p>
* Mechanism: thread.threadLocals -> ThreadLocalMap.table[].value (the cache)
* -> values that are org.mozilla.javascript.* (compiled scripts)
* -> value.getClass().getClassLoader() (the DefiningClassLoader)
*/
static void countRhinoLoadersPerThread() throws Exception {
Field tlField = Thread.class.getDeclaredField("threadLocals");
tlField.setAccessible(true);
Class<?> tlmClass = Class.forName("java.lang.ThreadLocal$ThreadLocalMap");
Field tableField = tlmClass.getDeclaredField("table");
tableField.setAccessible(true);
Class<?> entryClass = Class.forName("java.lang.ThreadLocal$ThreadLocalMap$Entry");
Field valueField = entryClass.getDeclaredField("value");
valueField.setAccessible(true);
Map<String, Integer> perThread = new TreeMap<>();
// Track loaders globally too, so we can see if any are shared across threads.
Set<ClassLoader> allLoaders = Collections.newSetFromMap(new IdentityHashMap<>());
for (Thread t : Thread.getAllStackTraces().keySet()) {
Object tlm = tlField.get(t);
if (tlm == null) continue;
Object[] table = (Object[]) tableField.get(tlm);
if (table == null) continue;
// distinct loaders reachable from THIS thread's thread-locals
Set<ClassLoader> loaders = Collections.newSetFromMap(new IdentityHashMap<>());
for (Object entry : table) {
if (entry == null) continue;
Object value = valueField.get(entry);
if (value == null) continue;
collectRhinoLoaders(value, loaders);
}
if (!loaders.isEmpty()) {
perThread.put(t.getName(), loaders.size());
allLoaders.addAll(loaders);
}
}
System.out.println("=== Rhino DefiningClassLoaders bound per thread ===");
int sum = 0;
for (var e : perThread.entrySet()) {
System.out.printf(" %-28s %d%n", e.getKey(), e.getValue());
sum += e.getValue();
}
System.out.println(" ----");
System.out.printf(" threads holding loaders : %d%n", perThread.size());
System.out.printf(" sum of per-thread counts: %d%n", sum);
System.out.printf(" distinct loaders total : %d%n", allLoaders.size());
// If sum > distinct total, some loaders are shared across threads.
// If sum == distinct total, every loader belongs to exactly one thread (pure per-thread).
}
/**
* Walk a thread-local value (typically a Map cache) and collect the loaders of any Rhino objects.
*/
private static void collectRhinoLoaders(Object value, Set<ClassLoader> out) {
if (value instanceof Map<?, ?> m) {
for (var e : m.entrySet()) {
addIfRhino(e.getKey(), out);
addIfRhino(e.getValue(), out);
}
} else if (value instanceof Iterable<?> it) {
for (Object o : it) addIfRhino(o, out);
} else {
addIfRhino(value, out);
}
}
private static void addIfRhino(Object o, Set<ClassLoader> out) {
if (o == null) return;
Class<?> c = o.getClass();
if (c.getName().startsWith("org.mozilla.javascript")) {
ClassLoader cl = c.getClassLoader();
if (cl != null && cl.getClass().getName().contains("DefiningClassLoader")) {
out.add(cl);
}
}
}
@Test
void test() throws Exception {
forceGc();
long baseline = countLoaders(RHINO_LOADER);
System.out.println("baseline Rhino loaders = " + baseline);
VeraGreenfieldFoundryProvider.initialise();
validateAllDocumentsInPath(FILE_SOURCE);
forceGc();
long after = countLoaders(RHINO_LOADER);
long retained = after - baseline;
System.out.println("\n" + RHINO_LOADER + " after GC = " + after + " (retained = " + retained + ")\n");
if (CREATE_HEAP_DUMP) {
dumpHeap(TEMP_DIR + "/heapDump.hprof", true);
}
countRhinoLoadersPerThread();
}
With higher java versions, e.g. 25, and the use of virtual threads this actually becomes much worse.
When running some (e.g. 8) processes in parallel certain classloaders are never GCed, leading to an accumulation of those class loaders and ultimatively resource
This veraPDFClassLoaderExample.zip
shows (when launched with
--add-opens java.base/java.lang=ALL-UNNAMED) how classloaders accumulate to in the end 584 non-clearable class loaders, output:The main logic living in src/test/java/ResourcesTest.java
With higher java versions, e.g. 25, and the use of virtual threads this actually becomes much worse.