JVM类加载机制

前言

前两天看了JVM中字节码的内存分布,趁热打铁,学习一下JVM的类加载机制,并做一下记录。
其中最重要的部分为类加载中的解析。

类加载

java中类的生命周期:

加载–>验证–>准备–>解析–>初始化–>使用–>卸载

其中 验证,准备,解析可以归为连接阶段。
另外 解析可能会在初始化之后开始。

不会初始化类的一些情况:

  • 在子类中引用父类的静态字段,不会初始化子类。
  • 通过数组定义来实用类。

    1
    Test[] a = new Test[10];
  • 使用某个类的常量 static final

加载
  1. 通过类的全限定名获取定义此类的二进制流。
  2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成对应的.class对象。

数组的类型:

  1. 数组的组件类型:指的是去掉维度后的类型。 数组将会在加载数组的组件类型上被标识。
  2. 如果数组是基础类型,那么该数组与引导类加载器相关。
  3. 数组的可见性与组件类型的可见性相同。
验证

验证是一个比较重要的过程,主要包括以下几个步骤:

  • 文件格式验证
    验证文件本身的格式是否正确,例如:魔数,版本号,常量池的tag, 常量是否utf-8,或者违反class的别的约定。通过了这个阶段,二进制流才会转化为方法区的运行时数据机构。
  • 元数据验证
    对元数据的数据类型进行校验,例如:类是否有父类,父类是否不允许被继承,是否实现了抽象类中所有方法,是否覆盖了父类的final字段。
  • 字节码验证
    确保程序的语义是合法的。例如:验证指令码操作栈接收int类型,但是却按照long类型载入本地变量表。保证跳转指令不会跳转 方法体以外的字节码上。
  • 符号引用验证
    对类自身以外信息进行验证。例如:符号引用中的全限定名是否能找到对应的类,符号引用中的类,字段,方法的访问性是否可以被当前类访问。
准备

准备阶段会为类变量分配内存并且设置类变量的初始值。这些变量的内存在方法区中分配。初始值一般为0值。
各种数据的零值如下表格:

数据类型 零值 数据类型 零值
int 0 boolean false
long 0L float 0.0f
short (short)0 double 0.0d
char ‘\u0000’ reference null
byte (byte) 0

当然也有特殊情况,如果变量被 static final修饰,那么在字节码中,字段表中将会存放ConstantValue那么在准备阶段就会赋初值。

解析

解析阶段中虚拟机将常量池中的符号引用替换为直接引用。

因为多个类中可能会共用一个类,所以在解析过程中可能会对同一个符号引用进行多次解析。虚拟机为了避免解析动作重复进行,对第一次解析的结果进行了缓存(在运行时常量池中记录直接引用地址,并且把常量标识为已解析)。 除了invokedynamic指令,同一个符号引用多次的解析结果与第一次解析结果相同。

类或者接口的解析

如果当前所处的类为D,那么要解析符号引用N解析为C的直接引用的时候:
1.如果C不是数组类型,那么虚拟机会用加载D的类加载器去加载C。在加载C的过程中由于元数据验证,字节码验证的需要,又可能加载别的类,只要过程中有一个类加载失败,那么就会加载失败。

  1. 如果C是一个数组,并且数组类型为对象,那么将会以1去加载数组元素类。然后由虚拟机生成代表此数组的数组对象。
  2. 执行符号引用验证。
字段的解析

如果加载完成后,后续的对该字段的搜索按照以下顺序:
1.先查找自身的字段
2.如果在自身中没有找到,查找接口与父接口。
3.如果还没找到,查找父类。
实际测试看来,如果接口中和父类中有同样的字段,那么编译失败。

类方法解析

先找出类方法的class_index然后加载该类或者接口。
加载完成后按照以下顺序进行类方法搜索:

  1. 类方法的class_index如果是接口,直接报错。
  2. 在本类中查找简单名称和描述符都匹配的方法。
  3. 在父类中查找简单名称和接口都匹配的方法。
  4. 接口中查找简单名称和接口都匹配的方法。如果能找到说明该类是一个抽象类。同时报错。
  5. 报错。
接口方法解析

