Java类加载机制

文章目录

类加载机制

类加载过程

类的加载触发条件
  • 一般在这些情况下,如果类没有被加载,那么会被自动加载:

    • 使用new关键字创建对象时

    • 使用某个类的静态成员(包括方法和字段)的时候

      • 当然,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类半毛钱关系都没有了。

所以说,当你在某些情况下疑惑为什么类加载了或是没有加载时,可以从字节码指令的角度去进行分析

一般情况下,只要遇到newgetstaticputstaticinvokestatic这些指令时,都会进行类加载,比如:

这里很明显,是一定会将Test类进行加载的。

类的详细加载流程

类的生命周期一共有7个阶段

  1. 加载阶段

    • 加载阶段需要获取此类的二进制数据流 ,比如我们要从硬盘中读取一个class文件,那么就可以通过文件输入流来获取类文件的byte[]

      • 也可以是其他各种途径获取类文件的输入流,甚至网络传输并加载一个类也不是不可以。
    • 然后交给类加载器进行加载

      • 类加载器可以是JDK内置的,也可以是开发者自己撸的
      • 类的所有信息会被加载到方法区中,并且在堆内存中会生成一个代表当前类的Class类对象
      • 我们可以通过此对象以及反射机制来访问这个类的各种信息。
    • 数组类要稍微特殊一点,数组类型本身不会通过类加载器进行加载的

      • 不过你既然要往里面丢对象进去,那最终依然是要加载类的。
  2. 验证阶段

    • 验证阶段相当于是对加载的类进行一次规范校验

      • 因为一个类并不一定是由我们使用IDEA编译出来的,有可能是像我们之前那样直接用ASM框架写的一个
        • 如果说类的任何地方不符合虚拟机规范,那么这个类是不会验证通过的,如果没有验证机制
        • 那么一旦出现危害虚拟机的操作,整个程序会出现无法预料的后果。
    • 验证阶段,首先是文件格式的验证:

      • 是否魔数为CAFEBABE开头。

      • 主、次版本号是否可以由当前Java虚拟机运行

      • Class文件各个部分的完整性如何。

      • ...

      • 有关类验证的详细过程,可以参考《深入理解Java虚拟机 第三版》268页。

  3. 准备阶段

    • 这个阶段会为类变量分配内存

    • 并为一些字段设定初始值

      • 注意是系统规定的初始值,不是我们手动指定的初始值。
  4. 解析阶段

    • 此阶段是将常量池内的符号引用替换为直接引用的过程

    • 也就是说,到这个时候,所有引用变量的指向都是已经切切实实地指向了内存中的对象了。

    • 到这里,链接过程就结束了

      • 也就是说这个时候类基本上已经完成大部分内容的初始化了。
  5. 最后就是真正的初始化阶段了,从这里开始,类中的Java代码部分,才会开始执行

    • <clinit>方法就是在这个时候执行的

      • 这个是类在初始化时会调用的方法(是隐式的,自动生成的)

      • 它主要是用于静态变量初始化语句和静态块的执行,因为我们这里给静态成员变量a赋值为10,所以会在一开始为其赋值:

  6. 全部完成之后,我们的类就算是加载完成了。


类加载器

  • 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方法了。

    java 复制代码
    public 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进行加载。
相关推荐
小龙报1 天前
《C语言疑难点 --- C语内存函数专题》
c语言·开发语言·c++·创业创新·学习方法·业界资讯·visual studio
TDengine (老段)1 天前
TDengine 数学函数 CRC32 用户手册
java·大数据·数据库·sql·时序数据库·tdengine·1024程序员节
心随雨下1 天前
Tomcat日志配置与优化指南
java·服务器·tomcat
Kapaseker1 天前
Java 25 中值得关注的新特性
java
wljt1 天前
Linux 常用命令速查手册(Java开发版)
java·linux·python
撩得Android一次心动1 天前
Android 四大组件——BroadcastReceiver(广播)
android·java·android 四大组件
canonical_entropy1 天前
Nop平台到底有什么独特之处,它能用在什么场景?
java·后端·领域驱动设计
chilavert3181 天前
技术演进中的开发沉思-174 java-EJB:分布式通信
java·分布式
国服第二切图仔1 天前
Rust开发实战之简单游戏开发(piston游戏引擎)
开发语言·rust·游戏引擎
ii_best1 天前
安卓/IOS工具开发基础教程:按键精灵一个简单的文字识别游戏验证
android·开发语言·游戏·ios·编辑器