😊你好,我是小航,一个正在变秃、变强的文艺倾年。
🔔本专栏《八股消消乐》旨在记录个人所背的八股文,包括Java/Go开发、Vue开发、系统架构、大模型开发、机器学习、深度学习、力扣算法
等相关知识点,期待与你一同探索、学习、进步,一起卷起来叭!
目录
- 题目
- 答案
- 类编译加载执行过程
- 类编译
- 类加载
- 类连接
- 类初始化
- 即时编译
- 即时编译器
- 分层编译
- 热点探测
- 编译优化技术
- 方法内联
- 逃逸分析
题目
💬技术栈:JVM
🔍简历内容:熟悉类编译加载执行过程,有一定的JIT编译优化经验,如方法内联、逃逸分析。
🚩面试问:谈谈类编译加载执行整个过程,你是如何在项目中进行优化编译的。
💡建议暂停思考10s,你有答案了嘛?如果你有不同题解,欢迎评论区留言、打卡。
答案
类编译加载执行过程
类编译
由 JDK 中自带的 Javac 工具
完成,我们可以使用javap反编译
命令查看一个class文件主要包含哪些信息,主要包含:常量池和方法表集合
。
- 常量池:主要记录的是类文件中出现的
字面量以及符号引用
。
- 字面常量包括字符串常量(例如 String str=“abc”,其中"abc"就是常量),声明为 final 的属性以及一些基本类型(例如,范围在 -127-128 之间的整型)的属性。
- 符号引用包括类和接口的全限定名、类引用、方法引用以及成员变量引用(例如 String str=“abc”,其中 str 就是成员变量引用)等。
- 方法表集合:主要包含一些方法的字节码、方法访问权限(public、protect、prviate 等)、方法名索引(与常量池中的方法引用对应)、描述符索引、JVM 执行指令以及属性集合等。
类加载
什么时候加载:一个类被创建实例或者被其它对象引用时,虚拟机在没有加载过该类的情况下
,会通过类加载器
将字节码文件加载到内存
中。
不同的实现类由不同的类加载器加载:
(1)JDK 中的本地方法类一般由根加载器(Bootstrp loader)
加载进来
(2)JDK 中内部实现的扩展类一般由扩展加载器(ExtClassLoader )
实现加载
(3)程序中的类文件则由系统加载器(AppClassLoader )
实现加载。
在类加载后,class 类文件中的常量池信息以及其它数据
会被保存到 JVM 内存的方法区
中。
类在加载进来之后,会进行连接、初始化,最后才会被使用
。
类连接
在连接过程中,又包括验证、准备和解析
三个部分。
(1)验证:验证类符合 Java 规范和 JVM 规范
,在保证符合规范的前提下,避免危害虚拟机安全
。
(2)准备:为类的静态变量分配内存
,初始化
为系统的初始值。对于 final static
修饰的变量,直接赋值为用户的定义值
。例如,private final static int value=123,会在准备阶段分配内存,并初始化值为 123,而如果是 private static
int value=123,这个阶段 value 的值仍然为 0
。
(3)解析:将符号引用转为直接引用
的过程。我们知道,在编译时,Java 类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。类结构文件的常量池中存储了符号引用,包括类和接口的全限定名、类引用、方法引用以及成员变量引用等。如果要使用这些类和方法,就需要把它们转化为 JVM 可以直接获取的内存地址或指针,即直接引用。
类初始化
JVM 首先将执行构造器 <clinit> 方法
,编译器会在将 .java 文件编译成 .class 文件时,收集所有类初始化代码
,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为 <clinit>
() 方法。
初始化类的静态变量和静态代码块为用户自定义的值,
初始化的顺序和 Java 源码从上到下的顺序一致
。
子类初始化时会
首先调用父类的 <clinit>() 方法
,再执行子类的 <clinit>() 方法
JVM 会保证 <clinit>()
方法的线程安全,保证同一时间只有一个线程执行
。
JVM 在初始化执行代码时,如果实例化一个新对象,会调用 <init>
方法对实例变量进行初始化,并执行对应的构造方法内的代码。
即时编译
初始化完成后,类在调用执行过程
中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行
。
在字节码转换为机器码
的过程中,虚拟机中还存在着一道编译,那就是即时编译
。
即时编译器
作用:
(1)最初,虚拟机中的字节码是由解释器( Interpreter )完成编译的
,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码
”。
(2)为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化
,然后保存到内存中
。
类型:在 HotSpot 虚拟机中,内置了两个 JIT,分别为 C1 编译器和 C2 编译器。
(1)C1 编译器是一个简单快速的编译器
,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序
,例如,GUI 应用对界面启动速度就有一定要求。
(2)C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器
,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这两种即时编译也被称为 Client Compiler 和 Server Compiler
。
分层编译
Java7 之前:需要根据程序的特性来选择对应的 JIT
,虚拟机默认采用解释器和其中一个编译器
配合工作。
Java7:引入了分层编译
,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势
,我们也可以通过参数 “-client”“-server”
强制指定虚拟机的即时编译模式。
分层编译将 JVM 的执行状态分为了 5 个层次:
- 第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;
- 第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling;
- 第 2 层:也称为 C1 编译,开启 Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译;
- 第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译;
- 第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
Java8:默认开启分层编译,-client 和 -server 的设置已经是无效的了
。如果只想开启 C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想用 C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1。
除了这种默认的混合编译模式
,我们还可以使用“-Xint”
参数强制虚拟机运行于只有解释器
的编译模式下,这时 JIT 完全不介入工作
;我们还可以使用参数“-Xcomp”
强制虚拟机运行于只有 JIT 的编译模式
下。
通过 java -version
命令行可以直接查看到当前系统使用的编译模式。
热点探测
热点探测是基于计数器的热点探测
,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数
,如果执行次数超过一定的阈值就认为它是“热点方法” 。
虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)
。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。
(1)方法调用计数器:用于统计方法被调用的次数
,方法调用计数器的默认阈值在 C1 模式下是 1500 次
,在 C2 模式在是 10000 次
,可通过 -XX: CompileThreshold 来设定;而在分层编译的情况下,-XX: CompileThreshold 指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发 JIT 编译器。
(2)回边计数器:用于统计一个方法中循环体代码执行的次数
,在字节码中遇到控制流向后跳转
的指令称为“回边”(Back Edge)
,该值用于计算是否触发 C1 编译的阈值,在不开启分层编译的情况下,C1 默认为 13995,C2 默认为 10700
,可通过 -XX: OnStackReplacePercentage=N 来设置;而在分层编译的情况下,-XX: OnStackReplacePercentage 指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。
建立回边计数器的主要目的是为了触发 OSR(On StackReplacement)编译,即栈上编译
。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM 会认为这段是热点代码,JIT 编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言。
编译优化技术
方法内联
把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。
(1)JVM 会自动识别热点方法
,并对它们使用方法内联进行优化
。可以通过 -XX:CompileThreshold 来设置热点方法的阈值。
(2)热点方法不一定会被 JVM 做内联优化
,如果这个方法体太大了,JVM 将不执行内联操作。
(3)方法体的大小阈值设置:
-
经常执行的方法
,默认情况下,方法体大小小于 325 字节
的都会进行内联,我们可以通过 -XX:MaxFreqInlineSize=N 来设置大小值; -
不是经常执行的方法
,默认情况下,方法大小小于 35 字节
才会进行内联,我们也可以通过 -XX:MaxInlineSize=N 来重置大小值。
(4)可以通过配置 JVM 参数来查看到方法被内联的情况:
提高方法内联的方式:
(1)通过设置 JVM 参数来减小热点阈值或增加方法体阈值
,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存;
(2)在编程中,避免在一个方法中写大量代码,习惯使用小方法体
;
(3)尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查
。
逃逸分析
逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问
的分析技术,编译器会根据逃逸分析的结果对代码进行优化。
(1)栈上分配:如果发现一个对象只在方法中使用
,就会将对象分配在栈上。
原因:Java 中默认创建一个对象是在堆中分配内存的
,而当堆内存中的对象不再使用时,则需要通过垃圾回收机制回收,这个过程相对分配在栈中的对象的创建和销毁来说,更消耗时间和性能
。
(2)锁消除:在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争
,这个时候 JIT 编译会对这个对象的方法锁进行锁消除
。
(3)标量替换:如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替
。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。
可以通过设置 JVM 参数来开关逃逸分析,还可以单独开关同步消除和标量替换,在 JDK1.8 中 JVM 是默认开启这些操作的。
🔨复盘:
(1)在 Java8 之前,HotSpot 集成了两个 JIT,用 C1 和 C2 来完成 JVM 中的即时编译
。虽然 JIT 优化了代码,但收集监控信息会消耗运行时的性能,且编译过程会占用程序的运行时间。
(2)到了 Java9,AOT 编译器被引入。和 JIT 不同,AOT 是在程序运行前进行的静态编译
,这样就可以避免运行时的编译消耗和内存消耗,且 .class 文件通过 AOT 编译器是可以编译成 .so 的二进制文件的。
(3)到了 Java10,一个新的 JIT 编译器 Graal 被引入。Graal 是一个以 Java 为主要编程语言、面向 Java bytecode 的编译器。与用 C++ 实现的 C1 和 C2 相比,它的模块化更加明显,也更容易维护。Graal 既可以作为动态编译器,在运行时编译热点方法;也可以作为静态编译器,实现 AOT 编译
。