接口方法的解析与上面步骤相同。
加载完成后按照余下顺序进行接口方法搜索:

  1. 如果接口方法的class_index是类,直接报错。
  2. 在本接口中查询简单名称与描述符都相同的方法,如果有直接返回直接引用。
  3. 在接口的父接口中查询,如果有直接返回。
  4. 报错。
初始化

在初始化阶段,才开始真正执行java代码。在初始化阶段,会去执行<clinit>方法去初始化变量和其它资源。
什么是方法?

clinit方法是由编译器自动收集类中所有类变量的赋值动作和 static{} 块的语句合并产生的。收集顺序是由语句在源码中的顺序决定的。
这里有一点要注意就是,静态语句块要在静态字段之后,否者的话会报非法向前引用的错误。
在执行子类的clinit方法之前,首先会调用父类的clinit.
接口不会执行父接口的clinit.只有真正在使用的时候才会执行。

类加载器

对于任意一个类,都需要类加载器与类本身共同确立它在JVM中的唯一性。每一个类加载器都有自身的命名空间。
会影响 equals() isAssignableFrom() instanceof 这些判断结果。

双亲委派模型

在java中存在三种类加载器

  • 启动类加载器
    由C++语言编写,存放在lib目录中。
  • 扩展类加载器
    负责加载lib/ext中的类
  • 应用程序类加载器
    Application ClassLoader.负责加载Classpath路径下的类。JVMJVM.png
简单来讲双亲委派模型指的是:

除了最顶端的启动类加载器,任何加载器都要有自己的父加载器。且一般父子关系不以继承的关系实现,而是使用组合的方式。当一个类加载器收到了类加载请求,它不会自己尝试加载该类,而是把请求委托给父类,所以最终的加载会传到启动类加载器。只有当父加载器反馈无法加载该类的时候,子加载器才会尝试去加载该类。

优势:

这样带来的好处就是对于一些基础的类,那么不管用户是否重写,最终存在内存中的都是启动类加载器和引导类加载器加载的类。可以保护jre核心类库不被混淆。

问题

1.类在什么时候会加载?

书上没有写到具体的类加载时间,只提到了类初始化的时机。
那么五种类初始化的时机必然会触发类的加载。
网上也搜不到具体的类加载时间。

2.在创建一个子类对象的时候,会创建出父类对象么?

不会,在创建子类对象的时候,只会对父类进行加载,截止到调用构造函数。此时还没有生成可以使用的父类对象。

https://q.cnblogs.com/q/29095/

3.JNDI如何破坏的双亲委派模型?

如果不想破坏类加载的双亲委派机制,那么当你自定义类加载器的时候只需要重写findClass()方法即可。如果想要破坏双亲委派模型,那么要重写loadClass()方法。
那么JNDI是如何破坏双亲委派模型的呢。
首先我们要了解SPI:

SPI的全名为Service Provider Interface,主要是应用于厂商自定义组件或插件中。在java.util.ServiceLoader的文档里有比较详细的介绍。简单的总结下java SPI机制的思想:我们系统里抽象的各个模块,往往有很多不同的实现方案,比如日志模块、xml解析模块、jdbc模块等方案。面向的对象的设计里,我们一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制。 Java SPI就是提供这样的一个机制:为某个接口寻找服务实现的机制。有点类似IOC的思想,就是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要。

因为SPI的ServiceLoader类是属于rt.jar的,由启动类加载器加载。所以当你想要使用serviceLoader.load()去加载自己写的类的时候,启动类加载器是找不到自己的类的。(这个可以看上边的类和方法的解析,在加载一个类的时候,首先会使用加载) 所以java引入了线程上下文加载器。

1
2
3
4
public static <S> ServiceLoader<S> load(Class<S> service) {
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}

通过在创建serviceLoader的时候传入当前线程的加载器。如果为空,那么默认为应用程序类加载器。这样就可以在启动类加载器中使用别的类加载器了。从而打破了双亲委派模型。

关于SPI和线程上下文类加载器的详细介绍:

https://blog.csdn.net/top_code/article/details/51934459
https://blog.csdn.net/yangcheng33/article/details/52631940