面试官问,你真的了解JVM类加载吗?

4 篇文章 0 订阅

各人下雪,各有各的隐晦与皎洁。

在java代码中,类型的加载,连接,初始化过程都是在程序运行期间完成的

类加载过程

当程序需要主动使用某个类时,前提条件是这个类已经被初始化。而如果这个类还没有被初始化时,JVM就会通过相应的机制来执行对类的加载,完成初始化。

所有的java虚拟机实现必须在每个类或接口被java程序“首次主动使用”时才初始化他们

  • 什么叫做主动使用?
    创建类的实例(new)
    访问某个类或接口的静态变量,或对该静态变量赋值
    调用类的静态方法
    反射(Class.forName(cn.test.Demo))
    初始化一个类的子类
    java虚拟机被标明为启动类的类(Java Test)
    java1.7开始提供的动态语言支持

  • 什么叫做被动使用?
    除了主动使用,其他对类的使用都可以看做是对类的被动使用,都不会导致类的初始化

类加载分为三步,加载,链接,初始化。

类加载流程

加载

查找并加载类的字节码文件中的二进制数据读到内存中,将其放到运行时数据区的方法区内,然后在内存中创建一个java.lang.Class对象,用来封装类在方法区内的数据结构,并且向java程序员提供了访问方法区内的数据结构的接口。

类的加载过程是由类加载器来完成的,类加载器是由JVM提供的。当然,用户也可以自定义类加载器进行加载指定的类,每个类加载负责的职责不同,这个我们后面会说到。

通过使用类加载可以对不同来源的类的二进制文件进行加载,通常的来源有:

  • 从本地文件系统加载class文件,这是最常用的方式,加载我们通过开发工具编写的class文件。
  • 从JAR包加载class文件。通常我们需要更多的需求,就需要引入jar包,这也是一种很常见的方式,比如,数据库连接驱动类jar,junit测试包等等。JVM可以从jar包文件中直接加载需要的class类。
  • 通过网络加载class文件。
  • 把一个Java源文件动态编译,并执行加载。

前面说过,当程序需要主动使用某个类时,就会对类进行初始化,但在类的加载阶段,并不一定要求“首次主动使用”时才执行对类的加载。Java虚拟机规范允许系统预先加载某些类。

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

链接

链接分为三步. 验证,准备,解析。

  • 验证

验证阶段用于检验被加载的类是否有正确的内部结构,并和其他类协调一致。Java是相对C++语言是安全的语言,例如它有C++不具有的数组越界的检查。这本身就是对自身安全的一种保护。验证阶段是Java非常重要的一个阶段,它会直接的保证应用是否会被恶意入侵的一道重要的防线,越是严谨的验证机制越安全。验证的目的在于确保Class文件的字节流中包含信息符合当前虚拟机要求,不会危害虚拟机自身安全。其主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。

文件格式验证:
主要验证字节流是否符合Class文件格式规范,并且能被当前的虚拟机加载处理。例如:主,次版本号是否在当前虚拟机处理的范围之内。常量池中是否有不被支持的常量类型。指向常量的中的索引值是否存在不存在的常量或不符合类型的常量。

元数据验证:
对字节码描述的信息进行语义的分析,分析是否符合java的语言语法的规范。

字节码验证:
最重要的验证环节,分析数据流和控制,确定语义是合法的,符合逻辑的。主要的针对元数据验证后对方法体的验证。保证类方法在运行时不会有危害出现。

符号引用验证:主要是针对符号引用转换为直接引用的时候,是会延伸到第三解析阶段,主要去确定访问类型等涉及到引用的情况,主要是要保证引用一定会被访问到,不会出现类等无法访问的问题。

  • 准备

类准备阶段负责为类的静态变量分配内存,并设置默认初始值。要注意的是,设置的是字段默认值,而不是我们编写代码赋予的值。

public class Main {
    //当前我们设置的值为abc,但在准备阶段,赋予的是字段默认值,String类型字段默认值为null
    //因此这时候String类型的str的值为赋予为null,而不是abc
    static String str="abc";
    //同样,int类型默认值为0,那这时候int字段变量i的值被赋予0
    static int i=1024;
}
  • 解析

将类的二进制数据中的符号引用替换成直接引用。

符号引用:
以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。布局和内存无关。

直接引用:
是指向目标的指针,偏移量或者能够直接定位的句柄。该引用是和内存中的布局有关的,并且一定加载进来的。

初始化

为类变量赋于正确的初始值。

步骤:

假如这个类还没有被加载和连接,那就先进行加载和连接。
假如类存在直接父类,并且这个类的父类还没有进行初始化,那就先对父类进行初始化(不适用接口)。这个后面会提到。
假如类中存在初始化语句,那就依次执行这些初始化语句。

public class Main {
    //这时候才会给字段String类型的str变量赋予正确的abc
    static String str="abc";
    //同样,这时候i的值就为0
    static int i=1024;
}

