虚拟机执行子系统


虚拟机最特殊的地方就是它是一个平台,其他语言也可以使用相应的编译器编译为 class 文件,放到虚拟机上执行。而 java 的跨平台特性,也是因为虚拟机这个平台可以将 class 文件解读为不同的01字节流

那编译器到底是如何编译的呢,编译后的 class 文件又有哪些特性呢

前端编译器与后端编译器

javac 前端编译器流程

1,解析与填充符号表

解析包含词法分析语法分析,词法分析指将我们认为的最小元素字符转换为标记(编译过程的最小元素)的过程,语法分析则是将读出来的标记组成抽象语法树 AST 的过程(每个节点代表一个语法结构,如包、类型、修饰符)

抽象语法树又是什么,他是以树的形式表现代码的,也许我们读取这颗树觉得费劲,但是计算机不这么认为,如下图,我们看看表达式 1+3*(4-1)+2 转换成树之后的样子

在这里插入图片描述
上图是举个例子,最终的树是这个样子:

while b != 0
{
    if a > b
        a = a-b
    else
        b = b-a
}
return a

在这里插入图片描述

填充符号表,符号表是由一组符号地址和符号信息构成的表格,可以理解成 hashmap

2,插入式注解处理器

注解处理器在运行期间发挥作用,在编译期间可对注解进行处理,可看做编译器的插件,可对抽象语法树进行修改。注解处理器对语法树进行修改后,编译器回到解析和填充符号表重新处理。我们著名的 Lombok,就是在这时候发挥作用的

3,解语法糖、语义分析与字节码生成

标注检查(标记、注解检查)变量使用前是否已被声明、变量与赋值之间数据类型是否匹配、折叠。数据及控制流分析,编译期间的数据集控制流分析与类加载时的数据及控制流分析基本一致

最后编译为了虚拟机可读的 class 文件

即时编译器

在虚拟机读取字节码的时候一般是通过解释器与编译器来转换成机器语言的。再虚拟机执行一开始,都是通过解释器编译的,随着时间的经过与虚拟机发现某个代码段执行的特别频繁,会逐渐使用编译器来执行代码。此时调用方法栈中的动态链接,进入代码的地点有所不同,它会进入已经是二进制代码的地方,这种方式叫栈上替换

这个编译器就是即时编译器,也称 JIT 编译器(just in time)。JVM 集成的编译器有两种,客户端编译器以及服务端编译器

客户端编译器注重启动速度和局部优化,HotSpot VM 使用的是 Client Compiler C1编译器,简称 C1编译器

服务端编译器注重全局优化,运行过程中性能更好,会对代码做很多优化,由于进行更多的全局分析,所以启动速度会变慢。Hotspot VM 使用有两种:C2编译器(默认)

C1、C2 都有各自的优缺点,为了综合两者的优势,在编译速度和执行效率之间取得平衡,JVM 引入了一种策略:分层编译。简单来说,就是从一开始的只使用解释器执行代码,慢慢过渡到客户端编译器以及服务端编译器。如果发生加载了新类等需要修改代码的情况,就会从编译器逆优化到解释状态进行执行

这个慢慢过渡是指虚拟机会发现热点代码并将这些代码使用编译器编译。具体过程是 JVM 设置一个阈值,当方法或者代码块的在一定时间内的调用次数超过这个阈值,就会被编译,存入 CodeCache

计算一段方法与代码块执行次数的叫计数器,有分方法调用计数器以及回边计数器,而客户端编译器的默认阈值是1500次,服务端的默认阈值是10000次

即时编译器的优化

编译器的难点不在能不能成功把字节码翻译成机器码,而是输出的代码质量高低,即时编译器对需要翻译的字节码做了很多优化,注意,以下的优化只在即时编译器中,翻译器是没有这些功能的

并且这些优化技术只是其中最重要的一部分,还有很多容易理解但是不好实现的功能比如乐观空值断言、常量折叠、重组等都没有提到

方法内联

方法内联就是把调用方函数代码复制到调用方函数中,减少因函数调用开销的技术。这项技术避免了生成一些不必要的栈帧,java8 推荐使用 lambda 表达式也是这个原因,该优化基本是其他优化的基础

虽然 JIT 号称可以针对代码全局的运行情况而优化,但是JIT对一个方法内联之后,还是可能因为方法被继承,导致需要类型检查而没有达到性能的效果。因为在运行时可以内联的方法必然要在编译期被编译,但是动态分派等技术让虚拟机无法确认方法的版本号。虽然虚拟机对这些情况做了一些优化,不过还是不能覆盖所有情况

