Java底层知识之JVM进阶

103 阅读37分钟

Class文件结构

Java是跨平台的语言,这曾经是它的优势,但是现在大多数语言都是满足跨平台性的,因此Java的这一点也就不值一提了

Java虚拟机与Class文件这种特点的二进制文件格式关联,只要语言可以通过编译器翻译成符合规范的二进制字节码文件,那么其就能被Java虚拟机所接受并执行

将高级语言翻译成字节码文件的编译器称为前端编译器,javac就是一种将Java源码编译为字节码文件的前端编译器,其翻译过程一共经历了词法解析、语法解析、语义解析和生成字节码四个步骤

值得一提的是,前端编译器并不在Java虚拟机中

前端编译器

ECJ是内置在Eclipse的编译器,与Javac的全量式编译不同,其是一种增量式编译器,也更加迅速和高效,但编译质量和javac是差不多的,顺带一提,idea使用的是javac编译器

前端编译器并不会负责优化等技术,具体的优化细节交给HotSpot的JIT编译器

字节码文件

字节码的指令可以帮助我们理解程序中的代码内部是如何执行的,比如下面的例子,光看代码我们是无法判断结果的,甚至可能结果出来了但是我们却不知道该如何解释,但是只要我们能看字节码文件,那么这些问题就会迎刃而解

image-20230102101438184

下面的例子中,new String("text")的方式会在常量池中先创建对应对象,再返回一个堆内存中对应的字符串对象给指定对象,因此两者不相等,下面的代码同样可以用字节码指令理解

image-20230102103027118

下面的代码中,执行的结果是0,30,20

image-20230102104250164

之所以会这样,是因为我们创建子类对象时,首先会创建父类对象,那么就会执行父类的构造器方法,但由于这里是父类引用指向子类对象,因此这里调用父类的print()方法,实际上调用的子类的print方法,而子类的x还没有进行赋值,这是在准备过程中进行了初始化,因此为0,尽管父类的成员变量是显式初始化,但其只是给父类的x进行了赋值,而这里调用的子类的x的值,因此并没有什么用

之后调用子类自身的print()方法,打印子类成员变量的值,最后我们通过属性构造器调用成员变量的值,由于是父类引用,因此调用的是父类的值,最后打印出的结果就是父类的20

这里有关于初始化的知识,如果对此有所忘记,可以参照该网址进行学习www.pudn.com/news/62aaf6…

image-20230102105910481

字节码文件就是Class文件,其是一种二进制类文件,内部存放JVM指令

image-20230102124225110

字节码指令是由byte长度代表某种操作含义的操作码,其后可能含有操作数,当然也可以不含有

查看字节码指令的方式有三种 ,我们后期使用方式三来作为主要的查看方式,目前我们先以方式一的方式来讲

image-20230102124514374

Class文件本质是一个以8位字节为基础单位的二进制流,不一定要存放到磁盘文件中,通过网路传输过来也是可以的

image-20230102163903682

其结构中只有两种数据类型,无符号数和表。无符号数属于基本数据类型,用于描述数字、索引引用、数量只或者按照UTF-8编码构成字符串值

表则是由多个无符号数或者其他数据项构成的复合数据类型,通常习惯性地以info结尾,Class文件本身也是一个表。表没有固定长度,所以一般会在其前面加上个数说明

结构概述

Class文件的结构并非一成不变的,但是其总体结构总是稳定的,因此我们这里主要讲解总体结构

image-20230102170520795

魔数使用四个字节表示,作用是标记Class文件的格式,用于该数才能确保该文件是一个Class文件

Class文件版本有两个,分别是小版本和大版本

常量池是一个表,因此先指定大小,后面指定常量池,所以一共两行表示一个常量池,可以看到表后面有info,常量池的大小是其指定大小的-1,这点要注意

访问标志则是表明该类是类还是接口还是抽象类,权限又是怎么样的等一类信息

类索引、父类索引都是指定类名和父类名。接口索引集合则是指定该类实现的接口,由于一个类可以实现多个接口,因此这里保存对应信息使用的是一个表

字段表集合存储各种属性,方法表集合存储各种方法、属性表集合存储字节码文件中的一些特殊属性,与我们上面说的字段有差别,这个我们后面会着重提

image-20230102170644343

下面是进行学习的源代码,非常简单

image-20230102172425235

这是其字节码的形式

image-20230102193212417

魔数

每个Class文件开头的4个字节的无符号整数称为魔数,其是Class文件的标识符,JVM中class文件的标识固定为0xCAFEBABE,不会改变

image-20230102193314729

