Class类文件结构以及虚拟机类加载

1.Class类文件结构

2.虚拟机类加载机制

2.1概述

虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这就是虚拟机的类加载机制。
与那些在编译时期需要进行连接工作的语言不同,java语言里,类型的加载和连接过程都是在运行期间完成的,这样会在类加载时稍微增加一些性能开销,但是却能为java应用程序提供高度的灵活性,java中天生可以动态扩展的语言特性就是依赖运行期动态加载和动态链接这个特点来的。

2.2类加载的时机

从加载到内存到卸出内存,整个生命周期包括:加载,验证,准备,解析,初始化,使用,卸载
验证,准备,解析这三个部分成为链接
虚拟机规定有且只有四种情况必须立即对类进行初始化,(加载,验证,准备自然需要在此之前完成)
1.遇到new,getstatic,putstatic,invokestatic这四条字节码指令时
2.使用java.lang.reflect包的方法对类进行反射调用时
3.当初始化一个类时,如果发现其父类还没有进行过初始化,则需要先触发父类的初始化
4.当虚拟机启动时,用户需要指定一个执行的主类,虚拟机会先初始化这个主类
这四种方式称为对一个类进行主动引用

public class SuperClass {
    static{
        System.out.println("Super init");
    }
    public static int value = 123;
}

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

public class Initialize {

    public static void main(String[] args) {
        System.out.println(SubClass.value);
    } 
}
运行结果:
Super init
123 
对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化
public class Initialize {

    public static void main(String[] args) {
        SuperClass[] sa = new SuperClass[10];
    } 
}
通过数组定义来引用类,不会触发类的初始化
public class ConstClass {
    static{
        System.out.println("ConstClass init");
    }
    public static final String HELLOWORLD = "Hello World";
}

public class Initialize {

    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWORLD);
    } 
}
运行结果:
Hello World
常量在编译阶段会存入调用类的常量池中本质上没有引用到定义常量的类,因此不会触发定义常量的类的初始化
编译阶段常量值“Hello World”存储到了Initialize类的常量池中,对常量的ConstClass.HELLOWORLD的实际引用都被转换成Initialize类对自身常量池的引用了,也就是说实际上Initialize的class类文件中并没有ConstClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了

接口与类的初始化稍有不同的是第三种情况,当一个接口初始化的时候,并不要求其父接口完成初始化,只有在真正使用到父接口的时候才会初始化

2.3类加载的过程

2.3.1加载

第一步:通过类的全限定名获取此类的二进制字节流
第二步:将这个字节流所代表的静态存储结构转换为方法去的运行时数据结构
第三步:在java堆中生成一个代表此类的java.lang.Class对象,作为方法区这些数据的访问入口

加载完成之后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中,方法区中的数据存储格式由虚拟机自行定义,,然后在堆中生成一个代表此类的Class对象,这个对象作为程序访问方法区中这个类型数据的外部接口,
加载和链接是交叉进行的,加载可能还未完成,链接可能已经开始

2.3.2验证

验证是链接阶段的第一步,目的是为了确保Class文件中的字节流中包含的信息符合当前虚拟机的要求,并且不会妨碍虚拟机的安全
java语言是相对安全的语言,使用纯java代码无法做到注入访问数据边界以外的数据,将一个类型转换为它并未实现的类型,跳转到不存在的代码行之类的事情,如果这样做了编译器会拒绝。
但是,Class文件不一定由java源代码编译而来,虚拟机如果不检查输入的字节流,对其完全信任的话,很可能因为载入了有害的字节流而导致系统崩溃。
java虚拟机规定如果验证到输入的字节流不符合Class文件的存储格式,就抛出异常
1.文件格式验证
验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理
这个阶段的验证是基于字节流进行的,经过了这个阶段的验证之后,字节流才会进入内存的方法区中进行存储,后面的三个阶段是基于方法区中的存储结构进行的
2.元数据验证
对字节码描述的信息进行语义分析,保证描述的信息符合java语言规范的要求
3.字节码验证
进行数据流和控制流分析
对类的方法体进行校验,保证被检验类的方法 在运行时不会做出危害虚拟机的行为
保证操作数栈的数据类型能与指令代码配合
保证跳转指令不会跳到方法体以外的字节码指令
保证方法体中的类型转换是有效的
4.符号引用验证
发生在虚拟机将符号引用转换为直接引用的时候,这个转化动作在解析阶段中发生
符号引用验证可以被看做是对类自身以外的数据机型匹配性的校验

