JVM 类加载机制

类加载

        在 JVM 虚拟机实现规范中,通过 ClassLoader 类加载器把 *. class 字节码文件(文件流)加载到内存,并对字节码文件内容进行验证、准备、解析和初始化,最终形成可以被虚拟机直接使用的 java.lang.class 对象,这个过程被称作类加载。

        类是在运行期间第一次使用时,被类加载器动态加载至 JVM。JVM不会一次性加载所有类。因为如果一次性加载,那么会占用很多的内存。

类生命周期

 类的生命周期包括以下 7 个阶段:
        加载(Loading)
        验证(Verification)
        准备(Preparation)
        解析(Resolution)
        初始化(Initialization)
       
 使用(Using)
        卸载(Unloading)

结束类生命周期的几种场景:
        执行 System.exit()方法
        程序正常执行结束
        程序执行中遇到了异常或错误而异常终止
        操作系统出现错误或强制结束程序而导致 JVM虚拟机进程终止

类加载过程

        类加载过程包含:加载、验证、准备、解析、初始化,一共包括 5 个阶段。

加载

在加载阶段, JVM 主要完成以下 3 件事:
        通过类的完全限定名称获取定义该类的 *. class 字节码文件的二进制字节流。
        将该字节流表示的静态存储结构转换为 Metaspace 元空间区的运行时存储结构。
        在内存中生成一个代表该类的 Class 对象,作为元空间区中该类各种数据的访问入口。

        由于 JVM 虚拟机对加载 *. class 字节码文件的来源并未做限制,所以出现了以下的 *. class 字节码文件加载方式:
        1. 本地文件系统直接读取;
        2. 从网络中通过服务器响应读取。例如: Web Applet 技术;
        3. 从 JAR、EAR、WAR 等压缩文件中读取;
        4.运行时通过动态代理技术生成字节码文件。例如:在 java.lang.reflect。Proxy 使用ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
        5.由其他文件或容器生成。例如:由tomcat将 *. jsp 文件翻译成 *. java 文件后,编译生成对应的 *. class 字节码文件。

        在加载阶段完成之后, *. class 字节码文件的类信息数据就会存储在元空间,同时在 JVM虚拟机堆区生成一个该类的Class 对象。

验证

在验证阶段, JVM 主要确保 *. class 字节码文件中包含的信息符合当前虚拟机的要求,并不会危害虚拟机的安全。
        验证阶段会完成下面四个阶段的检验:

  1. 文件格式验证:验证字节流是否符合 *. class 字节码文件格式的规范,且能被当前版本的虚拟机处理。
  • 是否以魔数 0xCAFEBABE 开头
  • 主、次版本号是否在当前虚拟机处理范围之内        
  • 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)
  • 指向常量的各种索引值中是否有指向不存在的常量或不符合装型的常量
  • CONSTANT_Utf8_info 型的常量中是否有不符合UTF8编码的数据
  • *. class 文件中各个部分及文件本身是否有被删除的或附加的其他信息

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

  •  这个类是否有父类(除了java.lang.0bject 之外,所有的类都应当有父类)
  •  这个类的父类是否继承了不允许被继承的类(被 finaI 修饰的类)
  • 如果这个类不是抽象类,是否实現了其父类或接口之中要求实现的所有方法
  • 类中的字段、方法是否与父类产生了矛盾,例如:覆盖了父类的final 字段。出现不符合规则的方法重载,例如:方法参数都一致,但返回值类型却不同等。

        3. 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

  • 保证跳转指令不会跳转到方法体以外的字节码指令上
  • 保证方法体中的类型转换是有效的,例如:可以把子类对象赋值给父类数据装型,这是安全的;但把父类对象意赋值给子类数据类型,甚至把对象赋值给毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。
  • 保证任意时刻操作数栈的数据装型与指令代码序列都能配合工作,例如:不会出现在操作栈中放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中

        4.符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三个阶段 -- 解析阶段中发生。确保解析动作能正常执行。

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

为什么需要验证?
        Java 语言本身是相对安全的语言,但 *. class 字节码文件并不一定要求用 Java 源码编译而来,可以使用任何途径,甚至可用十六进制编译器直接编写来产生 *. class 字节码文件。
        类的加载是 JVM 针对 *. class 字节码文件的读取加载机制,所以虚拟机如果不检查输入的字节流,可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。
        另外,通过类加载机制的验证环节,可以增强解释器的运行期执行性能。因为,解释器在运行期间无需再对每条执行指令进行检查。

准备

  • 类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值,使用的是元空间区的内存。
  • 实例变量不会在这阶段分配内存,它会在对象实例化时,随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。
  • 初始值一般为 0 值。

