重温JVM之Java类加载机制
前言
我们在学习 java 基础的时候,从宏观上了解一个类到运行大致是:.java 文件通过 javac 编译器编译得到 .class 文件,在用到该类时,jvm 会加载该 class 文件,并创建对应的 class 对象,将 class 文件加载到 jvm 的内存当中,这个过程也被称之为类加载过程。
下面我们将详细了解这个过程,本篇过长建议先收藏。
1、类加载过程
其实关于类加载过程是分为5个阶段的:
加载,验证,准备,解析,初始化
接下来我们看一下这五个阶段:
1.1 加载
JVM 在该阶段的主要目的是将字节码从不同的数据源(可能是 class 文件、也可能是 jar 包,甚至网络)转化为二进制字节流加载到内存中,并生成一个代表该类的 java.lang.Class 对象。
1.2 验证
这一阶段的主要目的是为了确保 Class 文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全,只有符合 JVM 字节码规范的才能被 JVM 正确执行。该阶段是保证 JVM 安全的重要屏障,下面是一些主要的检查。
-
确保二进制字节流格式符合预期(比如说是否以 cafe bene 咖啡北鼻开头)。
-
是否所有方法都遵守访问控制关键字的限定。
-
方法调用的参数个数和类型是否正确。
-
确保变量在使用之前被正确初始化了。
-
检查变量是否被赋予恰当类型的值。
1.3 准备
JVM 会在该阶段对类变量(也称为静态变量,static 关键字修饰的)分配内存并初始化(对应数据类型的默认初始值,如 0、0L、null、false 等)。
准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间。注意这里所说的初始值概念,比如一个类变量定义为:
public static int value1 = 123;
实际上变量 value1 在准备阶段过后的初始值为 0 而不是 123(如果是String类型,初始值为null),将 value1 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器方法之中。
但是注意如果声明为:
public static final int value2 = 123;
在编译阶段会为 value2 生成 ConstantValue 属性,在准备阶段虚拟机会根据 ConstantValue 属性将 value2 赋值为 123。
也就是,static final 修饰的变量被称作为常量,和类变量不同。常量一旦赋值就不会改变了,所以 value2 在准备阶段的值为 123 而不是 0。
1.4 解析
该阶段将常量池中的符号引用转化为直接引用。
what?符号引用,直接引用?
符号引用以一组符号(任何形式的字面量,只要在使用时能够无歧义的定位到目标即可)来描述所引用的目标。
在编译时,Java 类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如 club.sscai.Test1 类引用了 club.sscai.Test2 类,编译时 Test1 类并不知道 Test2 类的实际内存地址,因此只能使用符号 club.sscai.Test2。
直接引用通过对符号引用进行解析,找到引用的实际内存地址。
1.5 初始化
该阶段是类加载过程的最后一步。在准备阶段,类变量已经被赋过默认初始值,而在初始化阶段,类变量将被赋值为代码期望赋的值。换句话说,初始化阶段是执行类构造器方法的过程。
上面这段话说得比较抽象,不好理解,我来举个例子。
String niceyoo = new String("感谢关注");
上面这段代码使用了 new 关键字来实例化一个字符串对象,那么这时候,就会调用 String 类的构造方法对 niceyoo 进行实例化,怎么个实例化?就是赋值呗。
本节点总结
其实看完类加载过程,由于大部分偏理论,乏味的同时又很难理解,也不容易记忆。所以将类加载过程结合面试题来进一步扩展,如下:
建议先思考后再看答案
题目一:如下代码中,执行 main 函数会通过编译吗?如果可以通过,打印结果是什么呢?
public class A {
public static void fun1(){
System.out.println("fun1");
}
public void fun2(){
System.out.println("fun2");
}
public static void main(String[] args){
((A) null).fun1();
((A) null).fun2();
}
}
答案: 首先代码是可以通过编译的,null 可以强制转为任意类型,调用其类中的静态方法 fun1 不报异常,调用其类中的非静态方法 fun2 会报空指针异常。
分析: 编译是否正常通过最大的干扰项应该是 null 强转吧,估计有的小伙伴都不一定见过,null 可以被强制类型转换成任意类型的对象,知识点,下次要考。
关于打印结果则主要是类加载过程的考察:当加载类对象时,首先初始化静态属性,然后静态代码块;当实例化对象时,首先执行构造块(直接写在类中的代码块{ xxx }),然后执行构造方法。至于各静态块和静态属性初始化哪个些执行,是按代码的先后顺序。属性、构造块、构造方法之间的执行顺序(但构造块一定会在构造方法前执行),也是按代码的先后顺序。
综上,对象即便被将转为空时,静态方法也是可以被调用的,这也是我们平时在使用一些工具类时,直接通过对象.来访问其方法的原因。
题目二:请指出下面程序的运行结果。
class A {
static {
System.out.print("1");
}
public A() {
System.out.print("2");
}
}
class B extends A {
static {
System.out.print("a");
}
public B() {
System.out.print("b");
}
}
public class Hello {
public static void main(String[] args) {
A ab = new B();
ab = new B();
}
}
分析: 通过上一题目的分析中,我们可能机智的得到了静态代码块是优于构造方法的执行的,但是这个题目中出现了A\B类的继承关系,所以可能带来困扰,但是没关系,静态代码块就是优于构造方法的,只是父类优先级相对高一级罢了,比如 new B() 会先调用父类 A 的静态代码块,其次是 B 的静态代码块,然后是 A 的构造方法,最后是 B 的构造方法。
汇总:执行顺序是先执行父类的静态代码块,然后执行子类的静态代码块;然后执行父类的非静态代码块,再执行父类的构造方法;之后再执行子类的非静态代码块,再执行子类的构造方法。静态代码块>非静态代码块>构造方法。
再就是对象的创建只会调用一次静态代码块,因为类初始化信息是存在方法区里,当加载类的时候去检查,第二次的时候它会发现已经初始化过了,就不会再执行,所以再去 new B() 的时候,是不会再去打印 1a 的。
如果觉得比较绕,再举个例子,就好比你玩王者荣耀的时候,有个赵云的6元首充礼包,你第一次充钱,创建了这个首充礼包的对象,当你第二次充钱时就不会再有首充礼包了。
答案:1a2b2b
2、类加载器
聊完类加载过程的五个阶段,我们再来看看加载阶段用到的类加载器。
系统运行时,是由类加载器将 .class 文件的二进制数据从外部存储器(如光盘,硬盘)调入内存中,CPU再从内存中读取指令和数据进行运算,并将运算结果存入内存中的,显然类加载器是很重要的第一步。
一般来说,Java 程序员并不需要直接同类加载器进行交互。JVM 默认的行为就已经足够满足大多数情况的需求了。不过,如果遇到了需要和类加载器进行交互的情况,而对类加载器的机制又不是很了解的话,就不得不花大量的时间去调试 ClassNotFoundException 和 NoClassDefFoundError 等异常。
对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在 JVM 中的唯一性。也就是说,如果两个类的加载器不同,即使两个类来源于同一个字节码文件,那这两个类就必定不相等(比如两个类的 Class 对象不 equals)。
Java 类加载器可以分为三种:
1)启动类加载器(Bootstrap Class-Loader),加载 jre/lib 包下面的 jar 文件,比如说常见的 rt.jar。
启动类加载器主要加载的是JVM自身需要的类,这个类加载使用 C++ 语言实现的,是虚拟机自身的一部分,它负责将 java_home/lib 路径下的核心类库或-Xbootclasspath参数指定的路径下的jar包加载到内存中,注意必由于虚拟机是按照文件名识别加载jar包的,如rt.jar,如果文件名不被虚拟机识别,即使把jar包丢到lib目录下也是没有作用的(出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类)。
2)扩展类加载器(Extension or Ext Class-Loader),加载 jre/lib/ext 包下面的 jar 文件。
扩展类加载器是指Sun公司(已被Oracle收购)实现的sun.misc.Launcher$ExtClassLoader类,由Java语言实现的,是Launcher的静态内部类,它负责加载 java_home/lib/ext 目录下或者由系统变量-Djava.ext.dir指定位路径中的类库,开发者可以直接使用标准扩展类加载器。
3)应用类加载器(Application or App Clas-Loader),根据程序的类路径(classpath)来加载 Java 类。
也称应用程序加载器是指 Sun公司实现的sun.misc.Launcher$AppClassLoader。它负责加载系统类路径java -classpath或-D java.class.path 指定路径下的类库,也就是我们经常用到的classpath路径,开发者可以直接使用系统类加载器,一般情况下该类加载是程序中默认的类加载器,通过ClassLoader#getSystemClassLoader()方法可以获取到该类加载器。
来来来,通过一段简单的代码了解下。
public class Test1 {
public static void main(String[] args){
ClassLoader currentLoader = Test.class.getClassLoader();
System.out.println(currentLoader.toString());
ClassLoader parentLoader = currentLoader.getParent();
System.out.println(parentLoader.toString());
ClassLoader parentParentLoader = parentLoader.getParent();
System.out.println(parentParentLoader);
}
}
每个 Java 类都维护着一个指向定义它的类加载器的引用,通过 类名.class.getClassLoader() 可以获取到此引用;然后通过 .getParent() 可以获取类加载器的上层类加载器。
这段代码的输出结果如下:
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@4554617c
null
第一行输出为 Test 的类加载器,即应用类加载器,它是 sun.misc.Launcher 类的实例;第二行输出为扩展类加载器,是 sun.misc.LauncherAppClassLoader∗∗类的实例;第二行输出为扩展类加载器,是∗∗sun.misc.LauncherExtClassLoader 类的实例。那启动类加载器呢?按理说,扩展类加载器的上层类加载器是启动类加载器,但在我这个版本的 JDK 中, 扩展类加载器的 getParent() 返回 null。
在 Java 的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,需要注意的是,Java 虚拟机对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存生成 class 对象,而且加载某个类的 class 文件时, Java 虚拟机采用的是双亲委派模式即把请求交由父类处理,它一种任务委派模式,下面我们进一步了解它。
别放弃,加油!
3、双亲委派模型
双亲委派模式是在 Java 1.2 后引入的,其工作原理的是,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己想办法去完成,这不就是传说中的实力坑爹啊?那么采用这种模式有啥用呢?
双亲委派模式优势
采用双亲委派模式的是好处是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子 ClassLoader 再加载一次。
其次是考虑到安全因素,java 核心 api 中定义类型不会被随意替换,假设通过网络传递一个名为 java.lang.Integer 的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心 Java API 发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的 java.lang.Integer,而直接返回已加载过的 Integer.class,这样便可以防止核心API库被随意篡改。
可能你会想,如果我们在 classpath 路径下自定义一个名为 java.lang.SingleInterge 类(该类是胡编的)呢?该类并不存在 java.lang 中,经过双亲委托模式,传递到启动类加载器中,由于父类加载器路径下并没有该类,所以不会加载,将反向委托给子类加载器加载,最终会通过系统类加载器加载该类。但是这样做是不允许,因为 java.lang 是核心 API 包,需要访问权限,强制加载将会报出如下异常:
java.lang.SecurityException: Prohibited package name: java.lang
文字内容太乏味,上个例子吧,我们通过自定义类加载器去证实双亲委派模式。
先简单了解一下这个类加载器的主要方法:
loadClass:该方法中的逻辑就是双亲委派模式的实现,当类加载请求到来时,先从缓存中查找该类对象,如果存在直接返回,如果不存在则交给该类加载去的父加载器去加载,倘若没有父加载则交给顶级启动类加载器去加载,最后倘若仍没有找到,则使用findClass()方法去加载。
findClass:findClass()方法是在loadClass()方法中被调用的,当loadClass()方法中父加载器加载失败后,则会调用自己的findClass()方法来完成类加载,这样就可以保证自定义的类加载器也符合双亲委托模式。
defineClass:通过这个方法不仅能够通过class文件实例化class对象,也可以通过其他方式实例化class对象,defineClass()方法通常与findClass()方法一起使用,一般情况下,在自定义类加载器时,会直接覆盖ClassLoader的findClass()方法并编写加载规则,取得要加载类的字节码后转换成流,然后调用defineClass()方法生成类的Class对象。
resolveClass:使用该方法可以使用类的Class对象创建完成也同时被解析。前面我们说链接阶段主要是对字节码进行验证,为类变量分配内存并设置初始值同时将字节码文件中的符号引用转换为直接引用。
自定义类加载器 MyClassLoader :
package club.sscai.test7;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
/**
* author: niceyoo
* blog: https://cnblogs.com/niceyoo
* desc: 自定义类加载器
*/
public class MyClassLoader extends ClassLoader {
private String path;/* 加载器的路径 */
private String name;/* 类加载器名称 */
public MyClassLoader(String path,String name){
super();/* 让起同类加载器成为该类的父加载器 */
this.name = name;
this.path = path;
}
/**
* 父类加载器构造方法
* @param parent
* @param path
* @param name
*/
public MyClassLoader(ClassLoader parent,String path,String name){
super(parent);/* 显示指定父类加载器 */
this.name = name;
this.path = path;
}
/**
* 加载我们自己定义的类,通过我们自定义的这个 ClassLoader
* 例如:club.sscai.test7.Demo
* @param name
* @return
* @throws ClassNotFoundException
*/
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
/* 读取 class 文件,转换成二进制数组 */
byte[] data = readClassFileToByteArray(name);
return this.defineClass(name,data,0,data.length);
}
@Override
public String toString() {
return this.name;
}
/**
* 获取 .class 字节数组
* 【读取 class 文件,将类转换成二进制数组】
* club.sscai.test7.Demo >
* F:/idea_workspace/test/Demo.class
* @param name
* @return
*/
private byte[] readClassFileToByteArray(String name) {
InputStream is = null;
byte[] returnData = null;
name = name.replace("\\.","/");
String filePath = this.path + name + ".class";
File file = new File(filePath);
ByteArrayOutputStream os = new ByteArrayOutputStream();
try {
is = new FileInputStream(file);
int tep = 0;
while ((tep = is.read()) != -1){
os.write(tep);
}
returnData = os.toByteArray();
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
is.close();
os.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return returnData;
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
return super.loadClass(name, resolve);
}
}
当前项目位于 F:\idea_workspace\Demo
当前项目中的干扰项 Demo.java:
package club.sscai.test7;
/**
* author: niceyoo
* blog: https://cnblogs.com/niceyoo
* desc: 干扰项Demo
*/
public class Demo {
public Demo() {
System.out.println("我是父加载器加载的Demo:"+Demo.class.getClassLoader());
}
}
存放在 F:/idea_workspace/test/ 目录下的 Demo.java,注意如下代码需要通过 javac 编译成 Demo.class
public class Demo {
public Demo(){
System.out.println("Demo:" + this.getClass().getClassLoader());
}
}
测试代码 TestDemo.java 如下:
public class TestDemo {
public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
/* 参数一为读取class路径,参数二为自定义类加载器名称 */
MyClassLoader xwLoader = new MyClassLoader("F:/idea_workspace/test/","xiaowang");
Class<?> demo = xwLoader.loadClass("Demo");
demo.newInstance();
}
}
执行 main 方法后会打印什么呢?
Demo:xiaowang
我们改一下测试代码:
public class TestDemo {
public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {
MyClassLoader xwLoader = new MyClassLoader("F:/idea_workspace/test/","xiaowang");
Class<?> demo = xwLoader.loadClass("club.sscai.test7.Demo");
demo.newInstance();
}
}
再次执行会打印什么呢?
我是父加载器加载的Demo:sun.misc.Launcher$AppClassLoader@18b4aac2
显然第二次并没有加载 F:/idea_workspace/test/ 目录下的 Demo,而是执行了当前项目中的 Demo,为什么?
这就是双亲委派模式,由于当前启动类 TestDemo 的父级是 AppClassLoader,显然该包下已经加载过 Demo 类了,所以不会再去加载目标 Demo
4、热部署与热加载(扩展)
上边算是说了一堆理论吧,热部署、热加载则算是实际应用了,相信这两者应该并不陌生,或多或少的应该也有所了解吧。
热加载的实现原理主要依赖java的类加载机制,在实现方式可以概括为在容器启动的时候起一条后台线程,定时的检测类文件的时间戳变化,如果类的时间戳变掉了,则将类重新载入。
热部署原理类似,但它是直接重新加载整个应用,这种方式会释放内存,比热加载更加干净彻底,但同时也更费时间。
简单总结一下两者的区别与联系:
Java热部署与热加载的联系
- 不重启服务器编译/部署项目
- 基于Java的类加载器实现
Java热部署与热加载的区别:
- 部署方式
-- 热部署在服务器运行时重新部署项目
-- 热加载在运行时重新加载class - 实现原理
-- 热部署直接重新加载整个应用
-- 热加载在运行时重新加载class - 使用场景
-- 热部署更多的是在生产环境使用
-- 热加载则更多的实在开发环境使用
想要实现热部署可以分以下三个步骤:
- 销毁该自定义ClassLoader
- 更新class类文件
- 创建新的ClassLoader去加载更新后的class类文件。
相关代码:
User没有被修改类:
public class User {
public void add() {
System.out.println("addV1,没有修改过...");
}
}
User更新类
public class User {
public void add() {
System.out.println("我把之前的user add方法修改啦!");
}
}
自定义类加载器:
public class MyClassLoader extends ClassLoader {
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
/* 文件名称 */
String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
/* 获取文件输入流 */
InputStream is = this.getClass().getResourceAsStream(fileName);
/* 读取字节 */
byte[] b = new byte[is.available()];
is.read(b);
/* 将byte字节流解析成jvm能够识别的Class对象 */
return defineClass(name, b, 0, b.length);
} catch (Exception e) {
throw new ClassNotFoundException();
}
}
}
更新代码:
public class Hotswap {
public static void main(String[] args)
throws ClassNotFoundException, InstantiationException, IllegalAccessException,
NoSuchMethodException,
SecurityException, IllegalArgumentException, InvocationTargetException,
InterruptedException {
loadUser();
System.gc();
Thread.sleep(1000);/* 等待资源回收 */
/* 需要被热部署的class文件 */
File file1 = new File("F:\\test\\User.class");
/* 之前编译好的class文件 */
File file2 = new File(
"F:\\idea_workspace\\target\\classes\\club\\sscai\\User.class");
/* 删除旧版本的class文件 */
boolean isDelete = file2.delete();
if (!isDelete) {
System.out.println("热部署失败.");
return;
}
file1.renameTo(file2);
System.out.println("update success!");
loadUser();
}
public static void loadUser() throws ClassNotFoundException, InstantiationException,
IllegalAccessException,
NoSuchMethodException, SecurityException, IllegalArgumentException,
InvocationTargetException {
MyClassLoader myLoader = new MyClassLoader();
Class<?> class1 = myLoader.findClass("club.sscai.User");
Object obj1 = class1.newInstance();
Method method = class1.getMethod("add");
method.invoke(obj1);
System.out.println(obj1.getClass());
System.out.println(obj1.getClass().getClassLoader());
}
}
5、最后
本篇有点过长了,其实大致看下来,类加载无非也就那么回事。
类加载机制:JVM 将类的信息动态添加到内存并使用的一种机制。