1、背景
有时候本地打包部署到服务器时我们不需要 jar 包带上本地系统包(甚至三方包),因为服务上已经有了这些包,如果打进本地的包会造成包体积比较大,另外会造成服务器上版本冲突的问题。因此打包过程中,注意删除不必要的依赖以及 classpath 内容。咱们先通过 IntelliJ IDEA 打包编译 jar:
2、运行无依赖 jar
直接运行会报 :
Exception in thread "main" java.lang.NoClassDefFoundError: scala/Function0
Caused by: java.lang.ClassNotFoundException: scala.Function0
D:\code\idea\test-scala\out\artifacts\test_scala_jar>java -classpath E:\Program\scala\scala-2.12.4-Windows\lib\scala-library.jar -jar tes
t-scala.jar
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.NoClassDefFoundError: scala/Function0
at java.lang.Class.getDeclaredMethods0(Native Method)
at java.lang.Class.privateGetDeclaredMethods(Class.java:2701)
at java.lang.Class.privateGetMethodRecursive(Class.java:3048)
at java.lang.Class.getMethod0(Class.java:3018)
at java.lang.Class.getMethod(Class.java:1784)
at sun.launcher.LauncherHelper.validateMainClass(LauncherHelper.java:544)
at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:526)
Caused by: java.lang.ClassNotFoundException: scala.Function0
at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:338)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
... 7 more
D:\code\idea\test-scala\out\artifacts\test_scala_jar>
当用java -jar yourJarExe.jar来运行一个经过打包的应用程序的时候,你会发现如何设置-classpath参数应用程序都找不到相应的第三方类,报ClassNotFound错误。其实这和 java class 的类加载机制有关,实际上这是由于当使用-jar参数运行的时候,java VM会屏蔽所有的外部classpath,而只以本身yourJarExe.jar的内部class作为类的寻找范围。参考:the documentation:
When you use this option, the JAR file is the source of all user classes, and other user class path settings are ignored.
3、解决方案
3.1 BootStrap class扩展方案
Java 命令行提供了如何扩展bootStrap 级别class的简单方法.
-Xbootclasspath: 完全取代基本核心的Java class 搜索路径.
不常用,否则要重新写所有Java 核心class
-Xbootclasspath/a: 后缀在核心class搜索路径后面,常用!
-Xbootclasspath/p: 前缀在核心class搜索路径前面,不常用,避免
引起不必要的冲突.
语法如下:
(分隔符与classpath参数类似,unix使用:号, windows使用;号,这里以unix为例)
java -Xbootclasspath/a:/usrhome/thirdlib.jar: -jar yourJarExe.jar
例如上面咱们改写如下就运行正常了:
java -Xbootclasspath/a:E:\Program\scala\scala-2.12.4-Windows\lib\scala-library.jar -jar test-scala.jar
3.2 extend class 扩展方案
Java exten class 存放在{Java_home}\jre\lib\ext目录下。当调用Java时,对扩展class路径的搜索是自动的。总会搜索的。这样,解决的方案就很简单了,将所有要使用的第三方的jar包都复制到ext 目录下。
3.3 User class扩展方案
当使用-jar执行可执行Jar包时, JVM将Jar包所在目录设置为codebase目录, 所有的class搜索都在这个目录下开始. 所以如果使用了其他第三方的jar包, 一个比较可以接受的可配置方案, 就是利用jar包的Manifest扩展机制.
步骤如下:
1.复制 jar
将需要的第三方的jar包,复制在同可执行jar所在的目录或某个子目录下. 比如:jar 包在 /usrhome/yourJarExe.jar 那么你可以把所有jar包复制到/usrhome目录下或/usrhome/lib 等类似的子目录下.
2.修改Manifest 文件
在Manifest.mf文件里加入如下行
Manifest-Version: 1.0
Main-Class: ex.example
Class-Path:classes12.jar lib/thirdlib.jar
其中Manifest-Version表示版本号,一般由IDE工具(如eclipse)自动生成
Main-Class 是jar文件的主类,程序的入口
Class-Path 指定需要的jar,多个jar必须要在一行上,多个jar之间以空格隔开,如果引用的jar在当前目录的子目录下,windows下使用\来分割,linux下用/分割
文件的冒号后面必须要空一个空格,否则会出错
文件的最后一行必须是一个回车换行符,否则也会出错
Class-Path 是可执行jar包运行依赖的关键词.详细内容可以参考 http://java.sun.com/docs/books/tutorial/deployment/jar/downman.html 。要注意的是 Class-Path 只是作为你本地机器的CLASSPATH环境变量的一个缩写,也就是说用这个前缀表示在你的jar包执行机器上所有的CLASSPATH目录下寻找相应的第三方类/类库。你并不能通过 Class-Path 来加载位于你本身的jar包里面(或者网络上)的jar文件。因为从理论上来讲,你的jar发布包不应该再去包含其他的第三方类库(而应该通过使用说明来提醒用户去获取相应的支持类库)。如果由于特殊需要必须把其他的第三方类库(jar, zip, class等)直接打包在你自己的jar包里面一起发布,你就必须通过实现自定义的ClassLoader来按照自己的意图加载这些第三方类库。
3.4 放弃 jar 使用 class 方案
使用-cp在命令行中指定所有内容(包括您的jar):
java -classpath E:\Program\scala\scala-2.12.4-Windows\lib\scala-library.jar;test-scala.jar ex.example
以上4种方法推荐第一种,扩展性好,操作起来也最方便.
另外编写自己的ClassLoader, 来动态载入class, 是更加复杂和高级技术. 限于篇幅,不赘述.有兴趣了解可以去google一下 custom classloader,或者参考我的另一篇日志:让classpath参数走开。
Java的安全机制随不同的JDK版本有不同的变化,会影响很多核心CLASS,比如Thread,所以很多大型商业软件,要求JDK的版本很严格.部分原因也在此.这也要求在发布自己编写的应用时候,不管大小,都要说明开发和测试的JDK版本.
4、背景知识
自JDK 1.2以后,JVM采用了委托(delegate)模式来载入class.采用这种设计的原因可以参考http://java.sun.com/docs/books/tutorial/ext/basics/load.html
归纳来讲:是基于JVM sandbox(沙盒)安装模型上提供应用层的可定制的安全机制.
Java虚拟机(JVM)寻找Class的顺序
4.1 Bootstrap classes
属于Java 平台核心的class,比如java.lang.String等.及rt.jar等重要的核心级别的class.这是由JVM Bootstrap class loader来载入的.一般是放置在{java_home}\jre\lib目录下
4.2 Extension classes
基于Java扩展机制,用来扩展Java核心功能模块.比如Java串口通讯模块comm.jar.一般放置在{Java_home}\jre\lib\ext目录下
4.3 User classes
开发人员或其他第三方开发的Java程序包.通过命令行的-classpath或-cp,或者通过设置CLASSPATH环境变量来引用.JVM通过放置在{java_home}\lib\tools.jar来寻找和调用用户级的class.常用的javac也是通过调用tools.jar来寻找用户指定的路径来编译Java源程序.这样就引出了User class路径搜索的顺序或优先级别的问题.
4.3.1 缺省值:调用Java或javawa的当前路径(.),是开发的class所存在的当前目录
4.3.2 CLASSPATH环境变量设置的路径.如果设置了CLASSPATH,则CLASSPATH的值会覆盖缺省值
4.3.3 执行Java的命令行-classpath或-cp的值,如果制定了这两个命令行参数之一,它的值会覆盖环境变量CLASSPATH的值
4.3.4 -jar 选项:如果通过java -jar 来运行一个可执行的jar包, 当前jar包会覆盖上面所有的值.换句话说,-jar 后面所跟的jar包的优先级别最高,如果指定了-jar选项,所有环境变量和命令行制定的搜索路径都将被忽略. JVM APPClassloader将只会以jar包为搜索范围.
有关可执行jar有许多相关的安全方面的描述,可以参考http://java.sun.com/docs/books/tutorial/jar/ 来全面了解.
这也是为什么应用程序打包成可执行的jar包后,不管你怎么设置classpath都不能引用到第三方jar包的东西了.
4.4 类加载器
类加载器(class loader)将Java类从本地磁盘加载到Java虚拟机中,并同时创建了该类的Class对象,实现了“通过一个类的全限定类名来获取此类的二进制字节流”功能。
类加载器是Java语言的一项创新,也是Java语言流程的重要原因之一,在类层次划分、OSGI、热部署、代码加密等领域有着重要的作用,成为Java不可或缺的一部分。
首先,我们来写一个测试类,来看下类加载器,ClassLoaderTest测试类:
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoader loader = ClassLoaderTest.class.getClassLoader();
while (true) {
System.out.println(loader);
if(loader==null){
break;
}
loader = loader.getParent();
}
}
}
运行结果:
sun.misc.Launcher$AppClassLoader@41dee0d7
sun.misc.Launcher$ExtClassLoader@f7b650a
null
首先获取到的是AppClassLoader类加载器,紧接着又获取的是ExtClassLoader类加载器,最后获取的对象为null
为什么为null呢,后续来解答!
接下来,我们来看看在Java体系中到底有哪些类加载器。
4.5 类加载的分类
在Java中,类加载器可以分为两大类,一类是由Java系统提供的,另外一类是自定义的,由开发人员编写提供的。
系统类加载器:
引导类加载器(bootstrap class loader):用来加载Java的核心库,由于引导类加载器涉及到虚拟机本地实现细节,且 bootstrap classloader是由C++写成的,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作,不继承自java.lang.ClassLoader(这就是上面例子中为什么最后取到的对象为null的原因)。负责加载/lib下面的类库到内存中,或-Xbootclasspath选项指定的jar包装入内存;
扩展类加载器(extensions class loader):用来加载Java的扩展库,由sun.misc.Launcher$ExtClassLoader来实现。负责加载 /lib/ext,或-Djava.ext.dirs选项指定目录下的jar包装入到内存中;
系统/应用类加载器(system/app class loader):用来加载Java应用的类路径(CLASSPATH)的Java类,由sun.misc.Launcher$AppClassLoader来实现。一般来说,Java应用中的类都是由它来完成加载的,可以通过ClassLoader.getSystemClassLoader()来获取。
自定义类加载器:
自定义类加载器(User Custom ClassLoader):开发人员可以通过继承java.lang.ClassLoader类的方式实现自己的类加载器,以满足一些特殊的需求。在程序运行期间, 通过自定义的java.lang.ClassLoader子类动态加载class文件。
java.lang.ClassLoader类介绍
方法 | 说明 |
---|---|
getParent() | 返回该类加载器的父类加载器。 |
loadClass(String name) | 加载名称为 name的类,返回的结果是 java.lang.Class类的实例。 |
findClass(String name) | 查找名称为 name的类,返回的结果是 java.lang.Class类的实例。 |
findLoadedClass(String name) | 查找名称为 name的已经被加载过的类,返回的结果是 java.lang.Class类的实例。 |
defineClass(String name, byte[] b, int off, int len) | 把字节数组 b中的内容转换成 Java 类,返回的结果是 java.lang.Class类的实例。这个方法被声明为 final的。 |
resolveClass(Class<?> c) | 链接指定的 Java 类。 |
protected Class<?> loadClass(String name, boolean resolve) {
synchronized (getClassLoadingLock(name)) {
// 首先,检查该类是否已经被加载,如果从JVM缓存中找到该类,则直接返回
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 遵循双亲委派的模型,首先会通过递归从父加载器开始找,
// 直到父类加载器是BootstrapClassLoader为止
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {}
if (c == null) {
// 如果还找不到,尝试通过findClass方法去寻找
// findClass是留给开发者自己实现的,也就是说
// 自定义类加载器时,重写此方法即可
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
以上为ClassLoader对于类加载功能的主要方法介绍。
在我们的应用程序中,都是由这4种类加载器互相配合进行加载,这4种类加载器在虚拟机中维护了一种父子关系,这种关系叫做“双亲委派模型”。下面,我们就来看看什么是双亲委派模型。
4.6 双亲委派模型
下面的图片中,展示的就是“双亲委派模型”,模型中呈现出Java体系架构中的四大类加载器的关系,除了顶层的引导类加载器之外,其余类加载都需要有父加载器存在,但是此子父类关系并不是通过java代码中继承的方式实现。具体如何实现,后面讲解。
知道了类加载器的结构模型,那么该模型在代码整个Java体系中如何工作呢?
工作流程:一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把这个类加载请求委派给其父类加载器去完成,每一个层的类加载器都是如此,依次向父类加载器传递,最终所有的类加载请求都会传送到顶层的启动类加载器(bootstrap)中,只有当父加载器反馈无法完成这个类加载请求时,子类加载器才会尝试自己去进行类加载操作,如果子类加载器也依旧无法完成,则代码层面就会抛出异常。
此时,你会不会感到疑惑?为啥儿子自己的活不去干,而首先交给他爹去完成呢?这么做的目的何在?
在Java体系中,双亲委派模型保证了类的唯一性,将Java类与它的类加载器绑定到了一起,当父类加载器加载完成后,子类加载器不会再次加载。此外,双亲委派模型还保证了Java框架的安全性。例如:java.lang.Object类,无论是上述哪个类加载器要加载这个类,最终都会委派给模型中的启动类加载器去加载,因此java.lang.Object类在程序中保证了唯一性。我们通过一段简单的代码,尝试伪造String类,并通过系统类加载器尝试加载,验证双亲委派模型。
首先构建一个伪造的String类:
package java.lang;
public class String {
static {
System.out.println("our fake String class is loaded!!");
}
@Override
public String toString() {
return "Foobar";
}
}
在另一个类里,重新加载String里,看看是否能加载。
package com.utils;
public class ClassLoaderTest {
public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
ClassLoader loader = ClassLoaderTest.class.getClassLoader();
Class<?> klass = loader.loadClass("java.lang.String");
System.out.println(klass.getClassLoader());
Object instance = klass.newInstance();
System.out.println(String.class == klass);
}
}
输出:
null
true
通过一个自定义类的getClassLoader方法获得系统类加载器,然后通过它来载入String类。
第一行说明:klass.getClassLoader()返回NULL,只有一种情况,就是klass的classloader是最顶层的引导类加载器,说明String类的类加载器没有改变。
第二行说明:比较String.class和klass,它们指向同一个对象,说明没有加载新的String类,而是返回了已经加载的核心库中的String类。
就这样,使用双亲委派模式,用户伪造的类永远无法被加载运行。保证了Java程序运行时的安全。
相反,如果没有使用该模型,而是由各个类加载器自行去加载的话,那么系统中就会出现不同的java.lang.Object类,类的唯一性被打破,Java体系中的基本行为就得不到保证。例如:,比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义。否则,即使两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个类就必定不相等。涉及到“类相等”的方法有:Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法以及instanceof对象所属关系判定。
试想一下,再比如我们自定义一个java.lang.Object类会怎么样?(由前面的例子可知,我们自定义的java.lang.Object类无法在程序中被导入,只能模拟定义java.lang.Object类--java.lang.ObjectTest)
当JVM请求类加载进行自定义的类加载时,双亲委派模型会将请求传递到启动类加载器中,但是启动类加载器默认只加载/lib路径下的类,在该路径下并没有ObjectTest类,所以启动类加载器无法加载,只能向下传递给子类加载器,最终会将请求传递到系统类加载器中,但是系统类加载器也无法进行加载,会抛出异常。
为什么,why?
这是因为以java.开头的是核心API包,需要访问权限,强制加载会抛出异常,任何以java.开头的包都会报错:
Exception in thread "main" java.lang.SecurityException: Prohibited package name: java.lang
此异常是代码层面抛出的,并不是native方法虚拟机底层抛出,源码可见(ClassLoader类):
if ((name != null) && name.startsWith("java.")) {
throw new SecurityException
("Prohibited package name: " +
name.substring(0, name.lastIndexOf('.')));
}
此时,你会不会又突发奇想,我自己定义一个类,放在/lib路径下会如何?
编写代码,并打成jar包,jar包的名称就叫做 jiaboyan.jar:
jar cvf jiaboyan.jar com\jiaboyan\test\ObjectTest.class
接下来,把jiaboyan.jar包放入到/lib下,也同时放入到/lib/ext,并同时保留截图中的代码,如图所示:
执行main()方法,结果如下:
sun.misc.Launcher$AppClassLoader@8fd9b4d
从输出可以看出,放置到/lib目录下的ObjectTest.class类并没有被启动类加载器加载,而是由扩展类加载器加载了。
why?不是说了委派给最顶层的类加载进行加载吗?其实,这是由于虚拟机出于安全角度考虑,不会加载/lib中的陌生类,开发者把自定义的类放置到此目录下启动类加载器是不会进行加载的,也就是说我们不能访问bootstrap class loader,extensions class loader是我们的访问的最高层的classloader:
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoader loader = ClassLoaderTest.class.getClassLoader();
while (loader != null) {
System.out.println(loader.toString());
loader = loader.getParent();
}
System.out.println("---");
// 尝试访问核心库类的类加载器
System.out.println(Integer.class.getClassLoader());
}
}
得到的结果:
sun.misc.Launcher$AppClassLoader@4b1210ee
sun.misc.Launcher$ExtClassLoader@78308db1
---
null
4.7 Spring 之 SpringFactoriesLoader详解
双亲委派模型并不能解决所有的类加载器问题,比如,Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC、JNDI、JAXP 等,这些SPI的接口由核心类库提供,却由第三方实现,这样就存在一个问题:SPI 的接口是 Java 核心库的一部分,是由BootstrapClassLoader加载的;SPI实现的Java类一般是由AppClassLoader来加载的。BootstrapClassLoader是无法找到 SPI 的实现类的,因为它只加载Java的核心库。它也不能代理给AppClassLoader,因为它是最顶层的类加载器。也就是说,双亲委派模型并不能解决这个问题。
线程上下文类加载器(ContextClassLoader)正好解决了这个问题。从名称上看,可能会误解为它是一种新的类加载器,实际上,它仅仅是Thread类的一个变量而已,可以通过setContextClassLoader(ClassLoader cl)和getContextClassLoader()来设置和获取该对象。如果不做任何的设置,Java应用的线程的上下文类加载器默认就是AppClassLoader。在核心类库使用SPI接口时,传递的类加载器使用线程上下文类加载器,就可以成功的加载到SPI实现的类。线程上下文类加载器在很多SPI的实现中都会用到。但在JDBC中,你可能会看到一种更直接的实现方式,比如,JDBC驱动管理java.sql.Driver中的loadInitialDrivers()方法中,你可以直接看到JDK是如何加载驱动的:
for (String aDriver : driversList) {
try {
// 直接使用AppClassLoader
Class.forName(aDriver, true, ClassLoader.getSystemClassLoader());
} catch (Exception ex) {
println("DriverManager.Initialize: load failed: " + ex);
}
}
其实讲解线程上下文类加载器,最主要是让大家在看到Thread.currentThread().getClassLoader()和Thread.currentThread().getContextClassLoader()时不会一脸懵逼,这两者除了在许多底层框架中取得的ClassLoader可能会有所不同外,其他大多数业务场景下都是一样的,大家只要知道它是为了解决什么问题而存在的即可。
类加载器除了加载class外,还有一个非常重要功能,就是加载资源,它可以从jar包中读取任何资源文件,比如,ClassLoader.getResources(String name)方法就是用于读取jar包中的资源文件,其代码如下:
public Enumeration<URL> getResources(String name) throws IOException {
Enumeration<URL>[] tmp = (Enumeration<URL>[]) new Enumeration<?>[2];
if (parent != null) {
tmp[0] = parent.getResources(name);
} else {
tmp[0] = getBootstrapResources(name);
}
tmp[1] = findResources(name);
return new CompoundEnumeration<>(tmp);
}
是不是觉得有点眼熟,不错,它的逻辑其实跟类加载的逻辑是一样的,首先判断父类加载器是否为空,不为空则委托父类加载器执行资源查找任务,直到BootstrapClassLoader,最后才轮到自己查找。而不同的类加载器负责扫描不同路径下的jar包,就如同加载class一样,最后会扫描所有的jar包,找到符合条件的资源文件。
类加载器的findResources(name)方法会遍历其负责加载的所有jar包,找到jar包中名称为name的资源文件,这里的资源可以是任何文件,甚至是.class文件,比如下面的示例,用于查找Array.class文件:
// 寻找Array.class文件
public static void main(String[] args) throws Exception{
// Array.class的完整路径
String name = "java/sql/Array.class";
Enumeration<URL> urls = Thread.currentThread().getContextClassLoader().getResources(name);
while (urls.hasMoreElements()) {
URL url = urls.nextElement();
System.out.println(url.toString());
}
}
运行后可以得到如下结果:
$JAVA_HOME/jre/lib/rt.jar!/java/sql/Array.class
根据资源文件的URL,可以构造相应的文件来读取资源内容。
看到这里,你可能会感到挺奇怪的,你不是要详解SpringFactoriesLoader吗?上来讲了一堆ClassLoader是几个意思?看下它的源码你就知道了:
public static final String FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories";
// spring.factories文件的格式为:key=value1,value2,value3
// 从所有的jar包中找到META-INF/spring.factories文件
// 然后从文件中解析出key=factoryClass类名称的所有value值
public static List<String> loadFactoryNames(Class<?> factoryClass, ClassLoader classLoader) {
String factoryClassName = factoryClass.getName();
// 取得资源文件的URL
Enumeration<URL> urls = (classLoader != null ? classLoader.getResources(FACTORIES_RESOURCE_LOCATION) : ClassLoader.getSystemResources(FACTORIES_RESOURCE_LOCATION));
List<String> result = new ArrayList<String>();
// 遍历所有的URL
while (urls.hasMoreElements()) {
URL url = urls.nextElement();
// 根据资源文件URL解析properties文件
Properties properties = PropertiesLoaderUtils.loadProperties(new UrlResource(url));
String factoryClassNames = properties.getProperty(factoryClassName);
// 组装数据,并返回
result.addAll(Arrays.asList(StringUtils.commaDelimitedListToStringArray(factoryClassNames)));
}
return result;
}
有了前面关于ClassLoader的知识,再来理解这段代码,是不是感觉豁然开朗:从CLASSPATH下的每个Jar包中搜寻所有META-INF/spring.factories配置文件,然后将解析properties文件,找到指定名称的配置后返回。需要注意的是,其实这里不仅仅是会去ClassPath路径下查找,会扫描所有路径下的Jar包,只不过这个文件只会在Classpath下的jar包中。来简单看下spring.factories文件的内容吧:
// 来自 org.springframework.boot.autoconfigure下的META-INF/spring.factories
// EnableAutoConfiguration后文会讲到,它用于开启Spring Boot自动配置功能
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.boot.autoconfigure.admin.SpringApplicationAdminJmxAutoConfiguration,\
org.springframework.boot.autoconfigure.aop.AopAutoConfiguration,\
org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration\
执行loadFactoryNames(EnableAutoConfiguration.class, classLoader)后,得到对应的一组@Configuration类,
我们就可以通过反射实例化这些类然后注入到IOC容器中,最后容器里就有了一系列标注了@Configuration的JavaConfig形式的配置类。
这就是SpringFactoriesLoader,它本质上属于Spring框架私有的一种扩展方案,类似于SPI,Spring Boot在Spring基础上的很多核心功能都是基于此,希望大家可以理解。
Refer:
[1] -jar参数运行应用时classpath的设置方法
[2] 让classpath参数走开
http://www.zeali.net/entry/337
[3] 解密Java虚拟机如何加载一个Class文件
https://mp.weixin.qq.com/s/yq0C_-_PoS40t7BoK4oxrg
[4] 给你一份Spring Boot知识清单
https://www.jianshu.com/p/83693d3d0a65