关于java:JVM系列2Java虚拟机类加载机制及双亲委派模式分析

22次阅读

共计 8011 个字符,预计需要花费 21 分钟才能阅读完成。

前言

上一篇咱们粗略的介绍了一下 Java 虚拟机的运行时数据区,并对运行时数据区内的划分进行了解释,明天咱们就会从类加载开始剖析并会深刻去看看数据是具体以什么格局存储到运行时数据区的。

编译

一个.java 文件通过编译之后,变成了了.class 文件,次要通过留下步骤:
.java -> 词法分析器 -> tokens 流 -> 语法分析器 -> 语法树 / 形象语法树 -> 语义分析器 -> 注解形象语法树 -> 字节码生成器 -> .class 文件。
具体的过程不做剖析,波及到编译原理比较复杂,咱们须要剖析的是.class 文件到底是一个什么样的文件?

Class 文件

在 Java 中,每个类文件蕴含单个类或接口,每个类文件由一个 8 位字节流组成。所有 16 位、32 位和 64 位的量都是通过别离读取 2 个、4 个和 8 个间断的 8 位字节来构建的。

Java 虚拟机标准中规定,Class 文件格式应用一种相似于 C 语言的伪构造来存储数据,class 文件中只有两种数据类型,无符号数 。留神,class 文件中没有任何对齐和填充的说法,所有数据都依照特定的程序紧凑的排列在 Class 文件中

  • 无符号数
    属于数据的根本类型,以 u1,u2,u4,u8 来示意 1 个字节,2 个儿字节,4 个字节,8 个字节(在 Java SE 平台中,这些类型能够通过 readUnsignedByte、readUnsignedShort 和接口 java.io.DataInput 中的的 readInt 办法进行读取)。

  • 由 0 个或多个大小可变的项组成,用于多个类文件构造中,也就是说一个类其实就相当于是一个表。

Class 文件构造

一个 Class 文件大抵由如下构造组成:

ClassFile {
    u4             magic;// 魔数
    u2             minor_version;// 次版本号
    u2             major_version;// 主版本号
    u2             constant_pool_count;// 常量池数量
    cp_info        constant_pool[constant_pool_count-1];// 常量池信息
    u2             access_flags;// 拜访标记
    u2             this_class;// 类索引
    u2             super_class;// 父类索引
    u2             interfaces_count;// 接口数(2 位,所以一个类最多 65535 个接口)
    u2             interfaces[interfaces_count];// 接口索引 
    u2             fields_count;// 字段数
    field_info     fields[fields_count];// 字段表汇合 
    u2             methods_count;// 办法数
    method_info    methods[methods_count];// 办法汇合
    u2             attributes_count;// 属性数
    attribute_info attributes[attributes_count];// 属性表汇合
}

这个构造在本篇文章里不会一一去解释,如果一一去解释的话一来显得很干燥,二来可能会占据大量篇幅,这些货色脑子外面有个整体的概念,须要的时候再查下材料就好了,前面的内容中,如果遇到一些十分罕用的类构造含意会进行阐明,如魔数等还是有必要理解一下的。

Class 文件示例

咱们先任意写一个示例 TestClassFormat.java 文件:

package com.zwx.jvm;

public class TestClassFormat {public static void main(String[] args) {System.out.println("Hello JVM");
    }
}

而后进行编译,失去 TestClassFormat.class,利用 16 进制关上:

因为 Java 虚拟机只认 Class 文件,所以必然会对 Class 文件的格局有严格的安全性校验。

魔数

每个 Class 文件中都会以一个 4 字节的魔数 (magic) 结尾(u4),即上图中的 CA FE BA BE(咖啡宝贝)用来标记一个文件是不是一个 Class 文件。

主次版本号

魔数之后的 2 个字节 (u2) 就是 minor_version(次版本号),再往后 2 个字节 (u2) 记录的是 major_version(次版本号),这个还是十分有必要理解的,上面这个异样我想可能很多人都已经遇到过:

java.lang.UnsupportedClassVersionError: com/zwx/demo : Unsupported major.minor version 52.0
 at java.lang.ClassLoader.defineClass1(Native Method)
 at java.lang.ClassLoader.defineClassCond(ClassLoader.java:631)

这个异样就是提醒主版本号不对。
Java 中的版本号是从 45 开始的,也就是 JDK1.0 对应到 Class 文件的主版本号就是 45,而 JDK8 对应到的主版本就是 52。
上图中类文件的主版本号(第 7 和第 8 位)00 34,转成 10 进制就是 52,也就是这个类就用 JDK1.8 来编译的,而后因为我用的是 JDK1.6 来运行,就会报下面的错了,因为高版本的 JDK 能向下兼容低版本的 Class 文件,然而不能向上兼容更高版本的 Class 文件,所以就会呈现下面的异样。

