JVM学习.03 类加载机制

简介: 从事Java开发工作的都知道,Java程序提交到JVM运行时,需要编译成Class文件,才能被JVM加载运行。那么这些Class文件进入到虚拟机后会发生什么?以及Class是如何被加载的?这些都是本文要讲解的部分。

1、前言

从事Java开发工作的都知道,Java程序提交到JVM运行时,需要编译成Class文件,才能被JVM加载运行。那么这些Class文件进入到虚拟机后会发生什么?以及Class是如何被加载的?这些都是本文要讲解的部分。

2、类加载时机

所谓类装载机制,就是虚拟机把class文件加载到内存,并对数据进行校验,转换解析,初始化,形成可以虚拟机直接使用的java类型,即java.lang.Class。

一个类从被加载到虚拟机内存开始,到卸载出内存位置,他都会经历加载,验证,准备,解析,初始化,使用,卸载七个阶段。其中验证、准备、解析三个部分称为连接。

类的生命周期如下,网上借来的图:

image.png

加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。以下陈述的内容都已HotSpot为基准。特别需要注意的是,类的加载过程必须按照这种顺序按部就班地“开始”,而不是按部就班的“进行”或“完成”,因为这些阶段通常都是相互交叉地混合式进行的,也就是说通常会在一个阶段执行的过程中调用或激活另外一个阶段。

2.1、“加载”时机

类加载过程的第一个阶段加载,通常是交由虚拟机具体的实现来自由把握,《Java虚拟机规范》并没有强制约束。

2.2、“初始化”时机

《Java虚拟机规范》虽然对加载没有强制性约束,但是却严格规定了有且只有六种情况下必须立即对类进行“初始化”,这里加载,验证,准备需要在此之前开始。

1、遇到new、getstatic、putstatic或invokestatic这四条字节码指令,如果类没有进行过初始化,则需要先对其进行初始化。生成这四条指令的最常见的Java代码场景是:

  • 使用new关键字实例化对象的时候;
  • 读取或设置一个类的静态字段(被final修饰,已在编译器把结果放入常量池的静态字段除外)的时候;
  • 调用一个类的静态方法的时候。

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

3、当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

4、当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

5、 当使用JDK7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。

6、当一个接口定义了JDK8新加入的默认方法(被default关键字修饰的接口方法时,如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化)。

以上6中场景中的行为称为对一个类型进行的主动引用。

除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。何为被动引用?看下面代码:

示例代码一:

/**
 * 通过子类引用弗雷静态字段,子类不会初始化
 */
public class SuperClass {
    static {
        System.out.println("super class init");
    }
    public static int valueOf = 123;
}
public class SubClass extends SuperClass {
    static {
        System.out.println("sub class init");
    }
}
// 主函数调用
public class Test {
    public static void main(String[] args) {
        System.out.println(SubClass.valueOf);
    }
}

运行结果,只触发了父类的初始化:

image.png

示例代码二:

/**
 * 常量在编译阶段会进入调用类的常量池中,本质上没有直接引用定义常量的累,所以不会触发常量定义累的初始化
 */
public class ConstClass {
    static {
        System.out.println("ConstClass init");
    }
    public static final String CONSTANTS = "hello world";
}
// 主函数调用
public class Test {
    public static void main(String[] args) {
//        System.out.println(SubClass.valueOf);
        System.out.println(ConstClass.CONSTANTS);
    }
}

运行结果:

image.png

3、类加载过程

3.1、加载

加载阶段,主要完成以下三件事:

1、通过一个类的全限定名来获取定义此类的二进制流。

2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。

简单的说,该过程就是查找并通过类加载器将class文件导入到内存中。

3.2、验证

该阶段的目的是确保class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身安全。

3.2.1、文件格式验证

该阶段的主要目的是验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机所处理。保证输入的字节流能正确地解析并存储与方法区之内,格式上符合描述一个Java类型信息的要求。

验证比如:

1、验证主、次版本号是否在当前Java虚拟机接受范围之内。

2、常量池的常量中是否有不被支持的常量类型。

......

3.2.2、元数据验证

该阶段是对字节码描述信息进行语义分析,以保证符合《Java语言规范》要求。

验证比如:

1、这个类是否有父类。

2、这个类的父类是否继承了不允许被继承的类(如final修饰的类)。

3、如果这个类不是抽象类,是否实现了父类或接口中要求实现的所有方法。

......

3.2.3、字节码验证

该阶段的目的是通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的。元数据验证是对元数据信息中的数据类型校验,而该阶段则是要对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。

验证比如:

1、如在操作栈防止了一个int类型数据,使用时却按long类型加载入本地变量表。

2、保证任何跳转之历经都不会跳转到方法体之外的字节码指令上。

......

3.2.4、符号引用验证

该阶段的目的是确保解析行为能正常执行,如果无法通过验证,将抛出Java.lang.IncompatibleClassChangeError的子类异常,如常见的IllegalAccessError,NoSuchFieldError,NoSuchMethodError等。

验证比如:

1、符号应用的类、字段、方法的可访问性,是否可以被当前类访问(private,public等等)。

2、符号引用中通过字符串描述的全限定名是否能找到对应的类。

......

3.3、准备

该阶段正式为类中定义的变量(静态变量)分配内存并给类变量设值初始值。

注:该阶段进行内存分配的仅仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配到Java堆。

如:

public static int value = 123;

变量value在准备阶段后,初始值是0,而不是123。因为这时候还未开始执行任何java方法,而value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法中,所以value赋值为123的动作要到类的初始化阶段才会被执行。

当然,有些“意外情况”。如类字段的字段属性表中存在ConstantValue属性,那在准备阶段就会被初始化为ConstantValue属性所指定的初始值。

如:

public static final int value = 123;

加上final之后,编译时会为value生成ConstantValue属性,也会在初始化时直接设置value的值为123。

3.4、解析

该阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用就是一组符号来描述目标,可以是任何字面量。 直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

主要解析类型有:

  • 类或接口的解析
  • 字段解析
  • 方法解析
  • 接口方法解析

3.5、初始化

该阶段是类加载过程的最后一个步骤。前面几个类加载动作中,出了在加载阶段用户可以通过自定义类加载器的方式局部参与外,其余全部都由JVM自主控制完成。直到初始化阶段,JVM才真正开始执行类中编写的Java程序代码。

3.6、小结

前面几点说了那么多,简单的说就是,当我们程序定义了一个类,当我们需要使用到这个类的时候,JVM会从相应的class字节码文件中去加载,期间进行语义检查,权限的校验,预先的初始处理等,最终初始化应用程序中的构造。就完成了类在JVM中的整个加载过程,也可以直接被JVM所正常运行。

初始化过程:

  • 如果类还没有被加载和连接,那就先进行加载和连接
  • 如果类存在父类,并且父类没有初始化,那就先初始化直接父类
  • 如果类中存在初始语句,顺序执行初始化语句

类的初始化阶段是执行类构造器方法clinit()的过程

1、类加载就是执行Java程序编译之后在字节码文件中生成的clinit()方法(称之为类构造器),clinit()方法由静态变量和静态代码块组成。 2、子类的加载首先需要先加载父类,如果父类为接口。则不会调用父类的clinit方法。一个类中可以没有clinit方法。 3、clinit方法中的执行顺序为:父类静态变量初始化,父类静态代码块,子类静态变量初始化,子类静态代码块。 4、clinit()方法只执行一次。

4、类加载器

实现类加载阶段中“通过一个类的全限定名来获取描述该类的二进制字节流”的动作的代码,称为类加载器。

