From ef08290eccf45b0701c58453abfb2a07c4577d9b Mon Sep 17 00:00:00 2001 From: Ethan Atkins Date: Sat, 24 Nov 2018 12:01:34 -0800 Subject: [PATCH] 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. --- .../scala/sbt/internal/ClassLoaderCache.scala | 98 +++++++++++++++++++ .../scala/sbt/internal/JarClassPath.scala | 55 +++++++++++ .../sbt/internal/LayeredClassLoader.scala | 68 +++++++++++++ .../main/scala/sbt/internal/PrettyPrint.scala | 15 +++ .../sbt/internal/ClassLoaderCacheTest.scala | 68 +++++++++++++ 5 files changed, 304 insertions(+) create mode 100644 main/src/main/scala/sbt/internal/ClassLoaderCache.scala create mode 100644 main/src/main/scala/sbt/internal/JarClassPath.scala create mode 100644 main/src/main/scala/sbt/internal/LayeredClassLoader.scala create mode 100644 main/src/main/scala/sbt/internal/PrettyPrint.scala create mode 100644 main/src/test/scala/sbt/internal/ClassLoaderCacheTest.scala diff --git a/main/src/main/scala/sbt/internal/ClassLoaderCache.scala b/main/src/main/scala/sbt/internal/ClassLoaderCache.scala new file mode 100644 index 000000000..3a2c3db04 --- /dev/null +++ b/main/src/main/scala/sbt/internal/ClassLoaderCache.scala @@ -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 = {} + } +} diff --git a/main/src/main/scala/sbt/internal/JarClassPath.scala b/main/src/main/scala/sbt/internal/JarClassPath.scala new file mode 100644 index 000000000..93ed22690 --- /dev/null +++ b/main/src/main/scala/sbt/internal/JarClassPath.scala @@ -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 } + } +} diff --git a/main/src/main/scala/sbt/internal/LayeredClassLoader.scala b/main/src/main/scala/sbt/internal/LayeredClassLoader.scala new file mode 100644 index 000000000..20fe9564d --- /dev/null +++ b/main/src/main/scala/sbt/internal/LayeredClassLoader.scala @@ -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() + () + } +} diff --git a/main/src/main/scala/sbt/internal/PrettyPrint.scala b/main/src/main/scala/sbt/internal/PrettyPrint.scala new file mode 100644 index 000000000..a769f4ce9 --- /dev/null +++ b/main/src/main/scala/sbt/internal/PrettyPrint.scala @@ -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, "") + } +} diff --git a/main/src/test/scala/sbt/internal/ClassLoaderCacheTest.scala b/main/src/test/scala/sbt/internal/ClassLoaderCacheTest.scala new file mode 100644 index 000000000..201964666 --- /dev/null +++ b/main/src/test/scala/sbt/internal/ClassLoaderCacheTest.scala @@ -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) + } + } +}