其他不同格式的文件如果强制用二进制的方式打开,也能看到其具有魔数,但是魔数的值跟我们的不尽相同,每个不同的格式都有自己的用于标识的魔数

之所以使用魔数,是因为扩展名可以随意改动,使用魔数能够确保我们执行的二进制文件是合法的

文件版本号

接下来四个字节存储Class文件的版本号,其中前两者表示副版本,后两者表示主版本,其共同构成了文件的格式版本号。若主版本号为M,副版本号为m,则文件版本号就确定为M.m

image-20230102194407133

JDK的版本号从45开始,之后每一次大版本发布就将主版本号+1,高版本的JDK可以执行低版本的字节码文件,反之则不行

常量池

常量池是Class文件的基石,本质上其实一个表,因此需要提前指定大小,需要注意的是,常量池的计数是从1开始而非从0开始的

image-20230102201239303

常量池表中存放编译时期生成的各种字面量的符号引用,这些内容在类加载之后会进入到方法区中的运行吃常量池中存放

常量池计数器使用两个字节,我们的二进制文件里显示其计数器大小为22,那么就只有21个常量,索引的范围就为1-21

image-20230102201657095

常量池中的0永远是空出来的,其是用于表达某些指向不引用任何常量池中的值的含义而存在的

常量池主要存放字面量和符号引用,包含其下的所有字符串常量,类或接口名,字段名或者是其他常量,一般还有用一个字节来进行对应的存储的字面量进行标记

image-20230102202132530

基本变量有八种,但是由于byte、short、boolean等变量均可用Int来表示,因此这里只保留一个int类型变量的表

字面量包括文本字符串和声明为final的常量值,而符号引用则包括类和接口的全限定名,字段和方法的名称以及描述符

image-20230102210334749

描述符在描述方法时,会按照参数列表,后返回值的顺序描述

image-20230102210531045

JVM在加载Class文件时才会进行动态连接,在没有加载时,Class文件中只会保存一些符号引用,当JVM运行时其就能通过这些符号引用将其替换为直接引用并翻译到具体的内存地址中

下面是常见的常量类型和结构,倒数最后三个在JDK7之后才加入的,用于支持动态语言调用

image-20230102211232655

image-20230102211248595

我们具体分析字节码文件并比对下面已经反编译出来的结果,会发现我们的字节码中的常量内容就是下面的结果,我们查看时首先查看字节码文件对应的tag标识,然后在上表中找出其对应的常量,具体分析对应类型的常量占据多少空间,这里我们值得注意的是我们的字节码文件是16进制的,而我们上面的对照使用的是十进制的,因此我们这里一定要注意转换,别搞错了,最后我们经过分析可以发现常量池中的内容一共分布如下

image-20230102211434902

我们这里随便点入一个方法,查看其对应描述就知道其作用,然后其对应的内容中对应的引用,会发现其指向别的引用,但是最终他们都会指向其需要进行表示的字面量,这就是常量池的内容的表示方式

像我们这里直接分析字节码文件我们就做这一次就够了,后续我们都直接使用插件分析了

最后我们来看看常量池的总结

image-20230102212500773

访问标识

访问标识用两个字节标识,用于指明该类或者是接口的权限信息等,通常其都是以ACC_开头的常量,每一种类型的表示都是通过设置访问标记中的32位中的特定位来实现的

image-20230103082039638

注意类中的标记接口的值是各种标记值之和,比如我们这的类的标记值是21,其代表就是PUBLIC+SUPER这两个权限,加上正好是21

image-20230103082502307

上图是使用这些访问标记使用时的注意事项

接口索引集合等

类索引、父类索引都占两个字节,均指向常量池,前者提供该类的全限定名,后者提供该类父类的全限定名,所有类默认继承Object的,因此只有Object类的父类索引的值是00,表示其没有父类

image-20230103083642722

一个类可以实现多个接口,因此具有常量池索引集合,其是一个表,同样需要提前指定大小,其内部每一个值都必须是对常量池的有效索引,且其顺序与源代码中实现的接口的顺序是一样的

image-20230103083738945

字段表集合

字段表集合用于描述接口或类中声明的变量,包括类变量和实例变量,但是不包括局部变量。字段表集合中的每个值都会描述每个对应字段的完整信息,如字段的标识符、访问修饰符、是否为常量等

最后值得一提的是,字段表中不会列出从父类或者从接口类中继承来的字段,但是却可能列出原本Java代码中不存在的字段

image-20230103085643587

字段表也是一个表,需要字段计数器指定大小,一般使用两个字节来表示

image-20230103090129685

