Java代码是如何被CPU狂飙起来的?原创
引言
无论是刚刚入门Java的新手还是已经工作了的老司机,恐怕都不容易把Java代码如何一步步被CPU执行起来这个问题完全讲清楚。但是对于一个Java程序员来说写了那么久的代码,我们总要搞清楚自己写的Java代码到底是怎么运行起来的。另外在求职面试的时候这个问题也常常会聊到,面试官主要想通过它考察求职同学对于Java以及计算机基础技术体系的理解程度,看似简单的问题实际上囊括了JVM运行原理、操作系统以及CPU运行原理等多方面的技术知识点。我们一起来看看Java代码到底是怎么被运行起来的。
Java如何实现跨平台
在介绍Java如何一步步被执行起来之前,我们需要先弄明白为什么Java可以实现跨平台运行,因为搞清楚了这个问题之后,对于我们理解Java程序如何被CPU执行起来非常有帮助。
为什么需要JVM
其实对于大多数的编程语言来说,都需要将程序转换为机器语言才能最终被CPU执行起来。因为无论是如Java这种高级语言还是像汇编这种低级语言实际上都是给人看的,但是计算机无法直接进行识别运行。因此想要CPU执行程序就必须要进行语言转换,将程序语言转化为CPU可以识别的机器语言。
但是像X86架构或者ARM架构,不同类型的平台对应的机器语言是不一样的,这里的机器语言指的是用二进制表示的计算机可以直接识别和执行的指令集集合。不同平台使用的CPU不同,那么对应的指令集也就有所差异,比如说X86使用的是CISC复杂指令集而ARM使用的是RISC精简指令集。所以Java要想实现跨平台运行就必须要屏蔽不同架构下的计算机底层细节差异。因此,如何解决不同平台下机器语言的适配问题是Java实现一次编写,到处运行的关键所在。
所以说通过JVM实现了计算机底层细节的屏蔽,因此windows平台有windows平台的JVM,Linux平台有Linux平台的JVM,这样在不同平台上存在对应的JVM充当中间翻译的作用。因此只要编译一次,不同平台的JVM都可以将对应的字节码文件进行解析后运行,从而实现在不同平台下运行的效果。
那么问题又来了,JVM是怎么解析运行.class文件的呢?要想搞清楚这个问题,我们得先看看JVM的内存结构到底是怎样的,了解JVM结构之后这个问题就迎刃而解了。
JVM结构
JVM(Java Virtual Machine)即Java虚拟机,它的核心作用主要有两个,一个是运行Java应用程序,另一个是管理Java应用程序的内存。它主要由三部分组成,类加载器、运行时数据区以及字节码执行引擎。
不过需要说明的是在JDK 1.8及以后的版本中,方法区被移除了,取而代之的是元空间(Metaspace)。元空间与方法区的作用相似,都是存储类的结构信息,包括类的定义、方法的定义、字段的定义以及字节码指令。不同的是,元空间不再是JVM内存的一部分,而是通过本地内存(Native Memory)来实现的。在JVM启动时,元空间的大小由MaxMetaspaceSize参数指定,JVM在运行时会自动调整元空间的大小,以适应不同的程序需求。
JVM如何运行Java程序
通过上文我们知道,Java为了实现write once,run anywhere的宏伟目标设计了JVM来充当转换翻译的工作。因此我们编写好的.java文件需要通过javac编译成.class文件,这个class文件就是传说中的字节码文件,而字节码文件就是JVM的输入。
当我们有了.class文件也就是字节码文件之后,就需要启动一个JVM实例来进一步加载解析.class字节码。实际上JVM本质其实就是操作系统中的一个进程,因此要想通过JVM加载解析.class文件,必须先启动一个JVM进程。JVM进程启动之后通过类加载器加载.class文件,将字节码加载到JVM对应的内存空间。
当.class文件对应的字节码信息被加载到中之后,操作系统会调度CPU资源来按照对应的指令执行java程序。
以上是CPU执行Java代码的大致步骤,看到这里我相信很多同学都有疑问这个执行步骤也太大致了吧。哈哈,别着急,有了基本的解析流程之后我们再对其中的细节进行分析,首先我们就需要弄清楚JVM是如何加载编译后的.class文件的。
字节码文件结构
要想搞清楚JVM如何加载解析字节码文件,我们就先得弄明白字节码文件的格式,因为任何文件的解析都是根据该文件的格式来进行。就像CPU有自己的指令集一样,JVM也有自己一套指令集也就是Java字节码,从根上来说Java字节码是机器语言的.class文件表现形式。字节码文件结构是一组以 8 位为最小单元的十六进制数据流,具体的结构如下图所示,主要包含了魔数、class文件版本、常量池、访问标志、索引、字段表集合、方法表集合以及属性表集合描述数据信息。
常量池
在常量池中说明常量个数以及具体的常量信息,常量池中主要存放了字面量以及符号引用这两类常量数据,所谓字面量就是代码中声明为final的常量值,而符号引用主要为类和接口的完全限定名、字段的名称和描述符以及方法的名称以及描述符。这些信息在加载到JVM之后在运行期间将符号引用转化为直接引用才能被真正使用。常量池的第一个元素是常量池大小,占据两个字节。常量池表的索引从1开始,而不是从0开始,这是因为常量池的第0个位置是用于特殊用途的。
访问标志
类或者接口的访问标记,说明类是public还是abstract,用于描述该类的访问级别和属性。访问标志的取值范围是一个16位的二进制数。
索引
包含了类索引、父类索引、接口索引数据,主要说明类的继承关系。
字段表集合
主要是类级变量而不是方法内部的局部变量。
方法表集合
主要用来描述类中有几个方法,每个方法的具体信息,包含了方法访问标识、方法名称索引、方法描述符索引、属性计数器、属性表等信息,总之就是描述方法的基础信息。
属性表集合
方法表集合之后是属性表集合,用于描述该类的所有属性。属性表集合包含了所有该类的属性的描述信息,包括属性名称、属性类型、属性值等等。
解析字节码文件
类加载
2、"java"可执行文件此时就会解析相关的启动参数,主要包括了查找jre路径、各种包的路径以及虚拟机参数等,进而获取定位libjvm.so位置,通过libjvm.so来启动JVM进程实例;
3、当JVM启动后会创建引导类加载器Bootsrap ClassLoader,这个ClassLoader是C++语言实现的,它是最基础的类加载器,没有父类加载器。通过它加载Java应用运行时所需要的基础类,主要包括JAVA_HOME/jre/lib下的rt.jar等基础jar包;
4、而在rt.jar中包含了Launcher类,当Launcher类被加载之后,就会触发创建Launcher静态实例对象,而Launcher类的构造函数中,完成了对于ExtClassLoader及AppClassLoader的创建。Launcher类的部分代码如下所示:
public class Launcher {
private static URLStreamHandlerFactory factory = new Factory();
//类静态实例
private static Launcher launcher = new Launcher();
private static String bootClassPath = System.getProperty("sun.boot.class.path");
private ClassLoader loader;
private static URLStreamHandler fileHandler;
public static Launcher getLauncher() {
return launcher;
}
//Launcher构造器
public Launcher() {
ExtClassLoader var1;
try {
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}
try {
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}
Thread.currentThread().setContextClassLoader(this.loader);
String var2 = System.getProperty("java.security.manager");
if (var2 != null) {
SecurityManager var3 = null;
if (!"".equals(var2) && !"default".equals(var2)) {
try {
var3 = (SecurityManager)this.loader.loadClass(var2).newInstance();
} catch (IllegalAccessException var5) {
} catch (InstantiationException var6) {
} catch (ClassNotFoundException var7) {
} catch (ClassCastException var8) {
}
} else {
var3 = new SecurityManager();
}
if (var3 == null) {
throw new InternalError("Could not create SecurityManager: " + var2);
}
System.setSecurityManager(var3);
}
}
...
}
双亲委派模型
为了保证Java程序的安全性和稳定性,JVM设计了双亲委派模型类加载机制。在双亲委派模型中,启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)以及应用程序类加载器(Application ClassLoader)按照一个父子关系形成了一个层次结构,其中启动类加载器位于最顶层,应用程序类加载器位于最底层。当一个类加载器需要加载一个类时,它首先会委派给它的父类加载器去尝试加载这个类。如果父类加载器能够成功加载这个类,那么就直接返回这个类的Class对象,如果父类加载器无法加载这个类,那么就会交给子类加载器去尝试加载这个类。这个过程会一直持续到顶层的启动类加载器。
通过这种双亲委派模型,可以保证同一个类在不同的类加载器中只会被加载一次,从而避免了类的重复加载,也保证了类的唯一性。同时,由于每个类加载器只会加载自己所负责的类,因此可以防止恶意代码的注入和类的篡改,提高了Java程序的安全性。
数据流转过程
当类加载器完成字节码数据加载任务之后,JVM划分了专门的内存区域内承载这些字节码数据以及运行时中间数据。其中程序计数器、虚拟机栈以及本地方法栈属于线程私有的,堆以及元数据区属于共享数据区,不同的线程共享这两部分内存数据。我们还是以下面这段代码来说明程序运行的时候,各部分数据在Runtime data area中是如何流转的。
public class Test {
public static void main(String[] args) {
User user = new User();
Integer result = calculate(user.getAge());
System.out.println(result);
}
private static Integer calculate(Integer age) {
Integer data = age + 3;
return data;
}
}
以上代码对应的字节码指令如下所示:
如上代码所示,JVM创建线程来承载代码的执行过程,我们可以将线程理解为一个按照一定顺序执行的控制流。当线程创建之后,同时创建该线程独享的程序计数器(Program Counter Register)以及Java虚拟机栈(Java Virtual Machine Stack)。如果当前虚拟机中的线程执行的是Java方法,那么此时程序计数器中起初存储的是方法的第一条指令,当方法开始执行之后,PC寄存器存储的是下一个字节码指令的地址。但是如果当前虚拟机中的线程执行的是naive方法,那么程序计数器中的值为undefined。
除了程序计数器之外,字节码指令的执行流转还需要虚拟机栈的参与。我们先来看下虚拟机栈的大致结构,如下图所示,栈大家肯定都知道,它是一个先入后出的数据结构,非常适合配合方法的执行过程。虚拟机栈操作的基本元素就是栈帧,栈帧的结构主要包含了局部变量、操作数栈、动态连接以及方法返回地址这几个部分。
局部变量:主要存放了栈帧对应方法的参数以及方法中定义的局部变量,实际上它是一个以0为起始索引的数组结构,可以通过索引来访问局部变量表中的元素,还包括了基本类型以及对象引用等。非静态方法中,第0个槽位默认是用于存储this指针,而其他参数和变量则会从第1个槽位开始存储。在静态方法中,第0个槽位可以用来存放方法的参数或者其他的数据。
操作数栈:和虚拟机栈一样操作数栈也是一个栈数据结构,只不过两者存储的对象不一样。操作数栈主要存储了方法内部操作数的值以及计算结果,操作数栈会将运算的参与方以及计算结果都压入操作数栈中,后续的指令操作就可以从操作数栈中使用这些值来进行计算。当方法有返回值的时候,返回值也会被压入操作数栈中,这样方法调用者可以获取到返回值。
动态链接:一个类中的方法可能会被程序中的其他多个类所共享使用,因此在编译期间实际无法确定方法的实际位置到底在哪里,因此需要在运行时动态链接来确定方法对应的地址。动态链接是通过在栈帧中维护一张方法调用的符号表来实现的。这张符号表中保存了当前方法中所有调用的方法的符号引用,包括方法名、参数类型和返回值类型等信息。当方法需要调用另一个方法时,它会在符号表中查找所需方法的符号引用,然后进行动态链接,确定方法的具体内存地址。这样,就能够正确地调用所需的方法。
方法返回地址:当一个方法执行完毕后,JVM会将记录的方法返回地址数据置入程序计数器中,这样字节码执行引擎可以根据程序计数器中的地址继续向后执行字节码指令。同时JVM会将方法返回值压入调用方的操作栈中以便于后续的指令计算,操作完成之后从虚拟机栈中奖栈帧进行弹出。
知道了虚拟机栈的结构之后,我们来看下方法执行的流转过程是怎样的。
1、JVM启动完成.class文件加载之后,它会创建一个名为"main"的线程,并且该线程会自动调用定义在该类中的名为"main"的静态方法,这也是Java程序的入口点;
2、当JVM在主线程中调用当方法的时候就会创建当前线程独享的程序计数器以及虚拟机栈,在Test.class类中,开始执行mian方法 ,因此JVM会虚拟机栈中压入main方法对应的帧栈帧;
4、当进行calculate方法调用的时候,虚拟机栈继续压入calculate方法对应的栈帧,被调用方法的参数、局部变量和操作数栈等信息会存储在新创建的栈帧中。其中该栈帧中的方法返回地址中存放了main方法执行的地址信息,方便在调用方法执行完成后继续恢复调用前的代码执行;
6、程序计数器中存储了下一条需要执行操作的字节码指令的地址,因此Java线程执行业务逻辑的时候必须借助于程序计数器才能获得下一步命令的地址;
7、当calculate方法执行完成之后,对应的栈帧将从虚拟机栈中弹出,其中方法执行的结果会被压入main方法对应的栈帧中的操作数栈中,而方法返回地址被重置到main现场对应的程序计数器中,以便于后续字节码执行引擎从程序计数器中获取下一条命令的地址。如果方法没有返回值,JVM仍然会将一个null值推送到调用该方法的栈帧的操作数栈中,作为占位符,以便恢复调用方的操作数栈状态。
8、字节码执行引擎中的解释器会从程序计数器中获取下一个字节码指令的地址,也就是从元空间中获取对应的字节码指令,在获取到指令之后,通过翻译器翻译为对应的汇编语言而再交给硬件解析为机器指令,最终由CPU进行执行,而后再将执行结果进行写回。
CPU执行程序
CPU从PC寄存器中获取对应的指令地址,此处的指令地址是将要执行指令的地址,根据指令地址获取对应的操作指令到指令寄存中,此时如果是顺存执行则PC寄存器地址会自动加1,但是如果程序涉及到条件、循环等分支执行逻辑,那么PC寄存器的地址就会被修改为下一条指令执行的地址。
2、指令译码
将获取到的指令进行翻译,搞清楚哪些是操作码哪些是操作数。CPU首先读取指令中的操作码然后根据操作码来确定该指令的类型以及需要进行的操作,CPU接着根据操作码来确定指令所需的寄存器和内存地址,并将它们提取出来。
3、执行指令
当操作系统需要执行某些操作时,它会发送一个中断请求给CPU。CPU在接收到中断请求后,会停止当前的任务,并转而执行中断处理程序,这个处理程序是由操作系统提供的。中断处理程序会根据中断类型,执行相应的操作,并返回到原来的任务继续执行。
在执行完中断处理程序后,CPU会将之前保存的程序现场信息恢复,然后继续执行被中断的程序。这个过程叫做中断返回(Interrupt Return,IRET)。在中断返回过程中,CPU会将处理完的结果保存在寄存器中,然后从栈中弹出被中断的程序的现场信息,恢复之前的现场状态,最后再次执行被中断的程序,继续执行之前被中断的指令。
3、转移控制权
CPU会将程序的控制权转移到中断处理程序的入口地址,开始执行中断处理程序。
4、执行中断处理程序
中断处理程序会根据中断类型执行相应的操作,这些操作可能包括保存现场信息、读取中断事件的相关数据、执行特定的操作,以及返回到原来的程序继续执行等。
5、恢复现场
中断处理程序执行完毕后,CPU会从保存的现场信息中恢复原来程序的状态,然后将控制权返回到原来的程序中,继续执行被中断的指令。
后记
很多时候看似理所当然的问题,当我们深究下去就会发现原来别有一番天地。正如阿里王坚博士说的那样,要想看一个人对某个领域的知识掌握的情况,那就看他能就这个领域的知识能讲多长时间。想想的确如此,如果我们能够对某个知识点高度提炼同时又可以细节满满的进行展开阐述,那我们对于这个领域的理解程度就会鞭辟入里。这种检验自己知识学习深度的方式也推荐给大家。
关注公众号:慕枫技术笔记,可以和博主沟通交流考研、简历优化、技术面试。