逃逸分析

我们知道对象经常在方法中分配,而且在方法中分配的对象大部分不会被其他的方法访问到,同时如果在堆中分配这个对象还需要划分内存区域、判断对象引用、根节点标记等一系列费时费力的操作。如果栈上分配内存的话可以免除这些过程。逃逸分析技术应运而生

逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体。可以逃逸出函数体指的是在方法内被定义的对象在方法外可以被访问到,例如作为参数被传到其他方法中,这种叫方法逃逸。甚至有些可以被其他的线程访问到,这种被称为线程逃逸

对于不同的逃逸程度,虚拟机有不同的优化方式:

栈上分配:如果确定一个对象的作用域不会逃逸出方法之外,那可以将这个对象分配在栈上,这样,对象所占用的内存空间就可以随栈帧出栈而销毁
在这里插入图片描述

对于大量的零散小对象,栈上分配提供了一种很好的对象分配策略,栈上分配的速度快,并且可以有效地避免垃圾回收带来的负面的影响,但由于和堆空间相比,栈空间比较小,因此对于大对象无法也不适合在栈上进行分配

标量替换:标量是一个无法再分配成更小的数据,而聚合量即是可以进行拆解的数据,比如java在中的对象就是一个典型的聚合量。再程序访问阶段将使用到的成员变量恢复为原始状态来访问这种行为就是标量替换

同步消除:不会被其他线程访问到的同步代码会进行锁消除

class 文件结构

数据结构

在这里插入图片描述
这是我从网上找来的图,class 文件由无符号数与表两个数据类型组成,表由无符号数与表两个数据类型组成。因此 class 文件可以看作就是表

无符号数是基本的数据类型,分 u1,u2,u3,u4,代表了该数据所占几个字节,u1 占一个字节,u2 占两个

表一般以 _info 结尾,长度不确定,因此一般需要一个无符号数来确定大小,比如 interfaces_count 确定了 interfaces 的大小

class 文件的构成

每个 class 文件开头都有 0xCAFEBABY 的魔数,这个数用来确定这个 class 文件是合法的可以被虚拟机读取的 class 文件,如果将一个文件结尾命名为 class 也会被虚拟机读取,因此需要一些检测机制。在消息传输的时候也有类似操作

第二个是版本号,java 在不断的更新版本,新版本 jdk 可以执行老版本的 class 文件,但是老版本 jdk 不能执行新版本 class 文件,因此需要一个版本号来确定

随后跟着的是常量池的大小以及常量池

access_flags 是访问标志,用于识别接口或者类的访问信息,比如 ACC_PUBLIC,ACC_FINAL等。你可能会问访问标志可能有多个,怎么使用一个 u2 就能表示所有的访问标识呢?很简单,把他们的标识数加起来就行了
在这里插入图片描述

随后是类索引、父类索引、接口索引集合,这些索引都是指向常量池里的东西的,具体来说,是指向 CONSTANT_Class_info 的

常量池

常量池是存放字面量和符号引用的池子,类似与资源库,常量池表如下
在这里插入图片描述
通过这张表就可以读常量池了

符号引用指的是包、类和接口的全限定名、方法名称与描述等。因为在编译阶段不可能知道方法字段在内存中的布局,只有虚拟机读取时才会生成真正的直接引用,这些东西没有确定的地址,总得把他们标记出来好方便以后的查找吧,主要包含以下几种:

  • 字段、方法的名称和描述符
  • 类和接口的全限定名
  • 被模块导出或者开放的包
  • 方法句柄和方法类型

注意,这里放着的不是真正的方法或者属性,它只是个引用,你可以看到几乎 java 的所有东西都在这里面有对应的引用,八大基础类型对应前二到五种(boo、short 等不满4字节的全部用 Integer_info 表示),名称是 Utf8_info,方法与类也有对应的东西

字面量接近我们 java 语言层面的常量概念,指文本字符串、被声明为 final 的常量值等

一个常量池的例子如下:
在这里插入图片描述
在这里插入图片描述

字段表与方法表

字段表用于表述接口或者类中声明的变量,方法表表示的就是方法,两个非常相似

以字段表为例。它需要表述的信息有修饰符的作用域、是实例还是类变量(static)、可变性(final)、是否被序列化(transient)、数据类型(基本类型、对象、数组)等。这些信息里,修饰符都是可以用标志位来表示的,而字段的名称、字段的数据类型,都只能用常量池的字面量来表示

类型描述备注
u2access_flags记录字段的访问标志
u2name_index常量池中的索引项,指定字段的名称
u2descriptor_index常量池中的索引项,指定字段的描述符
u2attributes_countattributes包含的项目数
attribute_infoattributes[attributes_count]标志位

descriptor_index 指的就是数据类型,这个索引指向常量池中的值

那 attributes_count 与 attribute_info 又是个什么鬼?这就是属性表,比如在字段中设定了默认值(被 final 修饰的值),attribute_info 就会指向这个默认值,不过还是用在方法上比较多,因为方法里面的代码逻辑就是存放在这里的。access_flags 可能的值如下,它一般指的是 public、static 等标志

权限名称描述
ACC_PUBLIC0x0001public
ACC_PRIVATE0x0002private
ACC_PROTECTED0x0004protected
ACC_STATIC0x0008static,静态
ACC_FINAL0x0010final
ACC_VOLATILE0x0040volatile,不可和ACC_FIANL一起使用
ACC_TRANSIENT0x0080在序列化中被忽略的字段
ACC_SYNTHETIC0x1000由编译器产生,不存在于源代码中
ACC_ENUM0x4000enum

属性表

属性表中可以存放多个动态的属性,只要设置了表的大小,它不对表里的东西有严格的顺序限制,只要不与已有的属性名重复即可。最具代表的就是 code 方法表了。属性表包含的部分属性如下

属性名称使用位置含义
Code方法表Java代码编译成的字节码指令
ConstantValue字段表final关键字定义的常量值
Deprecated类、方法表、字段表被声明为deprecated的方法和字段
Exceptions方法表方法抛出的异常
InnerClasses类文件内部类列表
LineNumberTableCode属性Java源码的行号与字节码指令的对应关系
LocalVariableTableCode属性方法的局部变量描述

每个属性肯定要一个统一的结构标志大小、是什么属性、所含的内容的,因此属性表结构如下

类型名称数量
u2attribute_name_index1
u4attribute_length1
u1infoattribute_length

选择一个最具代表性的表 code,看看它的部分内容是什么

类型名称含义
u2attribute_name_index属性名称索引
u4attribute_length属性长度
u2max_stack操作数栈深度的最大值
u2max_locals局部变量表所需的存储空间
u4code_length字节码长度
u1code[code_length]存储字节码指令的一系列字节流
u2exception_table_length异常表长度

max_stack 是指执行该方法的操作数栈深度的最大值,操作数栈是用来存放方法执行过程中的暂时数据。比如有个指令叫 aload_0,这个指令含义是将第0个变量槽的引用类型数据推送到操作数栈顶,其他的操作也是如此,有些操作需要参数,这些参数在被操作的时候会被放在操作数栈中,是不是和计算机 CPU 中的寄存器有点像?

max_locals 则是局部变量表所需的存储空间,code 就是方法的字节流了,异常表在我的另一篇博客中有介绍,而其他的属性可以省略,不是非常重要

综上,属性表被其他的表所使用,用来描述例如方法流程等信息

总结

class 文件由紧密的二进制流组成,为了可以正常读取必须按一定顺序存放数据,用无符号数来表示各种属性与表示表的大小。无论如何,一个类的所有信息都包含在了对应的 class 文件中

由于无符号数有字节的限制,因此 java 才有一些大小限制条件,比如方法名、字段名、方法长度的限制

class 文件只是一串二进制字节流,它可以是磁盘中的文件,也可以是网络传进来的数据、数据库读取进来的文件,也能是虚拟机运行时动态生成的

类装载子系统

Class 文件需要加载到虚拟机中之后才能运行和使用,对象有对象的生命周期,你可以把类的从装载到消亡的过程看作类的生命周期

除了解析这一步,其他步骤都是按顺序开始的,这里说开始是因为不同的过程可以在同一时间执行,某一过程没有结束下一个过程就可能开始了

类的生命周期有以下几步

加载

将 class 字节码文件(或者其他的)由类加载器加载到内存中,生成这个类的 class 对象,也就是在方法区中定义该类(此时仅仅让虚拟机知道有这个类,此时这个类没有内存没有数据)

但是,加载阶段和连接阶段的部分内容是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了

这一步使用到了类加载器和双亲委托机制,加载主要完成下面两件事情:JVM 的主要目的是将字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,接着会为这个类在 JVM 的方法区创建一个对应的 Class 对象,这个 Class 对象就是这个类各种数据的访问入口

连接

连接由三步组成,分别是验证、准备、解析

验证

验证读取进内存的 class 文件是否有错,又分文件结构验证(是否以 cafebaby 开头等等)、符号引用验证、字节码验证等

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,对,准备阶段只干这个事

在准备阶段,JVM 会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予 Java 语言中该数据类型的零值,而不是用户代码里初始化的值。在之后的 < clint > 方法中才会对其进行赋值

值得注意的是,jdk1.7之前 static 的变量是分配在永久代中的,jdk1.7之后它会被分配在堆中

如果变量同时被 static 与 final 修饰,会在此时进行赋值

解析

如果说准备是对字段划分区域,解析就是为方法划分区域

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。在这一阶段会进行访问符的判断,同时对一个符号引用进行多次解析也是有可能的

解析的条件是方法在真正运行之前就有一个确定的版本(不确定的版本指的是重写、重载等,这些用分派来解决),比如静态方法与私有方法,我们在该阶段得到了这些方法的地址

符号引用只是一些符号,包含在字节码文件的常量池中,它主要包括在该类中,出现过的各类包,类,接口,字段,方法等元素的全限定名,通过这些字符串,我们可以唯一定位一个元素

而直接引用就是直接指向目标的指针、相对偏移量,通过直接引用能直接找到对应方法或者变量的值

这里随便讲一下静态分派:首先说一下静态类型是指编译器可以确定的类型,比如接口,实际类型是指变化的结果在运行时才可以确定的类型,在编译时不知道变量的实际类型是什么。比如在编译时就知道 map 类型,但只有在运行时才可以确定它时 treemap 还是 hashmap

    private void justTest() {
        Map<Integer, Integer> hashMap = new HashMap<>();
        Map<Integer, Integer> treeMap = new TreeMap<>();
        this.test(hashMap);
        this.test(treeMap);
    }
    
    private void test(Map map) {
        System.out.println(map);
    }

    private void test(HashMap map) {
        System.out.println(map);
    }

    private void test(TreeMap map) {
        System.out.println(map);
    }

编译器到底是怎么确定使用哪一个重载版本呢,答案是在编译时直接使用静态类型,不管是 hashmap 还是 treemap 都会使用 map 的重载方法。由于该过程发生在编译时,因此也可以被归入解析这一过程

同时,编译器只会选择最合适的重载版本,因为可能发生的情况有很多,虚拟机程序只能通过语法的规则去选择相对合适的版本,比如在上面的代码中,将 test(Map map) 注释掉之后,编译器就会使用 test(HashMap map) 与 test(TreeMap map) 版本了

同时,java 的静态分配是多分派,因为它依据了多于一个宗量(父类对象与子类对象、传入的参数)对目标方法进行选择

总结静态分配的作用是为了处理方法重载的,即前端编译器是如何确定调用一个类中有多个相同名字的方法的。它在编译期会根据入参的静态类型、方法入参数目去选择一个相对合适的方法并且调用它

初始化

对于初始化阶段,虚拟机严格规范了有且只有5种情况下,必须对类进行初始化(只有主动去使用类才会初始化类),比如以下条件:

1,当 jvm 执行 new、static 相关指令(调用静态方法、访问类的静态变量、给静态变量赋值)时会初始化类。即当程序创建一个类的实例对象
2,使用 java.lang.reflect 包的方法对类进行反射调用时如 Class.forname ,newInstance 等等。如果类没初始化,需要触发其初始化
3,初始化一个类,如果其父类还未初始化,则先触发该父类的初始化
4,当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类

类被主动使用的时候才会初始化,其他的情况都叫被动使用,比如子类调用父类的静态变量,此时子类没有初始化。简单来说类的初始化是惰性的

而初始化就是调用类构造器的 clinit 方法,该方法是编译器的自动生成的产物,该方法有以下特点:

1,clinit 方法是由编译器按照从上到下的顺序收集的类中声明的静态变量和静态代码块合并组成的方法
2,虚拟机会先执行父类的 clinit 方法,之后再执行子类的 clinit 方法,clinit 与类的构造函数(虚拟机视角的 init)不同,不需要显示调用父类构造器
3,执行 clinit 过程中如果需要访问其他类(包括子类)的内容会去执行这个类的 clinit 后再继续执行(加载子类而父类的 clinit 方法中访问子类的变量时除外)
4,线程安全,带锁线程安全(你可能会想到 DLC)

