深度分析面试阿里字节99会被问到Java类加载机制和类加载器

27次阅读

共计 5063 个字符,预计需要花费 13 分钟才能阅读完成。

1. 类加载机制

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

2. 类加载的过程

类加载的过程主要有装载(Load)、链接(Link)、初始化(Initialize)

2.1 装载(Load)

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

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

加载.class 文件的方式

从本地系统中直接加载
通过网络下载.class 文件
从 zip,jar 等归档文件中加载.class 文件
从专有数据库中提取.class 文件
将 Java 源文件动态编译为.class 文件

2.2 链接(Link)

链接这一过程又可以分为验证(Validate)、准备(Prepare)、解析(Resolve)三个阶段

验证(Validate)
保证被加载类的正确性。其主要包括四种验证,文件格式验证,元数据验证,字节码验证,符号引用验证。

准备(Prepare)
为类的静态变量分配内存,并将其初始化为默认值

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意

这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
这里所设置的初始值通常情况下是数据类型默认的零值(如 0、0L、null、false 等),而不是被在 Java 代码中被显式地赋予的值。
假设一个类变量的定义为:public static int value = 3;
那么变量 value 在准备阶段过后的初始值为 0,而不是 3,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 3 的 putstatic 指令是在程序编译后,存放于类构造器()方法之中的,所以把 value 赋值为 3 的动作将在初始化阶段才会执行。

这里还需要注意以下几点

对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
对于同时被 static 和 final 修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被 final 修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
对于引用数据类型 reference 来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即 null。
如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值
如果类字段的字段属性表中存在 ConstantValue 属性,即同时被 final 和 static 修饰,那么在准备阶段变量 value 就会被初始化为 ConstValue 属性所指定的值。假设上面的类变量 value 被定义为:public static final int value = 3;,编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 3。我们可以理解为 static final 常量在编译期就将其结果放入了调用它的类的常量池中

解析(Resolve)
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。

2.3 初始化

对类的静态变量,静态代码块执行初始化操作。准备阶段和初始化阶段看似有点矛盾,其实是不矛盾的,如果类中有语句:private static int a = 10,它的执行过程是这样的,首先字节码文件被加载到内存后,先进行链接的验证这一步骤,验证通过后准备阶段,给 a 分配内存,因为变量 a 是 static 的,所以此时 a 等于 int 类型的默认初始值 0,即 a =0, 然后到解析,到初始化这一步骤时,才把 a 的真正的值 10 赋给 a, 此时 a =10。

JVM 负责对类进行初始化,主要对类变量进行初始化。在 Java 中对类变量进行初始值设定有两种方式:

声明类变量是指定初始值,也就是直接给类别量一个值
使用静态代码块为类变量指定初始值
初始化,主要是执行类的类构造器 < clinit>()方法,JVM 会将类中的静态代码块和静态变量的赋值语句放在该方法里面。

JVM 初始化步骤

1、假如这个类还没有被加载和链接,则程序先加载并链接该类

2、假如该类的直接父类还没有被初始化,则先初始化其直接父类

3、假如类中有初始化语句,则系统依次执行这些初始化语句

类初始化时机:只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:

– 创建类的实例,也就是 new 的方式

– 访问某个类或接口的静态变量,或者对该静态变量赋值

– 调用类的静态方法

– 反射(如 Class.forName(“com.shengsiyuan.Test”))

– 初始化某个类的子类,则其父类也会被初始化

– Java 虚拟机启动时被标明为启动类的类(Java Test),直接使用 java.exe 命令来运行某个主类

3. clinit 方法

类初始化方法 clinit:JVM 通过 Classload 进行类型加载时,如果在加载时需要进行类的初始化操作时,则会调用类型、的初始化方法。clinit 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。

clinit 方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成 clinit 方法。
接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成 clinit 方法。但是接口鱼类不同的是:执行接口的 clinit 方法不需要先执行父接口的 clinit 方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的 clinit 方法。
虚拟机会保证一个类的 clinit 方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 clinit 方法,其他线程都需要阻塞等待,直到活动线程执行 clinit 方法完毕。如果在一个类的 clinit 方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
说到 clinit 方法,就不得不说一下对象实例化方法 init。

对象实例化方法 init:Java 对象在被创建时,会进行实例化操作,给成员变量赋值。该部分操作封装在 init 方法中,并且子类的 init 方法中会首先对父类 init 方法的调用。

clinit 方法和 init 方法的区别

init 和 clinit 方法执行时机不同

init 是对象构造器方法,也就是说在程序执行 new 一个对象调用该对象类的 constructor 方法时才会执行 init 方法,而 clinit 是类构造器方法,也就是在 jvm 进行类加载—–验证—- 解析—–初始化,中的初始化阶段 jvm 会调用 clinit 方法。

init 和 clinit 方法执行目的不同

init 是 instance 实例构造器,对非静态变量解析初始化,而 clinit 是 class 类构造器对静态变量,静态代码块进行初始化

clinit 和 init 方法的数量不同

编译器最多只为一个类生成一个 clinit 方法,如果类中没有静态成员或者代码块的话,就不有 clint 方法。而 init 方法,类中一个构造函数就对应一个 init 方法

4. 类加载器

类加载器负责加载所有的类,其为所有被载入内存中的类生成一个 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 ClassLoader):负责加载存放在 JDKjrelib(JDK 代表 JDK 的安装目录,下同)下,或被 -Xbootclasspath 参数指定的路径中的,并且能被虚拟机识别的类库(如 rt.jar,所有的 java.* 开头的类均被 Bootstrap ClassLoader 加载)。启动类加载器是由 C ++ 实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 代替。

扩展类加载器(Extension ClassLoader):负责加载 java 平台中扩展功能的一些 jar 包,包括 JDK/jre/lib/*.jar 或 -Djava.ext.dirs 指定目录下的 jar 包。,开发者可以直接使用扩展类加载器。

应用程序类加载器(Application ClassLoader):负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

自定义类加载器 Custom ClassLoader:通过继承 java.lang.ClassLoader 根据自身需要自定义 ClassLoader,如 tomcat、jboss 都会根据 j2ee 规范自行实现 ClassLoader。

5. 双亲委派模型

几种类加载器的层次关系如下图所示

这种层次关系称为类加载器的双亲委派模型。我们把每一层上面的类加载器叫做当前层类加载器的父加载器,当然,它们之间的父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中的代码。该模型在 JDK1.2 期间被引入并广泛应用于之后几乎所有的 Java 程序中,但它并不是一个强制性的约束模型,而是 Java 设计者们推荐给开发者的一种类的加载器实现方式。

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

双亲委派机制的优势:采用双亲委派模式的是好处是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子 ClassLoader 再加载一次。其次是考虑到安全因素,java 核心 api 中定义类型不会被随意替换,假设通过网络传递一个名为 java.lang.Integer 的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心 Java API 发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的 java.lang.Integer,而直接返回已加载过的 Integer.class,这样便可以防止核心 API 库被随意篡改。

正文完
 0