前言:本文讨论的核心问题是类加载隔离、类加载隔离在Spring Boot和TomCat中的应用,以及为什么要这样用,要搞明白这些问题首先需要知道jar包是如何组织依赖的。
目录
1.jar包规范
2.如何组织依赖
2.1.概述
2.2.以类的方式组织
2.2.1.maven打包
2.2.2.原生命令打包
2.3.以jar的方式组织
2.3.1.概述
2.3.2.JVM怎么加载jar
2.3.3.JVM能加载哪些jar
3.Spring Boot的类加载
3.1.Spring Boot项目的jar包结构
3.2.Spring Boot项目的打包机制
3.3.源码分析
4.类加载隔离
4.1.tomcat中的类加载隔离
4.2.代码实现一个类加载隔离
JVM(Java虚拟机)规范并没有对JAR(Java Archive)的规范进行详细说明,但是JVM定义了JAR的基本结构,jar包必须遵守这个基本结构才能被JVM正确的识别、加载。
根据JVM规范,JAR文件是一种ZIP格式的归档文件,可以用标准的ZIP工具进行解压和压缩。JAR文件中包含了Java类、资源文件和META-INF目录,其中META-INF目录下的MANIFEST.MF文件是必须的,该文件描述了JAR文件的信息,例如JAR文件的版本号、依赖库信息、启动类等。
如果需要业务jar单独运行起来,肯定就需要将三方依赖一并打入jar包,否则会出现ClassNotFound异常。
这些被打入业务jar的依赖有哪些组织形式喃?想想就能想明白,一共两种:
1.以类的方式组织:
将三方依赖jar解压出来,将其中的所有类和我们自己写的类放在一起,比如这样:
2.以jar的方式组织:
把三方依赖jar直接放进依赖它的业务jar中,让三方依赖jar在业务jar之前被JVM加载解析也是可以的, 通过配置业务jar的MANIFEST.MF的Class-Path可以做到。
maven打包插件,打包的结果就是以类的方式在业务jar内部组织的依赖。下面我们来看看:
举一个例子,项目依赖了外部依赖——log4j,使用maven插件将依赖打包入jar。
打包插件配置:
org.apache.maven.plugins
maven-compiler-plugin
3.8.1
1.8
org.apache.maven.plugins
maven-assembly-plugin
3.3.0
com.eryi.ExampleClass
jar-with-dependencies
true
make-assembly
package
single
打包结果:
可以看到maven打包插件就是将依赖jar解压开来的目录一起打包入业务jar中。
我们可以用原生JAVA命令来实现上面maven打包的效果,从而来理解一下,以类在业务jar内部组织依赖在打包层面要如何实现。
项目结构:
1.编译
编译.java,由于Main.java是依赖了log4j的,所以在编译的时候要通过-cp参数来跟上依赖项。再通过-d 参数将编译后的.class输出到指定目录。
javac -cp ../lib/log4j-1.2.17.jar Main.java -d ../target
编译结果:
2.解压依赖
jar -xvf log4j-1.2.17.jar
解压结果:
3.打包
用命令生成MANIFEST.MF文件、将依赖方便放到jar的不同路径下:
jar cfm myjar.jar ../meta/MANIFEST.MF -C D:\IDEAWorkSpace\E6TestProject\JavaProject\src\com\eryi\target\ com\eryi\classes -C D:\IDEAWorkSpace\E6TestProject\JavaProject\src\com\eryi\lib org\apache\log4j -C D:\IDEAWorkSpace\E6TestProject\JavaProject\src\com\eryi\resources \
打包结果:
把三方依赖jar直接放进依赖它的业务jar中,让三方依赖jar在业务jar之前被JVM加载解析也是可以的, 通过配置业务jar的MANIFEST.MF的Class-Path可以做到。
聊以jar的方式组织依赖前首先我们要搞明白两个问题:
JVM怎么加载jar?
JVM能加载哪些jar?
我们老是说JVM类加载,老师会忽略掉一层,就是类是装在jar里面的,在JVM类记载之前一定是JVM对于jar的加载。
JVM(Java虚拟机)加载jar的过程如下:
定位JAR文件:JVM需要知道JAR文件的位置。可以通过命令行参数或CLASSPATH环境变量来指定JAR文件的路径。
验证JAR文件:JVM需要验证JAR文件的格式是否正确,以及JAR文件中的所有类是否有正确的访问权限。
解压JAR文件:JVM需要将JAR文件解压缩到内存中。解压缩后,JVM才可以访问JAR文件中的所有文件和类,才能有后续的类加载。
将CLASSPATH环境变量中的路径中的所有jar文件都加载进来。
如果在命令行上使用了-cp或-classpath选项指定了类路径,则将指定的路径中的所有jar文件都加载进来。
CLASSPATH其实就是JAVA_HOME这个环境变量,加载其下的jar就是加载JRE。所以JVM默认是只能加载到JRE中的jar,如果直接以jar的方式组织依赖,依赖jar无法被JVM自动加载,不加载依赖jar,自然就会出现ClassNotFound。因此如果要以jar的方式在业务jar中组织依赖,需要自己实现一些类加载机制,将依赖jar在业务起来之前加载进JVM。Spring Boot就是这样干的。
首先我们打一个spring boot项目的包出来,项目结构很简单:
一个fastjson的外部依赖,一个controller、一个service、一个配置文件,使用Spring Boot专用的打包工具打包。
打出来的jar包结构如下:
classes,存放所有我们自己编写的类、配置文件
lib,存放所有外部依赖。
META-INF,存放jar包的元信息,jar包的描述、maven依赖描述等等。
org.springframework.boot.loader,这个路径很重要,存放Spring Boot自定义的类加载器。
通过上面的打包,我们可以发现Spring Boot是以jar包来组织外部依赖。这个其实很容易想明白,Spring Boot有很多依赖,如果直接以类的方式在业务jar内部组织依赖,很显然业务jar的目录会变得很乱,所以Spring Boot是以jar的方式在业务jar内部组织依赖的。
然后回看本文在章节2讲的在业务jar种以jar包组织依赖的相关注意事项,尤其是2.3.3.JVM能加载哪些jar这一小节种标红加粗的部分。以jar包的方式组织依赖,就意味着要自己解决类加载问题,所以Spring Boot自己实现一套类加载器,这个套类加载器放在org.springframework.boot.loader目录下。
Spring Boot自定义这套类加载器还破了双亲委派机制,打破双亲委派机制的目的是为了类加载隔离,因为Spring Boot自动装配的存在会自动装载很多依赖进去,业务中添加的依赖难免会和Spring Boot自动装载的依赖产生版本冲突,类加载隔离可以保证依赖的作用范围,解决这种不同版本间的冲突。
首先,Spring Boot启动,首先启动的不是我们在启动类里手写的Main方法,而是JarLauncher的main方法:
JarLauncher的main方法中会new一个自己,而JarLauncher继承自ExecutableArchiveLauncher,所以会先调用到ExecutableArchiveLauncher的构造方法:
public class JarLauncher extends ExecutableArchiveLauncher {
......
public static void main(String[] args) throws Exception {
(new JarLauncher()).launch(args);
}
}
ExecutableArchiveLauncher在被实例化的时候就会去获取当前业务jar以及依赖jar所在的位置:
public abstract class ExecutableArchiveLauncher extends Launcher {
private static final String START_CLASS_ATTRIBUTE = "Start-Class";
protected static final String BOOT_CLASSPATH_INDEX_ATTRIBUTE = "Spring-Boot-Classpath-Index";
private final Archive archive;
private final ClassPathIndexFile classPathIndex;
public ExecutableArchiveLauncher() {
try {
//获取归档文件,即当前jar
this.archive = this.createArchive();
this.classPathIndex = this.getClassPathIndex(this.archive);
} catch (Exception var2) {
throw new IllegalStateException(var2);
}
}
protected ExecutableArchiveLauncher(Archive archive) {
try {
this.archive = archive;
this.classPathIndex = this.getClassPathIndex(this.archive);
} catch (Exception var3) {
throw new IllegalStateException(var3);
}
}
protected ClassPathIndexFile getClassPathIndex(Archive archive) throws IOException {
return null;
}
protected String getMainClass() throws Exception {
Manifest manifest = this.archive.getManifest();
String mainClass = null;
if (manifest != null) {
mainClass = manifest.getMainAttributes().getValue("Start-Class");
}
if (mainClass == null) {
throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this);
} else {
return mainClass;
}
}
protected ClassLoader createClassLoader(Iterator archives) throws Exception {
ArrayList urls = new ArrayList(this.guessClassPathSize());
while(archives.hasNext()) {
urls.add(((Archive)archives.next()).getUrl());
}
if (this.classPathIndex != null) {
urls.addAll(this.classPathIndex.getUrls());
}
return this.createClassLoader((URL[])urls.toArray(new URL[0]));
}
private int guessClassPathSize() {
return this.classPathIndex != null ? this.classPathIndex.size() + 10 : 50;
}
该方法返回项目归档文件中符合过滤器的归档文件
protected Iterator getClassPathArchivesIterator() throws Exception {
EntryFilter searchFilter = this::isSearchCandidate;
//通过JarLauncher中的isNestedArchive来判断归档是否符合要求
//将符合要求的归档文件放置于集合中
//对于打包成jar格式的项目,符合要求的就是classes和lib下的文件
Iterator archives = this.archive.getNestedArchives(searchFilter, (entry) -> {
return this.isNestedArchive(entry) && !this.isEntryIndexed(entry);
});
if (this.isPostProcessingClassPathArchives()) {
archives = this.applyClassPathArchivePostProcessing(archives);
}
return archives;
}
private boolean isEntryIndexed(Entry entry) {
return this.classPathIndex != null ? this.classPathIndex.containsEntry(entry.getName()) : false;
}
private Iterator applyClassPathArchivePostProcessing(Iterator archives) throws Exception {
ArrayList list = new ArrayList();
while(archives.hasNext()) {
list.add(archives.next());
}
this.postProcessClassPathArchives(list);
return list.iterator();
}
protected boolean isSearchCandidate(Entry entry) {
return true;
}
protected abstract boolean isNestedArchive(Entry entry);
protected boolean isPostProcessingClassPathArchives() {
return true;
}
protected void postProcessClassPathArchives(List archives) throws Exception {
}
protected boolean isExploded() {
return this.archive.isExploded();
}
protected final Archive getArchive() {
return this.archive;
}
}
然后JarLauncher调用lunch方法其实是调用的ExecutableArchiveLauncher的父类Launcher的lunch方法,最终会替换掉上下文的加载器,并加载依赖jar:
public abstract class Launcher {
private static final String JAR_MODE_LAUNCHER = "org.springframework.boot.loader.jarmode.JarModeLauncher";
public Launcher() {
}
protected void launch(String[] args) throws Exception {
if (!this.isExploded()) {
JarFile.registerUrlProtocolHandler();
}
//创建一个新的class loader用来加载符合要求的路径下的文件
//如果当前打包出来的归档文件是jar,那么就是加载当前jar的lib和classes两个路径下的内容
ClassLoader classLoader = this.createClassLoader(this.getClassPathArchivesIterator());
String jarMode = System.getProperty("jarmode");
String launchClass = jarMode != null && !jarMode.isEmpty() ? "org.springframework.boot.loader.jarmode.JarModeLauncher" : this.getMainClass();
this.launch(args, launchClass, classLoader);
}
/** @deprecated */
@Deprecated
protected ClassLoader createClassLoader(List archives) throws Exception {
return this.createClassLoader(archives.iterator());
}
protected ClassLoader createClassLoader(Iterator archives) throws Exception {
ArrayList urls = new ArrayList(50);
while(archives.hasNext()) {
urls.add(((Archive)archives.next()).getUrl());
}
return this.createClassLoader((URL[])urls.toArray(new URL[0]));
}
protected ClassLoader createClassLoader(URL[] urls) throws Exception {
return new LaunchedURLClassLoader(this.isExploded(), this.getArchive(), urls, this.getClass().getClassLoader());
}
protected void launch(String[] args, String launchClass, ClassLoader classLoader) throws Exception {
//将当前线程的class loader换成刚刚新创建的类加载器,从而以供后面进行归档文件的类的加载
Thread.currentThread().setContextClassLoader(classLoader);
//真正执行sprngboot应用的main方法的类,上面传入线程的上下文类加载器也会在这个里面使用到
this.createMainMethodRunner(launchClass, args, classLoader).run();
}
protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) {
return new MainMethodRunner(mainClass, args);
}
protected abstract String getMainClass() throws Exception;
protected Iterator getClassPathArchivesIterator() throws Exception {
return this.getClassPathArchives().iterator();
}
/** @deprecated */
@Deprecated
protected List getClassPathArchives() throws Exception {
throw new IllegalStateException("Unexpected call to getClassPathArchives()");
}
protected final Archive createArchive() throws Exception {
ProtectionDomain protectionDomain = this.getClass().getProtectionDomain();
CodeSource codeSource = protectionDomain.getCodeSource();
URI location = codeSource != null ? codeSource.getLocation().toURI() : null;
String path = location != null ? location.getSchemeSpecificPart() : null;
if (path == null) {
throw new IllegalStateException("Unable to determine code source archive");
} else {
File root = new File(path);
if (!root.exists()) {
throw new IllegalStateException("Unable to determine code source archive from " + root);
} else {
return (Archive)(root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root));
}
}
}
protected boolean isExploded() {
return false;
}
protected Archive getArchive() {
return null;
}
}
类加载隔离,其本目的是隔绝不同版本依赖之间的干扰,最著名的应用就是——TomCat的类加载隔离机制。
tomcat实现类加载机制是必要的,首先因为tomcat自身是使用JAVA编写的,其本身就会依赖一些外部依赖,这些依赖会放在tomcat的lib目录下(这些依赖在内部还打包有一些其他的外部依赖):
其次tomcat作为一款web容器,其上会部署很多个项目,不同项目用到的依赖版本可能不同,应用A使用的是1.0版本的外部依赖,应用B使用的是2.0的版本依赖,如果不将他们各自的依赖隔离开来而是直接使用JAVA原生的遵循双亲委派机制的这一套类加载器,是无法控制使用到的依赖版本的,无法控制当前加载到的到底是1.0版本的依赖还是2.0版本的依赖,很可能出现A用到2.0版本的依赖,B用到1.0版本的依赖。不同版本的依赖在API等方面很可能存在很大差异,很容易出现错误。
综上所述TomCat实现类加载隔离机制是十分必要的,Tomcat打破了双亲委派机制,自定义了一套类加载器,使得整个Tomcat的类加载体系如下:
common classloader:
加载公共类,加载的类既可以被tomca访问,也可以被tomcat中部署的应用访问。
Catalina classloader:
tomcat私有的类加载器,被加载的类只能被tomcat访问到。
Shared classloader:
应用共享的类加载器,被加载的类能被tomcat中部署的所有应用访问到,但是tomcat访问不到
web application classloader:
每个应用私有的类加载器,被加载的类只能被当前应用访问到,其他应用和tomcat都访问不到。
类加载隔离的实现原理很简单,就是利用了不同类加载器加载到的类会被判断为不同的类的这一机制,然后不遵循双亲委派机制,首先自己去加载,自己加载不到再找父加载器拿。以下是个假单的demo实现:
自定义类加载器:
import java.io.IOException;
import java.io.InputStream;
public class CommonClassLoader extends ClassLoader {
@Override
public Class> loadClass(String name) throws ClassNotFoundException {
if (name.startsWith("com.eryi")) { // 加载com.example包下的类
return findClass(name);
}
return super.loadClass(name);
}
@Override
protected Class> findClass(String name) throws ClassNotFoundException {
String classFilePath = name.replace(".", "/") + ".class";
try {
InputStream is = getClass().getResourceAsStream("/" + classFilePath);
if (is == null) {
throw new ClassNotFoundException(name);
}
byte[] data = new byte[is.available()];
is.read(data);
return defineClass(name, data, 0, data.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}
测试:
//当前类加载器(AppClassLoader)加载的类
Hello hello_01=new Hello();
//自定义类加载器加载的类
Thread.currentThread().setContextClassLoader(new CommonClassLoader());
Class clazz=Thread.currentThread().getContextClassLoader().loadClass("com.eryi.Hello");
System.out.println("hello_01的类加载器:"+hello_01.getClass().getClassLoader());
System.out.println("clazz的类加载器:"+clazz.getClassLoader());
System.out.println("两者是否是同一个Class:"+(clazz==hello_01.getClass()));