init 方法同理,不过 init 是在对象初始化的时候才会执行的

使用

该类对象正在使用中

静态的变量或者方法(static)一般被认为和类绑定,随着类的加载而被加载

卸载

该类的class对象被GC,需要满足三个条件:

1,该类无实例对象
2,该类类加载器被GC
3,没有指向该类的引用(在JVM生命周期内,由jvm自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的)

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收

类加载过程与类加载器

上面也说过,类加载过程就是通过类的全限定名将类的二进制文件读取到虚拟机中,类加载器就是实现读取文件的东西。但是 java 并没有指定非要读 class 文件,因此 JVM 可以在这方面稍微多样化一下:比如可以读 JAR,EAR,WAR 格式、从网络中获取、甚至是运行时计算生成,最典型的是动态代理技术

类加载器

在上面的介绍里我们似乎已经讲完了类加载器是什么,但是它在 jvm 里起到的作用远超类加载阶段,对于任意一个类,都必须由它的类加载器与它本身一起确定它在 java 代码中的唯一性,具体表现为用 instanceof 方法做对象所属类型检查时会返回两个对象所属类型不同。这个设计可能有点反人类,但是它是必要的

类加载器对于虚拟机来说分为两种:启动类加载器(由 c++ 实现,未继承 ClassLoader 类,是 JVM 虚拟机的一部分)与所有其他的类加载器(java 实现,继承了 ClassLoader 类,是 java 代码的一部分)

对于开发来说,类加载器会划分的细致一些(根据目录来划分):

  • 引导(启动)类加载器:加载 jdk 中的最核心的类,加载目录是环境变量配置的目录下 lib(Java_Home/lib),这个类加载器使用C++语言实现,是虚拟机的一部分
  • 扩展类加载器:加载 jdk 中扩张的类,加载目录为 Java_Home /lib/ext。JDK 的开发团队是希望用户将通用的类放置在 ext 目录以扩张 java se 的功能,比如 common 下的代码,但是这个机制被模块化带来的天然扩展能力取代了
  • 应用程序类加载器:它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中,一般被称为系统(System)加载器

双亲委托机制

加载类时,类加载器会让父类加载器优先加载类;父类加载成功直接返回,父类加载失败则子类加载,因为父类的加载条件比子类严格,这样可以防止用户写的类覆盖 jdk 中的类

这么加载的好处是为了防止 java 核心 api 被修改,比如从网络传入了一个 java.lang.String,里面有些恶意代码,引导类加载器会阻止此类的加载,这个被称为沙箱安全机制

或者用户自定义了一个 Object 类,大家都知道 Object 是程序的核心类之一,如果其他类都依赖这个用户自定义的类程序是会崩溃的,但是因为这个双亲委托机制的存在保护了程序的安全

ClassLoader 类中的 loadClass 方法中实现了双亲委托机制,源码如下:

    if (parent != null) {
    	//父加载器不为空,调用父加载器loadClass()方法处理
        c = parent.loadClass(name, false);
    } else {
    	//父加载器为空,使用启动类加载器 BootstrapClassLoader 加载
        c = findBootstrapClassOrNull(name);
    }
    //省略一些代码
    //尝试自己加载
    c = findClass(name);

用户自定义类加载器

为什么用户想要自定义类加载器?

1,防止源码泄漏:任何人得到 class 文件都可以反编译成源码,这种情况大企业肯定是不能接受的,我们将 class 文件加密,然后通过重写类加载器来解密解密后的 class 文件

2,扩展加载源:可以从更多地方读取 class 文件,比如从网络上读取二进制字节流

用户可以自定义类加载器加载类,只需要继承 ClassLoader 重写一些方法即可:如果不想打破双亲委托机制,重写 findClass 方法

如果想打破双亲委托机制,重写 loadClass 方法。Tomcat 就打破了双亲委托机制,为了制定目录里面的类库的加载和隔离规则。因为一个 tomcat 可以运行多个 Web 应用程序,那假设我现在有两个 Web 应用程序,它们都有一个类,叫做 User,并且它们的类全限定名都一样,比如都是 com.yyy.User。但是他们的具体实现是不一样的。Tomcat 为了保证它们是不起冲突,创建一个类加载器实例,这样就做到了 Web 应用层级的隔离

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值