Java类加载机制

Java类加载机制

Posted by Anriku on April 4, 2018

今天我们主要进行一个Java类加载机制相关的东西进行一些讲解,本来还想加上类加载器的东西的,写完发现貌似有点长。那么咱们就分开进行讲解吧!下一篇博客我们在对类加载器进行解释!那么现在让我们开始啦!

类的生命周期

在讲类加载机制之前,我们先来看一张表示一个类生命周期的图:

类的生命周期

上面这张图很好的说明了一个类的生命周期是如何在变化的。其中从加载到初始化的整个过程就是我们要讨论的类加载机制。

类加载机制

类加载机制我想或多或少大家都有了解吧!把Class文件加载到内存中,然后经过加载、连接,初始化一系列的操作后,让其变成可以被JVM直接使用的Java类型,这就是类加载机制。

##类或接口被初始化的情况

在下面5种情况下一个类会进行初始化(这里说的初始化是指类加载机制的全部过程,不光是初始化一步。下面除了特殊说明,所说的初始化都是从加载到初始化的整个过程),也被称为主动引用。其它所有引用类的方式都不会初始化类,这些方式被称为被动引用

  • 当使用new实例化对象,读取或设置一个类的静态字段(被final修饰的除外,final修饰的字段在编译器就已经把结果放入调用类常量池了),以及调用一个类的静态方法的时候,如果该类没有被初始化,那么就会进行初始化。(说得底层一点就是当遇到new、getstatic、putstatic、invokestatic这四条字节码指令的时候)。
  • 当使用反射对类进行反射调用的时候,如果类没有进行过初始化,那么就会进行初始化。
  • 当在初始化一个类的时候,如果该类有父类并且父类没有被初始化过,那么父类会先进行初始化。
  • 当虚拟机启动的时候,用户所指定的一个要执行的主类(含main方法那个类),虚拟机会先初始化这个类。
  • 当使用JDK 7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokestatic的方法句柄,并且这个方法句柄所对应的类没有被初始化的时候,会进行初始化。

被动引用举例:

  • 通过子类引用父类的静态字段,不会导致子类的初始化:
public class NoInitialization {

    public static void main(String[] args) {
        int n = SubClass.value;
    }
}

//父类
class SuperClass{
    public static int value = 123;

    static {
        System.out.println("SuperClass Initial");
    }
}

//子类
class SubClass extends SuperClass{
    static {
        System.out.println("SubClass Initial");
    }
}

我们来看一下执行结果:

未加载

可以看到只有父类进行了初始化。这也符合上面分析的类被初始化的5种情况。

  • 通过数组定义来引用类的时候,不会进行类的初始化。
public class NoInitialization {

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


class SuperClass{
    public static int value = 123;