类加载注意点

  • 所有的java虚拟机实现必须在每个类或接口被java程序“首次主动使用”时才初始化他们。
  • 当一个类初始化的时候,要求其父类全部都初始化。

当需要使用某个类时,就会对该类进行初始化操作,而在初始化操作之前,会检查此类是否有父类,有父类,而且父类还没有被初始化的时候,就先会对父类进行初始化。

  • 常量(final)在编译阶段会存入到调用这个常量的方法所在的类的常量池中,本质上,调用类并没有直接引用到定义常量的类,因此并不会触发定义常量的类的初始化。
public class Main{
    
    public static void main(String[] args) {
    	//比如我们现在调用Demo类的静态常量str,而其实Demo这个类并没有初始化
        System.out.println(Demo.str);
    }
}
class Demo{
    static final String str="abc";
}
  • 而当一个常量并非编译器所可以确定的话,比如UUID,那么其值就不会放到调用类的常量池中,程序运行时,将导致主动使用这个类的常量,导致类的初始化。
public class Main{

    public static void main(String[] args) {
    	//而当我们这次调用Demo这个类的str方法时,Demo类将会被初始化
    	//因为str的值在编译期间不能确定。
        System.out.println(Demo.str);
    }
}
class Demo{
    static final String str= UUID.randomUUID().toString();
}
  • 对于数组实例来说,其类型是由JVM在运行期动态生成的,其父类是Object(new一个数组类型不会导致类的主动使用),类加载器是根类加载器,基本类型数组实例无类加载器
public class Main{

    public static void main(String[] args) {
    	//不会导致Demo类的初始化
        Demo[] demos=new Demo[5];
    }
}
class Demo{

}
  • 调用ClassLoader类的loadClass方法去加载一个类,并不是对类的主动使用,并不会导致类的初始化( Class.forName()除外)
public class Main{

    public static void main(String[] args) throws Exception{
    	//不会导致Demo类的初始化
        Demo.class.getClassLoader().loadClass("cn.test.test.Demo");
        //会导致Demo类的初始化
        Class.forName("cn.test.test.Demo");
    }
}
class Demo{

}
  • 当一个接口在初始化时,并不要求其父接口都完成初始化,只有在真正使用到父接口的时候。才会进行初始化。
    一个父接口并不会因为它的子接口或者实现类的初始化而初始化,只有当程序首次使用特定接口的静态变量时,才会导致该接口的初始化
    这个和前面的继承相反,需要区分。

类加载器

类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。一旦一个类被加载如JVM中,同一个类就不会被再次载入了。正如一个对象有一个唯一的标识一样,一个载入JVM的类也有一个唯一的标识。在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识。例如,如果在pg的包中有一个名为Person的类,被类加载器ClassLoader的实例kl负责加载,则该Person类对应的Class对象在JVM中表示为(Person.pg.kl)。这意味着两个类加载器加载的同名类:(Person.pg.kl)和(Person.pg.kl2)是不同的、它们所加载的类也是完全不同、互不兼容的。这里就提到了命名空间,后面说。

JVM预定义有三种类加载器,当一个 JVM启动的时候,Java开始使用如下三种类加载器:
在这里插入图片描述

根类加载器(Bootstrap)

它用来加载 Java 的核心类,是用原生代码来实现的,并不继承自 java.lang.ClassLoader(负责加载$JAVA_HOME中jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader子类)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。实现依赖于底层操作系统,属于虚拟机的实现的一部分。

public class Main{

    public static void main(String[] args) throws Exception{
        //获取根类加载加载的核心类库位置
        URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
        for(URL url : urls){
            System.out.println(url.toExternalForm());
        }
    }
    
}

在这里插入图片描述

扩展类加载器(Extension)

负责加载JRE的扩展目录,lib/ext或者由java.ext.dirs系统属性指定的目录中的JAR包的类。父类是根类加载器,纯java类,是java.lang.ClassLoader类的子类。

系统类加载器(System)

或者称为应用类加载器。

负责在JVM启动时加载来自Java命令的-classpath选项、java.class.path系统属性,或者CLASSPATH换将变量所指定的JAR包和类路径。程序可以通过ClassLoader的静态方法getSystemClassLoader()来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父加载器。由Java语言实现,父类加载器为扩展类加载器。纯java类,是java.lang.ClassLoader类的子类。

类加载器加载Class大致要经过如下8个步骤:

  1. 检测此Class是否载入过,即在缓冲区中是否有此Class,如果有直接进入第8步,否则进入第2步。
  2. 如果没有父类加载器,则要么Parent是根类加载器,要么本身就是根类加载器,则跳到第4步,如果父类加载器存在,则进入第3步。
  3. 请求使用父类加载器去载入目标类,如果载入成功则跳至第8步,否则接着执行第5步。
  4. 请求使用根类加载器去载入目标类,如果载入成功则跳至第8步,否则跳至第7步。
  5. 当前类加载器尝试寻找Class文件,如果找到则执行第6步,如果找不到则执行第7步。
  6. 从文件中载入Class,成功后跳至第8步。
  7. 抛出ClassNotFountException异常。
  8. 返回对应的java.lang.Class对象。