字段表fields[]中的每个成员都是fields_info结构的数据项,一个字段表中还包含各种其他信息,对应的修饰符都是布尔值,代表有或者无

image-20230103090334659

字段表中每个字段同样也有自己的结构,其具有访问标志、字段名索引、描述符索引、属性计数器和属性集合

也就是说,字段表集合可以理解为是一个二维的表结构,每个位置存储一个表

访问标识主要指明对应字段的权限,下图中列出了所有的权限

image-20230103090845097

字段名索引则是直接根据对应索引去常量池中查询对应的字符串常量,该常量就是字段名

描述符索引用于描述字段的数据类型,方法的参数列表以及返回值等内容,我们这里的数据类型为Int,因此其值只是一个I

image-20230103092017997

某些情况下一个字段还会拥有一些属性用于存储更多的额外信息,比如初始化值和一些注释信息等,这些属性的个数会存放在attribute_count中,具体内容则存放在attributes数组中,以常量属性为例,则有下图的结果

image-20230103092042486

第一个内容指向常量池中的字符串,第二个内容表达该属性的长度,对于常量属性而言,其值恒为2,最后一个内容则是指向具体的常量值,该值则是保存属性所具有的值

方法表集合

方法表集合与字段表集合十分相像,这里就不多提了

image-20230103095219210

同样也存在方法计数器,同样也是一个复合的表结构

image-20230103095357924

方法表中每个值里都有访问标志、方法名索引、描述符索引、属性计数器和属性集合

image-20230103095537553

这里方法访问标志是复数的,因为方法中可能有多种修饰符,比如可能是静态的公开的

方法名索引指向常量池的方法名,描述符索引则描述方法的传入值,返回值,传入类型返回类型等

同样,这里也还有属性集合这一说法,这些内容就等到我们下一节来具体讲述

属性表集合

属性表集合一般指的是Class文件所携带的辅助信息,比如该class文件源文件的名称等

image-20230103102545968

字段表、方法表都可以有自己的属性表,用于描述某些场景专有的信息

image-20230103102612977

属性表中的结构比较灵活,且属性的类型非常多,但是他们在总体上还是存在属性名索引、属性长度以及属性表这三个结构的

image-20230103103000830

属性表能有许多种类型,在Java8中里一共定义了23种,我们这里就只介绍必要的

首先我们的Class文件中就带有属性,这些属性一般是Class文件所携带的辅助信息,从字节码文件中的索引查询,我们能够发现其属性表的名字为SourceFile

image-20230103121600426

查看其对应的属性说明,可以判断接下来的二进制码代表属性长度,长度一般固定为2,但是却占据四个字节,最后是源码文件索引,一般指向常量池中源文件名.java的字符串

在字段表中的属性一般只在字段中有常量时存在,当字段中存在常量时,其属性名为ConstantValue,长度固定为2,索引则指向常量池中的具体保存的常量值

image-20230103122507680

在方法表中的属性首先存在的是Code属性,同样查询对应的属性说明

image-20230103122529214

那么其后的字节码就分别对应属性名,属性长度,操作数栈的最大深度等等,后面还有存储字节码的指令,那些字节码的指令就是我们之前看过的具体字节码指令,在二进制文件里他们用一些指定的数字进行代表,如果需要跳转到其他引用,那么二进制码中也会给出要跳转的引用的数字

这里我们注意到,在Code属性的最后还有属性集合计数器,这说明Code集合下还有集合,继续分析其后的字节码,会发现其后的字节码第一个对应的名字是LineNumberTable

image-20230103122907087

其实用于描述Java源码行号与字节码行号之间的对应关系,其可以用来在调试的时候定位代码执行的行数

该属性是循环的,每次循环都会有对应的名字、长度、行号、起始行数和行号属性,对应方法中Code属性中LineNumberTable属性展示的内容,有几个循环就展示几行

紧接着后面的内容是LocalVariableTable,该属性用于确定方法在执行过程中局部变量的信息,我们的局部变量的对应信息就保存于此,其他的信息都在下图中了,自己看吧

image-20230103123333225

Javac

直接看图吧

image-20230103134149395

image-20230103134206661

image-20230103134244036

image-20230103134311595

image-20230103134338522

字节码指令集

字节码指令由一个字节长度代表某种特定操作含义的数字以及其后跟随的0或多个参数构成,前者称为操作码,后者称为操作数,一个指令必须有操作码,但是操作数可以没有

image-20230103153903442

字节码指令的操作码长度为一个字节,这意味着操作码总数不可能超过256

image-20230103154236533

