Improve legacy launcher

To minimize classloading and consistency between sbt instances launched
with the latest launcher compared to old launchers, I overhauled code
that replaces the app configuration and meta build classloader at
startup. The goals of this change for legacy launchers were:

1) Do not ever load the scala-library.jar from the app provider class loader.
2) Close the class loaders that are below the topLoader in the class
   loading hierarcy

For the new launcher, we simply want to avoid modifying the loader at
all.

I added the SbtParserInit class so that it was more straightforward to
preload the global instance using reflection. We now use reflection to
instantiate an SbtParserInit instance for both the legacy and new
launcher cases to simplify the logic.

After this change, the legacy loader still uses somewhat more metaspace
than the new loader, but the difference seems to be O(10MB), which
should only impact projects that were close their MaxMetaspaceSize to
begin with.

I verified using javap that none of the code in this class uses the
scala standard library which should help metaspace since we don't load
much of the scala standard library until we enter xMainImpl.run.
This commit is contained in:
Ethan Atkins 2019-05-20 10:08:01 -07:00
parent e6d2b32902
commit df628d4f87
4 changed files with 242 additions and 88 deletions

View File

@ -8,7 +8,6 @@
package sbt
import java.io.{ File, IOException }
import java.lang.reflect.InvocationTargetException
import java.net.URI
import java.util.concurrent.atomic.AtomicBoolean
import java.util.{ Locale, Properties }
@ -27,7 +26,6 @@ import sbt.io._
import sbt.io.syntax._
import sbt.util.{ Level, Logger, Show }
import xsbti.compile.CompilerCache
import xsbti.{ AppMain, AppProvider, ComponentProvider, Launcher, ScalaProvider }
import scala.annotation.tailrec
import scala.concurrent.ExecutionContext
@ -35,67 +33,8 @@ import scala.util.control.NonFatal
/** This class is the entry point for sbt. */
final class xMain extends xsbti.AppMain {
def run(configuration: xsbti.AppConfiguration): xsbti.MainResult = {
val modifiedConfiguration = new ModifiedConfiguration(configuration)
val loader = modifiedConfiguration.provider.loader
// No need to memoize the old class loader. It is reset by the launcher anyway.
Thread.currentThread.setContextClassLoader(loader)
val clazz = loader.loadClass("sbt.xMainImpl$")
val instance = clazz.getField("MODULE$").get(null)
val runMethod = clazz.getMethod("run", classOf[xsbti.AppConfiguration])
try {
new Thread("sbt-load-global-instance") {
setDaemon(true)
override def run(): Unit = {
// This preloads the scala.tools.nsc.Global as a performance optimization"
loader.loadClass("sbt.internal.parser.SbtParser$").getField("MODULE$").get(null)
()
}
}.start()
runMethod.invoke(instance, modifiedConfiguration).asInstanceOf[xsbti.MainResult]
} catch {
case e: InvocationTargetException =>
// This propogates xsbti.FullReload to the launcher
throw e.getCause
} finally {
loader match {
case a: AutoCloseable => a.close()
case _ =>
}
}
}
/*
* Replaces the AppProvider.loader method with a new loader that puts the sbt test interface
* jar ahead of the rest of the sbt classpath in the classloading hierarchy.
*/
private class ModifiedConfiguration(val configuration: xsbti.AppConfiguration)
extends xsbti.AppConfiguration {
private[this] val metaLoader: ClassLoader = SbtMetaBuildClassLoader(configuration.provider)
private class ModifiedAppProvider(val appProvider: AppProvider) extends AppProvider {
override def scalaProvider(): ScalaProvider = new ScalaProvider {
val delegate = configuration.provider.scalaProvider
override def launcher(): Launcher = delegate.launcher
override def version(): String = delegate.version
override def loader(): ClassLoader = metaLoader.getParent
override def jars(): Array[File] = delegate.jars
override def libraryJar(): File = delegate.libraryJar
override def compilerJar(): File = delegate.compilerJar
override def app(id: xsbti.ApplicationID): AppProvider = delegate.app(id)
}
override def id(): xsbti.ApplicationID = appProvider.id()
override def loader(): ClassLoader = metaLoader
@deprecated("Implements deprecated api", "1.3.0")
override def mainClass(): Class[_ <: AppMain] = appProvider.mainClass()
override def entryPoint(): Class[_] = appProvider.entryPoint()
override def newMain(): AppMain = appProvider.newMain()
override def mainClasspath(): Array[File] = appProvider.mainClasspath()
override def components(): ComponentProvider = appProvider.components()
}
override def arguments(): Array[String] = configuration.arguments
override def baseDirectory(): File = configuration.baseDirectory
override def provider(): AppProvider = new ModifiedAppProvider(configuration.provider)
}
def run(configuration: xsbti.AppConfiguration): xsbti.MainResult =
new XMainConfiguration().runXMain(configuration)
}
private[sbt] object xMainImpl {
private[sbt] def run(configuration: xsbti.AppConfiguration): xsbti.MainResult =

View File

@ -9,7 +9,7 @@ package sbt
package internal
import java.io.File
import java.net.{ URL, URLClassLoader }
import java.net.URLClassLoader
import sbt.ClassLoaderLayeringStrategy._
import sbt.Keys._
@ -20,7 +20,6 @@ import sbt.internal.util.Attributed
import sbt.internal.util.Attributed.data
import sbt.io.IO
import sbt.librarymanagement.Configurations.{ Runtime, Test }
import xsbti.AppProvider
private[sbt] object ClassLoaders {
private[this] val interfaceLoader = classOf[sbt.testing.Framework].getClassLoader
@ -200,26 +199,3 @@ private[sbt] object ClassLoaders {
}
}
private[sbt] object SbtMetaBuildClassLoader {
def apply(appProvider: AppProvider): ClassLoader = {
val interfaceFilter: URL => Boolean = _.getFile.endsWith("test-interface-1.0.jar")
def urls(jars: Array[File]): Array[URL] = jars.map(_.toURI.toURL)
val (interfaceURL, rest) = urls(appProvider.mainClasspath).partition(interfaceFilter)
val scalaProvider = appProvider.scalaProvider
val interfaceLoader = new URLClassLoader(interfaceURL, scalaProvider.launcher.topLoader) {
override def toString: String = s"SbtTestInterfaceClassLoader(${getURLs.head})"
}
val updatedLibraryLoader = new URLClassLoader(urls(scalaProvider.jars), interfaceLoader) {
override def toString: String = s"ScalaClassLoader(jars = {${getURLs.mkString(", ")}}"
}
new URLClassLoader(rest, updatedLibraryLoader) {
override def toString: String = s"SbtMetaBuildClassLoader"
override def close(): Unit = {
super.close()
updatedLibraryLoader.close()
interfaceLoader.close()
}
}
}
}

View File

@ -0,0 +1,229 @@
package sbt.internal
import java.io.File
import java.lang.reflect.InvocationTargetException
import java.net.{ URL, URLClassLoader }
import java.util.regex.Pattern
import xsbti._
/**
* Generates a new app configuration and invokes xMainImpl.run. For AppConfigurations generated
* by recent launchers, it is unnecessary to modify the original configuration, but configurations
* generated by older launchers need to be modified to place the test interface jar higher in
* the class hierarchy. The methods this object are implemented without using the scala library
* so that we can avoid loading any classes from the old scala provider. Verified as of
* sbt 1.3.0 that there are no references to the scala standard library in any of the methods
* in this file.
*/
private[sbt] class XMainConfiguration {
private def close(classLoader: ClassLoader): Unit = classLoader match {
case a: AutoCloseable => a.close()
case _ =>
}
def runXMain(configuration: xsbti.AppConfiguration): xsbti.MainResult = {
val updatedConfiguration =
if (configuration.provider.scalaProvider.launcher.topLoader.getClass.getCanonicalName
.contains("TestInterfaceLoader")) {
configuration
} else {
makeConfiguration(configuration)
}
val loader = updatedConfiguration.provider.loader
Thread.currentThread.setContextClassLoader(loader)
val clazz = loader.loadClass("sbt.xMainImpl$")
val instance = clazz.getField("MODULE$").get(null)
val runMethod = clazz.getMethod("run", classOf[xsbti.AppConfiguration])
try {
loader.loadClass("sbt.internal.parser.SbtParserInit").getConstructor().newInstance()
runMethod.invoke(instance, updatedConfiguration).asInstanceOf[xsbti.MainResult]
} catch {
case e: InvocationTargetException =>
// This propogates xsbti.FullReload to the launcher
throw e.getCause
}
}
private def makeConfiguration(configuration: xsbti.AppConfiguration): xsbti.AppConfiguration = {
val baseLoader = classOf[XMainConfiguration].getClassLoader
val url = baseLoader.getResource("sbt/internal/XMainConfiguration.class")
val urlArray = new Array[URL](1)
urlArray(0) = new URL(url.getPath.replaceAll("[!][^!]*class", ""))
val topLoader = configuration.provider.scalaProvider.launcher.topLoader
// This loader doesn't have the scala library in it so it's critical that none of the code
// in this file use the scala library.
val modifiedLoader = new URLClassLoader(urlArray, topLoader) {
override def loadClass(name: String, resolve: Boolean): Class[_] = {
if (name.startsWith("sbt.internal.XMainConfiguration")) {
val clazz = findClass(name)
if (resolve) resolveClass(clazz)
clazz
} else {
super.loadClass(name, resolve)
}
}
}
val xMainConfigurationClass = modifiedLoader.loadClass("sbt.internal.XMainConfiguration")
val instance: AnyRef =
xMainConfigurationClass.getConstructor().newInstance().asInstanceOf[AnyRef]
val method = xMainConfigurationClass.getMethod("makeLoader", classOf[AppProvider])
val modifiedConfigurationClass =
modifiedLoader.loadClass("sbt.internal.XMainConfiguration$ModifiedConfiguration")
val loader = method.invoke(instance, configuration.provider).asInstanceOf[ClassLoader]
Thread.currentThread.setContextClassLoader(loader)
val cons = modifiedConfigurationClass.getConstructors()(0)
close(configuration.provider.loader)
val scalaProvider = configuration.provider.scalaProvider
val providerClass = scalaProvider.getClass
val _ = try {
val method = providerClass.getMethod("loaderLibraryOnly")
close(method.invoke(scalaProvider).asInstanceOf[ClassLoader])
1
} catch { case _: NoSuchMethodException => 1 }
close(scalaProvider.loader)
close(configuration.provider.loader)
cons.newInstance(instance, configuration, loader).asInstanceOf[xsbti.AppConfiguration]
}
/*
* Replaces the AppProvider.loader method with a new loader that puts the sbt test interface
* jar ahead of the rest of the sbt classpath in the classloading hierarchy.
*/
private[sbt] class ModifiedConfiguration(
val configuration: xsbti.AppConfiguration,
val metaLoader: ClassLoader
) extends xsbti.AppConfiguration {
private class ModifiedAppProvider(val appProvider: AppProvider) extends AppProvider {
private val delegate = configuration.provider.scalaProvider
object ModifiedScalaProvider extends ScalaProvider {
override def launcher(): Launcher = new Launcher {
private val delegateLauncher = delegate.launcher
private val interfaceLoader = metaLoader.loadClass("sbt.testing.Framework").getClassLoader
override def getScala(version: String): ScalaProvider = getScala(version, "")
override def getScala(version: String, reason: String): ScalaProvider =
getScala(version, reason, "org.scala-lang")
override def getScala(version: String, reason: String, scalaOrg: String): ScalaProvider =
delegateLauncher.getScala(version, reason, scalaOrg)
override def app(id: xsbti.ApplicationID, version: String): AppProvider =
delegateLauncher.app(id, version)
override def topLoader(): ClassLoader = interfaceLoader
override def globalLock(): GlobalLock = delegateLauncher.globalLock()
override def bootDirectory(): File = delegateLauncher.bootDirectory()
override def ivyRepositories(): Array[xsbti.Repository] =
delegateLauncher.ivyRepositories()
override def appRepositories(): Array[xsbti.Repository] =
delegateLauncher.appRepositories()
override def isOverrideRepositories: Boolean = delegateLauncher.isOverrideRepositories
override def ivyHome(): File = delegateLauncher.ivyHome()
override def checksums(): Array[String] = delegateLauncher.checksums()
}
override def version(): String = delegate.version
override def loader(): ClassLoader = metaLoader.getParent
override def jars(): Array[File] = delegate.jars
@deprecated("Implements deprecated api", "1.3.0")
override def libraryJar(): File = delegate.libraryJar
@deprecated("Implements deprecated api", "1.3.0")
override def compilerJar(): File = delegate.compilerJar
override def app(id: xsbti.ApplicationID): AppProvider = delegate.app(id)
def loaderLibraryOnly(): ClassLoader = metaLoader.getParent.getParent
}
override def scalaProvider(): ModifiedScalaProvider.type = ModifiedScalaProvider
override def id(): xsbti.ApplicationID = appProvider.id()
override def loader(): ClassLoader = metaLoader
@deprecated("Implements deprecated api", "1.3.0")
override def mainClass(): Class[_ <: AppMain] = appProvider.mainClass()
override def entryPoint(): Class[_] = appProvider.entryPoint()
override def newMain(): AppMain = appProvider.newMain()
override def mainClasspath(): Array[File] = appProvider.mainClasspath()
override def components(): ComponentProvider = appProvider.components()
}
override def arguments(): Array[String] = configuration.arguments
override def baseDirectory(): File = configuration.baseDirectory
override def provider(): AppProvider = new ModifiedAppProvider(configuration.provider)
}
/**
* Rearrange the classloaders so that test-interface is above the scala library. Implemented
* without using the scala standard library to minimize classloading.
* @param appProvider the appProvider that needs to be modified
* @return a ClassLoader with a URLClassLoader for the test-interface-1.0.jar above the
* scala library.
*/
private[sbt] def makeLoader(appProvider: AppProvider): ClassLoader = {
val pattern = Pattern.compile("test-interface-[0-9.]+\\.jar")
val cp = appProvider.mainClasspath
val interfaceURL = new Array[URL](1)
val rest = new Array[URL](cp.length - 1)
{
var i = 0
var j = 0 // index into rest
while (i < cp.length) {
val file = cp(i)
if (pattern.matcher(file.getName).find()) {
interfaceURL(0) = file.toURI.toURL
} else {
rest(j) = file.toURI.toURL
j += 1
}
i += 1
}
}
val scalaProvider = appProvider.scalaProvider
val topLoader = scalaProvider.launcher.topLoader
class InterfaceLoader extends URLClassLoader(interfaceURL, topLoader) {
override def toString: String = "SbtTestInterfaceClassLoader(" + interfaceURL(0) + ")"
}
val interfaceLoader = new InterfaceLoader
val siJars = scalaProvider.jars
val lib = new Array[URL](1)
val scalaRest = new Array[URL](siJars.length - 1)
{
var i = 0
var j = 0 // index into scalaRest
while (i < siJars.length) {
val file = siJars(i)
if (file.getName.equals("scala-library.jar")) {
lib(0) = file.toURI.toURL
} else {
scalaRest(j) = file.toURI.toURL
j += 1
}
i += 1
}
}
class LibraryLoader extends URLClassLoader(lib, interfaceLoader) {
override def toString: String = "ScalaLibraryLoader( " + lib(0) + ")"
}
val libraryLoader = new LibraryLoader
class FullLoader extends URLClassLoader(scalaRest, libraryLoader) {
private val jarString: String = {
val res = new java.lang.StringBuilder
var i = 0
while (i < scalaRest.length) {
res.append(scalaRest(i).getPath)
res.append(", ")
i += 1
}
res.toString
}
override def toString: String = "ScalaClassLoader(jars = " + jarString + ")"
}
val fullLoader = new FullLoader
class MetaBuildLoader extends URLClassLoader(rest, fullLoader) {
override def toString: String = "SbtMetaBuildClassLoader"
override def close(): Unit = {
super.close()
libraryLoader.close()
fullLoader.close()
interfaceLoader.close()
}
}
new MetaBuildLoader
}
}

View File

@ -163,6 +163,16 @@ private[sbt] object SbtParser {
}
}
private class SbtParserInit {
new Thread("sbt-parser-init-thread") {
setDaemon(true)
start()
override def run(): Unit = {
val _ = SbtParser.defaultGlobalForParser
}
}
}
/**
* This method solely exists to add scaladoc to members in SbtParser which
* are defined using pattern matching.