Skip to content

Commit c781b99

Browse files
dukhlovbasiljtnord
authored
[JENKINS-75675] Refactor class loading logic in order to reduce memory consumption (#10659)
Co-authored-by: Basil Crow <[email protected]> Co-authored-by: James Nord <[email protected]>
1 parent 7738998 commit c781b99

File tree

6 files changed

+186
-17
lines changed

6 files changed

+186
-17
lines changed

core/src/main/java/hudson/ClassicPluginStrategy.java

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import hudson.model.Hudson;
3434
import hudson.util.CyclicGraphDetector;
3535
import hudson.util.CyclicGraphDetector.CycleDetectedException;
36+
import hudson.util.DelegatingClassLoader;
3637
import hudson.util.IOUtils;
3738
import hudson.util.MaskingClassLoader;
3839
import java.io.File;
@@ -559,7 +560,7 @@ private static void unzipExceptClasses(File archive, File destDir, Project prj)
559560
/**
560561
* Used to load classes from dependency plugins.
561562
*/
562-
static final class DependencyClassLoader extends ClassLoader {
563+
static final class DependencyClassLoader extends DelegatingClassLoader {
563564
/**
564565
* This classloader is created for this plugin. Useful during debugging.
565566
*/
@@ -574,10 +575,6 @@ static final class DependencyClassLoader extends ClassLoader {
574575
*/
575576
private volatile List<PluginWrapper> transitiveDependencies;
576577

577-
static {
578-
registerAsParallelCapable();
579-
}
580-
581578
DependencyClassLoader(ClassLoader parent, File archive, List<Dependency> dependencies, PluginManager pluginManager) {
582579
super("dependency ClassLoader for " + archive.getPath(), parent);
583580
this._for = archive;

core/src/main/java/hudson/PluginManager.java

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
import hudson.security.PermissionScope;
6161
import hudson.util.CyclicGraphDetector;
6262
import hudson.util.CyclicGraphDetector.CycleDetectedException;
63+
import hudson.util.DelegatingClassLoader;
64+
import hudson.util.ExistenceCheckingClassLoader;
6365
import hudson.util.FormValidation;
6466
import hudson.util.PersistedList;
6567
import hudson.util.Retrier;
@@ -2400,18 +2402,26 @@ public <T> T of(String key, Class<T> type, Supplier<T> func) {
24002402
/**
24012403
* {@link ClassLoader} that can see all plugins.
24022404
*/
2403-
public static final class UberClassLoader extends ClassLoader {
2405+
public static final class UberClassLoader extends DelegatingClassLoader {
24042406
private final List<PluginWrapper> activePlugins;
24052407

24062408
/** Cache of loaded, or known to be unloadable, classes. */
24072409
private final ConcurrentMap<String, Optional<Class<?>>> loaded = new ConcurrentHashMap<>();
24082410

2409-
static {
2410-
registerAsParallelCapable();
2411-
}
2412-
2411+
/**
2412+
* The servlet container's {@link ClassLoader} (the parent of Jenkins core) is
2413+
* parallel-capable and maintains its own growing {@link Map} of {@link
2414+
* ClassLoader#getClassLoadingLock} objects per class name for every load attempt (including
2415+
* misses), and we cannot override this behavior. Wrap the servlet container {@link
2416+
* ClassLoader} in {@link ExistenceCheckingClassLoader} to avoid calling {@link
2417+
* ClassLoader#getParent}'s {@link ClassLoader#loadClass(String, boolean)} at all for misses
2418+
* by first checking if the resource exists. If the resource does not exist, we immediately
2419+
* throw {@link ClassNotFoundException}. As a result, the servlet container's {@link
2420+
* ClassLoader} is never asked to try and fail, and it never creates/retains lock objects
2421+
* for those misses.
2422+
*/
24132423
public UberClassLoader(List<PluginWrapper> activePlugins) {
2414-
super("UberClassLoader", PluginManager.class.getClassLoader());
2424+
super("UberClassLoader", new ExistenceCheckingClassLoader(PluginManager.class.getClassLoader()));
24152425
this.activePlugins = activePlugins;
24162426
}
24172427

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package hudson.util;
2+
3+
import java.util.Objects;
4+
import org.kohsuke.accmod.Restricted;
5+
import org.kohsuke.accmod.restrictions.NoExternalUse;
6+
7+
/**
8+
* A {@link ClassLoader} that does not define any classes itself but delegates class loading to
9+
* other class loaders to avoid the JDK's per-class-name locking and lock retention.
10+
*
11+
* <p>This class first attempts to load classes via its {@link ClassLoader#getParent} class loader,
12+
* then falls back to {@link ClassLoader#findClass} to allow for custom delegation logic.
13+
*
14+
* <p>In a parallel-capable {@link ClassLoader}, the JDK maintains a per-name lock object
15+
* indefinitely. In Jenkins, many class loading misses across many loaders can accumulate hundreds
16+
* of thousands of such locks, retaining significant memory. This loader never defines classes and
17+
* bypasses {@link ClassLoader}'s default {@code loadClass} locking; it delegates to the parent
18+
* first and then to {@code findClass} for custom delegation.
19+
*
20+
* <p>The actual defining loader (parent or a delegate) still performs the necessary synchronization
21+
* and class definition. A runtime guard ({@link #verify}) throws if this loader ever becomes the
22+
* defining loader.
23+
*
24+
* <p>Subclasses must not call {@code defineClass}; implement delegation in {@code findClass} if
25+
* needed and do not mark subclasses as parallel-capable.
26+
*
27+
* @author Dmytro Ukhlov
28+
*/
29+
@Restricted(NoExternalUse.class)
30+
public abstract class DelegatingClassLoader extends ClassLoader {
31+
protected DelegatingClassLoader(String name, ClassLoader parent) {
32+
super(name, Objects.requireNonNull(parent));
33+
}
34+
35+
public DelegatingClassLoader(ClassLoader parent) {
36+
super(Objects.requireNonNull(parent));
37+
}
38+
39+
/**
40+
* Parent-first delegation without synchronizing on {@link #getClassLoadingLock(String)}. This
41+
* prevents creation/retention of per-name lock objects in a loader that does not define
42+
* classes. The defining loader downstream still serializes class definition as required.
43+
*
44+
* @param name The binary name of the class
45+
* @param resolve If {@code true} then resolve the class
46+
* @return The resulting {@link Class} object
47+
* @throws ClassNotFoundException If the class could not be found
48+
*/
49+
@Override
50+
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
51+
Class<?> c = null;
52+
try {
53+
c = getParent().loadClass(name);
54+
} catch (ClassNotFoundException e) {
55+
// ClassNotFoundException thrown if class not found
56+
// from the non-null parent class loader
57+
}
58+
59+
if (c == null) {
60+
// If still not found, then invoke findClass in order
61+
// to find the class.
62+
c = findClass(name);
63+
}
64+
65+
verify(c);
66+
if (resolve) {
67+
resolveClass(c);
68+
}
69+
return c;
70+
}
71+
72+
/**
73+
* Safety check to ensure this delegating loader never becomes the defining loader.
74+
*
75+
* <p>Fails fast if a subclass erroneously defines a class here, which would violate the
76+
* delegation-only contract and could reintroduce locking/retention issues.
77+
*/
78+
private void verify(Class<?> clazz) {
79+
if (clazz.getClassLoader() == this) {
80+
throw new IllegalStateException("DelegatingClassLoader must not be the defining loader: " + clazz.getName());
81+
}
82+
}
83+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package hudson.util;
2+
3+
import java.util.Objects;
4+
import jenkins.util.URLClassLoader2;
5+
import org.kohsuke.accmod.Restricted;
6+
import org.kohsuke.accmod.restrictions.NoExternalUse;
7+
8+
/**
9+
* A {@link ClassLoader} that verifies the existence of a {@code .class} resource before attempting
10+
* to load the class. Intended to sit in front of servlet container loaders we do not control.
11+
*
12+
* <p>This implementation overrides {@link #loadClass(String, boolean)} and uses {@link
13+
* #getResource(String)} to check whether the corresponding <code>.class</code> file is available in
14+
* the classpath. If the resource is not found, a {@link ClassNotFoundException} is thrown
15+
* immediately.
16+
*
17+
* <p>Parallel-capable parent loaders retain a per-class-name lock object for every load attempt,
18+
* including misses. By checking getResource(name + ".class") first and throwing {@link
19+
* ClassNotFoundException} on absence, we avoid calling {@code loadClass} on misses, thus preventing
20+
* the parent from populating its lock map for nonexistent classes.
21+
*
22+
* <p>This class is only needed in {@link hudson.PluginManager.UberClassLoader}. It is unnecessary
23+
* for plugin {@link ClassLoader}s (because {@link URLClassLoader2} mitigates lock retention via
24+
* {@link ClassLoader#getClassLoadingLock}) and redundant for delegators (because {@link
25+
* DelegatingClassLoader} already avoids base locking).
26+
*
27+
* @author Dmytro Ukhlov
28+
* @see ClassLoader
29+
* @see #getResource(String)
30+
*/
31+
@Restricted(NoExternalUse.class)
32+
public final class ExistenceCheckingClassLoader extends DelegatingClassLoader {
33+
34+
public ExistenceCheckingClassLoader(String name, ClassLoader parent) {
35+
super(name, Objects.requireNonNull(parent));
36+
}
37+
38+
public ExistenceCheckingClassLoader(ClassLoader parent) {
39+
super(Objects.requireNonNull(parent));
40+
}
41+
42+
/**
43+
* Short-circuits misses by checking for the {@code .class} resource prior to delegation.
44+
* Successful loads behave exactly as the parent would; misses do not touch the parent's
45+
* per-name lock map.
46+
*/
47+
@Override
48+
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
49+
// Add support for loading of JaCoCo dynamic instrumentation classes
50+
if (name.equals("java.lang.$JaCoCo")) {
51+
return super.loadClass(name, resolve);
52+
}
53+
54+
if (getResource(name.replace('.', '/') + ".class") == null) {
55+
throw new ClassNotFoundException(name);
56+
}
57+
58+
return super.loadClass(name, resolve);
59+
}
60+
}

core/src/main/java/hudson/util/MaskingClassLoader.java

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,14 @@
4242
*
4343
* @author Kohsuke Kawaguchi
4444
*/
45-
public class MaskingClassLoader extends ClassLoader {
45+
public class MaskingClassLoader extends DelegatingClassLoader {
4646
/**
4747
* Prefix of the packages that should be hidden.
4848
*/
4949
private final List<String> masksClasses;
5050

5151
private final List<String> masksResources;
5252

53-
static {
54-
registerAsParallelCapable();
55-
}
56-
5753
public MaskingClassLoader(ClassLoader parent, String... masks) {
5854
this(parent, Arrays.asList(masks));
5955
}

core/src/main/java/jenkins/util/URLClassLoader2.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import java.net.URL;
44
import java.net.URLClassLoader;
5+
import java.util.Objects;
6+
import java.util.concurrent.atomic.AtomicInteger;
57
import jenkins.ClassLoaderReflectionToolkit;
68
import org.kohsuke.accmod.Restricted;
79
import org.kohsuke.accmod.restrictions.NoExternalUse;
@@ -12,6 +14,10 @@
1214
*/
1315
@Restricted(NoExternalUse.class)
1416
public class URLClassLoader2 extends URLClassLoader implements JenkinsClassLoader {
17+
private static final AtomicInteger NEXT_INSTANCE_NUMBER = new AtomicInteger(0);
18+
19+
private final String lockObjectPrefixName = String.format(
20+
"%s@%x-loadClassLock:", URLClassLoader2.class.getName(), NEXT_INSTANCE_NUMBER.getAndIncrement());
1521

1622
static {
1723
registerAsParallelCapable();
@@ -69,8 +75,25 @@ public Class<?> findLoadedClass2(String name) {
6975
return super.findLoadedClass(name);
7076
}
7177

78+
/**
79+
* Replace the JDK's per-name lock map with a GC-collectable lock object. This is a workaround
80+
* for JDK-8005233. When JDK-8005233 is resolved, this should be deleted. See also the
81+
* discussion in <a
82+
* href="https://mail.openjdk.org/pipermail/core-libs-dev/2025-May/146392.html">this OpenJDK
83+
* thread</a>.
84+
*
85+
* <p>Parallel-capable {@link ClassLoader} implementations keep a distinct lock object per class
86+
* name indefinitely, which can retain huge maps when there are many misses. Returning an
87+
* interned {@link String} keyed by this loader and the class name preserves mutual exclusion
88+
* for a given (loader, name) pair but allows the JVM to reclaim the lock when no longer
89+
* referenced. Interned Strings are heap objects and GC-eligible on modern JDKs (7+).
90+
*
91+
* @param className the binary name of the class being loaded (must not be null)
92+
* @return a lock object unique to this classloader/class pair
93+
*/
7294
@Override
7395
public Object getClassLoadingLock(String className) {
74-
return super.getClassLoadingLock(className);
96+
Objects.requireNonNull(className);
97+
return (lockObjectPrefixName + className).intern();
7598
}
7699
}

0 commit comments

Comments
 (0)