其余

其余还有很多校验,比如说常量池的一些信息和计数,拜访权限 (public 等) 及其他一些规定,都是依照 Class 文件规定好的程序往后紧凑的排在一起。

类加载机制

.java 文件通过编译之后,就须要将 class 文件加载到内存了了,并将数据依照分类存储在运行时数据区的不同区域。

一个类从被加载到内存,再到应用结束之后卸载,总共会通过 5 大步骤 (7 个阶段):
加载 (Loading),连贯(Linking),初始化(Initialization),应用(Using),卸载(Unloading) , 其中连贯(Linking) 又分为:验证(Verification), 筹备(Preparation),解析(Resolution)。

加载(Loading)

加载指的是通过一个残缺的类或接口名称来取得其二进制流的模式并将其依照 Java 虚拟机标准将数据存储到运行时数据区。

类的加载次要是要做以下三件事:

  • 1、通过一个类的全限定名获取定义此类的二进制字节流。
  • 2、将这个二进制字节流所代表的动态存储构造转化为办法区的运行时数据结构。
  • 3、在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对办法区中这些数据的拜访入口。

下面的第 1 步在虚拟机标准中并没有阐明 Class 来源于哪里, 也没有阐明怎么获取,所以就会产生了十分多的不同实现形式,上面就是一些罕用的实现形式:

  • 1、最失常的形式,读取本地通过编译后的.class 文件。
  • 2、从压缩包, 如:zip,jar,war 等文件中读取。
  • 3、从网络中获取。
  • 4、通过动静代理动静生成.class 文件。
  • 5、从数据库中读取。

执行 Class(类或者接口)的加载操作须要一个类加载器,而一个良好的,合格的类加载器须要具备以下两个属性:

  • 1、对于同一个 Class 名称,任何时候都应该返回雷同的类对象
  • 2、如果类加载器 L1 委派另一个类加载器 L2 来加载一个 Class 对象 C,那么以下场景呈现的任何类型 T,两个类加载器 L1 和 L2 应返回雷同的 Class 对象:
    (1) C 的间接父类或者父接口类型;
    (2) C 中的字段类型;
    (3) C 中办法或构造函数的中的参数类型;
    (4) C 中办法的返回类型

在 Java 中的类加载器不止一种,而对于同一个类,用不同的类加载器加载进去的对象是不相等的 ,那么 Java 是如何保障下面的两点的呢?
这就是双亲委派模式,Java 中通过双亲委派模式来避免歹意加载,双亲委派模式也确保了 Java 的安全性。

双亲委派模式

双亲委派模式的工作流程很简略,当一个类加载器收到加载申请时,本人不去加载,而是交给它的父加载器去加载,以此类推,直到传递到最顶层类加载器,而只有当父加载器反馈说本人无奈加载这个类,子加载器才会尝试去加载这个类。

上图中就是 双亲委派模型 ,仔细的人可能留神到,顶层加载器我应用了虚线来示意,因为顶层加载器是一个非凡的存在,没有父加载器,而且从实现上来说,也没有子加载器,是一个独立的加载器,因为扩大类加载器(Extension ClassLoader) 和应用程序类加载器 (Application ClassLoader) 两个加载器从继承关系来看,是有父子关系的,均继承了 URLClassLoader。然而尽管从类的继承关系来说启动类加载器 (Bootstrap ClassLoader) 没有子加载器,然而逻辑上扩大类加载器 (Extension ClassLoader) 还是会将收到的申请优先交给启动类加载器 (Bootstrap ClassLoader) 来进行优先加载。

  • 启动类加载器(Bootstrap ClassLoader),负责加载 $JAVA_HOMElib 下的类或者被参数 -Xbootclasspath 指定的能被虚拟机辨认的类(通过 jar 名字辨认,如:rt.jar),启动类加载器由 Java 虚拟机间接管制,开发者不能间接应用启动类加载器。
  • 扩大类加载器(Extension ClassLoader),负责加载 $JAVA_HOMElibext 下的类或者被 java.ext.dirs 零碎变量指定的门路中所有类库(System.getProperty(“java.ext.dirs”)),开发者能够间接应用这个类加载器。
  • 应用程序类加载器(Application ClassLoader),负责加载 $CLASS_PATH 中指定的类库。开发者能间接应用这个类加载器,失常状况下如果在咱们的应用程序中没有自定义类加载器,个别用的就是这个类加载器。
  • 自定义类加载器。如果须要,能够通过 java.lang.ClassLoader 的子类来定义本人的类加载器, 个别咱们都抉择继承 URLClassLoader 来进行适当的改写就能够了。

