一文让你不再畏惧Java类加载机制
我们都知道,编程语言从程序的执行过程区分,分为了编译型语言和解释性语言。
那么Java是解释型语言还是编译型语言呢?
Java是编译型语言
还记得我们刚开始学Java时候的javac
这个命令嘛,我们就是通过javac
这个命令去编译Java代码从而生成.class字节码文件。这是一个必要的步骤,如果不经过编译,.java字节码文件是不能运行的。
Java是解释型语言
在上面我们通过javac
命令将java文件编译成了.class文件,这个是Java自己的一个特殊类型的文件,它是运行在虚拟机上面的(JVM)。Java虚拟机的作用就是将.class文件一行一行的解释执行,将之翻译成能够被相应计算机识别的指令(0、1序列),所以我们说Java是解释型语言。
说到这里,我们就不得不来说一下Java的跨平台性了。正是由于.class必须运行在JVM上面,使得Java具有跨平台性,不同操作系统有不同的JVM,但是.class都能在上面去解释运行。举一个例子,JVM就好比一个翻译员,当某个重要人物讲中文的时候(.java文件),会被Java编译程序编译成英文(.class字节码文件),当这份英文文件被传送到各个国家的时候,再由当地的翻译员(JVM)翻译成当地的语言(机器语言),供当地的人去学习。
所以,JVM就相当于当地的翻译员,将.class字节码文件翻译成0、1序列,从而能够给计算机去识别执行。这就做到了编译一次,到处运行
。
总的来说:
- JVM就好比Java程序的操作系统,这个
“操作系统”
能够执行的文件就是.class字节码文件。 - JVM屏蔽了操作系统之间的差异,使得编译一次后的.class文件能够在不同的操作系统使用不同的JVM去运行。

