JVM(三)-java虚拟机类加载机制

概述:

  上一篇文章,介绍了java虚拟机的运行时区域,Java虚拟机根据不同的分工,把内存划分为各个不同的区域。在java程序中,最小的运行单元一般都是创建一个对象,然后调用对象的某个

方法。通过上一篇文章我们知道调用某个方法是通过虚拟机栈的栈帧并通过执行引擎来实现的,但是实际上一个方法的执行前提是,该对象对应的Class文件需要加载到内存的方法区,并且

要new一个对象,对象的引用存放在虚拟机栈的本地变量表,对象的实例存放在堆。本篇文章关注的重点就是Java虚拟机如何将Class文件加载到内存。

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

里,类型的加载、连接、初始化都是在程序运行期间完成的,这种策略让java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是确为java应用提供了极高的扩展性

和灵活性,java天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

类加载的时机:

  一个类型从被加载到虚拟机内存中开始,到卸载除内存为止,它的整个声明周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、准备、解析三个部分统称为连接。

这七个阶段的发生顺序如下图所示:

 

   上图中,加载、验证、准备、初始化和卸载这个五个阶段的顺序是确定的,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后开始,这是为了支持java语言的运行时绑定特性(也称为动态绑定或晚绑定)。

初始化只有在以下六种情况下才会触发:

  1. 使用new关键字实例化对象或者读取或设置一个类型的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)以及调用一个类型的静态方法的时候。
  2. 对类型进行反射调用时,如果没有进行过初始化,则需要先触发其初始化。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个执行的主类,虚拟机会先初始化这个主类。
  5. 使用java7新加入的MethodHandle动态语言支持,在使用某些方法时,如果类没有初始化,则需要先触发初始化。
  6. 当接口中定义了JDK8新加入的默认方法,如果有这个接口的实现类发生了初始化,则需要优先初始化该接口。

  这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。来看一个主动引用的例子,这是一个典型的饿汉式单例模式:

package singleton;

/**
 * @ClassName SingletonDemo1
 * @Description 饿汉式
 * 类加载到内存后,就实例化一个单例对象,JVM保证线程安全
 * @Author liuyi
 * @Date 2020/6/7 12:22
 * @Version 1.0
 */
public class SingletonDemo1 {
    //静态块方式
    private static final SingletonDemo1 instance;
    static {
        instance = new SingletonDemo1();
    }
    private SingletonDemo1(){
        System.out.println("我被初始化了");
    }
    public static SingletonDemo1 getInstance(){
        System.out.println("获取单例类对象");
        return instance;
    }

    public static void main(String[] args) {
        SingletonDemo1.getInstance();
    }
}

  代码的执行结果如下:

   很多java程序员对单例的饿汉式都有一个误解,就是static快里面的实例化代码在程序启动的过程中就会被执行,这样的理解是完全错误的。饿汉式指的是类被加载的时候初始化,而

懒汉式是使用的时候才初始化。我在概述里面也提到了,java是在程序运行期间进行类的加载、连接和初始化。所以这里,我在调用getInstance()方法的时候类才会被加载,而getInstance()

 方法又恰好是一个静态方法,满足六条触发初始化规则的第一条,所以当调用getInstance(),该类会被初始化,只有在类被初始化的时候才会执行static块的代码,所以会先打印我被初始化了,

然后再打印获取实例类对象。这里之所以被称为饿汉式单例是因为在获取实例之前,对象已经先一步初始化好了。只是这里恰好触发初始化的方法是getInstance(),会给人一种误解是我

调用了该方法才初始化的,但是实际上能够触发类初始化方式并不是只有这一种,上面列的六种情况中的任意一种都可以触发类的初始化。当在我调用getInstance()之前,该类被初始化过,这种

情况就很好解释它就是饿汉式了。

  我们来验证是不是这样的,我在代码中加了一个静态变量a,然后调用该静态变量,代码如下:

package singleton;