    static {
        System.out.println("SuperClass Initial");
    }
}

截图就不给了,就是SuperClass中的静态代码块不会执行。

其实这里是在虚拟机中有一个名为[LSuperClass的类进行了初始化,这是虚拟机自动生成的,直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发。这也说明了Java中一个数组也是一个对象

  • 常量会在编译时期存入调用类的常量池中,本质上并没有直接引用到定义常量的类,所以说这种情况也不会初始化这个类
public class NoInitialization {

    public static void main(String[] args) {
        int a = SuperClass.value;
    }
}

class SuperClass{
    public static final int value = 123;

    static {
        System.out.println("SuperClass Initial");
    }
}

这里的截图我也不给了,执行结果为空。

由于SuperClass中的value常量在编译阶段,通过常量传播优化,已将其值存储在了NoInitialization类的常量池中。所以说两个类并没啥关系,因此不会进行初始化。

上面5中类初始化的情况,除了第三种情况外,其它都是一样的。对于接口来说,一个接口被初始化时,只有在真正使用到父接口的时候(如引用接口中定义的常量),父接口才会进行初始化。

类加载的过程

类加载的过程包括了我们上面类的生命周期中提到的加载、验证、准备、解析和初始化然后我们要注意类加载和加载的区别,类加载时这个类被加载到JVM并做好一切的初始化操作的一个过程,而加载只是类加载中的一步操作。

加载

类加载主要要做下面的三件事情:

  • 通过类的全限定名来获取这个类的字节码。(PS:这里的字节码并不一定是Class文件,还有其它的来源比如说从zip包中读取等)
  • 将这个类所代表的静态存储结构转换成方法区的运行时数据结构。
  • 在方法区中生成代表这个类的java.lang.Class对象,作为方法区这个类的各个数据的访问入口。

对于一个非数组类我们是通过类类加载器来完成加载的。我们知道数组也是一个对象,但是数组类不是通过类加载器来进行加载创建的,它是有JVM进行创建的。只不过一个数组类的元素类最终还是要通过类加载器来进行加载创建的。

下面是一个数组类创建所需要遵循的一些规则:

  • 如果数组的元素类型是引用类型。那么元素类型会实现整个类加载的过程。这个数组类会与加载该数组元素类型的类加载器的类名称空间上被标识(因为一个类的唯一性是由这个类以及其类加载器来进行确定,这个将会在后面类加载器中进行解释)
  • 如果这个数组的元素类型不是引用类型(比如int[]数组),那么这个数组将会标记为与引导类加载器关联。
  • 如果元素类型是引用类型,数组类的可见性和元素类型的可见性是相同的;如果元素类型不是引用类型的,那么数组的可见性默认是public。

在加载完成后,JVM会将这个类的字节码根据JVM所需的格式存储在方法区中。然后,在内存中实例化一个java.lang.Class对象(对于HotSpot虚拟机而言,Class对象比较特殊,虽然它是对象,但是却是存在方法区中的)

这里还得提一下的就是,加载阶段和连接阶段是交叉进行的,也就是说在我们的加载阶段还没有完成的时候,连接阶段就可能开始执行了。只不过两个阶段的开始时间是按照着生命周期的先后顺序的。

验证

JVM在进行类加载的时候任何来源的类的字节码都可以进行读取的,不管是通过Java源码获取的还是通过自己按照Class文件的规范进行编译的都是可以的。想想如果这样的话会让很多的恶意攻击者有机可乘的。所以说类加载过程是需要一个验证过程来保证安全的。

这个阶段一共有四个检验操作:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证

这个阶段主要是保证类的字节码能够正确的被解析并存储到方法区中。也就是来验证类的字节码是否是符合Class文件格式规范以及能否被当前版本的JVM进行处理。

需要注意一点的就是这个验证是基于类的字节码进行的;其它的验证是基于方法区的存储结构进行的,并不会直接操作该类的字节码。

元数据验证

这个阶段主要是对类的元数据信息进行语义的验证,保证其符合Java语言的规范。比如说包括下面一些验证:

  • 检查一个被标记为final类型是否包含派生类。
  • 检查一个类中的final方法是否被派生类重写。
  • 确保超类与派生类之间没有不兼容的方法声明(比如方法签名相同,但方法返回值不同)。

字节码验证

这个阶段时验证过程中最复杂的一个阶段。这个阶段主要是对类的方法体进行校验分析,保证被校验的方法在运行时不会做出危害虚拟机安全的事件。

符号引用验证

最后这个阶段的验证发生在JVM将符号引用转换成直接引用的时候。这个转化动作将在连接的第三阶段(解析阶段)发生。符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。一些主要验证的内容如下:

  • 符号引用中通过字符串描述的全限定名是否能够找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的字段
  • 符号引用中的类、字段、方法是否可以被当前类访问
  • ….

总的来说,验证这个阶段是保证字节码安全的一个阶段,如果我们已经确认了字节码的安全性。我们可以通过使用-Xverify:none参数来关闭验证操作,来缩短虚拟机加载类的时间。

准备

这个阶段将会为类变量在方法区中进行内存分配以及设置初始值。

这里需要注意的有三点:

  • 这个阶段仅为类变量(就是static所修饰的变量)进行空间的分配。不会为实例变量分配空间,实例变量是在实例化对象的时候随着对象一起被分配到堆中去。
  • 还有就是这时候所说的设置初始值是不同变量对应的默认值,比如说:
//这段代码在准备阶段为value设置的值是0,而不是123
public static int value = 123;
  • 对于上面的默认值设置是一般的情况。但是有特殊的情况在准备阶段就会设置后面的值。如果这个类变量有ConstantValue属性,就会设置ConstantValue所指定的值。如果一个变量被static以及final修饰,并且这个变量为基本类型或者String类型的时候,就会在用javac进行编译的时候生成对应的ConstantValue属性,准备阶段就会根据ConstantValue来设置其指定的值。比如说:
//这段代码在准备阶段就会为value设置为123
public static final int value = 123;

解析

该阶段是虚拟机将常量池中的符号引用替换为直接引用的一个阶段。那下面我们介绍一下符号引用以及直接引用:

  • 符号引用通过任何没有歧义的字面量来描述所引用的目标。其中符号引用和虚拟机实现的内容布局无关,引用的对象也不一定被加载到了内存中。各个虚拟实现的内容布局不相同,但是它们能接受的符号引用是一致的,因为符号引用的字面量被明确的定义在了Class文件中。
  • 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。其中直接引用是和虚拟机实现的内存布局相关的,同一符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。而且,如果有了直接引用,那么引用的目标一定在内存中了。

初始化

这是类加载过程的最后一步了,到了这里我们回想一下前面的各个过程中是不是除了第一步加载我们可以通过类加载来进行自己的操作以为,其它的都是虚拟机在做呀!没错事实也是这样的。但是最后一步也是执行我们程序猿自己定义的初始化代码的一步的。通俗的说这一步开始真正执行定义在Java程序中的代码了。初始化阶段其实是执行类构造器\<clinit\>方法的过程。由于这一步是与我们平常最相关的一步。所以在这里我会进行详细的解释~

<clinit>方法的生成

  • 对于类来说:<clinit>是由编译器自动的收集类中所有的类变量(由static修饰的变量)的赋值操作和静态代码块中的语句然后合并生成的。编译器收集的顺序也是我们在Java的源码中编写的顺序。这里我们需要注意的一个问题就是,在静态代码块中我们只能访问在静态代码块前的静态变量,否则编译器会报非法向前引用错误。
  • 对于接口来说:对于接口来说,接口中不能定义静态代码块,但是接口中可以有变量(接口中定义的变量默认是加上static和final的关键字的)。因此接口中也会生成<clinit>方法。然后和类的区别我们在下面特点中一起进行解释。
  • 但是对于类和接口的话<clinit>方法并不是必需的,如果一个类或者是接口中没有类变量的赋值操作和静态代码块,那么编译器可以不为这个类生成<clinit>()方法。

下面是代码报错的截图:

非法向前引用

<clinit>方法的特点

  • 对于类:JVM会保证在子类的<clinit>方法执行之前,父类的<clinit>方法已经执行完毕。因此在JVM中第一个被执行的<clinit>方法肯定是java.lang.Object中的。

    通过下面的代码我们来具体的感受一下:

    //这段代码的执行结果是 2
    public class Main {
        public static void main(String[] args) {
            System.out.println(Sub.B);
        }
    
        static class Parent{
            public static int A = 1;
            static {
                A = 2;
            }
        }
    
        static class Sub extends Parent{
            public static int B = A;
        }
    }
    

    对于接口:执行子接口的<clinit>方法的时候不需要先执行父接口的<clinit>方法。同样,对于接口的实现来说是一样的,实现类的<clinit>方法的时候不需要先执行接口的<clinit>方法。只有当对应接口的变量使用到的时候才会去初始化接口。

  • 虚拟机会保证一个类的<clinit>方法在多线程的情况下被正确的加锁、同步执行。也就是当多个线程同时去初始化一个类的时候,只会有一个线程去进行初始化,其它线程都会被阻塞。还有一个需要特别注意的就是当一个线程执行完了<clinit>方法后,其它线程被唤醒了也不会去执行<clinit>方法。因为同一个类加载器,只会对一个类进行一次初始化。我们有些单例模式也是依靠这个特点来进行实现的。

    下面我们来看一个实例代码吧:

    public class Main {
        static {
            System.out.println(Thread.currentThread() + "<clinit> start");
        }
    
        public static void main(String[] args) {
            new Thread(() -> {
                Main main = new Main();
            }).start();
    
            new Thread(() -> {
                Main main = new Main();
            }).start();
    
        }
    }
    

    然后执行的结果是:

    类的初始化

    我们可以看到只有一个线程初始化了这个类。

总结

今天我们对Java的类加载机制做了一个了解。我们要牢记一个类的生命周期,然后根据这个生命周期作为框架再深入去记类加载机制中相关的东西。

参考

《深入理解Java虚拟机》

转载请注明链接