那么,.class文件是怎么被JVM执行运行的呢?容我下面一一道来。
一、什么是类的加载
我们先来看一张图:
从上面我们可以知道,.class字节码文件被类加载器
加载到JVM中。本文要讨论的就是这个环节——类的加载
总体来说,类的加载其实就是讲.class字节码文件中的二进制数读进内存中并放在数据区的方法去里面,然后在堆中创建一个java.lang.Class对象,用来封装在方法区内的数据机构。
那么,在什么时候会启动类加载器区加载类呢?
其实,类加载器并不会说等到某个类要被使用的时候才去加载它,JVM允许类加载器在预料某个类将要被使用的时候去加载它,如果预加载的过程中遇到.class文件确实或者存在错误的时候,类加载器会在程序首次主动使用该类的时候才报错(LinkageError),但是如果这个类一直没有被使用,则类加载器不会报错。
二、类加载的过程
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、准备、解析这三个部分统称为连接。这七个阶段的发生顺序如下图所示。
加载、验证、准备、初始化和卸载这五个阶段的顺序的确定的,类的加载必须按照这种顺序**按部就班地开始
**,而解析阶段则不一定。它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的动态绑定。
注意:为什么说是按部就班的“开始”,而不是按部就班地“进行”或者“完成”,是因为这些阶段通常都是互相交叉地混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
1、加载
“加载”阶段是整个“类加载”过程的一个阶段,在加载阶段,Java虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流;
- 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构;
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
注意
:加载的类会创建相应的类结构,在JDK1.8之前,会存储在方法区中,JDK1.8之后,则存储在元空间(以后介绍运行时内存分区会涉及到),第三步Class实例对象是创建在堆中的,每个类都对应一个Class类型的对象。能通过Class类提供的接口,获得目标类所关联的.class文件中具体的数据结构。
我们看到第一点,“通过一个类的全限定名来获取二进制字节流”,并没有指明必须从哪里去获取、如何获取。这给Java虚拟机的使用者在加载阶段构建了一个开放广阔的舞台。我们可以从下面的地方获取:
(1)本地磁盘
(2)网上加载.class文件(Applet)
(3)从数据库中
(4)压缩文件中(ZAR,jar等)
(5)从其他文件生成的(JSP应用)
(6)动态代理 ……
2、验证
这是连接阶段的第一步,验证是为了保证Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,说白了就是检测它运行之后会不会危害虚拟机自身的安全。
验证阶段大致会完成四个阶段的检查动作:文件格式验证、元数据验证、字节码验证和符号引用验证。
对整个类加载机制而言,验证阶段是一个很重要但是非必需的阶段,如果我们的代码能够确保没有问题,那么我们就没有必要去验证,毕竟验证需要花费一定的的时间。当然我们可以使用-Xverfity:none来关闭大部分的验证。
3、准备
准备阶段是为类变量分配设置初始值的的阶段,即在方法区中分配这些变量所使用的内存空间。
注意这里所说的初始值概念,比如一个类变量定义为:public static int v = 8080
实际上变量 v 在准备阶段过后的初始值为 0 而不是 8080, 将 v 赋值为 8080 的 put static 指令是程序被编译后, 存放于类构造器方法之中,所以把v赋值为8080的动作要到初始化阶段才会被执行。
但是注意如果声明为:public static final int v = 8080
;
在编译阶段会为 v 生成 ConstantValue 属性,在准备阶段虚拟机会根据 ConstantValue 属性将 v赋值为 8080。
在编译时Javac将会为被static和final修改的常量生成ConstantValue属性(此时ConstantValue属性的值是多少,暂时不知道),在类加载的准备阶段虚拟机便会根据ConstantValue为常量设置相应的值(这个值是什么意思呢,比如我们在程序中定义final static int a = 100,那么这个a就是ConstantValue属性,然后在准备阶段中a的值就会变成100),这就是ConstantValue属性在类加载过程的准备阶段做的事
4、解析
解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程。符号引用就是 class 文件中的:
CONSTANT_Class_info、CONSTANT_Field_info、CONSTANT_Method_info 等类型的常量。
符号引用
符号引用与虚拟机实现的布局无关, 引用的目标并不一定要已经加载到内存中。 各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中。就好比在班级中,老师可以用张三来代表你,也可以用你的学号来代表你,但无论任何方式这些都只是一个代号(符号),这个代号指向你(符号引用)。
直接引用
直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。
5、初始化
类加载的最后一个步骤,到这里,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。初始化阶段是执行类构造器方法的过程。
在准备阶段已经为类变量赋过一次值。在初始化阶段,程序员可以根据自己的需求来赋值了。
上面的话过于抽象,简单举个例子。
A a = new A("CS-Review");
上面这段代码使用了new
关键字来实例化一个字符串对象,那么这时候就会调用A类的构造方法对a进行实例化了。
三、类加载器
JVM中内置了三个重要的ClassLoader。
- BootstrapClassLoader(启动类加载器):最顶层的加载类,由C++实现,负责加载
%JAVA_HOME%/lib
目录下的jar包或类。 - ExtensionClassLoader(扩展类加载器): 主要负责加载目录
%JRE_HOME%/lib/ext
目录下的jar包或类。 - AppClassLoader(应用程序类加载器) :面向用户的加载器,负责加载当前应用classpath下的所有jar包和类。
我们用户也可以自定义类加载器。
可以发现,除了BootstrapClassLoader
,其他类加载器均由Java实现且全继承java.lang.ClassLoader
。所以,我们要定义自己的类加载器,需要继承ClassLoader
。
public class ClassloaderTest {
public static void main(String[] args) {
ClassLoader loader = ClassloaderTest.class.getClassLoader();
System.out.println(loader); // App
System.out.println(loader.getParent()); //Ext
System.out.println(loader.getParent().getParent());
}
}
输出:
从上面的结果可以看出,并没有获取到ExtClassLoader的父Loader,原因是Bootstrap Loader(引导类加载器)是用C语言实现的,找不到一个确定的返回父Loader的方式,于是就返回null。
下面抛出几个小问题~
那么为什么要自定义类加载器呢?
-
隔离加载类 (由于中间件会有自己定义的jar,在同个工程里边会有同名同路径的jar包或类,会发生冲突,需要隔离加载)
-
修改类加载的方式
-
扩展加载源
-
防止源码泄露
类加载器的作用?
它的作用就是完成加载阶段的任务。(将class文件字节码加载进内存中,并将静态存储结构转换成方法区运行时数据结构,然后在堆中生成一个代表这个类的java.lang.Class对象,作为访问该类数据结构的入口。)
获取ClassLoader的途径
Class.forName("...").getClassLoader()
获取当前类的ClassLoader
Thread.currentThread().getContextClassLoader()
获取当前线程上下文的ClassLoader
ClassLoader.getSystemClassLoader()
获取系统的ClassLoader
DriverManager.getCallerClassLoader()
获取调用者的ClassLoader
四、双亲委派机制
每个类都有一个对应它的类加载器。而JVM对class文件采用的是按需加载。系统中的ClassLoader在协同工作时会默认使用双亲委派机制。
在类加载时,系统首先会判断当前类是否被当前类加载器加载过,已经加载的类直接返回,否则尝试加载。
加载时,首先会把请求委派给父类加载器的loadClass()
处理,因为所有请求最终会到达启动类加载器BootstrapClassLoader
中。
当父类可以完成加载,就成功返回。当父类加载器无法处理时,才由自己来处理。
采用双亲委派的一个好处是比如加载位于 rt.jar 包中的类 java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个 Object 对象。
比如说,我们自定义了java.lang.String类,当类首次主动使用时,JVM会根据双亲委派机制,由启动类加载器去尝试加载。而它会找到rt.jar包中的java.lang.String类并加载进内存(并不会加载我们自定义的这个String类)。如果我们在String类写了main方法,会报main方法不存在错误。
加载了rt.jar包中的String类,这样可以保证对Java核心源代码的保护,这就是沙箱安全机制。
双亲委派机制的优势:
- 避免类的重复加载
- 保护程序安全,防止核心API被随意篡改
- 自定义类:java.lang.ShkStart(报错,阻止创建java.lang开头的类)哪怕用自定义的类加载器去强行加载,也会收到SecurityException。
END
硬着头皮终于写完了,这种文章真的难写,怕写出来的意思曲解了原来的本意,所以翻看了大量的资料还有博客,最后终于完成了,虽然很久之前已经学过了,但是经过这次文章的书写,加深了对Java类加载机制的理解,使得自己对这种类型的知识不再抗拒了。
参考资料
- 《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》 ————周志明
- zhuanlan.zhihu.com/p/73078336
转载自:https://juejin.cn/post/6932089494686416904