Spring Boot Classloader and Class Overriding
Want to temporarily override library classes with your own custom ones, but Spring Boot's classloader is making it tough? Here is one solution.
Join the DZone community and get the full member experience.
Join For Freethis article explains the spring boot classloader ( launchedurlclassloader ) and a way to temporarily override library classes with your custom ones.
just a little fix
let’s say you found a bug in some third-party jar your app uses. as a good scout, you fixed it and created a pull request with a solution. the pull request was merged, but the fix is critical for you and you can’t wait till next library release. is using a library snapshot the only way? wouldn’t it be great if there was a solution to temporarily override only a few particular classes?
as an imaginary example ( follow the code ), let’s say you found a bug in the springbootbanner class and already have a solution to fix the banner’s colors — springbootbanner fixed .
( i know we can easily define custom banners in spring boot, it’s just a useful example — it will be super easy to spot if the ‘fix’ is working or not )
so what can we do to have the solution work immediately? let’s just take the class (with the package) and paste it into our project ( src/main/java ).
now let’s run the app from the ide and everything seems to work:
great! but the joy is premature… if you build the app and run it:
./gradlew build
cd build/libs
java -jar spring-boot-loader-play-0.0.1-snapshot.jar
the original banner is still being displayed, and this is not about a terminal not supporting ansi colors.
the banner class ( springbootbanner ) was simply not overridden.
the difference is that when you launch the app from an ide, you have two kinds of artifacts: classes and jars. classes are loaded before jars, so even though you have two versions of a class (your fix in /src/main/java and original in spring-boot-2.0.0.m7.jar lib), only the fix will be loaded. (classloaders don’t care about duplicates — the class that is found first is loaded).
spring boot classloader
with jars, the situation is harder. it’s a spring boot fat jar with the structure below:
+--- spring-boot-loader-play-0.0.1-snapshot.jar
+--- meta-inf
+--- boot-inf
| +--- classes # 1 - project classes
| | +--- org.springframework.boot
| | | \--- springbootbanner.class # this is our fix
| | |
| | +--- pl.dk.loaderplay
| | \--- springbootloaderapplication.class
| |
| +--- lib # 2 - nested jar libraries
| +--- javax.annotation-api-1.3.1
| +--- spring-boot-2.0.0.m7.jar # original banner class inside
| \--- (...)
|
+--- org.springframework.boot.loader # spring boot loader classes
+--- jarlauncher.class
+--- launchedurlclassloader.class
\--- (...)
so actually, it contains three types of entries:
- project classes
- nested jar libraries
- spring boot loader classes
both project classes ( boot-inf/classes ) and nested jars ( boot-inf/lib ) are handled by the same classloader, which, in turn, resides in the root of the jar ( org.springframework.boot.loader.launchedurlclassloader ).
one might expect that launchedurlclassloader will load the class content before the lib content, but the loader seems not the have that preference.
launchedurlclassloader extends java.net.urlclassloader , which is created with a set of urls that will be used for classloading. the url might point to a location like a jar archive or classes folder. when classloading, all of the resources specified by urls will be traversed in the order the urls were provided, and the first resource containing the searched class will be used.
so how are the urls are provided to launchedurlclassloader? the jar archive is parsed from top to bottom, and when an archive is found, it’s added to the url list.
in our example:
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/spring-boot-starter-2.0.0.m7.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/spring-boot-autoconfigure-2.0.0.m7.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/spring-boot-2.0.0.m7.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/spring-boot-starter-logging-2.0.0.m7.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/javax.annotation-api-1.3.1.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/spring-context-5.0.2.release.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/spring-aop-5.0.2.release.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/spring-beans-5.0.2.release.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/spring-expression-5.0.2.release.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/spring-core-5.0.2.release.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/snakeyaml-1.19.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/logback-classic-1.2.3.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/log4j-to-slf4j-2.10.0.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/jul-to-slf4j-1.7.25.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/spring-jcl-5.0.2.release.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/logback-core-1.2.3.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/slf4j-api-1.7.25.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/lib/log4j-api-2.10.0.jar!/"
"jar:file:/users/dk/spring-boot-loader-play/build/libs/spring-boot-loader-play-0.0.1-snapshot.jar!/boot-inf/classes!/"
as we can see, /boot-inf/classes is the last entry on the list — far after /boot-inf/lib/spring-boot-2.0.0.m7.jar . so, in the search for springbootbanner.class, the version from the latter will be used — not an outcome we hoped for.
on our quest to figure out what we can do about it, it’s worth zooming into how classloaders work in hierarchies.
basically, classloaders form hierarchies — with every child loader having a reference to its parent. with launchedurlclassloader being the youngest descendant in spring boot's case, we end up with a simple hierarchy like this:
+--- sun.misc.launcher$extclassloader # loading classes /jre/lib/ext/
+--- sun.misc.launcher.launcher$appclassloader # loading classes from the root of the jar - spring-boot-loader-play-0.0.1-snapshot.jar
+--- org.springframework.boot.loader.launchedurlclassloader # loading classes from /boot-inf/lib/ & /boot-inf/classes/
in spring boot, when the class is about to be loaded, we always start with launchedurlclassloader, but the “parent first” rule applies. this means that the child loader will try to load a given class only if the parent doesn’t find it.
first idea: appclassloader
if launchedurlclassloader delegates classloading to appclassloader, then why not to use it to load our class before it’s loaded by launchedurlclassloader ?
you might be tempted to simply do:
thread.currentthread().getcontextclassloader()
.getparent()
.loadclass("org.springframework.boot.springbootbanner");
but this won’t work. yes, thread.currentthread().getcontextclassloader().getparent() gets us the correct appclassloader, but this one is designed to work with standard jars — where classes (with packages) are placed in the root of the jar. so where appclassloader has no problems handling the org.springframework.boot.loader classes (see the jar directory tree above), it will not find classes in boot-inf/classes .
meanwhile...
thread.currentthread().getcontextclassloader()
.getparent()
.loadclass("boot-inf/classes/org/springframework/boot/springbootbanner");
...won’t work either. yes, the class will be found, but the package will not match the path.
it appears there is no other way than copying springbootbanner from boot-inf/classes/org/ into the root of the jar. if we do that, there is no need to call appclassloader directly to load the class, as it will always have precedence before launchedurlclassloader .
this is easily done with this gradle build:
bootjar {
with copyspec {
from "$builddir/classes/java/main/org"
into 'org'
}
}
we can launch the app and find that
springbootbanner
was copied to the jar root and
appclassloader
was used to load it, but it won’t work. the problem is that
springbootbanner
depends on other classes — loaded by the child
launchedurlclassloader
. one thing we forgot about classloader hierarchy is that classes loaded by parents don’t see classes loaded by children.
the “load by appclassloader” idea seems to be a dead end — but worry not. we will use that knowledge with our second attempt!
launchedurlclassloader: resource order
it appears that parent loaders are not an option and we are stuck with the last loader in the hierarchy — launchedurlclassloader . you might remember that launchedurlclassloader loads classes traversing nested resources in the order they were provided to it. so let’s try to manipulate the order so that the /boot-inf/classes/ resource is first — not last on the list.
with org.springframework.boot.loader.jarlauncher , this seems to be an easy task, as it provides:
protected void postprocessclasspatharchives(list<archive> archives)
this method manipulates archives just before they are given to launchedurlclassloader .
so let’s write a custom launcher using this functionality:
public class classesfirstjarlauncher extends jarlauncher {
@override
protected void postprocessclasspatharchives(list<archive> archives)
throws malformedurlexception {
for (int i = archives.size() - 1; i >= 0; i--) {
archive archive = archives.get(i);
if (archive.geturl().getpath().endswith("/classes!/")) {
archives.remove(archive);
archives.add(0, archive);
break;
}
}
}
public static void main(string[] args) throws exception {
new classesfirstjarlauncher().launch(args);
}
}
a quick reminder is that jarlauncher is the class launching your spring boot app. check any spring boot manifest.mf, and you will find something like:
manifest-version: 1.0
start-class: pl.dk.loaderplay.springbootloaderapplication
main-class: org.springframework.boot.loader.jarlauncher
main-class being the class with the main method launched when we do:
java -jar spring-boot-loader-play-0.0.1-snapshot.jar
jarlauncher must be loaded by appclassloader ( launchedurlclassloader is not even loaded itself yet), and to do that, it must be placed in the root of the jar. let’s use the trick we learned before:
bootjar {
with copyspec {
from "$builddir/classes/java/main/pl/dk/loaderplay/classesfirstjarlauncher.class"
into 'pl/dk/loaderplay'
}
}
what remained is to replace main-class in our manifest.mf . spring boot gradle plugin provides a way to do it:
bootjar {
manifest {
attributes 'main-class': 'pl.dk.loaderplay.classesfirstjarlauncher'
}
}
unfortunately, when replacing main-class , the original spring boot loader/launcher classes are not copied to the root of the jar — and we still need them. this is how spring boot gradle plugin works, and i have not found a way around it. (it happens because the plugin’s bootzipcopyaction decision whether or not to copy the loader files is based upon whether the original jarlauncher was used or not).
so changing main-class by bootjar configuration is of no use to us. one can try to change it in some other way. for me, it was enough to leave the original main-class in the manifesto and simply specify start class when launching the app.
java -cp spring-boot-loader-play-0.0.1-snapshot.jar \
pl.dk.loaderplay.classesfirstjarlauncher
when doing so, the class was finally overridden:
quick summary
to summarize shortly:
goal
override...
spring-boot-loader-play-0.0.1-snapshot.jar
/boot-inf/lib/spring-boot-2.0.0.m7.jar/org/springframework/boot/springbootbanner.class
...with:
spring-boot-loader-play-0.0.1-snapshot.jar
/boot-inf/classes/org/springframework/boot/bootspringbootbanner.class
steps
- place the overriding springbootbanner in src/main/java
- create a custom launcher ordering the resources from which classes are loaded — classesfirstjarlauncher
- copy the launcher to root of the jar via bootjar gradle task
-
launch the archive specifying the launcher class:
java -cp spring-boot-loader-play-0.0.1-snapshot.jar \ pl.dk.loaderplay.classesfirstjarlauncher
again, you may check the code here .
Published at DZone with permission of Dawid Kublik, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments