前言:ClassLoader学习
ClassLoader、SPI机制
Class对象的理解
1 java在诞生之初,就有一次编译到处运行的名言,今天我们来探究一下,从java代码到class到运行,JVM中的ClassLoader充当一个什么样的角色。
一个简单的JVM流程图(简单了解)
流程图.jpg
从位置角度理解JVM:就JVM在物理结构上的位置而言,它与Java的传统理念”一次编译,到处运行”密切相关。我们的计算机操作系统(例如Windows、Linux和Mac)本质上都是一系列软件的组合,而JVM同样也是基于操作系统的软件之一。因此,从这个角度来看,可以很容易地理解JVM跨平台的原因:每个操作系统都可以安装一个JVM软件,从而使Java程序能够在不同平台上运行。
JVM是存在于操作系统之上的软件,具体存在于操作系统上的JRE构建环境中
JVM位置.jpg
JVM流程图:建议自己画一下简单的流程图(在JVM流程中了解ClassLoader的位置)
JVM详细的流程图:在学习JVM之前,我建议你浅浅看一下,流程图中所涉及的每一个知识的概念。
1 注意:只需要浅浅看一下,知道是每一步都是干啥的???
学前问题 ?
以下问题的答案并不唯一,目的是为了引出一些知识!!!
一个Java代码在运行时,需要编译几次?
在Java代码运行时,通常需要进行两次编译。首先,使用javac进行前端编译,将Java源代码编译成字节码文件,以便进行类加载和执行。其次,通过JIT(即时编译)后端编译,将HotSpot代码编译成本地机器码,以提高代码的执行速度。
Method Area 你是否了解?
方法区(Method)是Java虚拟机的内存区域之一。主要是用于存储类的结构信息、运行时常量池、字段、和方法描述、静态变量等元数据。下面是其中的一些常见的存储内容:
类的结构信息 :这包括类的字段、方法、构造方法,以及类的继承关系、接口实现等结构信息。
运行时常量池 :运行时常量池是类文件中常量池的运行时表示,它包含类中使用的字面常量、符号引用、方法和字段引用等。这些信息在运行时可以被解析为直接引用。
字段和方法描述 :方法区存储了类中各个字段和方法的描述信息,包括字段的数据类型、访问修饰符、方法的参数列表和返回类型等。
静态变量 :静态变量,即使用static关键字声明的类级别变量,也会被存储在方法区中,并在类的初始化阶段进行分配和初始化。
类的字节码 :类的字节码文件,即编译后的.class文件,其中包含了类的方法体、指令集等定义,这些字节码会被加载到方法区供执行。
异常处理表 :方法区还会存储异常处理信息,包括异常处理代码的偏移位置、异常类型等,用于异常处理。
需要注意的是,Java虚拟机的具体实现可以有不同的内存管理方式,例如使用永久代(在Java 7及之前的HotSpot虚拟机中)或使用元数据区(在Java 8及以后的HotSpot虚拟机中)。在Java 8及之后,方法区已经不再被称为”方法区”,而被替代为”元数据区”(Metaspace),它采用了不同的内存管理方式,如使用本机内存,而不再有固定的区域大小限制。
3.class关键字和Class对象的区别?
1 2 3 4 5 6 class就是Java的一个关键字,用于声明一个类,比如 public class Student 使用class关键字声明了一个类。 Class是存在于java.lang.Class的一个类,这个类是用于描述类与接口meta信息的、用于支持反射的一种类型。
Class对象的理解
Class对象理解
1 到文章这里你应该了解到JVM框架流程图中一些概念,比如:javac是什么?classLoader是干什么的?Class对象是什么?ClassLoader类加载系统的生命周期有那些部分?
4 . ClassLoader类加载系统做了什么?
我们通过类加载器(加载阶段)去加载特定的字节码文件。在加载阶段我们使用双亲委派机制去处理class,并将获取的二进制字节流转换为方法区的数据结构(这种数据结构包含了类的字段、方法、常量池)。然后在堆区对应生成一个Class对象,允许程序在运行时通过该对象去访问和处理类的相关信息。紧接着就是验证 这个信息是否有问题。在这里我们会进行一些文件格式的校验、元信息是否有问题、符号使用是否正常等等。在准备 阶段,处理静态变量、在解析 阶段,将使用的符号等转换为真实的内存地址。最后进行初始化,对我们之前的静态变量赋值,并且运行static{ System.out.println(‘“你好啊”‘)} 静态代码块。
至此,整个类加载系统就完成了,我们在加载阶段将字节码加载到JVM的内容中,在其他阶段对这个加载的信息进行验证和处理然后交给JVM运行时区域。
类的加载机制 作用:类加载器主要负责将编译后的字节码类文件(存放在磁盘的二进制数据),载入到JVM的内存中, 并将其放在方法区中,然后在堆中创建一个java.lang.Class对象,用来封装类在方法去内的数据结构,在成功装载到内存中之后,就需要堆数据进行 校验、转换解析和初始化,最终形成被虚拟机使用的java类型。
在堆区创建一个 java.lang.Class对象”:在加载类时,JVM会在堆内存中创建一个java.lang.Class类的实例,该实例用来代表加载的类,并允许程序在运行时通过反射等方式访问类的结构信息。
类生命周期
ClassLoader Oncreate
类加载的过程,包括了加载、验证、准备、解析、初始化五个阶段。
加载 在加载阶段主要做三件事
1、获取字节码流:类加载器根据类的完全限定名(例如com.example.nzp)来查找和获取表示该类的二进制字节流。
2、转化为方法区结构 :类加载器将获取到的字节码流转化为方法区(新版的Java虚拟机中称为元空间)中的数据结构。这些数据结构包括了类的字段、方法、常量池等信息,用于描述类的结构和特性。
3、生成class对象:在堆内存中生成一个代表加载的类的Java.lang.Class对象。这个Class对象充当了访问方法区中的数据的入口点,允许程序在运行时通过反射等方式访问类的信息。
验证
验证的主要作用就是确保被加载的类的正确性,如果类文件未通过验证,加载过程就会失败,并抛出’java.lang.VerifyError’异常。
1、文件格式验证(File Format Verification):首先虚拟机会对类文件的格式进行校验,确保它遵守Java虚拟机规范。这包括检查类文件的魔数、版本号、字段、常量池、方法表等部分。
2、元数据校验(Metadata Verification):在这一步,虚拟机会检查类的元数据信息,包括类、字段、方法的访问修饰符是否正确、类的继承关系是否合法等等。这有助于确保类的结构在语义上是正确的。
3、字节码验证(Bytecode Verification):虚拟机会对字节码进行验证,以确保它遵循类安全性规则,不会导致数组越界、类型转换错误等等。这是确保程序不会因为恶意代码而受到攻击的重要步骤。
3、符号引用验证(Symbolic Reference Verification): 这一步验证类中的符号引用是否能够正确被解析,例如检查类、字段和方法是否都能找到对应的定义。
准备
准备阶段主要是为类变量分配内存并设置初始值。
1、为类变量(static)分配内存:在准备阶段,虚拟机为类中的静态变量分配内存空间。这是在方法区中完成的,方法区用于存储类的结构信息和静态变量。
2、初始值:在该阶段,静态变量会被赋予初始值,这些默认值是数据类型的默认值,而不是代码中显示赋予的值。例如,整数类型的静态变量被赋予的默认值就是0,布尔值的默认值是false,引用类型的默认值为null。
1 2 3 4 5 6 public static int a = 1 ;public static boolean test;
1 2 3 4 注意:前面的a值被static所修饰的,在准备阶段为0 ,但如果是被static 和 final同时修饰,public static final int a = 1; 那么这个值在准备阶段就会是1了。 对于static final修饰的常量,在编译阶段会被优化,并且它们的值会被直接存放在调用它们的类的常量池中。这个优化是在编译器进行的,而不是在类加载的准备阶段。
解析
解析阶段主要是将虚拟机常量池中的符号引用转化为直接引用的过程。
在解析过程中,Java虚拟机会查找常量池中的符号引用,然后将其映射到实际内存地址或偏移量。这使得程序可以正确地访问和执行类、字段、方法等,而不受符号引用的抽象性和限制。
我们使用一个例子来理解 “符号引用 “转换为 “直接引用 “的过程:
1 2 3 4 5 6 7 8 9 10 11 12 public class MyClassExample { public void myMethod{ System.out.println("hello" ); } } 符号引用:在`myMethod`中的`System.out.println`是一个符号引用,它是一个抽象的引用,不包含实际的内存地址。它只包含方法的名称、参数类型和返回类型等信息。 解析:在解析阶段,Java虚拟机会查找System.out.println()的实际内存地址,并将其转化为直接引用。(在这里涉及查找System类,查找println方法,并确定方法的入口地址)。 直接引用:解析完成后,System.out.println就会被转换为实际的内存地址,当我们调用myMethod方法时,Java虚拟机就可以使用已解析的直接引用,跳转到System.out.println方法的内存地址,执行该方法,输出hello。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行
1 2 3 4 5 6 7 8 9 10 11 12 13 类或接口符号引用:用于解析类或接口的全限定名,以确定类或接口的位置。 字段符号引用:用于解析字段的名称和类型,以确定字段的内存布局和位置。 类方法符号引用:用于解析静态方法的名称和参数类型,以确定方法的入口地址。 接口方法符号引用:用于解析接口方法的名称和参数类型,以确定方法的入口地址。 方法类型符号引用:用于解析方法类型(方法的参数类型和返回类型),以确定方法类型的描述信息。 方法句柄符号引用:用于解析方法句柄,以确定方法句柄的类型和目标。 调用点限定符符号引用:用于解析调用点限定符,以确定方法调用的目标方法和接收者类型。
初始化
主要是用来确保类的静态成员(静态变量和静态初始快)在首次使用之前已经被正确初始化,以保证类的正确性和可用性。一句话描述这个阶段就是执行类构造器< clinit >()方法的过程。
1、执行类构造器”()方法” :初始化阶段会执行由编译器生成的类构造器方法,它负责对类的静态成员进行初始化。这包括静态变量的赋值和静态初始化块中的代码执行。
2、初始化静态变量:静态变量会被分配内存并设置为初始值。如果静态变量在类的生命中被显式初始化,这些显式复制的操作也会在初始化进行。
3、调用类的构造函数:如果类具有显式的构造函数,构造函数也会在初始化阶段执行。这通常发生在类的静态初始化块之后。
4、确保类的一致性:初始化确保类的所有静态成员都已被正确的初始化,以便在类执行使用时不会出现未初始化的情况。
实例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 1. 创建类的实例,也就是new 的方式2. 访问某个类或接口的静态变量,或者对该静态变量赋值public class Demo { static { System.out.println("进行初始化" ); } public static int x = 10 ; } 3. 调用类的静态方法public class Demo { static { System.out.println("进行初始化" ); } public static void test () { } } 4. 反射(如 Class.forName(“com.shengsiyuan.Test”))public static void main (String[] args) throws ClassNotFoundException { Class.forName("co.youzi.test.Demo" ); } 5. 初始化某个类的子类,则其父类也会被初始化注意:通过子类使用父类的静态变量只会导致父类的初始化,子类不会初始化。 public class Parent { static { System.out.println("父类初始化" ); } public static int x = 10 ; } public class Child extend Parent{ static { System.out.println("子类初始化" ); } public static int y = 100 ; } public class Test { public static void main (String[] args) throws ClassNotFoundException { System.out.println(Child.y); } } 6. Java虚拟机启动时被标明为启动类的类( JavaTest),直接使用 java.exe命令来运行某个主类 1. 通过子类引用父类的静态字段,不会导致子类初始化。2. 通过数组定义来引用类,不会触发此类的初始化。MyClass[] cs = new MyClass [10 ];public class Test05 { public static void main (String[] args) throws ClassNotFoundException { Parent[] parent = new Parent [10 ]; System.out.println(parent.length); } } 3. 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。public class Simple { static { System.out.println("进行初始化" ); } public final static int MAX = 10 ; public final static int RANDOM = new java .util.Random(10 ).nextInt(); }
ClassLoader
类加载器(ClassLoader)负责将字节码文件加载到内存中,并将其转换为运行时的类对象,以便JVM执行字节码时使用。
类加载器的层次结构 1 2 3 双亲委派模型是Java中的一种类加载机制,其核心思想就是除了顶层加载器没有父类加载器之外(BootStra ClassLoader),其他所有的类加载器都有自己的父类加载器。这就意味着在类加载的过程中,一个类加载器首先会尝试将加载请求委派给父类加载器,只有父类类加载器无法完成加载时,子类加载器才会去加载。
从java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(BootStrap ClassLoader),这个类加载器是用C++实现的,是虚拟机自身的一部分,另一种就是其他所有的类加载器,这些类加载器都是由java语言实现的,是独立于虚拟机外部,并且全部继承自 java.lang.ClassLoader。
从程序员的角度来讲,类加载器可以划分的更为细致,有以下四种(其中Custom ClassLoader加载器是根据需求去自定义的一个类加载器,非特殊场景,不需要):
四种加载器
启动类加载器:Bootstrap ClassLoader
这个类加载器使用C/C++语言实现,嵌套在JVM中,Java程序是无法直接操作该类。它用来加载Java的核心类库,如:JAVA_HOME/jre/lib/rt.jar 、resource.jar路径下的包,用于提供jvm运行所需要的包。
并不是继承自java.lang.ClassLoader,sun.boot.class.path它没有父类加载器
扩展类加载器:Extension ClassLoader
Java语言编写,由sun.misc.Launcher$ExtClassLoader实现,我们可以用Java程序操作这个加载器继承自Java.lang.ClassLoader,父类加载器为启动类加载器。它用来加载jre/lib/ext目录下的类库。我们就可以将我们自己的包放在以上目录下,就会自动加载进来了。
应用程序加载器:Appliacation ClassLoader
Java语言编写,由sun.misc.Launcher$AppClassLoader实现。
继承自java.langClassLoader,父类加载器为扩展类加载器。她负责加载环境变量classpath或者系统属性java.class.path指定路径下的类库。
它是程序中默认的类加载器,我们Java程序员中的类,都是由它加载完成的。
自定义加载器:Custom ClassLoader
当上述 3 种类加载器不能满足开发需求时,用户可以自定义类加载器
自定义类加载器时,需要继承ClassLoader类。如果不想打破双亲委派模型,那么只需要重写findClass方法即可;如果想打破双亲委派机制,就打破loadClass方法;
除了启动类加载器,其他三种类加载器都继承自 java.lang.ClassLoader 抽象类。其源码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 public abstract class ClassLoader { private static native void registerNatives () ; static { registerNatives(); } private final ClassLoader parent; protected Class<?> findClass(String name) throws ClassNotFoundException { throw new ClassNotFoundException (name); } protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { Class c = findLoadedClass(name); if (c == null ) { try { if (parent != null ) { c = parent.loadClass(name, false ); } else { c = findBootstrapClass0(name); } } catch (ClassNotFoundException e) { c = findClass(name); } } if (resolve) { resolveClass(c); } return c; } }
观察类加载器的整条链
1 2 3 4 5 6 7 8 9 10 11 12 13 public class res { public static void main (String[] args) { res res = new res (); ClassLoader classLoader = res.getClass().getClassLoader(); System.out.println(classLoader); System.out.println(classLoader.getParent()); System.out.println(classLoader.getParent().getParent()); } }
自定义类加载器 我们自定义类加载器里面重写了loadClass方法,在Main类中通过while循环输出当前及其父类类加载器。
输出结果中没有BootStrap ClassLoader是因为它是C++实现的,我们无法通过java代码去调用它
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 package ClassLoader;import java.io.IOException;import java.io.InputStream;public class ConsumerClassLoaderDemo extends ClassLoader { public static void main (String[] args) throws Exception { ClassLoader myClassLoader = new ConsumerClassLoader (); Object obj = myClassLoader.loadClass("ClassLoader.data" ).newInstance(); ClassLoader classLoader = obj.getClass().getClassLoader(); while (null != classLoader) { System.out.println(classLoader); classLoader = classLoader.getParent(); } } } class ConsumerClassLoader extends ClassLoader { @Override public Class<?> loadClass(String name) throws ClassNotFoundException { try { String classFile = name.substring(name.lastIndexOf("." ) + 1 ) + ".class" ; InputStream in = getClass().getResourceAsStream(classFile); if (null == in) { return super .loadClass(name); } int count = 0 ; while (count == 0 ){ count = in.available(); } byte [] bytes = new byte [count]; in.read(bytes); return defineClass(name, bytes, 0 , bytes.length); } catch (IOException e) { throw new ClassNotFoundException (name); } } }
输出:
1 2 3 ClassLoader.ConsumerClassLoader@74a14482 sun.misc.Launcher$AppClassLoader@18b4aac2 sun.misc.Launcher$ExtClassLoader@677327b6
双亲委派机制
什么是双亲委派机制? JVM中,类加载器默认使用双亲委派原则。
1 2 3 4 1.如果一个类加载器收到了类加载请求,它并不会自己先加载,而是把这个请求委托给父类的加载器去执行。 2.如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的引导类加载器 BootstrapClassLoader。 3.如果父类加载器可以完成类加载任务,就成功返回;倘若父类加载器无法完成加载任务,子加载器才会尝试自己去加载。 4.父类加载器一层一层往下分配任务,如果子类加载器能加载,则加载此类;如果将加载任务分配至系统类加载器(AppClassLoader)也无法加载此类,则抛出异常。
双亲? classloader 类存在一个 parent 属性,可以配置双亲属性。默认情况下,JDK 中设置如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ExtClassLoader.parent=null ; AppClassLoader.parent=ExtClassLoader XxxClassLoader.parent=AppClassLoader 在双亲委派模型中,一般情况下确实是ExtClassLoader的父加载器为Bootstrap Class Loader,AppClassLoader的父加载器为ExtClassLoader。但是,ClassLoader的parent属性是private 的,直接设置它的值是不允许的。实际上,这个关系是在ClassLoader的构造函数中初始化的。 如果你要自定义一个类加载器,例如XxxClassLoader,并且希望它的父加载器为AppClassLoader,你应该通过构造函数来指定: public class XxxClassLoader extends ClassLoader { public XxxClassLoader () { super (AppClassLoader.getSystemClassLoader()); } } 这样,你就通过构造函数显式地将AppClassLoader设置为了XxxClassLoader的父加载器。在这种情况下,ExtClassLoader并没有直接参与到XxxClassLoader的层级结构中。
委派? 委派就是ClassLoader类加载过程中的处理逻辑,是通过 java.lang.ClassLoader 类的loadClass方法实现的。
java.lang.ClassLoader.loadClass()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 public abstract class ClassLoader { private static native void registerNatives () ; static { registerNatives(); } private final ClassLoader parent; protected Class<?> findClass(String name) throws ClassNotFoundException { throw new ClassNotFoundException (name); } protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { Class c = findLoadedClass(name); if (c == null ) { try { if (parent != null ) { c = parent.loadClass(name, false ); } else { c = findBootstrapClass0(name); } } catch (ClassNotFoundException e) { c = findClass(name); } } if (resolve) { resolveClass(c); } return c; } }
loadClass、findClass、defineClass方法 关于类加载的三个方法的讲解:
loadClass() :双亲委派的实现逻辑就是通过方法
findClass() :根据名称或位置加载 .class字节码
defineClass() :把.class字节码转换为Class对象,就是类加载过程中的loading
双亲委派机制的优缺点
双亲委派可以保证一个类不会被多个类加载器重复加载,并且保证核心 API 不会被篡改。
1 2 3 4 5 6 7 8 9 类的唯一性: 双亲委派模型通过在类加载器层次中使用父子关系,确保了在一个Java虚拟机实例中,任何一个类都只会被加载一次。这避免了类的重复加载,提高了系统的内存利用率。 安全性: 双亲委派模型可以防止恶意类的加载。由于类加载是从父加载器向子加载器委派的,所以如果一个类已经被父加载器加载,子加载器就没有机会重新加载,避免了恶意类替换的可能性。(保证了核心的API的使用) 层次性: 类加载器之间形成了层次结构,每个加载器都有一个明确定义的父加载器。这种层次结构有助于更好地组织和管理类的加载过程。例如,应用程序的类可以由应用程序类加载器加载,扩展的类由扩展类加载器加载,核心的Java类由启动类加载器加载。 代码隔离: 每个类加载器都有自己的命名空间,一个加载器加载的类对于其它加载器是不可见的。这种隔离性有助于防止不同模块之间的类名冲突。 性能提升: 双亲委派模型在加载类时,先由父加载器尝试加载,只有在父加载器无法完成加载时才由子加载器尝试加载。这样可以避免重复加载,提高了类加载的效率。
缺点:
1 2 3 4 5 6 7 8 9 灵活性受限: 双亲委派模型在一定程度上限制了类加载的灵活性。在某些场景下,比如需要实现类的热替换、动态代码生成等,这种限制可能显得不够灵活。 资源浪费: 由于每个类加载器都要委托给父加载器,可能会导致一些资源浪费。在一个多层级的类加载器结构中,如果某个类加载器在加载类时不进行适当的缓存,可能会导致多次加载相同的类,浪费内存。 性能开销: 双亲委派模型在类加载的时候需要依次向上委托,直到达到启动类加载器。这个过程会引入一定的性能开销,尤其是在类加载器层次比较深或者类加载器之间的委托链较长的情况下。 自定义类加载器复杂性: 如果需要自定义类加载器,特别是在需要实现一些高级功能时,双亲委派模型的约束可能会增加实现的复杂性。 无法实现类的版本隔离: 双亲委派模型不能很好地处理同一类的不同版本的情况。在某些场景下,不同的应用程序可能需要加载相同包名下的不同版本的类,而双亲委派模型可能无法满足这种需求。
打破双亲委派机制
双亲委派模型并不是一个强制性约束,而是 Java 设计者推荐给开发者的类加载器的实现方式。在一定条件下,为了完成某些操作,可以 “打破” 模型。
重写loadClass方法: 自定义类加载器可以通过重写loadClass方法来改变默认的加载行为。在loadClass方法中,你可以自行决定是否委派给父加载器或者直接加载类。
1 2 3 4 5 6 7 8 public class MyClassLoader extends ClassLoader { @Override public Class<?> loadClass(String name) throws ClassNotFoundException { return super .loadClass(name); } }
独立加载特定类: 如果只想打破双亲委派模型加载某个特定类,可以使用findClass方法,该方法在默认实现中抛出ClassNotFoundException,但你可以重写它以实现自定义加载逻辑。
1 2 3 4 5 6 7 8 public class MyClassLoader extends ClassLoader { @Override protected Class<?> findClass(String name) throws ClassNotFoundException { return super .findClass(name); } }
设置父加载器为null: 在创建自定义类加载器的时候,可以通过构造函数显式地将父加载器设置为null,这将使得自定义加载器成为一个顶级加载器,不再委派给父加载器。
1 2 3 4 5 public class MyClassLoader extends ClassLoader { public MyClassLoader () { super (null ); } }
利用线程上下文加载器: 使用Thread.currentThread().setContextClassLoader在某些场景下,可以通过设置线程上下文类加载器(Context Class Loader)来影响类加载的行为。这样,线程在加载类时将会使用设置的上下文类加载器,而不再受限于双亲委派模型。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import java.io.InputStream;import java.util.Properties;public class ThreadContextClassLoaderExample { public static void main (String[] args) throws Exception { String resourceName = "nzp.test" ; ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); InputStream inputStream = contextClassLoader.getResourceAsStream(resourceName); if (inputStream != null ) { Properties properties = new Properties (); properties.load(inputStream); properties.forEach((key, value) -> System.out.println(key + ": " + value)); } else { System.out.println("Resource not found: " + resourceName); } } }