自定义类加载器

java.lang.ClassLoader的子类,用户可以定制类的加载方式。

类加载机制

  1. 全盘负责:所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖和引用其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入。
  2. 双亲委派:当一个自定义类加载器想要加载一个特定的类的时候,它会交给自己的父类进行加载,层层委托,直到根类加载器,然后根类加载器开始尝试加载,发现自己不能加载,又会层层往下提交,到了系统类加载器大多数就可以加载了(根类加载器和扩展类加载器只会加载特定的类和包),当加载成功时,把加载结果交给委托的自定义类加载器返回。
  3. 缓存机制:缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中找该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓冲区中。这就是为什么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。
双亲委派模型

在父亲委托机制中,各个加载器按照父子关系形成了树形结构(逻辑),除了根类加载器外,其余的类加载器都有且只有一个父加载器。
在这里插入图片描述

public class Main{

    public static void main(String[] args){
    	//当前类毫无疑问是由系统类加载器加载的,获取系统类加载器。
        ClassLoader classLoader1 = Main.class.getClassLoader();
        System.out.println(classLoader1);
        //获取系统类加载器的父加载器,即扩展类加载器
        ClassLoader parent = classLoader1.getParent();
        System.out.println(parent);
        //获取扩展类加载器的父类加载器,即根类加载器
        ClassLoader parent1 = parent.getParent();
        System.out.println(parent1);
    }

}

在这里插入图片描述
根类加载器为空,顶级类加载器,不允许访问。

线程上下文类加载器
  • 线程上下文类加载器是由JDK1.2开始引入的,类中的setContextClassLoader和getContextClassLoader分别用来获取和设置上下文类加载器,如果没有设置的话,线程将继承父线程的类加载器

  • 在父亲委托模型中,类加载器是由下至上的,即下层的类加载器会委托上层进行加载,但是对于SPI(Service Provider Interface)来说,有些接口是JAVA核心库提供的,而JAVA核心库是由启动类加载器来加载的,而这些接口的实现却来自不同的jar包(厂商提供),JAVA的启动类加载器是不会加载这些其他来源的jar包的这样传统的父亲委托模型就无法满足SPI的需求,而通过当前线程上下文类加载器,就可以实现对于接口的类加载。

优点
  • 提高软件系统的安全性,分工明确,确保JAVA核心库的类型安全。

  • 确保JAVA核心类库所提供的类不会被自定义的类替代。

  • 不同的类加载器可以为相同名称(binary name)的类创建额外的命名空间,相同名称的类可以并存在JAVA虚拟机中,只需要用不同的累加载器去加载(命名空间不同),不同类加载器所加载的类之间是不兼容的,相当于在JAVA虚拟机内部创建了一个又一个相互隔离的JAVA类空间。

注意

类加载器并不需要等到某个类被“首次主动使用”时才加载它

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

  • 在运行期,一个JAVA类是由该类的完全限定名(binary name,二进制名)和用于加载该类的定义类加载器(defining loader)所共同决定的,如果类的全限定名相同的类是由不同的加载器所加载,那么这些类就是不同的,即便.class文件的字节码完全一样,加载位置一样,仍旧不同

  • 当JVM启动时,一块特殊的机器码会运行,它会加载扩展类加载器和系统类加载器,这块特殊的机器码就是启动类加载器

  • 每个类会使用自己的类加载器去加载自己所依赖/引用的类

获取ClassLoader途径
  • 当前类
    Class<?> clazz = 类名.class;
    clazz.getClassLoader();

  • 当前线程上下文
    Thread.currentThread().getContextClassLoader()

  • 系统
    ClassLoader.getSystemClassLoader

  • 调用者
    DriverManager.getCallerClassLoader()

命名空间
  • 每个类加载器都有自己的命名空间,命名空间由该加载器及所有父加载器所加载的类组成

  • 子加载器的命名空间包含所有父加载器的命名空间,因此子加载器所加载的类能够访问到父加载器所加载的类。反之,父加载器所加载的类无法访问到父加载器所加载的类

  • 在同一个命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类

  • 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类

  • 不同的命名空间相互不可见。

类的卸载
  • 当类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,类在方法区的数据也会被清除。

  • 一个类何时结束生命周期取决于代表它的Class对象何时结束生命周期。

  • 由java虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中始终不会被卸载。

  • 由用户自定义的类加载器所加载的类时可以被卸载的。

文章持续更新,可以微信搜索「 绅堂Style 」第一时间阅读,回复【资料】有我准备的面试题笔记。
GitHub https://github.com/dtt11111/Nodes 有总结面试完整考点、资料以及我的系列文章。欢迎Star。
在这里插入图片描述

  • 4
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值