重温系列之JVM:类加载机制

2019-03-28  本文已影响0人  内卷星球

面试题

class SingletonA{
    private static SingletonA singleton = new SingletonA();
    public static int a;
    public static int b= 0;

    private SingletonA(){
        a++;
        b++;
    }

    public static SingletonA getInstance(){
        return singleton;
    }

}

class SingletonB{
    public static int a;
    public static int b= 0;
    private static SingletonB singleton = new SingletonB();

    private SingletonB(){
        a++;
        b++;
    }

    public static SingletonB getInstance(){
        return singleton;
    }

}

public static void main(String[] args) {
        SingletonA singleton = SingletonA.getInstance();
        System.out.println("SingletonA a:" + singleton.a);
        System.out.println("SingletonA b:" + singleton.b);

        SingletonB singleton = SingletonB .getInstance();
        System.out.println("SingletonB a:" + singleton.a);
        System.out.println("SingletonB b:" + singleton.b);
    }

运行的结果:

SingletonA a: 1 
SingletonA b: 0 
SingletonB a: 1 
SingletonB b: 1

SingletonA 解析过程:

1、首先执行main中的SingletonA singleton = SingletonA .getInstance(); 
2、类的加载:加载类SingletonA 
3、类的验证 
4、类的准备:为静态变量分配内存,设置默认值。这里为singleton(引用类型)设置为null,a,b(基本数据类型)设置默认值0 
5、类的初始化(按照赋值语句进行修改): 
执行private static SingletonA singleton = new SingletonA(); 
执行SingletonA 的构造器:a++;b++; 此时a,b均等于1 
执行 
public static int a; 
public static int b= 0; 
此时a=1,b=0

SingletonB 解析过程:

1、首先执行main中的SingletonB singleton = SingletonB.getInstance(); 
2、类的加载:加载类SingletonB
3、类的验证 
4、类的准备:为静态变量分配内存,设置默认值。这里为a,b(基本数据类型)设置默认值0,singleton(引用类型)设置为null, 
5、类的初始化(按照赋值语句进行修改): 
执行 
public static int value2 = 0; 
此时value2=0(value1不变,依然是0); 
执行 
private static SingletonB singleton = new SingletonB(); 
执行SingletonB的构造器:a++;b++; 
此时a,b均等于1,即为最后结果

含义

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产出是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向我们提供了访问方法区内的数据结构的接口。

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误

加载.class文件的方式:

生命周期

image

其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

加载

查找并加载类的二进制数据加载时类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:

加载方式:

  1. 命令行启动应用时候由JVM初始化加载
  2. 通过Class.forName()方法动态加载
  3. 通过ClassLoader.loadClass()方法动态加载

Class.forName()和ClassLoader.loadClass()加载的区别:

连接

验证

验证:确保被加载的类的正确性

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:

  1. 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理;例如:是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型等等。
  2. 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了java.lang.Object之外、这个类的父类是否继承了不允许被继承的类等等。
  3. 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。
  4. 符号引用验证:确保解析动作能正确执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类...

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

  1. 这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
  2. 这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。

假设一个类变量的定义为:
public static int value = 520;
那么变量value在准备阶段过后的初始值为0,而不是520,因为这时候尚未开始执行任何Java方法,而把value赋值为520的public static指令是在程序编译后,存放于类构造器<clinit>()方法之中的,所以把value赋值为520的动作将在初始化阶段才会执行。

  1. 如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。

假设上面的类变量value被定义为:
public static final int value = 524;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为524。可以理解为static final常量在编译期就将其结果放入了常量池中。

解析

虚拟机将常量池内的符号引用替换成直接引用的过程。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时可以无歧义的定位到目标即可。
直接引用:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

初始化

初始化,为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:

  1. 声明类变量是指定初始值
  2. 使用静态代码块为类变量指定初始值

JVM初始化步骤:

  1. 假如这个类还没有被加载和连接,则程序先加载并连接该类
  2. 假如该类的直接父类还没有被初始化,则先初始化其直接父类
  3. 假如类中有初始化语句,则系统依次执行这些初始化语句

虚拟机规范严格规定了有且只有5中情况(jdk1.7)必须对类进行“初始化”(加载、验证、准备自然需要在此之前开始):

虚拟机规定只有这五种情况才会触发类的初始化,称为对一个类进行主动引用,除此之外所有引用类的方式都不会触发其初始化,称为被动引用。下面举一些例子来说明被动引用。

  1. 通过子类引用父类中的静态字段,这时对子类的引用为被动引用,因此不会初始化子类,只会初始化父类

class Father{
    public static int f= 66;
    static{
        System.out.println("父类初始化");
    }
}
 
class Child extends Father{
    static{
        System.out.println("子类初始化");
    }
}
 
public class StaticTest{
    public static void main(String[] args){
        System.out.println(Child.f);
    }
}

输出的结果如下:

    父类初始化
    66