毁坏双亲委派模式

双亲委派模式并不是一个强制性的束缚模型,只是一种举荐的加载模型,尽管大家大都恪守了这个规定,然而也有不恪守双亲委派模型的,比方:JNDI,JDBC 等相干的 SPI 动作并没有齐全恪守双亲委派模式

毁坏双亲委派模式的一个最简略的形式就是:继承 ClassLoader 类,而后重写其中的 loadClass 办法 (因为双亲委派的逻辑就写在了 loadClass() 办法中)。

常见异样

如果加载过程中产生异样,那么可能抛出以下异样(均为 LinkageError 的子类):

  • ClassCircularityError:extends 或者 implements 了本人的类或接口
  • ClassFormatError:类或者接口的二进制格局不正确
  • NoClassDefFoundError:依据提供的全限定类名找不到对应的类或者接口

ClassNotFoundException 和 NoClassDefFoundError

还有一个异样 ClassNotFoundException 可能也会常常遇到,这个看起来和 NoClassDefFoundError 很类似,但其实看名字就晓得 ClassNotFoundException 是继承自 Exception,而 NoClassDefFoundError 是继承自 Error。

  • ClassNotFoundException
    当 JVM 要加载指定文件的字节码到内存时,发现这个文件并不存在,就会抛出这个异样。这个异样个别呈现在显式加载中 ,次要有以下三种场景:
    (1) 调用 Class.forName() 办法
    (2) 调用 ClassLoader 中的 findSystemClass() 办法
    (3) 调用 ClassLoader 中的 loadClass() 办法
    解决办法:个别须要查看 classpath 目录下是否存在指定文件。
  • NoClassDefFoundError
    这个异样个别呈现在隐式加载中 ,呈现的状况是可能应用了 new 关键字,或者是属性援用了某个类,或者是继承了某个类或者接口,或者是办法中的某个参数中援用了某个类,这时候就会触发 JVM 隐式加载,而在加载时发现类并不存在,则会抛出这个异样。
    解决办法:确保每个援用的类都在以后 classpath 下

连贯(Linking)

链接是获取类或接口类型的二进制模式并将其联合到 Java 虚拟机的运行时状态以便执行的过程。链蕴含三个步骤:验证,筹备和解析。

留神:因为链接波及到新数据结构的调配,所以它可能会抛出异样 OutOfMemoryError。

验证(Verification)

这个步骤很好了解,类加载进来了必定是须要对格局做一个校验,要不然什么货色都间接放到内存外面,Java 的安全性就齐全无奈失去保障。
次要验证以下几个方面:

  • 1、文件格式的验证:比如说是不是以魔数结尾,jdk 版本号的正确性等等。
  • 2、元数据验证:比如说类中的字段是否非法,是否有父类,父类是否非法等等
  • 3、字节码验证:次要是确定程序的语义和控制流是否合乎逻辑

如果验证失败,会抛出一个异样 VerifyError(继承自 LinkageError)。

筹备(Preparation)

筹备工作是正式开始调配内存地址的一个阶段,次要为类或接口创立动态字段 (类变量和常量),并将这些字段初始化为默认值。
以下是一些罕用的初始值:

数据类型

默认值

int

0

long

0L

short

(short)0

float

0.0f

double

0.0d

char

‘u0000’

byte

(byte)0

boolean

false

援用类型

null

须要留神的是,假如某些字段的在常量池中曾经存在了,则会间接在春被阶段就会将其赋值。
如:

static final int i = 100;

这种被 final 润饰的会间接被赋初始值,而不会赋默认值。

解析(Resolution)

解析阶段就是将常量池中符号援用替换为间接援用的过程。在应用符号援用之前,它必须通过解析,解析过程中符号援用会对符号援用的正确性进行查看。

留神:因为 Java 是反对动静绑定的,有些援用须要等到具体应用的时候才会晓得具体须要指向的对象,所以解析这个步骤是能够在初始化之后才进行的。

常见异样

解析过程中可能会产生以下异样:

  • IllegalAccessError:权限异样,比方一个办法或者属性被申明为 private,然而又被调用了,就会抛出这个异样。
  • InstantiationError:实例化谬误。在解析符号援用时,发现指向了一个接口或者抽象类而导致对象不能被实例化,就会抛出这个异样。
  • NoSuchFieldError:遇到了援用特定类或接口的特定字段的符号援用,然而类或接口不蕴含该名称的字段。
  • NoSuchMethodError:遇到了援用特定类或接口的特定办法的符号援用,但该类或接口不蕴含该签名的办法。

符号援用

