【Jvm基础篇4】类加载

💝💝💝欢迎来到我的博客,很高兴能够在这里和您见面!希望您在这里可以感受到一份轻松愉快的氛围,不仅可以获得有趣的内容和知识,也可以畅所欲言、分享您的想法和见解。
img

  • 推荐:kuan 的首页,持续学习,不断总结,共同进步,活到老学到老
  • 导航
    • 檀越剑指大厂系列:全面总结 java 核心技术点,如集合,jvm,并发编程 redis,kafka,Spring,微服务,Netty 等
    • 常用开发工具系列:罗列常用的开发工具,如 IDEA,Mac,Alfred,electerm,Git,typora,apifox 等
    • 数据库系列:详细总结了常用数据库 mysql 技术点,以及工作中遇到的 mysql 问题等
    • 懒人运维系列:总结好用的命令,解放双手不香吗?能用一个命令完成绝不用两个操作
    • 数据结构与算法系列:总结数据结构和算法,不同类型针对性训练,提升编程思维,剑指大厂

非常期待和您一起在这个小小的网络世界里共同探索、学习和成长。💝💝💝 ✨✨ 欢迎订阅本专栏 ✨✨

四.类加载

1.jvm 类加载的整体流程?

  1. 通过一个类的全限定名来获取此类的二进制字节流(加载阶段)
  2. Class 文件的格式验证(连接–>验证–>文件格式验证)
  3. 将这个字节流所代表的的静态存储(class 文件本身)结构转化为方法区的运行时数据结构(加载阶段)
  4. 在内存(堆内存)中生成这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口(加载阶段)
  5. 元数据验证(连接–>验证–>元数据验证)
  6. 字节码验证(连接–>验证–>字节码验证)
  7. 准备阶段,赋初始值(连接–>准备)
  8. 解析/符号引用验证(连接–>解析–>连接–>验证–>符号引用验证)
  9. 初始化(初始化)

image-20220319140535165

2.加载阶段 JVM 具体进行了什么操作?

加载阶段主要完成了 3 件事情

  1. 通过一个类的全限定名生成类的二进制字节流
  2. 将这个二进制字节流的静态存储结构转化为在方法区中虚拟机支持的运行时数据结构(将虚拟机外部的字节流转化为虚拟机所设定的格式存储在方法区中)
  3. 在内存中生成一个代表这个类的 java.lang.class 对象,作为方法区这个类的各种数据的访问入口

相对于类加载的过程,非数组类型的加载阶段(准确的说,是获取类的二进制字节流的动作)是开发人员可控性最强的阶段,可以使用虚拟机内置的类加载器来完成,也可以由用户自定义的类加载器来完成,开发人员通过自定义的类加载器去控制字节流的获取方式(重写一个类的类加载器的 findClass 方法或者 loadClass 方法),根据需求获取运行代码的动态性.

3.JVM 加载数组和加载类的区别?

对于数组而言,情况有所不同,数组类本身不通过类加载器创建,它是由 java 虚拟机直接在内存中动态构建出来的,但是数组跟类加载器还是密切关联的,因为数组类的元素类型最终还是需要通过类加载器加载完成.

如果数组的元素类型是引用类型,那么遵循 JVM 的加载过程,去加载这个组件类型.数组类将被标识在加载该组件类型的类加器的类命名空间上.(这一点很重要,一个类必须与类加载器一起确定唯一性)

如果数组类的组件类型不是引用类型(比如 int[]),java 虚拟机会把数组类标记为与启动类加载器关联

数组类的可访问性和它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类型的可访问性默认是 public,可被所有的接口和类访问到.

4.验证阶段 JVM 主要做了什么?

验证是连接的第一步.这一阶段的主要的目的是确保 class 的文件的二进制字节流中包含的信息符合 java 虚拟机规范的全部约束要求,保证这些信息被当做代码运行后,不会对虚拟机自身造成危害.