实际上,我们也可以将这些指令的英文称为助记符,对于大部分和数据类型相关的字节码指令,其操作码助记符中都有特殊的字符表明其为何种数据类型

当然,也有一些助记符与数据类型无关,比如arraylength,但其与数组类型有关,也只能应用于数组类型

image-20230103154355353

同时,大多数的指令集都没有支持byte、char、short和Boolean类型的,因为他们在编译期或运行期都会转换为int类型数据进行处理

字节码指令集一共可以分为下面九种,我们接下来就学习这九种

image-20230103154917961

加载与存储指令

首先是加载指令,作用是将一个局部变量加载到操作数栈,一般为xload_,n为0~3,如果超过这个范围就会在后面加上空格并指定具体的操作码

image-20230105010056548

之所以设置这种范围小的数据用一个指令来执行,是为了提高效率,同时节约空间。

i代表int类型、l代表long、f代表float、d代表double、a代表引用类型

再正式讲知识点之前,我们先来复习下操作数栈与局部变量表

image-20230105010411414

操作数栈是用于存放计算的操作数与返回结果的一块空间,存在于栈帧中

image-20230105010544813

局部变量表是存储方法中各种变量的区域,在方法执行之前就会确定好局部变量表的所需大小,值得一提的是,局部变量表存在槽位复用情况且long和double类型的值在局部变量表中占两个单元

将局部变量表中数据压入操作数栈的指令为xload_,值得一提的是,当数值为-1时,其会用m1进行展示

image-20230105010843802

常量入栈指令分为const、push、ldc三个系列,const指令只负责比较小的数,push则居中,ldc则是最大,他们对每一中数据类型都做了对应的处理,具体看下图

image-20230105010942682

下面是每个指令对应的范围

image-20230105011649385

下面是入栈指令示例

image-20230105011746028

注意:常量入栈指令中的n和局部变量压栈指令中的n不一样,本次的n代表数值或者对象,而不是局部变量表中的下标

出栈指令是xstore_,这里的n指的是局部变量表中的下标

image-20230105011850843

里面有代码,也有字节码,所以可以根据老师给的图展开分析,首先该方法被调用的时候,形式参数k和d都是有确定的值,由于该方法不是静态方法,所以局部变量表中的第一个位置(槽位)存储this,而第二个位置存储k具体的值,由于老师只是分析,没有调用这个方法,所以老师全部使用的变量名称来代替具体的值,所以明白就好,继续来分析,然后第三个和第四个位置储存d具体的值,由于d是double类型,所以需要占据两个槽位,数据已经准备好了,那就来看字节码,首先iload_1是将局部变量表中下标为1的k值取出来压入操作数栈中,然后iconst_2是将常量池中的整型值2压入操作数栈,iadd让操作数栈弹出的k值和整型值2执行相加操作,之后将相加的结果值m压入操作数栈中,请注意老师的画法,在执行弹栈和压栈操作之后,老师并没有删除操作数栈中的k值和2,这是因为老师让我们知道具体的操作过程,所以故意为之,不过真正的操作是弹栈之后k值和2就会从操作数栈中弹出,之后操作数栈中就没有k值和2了,只有m值了,然后istore_4是将操作数栈中的m值弹出栈,然后放在局部变量表中下标为4的位置,idc2_w #13<12>代表将long型值12压入操作数栈,istore5是将值12弹栈之后放入局部变量表中下标为5的位置,由于12是long型,所以占据两个位置(槽位),ldc #15代表将字符串atguigu压入操作数栈,astore 7代表将字符串atguigu弹栈之后放入局部变量表中下标为7的位置,idc #16<10.0>代表将float类型数据10.0压入操作数栈,fstore 8代表将10.0弹出栈,然后放入局部变量表中下标为8的位置,idc2_w #17<10.0>代表将10.0压入操作数栈,dstore2代表将10.0弹出栈,之后将10.0放入下标为2和3的操作,毕竟这是double类型数据

image-20230105012017456

算数指令

算数指令可以对整数数据或者浮点类型数据进行运算

image-20230105015540086

当一个操作溢出时,会用Infinity表示该数无穷,如果该数没有明确的数学定义,则会返回NaN

image-20230105015559932

所有的算术指令如下图所示

image-20230105015744234

搞不清楚执行过程的代码可以通过观察其字节码指令搞明白

image-20230105015853841

值得一提的是,当我们的调用println方法时,其实是会发展压栈,要打印的数会作为参数传入到该方法中使该方法执行,方法执行之后继续执行原来的方法

image-20230105015951424

image-20230105020005094

类型转换指令

不同的数值类型可以相互转换,这些类型转换一般用于实现用户代码中的显式类型转换操作

image-20230105022931824

一共分为两种,宽化类型转换和窄化类型转换,前者指的是范围小的数据类型转化为范围大的数据类型

image-20230105023025541

其转化过程会出现精度损失,同时为了节约指令资源在转化中byte、char、short类型全部视作int类型处理

窄化类型转换指的是范围大的数据类型向范围小的数据类型转换,中间存在精度损失问题

image-20230105023255002

如果fdl类型往bsc类型转换,需要int作为中间转换的桥梁

对象的创建与访问指令

字节码中对象的创建和访问指令具体可细分为创建指令、字段访问指令、数组操作指令和类型检查指令

image-20230105132519399

创建指令主要是与new相关的指令,但是这里值得一提的是,实际一个对象的真正创建经历的过程首先是new,该操作会在堆空间中创建出没有初始化的具体对象然后返回其地址到操作数栈中,然后是dup,该指令会在操作数栈中复制一份刚刚new的对象的地址,接着最后执行对象的init方法,对象才算真正创建完成

image-20230105132720827

get操作主要是入栈,将对象内的值取出并压入到操作数栈中 ,而put主要是出栈,将操作数栈中的值弹出并完成对应的赋值操作

image-20230105133108517

数组操作指令主要有xastore和xaload,后者是将把一个数组元素加载到操作数栈,前者是将操作数栈的值存储到数组元素,注意这里是存储到堆空间中的数组中,而不是存储到操作数栈的本地变量表中

image-20230105133321345

xastore命令执行需要值、索引、数组三个数据,执行时该命令会弹出这三个值并进行对数组的正确赋值。

xaload命令会将对应数组的值取出并压入到操作数栈中,执行该命令要求其栈顶元素为数组索引i,顺位第二是数组引用,同样会弹出该两个数据

image-20230105134232732

类型检查指令有两个,分别是instanceof和checkcast

方法调用与返回指令

方法调用指令一共分为五种,其中invokeinterface指令用于调用接口方法,invokespecial用于调用构造方法、私有方法和父类方法等静态类型绑定的方法,调用静态方法则指定用invokestatic,如果同时是是私有方法和静态方法,则会以静态方法的指令为准

image-20230105141426032

其他可能被重写的方法调用时都是用invokevirtual,其也是Java语言中最常见的方法分派方式

接口中指定静态方法仍然是使用invokestatic,而如果是默认方法的话则仍然是invokeinterface

image-20230105142003345

方法返回指令要注意的是如果返回的是synchronized方法,那么还会执行一个隐含的monitorexit指令来退出临界区,其他情况下都是将操作数栈的顶层元素弹出并将该元素压入到调用者函数的操作数栈中

操作数栈管理指令

操作数栈管理指令主要是dup和pop类指令,dupn_xm指令会复制栈顶的数值并重新压入栈顶,n代表移动的数据大小,m代表移动之后要插入的位置,要重新插入的位置在当前对象的后m+n的对象后

nop指令代表什么都不做,pop指令则代表从栈中弹出数据,一般数据没有任何变量指向的时候会调用该指令

image-20230105145221148

控制转义指令

字节码指令中的控制转义指令有五种,分别是比较指令、条件跳转指令、比较条件跳转指令、多条件分支跳转指令和无条件跳转指令

image-20230105205725503

比较指令属于算数指令的一种,其多和其他的指令结合使用,如果我们多加注意的话,会发现比较指令里并没有int类型的比较命令

image-20230105205958488

这是因为int类型往往是比较的结果,并且这个结果也会被用于比较,因此可以说int其实时时刻刻都被比较,不理解没关系,后面我们就会理解了

条件跳转指令通常和比较指令结合使用,其会弹出栈顶元素,测试其是否满足对应的条件,若满足则跳转到指定位置

image-20230105210236866

比较条件跳转指令则是会直接取出栈顶的两个元素进行比较,若满足预设条件则跳转,否则继续执行下一个语句

image-20230105210401312

多条件分支跳转指令是专为switch命令设计的,主要有tableswitch和lookupswitch两种,前者用于case值连续时,后者用于不连续时,前者的效率相对后者来说要高,同时,当case值不连续时,其内部会先对case值进行排序

image-20230105210542017

当case值为String时,其会先将String值计算对应的哈希值,进行字符串比较时,只有哈希值相同才执行equals方法,否则直接跳过对应的判断,同样的,计算出的哈希值也是会按照数字从小到大进行排序的

image-20230105210731843

无条件跳转指令主要是goto,后跟一个要跳转的位置数字,如果跳转的位置太大,也可以使用goto_w,其允许接受四个字节的操作数

image-20230105211107443

像for、while语句虽然在高级语言层面有区别,但是在字节码指令层面是没有区别的,都是使用goto完成的

异常处理指令

抛出异常throw命令在字节码指令中对应的命令是athrow,其会将异常对象抛给上一层并立刻结束当前栈帧,除此之外,JVM中还规定了许多运行时异常,当检测到发生这些异常时会自动抛出

值得一提的是异常本身也是一种对象,因此指定athrow对象之前还需要执行创建异常对象的操作

image-20230105215032365

throws则是一个和Code并列的属性,其作用于方法中

处理异常语句是try-catch或try-finally,只要创建了这种语句就会在Code属性中创建一个异常表,无论抛出什么异常,只要最终匹配了异常类型,代码就会继续执行

image-20230105215323581

异常表中包含捕捉发生异常的起始代码位置和结束代码位置和要捕捉的异常,Handler PC指的是发生异常之后要跳转的代码行

同步控制指令

同步控制指令包括方法级的同步和方法内部一段指令序列的同步,两者都是使用monitor来支持的

image-20230105222254626

当synchronized关键字添加到方法上时,为方法级上的同步,这种同步方式无需通过字节码指令来控制,其会在方法调用时加锁,在方法结束时释放锁,使用这种方式会给对应方法加入ACC_SYNCHRONIZED的访问标志

image-20230105222404398

尽管不能在字节码中看出区别,但是可以在方法的访问标识中看到区别

image-20230105222659950

使用同步代码块方式可以实现显式控制,在进入同步代码块时会执行monitorenter命令加锁,退出时则会执行monitorexit命令释放锁

image-20230105222737054

synchronized代码块中存在循环异常处理,如果同步代码块出现异常则进入到另外一段字节码指令中执行释放锁并抛出异常的命令,如果这个过程中又出现了异常,那么会重新执行这段异常处理指令,直到正确释放锁并抛出异常为止

image-20230105222751753

下面是例子分析

image-20230105223415933

类的加载过程详解

类的加载过程可以分为,加载、链接、初始化、使用、卸载五个过程

image-20230106004443393

其中,链接这部分可以细分为验证、准备、解析三个过程,下面是类的装载流程

image-20230106004529570

下面是一些大厂的面试题

image-20230106004619689

加载(Loading)

加载指的是将字节码文件加载到机器内存中,并在内存中构建出类模板对象的过程

image-20230106004646331

简单来说,加载阶段是在查找并加载类的二进制数据,并生成对应的Class实例的过程,而二进制流的获取方式也有许多中,包括但不限于下面的五种

image-20230106004937017

加载的类的二进制数据会保存到JVM中的元空间中,每个类都会在方法区中保存对应的二进制数据,堆区中会创建对应的Class对象实例并存在指向方法区的对应Class的二进制数据指针,值得注意的是,这里是说最后的结果会呈现这样的结果,不是说在这个加载阶段里就能得到这样的结果的意思

image-20230106005012870

数组类是JVM在运行时根据需要直接创建的,但是数组中的元素仍然需要依靠类加载器去创建

image-20230106005216347

链接(Linking)

链接分为三个阶段,分别是验证,准备和解析。验证阶段的目的是为了保证字节码的加载是合法且符合规范的,验证包括格式检查、语义检查、字节码验证和符号引用验证

image-20230106023325999

其中格式检查的行为会在加载阶段就执行,且字节码验证中存在栈映射帧的概念,在此阶段会检测特定字节码处是否存在正确的数据类型

最后会进行符号引用的验证,验证符号引用指向的类或者方法是真实存在的

image-20230106023340549

值得一提的是,没有通过验证的字节码肯定是有问题的,但是通过了验证的也不能说就肯定没问题

image-20230106023930711

在准备阶段会为静态变量分配内存并将其初始化为默认值,注意,这里指的是静态变量,非静态的变量和常量都是不包括在此的

image-20230106024103001

最后是解析阶段,这个阶段会将类、接口、字段和方法的各种引号引用转换为拥有具体的内存地址的直接引用。

Java为每个类都准备了方法表,当需要调用类中的方法时,只要知道方法在方法表中的具体位置就可以调用该方法,解析阶段可以说就是在做获得方法在方法表中的具体位置的事情,这样才能保证方法被成功调用

这个操作一般是在初始化过程中一起进行的

初始化(Initialization)

初始化阶段的主要作用是为了给类的静态变量赋予正确的初始值,在这个阶段才真正开始执行类中定义的方法

