JVM自理笔记
1.1 什么是JVM
JVM 全称是 Java Virtual Machine,中文译名 Java虚拟机。JVM 本质上是一个运行在计算机上的程序,他的职责是运行Java字节码文件。
Java源代码执行流程如下:
分为三个步骤:
1、编写Java源代码文件。
2、使用Java编译器(javac命令)将源代码编译成Java字节码文件。
3、使用Java虚拟机加载并运行Java字节码文件,此时会启动一个新的进程。
1.2 JVM的功能
-
1 - 解释和运行
-
2 - 内存管理
-
3 - 即时编译
1.2.1 解释和运行
对字节码文件中的指令,实时的解释成机器码,让计算机执行。
字节码文件中包含了字节码指令,计算器无法直接执行,Java虚拟机会将字节码文件中的字节码指令实时地解释成机器码,机器码是计算机可以运行的指令。
1.2.2 内存管理
-
自动为对象、方法等分配内存
-
自动的垃圾回收机制,回收不再使用的对象
Java虚拟机会帮助程序员为对象分配内存,同时将不用的对象使用垃圾回收器回收掉,这是对比C和C++这些语言的一个优势。在C/C++语言中,对象的回收需要程序员手动去编写代码完成,如果遗漏了这段删除对象的代码,这个对象就会永远占用内存空间,不会再回收。所以JVM的这个功能降低了程序员编写代码的难度。
1.2.3 即时编译
对热点代码进行优化,提升执行效率。即时编译可以说是提升Java程序性能最核心的手段。
Java性能低的主要原因和跨平台特性
Java语言如果不做任何的优化,性能其实是不如C和C++语言的。主要原因是:
在程序运行过程中,Java虚拟机需要将字节码指令实时地解释成计算机能识别的机器码,这个过程在运行时可能会反复地执行,所以效率较低。
C和C++语言在执行过程中,只需要将源代码编译成可执行文件,就包含了计算机能识别的机器码,无需在运行过程中再实时地解释,所以性能较高。
Java为什么要选择一条执行效率比较低的方式呢?主要是为了实现跨平台的特性。Java的字节码指令,如果希望在不同平台(操作系统+硬件架构),比如在windows或者linux上运行。可以使用同一份字节码指令,交给windows和linux上的Java虚拟机进行解释,这样就可以获得不同平台上的机器码了。这样就实现了Write Once,Run Anywhere 编写一次,到处运行 的目标。
但是C/C++语言,如果要让程序在不同平台上运行,就需要将一份源代码在不同平台上分别进行编译,相对来说比较麻烦。
再回到即时编译,在JDK1.1的版本中就推出了即时编译去优化对应的性能。
虚拟机在运行过程中如果发现某一个方法甚至是循环是热点代码(被非常高频调用),即时编译器会优化这段代码并将优化后的机器码保存在内存中,如果第二次再去执行这段代码。Java虚拟机会将机器码从内存中取出来直接进行调用。这样节省了一次解释的步骤,同时执行的是优化后的代码,效率较高。
Java通过即时编译器获得了接近C/C++语言的性能,在某些特定的场景下甚至可以实现超越。
1.3 常见的JVM
1.3.1 Java虚拟机规范
-
《Java虚拟机规范》由Oracle制定,内容主要包含了Java虚拟机在设计和实现时需要遵守的规范,主要包含class字节码文件的定义、类和接口的加载和初始化、指令集等内容。
-
《Java虚拟机规范》是对虚拟机设计的要求,而不是对Java设计的要求,也就是说虚拟机可以运行在其他的语言比如Groovy、Scala生成的class字节码文件之上。
1.3.2 Java虚拟机规范
平时我们最常用的,就是Hotspot虚拟机。
名称 | 作者 | 支持版本 | 社区活跃度(github star) | 特性 | 适用场景 |
---|---|---|---|---|---|
HotSpot (Oracle JDK版) | Oracle | 所有版本 | 高(闭源) | 使用最广泛,稳定可靠,社区活跃JIT支持Oracle JDK默认虚拟机 | 默认 |
HotSpot (Open JDK版) | Oracle | 所有版本 | 中(16.1k) | 同上开源,Open JDK默认虚拟机 | 默认对JDK有二次开发需求 |
GraalVM | Oracle | 11, 17,19企业版支持8 | 高(18.7k) | 多语言支持高性能、JIT、AOT支持 | 微服务、云原生架构需要多语言混合编程 |
Dragonwell JDK龙井 | Alibaba | 标准版 8,11,17扩展版11,17 | 低(3.9k) | 基于OpenJDK的增强高性能、bug修复、安全性提升JWarmup、ElasticHeap、Wisp特性支持 | 电商、物流、金融领域对性能要求比较高 |
Eclipse OpenJ9 (原 IBM J9) | IBM | 8,11,17,19,20 | 低(3.1k) | 高性能、可扩展JIT、AOT特性支持 | 微服务、云原生架构 |
1.3.3 HotSpot的发展历程
初出茅庐 - 1999年4月
源自1997年收购的SmallTalk语言的虚拟机,HotSpot虚拟机初次在JDK中使用。在JDK1.2中作为附加功能存在,
JDK1.3之后作为默认的虚拟机。
野蛮生长 - 2006年12月
JDK 6发布,并在虚拟机层面做了大量的优化,这些优化对后续虚拟机的发展产生了深远的影响。
稳步前进 - 2009-2013
JDK7中首次推出了G1垃圾收集器。收购了Sun公司之后,吸纳了JRockIt虚拟机的一些设计思想,JDK8中引入了JMC等工具,去除了永久代。
百家争鸣 - 2018-2019
JDK11优化了G1垃圾收集器的性能,同时推出了ZGC新一代的垃圾回收器,JDK12推出Shenan-doah垃圾回收器。
拥抱云原生 - 2019-至今
以Hotspot为基础的GraalVM虚拟机诞生,不仅让解决了单体应用中多语言整合的难题,同时也提升了这些语言运行时的效率。极高的性能、极快的启动速度也更适用于当下的云原生架构。
2、字节码文件详解
2.1 Java虚拟机的组成
Java虚拟机主要分为以下几个组成部分:
-
类加载子系统:核心组件类加载器,负责将字节码文件中的内容加载到内存中。
-
运行时数据区:JVM管理的内存,创建出来的对象、类的信息等等内容都会放在这块区域中。
-
执行引擎:包含了即时编译器、解释器、垃圾回收器,执行引擎使用解释器将字节码指令解释成机器码,使用即时编译器优化性能,使用垃圾回收器回收不再使用的对象。
-
本地接口:调用本地使用C/C++编译好的方法,本地方法在Java中声明时,都会带上native关键字,如下图所示。
2.2 字节码文件的组成
2.2.1 以正确的姿势打开文件
字节码文件中保存了源代码编译之后的内容,以二进制的方式存储,无法直接用记事本打开阅读。
通过NotePad++使用十六进制插件查看class文件:
无法解读出文件里包含的内容,推荐使用 jclasslib工具查看字节码文件。 Github地址: https://github.com/ingokegel/jclasslib
安装方式:找到 资料\工具\jclasslib_win64_6_0_4.exe 安装即可
2.2.2 字节码文件的组成
字节码文件总共可以分为以下几个部分:
-
基础信息:魔数、字节码文件对应的Java版本号、访问标识(public final等等)、父类和接口信息
-
常量池**:** 保存了字符串常量、类或接口名、字段名,主要在字节码指令中使用
-
字段: 当前类或接口声明的字段信息
-
方法: 当前类或接口声明的方法信息,核心内容为方法的字节码指令
-
属性: 类的属性,比如源码的文件名、内部类的列表等
2.2.2.1 基本信息
基本信息包含了jclasslib中能看到的两块内容:
Magic魔数
每个Java字节码文件的前四个字节是固定的,用16进制表示就是0xcafebabe。文件是无法通过文件扩展名来确定文件类型的,文件扩展名可以随意修改不影响文件的内容。软件会使用文件的头几个字节(文件头)去校验文件的类型,如果软件不支持该种类型就会出错。
比如常见的文件格式校验方式如下:
Java字节码文件中,将文件头称为magic魔数。Java虚拟机会校验字节码文件的前四个字节是不是0xcafebabe,如果不是,该字节码文件就无法正常使用,Java虚拟机会抛出对应的错误。
主副版本号
主副版本号指的是编译字节码文件时使用的JDK版本号,主版本号用来标识大版本号,JDK1.0-1.1使用了45.0-45.3,JDK1.2是46之后每升级一个大版本就加1;副版本号是当主版本号相同时作为区分不同版本的标识,一般只需要关心主版本号。
1.2之后大版本号计算方法就是 : 主版本号 – 44,比如主版本号52就是JDK8。
版本号的作用主要是判断当前字节码的版本和运行时的JDK是否兼容。如果使用较低版本的JDK去运行较高版本JDK的字节码文件,无法使用会显示如下错误:
有两种方案:
1.升级JDK版本,将图中使用的JDK6升级至JDK8即可正常运行,容易引发其他的兼容性问题,并且需要大量的测试。
2.将第三方依赖的版本号降低或者更换依赖,以满足JDK版本的要求。建议使用这种方案
其他基础信息
其他基础信息包括访问标识、类和接口索引,如下:
2.2.2.2 常量池
字节码文件中常量池的作用:避免相同的内容重复定义,节省空间。如下图,常量池中定义了一个字符串,字符串的字面量值为123。
比如在代码中,编写了两个相同的字符串“我爱北京天安门”,字节码文件甚至将来在内存中使用时其实只需要保存一份,此时就可以将这个字符串以及字符串里边包含的字面量,放入常量池中以达到节省空间的作用。
String str1 = "我爱北京天安门"; String str2 = "我爱北京天安门";
常量池中的数据都有一个编号,编号从1开始。比如“我爱北京天安门”这个字符串,在常量池中的编号就是7。在字段或者字节码指令中通过编号7可以快速的找到这个字符串。
字节码指令中通过编号引用到常量池的过程称之为符号引用。
2.2.2.3 字段
字段中存放的是当前类或接口声明的字段信息。
如下图中,定义了两个字段a1和a2,这两个字段就会出现在字段这部分内容中。同时还包含字段的名字、描述符(字段的类型)、访问标识(public/private static final等)。
2.2.2.4 方法
字节码中的方法区域是存放字节码**指令**的核心位置,字节码指令的内容存放在方法的Code属性中。
通过分析方法的字节码指令,可以清楚地了解一个方法到底是如何执行的。先来看如下案例:
int i = 0; int j = i + 1;
这段代码编译成字节码指令之后是如下内容:
要理解这段字节码指令是如何执行的,我们需要先理解两块内存区域:操作数栈和局部变量表。
操作数栈是用来存放临时数据的内容,是一个栈式的结构,先进后出。
局部变量**表**是存放方法中的局部变量,包含方法的参数、方法中定义的局部变量,在编译期就已经可以确定方法有多少个局部变量。
1、iconst_0,将常量0放入操作数栈。此时栈上只有0。
2、istore_1会从操作数栈中,将栈顶的元素弹出来,此时0会被弹出,放入局部变量表的1号位置。局部变量表中的1号位置,在编译时就已经确定是局部变量i使用的位置。完成了对局部变量i的赋值操作。
3、iload_1将局部变量表1号位置的数据放入操作数栈中,此时栈中会放入0。
4、iconst_1会将常量1放入操作数栈中。
5、iadd会将操作数栈顶部的两个数据相加,现在操作数栈上有两个数0和1,相加之后结果为1放入操作数栈中,此时栈上只有一个数也就是相加的结果1。
6、istore_2从操作数栈中将1弹出,并放入局部变量表的2号位置,2号位置是j在使用。完成了对局部变量j的赋值操作。
7、return语句执行,方法结束并返回。
同理,同学们可以自行分析下i++和++i的字节码指令执行的步骤。
i++的字节码指令如下,其中iinc 1 by 1指令指的是将局部变量表1号位置增加1,其实就实现了i++的操作。
而++i只是对两个字节码指令的顺序进行了更改:
面试题:
问:int i = 0; i = i++; 最终i的值是多少?
答:答案是0,我通过分析字节码指令发现,i++先把0取出来放入临时的操作数栈中,
接下来对i进行加1,i变成了1,最后再将之前保存的临时值0放入i,最后i就变成了0。
练习:
分析三种加1的性能
//对应的字节码: // 0 iconst_0 // 1 istore_1 // 2 iconst_0 // 3 istore_2 // 4 iconst_0 // 5 istore_3 // 6 iinc 1 by 1 // 9 iload_2 //10 iconst_1 //11 iadd //12 istore_2 //13 iinc 3 by 1 //16 return public static void main(String[] args) { int i = 0,j=0,k=0; i++; j=j+1; k+=1; }
属性
属性主要指的是类的属性,比如源码的文件名、内部类的列表等。
2.2.3 玩转字节码常用工具
2.2.3.1 javap
javap是JDK自带的反编译工具,可以通过控制台查看字节码文件的内容。适合在服务器上查看字节码文件内容。
直接输入javap查看所有参数。输入javap -v
字节码文件名称 查看具体的字节码信息。如果jar包需要先使用 jar –xvf
命令解压。
2.2.3.2 jclasslib插件
jclasslib也有Idea插件版本,建议开发时使用Idea插件版本,可以在代码编译之后实时看到字节码文件内容。
安装方式:
1、打开idea的插件页面,搜索jclasslib
2、选中要查看的源代码文件,选择 视图(View) - Show Bytecode With Jclasslib
右侧会展示对应源代码编译后的字节码文件内容:
tips:
1、一定要选择文件再点击视图(view)菜单,否则菜单项不会出现。
2、文件修改后一定要重新编译之后,再点击刷新按钮。
2.2.3.3 Arthas
Arthas 是一款线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息,并能在不修改应用代码的情况下,对业务问题进行诊断,大大提升线上问题排查效率。 官网:简介 | arthas Arthas的功能列表如下:
安装方法:
1、将 资料/工具/arthas-boot.jar 文件复制到任意工作目录。
2、使用java -jar arthas-boot.jar
启动程序。
3、输入需要Arthas监控的进程id。
4、输入命令即可使用。
dump
命令详解:dump | arthas
dump命令可以将字节码文件保存到本地,如下将java.lang.String
的字节码文件保存到了/tmp/output目录下:
$ dump -d /tmp/output java.lang.String HASHCODE CLASSLOADER LOCATION null /tmp/output/java/lang/String.class Affect(row-cnt:1) cost in 138 ms.
jad
命令详解:jad | arthas
jad命令可以将类的字节码文件进行反编译成源代码,用于确认服务器上的字节码文件是否是最新的,如下将demo.MathGame的源代码进行了显示。
$ jad --source-only demo.MathGame /* * Decompiled with CFR 0_132. */ package demo; import java.io.PrintStream; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Random; import java.util.concurrent.TimeUnit; public class MathGame { private static Random random = new Random(); public int illegalArgumentCount = 0; ...
2.3、类的生命周期
类的生命周期描述了一个类加载、使用、卸载的整个过程。整体可以分为:
-
加载
-
连接,其中又分为验证、准备、解析三个子阶段
-
初始化
-
使用
-
卸载
2.3.1 加载阶段
1、加载(Loading)阶段第一步是类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息,程序员可以使用Java代码拓展的不同的渠道。
-
从本地磁盘上获取文件
-
运行时通过动态代理生成,比如Spring框架
-
Applet技术通过网络获取字节码文件
2、类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到方法区中,方法区中生成一个InstanceKlass对象,保存类的所有信息,里边还包含实现特定功能比如多态的信息。
4、Java虚拟机同时会在堆上生成与方法区中数据类似的java.lang.Class对象,作用是在Java代码中去获取类的信息以及存储静态字段的数据(JDK8及之后)。
2.3.2 连接阶段
连接阶段分为三个子阶段:
-
验证,验证内容是否满足《Java虚拟机规范》。
-
准备,给静态变量赋初值。
-
解析,将常量池中的符号引用替换成指向内存的直接引用。
验证
验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规范》中的约束。这个阶段一般不需要程序员参与。主要包含如下四部分,具体详见《Java虚拟机规范》:
1、文件格式验证,比如文件是否以0xCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求。
2、元信息验证,例如类必须有父类(super不能为空)。
3、验证程序执行指令的语义,比如方法内的指令执行中跳转到不正确的位置。
4、符号引用验证,例如是否访问了其他类中private的方法等。
对版本号的验证,在JDK8的源码中如下:
编译文件的主版本号不能高于运行环境主版本号,如果主版本号相等,副版本号也不能超过。
准备
准备阶段为静态变量(static)分配内存并设置初值,每一种基本数据类型和引用数据类型都有其初值。
数据类型 | 初始值 |
---|---|
int | 0 |
long | 0L |
short | 0 |
char | ‘\u0000’ |
byte | 0 |
boolean | false |
double | 0.0 |
引用**数据类型** | null |
如下代码:
public class Student{ public static int value = 1; }
在准备阶段会为value分配内存并赋初值为0,在初始化阶段才会将值修改为1。
final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。
如下例子中,变量加上final进行修饰,在准备阶段value值就直接变成1了,因为final修饰的变量后续不会发生值的变更。
来看这个案例:
public class HsdbDemo { public static final int i = 2; public static void main(String[] args) throws IOException, InstantiationException, IllegalAccessException { HsdbDemo hsdbDemo = new HsdbDemo(); System.out.println(i); System.in.read(); } }
从字节码文件也可以看到,编译器已经确定了该字段指向了常量池中的常量2:
解析
解析阶段主要是将常量池中的符号引用替换为直接引用,符号引用就是在字节码文件中使用编号来访问常量池中的内容。
直接引用不在使用编号,而是使用内存中地址进行访问具体的数据。
2.3.3 初始化阶段
初始化阶段会执行字节码文件中clinit(class init 类的初始化)方法的字节码指令,包含了静态代码块中的代码,并为静态变量赋值。
如下代码编译成字节码文件之后,会生成三个方法:
public class Demo1 { public static int value = 1; static { value = 2; } public static void main(String[] args) { } }
-
init方法,会在对象初始化时执行
-
main方法,主方法
-
clinit方法,类的初始化阶段执行
继续来看clinit方法中的字节码指令:
1、iconst_1,将常量1放入操作数栈。此时栈中只有1这个数。
2、putstatic指令会将操作数栈上的数弹出来,并放入堆中静态变量的位置,字节码指令中#2指向了常量池中的静态变量value,在解析阶段会被替换成变量的地址。
3、后两步操作类似,执行value=2,将堆上的value赋值为2。
如果将代码的位置互换:
public class Demo1 { static { value = 2; } public static int value = 1; public static void main(String[] args) { } }
字节码指令的位置也会发生变化:
这样初始化结束之后,最终value的值就变成了1而不是2。
以下几种方式会导致类的初始化:
1.访问一个类的静态变量或者静态方法,注意变量是final修饰的并且等号右边是常量不会触发初始化。
2.调用Class.forName(String className)。
3.new一个该类的对象时。
4.执行Main方法的当前类。
添加-XX:+TraceClassLoading 参数可以打印出加载并初始化的类
面试题1:
如下代码的输出结果是什么?
public class Test1 { public static void main(String[] args) { System.out.println("A"); new Test1(); new Test1(); } public Test1(){ System.out.println("B"); } { System.out.println("C"); } static { System.out.println("D"); } }
分析步骤:
1、执行main方法之前,先执行clinit指令。
指令会输出D
2、执行main方法的字节码指令。
指令会输出A
3、创建两个对象,会执行两次对象初始化的指令。
这里会输出CB,源代码中输出C这行,被放到了对象初始化的一开始来执行。
所以最后的结果应该是DACBCB
clinit不会执行的几种情况
如下几种情况是不会进行初始化指令执行的:
1.无静态代码块且无静态变量赋值语句。
2.有静态变量的声明,但是没有赋值语句。
3.静态变量的定义使用final关键字,这类变量会在准备阶段直接进行初始化。
面试题2:
如下代码的输出结果是什么?
public class Demo01 { public static void main(String[] args) { new B02(); System.out.println(B02.a); } } class A02{ static int a = 0; static { a = 1; } } class B02 extends A02{ static { a = 2; } }
分析步骤:
1、调用new创建对象,需要初始化B02,优先初始化父类。
2、执行A02的初始化代码,将a赋值为1。
3、B02初始化,将a赋值为2。
变化
将new B02();
注释掉会怎么样?
分析步骤:
1、访问父类的静态变量,只初始化父类。
2、执行A02的初始化代码,将a赋值为1。
补充练习题
分析如下代码执行结果:
public class Test2 { public static void main(String[] args) { Test2_A[] arr = new Test2_A[10]; } } class Test2_A { static { System.out.println("Test2 A的静态代码块运行"); } }
数组的创建不会导致数组中元素的类进行初始化。
public class Test4 { public static void main(String[] args) { System.out.println(Test4_A.a); } } class Test4_A { public static final int a = Integer.valueOf(1); static { System.out.println("Test3 A的静态代码块运行"); } }
final修饰的变量如果赋值的内容需要执行指令才能得出结果,会执行clinit方法进行初始化。
2.4、类加载器
2.4.1 什么是类加载器
类加载器(ClassLoader)是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术,类加载器只参与加载过程中的字节码获取并加载到内存这一部分。
类加载器会通过二进制流的方式获取到字节码文件的内容,接下来将获取到的数据交给Java虚拟机,虚拟机会在方法区和堆上生成对应的对象保存字节码信息。
2.4.2 类加载器的分类
类加载器分为两类,一类是Java代码中实现的,一类是Java虚拟机底层源码实现的。
-
虚拟机底层实现:源代码位于Java虚拟机的源码中,实现语言与虚拟机底层语言一致,比如Hotspot使用C++。主要目的是保证Java程序运行中基础类被正确地加载,比如java.lang.String,Java虚拟机需要确保其可靠性。
-
JDK中默认提供或者自定义:JDK中默认提供了多种处理不同渠道的类加载器,程序员也可以自己根据需求定制,使用Java语言。所有Java中实现的类加载器都需要继承ClassLoader这个抽象类。
类加载器的设计JDK8和8之后的版本差别较大,首先来看JDK8及之前的版本,这些版本中默认的类加载器有如下几种:
类加载器的详细信息可以通过Arthas的classloader命令查看:
classloader
- 查看 classloader 的继承树,urls,类加载信息,使用 classloader 去 getResource
-
BootstrapClassLoader是启动类加载器,numberOfInstances是类加载器的数量只有1个,loadedCountTotal是加载类的数量1861个。
-
ExtClassLoader是扩展类加载器
-
AppClassLoader是应用程序类加载器
2.4.3 启动类加载器
-
启动类加载器(Bootstrap ClassLoader)是由Hotspot虚拟机提供的、使用C++编写的类加载器。
-
默认加载Java安装目录/jre/lib下的类文件,比如rt.jar,tools.jar,resources.jar等。
运行如下代码:
/** * 启动程序类加载器案例 */ public class BootstrapClassLoaderDemo { public static void main(String[] args) throws IOException { ClassLoader classLoader = String.class.getClassLoader(); System.out.println(classLoader); System.in.read(); } }
这段代码通过String类获取到它的类加载器并且打印,结果是null
。这是因为启动类加载器在JDK8中是由C++语言来编写的,在Java代码中去获取既不适合也不安全,所以才返回null
在Arthas中可以通过sc -d 类名
的方式查看加载这个类的类加载器详细的信息,比如:
通过上图可以看到,java.lang.String类的类加载器是空的,Hash值也是null。
用户扩展基础jar包
如果用户想扩展一些比较基础的jar包,让启动类加载器加载,有两种途径:
-
放入jre/lib下进行扩展。不推荐,尽可能不要去更改JDK安装目录中的内容,会出现即时放进去由于文件名不匹配的问题也不会正常地被加载。
-
使用参数进行扩展。推荐,使用-Xbootclasspath/a:jar包目录/jar包名 进行扩展,参数中的/a代表新增。
如下图,在IDEA配置中添加虚拟机参数,就可以加载D:/jvm/jar/classloader-test.jar
这个jar包了。
2.4.4 扩展类加载器和应用程序类加载器
-
扩展类加载器和应用程序类加载器都是JDK中提供的、使用Java编写的类加载器。
-
它们的源码都位于sun.misc.Launcher中,是一个静态内部类。继承自URLClassLoader。具备通过目录或者指定jar包将字节码文件加载到内存中。
继承关系图如下:
-
ClassLoader类定义了具体的行为模式,简单来说就是先从本地或者网络获得字节码信息,然后调用虚拟机底层的方法创建方法区和堆上的对象。这样的好处就是让子类只需要去实现如何获取字节码信息这部分代码。
-
SecureClassLoader提供了证书机制,提升了安全性。
-
URLClassLoader提供了根据URL获取目录下或者指定jar包进行加载,获取字节码的数据。
-
扩展类加载器和应用程序类加载器继承自URLClassLoader,获得了上述的三种能力。
扩展类加载器
扩展类加载器(Extension Class Loader)是JDK中提供的、使用Java编写的类加载器。默认加载Java安装目录/jre/lib/ext下的类文件。
如下代码会打印ScriptEnvironment类的类加载器。ScriptEnvironment是nashorn框架中用来运行javascript语言代码的环境类,他位于nashorn.jar包中被扩展类加载器加载
/** * 扩展类加载器 */ public class ExtClassLoaderDemo { public static void main(String[] args) throws IOException { ClassLoader classLoader = ScriptEnvironment.class.getClassLoader(); System.out.println(classLoader); } }
打印结果如下:
通过扩展类加载器去加载用户jar包:
-
放入/jre/lib/ext下进行扩展。不推荐,尽可能不要去更改JDK安装目录中的内容。
-
使用参数进行扩展使用参数进行扩展。推荐,使用-Djava.ext.dirs=jar包目录 进行扩展,这种方式会覆盖掉原始目录,可以用;(windows):(macos/linux)追加上原始目录
如下图中:
使用引号
将整个地址包裹起来,这样路径中即便是有空格也不需要额外处理。路径中要包含原来ext文件夹,同时在最后加上扩展的路径。
应用程序加载器
应用程序类加载器会加载classpath下的类文件,默认加载的是项目中的类以及通过maven引入的第三方jar包中的类。
如下案例中,打印出Student
和FileUtils
的类加载器:
/** * 应用程序类加载器案例 */ public class AppClassLoaderDemo { public static void main(String[] args) throws IOException, InterruptedException { //当前项目中创建的Student类 Student student = new Student(); ClassLoader classLoader = Student.class.getClassLoader(); System.out.println(classLoader); //maven依赖中包含的类 ClassLoader classLoader1 = FileUtils.class.getClassLoader(); System.out.println(classLoader1); Thread.sleep(1000); System.in.read(); } }
输出结果如下:
这两个类均由应用程序类加载器加载。
类加载器的加载路径可以通过classloader –c hash值 查看:
2.5、双亲委派机制
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会自底向上查找是否加载过,
再由顶向下进行加载。
详细流程:
每个类加载器都有一个父类加载器。父类加载器的关系如下,启动类加载器没有父类加载器:
在类加载的过程中,每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回,否则会将加载请求委派给父类加载器。
案例1:
比如com.itheima.my.A假设在启动类加载器的加载目录中,而应用程序类加载器接到了加载类的任务。
1、应用程序类加载器首先判断自己加载过没有,没有加载过就交给父类加载器 - 扩展类加载器。
2、扩展类加载器也没加载过,交给他的父类加载器 - 启动类加载器。
3、启动类加载器发现已经加载过,直接返回。
案例2:
B类在扩展类加载器加载路径中,同样应用程序类加载器接到了加载任务,按照案例1中的方式一层一层向上查找,发现都没有加载过。那么启动类加载器会首先尝试加载。它发现这类不在它的加载目录中,向下传递给扩展类加载器。
扩展类加载器发现这个类在它加载路径中,加载成功并返回。
如果第二次再接收到加载任务,同样地向上查找。扩展类加载器发现已经加载过,就可以返回了。
双亲委派机制的作用
1.保证类加载的安全性。通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。
2.避免重复加载。双亲委派机制可以避免同一个类被多次加载。
如何指定加载类的类加载器?
在Java中如何使用代码的方式去主动加载一个类呢?
方式1:使用Class.forName方法,使用当前类的类加载器去加载指定的类。
方式2:获取到类加载器,通过类加载器的loadClass方法指定某个类加载器加载。
例如:
三个面试题
1、如果一个类重复出现在三个类加载器的加载位置,应该由谁来加载?
启动类加载器加载,根据双亲委派机制,它的优先级是最高的
2、String类能覆盖吗,在自己的项目中去创建一个java.lang.String类,会被加载吗?
不能,会返回启动类加载器加载在rt.jar包中的String类。
3、类的双亲委派机制是什么?
-
当一个类加载器去加载某个类的时候,会自底向上查找是否加载过,如果加载过就直接返回,如果一直到最顶层的类加载器都没有加载,再由顶向下进行加载。
-
应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器。
-
双亲委派机制的好处有两点:第一是避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。第二是避免一个类重复地被加载。
2.6、打破双亲委派机制
打破双亲委派机制历史上有三种方式,但本质上只有第一种算是真正的打破了双亲委派机制:
-
自定义类加载器并且重写loadClass方法。Tomcat通过这种方式实现应用之间类隔离,《面试篇》中分享它的做法。
-
线程上下文类加载器。利用上下文类加载器加载类,比如JDBC和JNDI等。
-
Osgi框架的类加载器。历史上Osgi框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载,目前很少使用。
自定义类加载器
一个Tomcat程序中是可以运行多个Web应用的,如果这两个应用中出现了相同限定名的类,比如Servlet类,Tomcat要保证这两个类都能加载并且它们应该是不同的类。如果不打破双亲委派机制,当应用类加载器加载Web应用1中的MyServlet之后,Web应用2中相同限定名的MyServlet类就无法被加载了。
Tomcat使用了自定义类加载器来实现应用之间类的隔离。 每一个应用会有一个独立的类加载器加载对应的类。
那么自定义加载器是如何能做到的呢?首先我们需要先了解,双亲委派机制的代码到底在哪里,接下来只需要把这段代码消除即可。
ClassLoader中包含了4个核心方法,双亲委派机制的核心代码就位于loadClass方法中。
public Class<?> loadClass(String name) 类加载的入口,提供了双亲委派机制。内部会调用findClass 重要 protected Class<?> findClass(String name) 由类加载器子类实现,获取二进制数据调用defineClass ,比如URLClassLoader会根据文件路径去获取类文件中的二进制数据。重要 protected final Class<?> defineClass(String name, byte[] b, int off, int len) 做一些类名的校验,然后调用虚拟机底层的方法将字节码信息加载到虚拟机内存中 protected final void resolveClass(Class<?> c) 执行类生命周期中的连接阶段
1、入口方法:
2、再进入看下:
如果查找都失败,进入加载阶段,首先会由启动类加载器加载,这段代码在findBootstrapClassOrNull
中。如果失败会抛出异常,接下来执行下面这段代码:
父类加载器加载失败就会抛出异常,回到子类加载器的这段代码,这样就实现了加载并向下传递。
3、最后根据传入的参数判断是否进入连接阶段:
接下来实现打破双亲委派机制:
package classloader.broken;//package com.itheima.jvm.chapter02.classloader.broken; import org.apache.commons.io.IOUtils; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.security.ProtectionDomain; import java.util.regex.Matcher; /** * 打破双亲委派机制 - 自定义类加载器 */ public class BreakClassLoader1 extends ClassLoader { private String basePath; private final static String FILE_EXT = ".class"; //设置加载目录 public void setBasePath(String basePath) { this.basePath = basePath; } //使用commons io 从指定目录下加载文件 private byte[] loadClassData(String name) { try { String tempName = name.replaceAll("\\.", Matcher.quoteReplacement(File.separator)); FileInputStream fis = new FileInputStream(basePath + tempName + FILE_EXT); try { return IOUtils.toByteArray(fis); } finally { IOUtils.closeQuietly(fis); } } catch (Exception e) { System.out.println("自定义类加载器加载失败,错误原因:" + e.getMessage()); return null; } } //重写loadClass方法 @Override public Class<?> loadClass(String name) throws ClassNotFoundException { //如果是java包下,还是走双亲委派机制 if(name.startsWith("java.")){ return super.loadClass(name); } //从磁盘中指定目录下加载 byte[] data = loadClassData(name); //调用虚拟机底层方法,方法区和堆区创建对象 return defineClass(name, data, 0, data.length); } public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException { //第一个自定义类加载器对象 BreakClassLoader1 classLoader1 = new BreakClassLoader1(); classLoader1.setBasePath("D:\\lib\\"); Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A"); //第二个自定义类加载器对象 BreakClassLoader1 classLoader2 = new BreakClassLoader1(); classLoader2.setBasePath("D:\\lib\\"); Class<?> clazz2 = classLoader2.loadClass("com.itheima.my.A"); System.out.println(clazz1 == clazz2); Thread.currentThread().setContextClassLoader(classLoader1); System.out.println(Thread.currentThread().getContextClassLoader()); System.in.read(); } }
自定义类加载器父类怎么是AppClassLoader呢?
默认情况下自定义类加载器的父类加载器是应用程序类加载器:
以Jdk8为例,ClassLoader类中提供了构造方法设置parent的内容:
这个构造方法由另外一个构造方法调用,其中父类加载器由getSystemClassLoader方法设置,该方法返回的是AppClassLoader。
两个自定义类加载器加载相同限定名的类,不会冲突吗?
不会冲突,在同一个Java虚拟机中,只有相同类加载器+相同的类限定名才会被认为是同一个类。
在Arthas中使用sc –d 类名的方式查看具体的情况。
如下代码:
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException { //第一个自定义类加载器对象 BreakClassLoader1 classLoader1 = new BreakClassLoader1(); classLoader1.setBasePath("D:\\lib\\"); Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A"); //第二个自定义类加载器对象 BreakClassLoader1 classLoader2 = new BreakClassLoader1(); classLoader2.setBasePath("D:\\lib\\"); Class<?> clazz2 = classLoader2.loadClass("com.itheima.my.A"); System.out.println(clazz1 == clazz2); }
打印的应该是false,因为两个类加载器不同,尽管加载的是同一个类名,最终Class对象也不是相同的。
通过Arthas看:
也会出现两个不同的A类。
线程上下文类加载器
利用上下文类加载器加载类,比如JDBC和JNDI等。
我们来看下JDBC的案例:
1、JDBC中使用了DriverManager来管理项目中引入的不同数据库的驱动,比如mysql驱动、oracle驱动。
package classloader.broken;//package com.itheima.jvm.chapter02.classloader.broken; import com.mysql.cj.jdbc.Driver; import java.sql.*; /** * 打破双亲委派机制 - JDBC案例 */ public class JDBCExample { // JDBC driver name and database URL static final String JDBC_DRIVER = "com.mysql.cj.jdbc.Driver"; static final String DB_URL = "jdbc:mysql:///bank1"; // Database credentials static final String USER = "root"; static final String PASS = "123456"; public static void main(String[] args) { Connection conn = null; Statement stmt = null; try { conn = DriverManager.getConnection(DB_URL, USER, PASS); stmt = conn.createStatement(); String sql; sql = "SELECT id, account_name FROM account_info"; ResultSet rs = stmt.executeQuery(sql); //STEP 4: Extract data from result set while (rs.next()) { //Retrieve by column name int id = rs.getInt("id"); String name = rs.getString("account_name"); //Display values System.out.print("ID: " + id); System.out.print(", Name: " + name + "\n"); } //STEP 5: Clean-up environment rs.close(); stmt.close(); conn.close(); } catch (SQLException se) { //Handle errors for JDBC se.printStackTrace(); } catch (Exception e) { //Handle errors for Class.forName e.printStackTrace(); } finally { //finally block used to close resources try { if (stmt != null) stmt.close(); } catch (SQLException se2) { }// nothing we can do try { if (conn != null) conn.close(); } catch (SQLException se) { se.printStackTrace(); }//end finally try }//end try }//end main }//end FirstExample
2、DriverManager类位于rt.jar包中,由启动类加载器加载。
3、依赖中的mysql驱动对应的类,由应用程序类加载器来加载。
在类中有初始化代码:
DriverManager属于rt.jar是启动类加载器加载的。而用户jar包中的驱动需要由应用类加载器加载,这就违反了双亲委派机制。(这点存疑,一会儿再讨论)
那么问题来了,DriverManager怎么知道jar包中要加载的驱动在哪儿?
1、在类的初始化代码中有这么一个方法LoadInitialDrivers
:
2、这里使用了SPI机制,去加载所有jar包中实现了Driver接口的实现类。
3、SPI机制就是在这个位置下存放了一个文件,文件名是接口名,文件里包含了实现类的类名。这样SPI机制就可以找到实现类了。
4、SPI中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象。
总结:
JDBC案例中真的打破了双亲委派机制吗?
最早这个论点提出是在周志明《深入理解Java虚拟机》中,他认为打破了双亲委派机制,这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,所以打破了双亲委派机制。
但是如果我们分别从DriverManager以及驱动类的加载流程上分析,JDBC只是在DriverManager加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制。
所以我认为这里没有打破双亲委派机制,只是用一种巧妙的方法让启动类加载器加载的类,去引发的其他类的加载。
Osgi框架的类加载器
历史上,OSGi模块化框架。它存在同级之间的类加载器的委托加载。OSGi还使用类加载器实现了热部署的功能。热部署指的是在服务不停止的情况下,动态地更新字节码文件到内存中。
由于这种机制使用已经不多,所以不再过多讨论OSGi,着重来看下热部署在实际项目中的应用。
案例:使用阿里arthas不停机解决线上问题
背景:
小李的团队将代码上线之后,发现存在一个小bug,但是用户急着使用,如果重新打包再发布需要一个多小时的时间,所以希望能使用arthas尽快的将这个问题修复。
思路:
-
在出问题的服务器上部署一个 arthas,并启动。
-
jad --source-only 类全限定名 > 目录/文件名.java jad 命令反编译,然后可以用其它编译器,比如 vim 来修改源码
-
mc –c 类加载器的hashcode 目录/文件名.java -d 输出目录
mc 命令用来编译修改过的代码
-
retransform class文件所在目录/xxx.class
用 retransform 命令加载新的字节码
详细流程:
1、这段代码编写有误,在枚举中的类型判断上使用了==
而不是equals
。
2、枚举中是这样定义的,1001是普通用户,1002是VIP用户:
3、由于代码有误,导致传递1001参数时,返回的是收费用户的内容。
4、jad --source-only 类全限定名 > 目录/文件名.java
使用 jad 命令反编译,然后可以用其它编译器,比如 vim 来修改源码
这里直接双击文件使用finalShell编辑:
5、mc –c 类加载器的hashcode 目录/文件名.java -d 输出目录
使用mc 命令用来编译修改过的代码
6、retransform class文件所在目录/xxx.class
用 retransform 命令加载新的字节码
7、测试:
注意事项:
1、程序重启之后,字节码文件会恢复,除非将class文件放入jar包中进行更新。
2、使用retransform不能添加方法或者字段,也不能更新正在执行中的方法。
2.7、JDK9之后的类加载器
JDK8及之前的版本中,扩展类加载器和应用程序类加载器的源码位于rt.jar包中的sun.misc.Launcher.java。
由于JDK9引入了module的概念,类加载器在设计上发生了很多变化。
1.启动类加载器使用Java编写,位于jdk.internal.loader.ClassLoaders类中。
Java中的BootClassLoader继承自BuiltinClassLoader实现从模块中找到要加载的字节码资源文件。
启动类加载器依然无法通过java代码获取到,返回的仍然是null,保持了统一。
2、扩展类加载器被替换成了平台类加载器(Platform Class Loader)。
平台类加载器遵循模块化方式加载字节码文件,所以继承关系从URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑。