Skip to content

Classloader vs garbage collection causes resource leak #1616

Description

@jstaerk

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High priority issues to be scheduled in the upcoming release

    Type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions