jvm深入理解类加载机制

目录

概述

类加载的时机

类加载过程

一、加载*

深入理解方法区

二、验证

1.文件格式验证

2.元数据验证

3.方法体验证

4.符号验证阶段

三、准备

四、解析

1.类和接口的解析

2.字段解析

五、初始化

1.clinit

2.init

六. 卸载阶段

概述

        上一篇我们讲解了java文件到class文件的解析过程,但class文件中描述的信息都是死的,像一些类呀,在class文件里保存的只是一个全限定名,我们需要把这些类信息加载到虚拟机里面去运行和使用,这一篇将讲解calss文件加载到虚拟机的全过程,在内存中真正使用到各种信息。

        java虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这个过程就被叫做虚拟机的类加载机制。 

      

类加载的时机

        一个类从被加载到虚拟机内存中开始,到卸载除内存为止,它的生命周期分为如下几个阶段

有且只有六种情况需要立即对类进行初始化(前面的加载、验证、准备也必须在初始化之前)。

1、遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果对应的类没有初始化则需要先初始化该类,再执行对应的字节码指令。

  • new,也就是实例化该类的对象,肯定要需要先初始化该类咯
  • getstatic、putstatic即设置和取一个类的静态变量的时候,也是需要先初始化类的(被final修饰的常量除外,因为final static是一个常量字段,如果你的类用到了其他类的常量字段,会在编译期就把该字段变为一个常量,如:Math类有final static int PI=3.14,我的Test类中使用了Math.PI,编译期把这个Math.PI变成了3.14,和它的类没什么关系了,这也是一种优化)
  • invokestatic:调用类的静态方法时,同理也需要初始化对应的类。

2、使用java.lang.reflect包的方法对一个类进行反射调用的时候,如果该类没有进行过初始化,则需要先进行初始化。

3、初始化一个类的时候,发现它的父类(包括接口)没有进行初始化,则需要先触发它父类的初始化

4、当使用jdk7新加入的动态语言支持时,则需要先触发其初始化

5、当虚拟机启动时,需初始化你指定的主类(包含mian()方法的那个类)

6、当一个接口用到了jdk8新出的默认方法(@default)时,如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化。因为default的方法毕竟是它父类中的。

可通过-Xlog:class+load=info参数打印出程序运行期间所有被初始化的类。jdk9之前使用-XX:+TraceClassLoading

上面的六种情况被称为类型的主动引用,其他的都是被动引用,如下举三个例子说明何为被动引用:

public class SuperClass {
    public static int value=10;
    static {
        System.out.println("父类进行初始化...");
    }
}
class SubClass extends SuperClass{
    static {
        System.out.println("子类进行初始化...");
    }
}
class MainClass{
    public static void main(String[] args) {
        System.out.println(SuperClass.value);
    }
}

输出结果当然只有:父类进行初始化...和10啦!

复用上面的类,我们再改动Mian中的代码为

class MainClass{
    public static void main(String[] args) {
        SuperClass[] array=new SuperClass[10];
    }
}

他是不会触发SuperClass类的初始化的,因为我们创建的是数组,字节码指令为newarray,只会创建对应的数组。

public class ConstantClass {
    public final static int VALUE=123;//注意是常量
    static {
        System.out.println("ConstantClass进行初始化...");
    }
}
class MainClass{
    public static void main(String[] args) {
        System.out.println(ConstantClass.VALUE);
    }
}

如上代码只会打印123,不会初始化ConstantClass类,其实这段代码在编译阶段经过常量传播优化,把VALUE的值存到了MainClass的常量池里面,并且MainClass中对该Vlaue的引用并不是像原生的ConstantClass.VALUE一样,而是转为直接引用的常量池中的常量。这两个类在编译完成以后已经没有任何联系了。可以看出加final的常量比静态成员更省资源。

注意常量不包括一些new出来的类,只包含字面量。

另外接口也有初始化过程,《深入理解java虚拟机》一书中讲过过一段话是大致意思如下

接口和类的初始化过程有所区别,即前面说的六种情况的第三种:当类初始化时要求其父类全部都已经初始化;接口在初始化时并不需要其父接口全部完成初始化,只有在真正使用到它父类的时候(如引用接口中定义的方法时)才会初始化。

 但当我调试的时候发现接口的初始化也会初始化其父接口,我这点很困惑,大家伙可以尝试尝试。

