Gradle Plugin: Classloader caching
Mar 19, 2020
2 minute read

Why you may want to cache classloaders

Before 1.7.0 the detekt Gradle plugin used the javaexec method to execute a detekt run.
This was practical as it started a new JVM and created an isolated environment.
However starting a new process for every sub module lead to a JVM start time overhead.
This is especially inconvenient performance-wise concerning large multi-project Gradle builds.
It also appears probable that processing a 40mb load of libraries all over again comes along with a start time penalty.

Reflection plus Classloader caching to the rescue

Instead of calling your application via javaexec you may also use reflection. This may save us ~200ms when starting a JVM.

Unfortunately reflection alone however lead to metaspace OOM problems due to the size of the detekt-cli jar if executing the detekt Gradle task in parallel with org.gradle.parallel=true enabled. Even closing the classloader which was used for reflection didn’t help here as the garbage collector was slower than Gradle opening more and more 40+ mb classloaders.

Opening and closing so many classloaders felt like a waste. After some hours of try-and-error and googling I found a GitHub discussion where someone from Gradle proposed caching the class loader (https://github.com/diffplug/spotless/issues/61).

This is the class loader cache I came up for detekt in the end:

package io.gitlab.arturbosch.detekt.internal

import org.codehaus.groovy.runtime.DefaultGroovyMethodsSupport
import org.gradle.api.file.FileCollection
import java.net.URLClassLoader

// It's important that the cache class is a singleton so it lives as long
// as a Gradle daemon lives.
object ClassLoaderCache {

    private var loaderAndClasspath: Pair<URLClassLoader, FileCollection>? = null

    // Gradle tasks can be run in parallel therefore we synchronize this function
    fun getOrCreate(classpath: FileCollection): URLClassLoader = synchronized(ClassLoaderCache) {
        val lastLoader = loaderAndClasspath?.first
        val lastClasspath = loaderAndClasspath?.second

        if (lastClasspath == null) {
            cache(classpath)
        } else {
            // This is the central part of the caching.
            // Whenever the classpath changes, we need to update our cache.
            if (!lastClasspath.minus(classpath).isEmpty) {
                // Do not forget to close the old classloader to avoid memory leaks!!!
                DefaultGroovyMethodsSupport.closeQuietly(lastLoader)
                cache(classpath)
            }
        }

        return loaderAndClasspath?.first ?: error("Cached or newly created detekt classloader expected.")
    }

    // detekt is configured dynamically and uses Kotlin and it's embeddable compiler.
    // We do not want to somehow use Gradle's versions of these jars.
    private fun cache(classpath: FileCollection) {
        loaderAndClasspath = URLClassLoader(
            classpath.map { it.toURI().toURL() }.toTypedArray(),
            null /* isolate detekt environment */
        ) to classpath
    }
}