/**
 * @ClassName SingletonDemo1
 * @Description 饿汉式
 * 类加载到内存后,就实例化一个单例对象,JVM保证线程安全
 * @Author liuyi
 * @Date 2020/6/7 12:22
 * @Version 1.0
 */
public class SingletonDemo1 {

    private static int a = 2;

    //静态块方式
    private static final SingletonDemo1 instance;

    static {
        instance = new SingletonDemo1();
    }

    private SingletonDemo1() {
        System.out.println("我被初始化了");
    }

    public static SingletonDemo1 getInstance() {
        System.out.println("获取单例类对象");
        return instance;
    }

    public static void main(String[] args) {
        System.out.println(SingletonDemo1.a);
//        SingletonDemo1.getInstance();
    }
}

  来看看执行结果:

   可以看到,我没有调用getInstance()方法,该类还是被初始化了,这里是因为我调用了静态变量a,同样满足六种情况的第一种情况,所以类在加载的时候被初始化了。

static代码块同时也被执行了,所以打印了我被初始化了。

  我们再了看看另外一种方法实现单例模式,静态内部类方法,为什么说这种方式不会提前初始化,先来看代码:

package singleton;

/**
 * @ClassName SingletonDemo5
 * @Description 静态内部类方式
 * JVM保证线程安全
 * 加载外部类是不会加载内部类,实现了懒加载
 * 最完美的写法
 * @Author liuyi
 * @Date 2020/6/7 13:52
 * * @Version 1.0
 */
public class SingletonDemo5 {

    private SingletonDemo5() {
        System.out.println("我被实例化了");
        //防止恶意通过反射破坏单例            
        if (SingletonDemo5Inside.instance != null) {
            throw new RuntimeException("不允许创建多个实例");
        }
    }
    private static class SingletonDemo5Inside {
        private static final SingletonDemo5 instance = new SingletonDemo5();
    }

    public static SingletonDemo5 getInstance() {
        System.out.println("获取单例类对象");
        return SingletonDemo5Inside.instance;
    }

    public static void main(String[] args) {
        SingletonDemo5.getInstance();
    }
}

  来看代码的执行结果:.

   从代码的执行结果来看,是在调用getInstance()方法之后,类才被初始化的。为什么会这样呢,这是因为我们在调用该方法的时候,SingletonDemo5虽然被初始化了,

但是它并没有被实例化,而静态内部类SingletonDemo5Inside同样满足java是在程序运行期间进行类的加载、连接和初始化的原则,所以在没有调用SingletonDemo5Inside.instance()

之前,它是不会别加载的。当我们调用SingletonDemo5Inside.instance()的时候,SingletonDemo5才被实例化,所以这种方式是除了枚举方式之外最完美的单例写法。

  我们再来看看被动引用的例子:

package singleton;

/**
 * @ClassName Person
 * @description:
 * @author:liuyi
 * @Date:2020/11/29 1:00
 */
public class Person {
    static {
        System.out.println("初始化Person类");
    }

    public static int age = 28;
}
class Man extends Person{
    static {
        System.out.println("初始化Man类");
    }
}

class PersonTest{
    public static void main(String[] args) {
        System.out.println(Man.age);
    }
}

  代码执行结果:

 

   对于静态字段,只有直接定义这个字段的类才会被初始化,所以就算使用子类调用该静态变量,也只有父类才会被初始化。

  再来看一个例子:

package singleton;

/**
 * @ClassName ConstantClass
 * @description:
 * @author:liuyi
 * @Date:2020/11/29 1:06
 */
public class ConstantClass {
    static {
        System.out.println("初始化ConstantClass");
    }

    public static final String text = "hello";
}

class ConstantTest{
    public static void main(String[] args) {
        System.out.println(ConstantClass.text);
    }
}

  代码执行结果:

 

   可以看到,虽然我们是访问的static修饰的变量,但是依然没有触发该类的初始化。这是因为text是一个常量,会被放到常量池中,我们并不会通过类去获取,所以