例一:

//比如下面这个 static 修饰的变量,在准备这一步,是给他分配空间,并赋予系统高要求的零值
//                              在初始化时才给a 赋予我们要求的初始化值3
public static int a = 3;

 例二:

//下面的 a 是我们定义的静态常量,它则被初始化为 3 而不是 0 
public static final int a = 3;

解析

        将常量池的符号引用替换为直接引用

初始化

        初始化阶段才真正开始执行类中定义的 Java 程序代码。初始化阶段是虚拟机执行类构造器 <clinit>() 方法的过程。在准备阶段,类变量已经付过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其他资源。

        <clinit>() 是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。所以,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。

        例如:以下代码中静态变量 i 只能赋值,不能访问,因为i 定义在静态代码块
的后面。

public test {
    static{
        i = 3;                    //给变量赋值可以正常编译通过
        System .out.println(i);   //这句编译器会提示“非法向前引用”
    }
    static int i = 1;
}

         由于父类的 <clinit>()方法先执行,也就意味着父类中定义的静态语句块的执行要优先于子类。例如以下代码:

static class Parent{
    Public static int A = 1;
    static {
        A = 2;
    }
}

static class Son ectends Parent{
    public static int B = A;
}

    public static void main(String[] args){
        system.out.println(Son.B); // 2
    }

<clinit>线程安全
        虚拟机会保证一个类的 <clinit>() 方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit>()方法,其它线程都会阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中,该阻塞非常隐蔽,几乎不会被察觉。

类的加载时机

主动引用

      虚拟机规范中并没有强制约束何时进行加载,但是规范严格规定了只有下列六种情况必须对类进行加载:

  • 当遇到 new、getstatic、putstatic、或 invokestatic 这4条字节码指令时,比如 new 一个对象,读取一个静态字段(未被final修饰)、或调用一个类的静态方法时。        

                当 jvm 执行 new 指令时会加载类。即:当程序创建一个类的实例对象。

                当 jvm 执行 getstatic 指令时会加载类。即:程序访问类的静态变量(不是静态常量,常量会

         被加载到运行时常量池)。

                当 jvm 执行 putstatic 指令时会加载类。即:程序给类的静态变量赋值。

                当 jvm 执行 invokestatic 指令时会加载类。即:程序调用类的静态方法。

  • 使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname("."),或 newInstance()等等。如果类没初始化,需要触发类的加载。
  • 加载一个类,如果其父类还未加载,则先触发该父类的加载。
  • 当虚拟机启动时,用户需要定义一个要执行的主类(包含 main()方法的类),虚拟机会先加载这个类。
  • 当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了加载,则该接口要在实现类之前被加载。 

被动引用

        除主动引用之外,所有引用类的方式都不会触发加载成为被动引用。

被动引用的常见例子包括:

  • 通过子类引用父类的静态字段,不会导致子类加载。
System.out.println(Subclass.value); // value 字段在 SubClass类的父类中定义
  • · 通过数组定义来引用类,不会触发此类的加载。该过程会对数组类进行加载,数组类是一个由虚拟机自动生成的、直接继承自Object的子类,其中包含了数组的属性和方法。
SuperClass [] sca = new SuperClass[10];

  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的加载。
System.out.println(ConstClass.HELLOWORLD);

类加载器

类加载器

        在类加载过程的加载阶段,通过类的完全限定名,获取描述类的二进制流的实现类,被称为“类加载器”。

类加载器分类

JVM 虚拟机的角度来讲,只存在以下两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader ),使用 C++ 实现,是虚拟机的一部分;
  • 其它类的加载器,使用 Java 实现,独立于虚拟机,继承自抽象类 java.lang. ClassLoader .

Java 开发人员的角度看,类加载器可以划分得更细致一些:

  • · 启动类加载器 (Bootstrap ClassLoader),该类加载器负责将存放在<JRE_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。例如 java.util .* , java.io .** , java.lang .* 类等常用基础库都是由启动类加载器加载。启动类加载器无法被 Java 程序直接引用。
  • 扩展类加载器 (Extension ClassLoader),该类加载器是由 ExtClassLoader ( (sun.misc.Launcher$ExtClassLoader)实现,负责将 <JRE HOME>/lib/ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库载到内存中,例如 swing 系列、内置的js 引擎、xml 解析器等以javax 开头的扩展类库都是由扩展类加载器加载,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader),该类加载器是由 AppClassLoader (sun.misc.Launcher$AppClassLoader)实现。由于这个类加载器是ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此也被称为系统类加载器。它负责加载用户类路径(ClassPath) 上所指定的类库,比如:我们自己编写的自定义类或第三方 jar 包。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