类加载过程

明白了类加载的时机,接下来就是类加载的全过程了,即加载、验证、准备、解析和初始化的具体动作。

一、加载*

加载阶段是类加载阶段的第一个阶段,两个名词不要搞混了,加载阶段jvm需要完成以下三件事:

1)通过类的全限定名获取此类的二进制字节流,通常常量池中保存了用到的类的全限定名

2)将此类的二进制字节流转为方法区的运行时数据结构(称为Klass)

3)在内存中生成一个此类的java.lang.Class对象(类对象),作为方法区此类的各种数据的访问入口

其中第一步大多是通过直接在项目路径下的.class文件获取二进制字节流,这只是其中一种常规的方法,你只要能获取到随便在哪获取,怎么获取,只要你有想象力,如下有很多方式:

  • 从压缩包里获取,如jar包war包,ear包
  • 从网络中获取,如Web Applet场景
  • 运行时计算生成,典型的动态代理技术,通过特定接口生成..$Proxy形式的代理类的二进制字节流
  • 从数据库中获取
  • 加密文件中获取,这种方法是典型的防反编译的保护措施,通过加载时解密获取

加载阶段可以使用自带的引导类加载器也可以自定义类加载器。下一篇讲解自定义类加载器。但是类加载器加载类只是加载不会进行类的解析和初始化,后面你就知道了。

注意:加载阶段和连接阶段是交叉进行的,可能加载还未完成,连接阶段就已经开始了

这些jvm之外的二进制字节码被我们加载到方法区后(还需要一个文件格式验证才能放到方法区),会在java堆中生成一个java.lang.Class类的对象,我们通过这个类对象就能访问到方法区中的该类的数据了。如下:

System.out.println(Object.class);//class java.lang.Object
System.out.println(Object.class instanceof java.lang.Class);//true

深入理解方法区

方法区中,实际内部采用C++的instanceKlass描述java类(中间有一个转换过程),Klass的几个重要的重要字段如下:

  • java_mirror即java 的类镜像,例如对 string 来说,就是String.class,java要访问真的的类需要先通过访问String.class,间接访问到Klass。作用是把klass暴露给java使用,相当于c++的类和java类的一个桥梁

  • super即表示该class的父类

  • fields即class的成员变量

  • methods即class的方法

  • constants即常量池的引用,常量池在堆中

  • class_loader即表示是哪个类加载器加载的该类

  • _vtable虚方法表,虚方法表里存了对象应该执行的方法 多态底层实现原理(关注专栏,后续会出)

  • _itable接口方法表

如下通过堆中对象的对象头中的信息,找到class对象,class对象再去找到元空间的java_mirror,才能真正访问到Klass中的字段、方法等

二、验证

上一步我们已经加载了class文件的二进制字节流进了内存,但我们并不能保证该字节流是没有问题的,所以我们需要有一个阶段去验证它,保证这些字节流符合规范,不会危害虚拟机自身的安全。不是随便拿一个class文件就能运行的。

该阶段需要验证如下的几个内容:

1.文件格式验证

这一阶段主要是验证你加载进来的class文件是否符合规范,上一篇我们讲到的class文件格式就是它验证的内容。

主要是验证是否是规定的魔数、主次版本是否支持、常量池是否有不被支持的常量、class文件是否完整……(很多),如果该阶段出错了,就应该抛出一个java.lang.VerifyError错误。

只有通过了这个阶段,那么你加载的二进制流才被允许放进jvm的方法区中进行存储,所以后面的三个验证阶段全是基于方法区上进行的了,不会再直接读取、操作字节流了

2.元数据验证

文件格式验证完了,就相当于保证了该class文件的结构是一个完整的结构,结构是对的并不能说该class文件就没有问题,我们还需要验证该字节码的内容,也就是语义分析。这个阶段的验证点如下:

  • 这个类是否有父类,除了Object类所有的类都必须要有父类
  • 这个类是否实现了父类中或接口中要求实现的抽象方法
  • 这个类的字段和方法是否和父类或接口的产生矛盾,如覆盖了父类的final修饰的字段
  • ……

看名字就知道这是对元数据(理解为类)验证,所以涉及到的都是类和它的父类们的一些关系。

3.方法体验证