验证阶段对于虚拟机类加载机制来说,是非常重要的,但不一定是必要的,如果所运行的代码都已经被反复使用和验证过,在实施阶段就可以考虑使用-Xverify:none来关闭大部分的验证措施,以缩短加载时间

2.3.3准备

准备阶段:正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配
这个时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将在对象实例化时随对象一起分配在java堆中,初始值通常是指0值,
如:public static int value = 123;value在准备阶段过后初始化的值是0而不是123,因为这个时候没有执行任何java方法,而把value赋值为123是在putstatic指令被编译后,存放在()方法中,所以把value赋值为123的动作将在初始化阶段才会被执行。

如果类字段的字段属性存在ConstantValue属性,那么在准备阶段就会被初始化为ConstatnValue属性所指定的值,
public static final int value =123;编译时期javac为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123.

2.3.4解析

将常量池中的符号引用替换为直接饮用的过程
符号引用:用一组符号描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定义到目标即可。
直接引用:
直接指向目标的指针,相对偏移量或者一个能间接定位到目标的句柄。。
1.类或接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或者接口C的直接引用,那虚拟机的解析过程如下
第一步:如果C不是数组类型,那么虚拟机会把代表N的全限定名交给D的类加载器去加载这个类C。
第二步:如果C是数组类型,并且数组的元素类型为对象,那么就会加载数组的元素类型,接着虚拟机会生成一个代表此数组维度和元素的数组对象
第三步:如果上面没有出现异常,那么C在虚拟机中已经成为一个有效的类或者接口了,但解析完成前还要进行符号引用验证,确认C是否具备对D的访问权限

2.字段解析
首先对该字段所属的类进行解析
第一步:如果C本身包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用
第二步:如果C实现了接口,就按继承关系从上往下搜索各个接口和父接口,
第三步:如果C不是java.lang.Ojbect的话,就按照继承关系搜索其父类,
否则查找失败抛出异常

3.类方法解析
4.接口方法解析

2.3.5初始化

初始化阶段是执行构造器()方法的过程
1.()方法是由编译器自动收集类中所有类变量和赋值动作和静态语句块中的语句合并而成的,编译器收集顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在他之后的变量,静态语句块可以赋值但不能访问。

2.()方法和实例构造器()方法不同,它不需要显示的调用父类构造器,虚拟机会保证子类的()方法执行之前,父类的()方法已经执行完毕,因此在虚拟机中第一个执行()方法的肯定是Ojbect

3.由于父类的()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作

4.()方法对于类并不是必须的,如果没有静态语句块,也米有静态变量的赋值操作,编译器就不会生成()方法、
5.虚拟机会保证一个类的()方法在多线程中被正确的加锁和同步,如果多个线程去初始化一个类,那么只会有一个线程执行()方法,其他线程都要阻塞等待,知道活动线程执行()方法完毕,如果在一个类的()方法中有耗时操作,就可能造成长时间阻塞

2.4类加载器

2.4.1类与类加载器

自定义类加载器

Class类文件结构以及虚拟机类加载_第1张图片
双亲委派模型7.png

2.4.2双亲委派模型

从虚拟机角度看,有两种类加载器
第一种:启动类加载器,使用C++实现,是虚拟机自身的一部分
第二种:有java实现,独立于虚拟机外部,全部继承自ClassLoader
从开发人员角度看,有三种
第一种:启动类加载器(Bootstrap ClassLoader):负责将存放在\lib目录中的或者被-Xbootclasspath所指定的路径中的并且是虚拟机识别的类库加载到虚拟机内存中,启动类加载器无法直接被java程序引用
第二种:扩展类加载器:负责加载java_home\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器
第三种:应用程序类加载器,加载用户路径ClassPath指定的类库

如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去加载,因此所有的类加载请求最终都应该传递到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求的时候,子加载器才会尝试自己去加载。
好处是java类随着它的加载器具备了一种带有优先级的层次关系,如Object类,无论哪一个加载器加载这个类,最终都会委派给启动加载器,因此Ojbect类在程序的各个类加载器环境中都是同一个类,相反如果没有双亲委派模型,由各个类加载器自己去加载的话,系统中会出现多个不同的Object类,java体系中的最基础的行为也就无从保证,应用程序就一片混乱。

3.虚拟机字节码执行引擎

3.1概述

