【java基础】JVM执行流程

写在最前,本人也只是个大三的学生,如果你发现任何我写的不对的,请在评论中指出。
本篇以JDK1.8为准

  平时在用java编程的时候,就对JVM的运行机制和执行原理好奇的不行,所以花了点时间去浏览了下《深入了解JVM》,回来写篇笔记记录一下,我理解的JVM(篇幅原因未涉及GC,后期再补
  对于我来说,JVM体系可以分为三层:

  • 上层:前端编译器(IDEA、VSCODE等) → Class Files → 类装载器子系统
  • 中层:运行时数据区域(以JDK1.8为准):元空间、 堆、虚拟机栈、本地方法栈、程序计数器
  • 下层:执行引擎(称为后端编译器,主要用于将字节码文件识别为机器指令)、本地方法接口、本地方法库

要进一步了解JVM的执行流程,可以用以下一段代码为切入点:

public class example {

    public static final int age = 20;

    public static int sex = 1;	// 0=女 1=男

    public int add(){
        int number1 = 1;
        int number2 = 2;
        int result = (number1 + number2) * 100;
        return  result;
    }

    public static void main(String[] args) {
        example example = new example();
        int mainResult = example.add();
        System.out.println(mainResult);
    }
}

类加载

  当我们点击Run example.main时,前端编译器会负责从文件系统或网络中编译生成符合规范的Class文件交付于ClassLoader加载(这里是把编译后的example.class文件交付给ClassLoader),而运行交由执行引擎处理,即:
【java基础】JVM执行流程_第1张图片

类加载过程

  JVM类加载机制分为五个部分,分别为加载、验证、准备、解析、初始化,当类流程都走一遍后,加载的类信息存放于一块称为元空间的内存空间(除了类信息、还会存放运行时常量池信息),如图示:
【java基础】JVM执行流程_第2张图片

1、Loding 加载
  该过程就是通过一个类的全限定名获取定义此类的二进制字节流(比如:com.jvm.learn.example, Class文件就是一组以8字节为基础单位的二进制流),将这个字节流的静态存储结构转换为元空间的运行时数据结构,并且生成一个代表这个类的java.lang.Class对象,作为元空间这个类的各种数据的访问入口。
  加载的阶段还涉及到了双亲委派机制。概念是指:一个类加载器如果收到了类的加载请求,自身不会先去加载,而是向上层递交请求委托。如果父类还存在上级,就继续递交,直到请求最终到达最顶层的引导类加载器(根据加载路径判断)。如果此时父类无法完成加载任务,自身才会加载。这样做可以避免类的重复加载,还保证程序安全,防止核心API被随意篡改,比如我们就不能定义string
2、Linking 连接

  • 验证Verify
      该过程就是对字节流进行四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证。例如,用winHex打开explame.class文件,可以看到:
    【java基础】JVM执行流程_第3张图片
  • 准备Prepare
      准备阶段要注意两点:
    ① 不包含final修饰的类变量,因为final修饰的类变量会在编译期生成ConstantValue属性,在准备阶段JVM会根据ConstantValue属性来赋值;
    ② 另外实例变量也不会被分配和初始化,实例变量要随着对象一起分配到heap中。

但准备阶段ClassLoader会为类变量(指被static修饰的变量)分配内存并设置该类变量的默认初始值,引用类型为null,数值型为0。也是说,exmaple类中的类变量sex在准备阶段过后的初始值为0, 而不是1,将sex赋值为1的put static指令使程序被编译后,存放到类构造器< clinit>方法之中的

  • 解析Resolve
      解析阶段的工作很简单,就是将运行时常量池中的符号引用转换为直接引用。符号引用就是指:
CONSTANT_Class_info
CONSTANT_Field_info
// 等类型的常量, 符号引用应用的目标并不一定已经加载到内存中
// 直接引用是可以指向目标的指针,如果有了直接引用那引用的目标必定在内存之中

3、Initliation 初始化
  初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义加载器以外,其他操作都由JVM主导,到了初始化阶段,才真正执行类中定义的Java程序代码。
  初始化阶段是负责执行类构造器< clinit>方法的过程, 也就是为准备阶段的类变量和对静态块的赋值操作。如果一个雷中没有对静态变量赋值或者存在静态块,那< clinit>方法是不存在的。

//也就是
prepare阶段:  public static int sex = 0;				//initial阶段中 sex --> 1  	

执行引擎

虚拟机是相对于物理机的概念,两者都有执行代码的能力。 物理机的执行引擎是建立在处理器、缓存、指令集合和操作系统层面上的。而虚拟机的执行引擎则是由软件实现的。

  当ClassLoader完成自己的任务后,java代码的执行就交付给执行引擎,执行引擎与JVM运行时数据区一起完成代码执行流程。
  JVM的执行引擎有两种,一种是历史悠久的解释器,另一种则是JIT:

  • 解释器: 解释器响应速度快,对字节码采用逐行解释翻译成本地机器语言的方法去执行java代码
  • JIT: JIT是直接将字节码翻译成本地机器相关(Windwos对应Windows、linux对应linux)的机器语言

  解释器与JIT相辅相成,一般而言首先都是它发挥作用,不必等待JIT编译器全部编译后再执行,省去不必要的编译时间。并且随着程序的运行,JIT编译器会逐渐发挥作用,根据热点探测功能(即根据方法调用计数器或者回边计数器来确定热点代码, 一般而言for循环内的循环体会被确认为热点代码),将有价值的字节码编译成本地机器指令缓存起来,换取更高的程序执行效率。大致执行情况如下:
【java基础】JVM执行流程_第4张图片

运行时数据区

  此时我们再看到示例代码,当ClassLoader执行完毕后,运行时常量池会填充完毕,这里保存着各种字面量和符号引用。随后执行引擎中的解释器会率先启动,对ClassFile字节码采用逐行解释的方式加载机器码,并配合运行时数据区的程序计数器与操作数栈来支持java.exe程序的执行。

  简单查看main()方法的运行过程:

public static void main(String[] args) {
  	 example example = new example();
     int mainResult = example.add();
     System.out.println(mainResult);
 }

【java基础】JVM执行流程_第5张图片

执行过程如下:

1、为main方法创建栈帧:

  再创建栈帧之前,需要知道栈帧的组成部分:

  • 局部变量表: 局部变量表被定义为一个数字数组, 保存返回地址参数、方法参数类型和方法体内的局部变量
    注意: 基本数据类型(如byte/short/char)在存储前会被转换为int,boolean类型也会,对应的是0为false,1为true

  • 方法返回地址: 保存了PC寄存器的地址值,也就是调用者的PC计数器的值作为返回地址

  • 动态链接: 指向运行时常量池的方法引用。比如,描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
    注意: 方法的调用分为静态调用和动态调用
    ①、静态调用是指编译器确定的,是非虚方法(静态方法、私有方法 、final方法、实例构造器、父类方法)
    ②、动态调用是指运行期确定的,是虚方法

  • 操作数栈: 根据字节码指令,往栈中写入数据或提取数据。主要用于保存计算过程的中间结果,同时作为计算过程中临时变量的存储空间

回到创建栈帧,即局部变量表长度为3, slot0存放参数args, slot1存放局部变量example,slot2存放局部变量mainResult,操作数栈最大深度为2.
【java基础】JVM执行流程_第6张图片

2、 new #2 指令, 在java堆中创建java对象

  继续补充知识:

  • 堆:所有的对象实例以及数组都应该在运行时分配到堆上。
    注意:
    ①、堆区是线程共享的区域,任何线程都可以访问到堆区中的共享数据,但由于对象实例在堆区创建非常频繁,因此在并发环境下从堆区中划分内存空间时线程不安全的。因此额外需要一块空间加锁来避免死锁——这块空间叫TLAB, 仅占Eden区的百分之一。
    ②、堆区不是唯一的分配区域,这涉及到逃逸分析: 假设一个对象没有逃逸出该方法,那么就可能被优化为栈上分析,会使运行速度更快。当一个对象在方法中被定义后,对象只在方法内部使用,那就是没有发生逃逸。若对象在方法中被定义后,对象在方法外被引用了,那就是发送了逃逸,如下:
StringBuffer sb = new StringBuffer();
return sb;				//逃逸
return sb.toString();	//未逃逸

回到代码,new#2指令,在堆中创建了一个example对象,并将其引用值放入操作数栈,即:
【java基础】JVM执行流程_第7张图片
3、invokespecial #3 >
  调用的是< init>方法,编译器将调用父类的< init> 的语句、构造代码块、实例字段赋值语句,以及自己编写的构造方法中的语句整合在一起生成一个方法。这里调用的是默认构造函数,只是向上调用父类Object的init方法:

0 aload_0
1 invokespecial #1 <java/lang/Object.<init>>
4 return

4、第一行代码执行完毕, 将example返回给局部变量表,继续执行add方法
  此时可以看到:

astore_1			//将slot1处的引用类型值入局部表
aload_1				//将example加载到操作数栈
invokevirtual #4 <com/jvm/learn/example.add>		//调用example的add方法

5、add方法
  创建add()方法的栈帧, 即:
【java基础】JVM执行流程_第8张图片
  有了上面的理解,直接看字节码指令:

 0 iconst_1			// 直接将slot1的常量1压入操作数栈
 1 istore_1			// 存入局部变量表
 2 iconst_2			// 直接将slot2的常量2压入操作数栈
 3 istore_2			// 存入局部变量表
 4 iload_1			// 从局部变量表中加载
 5 iload_2			// 从局部变量表中加载
 6 iadd				// 相加
 7 bipush 100		// 将100如栈
 9 imul				// 与add结果相乘
10 istore_3			// 将结果保存到局部变量表汇总
11 iload_3			// 加载局部变量index为3的数据
12 ireturn			// 返回结果

6、 到这就剩下打印语句的执行
  过程也很简单,将add方法返回的结果入局部变量表,从运行时常量池中获取System.out的类元信息,从局部变量表中加载mainResult,调用PrintStream.println方法,返回结束。

总结

  总结起来就是,一个Class File文件首先经过ClassLoader的加载、链接、初始化加载到元空间,一些符号引用被解析为直接引用或等到运行时分派(动态绑定)。
  然后程序通过class对象来访问元空间里的各种类型数据,当加载完之后,执行引擎发现main方法,也就是程序入口,那么就会创建相应的栈帧,执行引擎逐行读取方法内的代码转换为机器码,而这些指令大多已经被解析为直接引用了,那么执行引擎通过持有这些直接引用去元空间寻找变量对应的字面量来进行方法操作。
  完成操作后,栈帧出栈,内存空间被GC回收。

全流程包括以下步骤: 源码编写——编译(javac编译)----> 类文件被加载到虚拟机(内存) —> 虚拟机执行引擎执行二进制字节码 —> 形成运行时数据区 -----> 垃圾回收(JVM回收机制)

你可能感兴趣的:(java基础,jvm)