在这个阶段会执行类的()方法,这个方法是由JVM生成的,内部包括静态成员变量的赋值语句与static语句块

父类的()总是会先于子类被执行,该方法不产生时只有三种情况

image-20230106132525103

一般来说,常量是直接在链接的准备阶段进行赋值的,但是如果常量的赋值方式是通过调用其他方法赋值的,那么由于在准备阶段还无法确定下来该值,那么其赋值方式还是在()方法中赋值

image-20230106133518011

在准备阶段就完成赋值的字段下是存在属性ConstantValue的

image-20230106133644942

()函数是线程安全的,也会产生死锁,当一个线程执行了()方法成功加载了对应的类,其他线程就不能再执行()方法了,当需要使用到这个类的时候虚拟机会直接返回已经准备好的信息

image-20230106133741383

Java程序对类的使用分为两种,主动使用和被动使用,前者指的是会导致类进行初始化的使用,后者则与之相反

image-20230106144031138

image-20230106144231618

下面是对应的举例

image-20230106144303773

image-20230106144318805

image-20230106144340872

image-20230106144410529

image-20230106144427339

最后提一嘴,可以利用-XX:+TraceClassLoading参数来追踪类的加载信息并打印

image-20230106144825782

当然,不被初始化,不代表不会被加载,这点还是要搞清楚的

类的使用(Using)

image-20230106150607940

类的卸载(Unloading)

类加载初始化之后会在方法区中存入对应类的二进制数据,堆区中会存在对应类的Class对象,对象的实例存在指向对应Class对象的实例,实例本身当然在栈区存在引用指向,Class对象要在堆区存在需要借助加载器,其和加载器存在双向引用,引用变量,Class对象自身还存在引用,同理还有加载器对象

image-20230106151905005

image-20230106152507659

方法区GC主要包括常量池中废弃的常量和不再使用的类型,他们的判断条件都比较苛刻,而且即使允许回收,也不是一定会被回收

image-20230106152521170

综合来说,一个已经加载的类型被卸载的概率是很小甚至几乎是不可能的

image-20230106152723206

类的加载器

类加载器是JVM执行类加载的前提 ,所有的Class都是在ClassLoader中进行加载的,其负责将Class信息的二进制数据流读入JVM内部,其只能影响到类的加载,而无法改变其链接和初始化行为,能否运行则交由执行引擎决定

image-20230106221851361

类的加载方式分为显式加载和隐式加载,前者指的是通过调用Class.forName();方法或this.getClass().getClassLoader()加载class对象,除此之外其他的均是隐式加载

image-20230106222135382

类加载器的存在有以下好处

image-20230106222408290

比较两个类是否相等,只有在两个类是在同一个类加载器的前提下才有意义

每个类加载器都有自己的命名空间,同一个命名空间里不可能出现同名的类,但在不同的命名空间里可以

image-20230106223130049

类加载器有双亲委派模型、可见性、单一性三个基本特征

image-20230106223303350

下面是一些大厂的面试题

image-20230106222122299

类加载器分类

JVM支持两种类型的类加载器,分别为引导类加载器和自定义类加载器

image-20230106223351892

启动类加载器是由C/C++编写的,其他的类加载器全部为自定义类加载器

不同的类加载器虽然看似是继承关系,但实际上是包含关系,在下层的加载器中包含着上层加载器的引用

image-20230106223534272

引导类加载器没有父加载器,且只加载包名为java、javax、sun等开头的类

image-20230107013736405

扩展类加载器和系统类加载器均由启动类加载器加载,并且是它们的父类加载器

image-20230107013940623

结果为null是因为得到的结果是C++的,在Java中无法显示,不代表其就是null,实际是存在这个加载器对象的

扩展类加载器继承ClassLoader类,其会从java.ext.dirs或jre/lib/ext目录下加载类库,如果用户创建的jar放在此目录下,也会被一同加载

image-20230107014029263

系统类加载器,也就是应用程序类加载器负责加载环境变量或者系统属性,还有java.class.path指定路径下的类库,其实系统默认的类加载器,用户自定义的类大多使用该加载器来进行加载,并且还是用户自定义类的默认父加载器

image-20230107014445496

用户自定义加载器不但能够实现应用隔离,还可以实现非常绝妙的插件机制

image-20230107014930055

测试不同的类加载器

线程当前上下文的加载器是系统类加载器

image-20230107020542830

数组对象是通过JVM根据需要自动创建的,获取其加载器会获得其存储的数据类型的加载器

同时基本数据类型没有加载器,因为基本数据类型在JVM运行时就已经通过JVM事先加载好了

image-20230107020739858

image-20230107020752471

