在我们先前探讨的文章中,我们已经深入到了Java虚拟机(JVM)的内部,透视了其如何通过元空间存储类的元数据和字节码。JVM的设计初衷是为了实现跨平台兼容性,但随着时间的推移,为了去满足性能和优化的需求,它的结构变得越来越复杂。
JVM中的元空间确实包含了大量的元数据,这些元数据为运行时提供了关于类、方法和字段的重要信息。但为什么在有了这么丰富的元数据之后,JVM还需要保留字节码呢?
答案就在执行引擎。执行引擎是JVM的核心部分,它负责将字节码翻译为可以在特定硬件上运行的机器代码。但是,这并不是一次性的过程。为了提高性能,JVM会使用Just-In-Time (JIT) 编译技术,将“热点”代码段编译成机器代码,从而大大加速程序的执行速度。
因此,尽管元数据为JVM提供了关于类和方法的大量信息,但字节码的存在是为了允许执行引擎进行即时编译优化。这个细节不仅揭示了JVM的优雅设计,也为我们展现了Java为什么能在保持跨平台特性的同时,还能提供出色的性能。
在本篇文章中,我们将更深入地探讨执行引擎,了解其如何与其他JVM组件协同工作,以及如何利用现代硬件的特性来提高Java应用的性能。
在深入探索Java虚拟机(JVM)的执行引擎之前,我们首先需要了解其主要组成部分和功能。执行引擎是JVM中的一个核心组件,负责管理和执行Java字节码。它主要由以下部分组成:
在Java程序初次运行时,大多数的字节码是由解释器执行的。解释器逐行读取字节码并翻译成本地机器指令执行。这种方法虽然跨平台,但效率相对较低,因为每次执行相同的字节码都需要重新解释。
字节码解释器的核心是一个巨大的switch-case结构,用于处理每一个Java字节码指令。源码位于:bytecodeInterpreter.cpp。部分源码如下,是不是很熟悉?
//...
CASE(_bipush):
SET_STACK_INT((jbyte)(pc[1]), 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(2, 1);
/* Push a 2-byte signed integer constant onto the stack. */
CASE(_sipush):
SET_STACK_INT((int16_t)Bytes::get_Java_u2(pc + 1), 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
/* load from local variable */
CASE(_aload):
VERIFY_OOP(LOCALS_OBJECT(pc[1]));
SET_STACK_OBJECT(LOCALS_OBJECT(pc[1]), 0);
UPDATE_PC_AND_TOS_AND_CONTINUE(2, 1);
//...
上述代码展示了解释器如何处理不同的字节码。对于每一种字节码,解释器都有相应的操作来翻译并执行。
逐行解析字节码也太慢了,有办法优化吗?
为了提高执行效率,JVM引入了JIT编译器。当某部分代码被频繁执行(称为“热点”代码)时,JIT编译器会将这部分字节码编译成本地机器代码。这样,下次执行时,JVM可以直接运行这段已编译的机器代码,从而大大提高执行速度。
JIT编译器的工作是将热点代码编译为本地机器代码。这就是为什么我上篇的元空间中既有类元信息又要有字节码。
对于一个经常被调用的方法,JIT可能会这样处理,我写了一个伪代码,你可以看下:
if (method->is_hot()) {
// 通过C2编译成机器码
compile_method_with_c2_compiler(method);
} else {
// 解释
interpret_method(method);
}
C2是啥?我们接着往下看
JVM采用分层编译策略,其中C1和C2是两个主要的编译器。C1,也称为客户端编译器,提供快速编译,但优化程度较低。而C2,也称为服务器编译器,提供高级优化,但编译速度较慢。
在OpenJDK中,有两个主要的JIT编译器:C1和C2。C1编译器快速,但优化较少,而C2编译器则进行深度优化。
if (UseC1Compiler) {
// 使用C1编译器
} else if (UseC2Compiler) {
// 使用C2编译器
}
尽管垃圾收集主要与内存管理相关,但它与执行引擎紧密相连。执行引擎需要与垃圾收集器协同工作,以确保在执行Java代码时,不会因为内存问题而中断或崩溃。
当Java源代码被编译后,它转化为一个或多个字节码文件(.class文件)。这些文件包含了JVM可解释和执行的指令集。Java字节码是源代码和机器代码之间的中间表示形式。
前面我简单介绍了一下字节码执行核心BytecodeInterpreter
,我们来介绍一下其它角色:
templateInterpreterGenerator是templateTable的生成器,它为每种字节码指令生成对应的机器代码模板。这些模板在JVM启动时只生成一次,并存储在templateTable中,以供BytecodeInterpreter在后续执行时使用。
templateTable为字节码指令提供了相应的机器代码模板。这意味着,对于每种字节码,templateTable都有一个预定义的、优化过的机器代码序列,该序列可以直接在物理硬件上执行。非常重要!
总而言之,BytecodeInterpreter
是处理 JVM 字节码逻辑的核心,而 templateTable
提供了对应的机器代码模板,这些模板是由 templateInterpreterGenerator
生成的。
templateInterpreterGenerator
也被触发。templateInterpreterGenerator
的主要任务是为每个Java字节码生成一个对应的机器代码模板。这些模板定义了如何在特定的硬件和操作系统上执行这些字节码。templateTable
中,为解释器在运行时使用。BytecodeInterpreter
开始逐条读取并解释字节码。BytecodeInterpreter
会查询templateTable
以找到对应的机器代码模板。BytecodeInterpreter
将执行对应的操作,从而实现字节码的功能。这样,通过在启动阶段预先生成模板,JVM避免了在运行时为每个字节码重新生成机器代码,从而提高了效率。
字节码解释器在Java虚拟机(JVM)中主要是用来执行Java字节码的。当JVM启动时,它默认使用解释器来逐条解释并执行字节码。但在实际的JVM实现中,尤其是像HotSpot这样的高性能JVM,除了解释执行,还会使用JIT编译器来将某些字节码编译成本地机器代码以提高性能。因此,字节码解释器主要在以下几种情况下解释执行字节码:
解释器并不会将字节码持久性地转换为机器代码。相反,它在运行时逐条读取字节码,并根据每条指令执行相应的操作。因此,每次程序运行时,字节码都需要被重新解释。
这与JIT编译器形成了对比,JIT编译器会在运行时将热点代码编译为机器代码,从而提高性能。
解释器知道如何将其转换为一系列的机器指令,我们在上文介绍的 templateTable
里面记录了机器代码模板,这非常重要。
换而言之,执行引擎只有解释器它也可以工作,只是编译器提供了一种优化手段,仅此而已。我们来看下这两种编译上的区别吧~
解释执行:字节码在每次执行时都被解释器逐条解释并执行。这种方式的主要优点是移植性,但代价是性能较低。
编译执行:JIT编译器将频繁执行的字节码片段(热点代码)编译为机器代码,并存储起来。当这些代码片段再次被调用时,JVM可以直接执行已编译的机器代码,而无需再次解释字节码,从而大大提高了执行效率。
总结来说,虽然字节码解释器为Java提供了出色的移植性,但从性能的角度看,JIT编译器是更优的选择。然而,在这种情况下:JVM首次启动或当应用只运行一小段时间时,解释执行就派上用场了。
JIT,即“Just-In-Time”编译器,是Java虚拟机的一部分,其主要任务是将热点代码(即频繁执行的代码)从字节码转化为本地机器代码。这种转化过程称为“即时编译”。与传统的“Ahead-Of-Time”编译相反,JIT编译只在运行时进行。
性能提升:机器代码通常执行速度比字节码快。当JVM检测到某段字节码被频繁执行(例如,循环中的代码或被频繁调用的方法),JIT编译器将这些字节码编译为机器代码,从而提高执行速度。
平台独立性:Java字节码是跨平台的,可以在任何支持Java的平台上执行。JIT编译器允许这些字节码在特定的硬件和操作系统上转化为高效的机器代码。
动态优化:由于JIT编译在运行时发生,编译器可以利用运行时的信息进行更有效的优化,如内联缓存、逃逸分析等。
OpenJDK中的JIT编译器使用内部的分析器(Profiler)来跟踪代码的执行频率。“热点”代码的晋升主要通过两个指标:方法调用次数和循环的回边次数。接下来,我们通过源码角度来分析以下。
要找到方法调用次数和循环的回边次数,我们定位到methodData.hpp
头文件,代码如下:
// How many invocations has this MDO seen?
// These counters are used to determine the exact age of MDO.
// We need those because in tiered a method can be concurrently executed at different levels.
// MDO就是MethodDataObject
InvocationCounter _invocation_counter;
// Same for backedges.
InvocationCounter _backedge_counter;
在templateInterpreterGenerator.hpp
可以看到计数器增加的方法:
void generate_counter_incr(Label* overflow);
在这两个值溢出的情况下,设置状态位:
// 处理回边计数器的溢出情况
void CompilationPolicy::handle_counter_overflow(const methodHandle& method) {
MethodCounters *mcs = method->method_counters();
if (mcs != nullptr) {
mcs->invocation_counter()->set_carry_on_overflow();
mcs->backedge_counter()->set_carry_on_overflow();
}
MethodData* mdo = method->method_data();
if (mdo != nullptr) {
mdo->invocation_counter()->set_carry_on_overflow();
mdo->backedge_counter()->set_carry_on_overflow();
}
}
根据状态位决定是否使用JIT编译器。
JIT编译器使用多种优化策略来提高生成的机器代码的性能:
实例背景:我们将实现一个简单的数字排序程序,使用插入排序算法。首先,我们将提供一个未优化的版本,然后观察其性能。接着,我们会对其进行优化,以体现如何使代码变成热点并提高效率。
1. 初始版本:
public class InsertionSort {
public static void sort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
public static void main(String[] args) {
int[] data = new int[10000];
for (int i = 0; i < data.length; i++) {
data[i] = (int)(Math.random() * 10000);
}
long startTime = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
sort(data.clone());
}
long endTime = System.currentTimeMillis();
System.out.println("Duration: " + (endTime - startTime) + "ms");
}
}
当我们多次运行上述代码,sort
方法会被频繁调用,并且内部的while
循环也会被大量执行,这使得它们很可能被JIT识别为热点代码。
2. 优化:
为了提高效率,我们可以考虑以下几点:
-XX:+PrintCompilation
。这样,我们可以知道哪些方法被JIT编译。结论:
JVM会根据代码的运行情况动态地决定何时进行JIT编译。频繁执行的代码块更有可能被识别为热点代码,并被JIT编译成本地机器代码,从而提高程序的运行速度。通过观察和调整代码的执行方式,我们可以有效地促使JVM进行更多的即时编译,进一步提高代码的执行效率。
这样的实战分析有助于读者更加直观地理解JIT编译器的工作机制以及如何在实际应用中利用其优势。
实例背景:我们将实现一个简单的程序,该程序计算从1到一个大数字(如10,000,000)的累加和。首先,我们将编写两个版本的累加函数:一个是循环版本,另一个是递归版本。循环版本很可能被JIT识别为热点代码并进行优化,而递归版本则可能不会。
1. 循环版本:
public static long sumUsingLoop(long n) {
long sum = 0;
for (long i = 1; i <= n; i++) {
sum += i;
}
return sum;
}
2. 递归版本:
public static long sumUsingRecursion(long n) {
if (n <= 1) {
return n;
}
return n + sumUsingRecursion(n - 1);
}
测试性能:
public static void main(String[] args) {
long n = 10000; // 小点的数字用于递归,以免栈溢出
long startTime = System.currentTimeMillis();
sumUsingLoop(10000000); // 更大的数字用于循环版本
long endTime = System.currentTimeMillis();
System.out.println("Loop Duration: " + (endTime - startTime) + "ms");
startTime = System.currentTimeMillis();
sumUsingRecursion(n); // 小数字用于递归
endTime = System.currentTimeMillis();
System.out.println("Recursion Duration: " + (endTime - startTime) + "ms");
}
结论:
循环版本由于被频繁执行,JIT编译器将其识别为热点代码并进行即时编译。因此,即使处理的数字比递归版本大得多,它仍然运行得更快。而递归版本由于函数调用栈的开销,以及它不太可能被JIT编译优化(尤其是深度很大的递归),其性能会较差。
当我们谈论JVM的JIT编译时,经常听到"C1"和"C2"这两个术语。这两者实际上是Java HotSpot VM的两种不同的JIT编译器。在JVM启动时,它们两者都会被启动。那么,为什么需要两种JIT编译器呢?
快速启动与最大化性能:
渐进式的优化:
当一个方法首次被执行时,它被C1编译器编译,并进行一些基本的优化。如果这个方法被频繁执行(即成为热点),C2编译器会重新编译它,执行更深入的优化。
JVM会根据方法的执行频率和方法的热度来决定何时以及如何进行编译。例如,当方法被执行了一定次数后,它可能首先被C1编译器编译(层次1)。随着它的执行次数继续增加,它可能会被升级到层次2或3。最后,如果它是一个真正的热点方法,它会被C2编译器编译(层次4)。
分层编译是Java HotSpot VM为了在启动性能和长时间运行性能之间取得平衡而采用的策略。通过使用两个不同的JIT编译器,它确保了应用程序在启动时能够快速响应,并且在长时间运行时能够获得最大的性能。
Java的自动内存管理,也就是垃圾收集(Garbage Collection, GC),是Java编程语言的核心特性之一。垃圾收集器负责自动检测并回收那些不再被程序引用的对象,从而防止内存泄漏。尽管垃圾收集与执行引擎在Java虚拟机内部的组织结构中属于不同的部分,但它们之间存在紧密的关联。
Java提供了多种垃圾收集器,每个都有其特定的用途和优点:
垃圾收集是Java虚拟机的核心组成部分,它确保了内存的有效利用并防止了内存泄漏。尽管它与执行引擎是独立的,但两者之间的交互确保了Java程序的平稳运行。正确地理解和配置垃圾收集器可以大大提高应用的性能和响应时间。
在了解了执行引擎的基本组成和工作方式后,本章将深入探讨执行引擎的一些关键优化技术。这些优化技术旨在确保Java代码运行得更快、更稳定。我们将探索以下几个主题:
想象你有一个习惯,那就是每天上班前去同一家咖啡店买咖啡。店员很快就认识了你,也知道你每天都点同样的咖啡。所以,当你走进咖啡店时,店员已经开始为你准备你喜欢的咖啡。这样,你几乎不用等待,就能迅速拿到咖啡并离开。
这就是一个现实生活中的**“内联缓存”。店员(JVM)“缓存”**了你的选择(对象的类型和它的方法),以便在下次你访问时更快地为你服务。但是,如果某一天你改变了主意,想要点别的咖啡,店员就需要重新调整,准备新的咖啡给你。同样的,如果JVM遇到一个新的对象类型调用同一个方法,它也需要“清除”缓存并重新“学习”这个新的类型。
如果你继续改变你的咖啡选择,店员可能会决定不再提前为你准备咖啡,而是等你告诉他们你的选择后再开始。这类似于JVM的“多态内联缓存”,当多个对象类型都调用同一个方法时,JVM会跟踪这些类型,并在运行时决定应该调用哪个版本的方法。
在理解了执行引擎的内部工作原理和结构之后,我们可以更好地知道如何在实际应用中利用这些知识。这一章节将探讨如何监控、诊断和优化JVM的执行性能。
jstat
: 用于监视JVM的统计信息。jmap
: 提供堆转储、类统计信息等。jstack
: 用于打印线程堆栈跟踪。jconsole
: 图形化的监控工具,提供了关于内存使用、线程活动和JIT编译的信息。-XX:+PrintCompilation
标志开启JIT编译日志,这会显示哪些方法被JIT编译。-Djava.compiler=NONE
参数可以禁止JIT编译。在Java虚拟机执行过程中,为了某些操作(如垃圾收集)能够安全地进行,有时需要确保不会发生某些系统级的更改。例如,为了进行垃圾收集,需要确保没有线程正在或可能访问或修改堆内存中的对象。为了实现这个目的,JVM定义了所谓的“安全点”概念。
简而言之,安全点是那些线程暂停执行原有字节码的地方,转而执行一些特殊操作,例如垃圾收集,然后再恢复到原来的执行点。
为什么选择字节码指令的边界作为安全点?
字节码指令的边界是JVM中预定义的点,JVM知道在这些点上,线程不会执行任何会更改堆内存内容的操作。因此,这些点被视为“安全的”,可以进行垃圾收集或其他需要整个JVM暂停的操作。
不是每一条字节码指令后面都是一个安全点。事实上,安全点的位置是由JVM的实现和其设置决定的。但为什么大部分安全点位于字节码指令的边界,而不是在指令的中间或其他地方?
答案在于效率和简单性。如果JVM试图在字节码指令的中间插入一个安全点,这将使得JVM的实现更为复杂,并可能导致性能问题。相反,在指令的边界上设置安全点更为简单,易于管理,且效率更高。
当JVM达到一个安全点时,所有正在执行的线程都会暂停。这种暂停可能会导致微小的性能开销,但它对于系统的整体健康和稳定性是必要的。
在实际应用中,开发者通常不需要关心安全点的位置或其存在。然而,对于那些关心JVM性能和内部工作机制的开发者来说,了解这些机制可以帮助他们更好地理解JVM的行为,以及为什么某些操作(如垃圾收集)需要所有线程暂停。
Safepoint
类: 这是OpenJDK中与安全点相关的核心类。你可以在src/hotspot/share/runtime/safepoint.hpp
和.cpp
中找到它。该类包含了许多与安全点同步相关的函数。
SafepointMechanism
类: 在src/hotspot/share/runtime/safepointMechanism.hpp
和.cpp
中,这个类处理安全点的具体机制,例如检查线程是否应该在安全点上暂停。
安全点检查:在OpenJDK中,为了执行某些系统范围的操作(例如全局的垃圾收集),可能需要暂停所有Java线程。这些暂停发生在称为"safepoints"的特定代码位置。在CompileBroker.cpp
中,有一个set_should_block()
方法:
void CompileBroker::set_should_block() {
assert(Threads_lock->owner() == Thread::current(), "must have threads lock");
assert(SafepointSynchronize::is_at_safepoint(), "must be at a safepoint already");
#ifndef PRODUCT
if (PrintCompilation && (Verbose || WizardMode))
tty->print_cr("notifying compiler thread pool to block");
#endif
_should_block = true;
}
此方法会设置一个标志_should_block = true;
,指示编译线程池或者线程应当被阻塞,不允许继续它的活动。一般是因为系统已经处于或正要进入一个安全点,并且希望确保在此期间不会有新的编译活动。
BLOCKED
或WAITING
状态的线程已经被视为在安全点上,而正在执行Java代码的线程需要到达下一个字节码边界才能达到安全点。经过前面的深入探讨,我们已经对Java执行引擎有了全面的了解。从其基本结构到其工作原理,再到其在实际应用中的表现,Java执行引擎是Java虚拟机中的核心组件之一。
回到我们一开始的讨论,Java虚拟机的元空间确实既存放了元数据也保存了字节码,这些都是为了更好地服务于执行引擎和JIT编译优化。知道这一点,我们更能理解JVM设计背后的深层次原因。