验证阶段是非常重要的,从代码量和耗费的执行性能的角度上来讲,验证阶段的工作量在虚拟机整个类加载过程中占比相当大.

验证主要分为四个验证: 文件格式验证,元数据验证,字节码验证,符号引用验证

文件格式验证:

  • 是否以魔数开头
  • 主次版本号是否在当前虚拟机支持的范围内
  • 常量池中是否含有不被支持的常量类型(检查常量 tag 标志)
  • 指向常量的各种索引值中是否含有指向不存在的常量或者不符合类型的常量
  • constant_utf8_info 型的常量是否存在不符合 utf8 编码的数据
  • class 文件中各个部分以及文件本身是否有被删除的或者附加的其他信息

这个阶段是基于二进制字节流进行的,只有通过这个验证,二进制字节流才会到达方法区进行存储,后面的三个验证也是基于方法区的存储结构,不会直接读取字节流了

元数据验证:

  • 这个类是否包含父类(除了 java.lang.object 外,所有类都要有父类)
  • 这个类的父类是否继承了不允许被继承的类(final 修饰的类)
  • 如果这个类不是抽象类,是否实现了其父类或者接口中要求实现的所有方法
  • 类中的字段,方法是否与父类中产生矛盾,(例如覆盖了父类的 final 字段,或者出现了不符合规范的方法重载)

字节码验证:

这一阶段是验证阶段最为复杂的阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的.这阶段主要是校验 class 文件的 code 属性.

  • 保证任意时刻操作数栈的数据类型和指令代码序列都能配合工作,例如不会出现类似于“在操作数栈放置了一个 int 类型的数据,使用的时候按 long 类型来加载入本地变量表中”这样的情况
  • 保证任何跳转指令都不会跳转到方法体以外的字节码指令上
  • 保证方法体之内的类型转换都是有效的,比如,可以把一个子类赋值给父类数据类型,这是安全的,但是把父类赋值给子类数据类型,甚至是毫无关系的数据类型,这是危险的,不合法的.

由于数据流和控制流的高度复杂性,为了避免过多的时间消耗,在 jdk1.6 之后的 javac 编译和 java 虚拟机里进行了一项联合优化,把尽可能多的校验移到 javac 编译器里进行.具体做法是给方法体 code 属性的属性表中添加一项 StackMapTable 的新属性,这个属性描述了方法体所有的基本块,开始时本地变量表和操作数栈应有的状态,在字节码验证期间,java 虚拟机就不需要根据程序推导了,只需要检查 StackMapTable 的记录是否合法即可.这样字节码验证的类型推导就变成了类型检查,从而节省了大量的校验时间.

符号引用验证:

最后一个验证阶段发生在虚拟机将符号引用转化为直接引用的时候,这个过程在连接的第三个阶段解析阶段发生.

符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗讲就是验证该类是否缺少或者被禁止访问它依赖的某些外部类,方法,字段等资源.

  • 符号引用中通过字符串的全限定名能否找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的字段和方法
  • 符号引用中的类,字段,方法的可访问性是否能被当前类访问

符号引用的目的是确保解析行为能够正常执行,如果无法通过符号引用验证,java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,典型的如:java.lang.IllegalAccessError,java.lang.NoSuchFieldError,java.lang.NoSuchMethodError 等

5.类加载器连接的准备阶段做了什么?

准备阶段是正式为类中定义的变量(静态变量,被 static 修饰的变量),分配内存并设置初始值的过程

这些变量使用的空间应当在方法区中进行分配,在 jdk1.7 及之前,hotspot 使用永久代来实现方法区,在 jdk1.8 之后,类变量会随着 class 对象一起存放在堆中.

准备阶段进行内存分配的只有类变量不包含实例变量,实例变量需要在对象实例化后在堆中分配,通常情况下,初始值一般为零值.这些内存都将在方法区内分配内存,不包含实例变量,实例变量在堆内存中,而且实例变量是在对象初始化时才赋值

public static int value=123;

准备阶段初始值是 0 不是 123,因为在此时还没执行任务 java 方法,而把 value 赋值为 123 是在 putstatic 指令是程序被编译后,存放在类构造器的 clinit 方法中,赋值为 123 在类的初始化阶段.上面说的通常情况是初始值为零值,特殊情况下被 final,static 修饰时,直接赋予ConstantValue属性值.比如 final static String

6.类加载器连接的解析阶段做了什么?

解析阶段是将常量池内的符号引用转化为直接引用的过程

符号引用: 用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时可以定位到目标即可

直接引用: 直接引用可以直接指向目标的指针,相对偏移量或者一个能间接定位到目标的句柄.如果有了直接引用,那么引用的目标在虚拟机的内存中一定存在.

并未明确指出解析的具体时间,可以是在加载时就解析常量池中的符号引用,或者是等到第一个符号引用将要被使用前解析它,这个 java 虚拟机规范中没有明确说明.对同一个符号引用进行多次解析是存在的,虚拟机可以对第一次解析的结果进行缓存,譬如运行时直接引用常量池中的状态,并把常量标示为已解析状态,从而避免了重复动作.

解析动作主要针对类或接口,字段,类方法,接口方法,方法类型,方法句柄,调用点限定符等 7 类符号引用进行,分别对应了常量池中的

  • constant_class_info
  • constant_firldref_info
  • constant_methodref_info
  • constant_interfacemethodref_info
  • constant_methodtype_info
  • constant_methodhandle_info
  • constant_dynamic_info
  • constant_invokedyanmic_info

一共 8 种常量类型.

7.类加载器初始化阶段做了什么?

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由 Java 虚拟机来主导控制。直到初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器 clinit()方法的过程。clinit()并不是程序员在 Java 代码中直接编写的方法,它是 Javac 编译器的自动生成物。

8.说说你对 clinit 方法的理解?

clinit 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的.编译器收集的顺序是由源文件中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在之后的变量发,在前面的静态语句块可以赋值,但是不能访问.

public class Jvm_0999_static {
    static {
        i = 0;//给变量赋值可以正常编译通过
        System.out.print(i);//这句编译器会提示“非法向前引用”
    }
    static int i = 1;
}

clinit 方法与类的构造函数不同,它不需要显式的调用父类构造器,java 虚拟机会保证在子类的 clinit 方法执行之前,父类的 clinit 方法已经执行完毕,因此在 java 虚拟机中第一个被执行的 clinit 方法的类型肯定是 java.lang.object 类型

public class Jvm_09999_Parent {
  //依次为A=0,A=1,A=2
  public static int A = 1;

  static {
    A = 2;
  }

  static class Sub extends Jvm_09999_Parent {
    public static int B = A;
  }

  public static void main(String[] args) {
    System.out.println(Sub.B);
  }
}

clinit 方法对于类或者接口不是必须的,如果一个类没有静态语句块,也没有对变量的赋值行为,那么编译器就不会生成这个类的 clinit 方法.

接口中不能使用静态语句块,但是可以有变量赋值操作,因此接口和类一样也会生成 clinit 方法,但是接口和类不同的是,接口的 clinit 方法不需要先执行父类的 clinit 方法,因为只有当父接口中被定义的接口被使用时,父接口才会初始化.接口的实现类在初始化时一样不会执行 clinit 方法.

java 虚拟机必须保证一个类的 clinit 方法在多环境中被正确的同步加锁.如果是多线程去初始化一个类,那么只会有一个线程去执行这个类的 clinit 方法,其他线程需要阻塞等待,直到活动线程执行完 clinit 方法.其他线程被唤醒后不会继续执行 clinit 方法.

9.JVM 会立即对类进行初始化?

