本文主要来自于Java代码是如何被CPU狂飙起来的? - 知乎 (zhihu.com) ,我这里只是为了自己学习,如果真有人看到这篇文章,欢迎大家去看原文。
首先,是程序员编写Java的.java文件,这个文件通过javac编译器编译成.class结尾的字节码文件,这个字节码文件借助不同平台的JVM虚拟机就可以实现在不同平台将.class文件转化为汇编语言,最终交给cpu执行。不同平台的JVM虚拟机都可以将.class字节码文件进行翻译成汇编语言,这就实现了跨平台运行。
-
JVM的结构和作用
-
JVM的核心作用有两个,一个是运行Java应用程序,另一个是管理Java应用程序的内存,它主要由三部分组成,类加载器、运行时数据区以及字节码执行引擎
-
类加载器
-
类加载器负责将字节码文件加载到内存里面,主要经历了加载>连接>实例化三个阶段完成类加载操作。
-
.所有的calss并不是一次全部加载到内存里面,而是Java应用程序需要的时候才会进行加载。当JVM请求一个类进行加载的时候,类加载器就会尝试定位这个类,当查找对应的类之后就会将他的完全限定类定义加载到运行时数据区中
-
-
运行时数据区:存放了字节码信息以及程序执行过程的数据。主要就是分为堆,程序计数器,虚拟机栈,本地方法栈和元空间数据区。
-
需要注意的是,元空间存储的是类的结构信息(原来叫方法区),也就是类的定义、方法的定义、字段的定义和字节码指令。元空间是通过本地内存(Native Menmory)来实现的,在JVM启动时,元空间的大小由MaxMetaspaceSize参数指定,JVM在运行时会自动调整元空间的大小,以适应不同的程序需求。
-
-
字节码执行引擎
-
字节码执行引擎最核心的作用就是将字节码文件解释为可执行程序,主要包含了解释器、JIT编译器和垃圾回收器。当Java程序调用一个方法时候,JVM会根据方法的描述符和方法所在的元空间中查找对应的字节码指令,字节码执行引擎从元空间获取到字节码指令,然后开始执行(和CPU交互)。
-
-
JVM的作用
-
当Javaci安逸得到了.class文件,就需要启动一个JVM来进一步解析.class字节码,实际上,JVM就相当于操作系统的一个进程。因此想通过JVM加载解析.class文件,就先要启动一个JVM进程,JVM进程启动之后,通过类加载器加载.class文件,将字节码加载到JVM对应的内存空间。当.class文件对应的字节码信息被加载到中之后,操作系统会调度CPU资源来按照对应的指令执行java程序。
-
-
-
字节码文件解说:要搞清楚JVm是如何工作的,首先要搞清楚他工作的对象,就是.class字节码文件
-
字节码文件结构
-
要搞清楚JVM如何加载解析字节码文件,首先要理解字节码的格式。就像CPU有自己的指令集一样,JVM也有自己的一套指令集,也就是Java字节码,也就是说,Java字节码就是机器语言的.class文件表现形式。字节码文件结构是一组以8位为最小单元的十六进制数据流,具体的结构如下图所示,主要包含了魔数、class文件版本、常量池、访问标志、索引、字段表集合、方法表集合以及属性表集合描述数据信息。
-
魔数与文件版本
-
魔数的作用就是标识自己是一个字节码文件,可以被JVM加载,对于Java字节码文件来说其魔数为0xCAFEBABE。紧随着魔数的四个字节是文件版本号,Java的文件版本号通常以52.0(java8)的形式表示,(两个版本号,就是四个字节,),例如(00000034)就是表示52.0,前面两个字节是次版本号(小数部分),后面是主版本号(整数部分)(i有点傻了,一个字节8位,就是两个十六进制的位置,我经常把4位搞成了一个字节,其实是半个字节)
-
-
常量池
-
在常量池中说明常数的个数以及具体的常量信息,常量池中主要存放了字面量以及符号引用这两类数据,字面量就是代码中声明为final的常量值和文本字符串,而符号引用主要为类和接口的完全限定名、字段的名称和描述符以及方法的名称和描述符(也即是说,int value = 1,常量池中只有字段描述符int和字段名称value,字面量1不会存在常量池)。这些信息在加载到JVM之后在运行期间将符号引用转化为直接引用才能被真正使用。常量池的第一个元素是常量池大小,占据两个字节。常量池表的索引从1开始,而不是从0开始,这是因为常量池的第0个位置是用于特殊用途的。
-
-
访问标志
-
类或者接口的访问标记,说明类是public还是abstract,用于描述该类的访问级别和属性。访问标志的取值范围是一个16位的二进制数。
-
-
索引
-
包含了类索引、父类索引、接口索引数据,主要说明类的继承关系
-
-
字段表集合
-
主要是类级变量而不是方法内部的局部变量
-
-
方法表集合
-
主要用来描述类中有几个方法,每个方法的具体信息,包含了方法访问标识、方法名称索引、方法描述符索引、属性计数器、属性表等信息,总之就是描述方法的基础信息。
-
-
属性表集合
-
方法表集合之后是属性表集合,用于描述该类的所有属性。属性表集合包含了所有该类的属性的描述信息,包括属性名称、属性类型、属性值等等。
-
-
-
解析字节码文件
-
知道了字节码文件的结构之后,JVM就需要对字节码文件进行解析,将字节码结构解析为JVM内部流转的数据结构。大致的过程如下:
-
读取字节码文件
-
JVM首先需要读取字节码文件的二进制数据,这通常是通过文件输入流来完成的。
-
-
解析字节码文件
-
JVM解析字节码的过程是将字节码的二进制数据解析为Java虚拟机中的数据结构。首先是JVM首先会读取字节码文件的前四个字节,判断魔数是否为0xCAFEBABE,以此来确认该文件是不是一个Java字节码文件。接着是读取常量池表,将其中的常量转换为虚拟机中的数据结构(例如将字符串常量转换为Java字符串对象)。JVM会依次解析访问标志、索引、字段表、方法表等信息,并将该信息转换为Java虚拟机中的数据结构。最后JVM虚拟机将得到的数据结构组装成Java类的结构,并将信息放入元空间中。在完成字节码文件解析之后,接下来就需要类加载器闪亮登场了,类加载器会将类文件加载到JVM内存中,并为该类生成一个Class对象。
-
-
-
加载器启动:
-
以linux系统为例,当我们通过"java"启动一个Java应用的时候,其实就是启动了一个JVM进程实例,此时操作系统会为这个JVM进程实例分配CPU、内存等系统资源;
-
"java"可执行文件此时就会解析相关的启动参数,主要包括了查找jre路径、各种包的路径以及虚拟机参数等,进而获取定位libjvm.so位置,通过libjvm.so来启动JVM进程实例;
-
当JVM启动后会创建启动类加载器Bootstrap ClassLoader ,这个ClassLoader是C++语言实现的,它是最基础的类加载器,没有父类加载器。通过它加载Java应用运行时所需要的基础类,主要包括JAVA_HOME/jre/lib下的rt.jar等基础jar包;
-
而在rt.jar中包含了Launcher类,当Launcher类被加载之后,就会触发创建Launcher静态实例对象,而Launcher类的构造函数中,完成了对于Extension ClassLoader及Application ClassLoader的创建。Launcher类的部分代码如下所示:
-
双亲委派模型:
-
为了保证Java程序的安全性和稳定性,JVM设计了双亲委派模型类加载机制。在双亲委派模型中,启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)以及应用程序类加载器(Application ClassLoader)按照一个父子关系形成了一个层次结构,其中启动类加载器位于最顶层,应用程序类加载器位于最底层。当一个类加载器需要加载一个类时,它首先会委派给它的父类加载器去尝试加载这个类。如果父类加载器能够成功加载这个类,那么就直接返回这个类的Class对象,如果父类加载器无法加载这个类,那么就会交给子类加载器去尝试加载这个类。这个过程会一直持续到顶层的启动类加载器(所有的工作最后都应该传递到顶层的启动类加载器,当它无法加载,再给子类加载)。通过这种双亲委派模型,可以保证同一个类在不同的类加载器中只会被加载一次,从而避免了类的重复加载,也保证了类的唯一性。同时,由于每个类加载器只会加载自己所负责的类,因此可以防止恶意代码的注入和类的篡改,提高了Java程序的安全性。
-
-
-
-
CPU执行
JVM实际上是创建线程来承载代码的执行过程,也就是说JVM就可以划分专门的内存区域承载这写字节码数据和运行时中间数据。其中程序计数器(Program Counter Register)、虚拟机栈(Java Virtual Machine Stack)和本地方法区是线程私有的,堆和元数据区属于共享的。
-
程序计数器
-
如果当前虚拟机中的线程执行的是Java方法,那么此时程序计数器中起初存储的是方法的第一条指令,当方法开始执行之后,PC寄存器存储的是下一个字节码指令的地址。但是如果当前虚拟机中的线程执行的是naive方法,那么程序计数器中的值为undefined。
-
如果是正常进行代码执行,那么当线程执行字节码指令时,程序计数器会进行自动加1指向下一条字节码指令地址。但是如果遇到判断分支、循环以及异常等不同的控制转移语句,程序计数器会被置为目标字节码指令的地址。另外在多线程切换的时候,虚拟机会记录当前线程的程序计数器,当线程切换回来的时候会根据此前记录的值恢复到程序计数器中,来继续执行线程的后续的字节码指令。
-
-
虚拟机栈,栈内存主要存放一些基本的变量和对象的引用变量,为每个方法创建一个栈帧。栈是运行时的单位,而堆是存储时候的单位,栈管运行,堆管存储。主体的数据都在堆中放,对象主要在堆中放。方法内的局部变量,是放在栈空间中的。指的基本数据类型(六种数字(四个整数,两个浮点),字字符类型和布尔型),要是引用数据类型,在栈空间,只是放了对象的引用。
-
虚拟机栈操作的基本元素就是栈帧,栈帧的结构主要包含了局部变量、操作数栈、动态连接以及方法返回地址这几个部分。
-
局部变量
-
主要存放了栈帧对应方法的参数以及方法中定义的局部变量,实际上它是一个以0为起始索引的数组结构,可以通过索引来访问局部变量表中的元素,还包括了基本类型以及对象引用等(对象的内存开辟在堆里面,取的都是引用)。非静态方法中,第0个槽位默认是用于存储this指针,而其他参数和变量则会从第1个槽位开始存储。在静态方法中,第0个槽位可以用来存放方法的参数或者其他的数据。
-
-
操作数栈
-
和虚拟机栈一样操作数栈也是一个栈数据结构,只不过两者存储的对象不一样。操作数栈主要存储了方法内部操作数的值以及计算结果,操作数栈会将运算的参与方以及计算结果都压入操作数栈中,后续的指令操作就可以从操作数栈中使用这些值来进行计算。当方法有返回值的时候,返回值也会被压入操作数栈中,这样方法调用者可以获取到返回值。
-
-
动态链接
-
一个类中的方法可能会被程序中的其他多个类所共享使用,因此在编译期间实际无法确定方法的实际位置到底在哪里,因此需要在运行时动态链接来确定方法对应的地址。动态链接是通过在栈帧中维护一张方法调用的符号表来实现的。这张符号表中保存了当前方法中所有调用的方法的符号引用,包括方法名、参数类型和返回值类型等信息。当方法需要调用另一个方法时,它会在符号表中查找所需方法的符号引用,然后进行动态链接,确定方法的具体内存地址。这样,就能够正确地调用所需的方法。
-
-
方法返回地址
-
当一个方法执行完毕后,JVM会将记录的方法返回地址数据置入程序计数器中,这样字节码执行引擎可以根据程序计数器中的地址继续向后执行字节码指令。同时JVM会将方法返回值压入调用方的操作栈中以便于后续的指令计算,操作完成之后从虚拟机栈栈帧中进行弹出。
-
-
-
执行的流转过程
-
JVM启动完成.class文件加载之后,它会创建一个名为"main"的线程,并且该线程会自动调用定义在该类中的名为"main"的静态方法,这也是Java程序的入口点;
-
当JVM在主线程中调用当方法的时候就会创建当前线程独享的程序计数器以及虚拟机栈,在Test.class类中,开始执行mian方法 ,因此JVM会虚拟机栈中压入main方法对应的栈帧;
-
在栈帧的操作数栈中存储了操作的数据,JVM执行字节码指令的时候从操作数栈中获取数据,执行计算操作之后再将结果压入操作数栈;
-
当进行calculate方法调用的时候,虚拟机栈继续压入calculate方法对应的栈帧,被调用方法的参数、局部变量和操作数栈等信息会存储在新创建的栈帧中。其中该栈帧中的方法返回地址中存放了main方法执行的地址信息,方便在调用方法执行完成后继续恢复调用前的代码执行;
-
对于age + 3一条加法指令,在执行该指令之前,JVM会将操作数栈顶部的两个元素弹出,并将它们相加,然后将结果推入操作数栈中。在这个例子中,指令的操作码是“add”,它表示执行加法操作;操作数是0,它表示从操作数栈的顶部获取第一个操作数;操作数是1,它表示从操作数栈的次顶部获取第二个操作数;这个过程是让字节码引擎交给cpu来执行的,cpu执行完毕之后又返回操作数栈,因为这些都是内存和cpu的交互
-
程序计数器中存储了下一条需要执行操作的字节码指令的地址,因此Java线程执行业务逻辑的时候必须借助于程序计数器才能获得下一步命令的地址;
-
当calculate方法执行完成之后,对应的栈帧将从虚拟机栈中弹出,其中方法执行的结果会被压入main方法对应的栈帧中的操作数栈中,而方法返回地址被重置到main现场对应的程序计数器中,以便于后续字节码执行引擎从程序计数器中获取下一条命令的地址。如果方法没有返回值,JVM仍然会将一个null值推送到调用该方法的栈帧的操作数栈中,作为占位符,以便恢复调用方的操作数栈状态。
-
字节码执行引擎中的解释器会从程序计数器中获取下一个字节码指令的地址,也就是从元空间中获取对应的字节码指令,在获取到指令之后,通过翻译器翻译为对应的汇编语言而再交给硬件解析为机器指令,最终由CPU进行执行,而后再将执行结果进行写回。
-
-
CPU的工作
-
CPU执行时候,每个进程都有一个时间片,时间片的流转表现为并发,当该进程的时间片用完或者发生了一些事件,需要转移进程的执行权,CPU就会被释放,操作系统就会调度另一个进程来给CPU执行。
-
CPU的核心工作:CPU上电之后,它就像一个勤劳的小蜜蜂一样,一直不断重复着获取指令-》指令译码-》执行指令的循环操作。
-
获取指令
-
CPU从PC寄存器中获取对应的指令地址,此处的指令地址是将要执行指令的地址,根据指令地址获取对应的操作指令到指令寄存中,此时如果是顺存执行则PC寄存器地址会自动加1,但是如果程序涉及到条件、循环等分支执行逻辑,那么PC寄存器的地址就会被修改为下一条指令执行的地址。
-
-
指令译码
-
将获取到的指令进行翻译,搞清楚哪些是操作码哪些是操作数。CPU首先读取指令中的操作码然后根据操作码来确定该指令的类型以及需要进行的操作,CPU接着根据操作码来确定指令所需的寄存器和内存地址,并将它们提取出来。
-
-
执行指令
-
经过指令译码之后,CPU根据获取到的指令进行具体的执行操作,并将指令运算的结果存储回内存或者寄存器中。
-
-
-
CPU的中断
当操作系统需要执行某些操作时,它会发送一个中断请求给CPU。CPU在接收到中断请求后,会停止当前的任务,并转而执行中断处理程序,这个处理程序是由操作系统提供的。中断处理程序会根据中断类型,执行相应的操作,并返回到原来的任务继续执行。在执行完中断处理程序后,CPU会将之前保存的程序现场信息恢复,然后继续执行被中断的程序。这个过程叫做中断返回(Interrupt Return,IRET)。在中断返回过程中,CPU会将处理完的结果保存在寄存器中,然后从栈中弹出被中断的程序的现场信息,恢复之前的现场状态,最后再次执行被中断的程序,继续执行之前被中断的指令。
-
保存当前程序状态
-
CPU会将当前程序的状态(如程序计数器、寄存器、标志位等)保存到内存或栈中,以便在中断处理程序执行完毕后恢复现场。
-
-
确定中断类型
-
CPU会检查中断信号的类型,以确定需要执行哪个中断处理程序。
-
-
转移控制权
-
CPU会将程序的控制权转移到中断处理程序的入口地址,开始执行中断处理程序。
-
-
执行中断处理程序
-
中断处理程序会根据中断类型执行相应的操作,这些操作可能包括保存现场信息、读取中断事件的相关数据、执行特定的操作,以及返回到原来的程序继续执行等。
-
-
恢复现场
-
中断处理程序执行完毕后,CPU会从保存的现场信息中恢复原来程序的状态,然后将控制权返回到原来的程序中,继续执行被中断的指令。
-
-
-
-