不需要对类进行初始化。

类的加载过程:

   接下来我们详细了解java虚拟机中类加载的全过程,即加载、验证、准备、解析和初始化这五个阶段所执行的具体动作。

加载:

  在加载阶段,虚拟机需要完成以下三件事情:

  1. 通过一个类的完全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表整个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。

相对于类加载过程的其他阶段,非数组类型的加载阶段是开发人员可控性最强的阶段。开发人员可以使用java虚拟机内置的引导类加载器来实现加载阶段,也可以自定义类加载

来实现。而对于数组而言,情况有所不同,因为数组类本身不通过类加载器创建,它是由java虚拟机直接在内存中动态构造出来的。但是如果数组的类型是引用类型,整个数组的

创建还是要依赖类加载器。

  加载阶段和连接阶段的部分动作是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但是整体上的顺序还是先加载,再进入连接阶段。举个简单的例子,比如一个类

有两个方法,可能一个方法加载完之后,立马就进入这个方法的连接阶段,但是此时第二个方法可能才刚开始加载。

验证:

  验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合java虚拟机规范的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

从整体上看,验证阶段大致上会完成下面四个阶段的检验动作。

  1. 文件格式验证:验证字节流是否符合Class文件的格式的规范,并且是否能被当前版本的虚拟机处理。

  2. 元数据验证:对字节码描述的信息进行语义分析,以保证其描述信息符合java虚拟机规范的要求。

  3. 字节码验证:这个阶段是整个验证过程最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。
  4. 符号引用验证:这个阶段的校验行为发生在虚拟机符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段发生。符号引用验证可看作是对类自身以外(常量池中的

 

各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某这些外部类、方法、字段等资源。

  验证阶段对于虚拟机的类加载机制来说,是一个非常重要、但却不是必须要执行的阶段。如果程序的全部代码(都已经被反复使用和验证过),在生产环境的实施阶段就可以考虑使用-Xverify:none

参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备:

  准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,关于准备阶段,有两个容易产生混淆的概念这里需要着重强调,首先是这时候进行内存

分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。其次这里所说的初始值通常情况是数据类型的零值,假设一个类型变量的定义为:

public static int value = 666;那变量value在准备阶段过后的初始值为0而不是666,value赋值为666要在类的初始化阶段才会被执行。

解析:

  解析阶段是java虚拟机将常量池内的符号引用替换为直接引用的过程,先来看看符号引用和直接引用的概念:

 

  符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机

内存当中的内容。各种虚拟机的内存布局可以各不相同,但是它们接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在java虚拟机规范的Class文件格式中。

  直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者一个能间接定位的目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同的虚拟机中翻译出来的直接引用

一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

  解析主要包括类或者接口的解析、字段解析、方法解析、接口方法解析,每项解析都有自己的解析步骤,这里就不一一介绍了。

初始化:

  类的初始化阶段是类加载过程的最后一个步骤,初始化完成之后,java虚拟机才会正在的开始执行类中编写的java程序代码,将主导权移交给应用程序。在准备阶段中,变量已经赋过一次系统要求的初始

零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表达:初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()

并不是指程序员在java代码中直接编写的类构造方法,它是javac编译器自动生成的,但是我们非常有必要了解这个方法具体是怎么产生的,以及该方法执行过程中各种可能影响程序运行行为的细节,这部分

比起其他类加载过程更贴近于普通开发人员的实际工作。

  <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器搜集的顺序是由语句在源文件中出现的位置决定的,静态语句块中只能访问到定义

在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如下面代码:

/**
 * @ClassName Test1
 * @description:
 * @author:liuyi
 * @Date:2020/11/29 16:51
 */
public class Test1 {
    static {
        i = 0;//给变量赋值可以正常编译通过
        System.out.println(i);//会提示"非法向前引用"
    }
    static int i = 1;
}

  <clinit>()方法与类的构造函数不同,它不需要显示的调用父类的构造器,java虚拟机保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法一定执行完毕,因此虚拟机中第一个被执行的<clinit>()

方法的类型肯定是java.lang.Object。所以父类中定义的静态语句块要优先于子类的变量赋值操作,如下代码的值将会是2而不是1.

package test;

/**
 * @ClassName Parent
 * @description:
 * @author:liuyi
 * @Date:2020/11/29 16:57
 */
public class Parent {
    public static int A = 1;
    static {
        A = 2;
    }

    public static void main(String[] args) {
        System.out.println(Sub.B);
    }
}
class Sub extends Parent{
    public static int B = A;
}

  <clinit>()方法对于类或接口来说并不是必需的,如果一个类没有静态代码块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。接口中不能使用静态代码块,但仍然有变量初始化操作,

因此接口和类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现

类在初始化时一样不会执行接口的<clinit>()方法。

  java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多线程同时去初始化一个类,那么只会其中一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待。这也是为什么说

饿汉式单例模式是由JVM保证线程安全的依据。

类加载器:

  java虚拟机设计团队把类加载阶段的"通过一个类的完全限定名来获取描述该类的二进制字节流"这个动作放到java虚拟机外部去实现,以便应用程序自己决定如何去获取所需的类。实现这个动作的代码被称为“类加载器”。

需要注意的是对于同一个类,不同的类加载器加载之后,它们的类型是不同的。

  站在java虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分;另外一种是其他所有的类加载器,这些类加载器都由java语言实现,独立存在

于虚拟机外部,并且全部继承自抽象类java.lang.Classloader。

  站在java开发人员的角度来看,类加载器就应当划分得更细致一些。自JDK1.2以来,java一直保持着三层类加载器、双亲委派的类加载架构。我们这里只针对java8及之前版本来介绍三层类加载器以及双亲委派模型。

  启动类加载器(Bootstrap):这个类加载器负责加载存放在lib目录,或者被-Xbootclasspath参数所指定的路径下存放的class类。启动类加载程序无法被java程序直接引用,如果需要把加载器请求委派给引导类加载器去处理,直接用

null代替即可。

  扩展类加载器(Extension):这个类加载器是由java实现的,负责加载\lib\ext目录中或者被java.ext.dirs系统变量指定的路径中所有的类库,主要包含java用户(公司团队或者个人)开发的扩展类库。

  应用程序类加载器(App):这个加载器也是由java实现的,它负责加载用户类路径上所有的类库,也可以理解为除了启动类加载器和扩展加载器加载以外的所有类库都是由应用程序类加载器来加载。

双亲委派模型:

  

 

   如图所示,双亲委派的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,它会先检查请求加载的类型是否被当前加载器加载过,如果没有则把这个请求委派给父类(注意这里的父类

并不是真正意义上的父类,源码中是以组合的形式来体现父子的关系的)加载器去完成,类加载实现的主要源码在java.lang.Classloader的loadClass()方法中,如下:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

  这段代码的逻辑清晰易懂:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。假如父加载器加载失败,抛出ClassNotFoundException

异常的话,才调用自己的findClass()方法尝试进行加载。

  为什么要采用双亲委派模型来实现类的加载呢?首先一个显而易见的好处就是java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类

最终都会委派给模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能保证是同一个类。反之,如果没有使用双亲委派模型,如果我自己也写一个java.lang.Object类,那系统中就会出现多个Object类,

java类型体系中最基础的行为也就无从保障。

 总结:

  本篇文章主要介绍了java虚拟机类加载的时机,主要关注类初始化的时机,哪些行为属于主动引用,哪些行为属于被动引用,接着介绍了类加载过程,主要包括加载、验证、准备、解析和初始化五个阶段。最后介绍了类加载器

以及双亲委派模型。下一篇文章,我们将对虚拟机(Hotspot)对象的创建进行介绍。

posted @ 2020-11-29 18:45  负重前行的小牛  阅读(307)  评论(0编辑  收藏  举报