Add ClassLoaderCache

In order to speed up the start up time of the test and run tasks, I'm
introducing a ClassLoaderCache that can be used to avoid reloading the
classes in the project dependencies (which includes the scala library).
I made the api as minimal as possible so that we can iterate on the
implementation without breaking binary compatibility. This feature will
be gated on a feature flag, so I'm not concerned with the cache class
loaders being useable in every user configuration. Over time, I hope
that the CachedClassLoaders will be a drop in replacement for the
existing one-off class loaders*.

The LayeredClassLoader was adapted from the NativeCopyLoader. The main
difference is that the NativeCopyLoader extracts named shared libraries
into the task temp directory to ensure that the ephemeral libraries are
deleted after each task run. This is a problem if we are caching the
ClassLoader so for LayeredClassLoader I track the native libraries that
are extracted by the loader and I delete them either when the loader is
explicitly closed or in a shutdown hook.

* This of course means that we both must layer the class loaders
appropriately so that the project code is in a layer above the cached
loaders and we must correctly invalidate the cache when the project, or
its dependencies are updated.
This commit is contained in:
Ethan Atkins 2018-11-24 12:01:34 -08:00
parent 5bbda9cf69
commit ef08290ecc
5 changed files with 304 additions and 0 deletions

View File

@ -0,0 +1,98 @@
/*
* sbt
* Copyright 2011 - 2018, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal
import java.io.File
import java.nio.file.Files
import sbt.internal.util.TypeFunctions.Id
import scala.annotation.tailrec
sealed trait ClassLoaderCache
extends Repository[Id, (Seq[File], ClassLoader, Map[String, String], File), ClassLoader]
object ClassLoaderCache {
private type Resources = Map[String, String]
private sealed trait CachedClassLoader extends ClassLoader {
def close(): Unit
}
private sealed trait StableClassLoader extends CachedClassLoader
private sealed trait SnapshotClassLoader extends CachedClassLoader
def apply(maxSize: Int): ClassLoaderCache =
new ClassLoaderCache {
private final def mktmp(tmp: File): File =
if (maxSize > 0) Files.createTempDirectory("sbt-jni").toFile else tmp
private[this] val lruCache =
LRUCache[(JarClassPath, ClassLoader), (JarClassPath, CachedClassLoader)](
maxSize = maxSize,
onExpire =
(_: (JarClassPath, ClassLoader), v: (JarClassPath, CachedClassLoader)) => close(v._2)
)
override def get(info: (Seq[File], ClassLoader, Resources, File)): ClassLoader =
synchronized {
val (paths, parent, resources, tmp) = info
val key @ (keyJCP, _) = (new JarClassPath(paths), parent)
def addLoader(base: Option[StableClassLoader] = None): CachedClassLoader = {
val baseLoader = base.getOrElse {
if (keyJCP.regularJars.isEmpty) new ClassLoader(parent) with StableClassLoader {
override def close(): Unit = parent match {
case s: StableClassLoader => s.close()
case _ => ()
}
override def toString: String = parent.toString
} else
new LayeredClassLoader(keyJCP.regularJars, parent, resources, mktmp(tmp))
with StableClassLoader
}
val loader: CachedClassLoader =
if (keyJCP.snapshotJars.isEmpty) baseLoader
else
new LayeredClassLoader(keyJCP.snapshotJars, baseLoader, resources, mktmp(tmp))
with SnapshotClassLoader
lruCache.put(key, keyJCP -> loader)
loader
}
lruCache.get(key) match {
case Some((jcp, cl)) if keyJCP.strictEquals(jcp) => cl
case Some((_, cl: SnapshotClassLoader)) =>
cl.close()
cl.getParent match {
case p: StableClassLoader => addLoader(Some(p))
case _ => addLoader()
}
case None => addLoader()
}
}
override def close(): Unit = synchronized(lruCache.close())
override def toString: String = {
import PrettyPrint.indent
val cacheElements = lruCache.entries.map {
case ((jcp, parent), (_, l)) =>
s"(\n${indent(jcp, 4)},\n${indent(parent, 4)}\n) =>\n $l"
}
s"ClassLoaderCache(\n size = $maxSize,\n elements =\n${indent(cacheElements, 4)}\n)"
}
// Close the ClassLoader and all of it's closeable parents.
@tailrec
private def close(loader: CachedClassLoader): Unit = {
loader.close()
loader.getParent match {
case c: CachedClassLoader => close(c)
case _ => ()
}
}
}
def empty(newLoader: (Seq[File], ClassLoader, Resources, File) => ClassLoader): ClassLoaderCache =
new ClassLoaderCache {
override def get(key: (Seq[File], ClassLoader, Resources, File)): ClassLoader =
newLoader.tupled(key)
override def close(): Unit = {}
}
}

View File

@ -0,0 +1,55 @@
/*
* sbt
* Copyright 2011 - 2018, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal
import java.io.File
import sbt.io.IO
private[internal] object JarClassPath {
class Snapshot private[internal] (val file: File, val lastModified: Long)
extends Comparable[JarClassPath.Snapshot] {
private[this] val _hash = (file.hashCode * 31) ^ java.lang.Long.valueOf(lastModified).hashCode()
def this(file: File) = this(file, IO.getModifiedTimeOrZero(file))
override def equals(obj: Any): Boolean = obj match {
case that: JarClassPath.Snapshot =>
this.lastModified == that.lastModified && this.file == that.file
case _ => false
}
override def hashCode: Int = _hash
override def compareTo(that: JarClassPath.Snapshot): Int = this.file.compareTo(that.file)
override def toString: String =
"Snapshot(path = " + file + ", lastModified = " + lastModified + ")"
}
}
private[internal] final class JarClassPath(val jars: Seq[File]) {
private[this] def isSnapshot(file: File): Boolean = file.getName contains "-SNAPSHOT"
private val jarSet = jars.toSet
val (snapshotJars, regularJars) = jars.partition(isSnapshot)
private val snapshots = snapshotJars.map(new JarClassPath.Snapshot(_))
override def equals(obj: Any): Boolean = obj match {
case that: JarClassPath => this.jarSet == that.jarSet
case _ => false
}
// The memoization is because codacy isn't smart enough to identify that
// `override lazy val hashCode: Int = jarSet.hashCode` does actually override hashCode and it
// complains that equals and hashCode were not implemented together.
private[this] lazy val _hashCode: Int = jarSet.hashCode
override def hashCode: Int = _hashCode
override def toString: String =
s"JarClassPath(\n jars =\n ${regularJars.mkString(",\n ")}" +
s", snapshots =\n${snapshots.mkString(",\n ")}\n)"
/*
* This is a stricter equality requirement than equals that we can use for cache invalidation.
*/
private[internal] def strictEquals(that: JarClassPath): Boolean = {
this.equals(that) && !this.snapshots.view.zip(that.snapshots).exists { case (l, r) => l != r }
}
}

