文章目录
类加载机制
类加载过程
类的加载触发条件
-
一般在这些情况下,如果类没有被加载,那么会被自动加载:
-
使用new关键字创建对象时
-
使用某个类的静态成员(包括方法和字段)的时候
- 当然,final类型的静态字段有可能在编译的时候被放到了当前类的常量池中,这种情况下是不会触发自动加载的
- 在编译阶段自动替换
- 当然,final类型的静态字段有可能在编译的时候被放到了当前类的常量池中,这种情况下是不会触发自动加载的
-
使用反射对类信息进行获取的时候
-
加载一个类的子类时
-
加载接口的实现类,且接口带有
default
的方法默认实现时
-
比如这种情况,那么需要用到另一个类中的成员字段,所以就必须将另一个类加载之后才能访问:
java
public class Main {
public static void main(String[] args) {
System.out.println(Test.str);
}
public static class Test{
static {
System.out.println("我被初始化了!");
}
public static String str = "都看到这里了,不给个三连+关注吗?";
}
}
这里我们就演示一个不太好理解的情况,我们现在将静态成员变量修改为final类型的:
java
public class Main {
public static void main(String[] args) {
System.out.println(Test.str);
}
public static class Test{
static {
System.out.println("我被初始化了!");
}
public final static String str = "都看到这里了,不给个三连+关注吗?";
}
}
可以看到,在主方法中,我们使用了Test类的静态成员变量,并且此静态成员变量是一个final类型的,也就是说不可能再发生改变。那么各位觉得,Test类会像上面一样被初始化吗?
按照正常逻辑来说,既然要用到其他类中的字段,那么肯定需要加载其他类,但是这里我们结果发现,并没有对Test类进行加载,那么这是为什么呢?我们来看看Main类编译之后的字节码指令就知道了:
很明显,这里使用的是ldc
指令从常量池中将字符串取出并推向操作数栈顶,也就是说,在编译阶段,整个Test.str
直接被替换为了对应的字符串(因为final不可能发生改变的,编译就会进行优化,直接来个字符串比你去加载类在获取快得多不是吗,反正结果都一样),所以说编译之后,实际上跟Test类半毛钱关系都没有了。
所以说,当你在某些情况下疑惑为什么类加载了或是没有加载时,可以从字节码指令的角度去进行分析
一般情况下,只要遇到new
、getstatic
、putstatic
、invokestatic
这些指令时,都会进行类加载,比如:
这里很明显,是一定会将Test类进行加载的。
类的详细加载流程
类的生命周期一共有7个阶段
-
加载阶段
-
加载阶段需要获取此类的二进制数据流 ,比如我们要从硬盘中读取一个class文件,那么就可以通过文件输入流来获取类文件的
byte[]
- 也可以是其他各种途径获取类文件的输入流,甚至网络传输并加载一个类也不是不可以。
-
然后交给类加载器进行加载
- 类加载器可以是JDK内置的,也可以是开发者自己撸的
- 类的所有信息会被加载到方法区中,并且在堆内存中会生成一个代表当前类的Class类对象
- 我们可以通过此对象以及反射机制来访问这个类的各种信息。
-
数组类要稍微特殊一点,数组类型本身不会通过类加载器进行加载的
- 不过你既然要往里面丢对象进去,那最终依然是要加载类的。
-
-
验证阶段
-
验证阶段相当于是对加载的类进行一次规范校验
- 因为一个类并不一定是由我们使用IDEA编译出来的,有可能是像我们之前那样直接用ASM框架写的一个
- 如果说类的任何地方不符合虚拟机规范,那么这个类是不会验证通过的,如果没有验证机制
- 那么一旦出现危害虚拟机的操作,整个程序会出现无法预料的后果。
- 因为一个类并不一定是由我们使用IDEA编译出来的,有可能是像我们之前那样直接用ASM框架写的一个
-
验证阶段,首先是文件格式的验证:
-
是否魔数为CAFEBABE开头。
-
主、次版本号是否可以由当前Java虚拟机运行
-
Class文件各个部分的完整性如何。
-
...
-
有关类验证的详细过程,可以参考《深入理解Java虚拟机 第三版》268页。
-
-
-
准备阶段
-
这个阶段会为类变量分配内存
-
并为一些字段设定初始值
- 注意是系统规定的初始值,不是我们手动指定的初始值。
-
-
解析阶段
-
此阶段是将常量池内的符号引用替换为直接引用的过程
-
也就是说,到这个时候,所有引用变量的指向都是已经切切实实地指向了内存中的对象了。
-
到这里,链接过程就结束了
- 也就是说这个时候类基本上已经完成大部分内容的初始化了。
-
-
最后就是真正的初始化阶段了,从这里开始,类中的Java代码部分,才会开始执行
-
<clinit>
方法就是在这个时候执行的-
这个是类在初始化时会调用的方法(是隐式的,自动生成的)
-
它主要是用于静态变量初始化语句和静态块的执行,因为我们这里给静态成员变量a赋值为10,所以会在一开始为其赋值:
-
-
-
全部完成之后,我们的类就算是加载完成了。
类加载器
-
Java提供了类加载器,以便我们自己可以更好地控制类加载
- 我们可以自定义类加载器,也可以使用官方自带的类加载器去加载类。
-
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性。
- 也就是说,一个类可以由不同的类加载器加载
- 并且,不同的类加载器加载的出来的类,即使来自同一个Class文件,也是不同的
- 只有两个类来自同一个Class文件并且是由同一个类加载器加载的,才能判断为是同一个。
- 默认情况下,所有的类都是由JDK自带的类加载器进行加载。
比如,我们先创建一个Test类用于测试:
java
package com.test;
public class Test {
}
接着我们自己实现一个ClassLoader来加载我们的Test类,同时使用官方默认的类加载器来加载:
java
public class Main {
public static void main(String[] args) throws ReflectiveOperationException {
Class<?> testClass1 = Main.class.getClassLoader().loadClass("com.test.Test");
CustomClassLoader customClassLoader = new CustomClassLoader();
Class<?> testClass2 = customClassLoader.loadClass("com.test.Test");
//看看两个类的类加载器是不是同一个
System.out.println(testClass1.getClassLoader());
System.out.println(testClass2.getClassLoader());
//看看两个类是不是长得一模一样
System.out.println(testClass1);
System.out.println(testClass2);
//两个类是同一个吗?
System.out.println(testClass1 == testClass2);
//能成功实现类型转换吗?
Test test = (Test) testClass2.newInstance();
}
static class CustomClassLoader extends ClassLoader {
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
try (FileInputStream stream = new FileInputStream("./target/classes/"+name.replace(".", "/")+".class")){
byte[] data = new byte[stream.available()];
stream.read(data);
if(data.length == 0) return super.loadClass(name);
return defineClass(name, data, 0, data.length);
} catch (IOException e) {
return super.loadClass(name);
}
}
}
}
-
结果:
-
通过结果我们发现,即使两个类是同一个Class文件加载的,只要类加载器不同,那么这两个类就是不同的两个类。
-
实际上,JDK内部提供的类加载器一共有三个
- 比如上面我们的Main类,其实是被AppClassLoader加载的,而JDK内部的类
- 都是由BootstrapClassLoader加载的,这其实就是为了实现双亲委派机制而做的。
双亲委派机制
- 类加载器
- 实际上类加载器就是用于加载一个类的,但是类加载器并不是只有一个。
**思考:**既然说Class对象和加载的类唯一对应,那如果我们手动创建一个与JDK包名一样,同时类名也保持一致,JVM会加载这个类吗?
java
package java.lang;
public class String { //JDK提供的String类也是
public static void main(String[] args) {
System.out.println("我姓🐴,我叫🐴nb");
}
}
我们发现,会出现以下报错:
java
错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
public static void main(String[] args)
但是我们明明在自己写的String类中定义了main方法啊,为什么会找不到此方法呢?实际上这是ClassLoader的双亲委派机制
在保护Java程序的正常运行:
-
实际上类最开始是由BootstarpClassLoader进行加载
- BootstarpClassLoader用于加载JDK提供的类
-
而我们自己编写的类实际上是AppClassLoader加载的
- 只有BootstarpClassLoader都没有加载的类,才会让AppClassLoader来加载
-
因此我们自己编写的同名包同名类不会被加载,而实际要去启动的是真正的String类,也就自然找不到
main
方法了。javapublic class Main { public static void main(String[] args) { System.out.println(Main.class.getClassLoader()); //查看当前类的类加载器 System.out.println(Main.class.getClassLoader().getParent()); //父加载器 System.out.println(Main.class.getClassLoader().getParent().getParent()); //爷爷加载器 System.out.println(String.class.getClassLoader()); //String类的加载器 } }
- 由于BootstarpClassLoader是C++编写的,我们在Java中是获取不到的。
手动将class文件加载到JVM
既然通过ClassLoader就可以加载类,那么我们可以自己手动将class文件加载到JVM中吗?先写好我们定义的类:
java
package com.test;
public class Test {
public String text;
public void test(String str){
System.out.println(text+" > 我是测试方法!"+str);
}
}
通过javac命令,手动编译一个.class文件:
java
nagocoler@NagodeMacBook-Pro HelloWorld % javac src/main/java/com/test/Test.java
编译后,得到一个class文件,我们把它放到根目录下,然后编写一个我们自己的ClassLoader,因为普通的ClassLoader无法加载二进制文件,因此我们编写一个自定义的来让它支持:
java
//定义一个自己的ClassLoader
static class MyClassLoader extends ClassLoader{
public Class<?> defineClass(String name, byte[] b){
return defineClass(name, b, 0, b.length); //调用protected方法,支持载入外部class文件
}
}
public static void main(String[] args) throws IOException {
MyClassLoader classLoader = new MyClassLoader();
FileInputStream stream = new FileInputStream("Test.class");
byte[] bytes = new byte[stream.available()];
stream.read(bytes);
Class<?> clazz = classLoader.defineClass("com.test.Test", bytes); //类名必须和我们定义的保持一致
System.out.println(clazz.getName()); //成功加载外部class文件
}
现在,我们就将此class文件读取并解析为Class了,现在我们就可以对此类进行操作了(注意,我们无法在代码中直接使用此类型,因为它是我们直接加载的),我们来试试看创建一个此类的对象并调用其方法:
java
try {
Object obj = clazz.newInstance();
Method method = clazz.getMethod("test", String.class); //获取我们定义的test(String str)方法
method.invoke(obj, "哥们这瓜多少钱一斤?");
}catch (Exception e){
e.printStackTrace();
}
我们来试试看修改成员字段之后,再来调用此方法:
java
try {
Object obj = clazz.newInstance();
Field field = clazz.getField("text"); //获取成员变量 String text;
field.set(obj, "华强");
Method method = clazz.getMethod("test", String.class); //获取我们定义的test(String str)方法
method.invoke(obj, "哥们这瓜多少钱一斤?");
}catch (Exception e){
e.printStackTrace();
}
探讨Tomcat类加载机制
- Tomcat服务器既然要同时运行多个Web应用程序
- 那么就必须要实现不同应用程序之间的隔离
- 也就是说,Tomcat需要分别去加载不同应用程序的类以及依赖
- 还必须保证应用程序之间的类无法相互访问,而传统的类加载机制无法做到这一点,同时每个应用程序都有自己的依赖
- 如果两个应用程序使用了同一个版本的同一个依赖,那么还有必要去重新加载吗,带着诸多问题,Tomcat服务器编写了一套自己的类加载机制。
- 那么就必须要实现不同应用程序之间的隔离
-
首先我们要知道,Tomcat本身也是一个Java程序
-
它要做的是去动态加载我们编写的Web应用程序中的类
-
而要解决以上提到的一些问题,就出现了几个新的类加载器,我们来看看各个加载器的不同之处:
-
Common ClassLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Web应用程序访问。
-
Catalina ClassLoader:Tomcat容器私有的类加载器 ,加载路径中的class对于Web应用程序不可见。
-
Shared ClassLoader:各个Web应用程序共享的类加载器 ,加载路径中的class对于所有Web应用程序可见,但是对于Tomcat容器不可见。
-
Webapp ClassLoader:各个Web应用程序私有的类加载器,加载路径中的class只对当前Web应用程序可见,每个Web应用程序都有一个自己的类加载器,此加载器可能存在多个实例。
-
JasperLoader:JSP类加载器,每个JSP文件都有一个自己的类加载器,也就是说,此加载器可能会存在多个实例。
-
-
-
-
通过这样进行划分,就很好地解决了我们上面所提到的问题
- 但是我们发现,这样的类加载机制,破坏了JDK的
双亲委派机制
(在JavaSE阶段讲解过 )- 比如Webapp ClassLoader,它只加载自己的class文件,它没有将类交给父类加载器进行加载
- 也就是说,我们可以随意创建和JDK同包同名的类,岂不是就出问题了?
- 实际上,WebAppClassLoader的加载机制是这样的:
- WebAppClassLoader 加载类的时候,绕开了 AppClassLoader
- 直接先使用 ExtClassLoader 来加载类。
- 这样的话,如果定义了同包同名的类,就不会被加载
- 而如果是自己定义 的类,由于该类并不是JDK内部或是扩展类,所有不会被加载,而是再次回到WebAppClassLoader进行加载
- 如果还失败,再使用AppClassloader进行加载。
- WebAppClassLoader 加载类的时候,绕开了 AppClassLoader
- 但是我们发现,这样的类加载机制,破坏了JDK的