public class ClassLoaderTest {
    public static void main(String[] args) {
        //(启动类)系统类加载器:
        ClassLoader systemClassLoader = ClassLoader. getSystemclassLoader();
        System.out.println(systemClassLoader); //sun.misc.Launcher$AppClassLoader@73d16e93

        //扩展类加载器:
        ClassLoader extendClassLoader = systemClassLoader. getParent();
        System.out.println(extendClassLoader); //sun.misc.Launcher$ExtClassLoader@15db9742

        // 引导类加载器:
        ClassLoader bootstrapClassLoader = extendClassLoader. getParent();
        System.out.println(bootstrapClassLoader); // null

        //用户自定义的类默认用系统类加载器
        ClassLoader classLoader = ClassLoaderTest. class. getClassLoader();
        System.out.println(classLoader); // sun.misc.Launcher$AppClassLoader@73d16e93
    }
}

自定义类加载器使用场景

  • 隔离加载类。在某些框架内进行中间件与应用的模块之间进行隔离,把类加载到不同的环境。
  • 修改类加载方式。
  • 扩展加载源。比如:从数据库、网络、电视机顶盒进行类加载。
  • 防止源码泄漏。比如:编译时字节码进行加密,需要通过自定义类加载器对字节码进行解密还原。

双亲委派模型

        应用程序是由三种类加载器互相配合,从而实现类加载,除此之外还可以加入自己定义的类加载器。
        类加载器之间的层次关系,称为双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其它的类加载器都要有自己的父类加载器。这里的父子关系一般通过组合关系(Composition)来实现,而不是继承关系(Inheritance)。

 双亲委派机制工作原理

        一个类加载器首先将类加载请求转发到父类加载器,只有当父类加载器无法完成时才尝试自己加载。

双亲委派的作用

        每个类只会加载一次,解决了各个类加载器加载基础类的统一问题(基础类库由上层的加载器进行加载);
        防止恶意破坏的类加载,内存中不会出现多份同样的字节码的系统类,保证Java程序安全稳定运行。

         例如: java.lang.Object 存放在 rt.jar 中,如果编写另外一个  java.lang.Object 并放到ClassPath中,程序可以编译通过。由于双亲委派模型的存在,所以在 rt.jar 中的Object比在ClassPath中的Object优先级更高,因为 rt.jar 中的 Object 使用的是启动类加载器,而 ClassPath 中的 Object 使用的是应用程序类加载器。rt.jar 中的 Object 优先级更高,那么程序中使用的所有的 Object都是由启动类加载器所加载的 Object。

双亲委派额实现源码

        以下是抽象类 java.lang.ClassLoader 的代码片段,其中的 loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出ClassNotFoundException,此时尝试自己去加载。

public abstract class classLoader {
    // The parent class loader for delegation
    private final ClassLoader parent;

    public Class <? > loadClass(String name) throws ClassNotFoundException {
        return loadclass(name, false);

    }

    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) {
                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.
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    protected Class <? > findclass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }
}

SPI打破双亲委派

        SPI(Service Provider Interface),是一种服务发现机制,它通过在 ClassPath 路径下的 META-INF/services 文件夹查找文件,自动加载文件里所定义的类。

        如下图,SPI 核心类定义在 rt.jar中(例如:java.lang.Driver 接口),所以 java.lang.Driver 接口本身是由启动类加载器加载,当调用 java.lang.Driver 接口的实现类时,启动类加载器是无法加载实现类的,这个时候就提供了线程上下文类加载器(Thread Context ClassLoader)加载实现类,ThreadContextClassLoader 是可以通过 java.lang.Thread #setContextclassLoader 方法设置类加载器,这样就打破了双亲委派的类加载模式。

对象的创建过程

类加载检查

        虚拟机遇到一条new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

分配内存

        在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。内存分配的查找方式有“指针碰撞”和“空闲列表”两种。

         选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而堆内存是否规整,取决于 GC 收集器的算法是“标记-清除”,还是“标记-整理”。

初始化零值

        内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

设置对象头

        初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象头中另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

执行 init 构造方法

        在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java 程序的视角来看,对象创建才刚开始,<init> 构造方法还没有执行,目前所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 构造方法,把对象按照程序逻辑的意愿进行初始化,这样一个真正可用的对象才算完整创建出来。                           

上一篇:【数据结构】基数排序高位优先(MSDF)


下一篇:TRIZ理论在机器人性能优化中的应用