View File

@ -0,0 +1,68 @@
/*
* sbt
* Copyright 2011 - 2018, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal
import java.io.File
import java.net.URLClassLoader
import java.{ util => jutil }
import scala.collection.JavaConverters._
import sbt.internal.inc.classpath._
import sbt.io.IO
private[sbt] class LayeredClassLoader(
classpath: Seq[File],
parent: ClassLoader,
override protected val resources: Map[String, String],
tempDir: File,
) extends URLClassLoader(classpath.toArray.map(_.toURI.toURL), parent)
with RawResources
with NativeCopyLoader
with AutoCloseable {
private[this] val nativeLibs = new jutil.HashSet[File]().asScala
override protected val config = new NativeCopyConfig(
tempDir,
classpath,
IO.parseClasspath(System.getProperty("java.library.path", ""))
)
override def findLibrary(name: String): String = {
super.findLibrary(name) match {
case null => null
case l =>
nativeLibs += new File(l)
l
}
}
override def close(): Unit = nativeLibs.foreach(NativeLibs.delete)
override def toString: String = s"""LayeredClassLoader(
| classpath =
| ${classpath mkString "\n "}
| parent =
| ${parent.toString.lines.mkString("\n ")}
|)""".stripMargin
}
private[internal] object NativeLibs {
private[this] val nativeLibs = new jutil.HashSet[File].asScala
Runtime.getRuntime.addShutdownHook(new Thread("sbt.internal.native-library-deletion") {
override def run(): Unit = {
nativeLibs.foreach(IO.delete)
IO.deleteIfEmpty(nativeLibs.map(_.getParentFile).toSet)
nativeLibs.clear()
}
})
def addNativeLib(lib: String): Unit = {
nativeLibs.add(new File(lib))
()
}
def delete(file: File): Unit = {
nativeLibs.remove(file)
file.delete()
()
}
}

View File

@ -0,0 +1,15 @@
/*
* sbt
* Copyright 2011 - 2018, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal
private[sbt] object PrettyPrint {
private[sbt] def indent(any: Any, level: Int): String = {
val i = " " * level
any.toString.lines.mkString(i, "\n" + i, "")
}
}

View File

@ -0,0 +1,68 @@
/*
* sbt
* Copyright 2011 - 2018, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal
import java.io.File
import java.nio.file.Files
import org.scalatest.{ FlatSpec, Matchers }
import sbt.io.IO
object ClassLoaderCacheTest {
private val initLoader = this.getClass.getClassLoader
implicit class CacheOps(val c: ClassLoaderCache) {
def get(classpath: Seq[File]): ClassLoader =
c.get((classpath, initLoader, Map.empty, new File("/dev/null")))
}
}
class ClassLoaderCacheTest extends FlatSpec with Matchers {
import ClassLoaderCacheTest._
def withCache[R](size: Int)(f: CacheOps => R): R = {
val cache = ClassLoaderCache(size)
try f(new CacheOps(cache))
finally cache.close()
}
"ClassLoaderCache.get" should "make a new loader when full" in withCache(0) { cache =>
val classPath = Seq.empty[File]
val firstLoader = cache.get(classPath)
val secondLoader = cache.get(classPath)
assert(firstLoader != secondLoader)
}
it should "not make a new loader when it already exists" in withCache(1) { cache =>
val classPath = Seq.empty[File]
val firstLoader = cache.get(classPath)
val secondLoader = cache.get(classPath)
assert(firstLoader == secondLoader)
}
it should "evict loaders" in withCache(2) { cache =>
val firstClassPath = Seq.empty[File]
val secondClassPath = new File("foo") :: Nil
val thirdClassPath = new File("foo") :: new File("bar") :: Nil
val firstLoader = cache.get(firstClassPath)
val secondLoader = cache.get(secondClassPath)
val thirdLoader = cache.get(thirdClassPath)
assert(cache.get(thirdClassPath) == thirdLoader)
assert(cache.get(secondClassPath) == secondLoader)
assert(cache.get(firstClassPath) != firstLoader)
assert(cache.get(thirdClassPath) != thirdLoader)
}
"Snapshots" should "be invalidated" in IO.withTemporaryDirectory { dir =>
val snapshotJar = Files.createFile(dir.toPath.resolve("foo-SNAPSHOT.jar")).toFile
val regularJar = Files.createFile(dir.toPath.resolve("regular.jar")).toFile
withCache(1) { cache =>
val jarClassPath = snapshotJar :: regularJar :: Nil
val initLoader = cache.get(jarClassPath)
IO.setModifiedTimeOrFalse(snapshotJar, System.currentTimeMillis + 5000L)
val secondLoader = cache.get(jarClassPath)
assert(initLoader != secondLoader)
assert(initLoader.getParent == secondLoader.getParent)
assert(cache.get(jarClassPath) == secondLoader)
assert(cache.get(jarClassPath) != initLoader)
}
}
}