上一阶段是对类的元数据进行语义分析,这一阶段主要是对类中方法体Code进行语义分析,确保方法中的语义是合法的、符合逻辑的。这个阶段的验证点如下:

  • 操作数栈的数据类型与指令代码序列能配合工作,保证方法中的类型转换有效等等。
  • 保证跳转指令不会跳转到方法体以外的字节码指令上
  • 保证类型转换是对的
  • ……

该阶段验证通过并不意味着方法体一定正确,用一个很著名的问题解答——停机问题:不能通过程序准确的检查出程序是否能在有限的时间内结束运行,不能用程序准确的判定一段程序是否存在bug。

4.符号验证阶段

此阶段将在解析阶段中发生,解析阶段就是将class文件中的各种符号引用转为直接引用,也就是把class文件中的常量池中的常量字面量变成真正的指针,引用内存中的数据。

符号验证是判断该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。内容如下:

  • 符号引用中描述类只是用一个全限定名表示,那我就需要检查该全限定名是否能找到对应的类。
  • 如果找到了类,检查该类的可访问性是否能被本类访问。
  • 同理,需要检查在指定的类中是否存在常量池中描述一样的方法。
  • ……

符号验证阶段未通过会抛上面的异常。

如果程序被反复使用和验证过,可以考虑使用-Xverify:none参数关闭大部分的类验证,缩短jvm类加载时间。

三、准备

准备阶段就是正式为类中的静态变量分配内存设置静态变量初始值的阶段,静态变量分配在堆中(jdk8已经将静态变量和常量池从方法区移到了堆)。注意不包括实例变量,且设置初始值只是设置0值,通常情况不是我们代码中静态变量x=3这样的初值,静态变量一开始初始值都是0值、false、null等。真正赋值是在初始化阶段。

例外情况加final的常量(不包括reference哦!),常量在准备阶段就会被赋值为它应该有的值

四、解析

将常量池中的符号引用解析为直接引用的过程

  • 符号引用:就是由符号来描述引用的目标,是一个字面量,和内存布局无关。
  • 直接引用:直接指向目标的一个指针、相对偏移量或者是能访问到目标的句柄。如果有了直接引用那引用的目标必定在虚拟机的内存中存在。

 假设:一个类有一个静态变量,该静态变量是一个自定义的类型,那么经过解析后,该静态变量将是一个指针,指向该类在方法区的内存地址。

解析动作的时机不是固定的,但在使用到一些指令之前必须解析,且同一个符号引用可能被多次解析,虚拟机也可以对解析的结果进行缓存,如某个类被解析过了,我就打上标记,下次用到,就不需要重新解析了。

解析动作主要针对类或接口、字段、类方法、接口方法进行。我们来看看几个的解析过程:

1.类和接口的解析

假如当前的类解析到了一个指令涉及到外部的类或接口了(我们编译时在常量池中保存了该类的符号引用),现在我们需要把该符号引用的全限定名给到当前类的类加载器去加载它,也会触发该类的加载验证准备等动作。

如果加载时出现异常就宣告我们的解析失败。如果成功那么该类就已经成为了一个有效的类或接口,并存在于方法区了,我们此时还需要验证我们对该类的访问权限,如果不具备该访问添加也会解析失败,抛出java.lang.IllegalAccessError异常。如果解析成功我们也就获取了该外部类在方法区中的直接引用了。

2.字段解析

首先取出该字段所属的类或接口的符号引用先进行类或接口的解析,解析完成后,在该类中进行搜索,如果找到我们需要的字段,就返回这个字段的直接引用。如果没找到的话从他的父接口和父类中继续找,会一直找到Object类,还找不到的话就抛出NoSuchFieldError。

最后找到的话也会进行访问权限检查。

如果有同一个字段出现在父类和父接口中就会报错Reference to 'A' is ambiguous, both 'SubClass.A' and 'SuperClass.A' match,或者同时在自己或者多个父接口中也会,父类不会。

 给出一段演示,我们只需查看该输出内容的输出顺序。