对于Java中任意一个类,都必须由加载他的类加载器和这个类本身一起共同确立其在JVM中的唯一性,每一个类加载器都拥有一个独立的类名称空间(后续如果接触到模块化系统,如OSGi中,每一个Bundle就具有一个类加载器,这个时候不同类加载器就算再同一个JVM中,上下文也不会共享)。

通常,我们会描述两个类比较是否相等,这个比较的前提是只有这两个类再同一个类加载器加载才有意义,否则就算这两个类是来源同一个class文件,被同一个JVM加载,只要类加载器不同,那就必定不相等(这里的相等包括equals()方法,isInstance()方法,当然也包括了instanceof关键字)。

如:

import java.io.IOException;
import java.io.InputStream;
/**
 * @author Shamee loop
 * @date 2023/3/23
 */
public class ClassLoaderDemo {
    public static void main(String[] args) throws Exception {
        Object classLoaderDemo2 = createOneClassLoader().loadClass("ClassLoaderDemo").newInstance();
        System.out.println("classLoaderDemo2实例的类加载器:" + classLoaderDemo2.getClass().getClassLoader());
        System.out.println("ClassLoaderDemo的类加载器:" + ClassLoaderDemo.class.getClassLoader());
        System.out.println("两个类是否相等:" + (classLoaderDemo2 instanceof ClassLoaderDemo));
    }
    /**
     * 模拟一个新的类加载器
     * @return
     */
    static ClassLoader createOneClassLoader(){
        return new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                String fieldName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                try (InputStream inputStream = getClass().getResourceAsStream(fieldName);){
                    if(inputStream == null){
                        return super.loadClass(name);
                    }
                    byte[] bytes = new byte[inputStream.available()];
                    inputStream.read(bytes);
                    return defineClass(name, bytes, 0, bytes.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };
    }
}

运行结果:

image.png

4.1、三层类加载器

从JDK1.2以来,Java一直保持着三层类加载器以及双亲委派的类加载结构。我们先来说什么是三层类加载器。

4.1.1、启动类加载器 Bootstrap Class Loader

  • JVM自带的引导类加载器,由C/C++语言实现。
  • 该类加载器主要负责加载存放在\lib目录,或被-Xbootclasspath参数指定的路径中存放的,而且是JVM能够识别的类库,如rt.jar,tools.jar等。
  • 只能加载java,javax,sun开头的包名类,如果自定义java,sun开头的包名类会直接报错。

4.1.2、扩展类加载器 Extension Class Loader

  • Java代码形式实现的sun.misc.Launcher$ExtClassLoader。
  • 加载\lib\ext目录,或被java.ext.dirs系统变量所指定的路径中的类库。
  • 允许用户将类库放置在ext目录以扩展JavaSE功能。
  • 指定Bootstrap Class Loader为父加载器,通过getParent()可以获取Bootstrap Class Loader。

4.1.3、应用程序类加载器 Application Class Loader