虚拟机在运行时如何找到正确的方法,如果执行方法内的字节码,以及执行代码时涉及的内存结构。

3.2运行时栈帧结构

Class类文件结构以及虚拟机类加载_第2张图片
zhanzhen.png

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,栈帧存储了方法的局部变量表,操作数栈,动态链接和方法返回等地质信息,每一个方法从调用开始到执行完成的过程都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

3.2.1局部变量表

一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,在java程序编译成class文件时,就再方法的code属性的max_locals数据项中确定了该方法所需要分配的最大局部变量表的容量。
局部变量表以变量槽为最小单位,虚拟机规范规定每个slot应该能存放一个boolean,byte,char,short,int,float,reference,returnAddress类型的数据,
reference,虚拟机应当能从此引用中直接或间接的找到对象在java堆中的起始地址索引和方法区中的对象类型数据,
returnAddress是为jsr,jsr_w和ret指令服务的。
局部变量表建立在线程的堆栈上,是线程私有的数据
虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始,如果是32位数据类型的变量,索引n就代表了使用第n个槽,如果是64位类型的数据,就使用n和n+1个槽
方法执行时,如果是实例方法,那么变量表中的第0位索引的slot默认是用于传递方法所属对象实例的引用,在方法中可以使用this关键字来方法这个隐含的参数,其余的参数按参数列表来排列,占位从1开始,参数列表分配完成之后,在根据方法体内定义的变量顺序和作用域分配其他的slot。
局部变量表的slot是可重用的,方法体中的变量,其作用域不一定会覆盖整个方法体,如果当前字节码pc计数器的值已经超出了某个变量的作用域,那么这个变量对应的slot就可以交给其他变量使用。这样设计不仅仅是为了节约空间,在某种情况下,slot的复用会直接影响系统的垃圾收集行为。

public class Initialize { 
    public static void main(String[] args) {
        byte[] placeholder = new byte[64*1024*1024];
        System.gc();
    } 
}
//placeholder没有被回收
public class Initialize { 
    public static void main(String[] args) {
        {
           byte[] placeholder = new byte[64*1024*1024];
        }
        System.gc();
    } 
}
//离开了作用域,还是没被回收
public class Initialize { 
    public static void main(String[] args) {
        {
           byte[] placeholder = new byte[64*1024*1024];
        }
        int a = 0;
        System.gc();
    } 
}
//被回收

使用a复用placeholder的slot,即清空placeholder到gcroots的引用
局部变量表没有准备阶段,如果一个局部变量定义了但没有赋值是不能使用的。

3.2.2操作数栈

操作数栈的最大深度是在编译期被写入code属性的max_stacks数据项中,操作数栈的每一个元素可以是任意的java数据类型,32为数据类型占一个栈容量,64位数据类型占两个栈容量。
如iadd指令要求栈顶的两个元素是int值,执行指令的时候,将两个int值出栈并相加,然后将结果入栈,在编译时期,操作数栈的数据类型必须和字节码指令严格匹配,

3.2.3动态链接

每个栈帧都包含一个指向常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接

3.2.4方法返回地址

方法返回有两种方式:1.正常返回2.异常返回
无论通过何种方式退出,方法退出后,都需要返回到方法被调用的位置,程序才可以继续执行。
方法返回时需要在栈帧中保存一些信息,用户恢复它上层方法的执行状态,一般来说,方法正常返回时,调用者的pc计数器的值就可以作为返回地址,而方法异常退出时,返回地址通过异常处理器来确定。
方法退出的过程实际上是把当前栈帧出栈,因此退出时可能有的操作是:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整pc计数器的值以指向方法调用者指令后面的一条指令。

3.3方法调用

方法调用不等于方法执行,方法调用的唯一任务是确定调用的是哪一个方法。
程序运行时,进行方法调用是很频繁的操作,class编译的过程不包括连接步骤,一切方法调用在class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局的入口地址。

3.3.1 解析

所有方法调用的目标方法在Class文件中都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转换为直接引用。
这种解析成立的前提是:编译期可知,运行期不变。
这种解析主要有类方法和私有方法两大类。
1.invokestatic:调用静态方法
2.invokespecial:调用实例构造器方法,私有方法和父类方法
3.invokevirtual:调用虚方法
4.invokeinterface:调用接口方法,运行期间再确定一个实现此接口的对象
5.invokedynamic:

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定,符合这个条件的有静态方法,私有方法,构造器方法和父类方法四个,他们在类加载的时候就会把符号引用转换为直接引用,这些方法称为非虚方法。
java中被final修饰的方法也是非虚方法:虽然final方法是被invokevirtual调用的,但是它无法被覆盖,没有其他版本,所以无须对方接受者进行多态选择,或者说多态选择的结果是唯一的。