对于初始化阶段,java 虚拟机规范严格规定有且只有 6 种情况必须对类进行“初始化”

  1. 遇到 new,getstatic,putstatic,invokestatic 这四条指令时,如果类型没有进行初始化,则需要触发其初始化阶段,这四条指令的 java 场景
    • 使用 new 关键字实例化对象的时候
    • 读取或设置一个类型的静态字段(被 final 修饰,已在编译器把结果放入常量池的静态字段除外)的时候
    • 调用一个类型的静态方法的时候
  2. 使用 java.lang.reflect 包的方法对类型进行反射调用的时候,如果类型没有初始化,则需要先触发其初始化
  3. 当初始化类时,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化
  4. 当虚拟机宕机时,用户需要指定一个主类,(包含 main 方法的那个类),虚拟机会先初始化这个类
  5. 当使用 jdk1.7 新加入的动态语言支持时,如果一个 java.lang.invoke.methodhandle 实例最后的解析结果为 ref_getstatic,ref_putstatic,ref_invokestatic,``ref_newinvokespecial` 四种类型的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化.
  6. 当一个接口中定义了默认方法,如果这个类的实现类发生了初始化,这个接口要在其之前被初始化.
public class SuperClass {
  static {
    System.out.println("SuperClass init");
  }

  public static int value = 123;
}

public class SubClass extends SuperClass {
  static {
    System.out.println("SubClass init");
  }
}

public class NotInitialization {

  public static void main(String[] args){
    System.out.println(SubClass.value);
  }
}

上述代码只会执行“SuperClass init”,对于静态字段,只有直接定义这个字段的类才会被初始化,因此只会触发父类的初始化,不会触发子类的初始化.

public class NotInitialization {

  public static void main(String[] args){
    SuperClass[] sc = new SuperClass[10];
  }
}

通过数组引用的类,不会触发此类的初始化.

public class ConstClass {
  static {
    System.out.println("ConstClass init");
  }
  public static final String HELLO_WORD = "hello world";
}

public class NotInitialization {
  public static void main(String[] args){
    System.out.println(ConstClass.HELLO_WORD);
  }
}

final static 常量不会触发类的初始化编译器就放入到属性表的 ConstantValue

10.不同的类加载器对 instanceof 影响?

public class ClassLoaderTest {
  public static void main(String[] args) throws Exception {
    ClassLoader myLoader = new ClassLoader(){
      @Override
      public Class<?> loadClass(String name) throws ClassNotFoundException {
        try {
          String fileName = name.substring(name.lastIndexOf(".")+1)+".class";
          InputStream resourceAsStream = getClass().getResourceAsStream(fileName);
          if (resourceAsStream == null){
            return super.loadClass(name);
          }
          byte[] bytes = new byte[resourceAsStream.available()];
          resourceAsStream.read(bytes);
          return defineClass(name, bytes,0, bytes.length);
        } catch (IOException e){
          throw new ClassNotFoundException();
        }
      }
    };
    Object o = myLoader.loadClass("com.xiaofei.antbasic.demo4.ClassLoaderTest").newInstance();
    System.out.println(o.getClass());
    System.out.println(o instanceof com.xiaofei.antbasic.demo4.ClassLoaderTest);
  }
}
class com.xiaofei.antbasic.demo4.ClassLoaderTest
false

虚拟机中存在了 2 个 ClassLoaderTest 类,一个是虚拟机的类加载器加载的,另一个是我们自定义的类加载器加载的,即使这 2 个类源自同一个 class 文件,被同一个 java 虚拟机加载,但是类加载器不同,那么 2 个类必不相等.

11.说下双亲委派模型?

image-20220322091848963

启动类加载器:负责加载存在 java_home/lib 下的,或者是被-xbootclasspath 参数所指定的路径中存放,而且能被虚拟机所识别的(按照文件名称识别,如 rt.jar,tools.jar)类库加载到虚拟机内存中.启动类加载器无法被用户直接引用,如果需要委派加载请求给启动类加载器,直接使用 null 代替即可.

扩展类加载器:它负责加载 java_home\lib\ext 目录中的,或者被 java.ext.dirs 所指定路径中的类库.

应用程序类加载器:这个类加载器是 ClassLoader 类中 getSystem-ClassLoader 方法的返回值.所以也称为系统类加载器.它负责加载类路径 classpath 上所有的类库,如果没有自定义类加载器,应用程序类加载器就是默认的类加载器.

双亲委派模型要求,除了顶层的类加载器除外,其他的类加载器都要有父类加载器,这里的父子不是继承关系,而是组合关系来复用父加载器的代码.

双亲委派工作流程:

当一个类加载器收到类加载的请求,首先不会自己去加载此类,而是请求父类去加载这个类,层层如此,如果父类加载器不能加载此类(在搜索范围内没有找到需要的类),子类才会去尝试加载.

使用双亲委派的好处是,java 中的类随着类加载器具备了一种优先级的层次关系,例如 java.lang.object,它存放在 rt.jar 中,无论哪一个类加载器要加载这个类,最终都委派给顶层的启动类加载器去加载,因此 object 在各个类加载器环境中都能保证是同一个类.

ClassLoader 源码

protected Class<?> loadClass(String name, boolean resolve)
  throws ClassNotFoundException
{
  synchronized (getClassLoadingLock(name)){
    // First, check if the class has already been loaded
    Class<?> c = findLoadedClass(name);
    if (c == null){
      long t0 = System.nanoTime();
      try {
        if (parent != null){
          c = parent.loadClass(name, false);
        } else {
          c = findBootstrapClassOrNull(name);
        }
      } catch (ClassNotFoundException e){
        // ClassNotFoundException thrown if class not found
        // from the non-null parent class loader
      }

      if (c == null){
        // If still not found, then invoke findClass in order
        // to find the class.
        long t1 = System.nanoTime();
        c = findClass(name);

        // this is the defining class loader; record the stats
        sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
        sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
        sun.misc.PerfCounter.getFindClasses().increment();
      }
    }
    if (resolve){
      resolveClass(c);
    }
    return c;
  }
}

双亲委派的破坏:自定义类加载器,然后重写 loadClass()方法

12.为什么 Tomcat 打破双亲委派?

tomcat 的类加载机制是违反了双亲委托原则的,对于一些未加载的非基础类(Object,String 等),各个 web 应用自己的类加载器(WebAppClassLoader)会优先加载,加载不到时再交给 commonClassLoader 走双亲委托。

image-20230728002638535

一个 web 容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离。

打破双亲委派的真正目的是,当 classpath、catalina.properties 中 common.loader 对应的路径、web 应用/WEB-INF,下有同样的 jar 包时,优先使用/WEB-INF 下的 jar 包.

tomcat 不遵循双亲委派机制,只是自定义的 classLoader 顺序不同,但顶层还是相同的,还是要去顶层请求 classloader.

13.类元素加载过程

如果有两个类 A 和 B,B 继承了 A,A 和 B 里面都有静态方法和静态变量,那么它的是怎么样的呢?

1、父类的静态变量

2、父类的静态代码块

3、子类的静态变量

4、子类的静态代码块

5、父类的非静态变量

6、父类的非静态代码块

7、父类的构造方法

8、子类的非静态变量

9、子类的非静态代码块

10、子类的构造方法

觉得有用的话点个赞 👍🏻 呗。
❤️❤️❤️本人水平有限,如有纰漏,欢迎各位大佬评论批评指正!😄😄😄

💘💘💘如果觉得这篇文对你有帮助的话,也请给个点赞、收藏下吧,非常感谢!👍 👍 👍

🔥🔥🔥Stay Hungry Stay Foolish 道阻且长,行则将至,让我们一起加油吧!🌙🌙🌙

img

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Kwan的解忧杂货铺@新空间代码工作室

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

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

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

打赏作者

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

抵扣说明:

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

余额充值