1.引言
jvm是深入了解java底层逻辑的必备知识储备,在中大型开发团队里,中高级工程师必须要了解和掌握,也是中高级工程师面试必考题,在实战中用于程序性能调优,内存泄露分析等
2.jvm概念理解
1.1什么是jvm
Java虚拟机(Java Virtual Machine 简称JVM)是运行所有Java程序的抽象计算机,是Java语言的运行环境,它是Java 最具吸引力的特性之一。
Java虚拟机有自己完善的硬件架构,如处理器、堆栈等,还具有相应的指令系统。
Java虚拟机本质上就是一个程序,当它在命令行上启动的时候,就开始执行保存在某字节码文件中的指令。Java语言的可移植性正是建立在Java虚拟机的基础上。任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class)就可以在该平台上运行。这就是“一次编译,多次运行”。
Java虚拟机不仅是一种跨平台的软件,而且是一种新的网络计算平台。该平台包括许多相关的技术,如符合开放接口标准的各种API、优化技术等。Java技术使同一种应用可以运行在不同的平台上。Java平台可分为两部分,即Java虚拟机(Java virtual machine,JVM)和Java API类库。
1.2 jvm功能
- 解释和运行
对文件字节码里的指令,解释成机器语言,让计算机执行
- 2.内存管理
自动为对象、方法分配内存
自动的垃圾回收机制,回收不在使用的对象
- 3.即时编译
jvm为取得跨平台的效果,在程序每次运行时,必须把编译文件class翻译成机器指令,称之为实时翻译,而c/c++等语言是在编译
就就转化成机器指令,这个使java的自己执行效率天生不如c/c++等语言.
java采用了即使编译的技术方案,对热点代码进行优化,提升执行效率。
即时编译
(英语:just-in-time compilation,缩写为JIT;又译及时编译、实时编译),也称为动态翻译或运行时编译,是一种执行计算机代码的方法,这种方法涉及在程序执行过程中(在运行期)而不是在执行之前进行编译。
热点代码
如果java发现一段代码反复出现,就会判断为热点代码,jvm会把这段代码编译成机器指令保存在内存里,下次操作时会直接调用内存里的代码,而不需要再转化成机器代码再执行,从而提高了执行效率
1.3 jvm规范及主流版本
-
JVM虚拟机规范由oracle制定,主要包含了java虚拟机再涉及和实现时需要准寻的规范,主要包含class字节码文件的定义、类和接口的加载和初始化、指令集等
-
规范时对虚拟机设计的要求,而而不是对java设计的要求,也就是说虚拟机可以运行再其他语言比比如Groovy,Scala生成的class字节码文件之上
参考网址
https://docs.oracle.com/javase/specs/index.html
主流jvm版本
jvm版本发展历程
jvm有很多版本,我们一般使用HotSpot版本
1.4 jre jdk jvm的区别和联系
三者的大致结构是这样的,简单来说就是JDK包含JRE,JRE又包含JVM的关系。如下图所示:
-
JDK
JDK是整个JAVA的核心,包括了Java运行环境JRE(Java Runtime Envirnment)、一堆Java开发工具(javac/java/jdb等)和Java基础的类库(即Java API 包)。 -
JRE
JRE:Java Runtime Environment,是java运行时的环境,包含了java虚拟机,java基础类库,是使用java语言编写的程序运行所需要的软件环境。
JRE:Java runtime environment 是运行基于Java语言编写的程序所不可缺少的运行环境,用于解释执行Java的字节码文件。
通过它,Java的开发者才得以将自己开发的程序发布到用户手中,让用户使用。
JRE中包含了Java virtual machine(JVM),runtime class libraries和Java application launcher,这些是运行Java程序的必要组件。
与大家熟知的JDK不同,JRE是Java运行环境,并不是一个开发环境,所以没有包含任何开发工具(如编译器和调试器),只是针对于使用Java程序的用户
上图是Java中JRE的安装目录,里面有两个文件夹bin和lib。你可以认为bin里的就是JVM,lib中则是JVM工作所需要的类库,而JVM和 lib和起来就称为JRE;
- JVM
JVM:Java Virtual Machine 是Java的虚拟机,是JRE的一部分。它是整个java实现跨平台的最核心的部分,负责解释执行字节码文件,是可运行java字节码文件的虚拟计算机。
所有平台的上的JVM向编译器提供相同的接口,而编译器只需要面向虚拟机,生成虚拟机能识别的代码,然后由虚拟机来解释执行。
1.5 jvm组成
jvm由以下四部分组成:
类加载器(ClassLoader)
运行时数据区(Runtime Data Area)
执行引擎(Execution Engine)
本地库接口(Native Interface)
组成说明
1.程序在执行之前先要把 java 代码转换成字节码(class 文件)
2.jvm 首先需要把字节码通过一定的方式 类加载器(ClassLoader) 把文件加载到内存中的运行时数据区(Runtime Data Area) ,而字节码文件是 jvm 的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器 执行引擎(Execution Engine) 将字节码翻译成底层系统指令再交由CPU 去执行,而这个过程中需要调用其他语言的接口 本地库接口(NativeInterface) 来实现整个程序的功能,这就是这 4 个主要组成部分的职责与功能。
而我们通常所说的 JVM 组成指的是 运行时数据区(Runtime Data Area) ,因为通常需要程序员调试分析的区域就是“运行时数据区”,或者更具体的来说就是“运行时数据区”里面的 Heap(堆)模块。
jvm整体框架
2.jvm-字节码文件class
jvm管理的是编译好的class字节码文件,我们先从字节码文件class了解开始
2.1 java和class无关性
我们通过javac把java文件编译成class文件,但class字节文件不一定全部来自java,java虚拟机jvm还能运用其他语言编程成class的字节文件。
Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode,Class文件语法)是构成平台无关性的基石,也是实现语言无关性的基石。
2.2 字节码应用场景
1.能回答如下算法问题:如
(1)int i=0;i=i++; i的值是多少
(2)int i=1; i +i +=++i +4.4+i; i的值为多少
(3)反射的原理和如何实现的?
2.解决版本冲突问题:
如上诉异常该如何解决
3.明明系统升级了,为什么bug还存在呢?(系统升级问题)
要解答如上问题,需要掌握相关的字节码文件知识。
2.4 字节码文件打开方式
字节码文件是编译好的二进制文件,用普通记事本打开的是乱码,无法阅读,需要借助专业的工具来
1.使用notepad++编辑器打开–点击插件–HEX-Editor(没有该插件的自行下载)–view in HEX,即可以16进制形式查看Clsaa文件。
当然也可以直接使用HEX-Editor软件打开:hex-editor。
打开如下所示
2.字节码分析插件
直接查看代码,不好做直观分析,我们一般采用jclasslib
分析工具,提供单独安装版本和idea插件
我们可以使用idea插件,再插件库里查询并安装
安装好后,我们选中编译好的class文件,在idea窗口view->show Bytecode With Jclasslib
打开界面如下:
2.3 字节码文件组成
字节码文件由以下五部分组成
这里比较重要的分析数据:一般信息、常量池、方法 3部分
2.3.1 一般信息
包含:魔数、字节码文件对应的java版本号、访问表示标识(public final)父类和接口
(1)组成部分:魔术-magic
魔术在 Jclasslib分析面板看不到,我们用notepad++打开文件
二进制文件并不是通过后缀名如.jpg,.avi,.class来确定文件类型,因为文件后缀名可以任意修改的,一般采用开头的自己字节来表示文件类型,执行二进制文件的软件检查头几个字节是否是规定的常量,如果不是,则会报错。
常见二进制文件字节
可以看出,class字节文件的头4个字节数是cafebabe
,我们打开所有class文件的开头的二进制代码都是这个。
这就是魔术-megic的作用。
尝试:
把.png文件后缀改成.avi,用视频浏播放器播放,看是否会提示出错。
(2)版本号
注意:当前流行是用maven来编译,这里编译是配置在maven里的编译版本,而不是idea里面的版本,那个是运行版本。
maven在setting.xml文件里面的编译配置
<profiles>
<!--
<profile>
<id>jdk18</id>
<activation>
<jdk>1.8</jdk>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<maven.compiler.compilerVersion>1.8</maven.compiler.compilerVersion>
</properties>
</profile>
-->
<profile>
<id>jdk17</id>
<activation>
<activeByDefault>true</activeByDefault>
<jdk>17</jdk>
</activation>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<maven.compiler.compilerVersion>17</maven.compiler.compilerVersion>
<encoding>UTF-8</encoding>
</properties>
</profile>
版本号表示编程class使用的jdk版本,这里有个算法: 显示的版本号-44=jdk的版本号
这里52-44=18,对应的版本号就是1.8。(jdk1.2==46,后面每增加一个小版本,版本号就+1)
如果编译的版本和运行的版本不一致,尤其是高版本编译,低版本的运行环境,就会报错
我们在实际开发中,有两种常见场景:
1.我们开发时用的是高版本jdk,但服务器上用的是低版本,则会报错
2.我们引用第三方中间件jar编译的是高版本,但我们开发环境用的是低版本,也会报错:
例如以下:
查看jar包里的RandomStringUtils.class文件,其版本号为52,即编译版本是1.8,但我们开发环境是jdk1.6(对应50),运行程序时就会报上面错误。
解决方案:
1.升级运行环境的jre版本号,如上升级成1.8
2.第三方降低版本号,降级为1.6
一般选中第二种方案,因为第一种方案需要整体升级,有些不可控风险
3.常量计数池
编译后的常量池里的常量个数,注意不是类里面的常量个数
4.本类索引、父类索引
描述类索引对应的常量
5 访问标志
就是对应的类的修饰符,如示例是public
5.接口计数
此类实现了的接口数
6.字段计数
字节码对应类的字段数
7.方法计数
字节码对应类的方法数,注意:每个编译好后的类
注意如果没有默认构造函数,都要自动创建一个无参的构造函数
8.属性计数
这里属性不是类的字段,而是编译码文件里类的信息,如果有内部类等,则计数会加1
2.3.2 常量池
常量池保存了字符串常量、类或者接口名、字段名等数据,主要在字节码指令中使用
- 常量池里面都有一个编号,从1开始,在字段或字节码指令中可以快速通过编号找到对应的数据
- 字节码指令通过编号引用常量池的过程叫符号引用
常量池的作用:
内容相同的常量,在常量池里只有一个,不同的常量都指向这个值,这样就节约了空间
一个常量类型在常量池里的映射
常量值
可以看见:
常量的名字、描述符(就是类型)、修饰符(public/private )、值
都在常量池里有映射
2.3.3 方法
当前类或者接口里方法的直接码指令
我们用一个常见的面试题目示例来讲解,这里打印出来i=0还是1?
public class Main {
public static void main(String[] args) {
int i = 0;
int i = i++;
System.out.println(i);
}
}
查看编译后的方法字节码指令,字节码文件中的方法区域是存放直接吗指令的核心位置,直接吗指令的内容是放在方法的Code属性里
字节码指令解读
我们通过jclasslib可以关联指令的解读规范,点击进去,可以看到对指令的解释
规范解释
常见指令
指令 | 解释 |
---|---|
iconst_<i> | Push the int constant (-1, 0, 1, 2, 3, 4 or 5) onto the operand stack. 把一个int常量 i 放入操作堆栈里 |
操作堆栈 operand stack和局部变量表
操作数栈是临时存放数据的地方,局部变量表是存放方法中局部变量的位置。
操作数栈作用是用于加载和计算,是一个临时数据区间
局部变量表是存放方法里的变量,变量由序号索引来指向
局部变量
如图这个方法内部变量有2个,i和j,在我们局部变量表里有三个变量对应,this,i,j 。他们对应的序号是0,1,2注意这个序号,我们在字节指令中会用到。
我们观看上述方法对应的字节指令
java源码:
public void operatorStack(){
int i=5;
int j=i+1;
}
解释说明:
1. -iconstant_5
:把常量5放入到堆栈里;
2. istroe_1
:把堆栈里的数据(就是5)存储到序号1对应的局部变量里,就是源码的i=5,操作堆栈清空
3. iload_1
:把局部变量里序号为1的数据copy一份到堆栈里(就是5)。
4. iconst_1
:把常量1放入堆栈
5. iadd
:把堆栈里的数据相加,就是5+1,得到数据6
6. istore_2
:把堆栈里的值,存储到局部变量表里序号为2的变量里,就是源码j=6
7. return
:退出方法
我们比较两段代码,比较i++和++i的指令区别
public static void m1(String[] args) {
int i=0;
i=i++;
System.out.println(i); //0
}
局部变量表里i的序号为1
字节指令
0 iconst_0
1 istore_1
2 iload_1
3 iinc 1 by 1
6 istore_1
解释说明:
1. -iconstant_0
:把常量0放入到堆栈里;
2. istroe_1
:把堆栈里的数据(就是0)存储到序号1对应的局部变量里,就是源码的i=0,操作堆栈清空
3. iload_1
:把局部变量里序号为1的数据copy一份到堆栈里(就是0)。
4. iinc 1 by 1
: 这这个指令的意思是把局部变量表序号为1的变量加1,就是局部变量表里i=1
5. istore_1
:把堆栈里的值(0)存储到局部变量表里序号为1的变量(i的值从1变成了0)
看++i的字节指令
1.代码
public static void m2(String[] args) {
int i=0;
i=++i;
System.out.println(i); //1
}
局部变量,i的序号为1
字节指令
0 iconst_0
1 istore_1
2 iinc 1 by 1
5 iload_1
6 istore_1
解释说明:
1. -iconstant_0
:把常量0放入到堆栈里;
2. istroe_1
:把堆栈里的数据(就是0)存储到序号1对应的局部变量里,就是源码的i=0,操作堆栈清空
3. iinc 1 by 1
: 这这个指令的意思是把局部变量表序号为1的变量加1,就是局部变量表里i=1
4. iload_1
:把局部变量里序号为1的数据copy一份到堆栈里(就是1)。
5. istore_1
:把堆栈里的值(1)存储到局部变量表里序号为1的变量(i的值从1变成了1)
总结:
i++与++i的区别就是
iinc 1 by 1
位置不同,++i先执行iinc 1 by 1
,就是先把局部变量i变成1,copy到堆栈里,此时堆栈为1,再存储到局部变量i里,所以打印出来I的值为1,而不是0
一些常规jvm指令语法
理解指令模型
一般方法体都是由于 入参、出参、方法体组成。
- 局部变量表会把
整个类(this)、入参、出参、方法体里定义的变量,都放入局部变量表里,并根据序号索引来指定。
public String constantStack(String id){
a=100;
int a1=1;
int a2=10;
double d1=3.4;
float f1=3.44f;
Integer i=5;
long l=333344L;
String s="a";
Person person=new Person();
String ret=id+"id"+person.getName();
return ret;
}
局部变量表
- 操作堆栈是jvm为类运行提供的一个临时内存区,是一个虚拟容器,把相关的数据从常量池或者局部变量表里加载进来,通过直接指令进行运算,并把相关的值传递给相关变量,是一个
in-执行指令-out
的过程
数据加载指令:数据和变量如何进入操作堆栈
(1)常量入栈
x
const_值
:iconst_v lconst_v,fconst_v,dconst_v
数字常量入栈bipush、sipush
: 大数据和带符号的入栈ldc 、ldc_w 、ldc2_w
: 常量池数据入栈
aconst_null 将null对象引用压入栈
int类型入栈
iconst_m1 将int类型常量-1压入栈
iconst_0 将int类型常量0压入栈
long类型入栈
lconst_0 将long类型常量0压入栈
lconst_1 将long类型常量1压入栈
float类型入栈
fconst_0 将float类型常量0压入栈
fconst_1 将float类型常量1压入栈
double类型入栈
dconst_0 将double类型常量0压入栈
dconst_1 将double类型常量1压入栈
带符号int类型入栈
bipush 将一个8位带符号整数压入栈
sipush 将16位带符号整数压入栈
常量池数据入栈
ldc 把常量池中的项压入栈
ldc_w 把常量池中的项压入栈(使用宽索引)
ldc2_w 把常量池中long类型或者double类型的项压入栈(使用宽索引)
(2).从栈中的局部变量中装载值的指令
x
load_局部变量表索引
:x代表数据类型,后缀是局部变量表的对应序号
iload 从局部变量中装载int类型值
lload 从局部变量中装载long类型值
fload 从局部变量中装载float类型值
dload 从局部变量中装载double类型值
aload 从局部变量中装载引用类型值(refernce)
iload_0 从局部变量0中装载int类型值
iload_1 从局部变量1中装载int类型值
iload_2 从局部变量2中装载int类型值
lload_0 从局部变量0中装载long类型值
lload_1 从局部变量1中装载long类型值
lload_2 从局部变量2中装载long类型值
fload_0 从局部变量0中装载float类型值
fload_1 从局部变量1中装载float类型值
fload_2 从局部变量2中装载float类型值
dload_0 从局部变量0中装载double类型值
dload_1 从局部变量1中装载double类型值
dload_2 从局部变量2中装载double类型值
aload_0 从局部变量0中装载引用类型值
aload_1 从局部变量1中装载引用类型值
aload_2 从局部变量2中装载引用类型值
iaload 从数组中装载int类型值
laload 从数组中装载long类型值
faload 从数组中装载float类型值
daload 从数组中装载double类型值
aaload 从数组中装载引用类型值
baload 从数组中装载byte类型或boolean类型值
caload 从数组中装载char类型值
saload 从数组中装载short类型值
(3)将栈中的值存入局部变量的指令
x
store_局部变量表索引
:x代表数据类型,后缀是局部变量表的对应序号
store 将int类型值存入局部变量
lstore 将long类型值存入局部变量
fstore 将float类型值存入局部变量
dstore 将double类型值存入局部变量
astore 将引用类型或returnAddress类型值存入局部变量
istore_0 将int类型值存入局部变量0
istore_1 将int类型值存入局部变量1
istore_2 将int类型值存入局部变量2
lstore_0 将long类型值存入局部变量0
lstore_1 将long类型值存入局部变量1
lstore_2 将long类型值存入局部变量2
fstore_0 将float类型值存入局部变量0
fstore_1 将float类型值存入局部变量1
fstore_2 将float类型值存入局部变量2
dstore_0 将double类型值存入局部变量0
dstore_1 将double类型值存入局部变量1
dstore_2 将double类型值存入局部变量2
astore_0 将引用类型或returnAddress类型值存入局部变量0
astore_1 将引用类型或returnAddress类型值存入局部变量1
astore_2 将引用类型或returnAddress类型值存入局部变量2
iastore 将int类型值存入数组中
lastore 将long类型值存入数组中
fastore 将float类型值存入数组中
dastore 将double类型值存入数组中
aastore 将引用类型值存入数组中
bastore 将byte类型或者boolean类型值存入数组中
castore 将char类型值存入数组中
sastore 将short类型值存入数组中
例子
private int a;
public static final int SEX=2;
public String constantStack(String id){
a=100;
int a1=1;
int a2=10;
double d1=3.4;
float f1=3.44f;
Integer i=5;
long l=333344L;
String s="a";
Person person=new Person();
String ret=id+"id"+person.getName();
return ret;
}
字节码指令
0 aload_0
1 bipush 100
3 putfield #21 <org/jsoft/Main.a : I>
6 iconst_1
7 istore_2
8 bipush 10
10 istore_3
11 ldc2_w #25 <3.4>
14 dstore 4
16 ldc #27 <3.44>
18 fstore 6
20 iconst_5
21 invokestatic #28 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
24 astore 7
26 ldc2_w #34 <333344>
29 lstore 8
31 ldc #36 <a>
33 astore 10
35 new #37 <org/jsoft/Person>
38 dup
39 invokespecial #39 <org/jsoft/Person.<init> : ()V>
42 astore 11
44 aload_1
45 aload 11
47 invokevirtual #40 <org/jsoft/Person.getName : ()Ljava/lang/String;>
50 invokedynamic #44 <makeConcatWithConstants, BootstrapMethods #0>
55 astore 12
57 aload 12
59 areturn
2.3.4 字段
字段是指类类的字段
public class Main {
private int a = 30;
public static final int SEX = 2;
}
其4个属性:名字、描述符在常量池定义
如果是常量,还有值
2.3.5 属性
这个不是类的属性,而是收集了类名,内部类等信息
2.4 字节码工具
2.4.1 Javap-v 命令
javap是jdk自带的反编译工具,可以通过控制台产看字节码文件的内容。
为什么不用我们上面的jclasslib工具呢?
javap的应用场景是在服务器上
,我们一般部署的都是jar文件,我们想直接看jar包里面的一个class字节文件的内容,用javap-v简单方便
操作步骤:
- 如果是jar包,使用jar -xvf命令解压
- javap -v class所在的全路径/xx.class,则直接文件内容会在屏幕上展示
- 一般情况下字节码类文件会非常多,直接在屏幕上展示查阅不方便,可以输入到文件
javap -v xx/xx.class > /opt/xx.txt
,这样我们就可以用文件查阅工具或者下载到本地查阅字节码文件内容
2.4.2 jclasslib插件
jclasslib
可以安装到本地或者idea插件,他的使用场景是本地开发模式
中查看字节码文件内容,我们上文已经说了,这里不在描述.
2.4.3 阿里arthas
1.athas介绍
Arthas(阿尔萨斯)是阿里巴巴开源的 Java 诊断工具,深受开发者喜爱。
当你遇到以下类似问题而束手无策时,Arthas 可以帮助你解决:
- 这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?
- 我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?
- 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
- 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
- 是否有一个全局视角来查看系统的运行状况?
- 有什么办法可以监控到 JVM 的实时运行状态?
Arthas官方网址:
https://arthas.aliyun.com
2.应用场景
3 启动监控
把arthas-boot.jar文件下载到本地
dos执行
java -jar arthas-boot.jar
会显示正在运行的进程,我们可以输出进程编号,就可以进入。
我们写一个演示代码:
package org.jsoft;
public class MainThead {
public static void main(String[] args) throws InterruptedException {
while(true){
System.out.println("aaaa");
Thread.sleep(1000L);
}
}
}
打印界面如下:
我们输入1 进入进程
4. jvm监控面板 dashboard
dashboard -i 刷新间隔时间(默认5000) -n 刷新次数
ctrl+c 退出监控面板
我们执行
dashboard -i 2000 -n 1
5.查看字节码文件-dump
dump 已加载类的 bytecode 到特定目录
dump 命令将 JVM 中实际运行的 class 的 byte code dump 到指定目录,适用场景批量下载指定包目录的 class 字节码
参数说明
运行命令:
dump -d D:/jvm/data/ org.jsoft.MainThead
MainThead的class文件就下载到data目录
6 反编译-jad
jad --source-only org.jsoft.MainThead
直接查看反编译的源码
jad的应用场景
我们修复了一个bug,但发布后,问题依然存在,我们怀疑线上的代码不是最新代码,可以用此功能进行验证
3.类生命周期
1.类的生命周期指一个类的
加载、使用、卸载
的整个过程
2.类的生命周期可以拆解成"
加载、连接、初始化、使用、卸载
"五个阶段,其中连接有可分为**验证、准备、解析
**三个步骤,如图所示
3.类的生命周期应用场景
通过对类的生命周期理解,我们可以
(1)类的加载是常量池的理解
(2)对类的加载器作用理解
(3)多态是java最重要特征,通过类的生命周期可以深刻理解
(4)如何对源代码进行保护,源代码的加解密
3.1 加载-loading
java类的加载是指把class字节码文件加载如jvm内存的过程。
1.class来源:本地、网络、动态生成
1:本地磁盘
2:网络下载.class文件(如Japplet)
3:war,jar下加载.class文件
4:从数据库中读取.class文件(少见,序列化保存)
5:典型的就是动态代理,通过运行期生成class文件(如gclib)
6:我们的jsp会被转换成servlet,而我们的serlvet是一个java文件,会被编译成class文件
类的加载是动态的,它不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(如基类)完全加载到JVM中,至于其他类,则在需要时才加载。类分为3类:核心类、扩展类、自定义类。针对这3种类有3种类型的加载器:Bootstrip ClassLoader、Extension ClassLoader、Application ClassLoader
。
启动类加载器-Bootstrap ClassLoade
负责加载 Java 的核心类( $JAVA_HOME 中 jre/lib/rt.jar 里所有的class),由C++实现的,不是 java.lang.ClassLoader 的子类。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到该类加载器的引用,所以不允许直接通过引用进行操作。
扩展类加载器-Extension ClassLoade
负责加载JRE的扩展目录,lib/ext 或者由 java.ext.dirs 系统属性指定的目录中的JAR包的类。由Java语言实现,父类加载器为null
应用类加载器-Application ClassLoader
也被称为系统类加载器(System ClassLoader),负责在JVM启动时加载来自Java 命令的 -classpath 选项、java.class.path 系统属性,或者 CLASSPATH 环境变量所指定的 JAR 包和类路径。程序可以通过 ClassLoader 的静态方法 getSystemClassLoader() 来获取系统类加载器。如果没有特别指定,则用户自定义的类加载器都以此类加载器作为父类加载器。由Java语言实现,父类加载器为ExtClassLoader。
自定义类加载器(User ClassLoader):
必须继承 java.lang.ClassLoader。
public class Test {
public static void main(String[] args) {
ClassLoader appClassLoader=Main.class.getClassLoader(); //获取AppClassLoader
System.out.println(appClassLoader);
ClassLoader extClassLoader=appClassLoader.getParent(); //获取ExtClassLoader
System.out.println(extClassLoader);
ClassLoader bootClassLoader=extClassLoader.getParent(); //获取BootClassLoader
System.out.println(bootClassLoader);
}
}
打印
jdk.internal.loader.ClassLoaders$AppClassLoader@63947c6b
jdk.internal.loader.ClassLoaders$PlatformClassLoader@7b23ec81
null
User ClassLoader->Application ClassLoader->Extension ClassLoade->Bootstrap ClassLoade
注意:上图是委派关系,不是继承关系。
双亲委派机制工作原理:
如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托
给父类加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托
,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都很懒,每次有活就丢给父亲去干,直到父亲说这件事我也干不了时,儿子自己才想办法去完成
。
双亲委派机制的优势:避免重复加载和安全
采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
jdk9前后的双亲委托区别
jdk1.9实现了模块化,每个模块都有一个类加载器,系统先判断类属于哪个模块,然后用制定模块的加载器直接加载,目的是提升加载性能。
类加载完毕后,加载的类信息会生成两个对象,instanceKclasds和Class
,存放在内存方法区
和堆区
两处,
方法区:
一块称为方法区的内存空间。方法区在jdk1.8后又称之元空间区,类会生成一个instanceKclasds对象,保存类的所有信息,就是我们前面用jclasslib可查看的类的属性、方法、字段等信息。
堆区
一块是堆区,创建的java.lang.Class对象,存储类的字段和方法信息,供我们的反射获得对象的字段和方法使用
public void t1(){
Class<?> cls=Person.class;
Field[] f=cls.getFields();
Method[] m=cls.getMethods();
}
堆区里Class的字段和方法信息在方法区instanceKclasds都有,为什么还需要做一份?
- instanceKclasds是C++编写的,包含所有类的信息,是提供给jvm使用的,开发者不能用java直接访问
- Class专门为开发者开放的,用java写的,开发者可直接访问。
- 数据隔离,也为jvm的安全提供保障
3.2 连接-linking
链接包含三个步骤: 分别是 验证Verification , 准备Preparation , 解析Resolution
三个过程
3.2.1.验证Verificatio-Class文件规范性检查
连接接阶段的第一步,这一阶段的目的是为了确保.class文件的字节流中包含的信息符合当前虚拟机的要求
,并且不会危害虚拟机自身的安全。 Java语言本身是相对安全的语言(相对C/C++来说),但是前面说过,.class文件未必要从Java源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生.class文件。在字节码语言层面上,Java代码至少从语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。 验证阶段将做以下几个工作:文件格式验证 、元数据验证、字节码验证、符号引用验证
演示:
我们把编译好的class文件修改一下前4个字符,然后再运行,将报加载异常
3.2.2.准备阶段-Preparation
准备阶段就是为类的静态变量分配内存,并将其赋默认值
准备节点只会给静态变量赋默认值,基础类型都有默认值,如下
public class Test {
static int i;
public static void main(String[] args)throws ClassNotFoundException {
System.out.println(i);
}
}
打印出来是0,这个0就是在准备阶段赋予静态变量i的
注意:
如果static 被final修饰,则在准备阶段,就会直接把值赋予这个静态变量,而不是赋默认值。
我们我们通过jclasslib可以查看文件的变化
1.当是static int i=2;时,字段并没有值,=2时在下阶段完成的
2.当是final static int i=2时,字段是有值的
为什么final在准备阶段就给他赋予值,而不是默认值呢?
因为final定义的是常量,在后面代码里是不可修改的,所以此处变可赋值
3.2.3 解析阶段-Resolution
解析阶段就是把字节码文件里面的常量池里的符号引用
解析成直接引用
.
前面学习字节码文件时,字节码文件会把大量的信息存储到常量池里,指令里直接引用常量编号来获得数据,这样提升效率,这个叫符号引用
在解析阶段就是把这些符号引用解析成内存里地址直接引用。这样我们获得内存里的地址,就可以直接访问了。这个就称为直接引用
3.2.4 总结
阶段 | 说明 |
---|---|
验证-Verificatio | 验证字节码文件是否符合jvm规范 |
准备阶段-Preparation | 给静态static变量赋予默认初始值 |
解析阶段-Resolution | 把字节码常量池里的符号引用替换成直接引用 |
3.3 初始化-init
类的初始化阶段是类加载过程的最后一个步骤, 之前介绍的几个类加载的动作里, 除了在加载阶 段用户应用程序可以通过自定义类加载器的方式局部参与外, 其余动作都完全由Java虚拟机来主导控 制。 直到初始化阶段, Java虚拟机才真正开始执行类中编写的Java程序代码,
将主导权移交给应用程
为类的静态变量赋初值(只有对类的主动使用才会导致类的初始化)
在编译生成class文件时,编译器会产生两个方法加于class文件中,
一个是类的初始化方法clinit,
另一个是实例的初始化方法init
查看class代码
public class Demo1 {
public static int i=1;
static {
i=2;
}
public static void main(String[] args) {
System.out.println(i);
}
}
gclasslib查看方法,增加了init和clinit两个方法
3.3.1 clinit 类初始化方法
clinit指的是类构造器,主要作用是在类加载过程中的初始化阶段
进行执行,执行内容包括静态变量初始化和静态块
的执行。
1.如果类中没有静态变量或静态代码块,那么clinit方法将不会被生成。
2.在执行clinit方法时,必须先执行父类的clinit方法。
3.clinit方法只执行一次,意思是static{}只会执行一次。
static变量的赋值操作和静态代码块的合并顺序
由源文件中出现的顺序决定
,两者保持一致。
我们通过实际案例来验证上述结论
public class Demo1 {
public static int i=1;
static {
i=2;
}
public static void main(String[] args) {
System.out.println(i);
}
}
clinit对应的方法指令解读
0 iconst_1 #把常量值1压入操作堆栈
1 putstatic #13 <org/jsoft/Demo1.i : I> #把堆栈中值1赋予给#13(就是变量i),此时1=1
4 iconst_2 #把常量值2压入操作堆栈
5 putstatic #13 <org/jsoft/Demo1.i : I> #把堆栈中值2赋予给#13(就是变量i),此时i=2
8 return
putstatic
指令解释:
从操作数栈里获取值给静态变量赋值
如果我们把这段代码屏蔽掉,clinit方法就没有,说明clinit是初始化静态变量和static{}程序块的。
public static int i=1;
static {
i=2;
}
我们把上述代码顺序换成以下:
static {
i=2;
}
public static int i=1;
查看clinit方法指令
iconst_2
1 putstatic #13 <org/jsoft/Demo1.i : I>
4 iconst_1
5 putstatic #13 <org/jsoft/Demo1.i : I>
8 return
发现字节指令顺序也换了,说明“static变量的赋值操作和静态代码块的合并顺序
由源文件中出现的顺序决定
,两者保持一致。”
哪些行为会触发类的初始化?【高频】
- 访问一个类的静态变量或者静态方法,注意:
如果类的变量是final修饰并且等号右边是常量的不会触发初始化【就是访问一个类的常量】
- 用Class.forName()引入类,会触发初始化,ClassLoader.loadClass();不会触发初始化
- new一个类时,会触发初始化
- 执行一个类的main方法,会触发初始化
用以下代码测试
public class DemoX {
static {
System.out.println("触发DemoX初始化");
}
public static int i=10;
public final static int age=100;
public static void test(){
System.out.println("DemoX.test被执行");
}
public static void main(String[] args) {
System.out.println("DemoX.main()执行");
}
}
测试代码
public class Demo2 {
public static void main(String[] args) throws ClassNotFoundException {
// int x=DemoX.i; //触发初始化
// int age=DemoX.age; //不会触发初始化
// Class<?> cls=Class.forName("org.jsoft.DemoX");//触发初始化
// DemoX demoX=new DemoX();//触发初始化
// Demo2 demo=new Demo2();
// demo.t(); //ClassLoader.loadClass()不会触发初始化
}
public void t() throws ClassNotFoundException {
//ClassLoader.loadClass();//不会触发初始化
Class<?> cls2=this.getClass().getClassLoader().loadClass("org.jsoft.DemoX");
}
}
以下不会触发初始化
- 无静态代码块或者无静态变量赋值语句
- 有静态变量声明,但没有对其赋值
- 静态变量使用final修饰,这个变量汇总连接-准备阶段初始化
public class Demo4 {
private static int id;
public final static int age=10;
}
查看jclasslib
无clinit方法产生
loadClass和forName的区别?
Class.forName也是可以对类进行加载的,内部实际调用的方法是 Class.forName(className,true,classloader);
注意它的第二个参数为true,这个参数表示是否进行初始化,默认为true,它会让jvm对指定的类执行加载、连接、初始化
操作。
ClassLoader.loadClass:从上边的加载分析可以知道,它只负责连接,不会进行初始化等
操作,所以static这样的静态代码是不会进行初始化的
public void t() throws ClassNotFoundException {
Class<?> person1=this.getClass().getClassLoader().loadClass("org.jsoft.Person");
Class<?> person2=Class.forName("org.jsoft.Person"); //要执行初始化操作
}
public class Person {
static {
System.out.println("Person==== static init");
}
private String id;
}
clinit初始化的特殊点
- 直接方法父类的静态变量,不会触发子类的初始化
- 子类的初始化clinit调用之前,会先调用父类的初始化
演示代码
public class A01 {
static int i=0;
static {
i=1;
}
}
class A02 extends A01{
static {
i=2;
}
}
测试1:
public class Demo5 {
public static void main(String[] args) {
System.out.println(A02.i);
}
}
输出结果为1;
因为i是父类的静态变量,所以不会触发子类的初始化
测试2:
public class Demo5 {
public static void main(String[] args) {
A02 a02=new A02();
System.out.println(a02.i);
}
}
输出结果为2,因为
A02 实例化触发了A02的初始化,子类的初始化前必去把父类的初始化,所以i一次被赋予0,1,2
3.3.2 init 构造方法
- init指的是实例构造器,主要作用是在
类实例
化过程中执行,执行内容包括非静态成员变量初始化和非静态代码块
+构造函数本身的代码
的执行,构造函数本身代码的优先在后。 - 在执行init方法时,必须先执行父类的init方法。
- init方法每实例化一次就会执行一次。
类编译和new实例化对象流程图
案例演示1
public class DemoA {
private int age=10;
private int id;
{
age=50;
id=100;
System.out.println("age=50--id=100---------");
}
}
编译后。我们用idea打开class文件
public class DemoA {
private int age = 10;
private int id;
public DemoA() {
this.age = 50;
this.id = 100;
System.out.println("age=50--id=100---------");
}
}
用jclasslib打开字节指令,生成了一个init方法
直接指令
0 aload_0 #把this加入操作数栈
1 invokespecial #1 <java/lang/Object.<init> : ()V> #调用构造函数init
4 aload_0 #把this加入操作数栈
5 bipush 10 #把常量10压入操作数栈
7 putfield #7 <org/jsoft/DemoA.age : I> #putfield 把操作栈值10赋给age变量
10 aload_0 #把this加入操作数栈
11 bipush 50 #把常量50压入操作数栈
13 putfield #7 <org/jsoft/DemoA.age : I> #putfield 把操作栈值50赋给age变量
16 aload_0 #把this加入操作数栈
17 bipush 100 #把常量100压入操作数栈
19 putfield #13 <org/jsoft/DemoA.id : I> #putfield 把操作栈值100 赋给id变量
22 getstatic #16 <java/lang/System.out : Ljava/io/PrintStream;> #调用PrintStream
25 ldc #22 <age=50--id=100---------> #打印内容
27 invokevirtual #24 <java/io/PrintStream.println : (Ljava/lang/String;)V>
30 return
查看字节指令,我们可以清晰的看出构造函数数据变化流程。
1.检查局部变量,查看是否有赋初值的成立变量 private int age=10;执行此语句
2.检查是否有{}代码块,有则执行
测试效果
public class Demo3 {
public static void main(String[] args) {
DemoA a=new DemoA();
}
}
打印输出
age=50–id=100---------
演示案例2-有声明的构造函数
public class DemoA {
private int age=10;
private int id;
{
age=50;
id=100;
System.out.println("代码块执行:age=50--id=100---------");
}
public DemoA(){
this.age=200;
this.id=400;
System.out.println("声明式构造函数:age="+age+"--id=100---------");
}
}
idea编译class代码
public class DemoA {
private int age = 10;
private int id;
public DemoA() {
this.age = 50;
this.id = 100;
System.out.println("代码块执行:age=50--id=100---------");
this.age = 200;
this.id = 400;
System.out.println("声明式构造函数:age=" + this.age + "--id=100---------");
}
}
jclasslib查看init方法指令
aload_0
1 invokespecial #1 <java/lang/Object.<init> : ()V>
4 aload_0
5 bipush 10
7 putfield #7 <org/jsoft/DemoA.age : I>
10 aload_0
11 bipush 50
13 putfield #7 <org/jsoft/DemoA.age : I>
16 aload_0
17 bipush 100
19 putfield #13 <org/jsoft/DemoA.id : I>
22 getstatic #16 <java/lang/System.out : Ljava/io/PrintStream;>
25 ldc #22 <代码块执行:age=50--id=100--------->
27 invokevirtual #24 <java/io/PrintStream.println : (Ljava/lang/String;)V>
30 aload_0
31 sipush 200
34 putfield #7 <org/jsoft/DemoA.age : I>
37 aload_0
38 sipush 400
41 putfield #13 <org/jsoft/DemoA.id : I>
44 getstatic #16 <java/lang/System.out : Ljava/io/PrintStream;>
47 aload_0
48 getfield #7 <org/jsoft/DemoA.age : I>
51 invokedynamic #30 <makeConcatWithConstants, BootstrapMethods #0>
56 invokevirtual #24 <java/io/PrintStream.println : (Ljava/lang/String;)V>
59 return
说明:
有声明式构造函数,在编译时会先检查是否有需要赋值的成员变量和程序块,有的话,先把这部分代码编译成构造函数。
注意:
这里指的是有初始值的成员变量,如果没有初始值,构造函数不会对他们进行操作
public class DemoA {
private int id;
public DemoA(){
}
}
编译后的init的字节码指令
0 aload_0
1 invokespecial #1 <java/lang/Object.<init> : ()V>
4 return
可才看出构造函数没有对id进行操作
每次示例化都要执行构造函数的指令,而类的初始化只执行一次,这个是类非静态代码块和静态代码块最显著的区别,也最容易被面试者提问的事情。
代码
public class DemoA {
private int id;
public DemoA(){
}
static {
System.out.print("A");
}
{
System.out.print("B");
}
public static void main(String[] args) {
DemoA a=new DemoA();
DemoA a1=new DemoA();
}
}
执行结果:ABB
因为A在static{}中,只会在类初始化执行一次,B在{}里,被放入了构造函数,则程序两次实例化就执行两次
init构造函数触发条件
- new 对象
- Class.newInstance() //类的newInstance过时了
- Class<?> clazz = DemoA.class; // 目标类的Class对象 Constructor<?> constructor = clazz.getDeclaredConstructor(); // 获取无参构造方法
DemoA A= (DemoA) constructor.newInstance();
3.6 总结
阶段 | 说明 |
---|---|
01-加载阶段 | 1.加载启动类 2.加载扩展 3.加载应用类 4.自定义类,把字节码文件转化成jvm方法区和堆栈上 |
02-连接-验证 | 是否符合jvm规范 |
02-连接-准备 | 1.为静态变量赋初始值(默认值) 2.给final修饰的静态变量直接赋值 |
02-连接-解析 | 常量池 符号引用转化为内存直接引用 |
03-初始化 | 1给静态变量赋值 2.执行静态代码块 |
实例化new | 1.给非静态成员变量赋值 2.执行非静态代码块 3.执行声明式构造函数体 |
注意事项:
1.final static int id=1是发生在准备阶段,而不是初始化阶段
2.直接访问父类的静态变量,不是触发子类的初始化,子类的初始化,必须先进行父类的初始化动作
3.6典型面试题
面试jvm生命周期最高频的就是两方面的踢,
1.描述一个类的生命周期
2.类指令执行的先后顺序
3.什么是双亲委托加载?双亲委托加载的价值什么?
4.ClassLoader.loadClass和Class.forName区别?
4.final static i=2;在类生命周期哪个阶段被赋值2?
5.就是考虑准备阶段、初始阶段、类实例化是构造函数,一些代码执行的先后顺序,已在前面例子中已经讲解
4.类加载器详解
4.1 定义
类加载器(ClassLoader)是jvm提供给应用程序区实现获取类和接口字节码
的技术,类加载器值参与加载过程中字节码的获取并加载到内存在一部分,通过类加载器,把字节码文件转化成jvm的内存中的方法区和堆栈。
4.2应用场景
1.企业级应用
(1)SPI如 spring jdbc等,和类加载器是密不可分的
(2)类的热部署
(3)Tomcat的类隔离
2.面试题
1.什么是双亲委派机制
2.如何打破双亲委派机制
3.自定义类加载器
3.解决线上问题
使用arthas不停机解决线上问题
4.3 加载类分类
jvm加载类以jdk1.8为分水线区别巨大
我们可以把加载器分类C++实现和java实现两大类(jdk1.8及以前)
4.4 启动类加载器-Bootstrap
启动类加载器(BootStrap ClassLoader)是由Hotspot虚拟机提供,使用C++编写的类加载器
默认加载java安装目录/jre/lib下的类文件,比如rt.jar,tools.jar,resource.jar等,
启动类目的
其目的是给java提供一个基础的运行环境
为什么要用C++编写
启动类完全是给jvm底层提供服务的,无法用java代码来获得,保障其绝对安全性
代码:
public class BootstrapDemo {
public static void main(String[] args) throws IOException {
ClassLoader loader=String.class.getClassLoader();
System.out.println(loader);
System.in.read();//作用:让程序运行不结束
}
}
打印出来ClassLoader 是null,因为String是位于rt.jar中,为什么打印出来为null呢,因为其是c++编写的,所以无法用java代码获得,如果我们获得一个类的加载器为null,一般情况其是由启动类加载的
我们用arthas在线监控,进入BootstrapDemo 线程,使用以下命令
·sc -d java.lang.String 可以查看String类的一些情况
如何用启动类加载器加载自己写的jar包
(1)放在默认加载区目录。
不推荐,因为jvm要校验加载文件名,不规范可能也加载不了,2.破坏了jre目录结构
(2)推荐启动java时携带参数 -Xbootclasspath/a:jar目录/jar名
案例
(1)制作一个jar包如load.jar其中有个LoadTes类
package org.load;
/**
* @class: org.load.LoadTest
* @description:
* @author: jiangzengkui
* @company: 教育家
* @create: 2024-02-12 16:54
*/
public class LoadTest {
static {
System.out.println("LoadTest被初始化...");
}
}
(2)测试代码
public class BootLoad {
public static void main(String[] args) throws ClassNotFoundException, IOException {
//不报异常说明被加载,并执行LoadTest的静态static{}
Class<?> cls1=Class.forName("org.load.LoadTest");
//如果是Bootstrap启动类加载器加载,打印出来为null
System.out.println(cls1.getClassLoader());
//打印启动类加载器加载的类,只限制jdk1.8
String path = System.getProperty("sun.boot.class.path");
Arrays.asList(path.split(";")).forEach(System.out::println);
System.in.read();
}
(3)运行测试代码
增加java参数 -Xbootclasspath/a:D:/java/jar/load.jar
在编译好的class目录执行:
java -Xbootclasspath/a:D:\java\jar\load.jar org.jsoft.BootLoad
执行结果:
load.jar被启动类加载器加载进入,打印处加载器为null
应用场景:
如果我们很多项目都需要一个通用jar包,可以通过此方法加载
4.5扩展和应用程序加载类共同点
扩展类ExtClassLoader和应用程序加载类AppClassLoader都是jdk提供的,用java编写的加载器
源码在sun.misc.Launcher类中,是两个静态类内部类,都继承,具备通过目录或者指定jar把相关字节码加载进入jvm中
源码Launcher
static class ExtClassLoader extends URLClassLoader {
.........
}
static class AppClassLoader extends URLClassLoader {
.........
}
4.6 扩展类ExtClassLoader加载器
ExtClassLoader加载器是jdk提供,使用java编写的类加载器
其作用:
默认加载java安装目录/jre/lib/ext下类文件
用arthas.jar来观察其加载情况
观察代码
public class BootLoad {
public static void main(String[] args) throws ClassNotFoundException, IOException {
System.in.read();
}
}
启动arthas进入这个类线程
arthas语法说明
classloader -l #显示这个进程加载器的情况
classloader -c 加载器的hash码,查看加载器加载了哪些类或者jar
通过扩展加载器加载自己写的类
不推荐,把jar放入java安装目录/jre/lib/ext目录下
推荐用 -Djava.ext.dirs=jar包的目录
但注意:-Djava.ext.dirs会覆盖掉jdk默认的目录,所以必须把jdk默认的目录加上,在windows用; linux上用:
案例
public class ExtLoad {
public static void main(String[] args) throws ClassNotFoundException, IOException {
//不报异常说明被加载,并执行LoadTest的静态static{}
Class<?> cls1=Class.forName("org.load.LoadTest");
//如果是Bootstrap启动类加载器加载,打印出来为null,否则就打印处具体加载器名
System.out.println(cls1.getClassLoader());
System.out.println("extClassloader加载以下文件:");
System.out.println(System.getProperty("java.ext.dirs"));
System.out.println();
System.out.println("appClassLoader加载以下文件:");
System.out.println(System.getProperty("java.class.path"));
//形成柱塞,便于用arthas观察
System.in.read();
}
}
加载的jar目录
在编译后的class目录执行
java -D'java.ext.dirs'='D:\java\jar' org.jsoft.ExtLoad
不知道是jdk版本还是操作系统的原因,需要用单引号包围
执行结果如下:
我们用arthas观察加载的jar文件:
目录下的两个jar被ExtClassLoader加载器预期加载
-Djava.ext.dirs会覆盖jdk默认的扩展类目录“安装路径\jre\lib\ext”所以需要加上才能避免风险
dos执行:
java -D'java.ext.dirs'='D:\java\jdk1.8\jre\lib\ext;D:\java\jar' org.jsoft.ExtLoad
执行效果:
我们用arthas观察加载的jar文件:
4.7 AppClassLoader应用程序加载类
jdk编写,加载classpath下的类。这个classpath包含:
1.这个项目里的所有类
2.maven配置的类
3.放在环境变量里的jar包
用arthas.jar来观察其加载情况
我们打印以下应用程序加载类
public class BootLoad {
public static void main(String[] args) throws ClassNotFoundException, IOException {
Class<?> cls1=BootLoad.class;
Class<?> cls2=Person.class;
System.out.println(cls1.getClassLoader());
System.out.println(cls2.getClassLoader());
}
}
打印输出
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$AppClassLoader@18b4aac2
可以得出AppClassLoader是sun.misc.Launcher的内部类
4.8 jdk1.8jvm加载器总结
加载器 | 说明 | 加载自己的jar包 |
---|---|---|
启动类加载器- | 默认加载java安装目录/jre/lib下的类文件, 比如rt.jar,tools.jar,resource.jar等 由C++编写 无发用java程序获得 | 推荐启动java时携带参数 -Xbootclasspath/a:jar目录/jar名 |
扩展加载器-ExtClassLoader | ExtClassLoader加载器是jdk提供,使用java编写的类加载器 其作用:默认加载java安装目录/jre/lib/ext下类文件 | 用 -Djava.ext.dirs=jar包的目录 |
应用程序加载器- AppClassLoader | java编写,加载classpath下的类 |
4.9.双亲委派
我们有启动类加载器、扩展类加载器、应用程序加载器三个加载器,到底谁来加载一个类呢,这里就涉及到双亲委派的原理和价值。
4.9.1 双亲委派作用
(1)保证类加载的安全性
避免jvm核心类不会被恶意纂改,比如用自己写一个java.lang.String,如果被jvm加载,将给程序带来不可预知的问题。
(2)避免多次重复加载
可以避免一个类被重复加载
4.9.2 双亲委派原理
双亲委派机制定义:
当一个类加载器接收到加载类的任务时,会自底向上查找,依次看每个加载器是否加载过,如果没有,则找上一个加载器。如果加载过,就返回。
如果走到顶部Bootstrap都发现没有加载,则向下尝试加载,先从顶层开始,如果本层加载范围内就加载退出,如果没有,则寻找下层加载器加载,一直到最底层,如果最后都没有加载成果,则报ClassNotFound加载异常。
总结为:
向上查找已加载,向下尝试加载
向上查找加载,有就返回,则避免了重复加载
向下尝试加载,找到就加载返回,并且从BootStrap顶层开始,确定加载优先级,目的时避免jdk核心类被应用程序类恶意篡改
案例1:避免应用程序恶意篡改
恶意篡改String类
package java.lang;
public class String {
static {
System.out.println("程序恶意篡改java.lang.String");
}
}
测试代码:
public class DoubleLoadDemo {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> cls=Class.forName("java.lang.String");
System.out.println(cls);
System.out.println(cls.getClassLoader());
}
}
执行结果:
class java.lang.String
null
打印出加载器为null,表名是Bootstrap启动类加载器加载的,并没有加载我们自己写的java.lang.String类,篡改失败。
案例2 我们自己创建jar,放到启动类或者扩展类j加载器里,然后在项目里自己写一个和jar里一样的类去覆盖它,看加载优先级
jar包里的代码
package org.load;
public class LoadTest {
static {
System.out.println("jar包里的代码-初始化");
String d;
}
}
自己项目里写一个类覆盖它
package org.load;
public class LoadTest {
static {
System.out.println("我是应用里的程序");
String d;
}
}
测试代码:
package org.jsoft;
import java.io.IOException;
import java.util.Arrays;
public class BootLoad {
public static void main(String[] args) throws ClassNotFoundException, IOException {
//不报异常说明被加载,并执行LoadTest的静态static{}
Class<?> cls1=Class.forName("org.load.LoadTest");
//如果是Bootstrap启动类加载器加载,打印出来为null
System.out.println(cls1.getClassLoader());
//打印启动类加载器加载的类,只限制jdk1.8
String path = System.getProperty("sun.boot.class.path");
Arrays.asList(path.split(";")).forEach(System.out::println);
System.in.read();
}
}
class目录执行
java org.jsoft.BootLoad
打印出来是:
我是应用里的程序
采用bootstrap合作ExtClassLoader加载
java -Xbootclasspath/a:D:\java\jar\load.jar org.jsoft.BootLoad
##或者
java -D'java.ext.dirs'='D:\java\jdk1.8\jre\lib\ext;D:\java\jar' org.jsoft.ExtLoad
打印出来是
jar包里的代码-初始化
面试提问
(1)重复类加载
一个类重复出现在三个类加载器里,最终谁来负载加载?
答案:启动类加载器
(2)String能否覆盖?
我们自己创建一个java.lang.String,可以覆盖掉jdk的String吗?
答案:不能,双亲委派从顶层开始加载,启动类加载器会加载tr.jar里的String类
4.9.3 主动加载类
主动加载类有两种方法
(1)class.forName()
(2)找到应用类加载器,通过类加载器.loadClass()加载
代码:
public class LoadDemo1 {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> cls1=LoadDemo1.class;
//得到应用类加载器
ClassLoader loader1=cls1.getClassLoader();
//打印出sun.misc.Launcher$AppClassLoader@18b4aac2
//表名是AppClassLoader加载器
System.out.println(loader1);
Class<?> cls2=loader1.loadClass("java.lang.String");
//打印出来是null
//原理是AppClassLoader加载器去加载java.lang.String
//首先是查找,一直查询到启动类加载器,然后由启动类加载器向下加载
//java.lang.String属于启动类加载器的加载范围,找到后返回,加载器是启动类加载器,所以打印出来是null
System.out.println(cls2.getClassLoader());
}
}
4.9.4 父亲加载器
每个java实现的类加载器(应用程序和扩展类加载器),都有一个成员变量叫父(Parent)类加载器,可以理解为商机,并不是继承关系。
源码如图
应用程序加载器的父类加载器是扩展类加载器,但扩展类加载器的parent为null,但在逻辑上,扩展类加载器依然会把启动类加载器作为其父类加载器(原因:启动类加载器是C++写的,无法关联)
启动类加载器是用C++编写,没有父类加载器
我们可以用arthas里面的classloader -t命令来查看这个关系
如图