无论是刚刚入门Java的新手还是已经工作了的老司机,恐怕都不容易把Java代码如何一步步被CPU执行起来这个问题完全讲清楚。但是对于一个Java程序员来说写了那么久的代码,我们总要搞清楚自己写的Java代码到底是怎么运行起来的。另外在求职面试的时候这个问题也常常会聊到,面试官主要想通过它考察求职同学对于Java以及计算机基础技术体系的理解程度,看似简单的问题实际上囊括了JVM运行原理、操作系统以及CPU运行原理等多方面的技术知识点。我们一起来看看Java代码到底是怎么被运行起来的。通过这种双亲委派模型,可以保证同一个类在不同的类加载器中只会被加载一次,从而避免了类的重复加载,也保证了类的唯一性。同时,由于每个类加载器只会加载自己所负责的类,因此可以防止恶意代码的注入和类的篡改,提高了Java程序的安全性。
在介绍Java如何一步步被执行起来之前,我们需要先弄明白为什么Java可以实现跨平台运行,因为搞清楚了这个问题之后,对于我们理解Java程序如何被CPU执行起来非常有帮助。
write once run anywhere曾经是Java响彻编程语言圈的slogan,也就是所谓的程序员开发完java应用程序后,可以在不需要做任何调整的情况下,无差别的在任何支持Java的平台上运行,并获得相同的运行结果从而实现跨平台运行,那么Java到底是如何做到这一点的呢?
其实对于大多数的编程语言来说,都需要将程序转换为机器语言才能最终被CPU执行起来。因为无论是如Java这种高级语言还是像汇编这种低级语言实际上都是给人看的,但是计算机无法直接进行识别运行。因此想要CPU执行程序就必须要进行语言转换,将程序语言转化为CPU可以识别的机器语言。
学过计算机组成原理的同学肯定都知道,CPU内部都是用大规模晶体管组合而成的,而晶体管只有高电位以及低点位两种状态,正好对应二进制的0和1,因此机器码实际就是由0和1组成的二进制编码集合,它可以被CPU直接识别和执行。
但是像X86架构或者ARM架构,不同类型的平台对应的机器语言是不一样的,这里的机器语言指的是用二进制表示的计算机可以直接识别和执行的指令集集合。不同平台使用的CPU不同,那么对应的指令集也就有所差异,比如说X86使用的是CISC复杂指令集而ARM使用的是RISC精简指令集。所以Java要想实现跨平台运行就必须要屏蔽不同架构下的计算机底层细节差异。因此,如何解决不同平台下机器语言的适配问题是Java实现一次编写,到处运行的关键所在。
那么Java到底是如何解决这个问题的呢?怎么才能让CPU可以看懂程序员写的Java代码呢?其实这就像在我们的日常生活中,如果双方语言不通,要想进行交流的话就必须中间得有一个翻译,这样通过翻译的语言转换就可以实现双方畅通无阻的交流了。打个比方,一个中国厨师要教法国厨师和阿拉伯厨师做菜,中国厨师不懂法语和阿拉伯语,法国厨师和阿拉伯厨师不懂中文,要想顺利把菜做好就需要有翻译来帮忙。中国厨师把做菜的菜谱告诉翻译者,翻译者将中文菜谱转换为法文菜谱以及阿拉伯语菜谱,这样法国厨师和阿拉伯厨师就知道怎么做菜了。
因此Java的设计者借助了这样的思想,通过JVM(Java Virtual machine,Java虚拟机)这个中间翻译来实现语言转换。程序员编写以.java为结尾的程序之后通过javac编译器把.java为结尾的程序文件编译成.class结尾的字节码文件,这个字节码文件需要JVM这个中间翻译进行识别解析,它由一组如下图这样的16进制数组成。JVM将字节码文件转化为汇编语言后再由硬件解析为机器语言最终最终交给CPU执行。
所以说通过JVM实现了计算机底层细节的屏蔽,因此windows平台有windows平台的JVM,linux平台有Linux平台的JVM,这样在不同平台上存在对应的JVM充当中间翻译的作用。因此只要编译一次,不同平台的JVM都可以将对应的字节码文件进行解析后运行,从而实现在不同平台下运行的效果。
那么问题又来了,JVM是怎么解析运行.class文件的呢?要想搞清楚这个问题,我们得先看看JVM的内存结构到底是怎样的,了解JVM结构之后这个问题就迎刃而解了。
JVM(Java Virtual Machine)即Java虚拟机,它的核心作用主要有两个,一个是运行Java应用程序,另一个是管理Java应用程序的内存。它主要由三部分组成,类加载器、运行时数据区以及字节码执行引擎。
类加载器负责将字节码文件加载到内存中,主要经历加载-》连接-》实例化三个阶段完成类加载操作。
另外需要注意的是.class并不是一次性全部加载到内存中,而是在Java应用程序需要的时候才会加载。也就是说当JVM请求一个类进行加载的时候,类加载器就会尝试查找定位这个类,当查找对应的类之后将他的完全限定类定义加载到运行时数据区中。
JVM定义了在Java程序运行期间需要使用到的内存区域,简单来说这块内存区域存放了字节码信息以及程序执行过程数据。运行时数据区主要划分了堆、程序计数器虚拟机栈、本地方法栈以及元空间数据区。其中堆数据区域在JVM启动后便会进行分配,而虚拟机栈、程序计数器本地方法栈都是在常见线程后进行分配。
不过需要说明的是在JDK 1.8及以后的版本中,方法区被移除了,取而代之的是元空间(Metaspace)。元空间与方法区的作用相似,都是存储类的结构信息,包括类的定义、方法的定义、字段的定义以及字节码指令。不同的是,元空间不再是JVM内存的一部分,而是通过本地内存(Native Memory)来实现的。在JVM启动时,元空间的大小由MaxMetaspaceSize参数指定,JVM在运行时会自动调整元空间的大小,以适应不同的程序需求。
字节码执行引擎最核心的作用就是将字节码文件解释为可执行程序,主要包含了解释器、即使编译以及垃圾回收器。字节码执行引擎从元空间获取字节码指令进行执行。当Java程序调用一个方法时,JVM会根据方法的描述符和方法所在的类在元空间中查找对应的字节码指令。字节码执行引擎从元空间获取字节码指令,然后执行这些指令。
在搞清楚了JVM的结构之后,接下来我们一起来看看天天写的Java代码是如何被CPU飙起来的。一般公司的研发流程都是产品经理提需求然后程序员来实现。所以当产品经理把需求提过来之后,程序员就需要分析需求进行设计然后编码实现,比如我们通过Idea来完成编码工作,这个时候工程中就会有一堆的以.java结尾的Java代码文件,实际上就是程序员将产品需求转化为对应的Java程序。但是这个.java结尾的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文件版本、常量池、访问标志、索引、字段表集合、方法表集合以及属性表集合描述数据信息。
这里简单说明下各个部分的作用,后面会有专门的文章再详细进行阐述。
魔数的作用就是告诉JVM自己是一个字节码文件,你JVM快来加载我吧,对于Java字节码文件来说,其魔数为0xCAFEBABE,现在知道为什么Java的标志是咖啡了吧。而紧随魔数之后的两个字节是文件版本号,Java的版本号通常是以52.0的形式表示,其中高16位表示主版本号,低16位表示次版本号。。
在常量池中说明常量个数以及具体的常量信息,常量池中主要存放了字面量以及符号引用这两类常量数据,所谓字面量就是代码中声明为final的常量值,而符号引用主要为类和接口的完全限定名、字段的名称和描述符以及方法的名称以及描述符。这些信息在加载到JVM之后在运行期间将符号引用转化为直接引用才能被真正使用。常量池的第一个元素是常量池大小,占据两个字节。常量池表的索引从1开始,而不是从0开始,这是因为常量池的第0个位置是用于特殊用途的。
类或者接口的访问标记,说明类是public还是abstract,用于描述该类的访问级别和属性。访问标志的取值范围是一个16位的二进制数。
包含了类索引、父类索引、接口索引数据,主要说明类的继承关系。
主要是类级变量而不是方法内部的局部变量。
主要用来描述类中有几个方法,每个方法的具体信息,包含了方法访问标识、方法名称索引、方法描述符索引、属性计数器、属性表等信息,总之就是描述方法的基础信息。
方法表集合之后是属性表集合,用于描述该类的所有属性。属性表集合包含了所有该类的属性的描述信息,包括属性名称、属性类型、属性值等等。
知道了字节码文件的结构之后,JVM就需要对字节码文件进行解析,将字节码结构解析为JVM内部流转的数据结构。大致的过程如下:
JVM首先需要读取字节码文件的二进制数据,这通常是通过文件输入流来完成的。
JVM解析字节码的过程是将字节码文件中的二进制数据解析为Java虚拟机中的数据结构。首先JVM首先会读取字节码文件的前四个字节,判断魔数是否为0xCAFEBABE,以此来确认该文件是否是一个有效的Java字节码文件。JVM接着会解析常量池表,将其中的常量转换为Java虚拟机中的数据结构,例如将字符串常量转换为Java字符串对象。解析类、接口、字段、方法等信息:JVM会依次解析类索引、父类索引、接口索引集合、字段表集合、方法表集合等信息,将这些信息转换为Java虚拟机中的数据结构。最后,JVM将解析得到的数据结构组装成一个Java类的结构,并将其放入元空间中。
在完成字节码文件解析之后,接下来就需要类加载器闪亮登场了,类加载器会将类文件加载到JVM内存中,并为该类生成一个Class对象。
我们都知道,Java应用的类都是通过类加载器加载到运行时数据区的,这里很多同学可能会有疑问,那么类加载器本身又是被谁加载的呢?这有点像先有鸡还是先有蛋的灵魂拷问。实际上类加载器启动大致会经历如下几个阶段:
1、以linux系统为例,当我们通过"java"启动一个Java应用的时候,其实就是启动了一个JVM进程实例,此时操作系统会为这个JVM进程实例分配CPU、内存等系统资源;
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。
那么程序计数器中的值又是怎么被改变的呢?如果是正常进行代码执行,那么当线程执行字节码指令时,程序计数器会进行自动加1指向下一条字节码指令地址。但是如果遇到判断分支、循环以及异常等不同的控制转移语句,程序计数器会被置为目标字节码指令的地址。另外在多线程切换的时候,虚拟机会记录当前线程的程序计数器,当线程切换回来的时候会根据此前记录的值恢复到程序计数器中,来继续执行线程的后续的字节码指令。
除了程序计数器之外,字节码指令的执行流转还需要虚拟机栈的参与。我们先来看下虚拟机栈的大致结构,如下图所示,栈大家肯定都知道,它是一个先入后出的数据结构,非常适合配合方法的执行过程。虚拟机栈操作的基本元素就是栈帧,栈帧的结构主要包含了局部变量、操作数栈、动态连接以及方法返回地址这几个部分。
局部变量:主要存放了栈帧对应方法的参数以及方法中定义的局部变量,实际上它是一个以0为起始索引的数组结构,可以通过索引来访问局部变量表中的元素,还包括了基本类型以及对象引用等。非静态方法中,第0个槽位默认是用于存储this指针,而其他参数和变量则会从第1个槽位开始存储。在静态方法中,第0个槽位可以用来存放方法的参数或者其他的数据。
操作数栈:和虚拟机栈一样操作数栈也是一个栈数据结构,只不过两者存储的对象不一样。操作数栈主要存储了方法内部操作数的值以及计算结果,操作数栈会将运算的参与方以及计算结果都压入操作数栈中,后续的指令操作就可以从操作数栈中使用这些值来进行计算。当方法有返回值的时候,返回值也会被压入操作数栈中,这样方法调用者可以获取到返回值。
动态链接:一个类中的方法可能会被程序中的其他多个类所共享使用,因此在编译期间实际无法确定方法的实际位置到底在哪里,因此需要在运行时动态链接来确定方法对应的地址。动态链接是通过在栈帧中维护一张方法调用的符号表来实现的。这张符号表中保存了当前方法中所有调用的方法的符号引用,包括方法名、参数类型和返回值类型等信息。当方法需要调用另一个方法时,它会在符号表中查找所需方法的符号引用,然后进行动态链接,确定方法的具体内存地址。这样,就能够正确地调用所需的方法。
方法返回地址:当一个方法执行完毕后,JVM会将记录的方法返回地址数据置入程序计数器中,这样字节码执行引擎可以根据程序计数器中的地址继续向后执行字节码指令。同时JVM会将方法返回值压入调用方的操作栈中以便于后续的指令计算,操作完成之后从虚拟机栈中奖栈帧进行弹出。
知道了虚拟机栈的结构之后,我们来看下方法执行的流转过程是怎样的。
1、JVM启动完成.class文件加载之后,它会创建一个名为"main"的线程,并且该线程会自动调用定义在该类中的名为"main"的静态方法,这也是Java程序的入口点。
2、当JVM在主线程中调用当方法的时候就会创建当前线程独享的程序计数器以及虚拟机栈,在Test.class类中,开始执行mian方法 ,因此JVM会虚拟机栈中压入main方法对应的帧栈帧。
3、在栈帧的操作数栈中存储了操作的数据,JVM执行字节码指令的时候从操作数栈中获取数据,执行计算操作之后再将结果压入操作数栈。
4、当进行calculate方法调用的时候,虚拟机栈继续压入calculate方法对应的栈帧,被调用方法的参数、局部变量和操作数栈等信息会存储在新创建的栈帧中。其中该栈帧中的方法返回地址中存放了main方法执行的地址信息,方便在调用方法执行完成后继续恢复调用前的代码执行。
5、对于age + 3一条加法指令,在执行该指令之前,JVM会将操作数栈顶部的两个元素弹出,并将它们相加,然后将结果推入操作数栈中。在这个例子中,指令的操作码是“add”,它表示执行加法操作;操作数是0,它表示从操作数栈的顶部获取第一个操作数;操作数是1,它表示从操作数栈的次顶部获取第二个操作数。
6、程序计数器中存储了下一条需要执行操作的字节码指令的地址,因此Java线程执行业务逻辑的时候必须借助于程序计数器才能获得下一步命令的地址。
7、当calculate方法执行完成之后,对应的栈帧将从虚拟机栈中弹出,其中方法执行的结果会被压入main方法对应的栈帧中的操作数栈中,而方法返回地址被重置到main现场对应的程序计数器中,以便于后续字节码执行引擎从程序计数器中获取下一条命令的地址。如果方法没有返回值,JVM仍然会将一个null值推送到调用该方法的栈帧的操作数栈中,作为占位符,以便恢复调用方的操作数栈状态。
8、字节码执行引擎中的解释器会从程序计数器中获取下一个字节码指令的地址,也就是从元空间中获取对应的字节码指令,在获取到指令之后,通过翻译器翻译为对应的汇编语言而再交给硬件解析为机器指令,最终由CPU进行执行,而后再将执行结果进行写回。
通过上文我们知道无论什么编程语言最终都需要转化为机器语言才能被CPU执行,但是CPU、内存这些硬件资源并不是直接可以和应用程序打交道,而是通过操作系统来进行统一管理的。对于CPU来说,操作系统通过调度器(Scheduler)来决定哪些进程可以被CPU执行,并为它们分配时间片。它会从就绪队列中选择一个进程并将其分配给CPU执行。当一个进程的时间片用完或者发生了I/O等事件时,CPU会被释放,操作系统的调度器会重新选择一个进程并将其分配给CPU执行。也就是说操作系统通过进程调度算法来管理CPU的分配以及调度,进程调度算法的目的就是为了最大化CPU使用率,避免出现任务分配不均空闲等待的情况。主要的进程调度算法包括了FCFS、SJF、RR、MLFQ等。
前文中我们大致搞清楚了类是如何被加载的,各部分类字节码数据在运行时数据区怎么流转以及字节码执行引擎翻译字节码。实际上在运行时数据区数据流转的过程中,CPU已经参与其中了。程序的本质是为了根据输入获得相应的输出,而CPU本质就是根据程序的指令一步步执行获得结果的工具。对于CPU来说,它核心工作主要分为如下三个步骤;
CPU从PC寄存器中获取对应的指令地址,此处的指令地址是将要执行指令的地址,根据指令地址获取对应的操作指令到指令寄存中,此时如果是顺存执行则PC寄存器地址会自动加1,但是如果程序涉及到条件、循环等分支执行逻辑,那么PC寄存器的地址就会被修改为下一条指令执行的地址。
将获取到的指令进行翻译,搞清楚哪些是操作码哪些是操作数。CPU首先读取指令中的操作码然后根据操作码来确定该指令的类型以及需要进行的操作,CPU接着根据操作码来确定指令所需的寄存器和内存地址,并将它们提取出来。
经过指令译码之后,CPU根据获取到的指令进行具体的执行操作,并将指令运算的结果存储回内存或者寄存器中。
因此一旦CPU上电之后,它就像一个勤劳的小蜜蜂一样,一直不断重复着获取指令-》指令译码-》执行指令的循环操作。
当操作系统需要执行某些操作时,它会发送一个中断请求给CPU。CPU在接收到中断请求后,会停止当前的任务,并转而执行中断处理程序,这个处理程序是由操作系统提供的。中断处理程序会根据中断类型,执行相应的操作,并返回到原来的任务继续执行。
在执行完中断处理程序后,CPU会将之前保存的程序现场信息恢复,然后继续执行被中断的程序。这个过程叫做中断返回(Interrupt Return,IRET)。在中断返回过程中,CPU会将处理完的结果保存在寄存器中,然后从栈中弹出被中断的程序的现场信息,恢复之前的现场状态,最后再次执行被中断的程序,继续执行之前被中断的指令。
那么CPU又是如何响应中断的呢?主要经历了以下几个步骤:
CPU会将当前程序的状态(如程序计数器、寄存器、标志位等)保存到内存或栈中,以便在中断处理程序执行完毕后恢复现场。
CPU会检查中断信号的类型,以确定需要执行哪个中断处理程序。
CPU会将程序的控制权转移到中断处理程序的入口地址,开始执行中断处理程序。
中断处理程序会根据中断类型执行相应的操作,这些操作可能包括保存现场信息、读取中断事件的相关数据、执行特定的操作,以及返回到原来的程序继续执行等。
中断处理程序执行完毕后,CPU会从保存的现场信息中恢复原来程序的状态,然后将控制权返回到原来的程序中,继续执行被中断的指令。
很多时候看似理所当然的问题,当我们深究下去就会发现原来别有一番天地。正如阿里王坚博士说的那样,要想看一个人对某个领域的知识掌握的情况,那就看他能就这个领域的知识能讲多长时间。想想的确如此,如果我们能够对某个知识点高度提炼同时又可以细节满满的进行展开阐述,那我们对于这个领域的理解程度就会鞭辟入里。这种检验自己知识学习深度的方式也推荐给大家。