【JAVA 学习笔记】java类文件及类加载 探究

前言

文章仅是笔者个人的学习笔记,存在一些只有笔者个人能看到的用词或者描述,如果有不明确的地方,欢迎留言,理性讨论。

一、类文件结构

  1. 说实话这东西真的非常枯燥,就像一个字典一样。
  2. 类文件结构(.class文件)是java平台无关性的基石。
  3. “.class文件”也并非特指某个存在于 具体磁盘中的文件,而应当是一串二进制字节流,就是它可以不是一个确切的文件。
  4. 从定义好 .class 文件的结构开始(97年,和笔者差不多大了),这么多年它的结构一直没有大的重构过,这确实是很不容易的一件事。
  5. 不过了解了它的实现之后,我感觉,它能做到这一步,靠的是一些“笨办法”:
    1. Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文 件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数 据,没有空隙存在。
    2. 例如它会规定:
      1. u1,u2,u4,u8分别代表 1、2、4、8个字节,
      2. 然后有一张映射表,规定:0x007表示的是类或接口引用,同时它具有1个u1数据,1个u2数据,分别表示什么意思,
      3. 这样按顺序一个字节,一个字节的,就可以把整个.class文件的内容解析出来了。
    3. 这个方法真的很简陋,没有什么取巧的地方,但是作为java运行至关重要的一环,它这种简陋却保证了java的长盛不衰
  6. 一个有趣的知识:
    1. 每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为 一个能被虚拟机接受的Class文件
    2. 这个“魔数”是:0xCAFEBABE (咖啡宝贝?)
  7. 一个简单的.class文件示例,它是16进制的,要用打开16进制文件的工具来打开它

二、java类加载机制概述

  • Java虚拟机 .class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
  • 与那些在编译时需 要进行连接的语言不同,在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的
  • 动态加载和动态连接,是java动态扩展的基础

三、java类加载的时机

  • Java 类的生命周期(加载、使用、卸载出内存)总共分为:
    • 加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading)七个阶段。
    • 其中验证、准备、解析三个部分统称 为连接(Linking)。
    • 解析不一定是在初始化之前,这个是需要注意的!
  • 《Java虚拟机规范》没有强制约束什么时机去进行加载,但是对于什么时机进行初始化,严格规定了以下 6 种场景,必须立刻初始化,注意只是初始化啊!(加载、验证、准备当然要在此之前执行)
    1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始 化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
      1. 使用new关键字实例化对象的时候。
      2. 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外) 的时候。
      3. 调用一个类型的静态方法的时候。
    2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需 要先触发其初始化。
    3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
    4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先 初始化这个主类。
    5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
    6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有 这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
  • 总结一下上面的 6 种场景:
    • 在使用这个类的时候(包括new对象,或者使用的类的变量、静态方法什么的,或者反射调用)当然是要先初始化的
    • 在初始化类的时候,如果它的父类没有初始化(其实子类相当于使用了父类)那么,父类是要先初始化的。
    • jdk 7 的动态语言来使用类、jdk 8的接口默认方法,如果子类修改了这个默认实现,也会触发类和接口的初始化。
  • 这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化(不触发初始化,不代表不触发加载那些,这个要注意),称为被动引用。被动引用的例子有这些:
    • 子类引用父类的静态变量,不会触发子类的初始化
    • 类数组的定义,类似SuperClass[] sca = new SuperClass[10],不会触发初始化
    • 使用类的 static final 变量,不会触发类初始化(因为在编译的时候,这个变量已经被优化成常量放入常量池了,所以其实不涉及类了)
package org.fenixsoft.classloading;

/*** 被动使用类字段演示一: * 通过子类引用父类的静态字段,不会导致子类初始化 **/
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) {
        
       //被动引用,不会触发打印 "SubClass init!"
        System.out.println(SubClass.value);
    }
}

四、类的加载过程

1.“加载”阶段
  • 此“加载”(Loading)是指整个“类加载”(Class Loading)过程中的一个阶段,这个别弄混了
  • 步骤:
    1. 通过一个类的全限定名来获取定义此类的二进制字节流。
    2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
    3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入 口。
  • 灵活性:
    • 对这三个步骤的要求其实并不是特别具体,留给虚拟机实现与Java应用的灵活度都是 相当大的。
    • 从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
    • 从网络中获取,这种场景最典型的应用就是Web Applet。
    • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect.Proxy中,就是用 了ProxyGenerator.generateProxyClass()来为特定接口生成形式为“*$Proxy”的代理类的二进制字节流。
  • 对于非数组类:
    • 加载阶段是非常灵活 的。
    • 既可以使用Java虚拟机里内置的引导类加载器来完成,也可以由用户自定义的类加载器去完成。
    • 通过类似于,重写一个类加载器的findClass()或loadClass()方法,实现动态性
  • 对于数组类:
    • 数组类本身不通过类加载器创建,它是由Java虚拟机直接在 内存中动态构造出来的。
    • 数组的具体元素的类----也就是泛型,当然还是要走正常的类加载流程的
  • 一个类型必须与类加 载器一起确定唯一性:不同类加载器加载的类,即使字段完全一样,也不是同一个类
  • 加载结束之后,类型数据就放置好在方法区了,接着会在java堆中实例化一个.class 对象,这个对象将作为程序访问方法区中的类型数据的外部接口
  • 加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段 尚未完成,连接阶段可能已经开始。也就是说:加载和连接,开始的时间有先后之分, 但是执行的过程是有重叠的

2.“连接-验证”阶段
  • 验证是连接阶段的第一步,目的是确保 class文件的内容是合法的
  • Java虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有错误或有恶意企图的字节码流而导致整个系统受攻击甚至崩溃,所以这是重要的一项自我保护措施
  • 大致步骤(实际规范非常复杂):
    1. 文件格式验证:
      1. 是否以魔数0xCAFEBABE开头。、…肥肠多
      2. 这个阶段的验证是针对二进制字节流的
      3. 通过这个步骤之后,二进制字节流就变成方法区要求的数据结构的了
      4. 后续的验证步骤不会再针对字节流了
    2. 元数据验证
      1. 第二阶段是对字节码描述的信息进行语义分析 ,如:
      2. 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。、是否继承了不能继承的类、不是抽象类的话,是否实现了所有抽象方法、…
    3. 字节码验证
      1. 第三阶段是最为复杂的一个阶段,
      2. 主要目的是分析数据流和控制流,以确定程序语义是合法且符合逻辑的
      3. 主要的校验对象,是类的方法体(Class文件中的Code属性)
      4. 保证方法体中的类型转换总是有效的(父类不能强转成子类)、…
      5. 由于数据流分析和控制流分析的高度复杂性,为了优化速度,在JDK 6之后,把尽可能多的校验辅助措施挪到Javac编译器里进行
    4. 符号引用验证
      1. 最后一个阶段的校验行为,发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段-----解析阶段发生。
      2. 这再次说明了,各个阶段的执行并不是严格的上一个阶段执行完再执行下一个阶段,它们的顺序只是它们第一个子步骤开始的顺序,后续其他步骤之间是有穿插的。
      3. 校验内容有:符号引用中的全限定名(包名路径)是否能找到对应的类、要求的方法是否能找到并访问、…
  • 验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为 验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。
  • 所以可以考虑使用-Xverify:none参数来关闭大部分的类验证措施。当然,安卓是不可能这样做的吧。

3.“连接-准备”阶段
  • 准备阶段是正式为类中定义的字段(静态变量)分配内存并设置初始值的阶段,从概念上讲,这些变量应该是放在方法区中的,但是,方法区已经是一个逻辑上的概念了:
    • 从《Java虚拟机规范》所定义的概念模型来看,所有Class相关的信息都应该存放在方法区之中。
    • 但方法区该如何实现,《Java虚拟机规范》并未做出规定,这就成了一件允许不同虚拟机自己灵活把 握的事情。
    • JDK 7及其以后版本的HotSpot虚拟机选择把静态变量与类型在Java语言一端的映射Class对 象存放在一起,存储于Java堆之中,从我们的实验中也明确验证了这一点。
  • 两个容易混淆和弄错的概念:
    • 首先是这时候进行内存分配的 仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
    • 其 次是这里所说的初始值“通常情况”下是数据类型的零值:
      • 例如:public static int value = 123;
      • 经过准备阶段后,它的值是0,而不是 123,因为这时候没有执行任何java方法,实际的赋值要到初始化时候才会做的
      • 当然,如果是 static final 修饰的,那它相当于是常量了,就会直接赋值 123,而不是等初始化的时候才变成123。