解析调用一定是静态的,在类装载的解析阶段就会把符号引用转换成直接引用,不会延迟到运行期,而分派调用则有静态有动态。

3.3.2分派

3.3.2.1静态分派

静态分派的典型应用是方法重载。

public class Dispatch { 
    public static void main(String[] args) {
        Human man = new Man();
        Human wo = new Woman();
        Dispatch dispatch = new Dispatch();
        dispatch.sayHi(man);
        dispatch.sayHi(wo);
    } 
    
    static abstract class Human{
        
    }
    
    static class Man extends Human{
        
    }
    
    static class Woman extends Human{
        
    }
    
    public void sayHi(Human guy){
        System.out.println("hello guy");
    }
    
    public void sayHi(Man man){
        System.out.println("hello man");
    }
    
    public void sayHi(Woman wo){
        System.out.println("hello woman");
    } 
    
}
运行结果:
hello guy
hello guy

静态分派发生在编译阶段,另外,编译器虽然能确定方法的重载版本,但是很多情况下,这个重载的版本并不是唯一的,往往只能确定一个更加适合的版本。

public class Overload {

     
    public static void main(String[] args) {
         sh('a');
    }
    
    public static void sh(Object arg){
        System.out.println("hello obj");
    }
    
    public static void sh(int arg){
        System.out.println("hello int");
    }
    
    public static void sh(long arg){
        System.out.println("hello long");
    }
    
    public static void sh(Character arg){
        System.out.println("hello Character");
    }
    
    public static void sh(char arg){
        System.out.println("hello char");
    }
    
    public static void sh(char... arg){
        System.out.println("hello char...");
    }
    
    
    public static void sh(Serializable arg){
        System.out.println("hello serializble");
    }
}

运行结果:
hello char
'a'是char类型,
注释掉char方法
输出:hello int
这个时候发生了一次自动类型转化,'a'除了代表字符,还代表97,
注释掉int方法,输出 hello long
'a'转成97后进一步转成97L
char-int-long-float-double
注释掉long,输出 :hello character
这时发生了一次自动装箱,'a'被包装为Character,注释掉Character,输出:hello serializable
因为character实现了serializble接口,
同时Character还实现了Comparable接口
所以如果Serailizable和Comparable同时出现,程序无法判断优先级就会拒绝编译
注释掉输出:hello object
注释掉object:输出 hello char...
变长参数的重载优先级是最低的

解析和静态分派并不是二选一的关系,他们是在不同的层次上去筛选,静态方法会在类加载时期就解析,而静态方法是可以
重载的,选择重载版本的过程是在静态分派完成的

3.2.2动态分派

动态分派和重写有很大关系

public class DynamicDispatch {

     
    public static void main(String[] args) {
        Human man = new Man();
        Human wo = new Woman();
        man.SH();
        wo.SH();
        man = new Woman();
        man.SH();
    }
    
    static abstract class Human{
        protected abstract void SH();
    }
    
    
    static class Man extends Human{  
        @Override
        protected void SH() {
            System.out.println("Man SH");
        } 
    }
    
    
    static class Woman extends Human{ 
        @Override
        protected void SH() {
            System.out.println("Woman SH");
        } 
    }

}
运行结果:
Man SH
Woman SH
Woman SH

invokevirtual指令的几个步骤
1.找到操作数栈顶的第一个元素所指向的对象的实际类型C
2.如果在C中找到方法,则进行权限校验,如果通过返回该方法的直接引用,如果不通过,返回IllegalAccessError异常。
3.否则通过继承关系从下往上依次查询C的各个父类
4.如果始终没有找到,抛出AbstractMethodError异常
这个过程就是重写的本质,把这种在运行期间确定方法实际运行版本的分派过程称为动态分派。

3.2.3单分派和多分派

3.2.4虚拟机分派的实现

Class类文件结构以及虚拟机类加载_第3张图片
方法表.png

3.3动态类型语言支持

3.4基于栈的字节码执行引擎

你可能感兴趣的:(Class类文件结构以及虚拟机类加载)