public class Class1 {
    static {
        System.out.println("class1...");
    }
}
class Class2 {
    static {
        System.out.println("class2...");
    }
    static Class1 c = new Class1();
}
class MainClass {
    static {
        System.out.println("MainClass...");
    }
    public static void main(String[] args) {
        System.out.println(Class2.c);
        System.out.println("heihei");
//        MainClass...
//        class2...
//        class1...
//        vm.gc.demo.Class1@677327b6
//        heihei

    }
}

 一开始首先初始化我们的主类,执行到第一个输出时我们需要找到c这个字段,必须先加载解析Class2类,途中发现c这个字段使用了Class1,再初始化Class1,然后主类中才能取到c,再向下执行。

五、初始化

在准备阶段我们只是给静态变量设置了类似0的初值,在这一阶段,则会根据我们的代码逻辑去初始化类变量和其他资源。更直观的说初始化过程就是执行类构造器<clinit>方法的过程

初始化完也就差不多是类加载的全过程了,什么时候需要初始化也就是我们最前面讲到的几种情况。

类初始化时懒惰的,不会导致类初始化的情况,也就是前面讲到的被动引用类型,再讲全一点:

  • 访问类的static final静态常量(基本类型和字符串)不会触发初始化
  • 访问类对象.class不会触发初始化
  • 创建该类的数组不会触发初始化
  • 类加载器的 loadClass方法
  • Class.forName(反射)的参数2为false时(为true才会初始化)

在编译生成class文件时,编译器会产生两个方法加于class文件中,一个是类的初始化方法clinit, 另一个是实例的初始化方法init

1.clinit

  • <clinit>()方法是用来给静态变量赋初值执行静态代码块中的语句的方法,给他们合成了一个方法,收集的顺序由源代码中的顺序决定。静态代码块中只能访问定义它之前的变量,定义在它之后的变量可以赋值但不能访问
class Class{
    static {
        c=2;//赋值操作可以正常编译通过
        System.out.println(c);//编译器提示Illegal forward reference,非法向前引用
    }
    static int c=1;
}
  • <clinit>()方法不需要显示调用,类解析完了会立即调用,且父类的<clinit>()永远比子类的先执行,因此在jvm中第一个执行的肯定是Object中的<clinit>()方法。
  • <clinit>()方法不是必须的,如果没有静态代码块和变量赋值就没有
  • 接口也有变量复制操作,因此也会生成<clinit>(),但是只有当父接口中定义的变量被使用时才会初始化。
  • 一个类的<clinit>()方法必须保证在多线程下同步加锁,如果两个线程同时初始化一个类,一个类在执行,另一个线程需要阻塞等待
class Class2{
    static {
        //如果不加if,则会报Initializer must be able to complete normally,并拒绝编译
        //加一个if只是骗过编译器
        if (true){ 
            while (true);
        }
    }
    static int c=1;
}

例子

public class ClassDemo {
    static{
        i=20;
    }
    static int i=10;
    static{
        i=30;
    }
   //init方法收集后里面的代码就是这个,当然你是看不到该方法的
    init(){
      i=20;
      i=10;
      i=30;
    }
}

2.init

init是实例方法自动生成的方法。

编译器会按从上至下的顺序,收集所有{}代码块和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在最后

public class ClassDemo {
    int a = 1;
    {
        a = 2;
        System.out.println(2);
    }

    {
        b = "b2";
        System.out.println("b2");
    }
    String b = "b1";
    public ClassDemo(int a, String b) {
        System.out.println("构造器赋值前:"+this.a+" "+this.b);
        this.a = a;
        this.b = b;
    }
    public static void main(String[] args) {
        ClassDemo demo = new ClassDemo(3, "b3");
        System.out.println("构造结束后:"+demo.a+" "+demo.b);
//        2
//        b2
//        构造器赋值前:2 b1
//        构造结束后:3 b3
    }
}

上面的代码的init()方法实际为:

    public init(int a, String b){
        super();//不要忘记在底层还会加上父类的构造方法
        this.a=1;
        this.a = 2;
        System.out.println(2);
        this.b = "b2";
        System.out.println("b2");
        this.b = "b1";
        System.out.println("构造器赋值前:" + this.a + " " + this.b);//构造方法在最后
        this.a = a;
        this.b = b;
    }

这其中的优先级是比较重要的

六. 卸载阶段

  •     执行了System.exit()方法
  •     程序正常执行结束
  •     程序在执行过程中遇到了异常或错误而异常终止
  •     由于操作系统出现错误而导致Java虚拟机进程终止
  •     方法区进行垃圾回收,回收无价值的类信息
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值