4.“连接-解析”阶段
  • 解析阶段是Java虚拟机将常量池内的符号引用,替换为直接引用的过程,按我理解
    • 符号引用就是类似包名+类名的一个可以表明引用类型的字符串之类的东西,
    • 而直接引用就是内存中的地址,或者是一个句柄,反正是能实际定位内存位置的东西
  • 《Java虚拟机规范》之中并未规定解析阶段发生的具体时间,只要求了在执行 new、getfileid、…等指令之前,先对它们使用的符号引用进行解析。
  • 所以虚拟机实现可以根据需 要来自行判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引 用将要被使用前才去解析它。
    • 解析阶段还会对方法或者字段的可见性进行检查:private、public、protect、。
  • 一般的解析分类,假设当前类为D,要解析的类或接口为C:
    • 类或接口的解析:
      • 首先数组和非数组是区分开的
      • 针对非数组来说
        • 首先要将C的全限定名交给D的类加载器去加载C这个类,中间过程就是一般类加载的过程,按下不表
        • 加载完成之后,虚拟机中实际已经有了C这个类或接口的数据了,这时候会检查访问权限,如果不具备访问权限,那么会抛出异常
        • 这里注意模块化之后,不仅是检查权限关键字,还要检查模块间的可见性
      • 针对数组来说
        • 这里要加载的类或接口,就是数组的元素了,加载完成之后,会由虚拟机来生成一个数组对象
        • 其他和非数组的处理是一致的
    • 字段解析:
      • 首先要解析字段所属的,类或者接口的符号引用进行解析,也就是上面的那部分内容
      • 接着按步骤进行判断:
        • C本身就包含该字段,那就直接找到了,返回就是
        • 否则,如果C实现了接口,从下往上找它的接口及父接口,也就是按照接口继承关系来找,直到找到
        • 否则,从下往上查找它的父类,也就是按类继承关系来找,直到找到
        • 如果都没有,抛出异常
    • 方法的解析
      • 方法解析和字段解析一样,也是要先去加载类的
      • 由于class文件结构中,类的方法和接口的方法的定义是分开的,所以如果在类的方法表中,发现索引的C是一个接口,就会直接抛异常了,(这里要怎么理解呢,有点不懂
      • 如果上述检查通过了,那么就在C中查找是否有符合要求的方法
      • 否则,从下往上查找它的父类,也就是按类继承关系来找,直到找到
      • 否则,如果C实现了接口,从下往上找它的接口及父接口,也就是按照接口继承关系来找,如果找到了,说明C是一抽象类,实例化抽象类是不行的,那么就要抛出异常了
      • 否则,查找失败,抛出异常
    • 接口方法的解析
      • 接口方法的解析和类方法的是相似的,只是反过来,如果发现索引的C是一个类,那就要抛异常了
      • 就在C中查找是否有符合要求的方法
      • 否则,在C的父接口中,根据继承关系,从下往上的查找
      • 因为接口是允许多重继承的,所以如果有同名的,会返回其中一个,具体规则就看虚拟机怎么定义了
      • 如果没找到,抛出异常

5.“初始化”阶段
  • 类的初始化,是类加载的最后一个步骤。前面的步骤,除了加载阶段用户可以自定义类加载器,其他阶段基本上是由虚拟机来把控的,用户不能参与进行自定义的操作。
  • 进行准备阶段时,变量已经赋过一次系统要求的初始零值。而在初始化阶段,则会根据开发者的自定义设置,去初始化类变量和其他资源。
  • 也可以更具代码化的表述方式:
    • 初始化阶段就是执行类构造器()方法的过程。()并不是程序员在Java代码中直接编写 的方法,它是Javac编译器的自动生成物。
    • ()方法是由编译器自动收集类中的所有类变量的赋值动作,和静态语句块(static{}块)中的 语句合并产生的
    • 注意:静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,怎么理解呢,看代码:
public class Test { 
    static { 
        i = 0; // 给变量复制可以正常编译通过 
        System.out.print(i); // 这句编译器会提示“非法向前引用” 
    }
    static int i = 1; 
}
  • 同时,父类的该方法,自然是要在子类的之前执行的,这就涉及到代码执行顺序问题了,知识点啊
  • 此外,接口的情况要单独讨论:
    • 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成 ()方法。
    • 但接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法, 因为只有当父接口中定义的变量被使用时,父接口才会被初始化。
    • 接口的实现类在初始化时也 一样不会执行接口的()方法。
  • 这里还特别需要考虑多线程的问题:
    • Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步,
    • 如果多个线程同 时去初始化一个类,那么只会有其中一个线程去执行这个类的()方法,其他线程都需要阻塞等 待,直到活动线程执行完毕()方法。
    • 所以注意了,不要在() 方法做耗时的操作,不然是会卡线程的!

五、类加载器

1.类与类加载器
  • 类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于一个类来说,它的唯一性,是通过它本身+加载它的类加载器共同来决定的
  • 也就是说:不同类加载器加载的同一个类文件,在虚拟机中,就是不同的类对象

2.双亲委派模型(重点中的重点)
  • 细致一点的划分,java的加载器分三层:
    • 启动类加载器(Bootstrap Class Loader):这个类加载器可能使用C++语言实现(看是什么虚拟机),是虚拟机自身的一部分
    • 扩展类加载器(Extension Class Loader):JDK的开发团队允许用户将具有通用性的类库放置在ext目录里以扩展Java SE的功能
    • 应用程序类加载器(Application Class Loader):它负责加载用户类路径 (ClassPath)上所有的类库
  • 双亲委派模型:
    • 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。注意这里是持有引用,就是组合的形式,而非继承的形式
    • 双亲委派模型的工作过程是:
      • 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加 载这个类,而是把这个请求委派给父类加载器去完成,
      • 每一个层次的类加载器都是如此,因此所有的 加载请求最终都应该传送到最顶层的启动类加载器中,
      • 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
    • 它的好处是显而易见的:
      • 因为上面也有说,Java中类的唯一性是有它的加载器参与确认的,同一个加载器加载出的,才是同一个类,
      • 双亲委派就确保了类似于 java.lang.object 这种基础类,每次都会由启动类加载器来加载,
      • 这样就确保了Object类的唯一性,不会有恶意的基础类代码(例如自定义一个 java.lang.object类)被加载进虚拟机中
    • 双亲委派的代码和逻辑结构都非常清晰简单
      • image.png

3.破坏双亲委派模型
  • 第一次被破坏:
    • java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代 码,Java设计者们引入双亲委派模型时不得不做出一些妥协
    • 无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一个新的 protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass()中编写代码。
  • 第二次被破坏:
    • 由这个模型自身的缺陷导致的
    • 双亲委派解决了基础类的加载问题,但是如果基础类又要调用回用户的代码呢,这时候就会出问题了
    • 为了解决这个困境,Java的设计团队只好引入了一个不太优雅的设计:线程上下文类加载器 (Thread Context ClassLoader)。(这里不太懂,后续补充学习一下
      • 这个类加载器可以通过java.lang.Thread类的setContext-ClassLoader()方法进行设置,
      • 如果创建线程时还未设置,它将会从父线程中继承一个,
      • 如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器
    • 这是一种父类加载器去请求子类加载器完成类加载的行为,这种行 为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器
    • 同时,当所需要向下查找的类加载器多于一个的时候,它还会有问题,因为需要判断目前所需的是哪个加载器。在JDK 6时,JDK提供了 java.util.ServiceLoader类,以META-INF/services中的配置信息,辅以责任链模式,才解决这个问题
  • 第三次被破坏:
    • 是由于用户对程序动态性的追求而导致的
    • 具体可参见 OSGi 框架的相关知识,看起来主要是用于服务端的,不知道安卓的热修复等有没有使用到。

六、总结

  • 明确了Java类文件的结构:
    • .class文件就像一个字典一样,非常的枯燥,很工具化
    • “.class文件”也并非特指某个存在于 具体磁盘中的文件,它准确定义是一串二进制流
    • 它是java平台无关性的基础
    • 每个Class文件的头4个字节被称为魔数,它会被用来确认此文件是一个Class文件,这个魔数是:0xCAFEBABE (咖啡宝贝?)
  • 分析了Java类加载的流程:
    • Java 类的生命周期(加载、使用、卸载出内存)总共分为:加载 、验证、准备、解析、初始化 、使用和卸载七个阶段。
    • 其中验证、准备、解析三个部分统称 为连接(Linking)。
    • 每个阶段做了什么时候,这个需要多复习。
  • 分析了Java的类加载器:
    • 双亲委派模型,机制是先父类后自己,可以避免恶意代码导致加载基础类出错。
    • 双亲委派也经历过“破坏”,总共有三次,说是破坏,其实从另一个角度来说,也是对双亲委派的完善和发展。

七、引用

  • 《深入理解java虚拟机-第三版》-- 第六章类文件结构
  • 《深入理解java虚拟机-第三版》-- 第七章类虚拟机类加载机制]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值