  • Java代码形式实现的sun.misc.Launcher$AppClassLoader。
  • 负责加载用户类路径(ClassPath)上的所有类库,Java程序中可以直接使用这个类加载器。
  • 指定Extension Class Loader为父加载器,通过getParent()可以获取Extension Class Loader。
  • 默认的类加载器,Java应用的类都是该类加载器加载的。

4.1.4、如何自定义Class Loader

什么时候需要自定义ClassLoader?

1、修改类的加载方法,如tomcat中多个war工程可以独立运行;保证了各个war中的jar不会冲突。

2、防止源码泄露,对class字节码进行编码加密,再在laod过程中对其解密。

......

如何自定义Class Loader?

1、继承ClassLoader,重写loadClass方法

2、继承UrlClassLoader

5、双亲委派

5.1、双亲委派模型

从4.1小节中可以看出三层类加载器的一定关系。当然我们还可以加入自己定义的类加载器来进行扩展。因此就有了如下的类加载器协作关系(也就是经常被提到的双亲委派模型)。

网上借来的图:

image.png

网络异常,图片无法展示
|
双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都必须有自己的父类加载器。

5.1.1、双亲委派的加载过程

1、如果一个类加载器(比如User ClassLoader)收到了类加载请求,首先不会自己尝试加载这个类;

2、把这个请求委托给父亲加载器(如Application Class Loader)去完成;

3、父加载器会继续委托给上一层类加载器(如Extension Class Loader)去完成;

4、最终都会传送到顶层的启动类加载器(Bootstrap Class Loader)中;

5、只有当父加载器反馈自己无法完成这个加载请求时,子类加载器才会尝试自己去完成加载。

简而言之,也就是逐层向上寻找合适的加载器进行加载,从而保证此类所有的加载器只加载一次。从java.lang.ClassLoader源码中我们也可以看到:

image.png

5.1.2、双亲委派的好处

1、Java中的类随着他的类加载器一起具备了一种有优先级的层次关系。能够保证类不会被重复加载。

2、保护程序安全,防止核心Java语言环境被破坏。比如定义一个java.lang.String,在定义一个static语句,你会发现永远无法执行你定义的static内容。如下:

/**
 * @author Shamee loop
 * @date 2023/3/23
 */
public class String {
    static {
        System.out.println("我是自定义的String");
    }
}
public static void main(String[] args) throws ClassNotFoundException {
    String s = new String();
}

执行结果:

image.png

5.2、打破双亲委派

既然前面讲到了双亲委派的诸多好处,那么这里为什么要破坏这样的一个环境呢?

试想一下这样一种“例外”情况。双亲委派机制很好的解决了各个类加载器写作时基础类型一致性的问题(越基础的类越往上层加载)。但是如果有基础类型有需要回调用户的代码呢?该如何处理?

比如JNDI服务,JNDI服务存在的目的就是为了对资源进行查找和几种管理,他需要调用由其他厂商实现并部署在应用程序的ClassPath下的JNDI服务提供接口(SPI)的代码。为了解决这个问题,Java设计团队引入了线程上下文类加载器(Thread Context Class Loader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法来设置。显然这个有点类似“舞弊”的操作。正是如此,JNDI可以通过这个线程上下文类加载器去加载所需的SPI服务代码,实际上打通了双亲委派模型的层次结构,来逆向使用类加载器。

所以Java中涉及SPI的加载基本都是采用该方式,如后面的JDBC,JAXB等。

直到JDK6时,JDK提供了java.util.ServiceLoader类来替代前面不太优雅的SPI硬编码的方式,可以通过META-INF/services中的配置信息来解决。

6、模块化中的类加载器

JDK9开始模块化的引入,是为了能够实现模块化的“可配置封装隔离机制”。而该机制首先要解决的便是JDK9之前基于类路径查找以来的可靠性问题。

在这之前,如果类路径中确实了运行时依赖的类型,那就只能等程序运行到发生该类型的加载,连接时才会报运行异常。 在JDK9之后,如果启用了模块化进行封装,模块就可以声明对其他模块的显式依赖,这样JVM就能够在启动时验证应用程序的完备性。

JDK9中,为了使得可配置封装隔离机制能够兼容传统的类路径查找机制,提出了与“类路径(ClassPath)”相对应的“模块路径(ModulePath)”。简单的说,就是某个类库到底是在模块还是在传统的jar包,只取决于他存放在哪种路径上。

模块化系统除了JDK9以外,还有不得不提的OSGi模块化服务了。OSGi的热部署成为当下流行的一项优势。它通过自定义类加载机制实现,每一个程序模块(Bundle)都有一个属于自己的类加载器,当需要更换一个Bundle时,就把Bundle联通类加载器一起换掉,以实现热替换。在此环境下,类加载器不再需要双亲委派模型的树状结构,二十进一步发展为更加复杂的网状结构。

附带一张osgi类加载器(网上借的图):

image.png

最后说一下JDK9中类加载器的变化:

1、扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。

2、Java类库不再保留\lib\ext,JDK已基于模块化进行构建(原来的rt.jar和tools.jar被拆分成数十个JMOD)。

3、取消了\jre目录,因为随时可以组合构建出程序运行所需的jre,如我们只需要使用java.base模型中的类型,那么随时可以打包出一个jre,需要如下命令:

jlink -p $JAVA_HOME/jmods --add-modules java.base --output jre

4、平台类加载器(Platform Class Loader)和应用类加载器(Application Class Loader)都不再派生自java.net.URLClassLoader,而全部继承jdk.internal.loader.BuiltinClassLoader。

5、当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,先判断该类是否能够归属到某个系统模块中,如果可以找到归属系统模块,就优先委派给负责那个模块的加载器加载。

因此模块化中的类加载委派关系如下:(与三层类加载器图对比)

网上借的图:

image.png

7、小结

本篇整理了类的整个加载机制,流程,以及JVM进行了那些动作,加载原理以及加载对于整个程序的意义。希望对于Java程序在JVM内的执行有了更深层次的了解。后续还会努力更新中......一起加油学习吧。

相关文章
|
5天前
|
安全 Java
对 JVM 的类加载机制以及寻找字节码文件的“双亲委派模型”的理解
对 JVM 的类加载机制以及寻找字节码文件的“双亲委派模型”的理解
10 0
|
5天前
|
安全 前端开发 Java
JDK源码级别彻底剖析JVM类加载机制
JDK源码级别彻底剖析JVM类加载机制
|
5天前
|
Oracle Java 编译器
基本概念【入门、 发展简史、核心优势、各版本的含义、特性和优势、JVM、JRE 和 JDK 】(二)-全面详解(学习总结---从入门到深化)
基本概念【入门、 发展简史、核心优势、各版本的含义、特性和优势、JVM、JRE 和 JDK 】(二)-全面详解(学习总结---从入门到深化)
49 1
|
5天前
|
存储 缓存 安全
深入浅出JVM(三)之HotSpot虚拟机类加载机制
深入浅出JVM(三)之HotSpot虚拟机类加载机制
|
5天前
|
前端开发 Java
深入理解Java虚拟机:类加载机制
【2月更文挑战第23天】本文深入探讨了Java虚拟机(JVM)的类加载机制,包括类加载器的层次结构、类加载的过程以及双亲委派模型。通过对JVM类加载机制的理解,可以帮助我们编写更高效的Java代码。
|
5天前
|
安全 Java 应用服务中间件
|
5天前
|
Oracle IDE Java
基本概念【入门、 发展简史、核心优势、各版本的含义、特性和优势、JVM、JRE 和 JDK 】(二)-全面详解(学习总结---从入门到深化)(下)
基本概念【入门、 发展简史、核心优势、各版本的含义、特性和优势、JVM、JRE 和 JDK 】(二)-全面详解(学习总结---从入门到深化)
41 1
|
5天前
|
Java 程序员 PHP
基本概念【入门、 发展简史、核心优势、各版本的含义、特性和优势、JVM、JRE 和 JDK 】(二)-全面详解(学习总结---从入门到深化)(上)
基本概念【入门、 发展简史、核心优势、各版本的含义、特性和优势、JVM、JRE 和 JDK 】(二)-全面详解(学习总结---从入门到深化)
35 0
|
5天前
|
存储 缓存 Java
JVM【带着问题去学习 02】数据结构栈+本地方法栈+虚拟机栈+JVM栈运行原理
JVM【带着问题去学习 02】数据结构栈+本地方法栈+虚拟机栈+JVM栈运行原理
36 0
|
5天前
|
存储 缓存 算法
JVM【带着问题去学习 01】什么是JVM+内存结构+堆内存+堆内存参数(逃逸分析)
JVM【带着问题去学习 01】什么是JVM+内存结构+堆内存+堆内存参数(逃逸分析)
36 0