类加载子系统

Class Load System

加载:把二进制字节码流(可以是class文件、网络字节流)加载到内存中,这片内存称为方法区(存放Code 字节码 + 元数据 (类、字段(名称和描述符)、方法(名称和描述符)、常量池信息),然后经过以下几个阶段来完善class字节码中信息,为虚拟机的执行做准备。

因此在加载阶段虚拟机需要完成的三件事

1、通过全限定名获取二进制字节流(可以从类文件、ZIP(jar、war)、网络、动态生成类(代理原理)、数据库、有其他文件生成(JSP生成对应class类))

2、将字节码中的静态存储结构转化成方法区运行时数据结构(元数据区+代码区)

3、生成java.lang.Class对象,作为方法区这个类的各种数据访问入口

以下阶段包括:

加载–>连接–>初始化

加载阶段可能会在连接阶段进行触发,类加载的各个阶段是交替开始的。

首先jvm会通过类加载器加载Main class数据到内存中,然后进行连接阶段,验证A class文件是否合法在此阶段进而会load SubClass和SuperClass进入内存中,检查字段a是否允许被访问。进而进入了Main的准备阶段,给static变量分配内存并赋予初始值,然后进入解析阶段,会将符号变量转换成直接引用(会到SubClass根据字段描述符号找对应字段,如果没找到则去父类SuperClass找,然后替换成逻辑地址),最后执行Main由编译器生成的方法初始化其值,然后初始化其SuperClass类(执行SuperClass的方法)

package com.dynamo

 class Main {

  public static void main(){
    
     SubClass.a=88;
  
  }

}


 public class SubClass extends SuperClass {

    static {
        System.out.println("sub class init");
    }

    public static void main(String[] args) {
         SubClass.a = 88;
    }
}


 public class SuperClass {
    protected static int a = 1;
    static {
        System.out.println("super class init");
    }
}

java Main 输出结果为:

main init
super class init

由于类变量a是在SuperClass类中,因而只初始化SuperClass类,但是SubClass也需要被加载到内存中(因为在Main class中需要检查是否有权限访问类变量a),如下图为load class过程图,类似于树的广度优先遍历算法

如图 load_step.png

在这里插入图片描述

Load

假设1:如果我们在程序里面自定义命名java.lang.Object 这个类,这个类会被加载到内存里吗?

假设如果会,那么如果Object代码是一个不安全(损坏硬件)的代码,则被加载到内存中,那么安全就不能保证。 那jvm如何做到只会加载自己rt.jar下的Object类呢?

###双亲委派模式:

有四类类加载器(搜索类范围不一样):

Bootstrap ClassLoader(C++实现的) : 只加载java定义api core类 /java_home/lib/rt.jar里的类集合

Extension ClassLoader : 只加载 /java_home/lib/ext 里面的类集合

App ClassLoader : 只加载用户写的java class,也就是class path下的所有类

继承ClassLoader 自定义类加载器 : 可以随意指定搜索范围,可以是硬盘里某个文件,也可以来自网络字节流

拿上面的举例子:加载自定义类 java.lang.Object

没有自己重写类加载器时,默认交给App ClassLoader

1、首先通过类全限定名到App ClassLoader查找是否已经被加载过,若加载了则返回Class对象则结束,否则到2

2、再委派给父类加载器 Extension ClassLoader 判断该类是否已经被加载了,若加载则返回Class对象则结束,否则到3

3、委派给父类加载器 Bootstrap ClassLoader 断该类是否已经被加载了,若加载则返回Class对象则结束,否则到4

4、根据类全限定名,到rt.jar搜索该类,如果存在则会加载该类到内存里,否则又会向下进行委派给Extension ClassLoader,同样如果在 ext文件下仍然搜不到则委派给App ClassLoader,最后如果载此类加载器找不到的话,直接抛出异常 ClassNotFoundException ClassNotDefError

所以从上面的过程可以看出,自定义的Object类并没有加载,而是加载了rt.jar里的Object类,由于使用委派模式保证了一个同名的类在内存里只会被加载一次,并仅存在一个Class对象。

假设2 如果自定一个类加载器,不走委派模式,指定加载自定义的java.lang.Object到内存里,是否可行?

不可以,会抛出一个异常 java.lang.SecurityException:Prohinited package name:java.lang,因为jvm控制了不能加载自定义的java.lang 这样命名包的类,有兴趣的可以动手试试

加载类过程的核心方法loadClass:

loadClass是个线程安全方法,当多个线程同时加载某个类时,只允许一个线程进行加载类操作,当该线程结束时其他线程直接获取加载到内存的Class对象

public synchronized Class<?> loadClass(String name,boolean resolve) throws ClassNotFoundException{

//首先检查请求的类是否被加载过
Class c = findLoadedClass(name);

if(c == null){
	try{
	   
	   if(parent != null ){
	     //委派给父类加载器进行加载
	     c = parent.loadClass(name,false);
	   
	   }else{
	   	 //如果parent为null,表示当前时Bootstrap ClassLoader
	     c = findBootstrapClassOrNull(name);
	   }
	   
	}catch(ClassNotFoundException e){
		//说明父类在其搜索范围没有发现该类,则委派给其子类进行加载
	}
	
	if(c == null){
		//在父类加载器无法加载的时候再将调用本身的findClass方法进行加载
	   c = findClass(name);
	
	}

  if(resolve){
  	
  	resolveClass(c);
  
  }
}


return c;

}

在自定义类加载器时,继承ClassLoader类,然后重写loadClass类,或findClass,此时建议重写findClass方法因为,loadClass方法具有委派模式逻辑,也是为了安全、避免多个类出现在内存中。
如果重写了loadClass方法逻辑,那么就不满足了委派模式了。

类全限定名+Class Loader 唯一确定一个类,同个类被不同的类加载器加载,那么也属于不同的类对象,两个类必定不相等。这里的相等包含Class中的equals方法、isAssignableFrom()方法isInstance()、instanceof

加载类的过程如图:class-loader.png

在这里插入图片描述

Link

对于C++ 直接将源程序编译成机器码,而java只会编译成平台无关的class二进制流,从而可以看出,如果换一个操作系统执行c++代码都要重新编译,而java就不需要。

连接:
简单的理解为将我们写的代码中的符号引用转化成逻辑地址引用(例如在代码中类A 中的方法test中调用了类B的 f1方法,这个时候在类A中仅仅使用 B.f1()符号来调用,然后通过连接,找到类B的方法f1的逻辑地址,然后将原来符号替换成地址)

这里的逻辑地址:有人可能会问为什么不直接的内存物理地址呢?原因是这个时候该程序还没有被操作系统载入,因此实际的地址肯定不知道。当程序载入系统中后,操作系统会保持逻辑地址和物理地址的对应关系,所以通过访问逻辑地址就可以获取到真实内存里的数据(内存地址映射)。

其中C++是在编译的时候完成连接操作,而java是在load class文件时将符号引用转化成能直接定位到目标的直接引用,对于多态的实现就是在运行进行解析的,当执行指令的时候进行查找到真实类型的方法地址引用,然后进行替换的。

Verfication 字节码验证

在加载阶段之后进行连接阶段的第一个步,在java语言中 对于我们访问不存在的常量,以及跳到不存在的代码处,无法访问数组边界以外的事情,因为不符合正常语法,编译器会提示。 但是我们知道字节码不一定由java语言编译生成的,还可以自己通过编译进行编码,这个时候就可以实现java代码无法做到的事情了。因此字节码验证对系统稳定性起到了决定性作用。

会由以下四个步骤组成:

1、类文件格式验证

文件是否以0xCAFEBABE 开头,是否定义了不存在的常量池类型,常量池索引值是否指向了不存在的常量或不符号类型的常量。

该步骤,基于类的二进制流进行,只有类文件格式验证通过后才会在方法区生成一个Class对象,那下面3个步骤都是基于方法区的存储结构进行的,不会操作字节流。

2、元数据验证

对字节码描述消息进行语义分析,例如除类java.lang.Object是否有父类,是否继承了final 修饰的类,是否实现了抽象类或接口中所要求实现的方法

3、字节码验证

主要对类的方法体进行校验分析,保证类的方法在运行时不会做出危害虚拟机安全事件。

例如保证操作数栈中的数据类型与操作码配合工作,例如操作数栈方了int类型,使用时确按照long类型存入本地变量表。

保证跳转指令不能跳到方法体以外字节码上

保证方法体的类型转换有效,例如父类转换成子类,或转换成一个毫无继承关系的数据类型

字节码的校验也不能保证百分百准确,因为通过程序去检验程序逻辑性是否无法做到绝对准确。

4、符号引用验证

该校验动作发生在第三阶段解析中

确保引用的类可以根据全限定名能找到对应的类

在引用类中是否存在符合方法描述符和字段的描述符以及简单字段名和方法名

符号引用的类、字段、方法的访问性是否可被当前类访问

Preparation 准备(给static字段分配初始值)

只会给类变量也就是static修饰的字段,所需要的内存在方法区进行分配。 而对象实例变量会在对象初始化 new 时候在堆内分配内存。

static int value = 123 在准备阶段会给value变量分配一个初始值为0,而把value值赋值为123的putstatic指令是程序编译后存放于类构造器方法,所以这个阶段是在初始化进行的。

特殊例子: static final int value = 123 会在编译的时候在field表中生存ConstantValue属性,在准备阶段会将value赋值为123

Resolution 解析(符号引用换成直接引用)

一般都是在类加载的时候完成解析,但是为了支持java语言的运行时绑定(动态绑定),某些情况下是在初始化阶段之后才开始的。 例如我们熟悉的多态的实现。

1、符号引用

通过常量池中的 Constant_class_info Constant_method_info Constant_field_info 这种唯一标识的符号来表示调用的对象。

例如方法体中有如下字节码 invokespecail #1 #1表示class字节码中第一个常量 Constant_method_info类型的常量(class_name_index,name_and_type_index) java/lang/Object.()V 表示的是类全名+方法描述符(方法名称+方法参数以及返回值),含义就是调用父类Object中的构造方法进行初始化实例变量。

2、直接引用

直接引用就是和内存布局有关,通过这个直接引用就可以定位到内存中对应的变量和方法入口地址,此处为逻辑地址,操作系统中的地址变化器会帮我们把逻辑地址和物理地址映射起来。

3、解析过程

类或接口的解析:例如new #1 这个指令就会把类符号引用替换成直接引用,这时会经过类加载器加载到内存中,在这个过程中又会触发其他相关类的加载动作,例如父类或实现的接口。最终该类对应内存中的逻辑地址就是直接引用

类字段解析、 类方法解析、接口方法解析:

首先会拿到类全限定名通过类加载器进行加载到内存中,若已经被加载了则进行后面的验证操作,然后再拿方法名称和描述符号查找对应方法入口地址,如果没找到则继续从父类进行搜索。

这里有个优化,在每个类中都有个虚拟方法表,存储的就是(方法名+描述符)和入口地址映射关系(如何重写的方法那么入口地址就是子类中的方法入口地址,否则存放的就是父类中的方法入口地址),这样在后面动态解析过程中,就没必要每调用一次方法都进行一次搜索方法过程,直接从该类的虚拟方法表获取。

Initialization

初始化过程:由编译器会给有类变量或有static{} 静态块类生成一个 类初始化方法,静态变量和静态块里初始化都会统一放在此方法进行,按照代码顺序分别进行初始化类变量。实例变量初始化需要显示通过调用父类构造器,但是类初始化会默认先初始化完父类类变量。

接口虽然没有static{} 但是也有类变量,因此编译器也会自动生成方法,然而接口A的初始化并不会导致其父接口B的初始化,只有当B接口定义的变量使用时,父接口B才会进行初始化(加载、连接、初始化类变量)

同样类A进行初始化时并不会初始化实现的接口A

方法加锁保证了线程安全,当多个线程同时初始化类A(加载、连接、初始化),有且只有一个线程进行这个过程,其他线程一直在等待,直到初始化完成了唤醒其他线程之后也不会进入方法,同样loadClass方法也是synchronized 同步的。一个类在同个类加载器下只会进行一次初始化

类加载时机

jvm规范中固定有且只有以下5种情况会立即进行初始化(加载、验证、准备需要在此之前)

1、遇到 new、putstatic、getstatic、invokestatic 这4条指令如果类没有进行过初始化,则需要进行初始化,new实例化对象、读取或设置一个静态字段(被fianl修饰,在编译阶段放入常量池的静态字段除外)的时候,以及调用一个类静态方法时。

2、使用java.lang.reflect包方法对类进行反射调用时候

3、在初始化类时候,如果发现父类没有初始化,则先初始化父类

4、虚拟机启动时需要用指定一个包含main方法的主类,则会先初始化该主类

5、在jdk1.7的动态语言时,如果MethodHandle解析的结果REF_getStatic 并且此方法句柄对应类没有初始化,那么先进行初始化

以上5种情况为类主动引用,除此之外所有引用的类都不会初始化,称为被动引用,被动引用的意思就是:当初始化A类时候,由于A类引用了B类,那么A为主动引用 B就是被动引用,并不会触发初始化但是会进行其他阶段,例如加载、验证、准备等阶段。

以下为三种被动引用的情况:

1、当访问的是父类的静态字段时 例如SubClass.a,只会初始化父类SuperClass

2、SupClass[] s = new SuperClass[0] 由虚拟机使用newarray指令创建的[Ltest.SuperClass,此类继承于Object,此时并不会初始化SuperClass

3、当类A访问类B的 static final String s = “test” 修饰的字段时,在编译的时候通过常量传播优化,将对应的常量值存储到类A的常量池中,类A和类B在编译之后不存在任何联系。在访问 B.s时候并不会加载B类,这也减少了类的加载次数,加快了运行速度

接口和类在加载过程中有什么区别:

接口并没有static{} 静态块,但是编译器仍然会生成 方法对接口变量进行初始化,其中在需要初始化场景第三种,接口的初始化不需要先初始化父接口,而是使用到了父接口再去初始化。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章