符号援用以一组符号来形容锁援用的指标,其中的符号能够是任何模式的字面量,只有依据符号惟一的定位到指标即可。比如说:String s = xx,xx 就是一个符号,只有依据这个符号能定位到这个 xx 就是变量 s 的值就行。

间接援用

间接指向指标的指针、绝对偏移量或是一个能间接定位到指标的句柄。对于同一个符号援用通过不同虚拟机转换而失去的间接饮用个别是不雷同的。当有了间接援用之后,那么援用的指标必然曾经存在于内存,所以这一步要在筹备之后,因为筹备阶段会分配内存,而这一步实际上也就是一个地址的配对的过程。

初始化(Initialization)

这个阶段就是执行真正的赋值,会将之前赋的默认值替换为真正的初始值,在这一步,会执行结构器办法。

那么一个类什么时候须要初始化?父类和子类的初始化程序又是如何?

初始化程序

在 Java 虚拟机标准中规定了 5 种状况必须立刻对类进行初始化,被动触发初始化的行为也被称之为 被动援用(除了以下 5 种状况之外,其余不会触发初始化的援用都称之为被动援用)。

  • 1、虚拟机启动时候,会先初始化咱们指定的须要执行的主类(即 main 办法所在类)。
  • 2、应用 new 关键字实例化对象时候,读取或者设置一个类的动态字段(final 润饰除外),以及调用一个类的静态方法时。
  • 3、初始化一个类的时候,如果其父类没被初始化,则会先触发父类的初始化。
  • 4、应用反射调用类的时候。
  • 5、JDK1.7 开始提供的动静语言反对时,如果一个
    java.lang.invoke.MethodHandle 实例解析的后果 REF_getStatic,REF_putStatic,REF_invokeStatic 的办法句柄对应的类没有被初始化,须要触发其初始化。

留神:接口的初始化在第 3 点会有点不一样,就是当一个接口在初始化的时候,并不要求其父接口全副都初始化,只有在真正应用到父接口的时候 (如调用接口中定义的常量) 才会初始化

初始化实战举例

上面咱们来看一些初始化的例子:

package com.zwx.jvm;

public class TestInit1 {public static void main(String[] args) {System.out.println(new SubClass());//A- 先初始化父类,后初始化子类
//        System.out.println(SubClass.value);//B- 只初始化父类,因为对于 static 字段,只会初始化字段所在类
//        System.out.println(SubClass.finalValue);//C- 不会初始化任何类,final 润饰的数据初始化之前就会放到常量池
//        System.out.println(SubClass.s1);//D- 不会初始化任何类,final 润饰的数据初始化之前就会放到常量池
//        SubClass[] arr = new SubClass[5];//E- 数组不会触发初始化
    }
}

class SuperClass{
  static {System.out.println("Init SuperClass");
    }
    static int value = 100;

    final static int finalValue = 200;

    final static String s1 = "Hello JVM";
}

class SubClass extends SuperClass{
    static {System.out.println("Init SubClass");
    }
}
  • 1、语句 A 输入后果为:

    Init SuperClass Init SubClass com.zwx.jvm.SubClass@xxxxxx

因为 new 关键字会触发 SubClass 的初始化(被动援用状况 2),而其父类没有被初始化会先触发父类的初始化(被动援用状况 3)

  • 2、语句 B 输入后果为:

    Init SuperClass 100

调用了类的动态常量(被动援用状况 2),尽管是通过子类调用的,然而动态常量却定义在父类,所以只会触发其父类初始化,因为动态属性的调用只会触发属性所在类

  • 3、语句 C 和语句 D 输入后果为:

    200

因为被 final 润饰的动态常量存在于常量池中,在连贯的筹备阶段就会将属性间接进行赋值了,不须要初始化类

  • 4、语句 E 不会输入任何后果
    因为结构数组对象和间接结构对象是通过不同的字节码指令来实现的,创立数组是通过一个独自的 newarray 指令来实现的,并不会初始化对象。

应用(Using)

通过下面五个步骤之后,一个残缺的对象曾经加载到内存中了,接下来在咱们的代码中就能够间接应用啦。

卸载(Unloading)

当一个对象不再被应用之后,会被垃圾回收掉,垃圾回收会在 JVM 系列后续文章中进行介绍。

总结

本文次要介绍了 Java 虚拟机的类加载机制,置信看完这篇再联合上一篇对运行时数据区的解说,大家对 Java 虚拟机的类加载机制的工作原理有了一个整体的认知,那么下一篇,咱们会从更深层次的字节码上来更具体更深刻的剖析 Java 虚拟机的办法调用流程及办法重载和办法重写的原理剖析。

请关注我,一起学习提高

正文完
 0