下面是我对上面所链接文章的翻译,如有错误,可以私信我或者在下方评论中提出,我会在第一时间作出修正,谢谢。
正文:
不管你是否用Java语言开发程序,你可能或多或少听说过Java虚拟机(JVM)。
JVM是Java生态系统的核心,使得基于Java的程序能够“write once,run anywhere”,即你可以用在一台机器上写的Java代码,运行在任何使用JVM的机器上。
JVM最初是为了支持Java而设计的。但随着时间的推移,在Java平台上,例如Scala,Kotlin和Groovy等诸多语言都被采用。这些语言被统称为JVM语言。
在本篇文章中,我们将了解JVM是如果工作的以及它由哪些部件所组成。
什么是虚拟机(Virtual Machine)?
在我们跳到JVM之前,先回顾一下虚拟机的概念。
一台虚拟机是一台物理机的虚拟表示,我们把虚拟机成为客户机(guest machine),物理计算机称为宿主机(host machine)。
单个物理机可以运行多个虚拟机,每个虚拟机都有自己的操作系统和应用程序。虚拟机之间是相互隔离的。
什么是Java虚拟机
在像C和C++的编程语言中,代码首先被编译成特定于平台的机器代码。这些语言被称为编译式语言。
另一方面,在像JavaScript和Python的语言中,计算机直接执行指令,而无需编译。这些语言被称为解释型语言。
Java两种技术都采用。Java代码首先被编译成字节码,并生成类文件(后缀为.class的文件)。然后,Java虚拟机会为底层的平台解释类文件。运行于任何平台和操作系统的任意版本虚拟机,都能执行相同的类文件。
与虚拟机相似的是,JVM也会在宿主机上创建一个隔离的空间。该空间能够执行Java程序,且不受这台机器上的平台或操作系统影响。
Java虚拟机架构
Java虚拟机由三个不同的部件构成:
- 类加载器 (Class Loader)
- 运行时内存/数据区(Runtime Memory/Data Area)
- 执行引擎(Execution Engine)
类加载器
当你编译一个Java源文件,它会被编译成字节码文件。在你的程序中,当你尝试使用使用这个类时,类加载器会把它加载到内存。
通常,含有main方法的类会被第一个加载到内存中。
类加载过程包含三个阶段:加载(loading),链接(linking),初始化(initialization)。
加载(Loading)
加载是通过一个指定的名称,获得一个类或接口的二进制表示(字节码),并通过它生成原始类或接口。
在Java中,有三种内置类加载器:
- 引导类加载器(Bootstrap Class Loader)—— 根类加载器。它是扩展类加载器的父类,并加载像java.lang,java.net,java.util,java.io等其它标准Java包。这些包在rt.jar文件中,而其他的核心库在$JAVA_HOME/jre/lib目录。
- 扩展类加载器(Extension Class Loader)—— 是引导类加载器的子类,应用加载器的父类。它在$JAVA_HOME/jre/lib/ext目录加载标准Java库的扩展。
- 应用类加载器(Application Class Loader)—— 是最后的类加载器,是扩展类加载器的子类。它加载在类路径(classpath)中的文件。类路径默认是应用的当前路径。类路径可用通过命令行选项 -classpath 或 -cp 修改。
JVM使用ClassLoader.loadClass() 方法把类加载到内存。它基于完全限定名加载类。如果父类加载器不能发现一个类,它会把这项工作委托给子类加载器。如果最后一个子类加载其也不能加载这个类,它会抛出异常 NoClassDefFoundError 或 ClassNotFoundException。
链接(Linking)
类被加载到内存之后,会经历链接过程。
链接一个类或接口是把程序的不同元素和依赖组合在一起。
链接包行下面这些步骤:
验证(Verification): 这个阶段通过检查一系列约束或规则检查 .class 文件的结构是否正确。如果由于一些原因而验证失败,我们会得到异常 VerifyException。
例如,如果用Java 11 构建的代码运行在安装了Java 8 的系统上,验证阶段将会失败。
准备(Preparation): 在这个阶段,JVM会为一个类或接口的静态字段分配内存,并用默认值初始化她们。
例如,你在类中声明了下面的变量:
private static final boolean enabled = true;
在准备阶段,JVM为变量 enabled 分配内存,并把它的值设置为boolean类型的默认值false。
解析(Resolution): 在这个阶段,符号引用被运行时常量池(runtime constant pool)中的直接引用替换。
例如:如果你引用其它类或在其它类中的常量,它们会被解析替换成它们实际上的引用。
初始化(Initialization)
初始化是执行类或接口的初始方法(称为< clinit >)。它包括调用类的构造器(constructor),执行静态代码块,为所有的静态变量赋值。这是类加载的最后一个阶段。
例如,下方是前面所声明的变量:
private static final boolean enabled = true;
在准备阶段,变量被设为默认值false。在初始化阶段,该变量被分配它实际的值true。
注意:JVM是多线程的,多个线程在相同的时间初始化相同的类是有可能的。这会导致并发问题,你需要处理线程安全以确保程序能在多线程环境中正常运行。
运行时数据区(Runtime Data Area)
在运行时数据区有五个部件:
让我们分别看一个每个部件。
方法区(Method Area)
类级别的所有数据,如运行时常量池,字段,方法数据以及方法和构造器的代码,都存储在这里。
如果方法区中的内存不足以让程序启动,JVM会抛出异常 OutOfMemoryError。
例如,假设你定义下面这个类:
public class Employee {
private String name;
private int age;
public Employee(String name, int age) {
this.name = name;
this.age = age;
}
}
在实例代码中,例如name,age等字段级数据以及构造器信息被载入方法区。
方法区在虚拟机启动时被创建,一个JVM只有一个方法区。
堆区(Heap Area)
所有的对象以及他们的实例变量被保存在这里。这是为所有对象实例和数组分配内存的运行时数据区。
例如,假设你声明了如下的实例:
Employee employee = new Employee();
在示例代码中,Employee的实例被创建并载入堆区。
堆区在虚拟机启动时被创建,一个JVM只有一个堆区。
注意:由于多个线程在方法区和堆区中共享同样的内存,所以存储在这里的数据不是线程安全的。
栈区(Stack Area)
在JVM中,每当创建一个新的线程,就会在相同时刻创建一个独立的运行时栈。所有的局部变量,方法调用以及部分结果都会被存储在栈中。
在处理线程的过程中,如果需要比可分配空间更大的栈空间,JVM会抛出异常StackOverflowError。
对于每个方法调用,都会在栈内存中创建一个条目,称为栈帧。 方法调用完成后,栈框将被销毁。
栈帧由三部分组成:
- 局部变量(Local Variables)—— 每一帧包含一个被称为局部变量的数组。所有的局部变量和它们的值被存储在这里。数组的长度在编译时确定。
- 操作数栈(Operand Stack)—— 每一帧都有一个被称为操作数栈的后进先出栈,这作为运行时工作区,用于执行任何中间操作。该栈的最大深度在编译时确定。
- 数据帧(Frame Data) —— 与方法相对应的所有符号都被存储在这里。如果有异常,这里也存储catch块的信息。
例如,假设有如下代码:
double calculateNormalisedScore(List<Answer> answers) {
double score = getScore(answers);
return normalizeScore(score);
}
double normalizeScore(double score) {
return (score – minScore) / (maxScore – minScore);
}
在代码示例中,score,answers等变量存储在局部变量数组中。操作数栈包含变量以及用于执行减法与除法操作符。
注意:由于栈区不是共享的,所以它是线程安全的。
程序计数器(Program Counter Registers)
JVM能同时支持多个线程。每个线程都有自己的程序计数器,用于保存当前在执行的JVM指令的地址。一旦该指令被执行,程序计数器会更新到下一条。
本地方法栈(Native Method Stacks)
JVM包含支持本地方法的栈,这些方法不是用Java语言写的,而是C或C++等其它语言。每创建一个新的线程,都会创建一个独立的本地方法栈。
执行引擎(Execution Engine)
当字节码被载入内存,并且它的详细信息可在运行时数据区获得的时候,下一步就是运行程序。执行引擎通过执行每个类中的代码来完成这一步。
然而,在执行程序之前,字节码需要被转换成机器代码指令。JVM可以将解释器或JIT编译器用于执行引擎。
解释器(Interpreter)
解释器逐行读取并解释字节码指令。由于是逐行执行,解释器相对较慢。
解释器的另一个缺点是,当多次调用某个方法时,每次都需要进行新的解释。
JIT 编译器(JIT Compiler)
JIT编译器克服了解释器的缺点。 执行引擎首先使用解释器执行字节代码,但是当发现重复的代码时,它将使用JIT编译器。
然后,JIT编译器将编译整个字节码,并将其更改为本地机器代码。 此本地机器代码直接用于重复的方法调用,从而提高了系统的性能。
JIT编译器具有以下组件:
- 中间代码生成器(Intermediate Code Generator)——生成中间代码
- 代码优化器(Code Optimizer)——优化中间代码以获得更好的性能
- 目标代码生成器(Target Code Generator)——将中间代码转换为本地机器代码
- 剖析器(Profiler)——查找热点(重复执行的代码)
为了更好地理解解释器和JIT编译器之间的区别,假定您具有以下代码:
int sum = 10;
for(int i = 0 ; i <= 10; i++) {
sum += i;
}
System.out.println(sum);
解释器将为循环中的每次迭代从内存中获取sum的值,将i的值添加到其中,然后将其写回到内存中。 这是一项昂贵的操作,因为它每次进入循环都会访问内存。
但是,JIT编译器将识别出此代码具有HotSpot,并将对其进行优化。 它将在线程的程序计数器中存储sum的本地副本,并将在循环中继续将i的值添加到该副本中。 一旦循环完成,它将把sum的值写回到内存中。
注意:与解释器逐行解释代码相比,JIT编译器花费更多的时间来编译代码。 如果您只打算运行一次程序,那么使用解释器会更好。
垃圾回收器(Garbage Collector)
垃圾回收器收集并从堆区域中删除未引用的对象。 它是通过销毁运行时未使用的内存来自动回收它们的过程。
垃圾回收使Java内存高效,因为垃圾回收从堆内存中删除了未引用的对象,并为新对象腾出了可用空间。 它包含两个阶段:
- 标记(Mark)——在此步骤中,GC会识别内存中未使用的对象。
- 扫描(Sweep)——在此步骤中,GC会删除上一阶段中标识的对象。
垃圾回收是由JVM定期自动完成的,不需要单独处理。 也可以通过调用System.gc()来触发它,但是不能保证执行。
JVM包含3种不同类型的垃圾回收器:
- 串行垃圾回收器(Serial GC)——这是最简单的GC实现,是为在单线程环境中运行的小型应用程序设计的。 它使用单个线程进行垃圾回收。当它运行时,将导致“停止世界(stop the world)”事件,整个应用程序将暂停。 使用串行垃圾收集器的JVM参数是 -XX:+ UseSerialGC。
- 并行垃圾回收器(Parallel GC )——这是JVM中GC的默认实现,也称为吞吐量回收器。 它使用多个线程进行垃圾回收,但是在运行时仍会暂停应用程序。 使用并行垃圾收集器的JVM参数是-XX:+ UseParallelGC。
- 垃圾优先垃圾回收器(Garbage First (G1) GC )——G1GC是为具有大可用堆大小(大于4GB)的多线程应用程序设计的。 它将堆划分为一组大小相等的区域,并使用多个线程对其进行扫描。 G1GC会识别出垃圾最多的区域,并首先对该区域执行垃圾收集。 使用G1垃圾收集器的JVM参数是-XX:+ UseG1GC
注意:还有另一种垃圾回收器,称为并发标记扫描垃圾回收器(Concurrent Mark Sweep (CMS) GC)。 但是,从Java 9开始不推荐使用它,而在Java 14中完全删除了它以支持G1GC。
Java本地接口(Java Native Interface)
有时,有必要使用本地(非Java)代码(例如C / C ++)。 这可能是在我们需要与硬件交互或克服Java中的内存管理和性能约束的情况下。 Java支持通过Java本地接口(JNI)执行本地代码。
JNI充当了桥梁,以允许使用其他编程语言(例如C,C ++等)的支持包。 这在需要编写Java不完全支持的代码(例如某些只能用C编写的平台特定功能)的情况下特别有用。
您可以使用native关键字指示方法实现将由本地库提供。 您还需要调用System.loadLibrary()将共享的本地库加载到内存中,并使它的功能可用于Java。
本地方法库(Native Method Libraries)
本机方法库是以其他编程语言(例如C,C ++和汇编语言)编写的库。 这些库通常以.dll或.so文件的形式存在。 这些本机库可以通过JNI加载。
常见的JVM错误
- ClassNotFoundExcecption——当类加载器尝试使用Class.forName(),ClassLoader.loadClass()或ClassLoader.findSystemClass()加载类,但未找到具有指定名称的类的定义时,会发生这种情况。
- NoClassDefFoundError——当编译器成功编译了类,但类加载器无法在运行时找到类文件时,会发生这种情况。
- OutOfMemoryError——当JVM由于内存不足而无法分配对象并且垃圾回收器无法再提供更多内存时,会发生这种情况。
- StackOverflowError——如果JVM在处理线程创建新的堆栈帧时空间不足,则会发生这种情况。
总结
在本文中,我们讨论了Java虚拟机的体系结构及其各种组件。 通常,我们不会深入研究JVM的内部机制,也不会在我们的代码工作时关心它的工作方式。
只有当出现问题并且需要调整JVM或修复内存泄漏时,我们才会尝试了解其内部机制。
这也是一个非常受欢迎的面试问题,无论是初级职位还是高级职位,都涉及后端角色。 对JVM的深入了解可帮助您编写更好的代码,并避免与堆栈和内存错误有关的陷阱。