ClassLoader源码解析

用户可以自定义加载器,所有用户自定义的类加载器都继承ClassLoader类

image-20230107130513950

下面让我们来看看ClassLoader类中的主要方法,注意其内部没有抽象方法

loadClass方法的逻辑就是双亲委派模式的实现,特定重写该方法可以打破双亲委派模式

image-20230107135749699

下面是对上面的方法的说明

image-20230107135833314

findClass是一个受保护的方法,只能在子类中调用,JVM鼓励我们重写此方法,loadClass中就调用了该方法,重写该方法不会打破双亲委派模型

defineClass通过传入byte字节流来生成对应的Class实例,传入的方式并不做限制,只要能传入正确的规范的字节流就行

image-20230107130750657

findLoadedClass方法作用是查找指定名称的加载过的类,返回Class实例,其是final修饰的方法

image-20230107131917373

SecureClassLoader主要扩展了ClassLoader,了解即可。我们更多的还是和其子类URLClassLoader打交道

URLClassLoader实现类中实现了ClassLoader抽象类中的许多方法,在编写自定义类加载器时,如果没有复杂的需求,可以直接继承URLClassLoader类,这样能使得我们的自定义类加载器编写更加简洁

image-20230107132037499

拓展类加载器(ExtClassLoader)和系统类加载器(AppClassLoader),两者都继承于URLClassLoader类,两者都遵从双亲委派模式,不过后者重写了loadClass方法,但只是加了一个判断,最终还是调用了父类的loadClass()方法,因此还是遵从双亲委派模式

image-20230107132420261

Class.forName()方法是一个景田方法,其将Class文件加载到内存的同时就会执行类的初始化。而ClassLoader.loadClass()方法将Class文件加载到内存并不会执行类的初始化,只有第一次使用时才进行。这有点像是单例模式里的懒汉式和饿汉式

image-20230107132752918

双亲委派模型

双亲委派模型的本质是一个类加载器在接到加载类的请求时,会先将这个请求委托给父类加载器完成,若父类可以完成,就成功返回,反之则自己进行加载

image-20230107142155020

下面是双亲委派模型的流程图

image-20230107142312929

双亲委派模型具有确保类的全局唯一性和防止核心API被篡改的优势,同时即使破坏双亲委派模型,核心API也仍然有preDefineClass()接口来提供对核心类库的保护

image-20230107142402623

而双亲委派模型的劣势则在于顶层的ClassLoader无法访问底层的ClassLoader所加载的类,在某些情况下这可能会造成一些问题,因此双亲委派模型也只是建议使用,而非必须

image-20230107142624940

jdk1.2之前还没有引入双亲委派机制,所以jdk1.2之前就是破坏双亲委派机制的情况

image-20230107142754448

为了解决这个问题,引入了线程上下文加载器,当父类加载器需要访问底层加载器所加载的类时,就会令其代为工作

image-20230107143051622

这个设计并不优雅,也违背的双亲委派模型的一般性原则,但也是没办法的事情

image-20230107143218456

由于追求程序的动态性,因此出现了OSGI,能够实现代码的热替换或模块的热部署,简单来说就是即使不用重新启动项目,也能实现代码的更新

image-20230107143248483

热替换的实现基本思路可以是每次调用方法之前都创建新的ClassLoader实例并加载对应的字节码文件创建实例并调用方法,这样我们要更新代码时不需要重启项目,只需要重新编译并生成Class字节码文件即可

image-20230107143505391

沙箱安全机制

image-20230107145030889

image-20230107145041655

image-20230107145052998

image-20230107145110812

image-20230107145121493

自定义类的加载器

自定义类加载器有隔离加载类、修改类加载的方式、扩展加载源和防止源码泄露的好处

image-20230107151045395

要注意只有两个类型都是同一个加载类所加载才能进行类型转换,否则转换时会发生异常

image-20230107151151074

自定义类的加载器时,我们推荐重写findClass()方法

Java9新特性

Java9中仍然存在三层加载器架构和双亲委派模型,但是其jar包系统改为了模块化的系统,溢出了拓展机制,但是扩展类加载器仍然保留,重命名为平台类加载器(platform classloader),全部加载器都继承于BuiltinClassLoader

image-20230107152609089

启动类加载器在JDK9中有了实现,但是为了兼容性仍然令其在获得对应实例的场景中返回null

image-20230107152819083

由于JDK9系统模块化,因此双亲委派机制也由父加载器判断自己能否加载该请求变为先判断该类是否能够归属到某一个系统模块中,若可以则优先委派给对应模块的加载器完成加载,反之则委派给父加载器