对于静态字段,只有直接定义这个字段的类才会被初始化,因此,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。

  1. 常量在编译阶段会存入调用它的类的常量池中,本质上没有直接引用到定义该常量的类,因此不会触发定义常量的类的初始化

class Contant{
    public static final String NAME = "常量哦哦哦";
    static{
        System.out.println("初始化Contant类");
    }
}
 
public class Test{
    public static void main(String[] args){
        System.out.println(Contant.NAME);
    }
}

执行后输出的结果如下:

常量哦哦哦

虽然程序中引用了Contant类的常量NAME,但是在编译阶段将此常量的值“我是常量”存储到了调用它的类Test的常量池中,对常量Contant.NAME的引用实际上转化为了Test类对自身常量池的引用。也就是说,实际上Test的Class文件之中并没有Contant类的符号引用入口,这两个类在编译成Class文件后就不存在任何联系了。

  1. 通过数组定义来引用类,不会触发类的初始化
class Contant{
    static{
        System.out.println("初始化Contant");
    }
}
 
public class ArrayTest{
    public static void main(String[] args){
        Contant[] contant= new Contant[6];
    }
}

执行后没有输出任何信息,因此Contant类并没有被初始化。
但这段代码里触发了另一个类的初始化,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发,很明显,这是一个对数组引用类型的初初始化,而该数组中的元素仅仅包含一个对Contant类的引用,并没有对其进行初始化。如果我们加入对contant数组中各个Contant类元素的实例化代码,便会触发Contant类的初始化,如下:

class Contant{
    static{
        System.out.println("初始化Contant类");
    }
}
 
public class ArrayTest{
    public static void main(String[] args){
        Contant[] contant= new Contant[6];
        for(Contant con:contant)
            con = new Contant();
    }
}

这样便会得到如下输出结果:(这里的new触发了Contant类)
初始化Const类

接口的初始化过程与类初始化过程的不同:
接口也有初始化过程,上面的代码中我们都是用静态语句块来输出初始化信息的,而在接口中不能使用“static{}”语句块,但编译器仍然会为接口生成<clinit>类构造器,用于初始化接口中定义的成员变量(实际上是static final修饰的全局常量)。

二者在初始化时最主要的区别是:当一个类在初始化时,要求其父类全部已经初始化过了,但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量),才会初始化该父接口。这点也与类初始化的情况很不同,回过头来看第2个例子就知道,调用类中的static final常量时并不会 触发该类的初始化,但是调用接口中的static final常量时便会触发该接口的初始化。

结束生命周期

在如下几种情况下,Java虚拟机将结束生命周期

类加载器

image

父类加载器并不是通过继承关系来实现的,而是采用组合实现的。

类加载器可以大致划分为以下三类:

应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:

  1. 在执行非置信代码之前,自动验证数字签名。
  2. 动态地创建符合用户特定需要的定制化构建类。
  3. 从特定的场所取得java class,例如数据库中和网络中。

类加载机制:

双亲委派模型

双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

双亲委派机制:

  1. AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
  2. ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
  3. 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
  4. ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
public Class<?> loadClass(String name)throws ClassNotFoundException {
        return loadClass(name, false);
}

protected 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 {
                //如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name)
                    c = findBootstrapClass0(name);
                }
            } catch (ClassNotFoundException e) {
             // 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }

双亲委派模型意义:

破坏双亲委派模型

双亲委派模型很好的解决了各个类加载器加载基础类的统一性问题。即越基础的类由越上层的加载器进行加载。 若加载的基础类中需要回调用户代码,而这时顶层的类加载器无法识别这些用户代码,怎么办呢?这时就需要破坏双亲委派模型了。
下面介绍两个例子来讲解破坏双亲委派模型的过程:

  1. JNDI破坏双亲委派模型
    JNDI是Java标准服务,它的代码由启动类加载器去加载。但是JNDI需要回调独立厂商实现的代码,而类加载器无法识别这些回调代码(SPI)。
    为了解决这个问题,引入了一个线程上下文类加载器。 可通过Thread.setContextClassLoader()设置。 利用线程上下文类加载器去加载所需要的SPI代码,即父类加载器请求子类加载器去完成类加载的过程,而破坏了双亲委派模型。
  2. Spring破坏双亲委派模型

Spring要对用户程序进行组织和管理,而用户程序一般放在WEB-INF目录下,由WebAppClassLoader类加载器加载,而Spring由Common类加载器或Shared类加载器加载。

问:Spring是如何访问WEB-INF下的用户程序呢?
答:使用线程上下文类加载器。 Spring加载类所用的classLoader都是通过Thread.currentThread().getContextClassLoader()获取的。当线程创建时会默认创建一个AppClassLoader类加载器(对应Tomcat中的WebAppclassLoader类加载器): setContextClassLoader(AppClassLoader)。
利用这个来加载用户程序。即任何一个线程都可通过getContextClassLoader()获取到WebAppclassLoader。

上一篇 下一篇

猜你喜欢

热点阅读