Java类加载机制

类加载机制

类加载过程

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

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

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

      • 当然,final类型的静态字段有可能在编译的时候被放到了当前类的常量池中,这种情况下是不会触发自动加载的
        • 在编译阶段自动替换
    • 使用反射对类信息进行获取的时候

    • 加载一个类的子类时

    • 加载接口的实现类,且接口带有default的方法默认实现时

比如这种情况,那么需要用到另一个类中的成员字段,所以就必须将另一个类加载之后才能访问:

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类型的:

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类编译之后的字节码指令就知道了:

image-20230306170622970

很明显,这里使用的是ldc指令从常量池中将字符串取出并推向操作数栈顶,也就是说,在编译阶段,整个Test.str直接被替换为了对应的字符串(因为final不可能发生改变的,编译就会进行优化,直接来个字符串比你去加载类在获取快得多不是吗,反正结果都一样),所以说编译之后,实际上跟Test类半毛钱关系都没有了。

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

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

image-20230306170635704

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

类的详细加载流程

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

image-20230306170654350

  1. 加载阶段

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

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

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

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

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

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

      • 是否魔数为CAFEBABE开头。

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

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

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

  3. 准备阶段

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

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

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

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

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

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

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

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

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

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

        image-20230306170411225

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


类加载器

  • Java提供了类加载器,以便我们自己可以更好地控制类加载

    • 我们可以自定义类加载器,也可以使用官方自带的类加载器去加载类。
  • 对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性。

    • 也就是说,一个类可以由不同的类加载器加载
    • 并且,不同的类加载器加载的出来的类,即使来自同一个Class文件,也是不同的
    • 只有两个类来自同一个Class文件并且是由同一个类加载器加载的,才能判断为是同一个。
    • 默认情况下,所有的类都是由JDK自带的类加载器进行加载

比如,我们先创建一个Test类用于测试:

package com.test;

public class Test {
    
}

接着我们自己实现一个ClassLoader来加载我们的Test类,同时使用官方默认的类加载器来加载:

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);
            }
        }
    }
}
  • 结果:

    • image-20231105202743811
  • 通过结果我们发现,即使两个类是同一个Class文件加载的,只要类加载器不同,那么这两个类就是不同的两个类

  • 实际上,JDK内部提供的类加载器一共有三个

    • 比如上面我们的Main类,其实是被AppClassLoader加载的,而JDK内部的类
    • 都是由BootstrapClassLoader加载的,这其实就是为了实现双亲委派机制而做的。

双亲委派机制

  • 类加载器
    • 实际上类加载器就是用于加载一个类的,但是类加载器并不是只有一个。

**思考:**既然说Class对象和加载的类唯一对应,那如果我们手动创建一个与JDK包名一样,同时类名也保持一致,JVM会加载这个类吗?

package java.lang;

public class String {    //JDK提供的String类也是
    public static void main(String[] args) {
        System.out.println("我姓🐴,我叫🐴nb");
    }
}

我们发现,会出现以下报错:

错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
   public static void main(String[] args)

但是我们明明在自己写的String类中定义了main方法啊,为什么会找不到此方法呢?实际上这是ClassLoader的双亲委派机制在保护Java程序的正常运行:

img

  • 实际上类最开始是由BootstarpClassLoader进行加载

    • BootstarpClassLoader用于加载JDK提供的类
  • 我们自己编写的类实际上是AppClassLoader加载的

    • 只有BootstarpClassLoader都没有加载的类,才会让AppClassLoader来加载
  • 因此我们自己编写的同名包同名类不会被加载,而实际要去启动的是真正的String类,也就自然找不到main方法了。

    • 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类的加载器
          }
      }
      
      • image-20231105203043885
      • 由于BootstarpClassLoader是C++编写的,我们在Java中是获取不到的。

手动将class文件加载到JVM

既然通过ClassLoader就可以加载类,那么我们可以自己手动将class文件加载到JVM中吗?先写好我们定义的类:

package com.test;

public class Test {
    public String text;

    public void test(String str){
        System.out.println(text+" > 我是测试方法!"+str);
    }
}

通过javac命令,手动编译一个.class文件:

nagocoler@NagodeMacBook-Pro HelloWorld % javac src/main/java/com/test/Test.java

编译后,得到一个class文件,我们把它放到根目录下,然后编写一个我们自己的ClassLoader,因为普通的ClassLoader无法加载二进制文件,因此我们编写一个自定义的来让它支持:

//定义一个自己的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了,现在我们就可以对此类进行操作了(注意,我们无法在代码中直接使用此类型,因为它是我们直接加载的),我们来试试看创建一个此类的对象并调用其方法:

try {
    Object obj = clazz.newInstance();
    Method method = clazz.getMethod("test", String.class);   //获取我们定义的test(String str)方法
    method.invoke(obj, "哥们这瓜多少钱一斤?");
}catch (Exception e){
    e.printStackTrace();
}

我们来试试看修改成员字段之后,再来调用此方法:

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服务器编写了一套自己的类加载机制。

img

  • 首先我们要知道,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进行加载。

img

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

雪芙花

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值