踩坑集锦之你真的明白Java类路径的含义吗?
- 引言
- 前置知识补充
- 故事还要从程序启动讲起...
- C++和Java的桥接类LauncherHelper
- 主类是如何被加载的
- 加餐: 如何利用jdk预留的口子,替换系统类加载器为我们自定义的类加载器
- Launcher启动类的初始化
- 启动类加载器类路径如何确定的
- 扩展类加载器初始化时机
- 应用程序类加载器初始化时机
- 前置知识补充
- URLClassLoader
- URLClassLoader初始化时机
- 从findClass看起
- 从Class类提供的getResource和getResourceAsStream方法看起
- ClassLoader的getResource方法
- URLClassLoader的findResource方法
- 类路径的实际表示-->URLClassPath
- FileLoader的getResource方法
- URLClassPath的getResource/findResource方法
- 参考
类加载器前置知识:
- JVM学习—类加载子系统
- JVM第八卷—类加载与执行子系统的案例与实战
- 类加载器如何实现类隔离
- 三种类加载姿势
- JAVA实现代码热更新
- 独特视角解读JVM内存模型
引言
本文基于JDK 1.8进行讲解!!!
在Dubbo源码篇02—从泛化调用探究Wrapper机制的原理一文中,我们写过compileJava2Class这个方法,来编译,加载,实例化我们的代理对象的java文件:
private static Object compileJava2Class(String filePath, String proxyClassName) throws Exception {
// 编译 Java 文件
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
Iterable<? extends JavaFileObject> compilationUnits =
fileManager.getJavaFileObjects(new File(filePath + File.separator + proxyClassName + ".java"));
JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, null, null, null, compilationUnits);
task.call();
fileManager.close();
// 加载 class 文件
URL[] urls = new URL[]{new URL("file:" + filePath)};
URLClassLoader urlClassLoader = new URLClassLoader(urls);
Class<?> clazz = urlClassLoader.loadClass(CustomInvokerProxyUtils.WRAPPER_PACKAGE_NAME + "." + proxyClassName);
// 反射创建对象,并且实例化对象
Constructor<?> constructor = clazz.getConstructor();
return constructor.newInstance();
}
在该方法中,我们使用URL从指定路径下加载我们的class文件,那么URLClassLoader究竟是如何定位资源的呢?
还有,我们经常会使用类加载器的getResource等方法加载类路径下的资源,那么这其中的细节你又知道多少呢?
前置知识补充
classpath
- Java中有两个classpath,一个是bootstrap classpath,另一个是classpath。
- classpath有如下两种形式 :
- JAR files(JAR文件的全路径)
- Paths to the top of package hierarchies.(顶级目录路径)
bootrap classpath
bootstrap classpath
对应于启动类加载器,根据类加载的双亲委派模型,Java
程序运行时首先会由启动类加载器加载bootstrap classpath
下的类和Jar包中的类。bootstrap classpath
可以通过-Xbootclasspath JVM
参数来指定。- 在Java代码中,我们可以通过
System.getProperty("sun.boot.class.path")
来获取bootstrap classpath
。
Java中的getResource等资源加载方法也遵循双亲委派模型,首先会委托给父类加载器加载资源。委托到启动类加载器时,启动类加载器会从bootstrap classpath对应的jar包或目录中加载资源。因此放在bootstrap classpath中的资源也能够被加载。
- classpath
- classpath用于告诉Java程序从哪里加载用户类以及用户资源。(JRE、JDK本身的类,以及扩展类应该通过其他方式来定位,例如bootstrap class path 或 扩展目录。)
- 所以说,classpath是用来定位用户自定义的类和资源的。
- 在Java代码中,可以通过
System.getProperty("java.class.path")
来获取当前的classpath。
extention directory
- extention directory也就是扩展目录,是Java程序加载扩展类的目录,可以通过
System.getProperty("java.ext.dirs")
来获取。
- extention directory也就是扩展目录,是Java程序加载扩展类的目录,可以通过
故事还要从程序启动讲起…
我们知道JVM中有两种类型的类加载器,由C++编写的及由Java编写的。除了启动类加载器(Bootstrap Class Loader)是由C++编写的,其他都是由Java编写的。由Java编写的类加载器都继承自类java.lang.ClassLoader。
各种类加载器之间存在着逻辑上的父子关系:
启动类加载器是Java虚拟机中内置的一个特殊类加载器,主要用于加载Java平台核心库中的类。它是由Java虚拟机自身实现的,并且是用C++语言编写的。它的主要作用是在Java虚拟机启动时,负责加载Java核心库(如rt.jar等)中的类,以及其他一些需要在Java虚拟机启动时就可用的类和资源。
- 启动类加载器的核心逻辑是在
java.c
文件中的LoadMainClass
函数中实现的。该函数主要调用了checkAndLoadMain
函数和GetLauncherHelperClass
函数。 GetLauncherHelperClass
函数的作用是查找并返回名为"sun.launcher.LauncherHelper"
的类。- 而
checkAndLoadMain
函数则是在LauncherHelper
类中实现的,主要负责加载包含main
方法的主类,并在加载该类时完成扩展类加载器和应用类加载器的初始化工作。
总的来说,启动类加载器的主要作用是在Java虚拟机启动时,加载核心类库以及其他必要的类和资源,以便Java程序能够正常运行。启动类加载器是Java虚拟机中最早启动的类加载器,因此它的实现非常简单、高效。
核心源码如下:
int JNICALL
JavaMain(void * _args)
{
...
mainClass = LoadMainClass(env, mode, what);
...
}
static jclass
LoadMainClass(JNIEnv *env, int mode, char *name)
{
jclass cls = GetLauncherHelperClass(env);
...
NULL_CHECK0(mid = (*env)->GetStaticMethodID(env, cls,
"checkAndLoadMain",
"(ZILjava/lang/String;)Ljava/lang/Class;"));
...
}
jclass
GetLauncherHelperClass(JNIEnv *env)
{
if (helperClass == NULL) {
NULL_CHECK0(helperClass = FindBootStrapClass(env,
"sun/launcher/LauncherHelper"));
}
return helperClass;
}
总结:这套逻辑做的事情就是通过启动类加载器加载sun.launcher.LauncherHelper类,执行该类的方法checkAndLoadMain,最终完成加载main函数所在的类
C++和Java的桥接类LauncherHelper
- LauncherHelper的checkAndLoadMain方法作为桥接C++和Java语言的关键方法,主要做了以下几件事情:
public static Class<?> checkAndLoadMain(boolean printToStderr,
int mode,
String what) {
//根据参数决定将ostream初始化为标准输出流还是标准错误流,从而保证程序的正常输出。
initOutput(printToStderr);
// mode 变量是一个枚举类型,表示不同的启动模式
// what参数指定要运行的主类名或要运行的JAR文件路径。根据mode参数的不同,what的含义也有所不同。
String cn = null;
switch (mode) {
//当mode为LM_CLASS时,what为要运行的主类名
case LM_CLASS:
cn = what;
break;
//当mode为LM_JAR时,what为要运行的JAR文件路径。
case LM_JAR:
cn = getMainClassFromJar(what);
break;
default:
// should never happen
throw new InternalError("" + mode + ": Unknown launch mode");
}
//将路径符替换为包分割符号
cn = cn.replace('/', '.');
Class<?> mainClass = null;
try {
//加载启动类
mainClass = scloader.loadClass(cn);
} catch (NoClassDefFoundError | ClassNotFoundException cnfe) {
//检查操作系统是否为 OS X,如果是,则可能存在字符串规范化的问题,需要对类名进行重新规范化处理,然后再次尝试加载
...
}
// 设置启动类
appClass = mainClass;
// JavaFX是一组用于创建富客户端应用程序的工具和库,可以帮助开发人员轻松构建跨平台的桌面和移动应用程序。
//在 Java 8 及之前版本中,JavaFX 应用程序和普通 Java 应用程序启动方式不同。JavaFX 应用程序需要通过特定的启动类来启动,而不是通过 main 方法。
...
//校验启动类的psvm方法是否符合规范
//1.存在方法名为main的方法
//2.方法修饰符是否为public
//3.方法是否为静态方法
//4.方法是否携带一个String[]数组类型的入参
//5.方法的返回值是否为void
validateMainClass(mainClass);
return mainClass;
}
static void validateMainClass(Class<?> mainClass) {
Method mainMethod;
try {
mainMethod = mainClass.getMethod("main", String[].class);
} catch (NoSuchMethodException nsme) {
// invalid main or not FX application, abort with an error
abort(null, "java.launcher.cls.error4", mainClass.getName(),
FXHelper.JAVAFX_APPLICATION_CLASS_NAME);
return; // Avoid compiler issues
}
/*
* getMethod (above) will choose the correct method, based
* on its name and parameter type, however, we still have to
* ensure that the method is static and returns a void.
*/
int mod = mainMethod.getModifiers();
if (!Modifier.isStatic(mod)) {
abort(null, "java.launcher.cls.error2", "static",
mainMethod.getDeclaringClass().getName());
}
if (mainMethod.getReturnType() != java.lang.Void.TYPE) {
abort(null, "java.launcher.cls.error3",
mainMethod.getDeclaringClass().getName());
}
}
这里注意几点:
- mode 变量是一个枚举类型,表示不同的启动模式。LM_CLASS 和 LM_JAR 分别代表两种不同的启动模式。
LM_CLASS
表示通过指定一个类名来启动程序,这个类名可以是任意一个带有 main() 方法的类。LM_JAR
则表示通过指定一个 jar 文件路径来启动程序。在这种模式下,需要在 jar 文件的META-INF/MANIFEST.MF
文件中指定Main-Class
属性,该属性值为带有main()
方法的类的全限定名。这个属性会被解析出来,然后作为启动类。- 在代码中,根据传入的 mode 值来决定是使用类名还是 jar 文件路径来获取启动类。如果是 jar 文件,则需要通过解析
META-INF/MANIFEST.MF
文件来获取启动类。
主类是如何被加载的
我们的主类是通过scloader类加载器加载的,scloader类加载器在LauncherHelper桥接类进行类初始化操作时被初始化:
系统类加载器别名应用程序上下文类加载器,说到这里大家应该就不陌生了,下面看看系统类加载器是如何被初始化的吧:
public static ClassLoader getSystemClassLoader() {
//尝试初始化系统类加载器---单例初始化操作
initSystemClassLoader();
if (scl == null) {
return null;
}
...
return scl;
}
initSystemClassLoader方法的关键是调用Launcher的getLauncher完成Java 程序的启动器:
private static synchronized void initSystemClassLoader() {
//sclSet变量控制系统类加载器只被初始化调用一次
if (!sclSet) {
//scl就是系统类加载器
if (scl != null)
throw new IllegalStateException("recursive invocation");
//
sun.misc.Launcher l = sun.misc.Launcher.getLauncher();
if (l != null) {
//拿到系统类加载器
scl = l.getClassLoader();
//SystemClassLoaderAction就比较有意思了--我们可以在此处替换系统类加载器为我们自定义的类加载器
scl = AccessController.doPrivileged(new SystemClassLoaderAction(scl));
}
sclSet = true;
}
}
加餐: 如何利用jdk预留的口子,替换系统类加载器为我们自定义的类加载器
上面说过,在initSystemClassLoader方法中,在创建完java启动器后,会获取java启动器在初始化阶段创建好的appClassLoader,但是在SystemClassLoaderAction的run方法中,jdk给我们预留了替换默认系统类加载器的口子:
class SystemClassLoaderAction
implements PrivilegedExceptionAction<ClassLoader> {
private ClassLoader parent;
//parent为系统类加载器
SystemClassLoaderAction(ClassLoader parent) {
this.parent = parent;
}
public ClassLoader run() throws Exception {
//我们可以在系统上下文中设置我们自定义的系统类加载器全类名
String cls = System.getProperty("java.system.class.loader");
if (cls == null) {
return parent;
}
//此处利用系统类加载器来加载我们的自定义系统类加载器
Constructor<?> ctor = Class.forName(cls, true, parent)
//需要提供一个有一个参数的构造函数,参数类型为ClassLoader
.getDeclaredConstructor(new Class<?>[] { ClassLoader.class });
//实例化我们的自定义系统类加载器
ClassLoader sys = (ClassLoader) ctor.newInstance(
new Object[] { parent });
//设置我们的自定义的系统类加载器为线程上下文类加载器
Thread.currentThread().setContextClassLoader(sys);
//返回替换默认的系统类加载器
return sys;
}
}
这里我们介绍完了jdk预留的口子SystemClassLoaderAction,但是还没介绍Launcher对象初始化的时候,是如何把ExtClassLoader和AppClassLoader创建出来的,下面一起来看看。
Launcher启动类的初始化
public class Launcher {
//Launcher类进行类初始化操作时,会创建一个单例的Launcher对象--饿汉式单例
private static Launcher launcher = new Launcher();
//系统类加载器
private ClassLoader loader;
//从系统上下文中获取BootStrapClassLoader启动类加载器加载的类路径
private static String bootClassPath = System.getProperty("sun.boot.class.path");
//返回实例化好的单例Launcher对象
public static Launcher getLauncher() {
return launcher;
}
注意我们无法通过断点断到Launcher构造函数被调用的过程,具体原因参考这篇文章:
- 为什么Java-Launcher类上打断点无效
// java虚拟机启动的时候调用
public Launcher() {
// Create the extension class loader
ClassLoader extcl;
//初始化扩展类加载器
extcl = ExtClassLoader.getExtClassLoader();
// 初始化线程上下文类加载器
loader = AppClassLoader.getAppClassLoader(extcl)
//设置线程上下文类加载器默认为appClassLoader
//上面提到,我们可以在后续的jdk预留的口子中替换默认的系统类加载器为我们自定义的类加载器
Thread.currentThread().setContextClassLoader(loader);
...
}
启动类加载器类路径如何确定的
Launcher内部提供的一个静态内部类BootClassPathHolder,用于持有启动类加载器的类路径:
//BootClassPathHolder类会在JVM启动时被类加载器初始化
private static class BootClassPathHolder {
static final URLClassPath bcp;
static {
URL[] urls;
//从系统上下文中拿到启动类加载器加载的类路径
if (bootClassPath != null) {
//对类路径按照分隔符进行分割,并封装为一组File对象返回
File[] classPath = getClassPath(bootClassPath);
int len = classPath.length;
//过滤掉重复的目录
Set<File> seenDirs = new HashSet<File>();
for (int i = 0; i < len; i++) {
File curEntry = classPath[i];
//这段代码的作用是为了正确地处理启动类路径中不存在的JAR文件。
//如果curEntry代表的是一个JAR文件,!curEntry.isDirectory()将返回true,
//此时代码将curEntry设置为JAR文件的父文件夹。
//这是因为JAR文件是一个文件而不是一个目录,如果直接将JAR文件添加到类路径中可能会导致ClassNotFoundException。
//因此,将JAR文件所在的文件夹添加到类路径中可以避免这个问题。
if (!curEntry.isDirectory()) {
curEntry = curEntry.getParentFile();
}
//将JAR文件中的元数据信息注册到内存中的元数据索引中,以便在需要查找该JAR文件时进行快速查找
//前提是jar包提供了meta-index文件
if (curEntry != null && seenDirs.add(curEntry)) {
MetaIndex.registerDirectory(curEntry);
}
}
//将File转换为URL
urls=pathToURLs(classPath);
} else {
urls = new URL[0];
}
//初始化启动类加载器对应URLClassPath--类路径集合操作的具体表示
bcp = new URLClassPath(urls, factory, null);
bcp.initLookupCache(null);
}
}
我们可以Launcher提供的getBootstrapClassPath方法,获取启动类加载器对应的URLClassPath:
public static URLClassPath getBootstrapClassPath() {
return BootClassPathHolder.bcp;
}
注意:
MetaIndex.registerDirectory(curEntry)
方法用于将JAR文件中的元数据信息注册到内存中的元数据索引中,以便在需要查找该JAR文件时进行快速查找,这在加载类和资源时非常有用。- 在Java 9及更高版本中,JAR文件中的元数据信息已被放置在
META-INF
目录下,包括module-info.class
文件和module-info
文件。当执行该方法时,会扫描指定的目录下的所有JAR文件,将这些JAR文件中的元数据信息读取到内存中,以便在后续的类加载和资源查找中使用。
MetaIndex.getJarMap()
方法返回一个包含所有元索引记录的Map对象,其中的键是JAR文件名,值是该JAR文件的元数据记录。元数据记录是包含JAR文件中所有类和资源名称的列表,以及这些名称对应的SHA-1散列的字符串数组。这个Map对象被用于构建Java运行时的类路径索引,用于快速查找类和资源。
MetaIndex类的registerDirectory方法的作用是解析meta-index文件,提取其中的类和资源信息,并将其保存到JarMap中,以便在加载类时加快查找速度。如果某个Jar包没有提供meta-index文件,那么该方法啥也不做,直接返回。
- 启动类加载器类路径状态
扩展类加载器初始化时机
ExtClassLoader会调用getExtClassLoader来创建一个单例的扩展类加载器实例:
public static ExtClassLoader getExtClassLoader() throws IOException
{
if (instance == null) {
synchronized(ExtClassLoader.class) {
if (instance == null) {
instance = createExtClassLoader();
}
}
}
return instance;
}
private static ExtClassLoader createExtClassLoader() throws IOException {
//获取扩展类加载器负责加载的目录---也就是我们说的扩展类加载器能够加载的类路径
final File[] dirs = getExtDirs();
int len = dirs.length;
//将JAR文件中的元数据信息注册到内存中的元数据索引中,以便在需要查找该JAR文件时进行快速查找
//前提是jar包提供了meta-index文件
for (int i = 0; i < len; i++) {
MetaIndex.registerDirectory(dirs[i]);
}
return new ExtClassLoader(dirs);
}
private static File[] getExtDirs() {
String s = System.getProperty("java.ext.dirs");
File[] dirs;
if (s != null) {
StringTokenizer st =
new StringTokenizer(s, File.pathSeparator);
int count = st.countTokens();
dirs = new File[count];
for (int i = 0; i < count; i++) {
dirs[i] = new File(st.nextToken());
}
} else {
dirs = new File[0];
}
return dirs;
}
扩展类加载器默认能够加载的路径:
ExtClassLoader的构造方法最终会调用父类URLClassLoader的构造方法:
//URLStreamHandlerFactory是Java中用于自定义URL协议处理程序的接口
private static URLStreamHandlerFactory factory = new Factory();
public ExtClassLoader(File[] dirs) throws IOException {
//将file包装为URL,
super(getExtURLs(dirs), null, factory);
...
}
private static URL[] getExtURLs(File[] dirs) throws IOException {
// 将扩展类加载器所能加载的两个类路径下所有文件搜寻出来
//把每一个文件都封装为一个file:协议URL
Vector<URL> urls = new Vector<URL>();
for (int i = 0; i < dirs.length; i++) {
String[] files = dirs[i].list();
if (files != null) {
for (int j = 0; j < files.length; j++) {
if (!files[j].equals("meta-index")) {
File f = new File(dirs[i], files[j]);
urls.add(getFileURL(f));
}
}
}
}
URL[] ua = new URL[urls.size()];
urls.copyInto(ua);
return ua;
}
可以看到最终是调用到了URLClassLoader父类的构造方法:
应用程序类加载器初始化时机
AppClassLoader会调用getAppClassLoader来创建一个单例的扩展类加载器实例:
//传入的类加载器是上面已经实例化好的扩展类加载器,这里作为应用程序上下文类加载器的双亲类加载器
public static ClassLoader getAppClassLoader(final ClassLoader extcl)
throws IOException {
//获取环境上下文中定义的类路径
final String s = System.getProperty("java.class.path");
//getClassPath会利用分隔符切分类路径--和扩展类路径处理一个套路
final File[] path = (s == null) ? new File[0] : getClassPath(s);
//将path转换为url返回
URL[] urls = (s == null) ? new URL[0] : pathToURLs(path);
return new AppClassLoader(urls, extcl);
}
AppClassLoader不同之处在于他自己内部有一个单独的URLClassPath,独立于其父类URLClassLoader提供的ucp:
final URLClassPath ucp;
AppClassLoader(URL[] urls, ClassLoader parent) {
//调用到父类URLClassLoader的构造函数
super(urls, parent, factory);
//初始化URLClassPath--单独提供的
ucp = SharedSecrets.getJavaNetAccess().getURLClassPath(this);
ucp.initLookupCache(this);
}
前置知识补充
- 资源的访问方式
- 资源(resource) 就是我们的程序需要访问的数据,例如图片、文本、视频、音频等等。
- 访问资源有两种方式:
- Location dependent
- Location Independent
- Location dependent
- 所谓Location Dependent,就是我们对资源的访问方式受程序所在位置的影响。
- 例如,在Java中,使用本机绝对路径访问文件时,就是一种Location Dependent的访问方法,代码如下:
File file = new File("/root/project/resource/config.xml")
如果项目中使用上述方式读取文件,当项目在其他目录或其他机器上部署和运行时,就需要修改上述代码中的文件路径,因此上述用法是LocationDependent的。
注意:
- 并不是说通过File类来访问资源一定是Location Dependent的,我们借助File也可以实现Location Independent的访问,例如我们可以给File构造器传入相对路径,这里的相对路径是相对于当前工作目录(
System.getProperty("user.dir")
)的,所以如果要访问的资源是项目的一部分,File类搭配相对路径也可以实现Location Independent的访问。
- Location Independent
- 实现Location Independent的资源读取最常用的就是Class或ClassLoader类中的如下方法:
URL getResource(String name)
InputStream getResourceAsStream(String name)
Enumeration<URL> getResources(String name)
getSystemResource, getSystemResources, getSystemResourceAsStream
- 其中,前两个方法是Class和ClassLoader类都有的,后面的方法只有ClassLoader类有。
- 借助这些方法,可以实现从classpath下读取资源,或者相对于当前class文件所在的目录读取资源。
借助Class和ClassLoader都可以获取资源,并且后面分析源码可以看到,Class类获取资源的方法最终会调用ClassLoader类中对应的方法,那么,这两个类中获取资源的方法的区别在哪里呢?
- 区别在于ClassLoader类中的这两个方法仅支持相对于classpath的路径(开头不能加/,加了就获取不到classpath下的文件了),而Class类中的这两个方法除了支持相对于classpath的路径外(以/开头),还支持相对于当前class文件所在目录的路径(开头不加/)。
这里以一个实际的例子为例进行说明:
- 项目结构
当前工作目录可以打开IDEA进行调整,默认为当前项目的根路径:
注意:
- 在 IDEA 中,默认只会把
src/main/resources 和 src/test/resources
下的资源文件编译存放到类路径下。这意味着在编译后,这些资源文件会被打包到 JAR 或者 WAR 中,并且可以在运行时被访问到。这些资源文件包括配置文件、图片、XML 文件、JSON 文件等等。- 对于其他的文件,如源代码、Markdown 文档、Git 忽略文件等等,它们不会被编译和打包到 JAR 或者 WAR
中。这些文件通常只是在开发过程中使用,而不需要在生产环境中使用。- 如果您希望将其他的文件也打包到 JAR 或者 WAR 中,可以在
build.gradle 或者 pom.xml
中的构建配置中添加相应的配置。
注意:
src/main/resources
目录下的资源文件是主代码的资源文件,会被编译到项目的classpath路径下,最终打包进入生成的jar包或war包中。src/test/resources
目录下的资源文件是测试代码的资源文件,不会被编译到项目的classpath路径下,只有在执行测试时才会将这些资源文件添加到测试类路径下,用于测试代码中的资源读取或者加载。
- 测试代码
/**
* 类路径资源定位测试
*/
public class ClassPathTest {
@Test
public void classPathTest(){
//情况1: 资源寻找路径=加载ClassPathTest的类加载器的类路径作为basePath+ClassPathTest的包名('.'替换为'/'后的路径)+资源文件名
URL resource0 = ClassPathTest.class.getResource("a.txt");
URL resource1 = ClassPathTest.class.getResource("b.txt");
//情况2: 资源寻找路径=加载ClassPathTest的类加载器的类路径作为basePath+资源文件名
URL resource2 = ClassPathTest.class.getResource("/a.txt");
URL resource3 = ClassPathTest.class.getResource("/test.txt");
//情况3: 资源寻找路径=加载ClassPathTest的类加载器的类路径作为basePath+资源文件名
URL resource4 = ClassPathTest.class.getClassLoader().getResource("a.txt");
URL resource5 = ClassPathTest.class.getClassLoader().getResource("test.txt");
URL resource6 = ClassPathTest.class.getClassLoader().getResource("./a.txt");
URL resource7 = ClassPathTest.class.getClassLoader().getResource("./test.txt");
//情况4: 获取不到,无法被解析为相对于classpath的路径
URL resource8 = ClassPathTest.class.getClassLoader().getResource("/a.txt");
URL resource9 = ClassPathTest.class.getClassLoader().getResource("/test.txt");
System.out.println(resource0);
System.out.println(resource1);
System.out.println(resource2);
System.out.println(resource3);
System.out.println(resource4);
System.out.println(resource5);
System.out.println(resource6);
System.out.println(resource7);
System.out.println(resource8);
System.out.println(resource9);
}
}
上面所说的类路径并非只有一个路径,而是一类URL路径的集合,类加载器会挨个尝试将每个url path作为base path,去下面寻找资源,哪个路径下找到了,就直接返回。
- 测试结果
URLClassLoader
URLClassLoader作为应用程序上下文类加载器,扩展类加载器和其他一些类加载器的公共父类,主要负责承载资源定位的公共逻辑,下面是java api文档对该类的定义(省略权限保护部分内容介绍):
URLClassLoader类装载器用于从引用 JAR 文件和目录的 URL 的搜索路径装入类和资源。任何以"/"
结尾的 URL 都假定引用目录。否则,假定 URL 引用将根据需要打开的 JAR 文件。
对于URLClassLoader来说,最重要的概念就是searchPath:
public class URLClassLoader extends SecureClassLoader implements Closeable {
// 用于搜索class类资源和resource资源的urls集合
private final URLClassPath ucp;
URLClassPath也就是我们常说的类路径,类路径并非只有一个路径,而是一类URLS的集合,每个URL可以代表一个目录,一个jar,或者其他形式的资源。
我们来看一下用的最多的URLClassLoader的构造函数:
public URLClassLoader(URL[] urls, ClassLoader parent,
URLStreamHandlerFactory factory) {
//设置双亲类加载器
super(parent);
...
//初始化类路径集合
ucp = new URLClassPath(urls, factory, acc);
}
URLClassLoader初始化时机
public URLClassLoader(URL[] urls, ClassLoader parent,
URLStreamHandlerFactory factory) {
//设置双亲类加载器
super(parent);
...
//初始化类路径管理器
ucp = new URLClassPath(urls, factory, acc);
}
从findClass看起
URLClassLoader重写了父类ClassLoader的findClass方法,用于根据要加载的class文件全类名,借助URLClassPath定位class文件所在地址:
protected Class<?> findClass(final String name)
throws ClassNotFoundException {
final Class<?> result;
//将全类名的.替换为/ ,最后加上.class
String path = name.replace('.', '/').concat(".class");
//拿着包路径加文件名组成的相对路径,去当前类加载器管理的类路径下匹配查找
Resource res = ucp.getResource(path, false);
//资源存在,则执行后续类加载步骤: 装载,链接,初始化
if (res != null) {
result = defineClass(name, res);
} else {
return null;
}
//资源不存在
if (result == null) {
throw new ClassNotFoundException(name);
}
return result;
}
我们重点关注上面方法中通过URLClassPath的getResource方法完成class资源文件的定位。
从Class类提供的getResource和getResourceAsStream方法看起
public java.net.URL getResource(String name) {
//判断是否需要对资源路径进行标准化处理--什么是标准化处理,下面会讲
name = resolveName(name);
//获取加载当前类的类加载器
ClassLoader cl = getClassLoader0();
//如果为空,使用系统类加载器 --- Bootstrap ClassLoader加载的系统类,它们的类加载器都为null
if (cl==null) {
// A system class. ---> 加载系统资源,怎么实现的,一会看看便知
return ClassLoader.getSystemResource(name);
}
//调用类加载的getResource方法获取资源
return cl.getResource(name);
}
public InputStream getResourceAsStream(String name) {
name = resolveName(name);
ClassLoader cl = getClassLoader0();
if (cl==null) {
// A system class.
return ClassLoader.getSystemResourceAsStream(name);
}
return cl.getResourceAsStream(name);
}
resolveName方法说明:
- 首先检查名称是否以’/'开头。
- 如果是,它将去掉开头的’/'。
- 如果不是,它将使用当前的Class类的包名来作为基础名称,然后将其与传入的名称组合起来来得到相对路径。
- 这里,需要注意的是,如果Class是数组类型,则它的组件类型将的包名将被作为基础名称。
- 最后,将相对路径转换为标准格式,并返回结果。
private String resolveName(String name) {
if (name == null) {
return name;
}
if (!name.startsWith("/")) {
Class<?> c = this;
while (c.isArray()) {
c = c.getComponentType();
}
String baseName = c.getName();
int index = baseName.lastIndexOf('.');
if (index != -1) {
name = baseName.substring(0, index).replace('.', '/')
+"/"+name;
}
} else {
name = name.substring(1);
}
return name;
}
ClassLoader的getResource方法
类加载加载资源的方式遵循双亲委派机制,类文件的加载是资源文件加载的一种特殊情况:
public URL getResource(String name) {
URL url;
if (parent != null) {
url = parent.getResource(name);
} else {
url = getBootstrapResource(name);
}
if (url == null) {
url = findResource(name);
}
return url;
}
//ClassLoader的findResource方法是空实现,最终会调用到子类URLClassLoader的getResource方法
protected URL findResource(String name) {
return null;
}
URLClassLoader的findResource方法
public URL findResource(final String name) {
URL url = ucp.findResource(name, true);
return url != null ? ucp.checkURL(url) : null;
}
可以看到最终的最终我们的资源查找逻辑都是交给URLClassPath负责完成的,所以下面跟随我的视角,一起来看看URLClassPath是怎么实现的吧。
类路径的实际表示–>URLClassPath
每个类加载器都有与之对应的 URLClassPath:
- 应用(系统)类加载器 AppClassLoader 和 扩展类加载器 ExtClassLoader 都继承自 URLClassLoader,URLClassLoader有一个URLClassPath字段:
- 启动类加载器对应的是null,它对应的URLClassPath是通过getBootstrapClassPath()方法获取的,参考ClassLoader.getBootstrapClassPath方法
private static URL getBootstrapResource(String name) {
URLClassPath ucp = getBootstrapClassPath();
Resource res = ucp.getResource(name);
return res != null ? res.getURL() : null;
}
getBootstrapClassPath()最终获取的是sun.misc.Launcher.BootClassPathHolder.bcp字段,该字段的赋值过程,上面已经讲过了。
下面是java api文档对该类的介绍:
- 此类用于维护 URL 的搜索路径,以便从 JAR 文件和目录加载类和资源。
URLClassPath用于维护从JAR包或目录中加载类或资源的查找路径,这个路径由若干个URL组成(URL封装在了Loader里面,一个Loader对应一个URL)。
ArrayList<Loader> loaders = new ArrayList<Loader>();
URLClassPath
包括一个ArrayList<Loader> loaders
字段,Loader
是URLClassPath
的内部类,顾名思义,Loader
是用来从JAR
包或目录中加载类或资源的,它用于加载资源的方法是findResource
和getResource
。
注意,查看JarLoader和FileLoader的源码可以发现,findResource最终也会调用getResource:
Loader有两个实现类JarLoader和FileLoader,负责实际的资源加载任务,分别负责从JAR包和目录中加载资源。
每个Loader对应一个base URL,表示对应的JAR包或目录的URL。
FileLoader的getResource方法
Loader不管是用findResource还是getResource获取资源,最终都是调用getResource,这里以较为简单的FileLoader的getResource方法进行分析:
Resource getResource(final String name, boolean check) {
final URL url;
try {
//使用当前Loader管理的类路径作为baseUrl
URL normalizedBase = new URL(getBaseURL(), ".");
//baseUrl拼接资源相对路径得到完整的资源路径
url = new URL(getBaseURL(), ParseUtil.encodePath(name, false));
//判断请求资源是否在类路径之内。
//如果请求资源的路径中包含了 ../ 等导航符,则需要先进行归一化处理,再比较是否在类路径之内。
//如果请求的资源不在类路径之内,则返回 null,表示未找到该资源。
if (url.getFile().startsWith(normalizedBase.getFile()) == false) {
// requested resource had ../..'s in path
return null;
}
...
final File file;
//判断资源相对路径中是否包含".."符号
if (name.indexOf("..") != -1) {
//如果包含,那么将FileLoader的dir路径作为basePath--这里的dir路径也就是FileLoader能够处理的类路径
file = (new File(dir, name.replace('/', File.separatorChar)))
.getCanonicalFile();
//判断所请求的资源是否越过了类路径的范围
if ( !((file.getPath()).startsWith(dir.getPath())) ) {
/* outside of base dir */
return null;
}
} else {
//资源相对路径中没有包含".."符号
//正常将当前FileLoader的类路径作为basePath
//拼接资源相对路径得到资源完整路径
file = new File(dir, name.replace('/', File.separatorChar));
}
//文件存在,那么构建Resource资源对象,然后返回
if (file.exists()) {
return new Resource() {
public String getName() { return name; };
public URL getURL() { return url; };
public URL getCodeSourceURL() { return getBaseURL(); };
public InputStream getInputStream() throws IOException
{ return new FileInputStream(file); };
public int getContentLength() throws IOException
{ return (int)file.length(); };
};
}
} catch (Exception e) {
return null;
}
return null;
}
URLClassPath的getResource/findResource方法
三种类加载器对资源的加载最终都是靠URLClassPath的getResource或findResoource方法完成的,而这两个方法又是借助loaders列表中的每一个loader来分别对指定的JAR包或目录进行资源加载的。
其中,应用类加载器和扩展类加载器会调用URLClassPath的findResource方法:
public URL findResource(String name, boolean check) {
Loader loader;
// 遍历loaders列表中的每一个loader,看看哪个Loader能够加载当前资源,如果能够加载,就直接返回
for (int i = 0; (loader = getLoader(i)) != null; i++) {
URL url = loader.findResource(name, check);
if (url != null) { return url; }
}
return null;
}
启动类加载器会调用URLClassPath的getResource方法,该方法的逻辑和findResource几乎一样:
public Resource getResource(String name, boolean check) {
if (DEBUG) {
System.err.println("URLClassPath.getResource(\"" + name + "\")");
}
Loader loader;
for (int i = 0; (loader = getLoader(i)) != null; i++) {
Resource res = loader.getResource(name, check);
if (res != null) { return res;}
}
return null;
}
前面介绍过,Loader负责从Jar包或目录下加载资源,每个Loader对应一个base URL。
这个base URL其实来源于bootstrap classpath或classpath
中的每一个条目对应的URL,以及扩展目录下的每一个jar包对应的URL。
以AppClassLoader的URLClassPath对象为例,假设程序的classpath有3个条目,记为a;b;c,则URLClassPath对象有3个Loader,这3个Loader的base URL分别为a,b,c对应的URL,分别负责从这三个地方加载资源。
URLClassPath的loaders集合初始化采用的是懒加载策略,只有当第一次调用其findResource或者getResource方法时,才会进行初始化,这里以findResource方法为例进行讲解:
//遍历当前URLClassPath内部所有Loader,挨个尝试加载资源,哪个先成功,就直接返回
public URL findResource(String name, boolean check) {
Loader loader;
...
//重点在getNextLoader第一次被调用的时,会对loaders集合进行初始化
for (int i = 0; (loader = getNextLoader(cache, i)) != null; i++) {
URL url = loader.findResource(name, check);
if (url != null) {
return url;
}
}
return null;
}
private synchronized Loader getNextLoader(int[] cache, int index) {
if (closed) {
return null;
}
//...
return getLoader(index);
}
getLoader方法负责根据下标返回一个Loader,具体逻辑如下:
private synchronized Loader getLoader(int index) {
if (closed) {
return null;
}
//按需创建Loader
while (loaders.size() < index + 1) {
// Pop the next URL from the URL stack
URL url;
//当urls栈为空时,说明所有Loaders都完成了初始化
//也说明此时没有Loa
synchronized (urls) {
if (urls.empty()) {
return null;
} else {
url = urls.pop();
}
}
//检查给定的URL是否已经被处理过,如果已经被处理过,则跳过该URL,
//URLClassPath内部维护了一个lmap,用来保存已经处理过的URL和对应的Loader;
//给定的URL在lmap中已经存在,则说明该URL已经被处理过,可以直接跳过;
//如果给定的URL在lmap中不存在,则说明该URL尚未被处理过,需要创建对应的Loader,并加入到lmap和loaders列表中。
String urlNoFragString = URLUtil.urlNoFragString(url);
if (lmap.containsKey(urlNoFragString)) {
continue;
}
Loader loader;
try {
//通过给定的URL创建并返回一个新的Loader对象
//根据URL的不同,可以创建不同类型的Loader。
//如果URL指向一个本地文件,就会创建FileLoader;
//URL指向一个Jar文件,就会创建JarLoader。
loader = getLoader(url);
// If the loader defines a local class path then add the
// URLs to the list of URLs to be opened.
//在对于新的URL进行处理时,会获取这个URL对应的Loader,并查看Loader是否定义了本地的类路径。
//如果定义了本地类路径,那么就会将本地类路径中的URL加入到URL栈中,
//这样在后续的查找资源的过程中就可以继续遍历这些URL。
URL[] urls = loader.getClassPath();
if (urls != null) {
push(urls);
}
} ...
// Finally, add the Loader to the search path.
...
//将新创建的Loader,加入URLClasspath的loader集合汇总
loaders.add(loader);
//记录已经加载的 URL 对应的 Loader 对象
//URL 在集合中不存在时,将会创建一个新的 Loader 对象,并添加到集合中,以便进行缓存,避免重复加载相同的 URL。
// URL 在集合中已经存在时,则跳过该 URL,不再进行加载操作。这样可以有效地避免重复加载相同的 URL,提高了加载的效率。
lmap.put(urlNoFragString, loader);
}
...
return loaders.get(index);
}
getLoader方法的逻辑设计的十分巧妙,其中的一段while循环完成了对Loaders集合的按需加载,具体思路如下图所示:
参考
- ChatGpt
- java类加载器通俗理解
- Class和ClassLoader的getResource方法详解与源码分析