JVM系列文章(一)——Java 程序运行机制和流程

unimof 2021年02月03日 320次浏览

0. 概述

通过分析 Hello World 程序执行流程,对Java程序是如何从源码到的进程的执行机制进行分析和理解。

整体式,Java 程序的工作流程如下:
image.png

下面,我们开始进行逐步分析!

1. 从源码开始

使用任意的文本编辑器编写一个简单的 Hello World 程序:

import System;

public class LanshuTec {
    public static void main(String[] args){
        System.out.println("Hello World!");
    }
}

保存后,即可使用javac进行编译,并使用java执行,即可在屏幕输出:

Hello World!

2. 从 源码 到 Java 字节码

源码编写完成后,通过 javac 命令进行编译,输出 Java 字节码到对应的 .class 文件。下图展示一个 Java 字节码用文本文件打开后的效果,我们可以看到一堆16进制的字节。
如果你使用IDE去打开,也许看到的是已经被反编译的我们所熟悉的java代码,而这才是纯正的字节码,

image.png

作为编码老司机,不仅要知其然,还要知其所以然,其实 Java 字节码,也是有严格的格式定义的,直接上图:
image.png

这张图是一张java字节码的总览图,我们也就是按照上面的顺序来对字节码进行解读的。一共含有10部分,包含魔数,版本号,常量池等等,接下来我们按照顺序一步一步解读。

  • 魔数
    从上面的总览图中我们知道前4个字节表示的是魔数,对应我们Demo的是 0XCAFE BABE。什么是魔数?魔数是用来区分文件类型的一种标志,一般都是用文件的前几个字节来表示。比如0XCAFE BABE表示的是class文件,那么有人会问,文件类型可以通过文件名后缀来判断啊?是的,但是文件名是可以修改的(包括后缀),那么为了保证文件的安全性,将文件类型写在文件内部来保证不被篡改。
    从java的字节码文件类型我们看到,CAFE BABE翻译过来是咖啡宝贝之意,然后再看看java图标。

  • 版本号
    我们识别了文件类型之后,接下来要知道版本号。版本号含主版本号和次版本号,都是各占2个字节。在此Demo种为0X0000 0033。其中前面的0000是次版本号,后面的0033是主版本号。通过进制转换得到的是次版本号为0,主版本号为51。
    从oracle官方网站我们能够知道,51对应的正式jdk1.7,而其次版本为0,所以该文件的版本为1.7.0。如果需要验证,可以在用java –version命令输出版本号,或者修改编译目标版本–target重新编译,查看编译后的字节码文件版本号是否做了相应的修改。

至此,我们共了解了前8字节的含义,下面讲讲常量池相关内容。

  • 常量池
    紧接着主版本号之后的就是常量池入口。常量池是Class文件中的资源仓库,在接下来的内容中我们会发现很多地方会涉及,如Class Name,Interfaces等。常量池中主要存储2大类常量:字面量和符号引用。字面量如文本字符串,java中声明为final的常量值等等,而符号引用如类和接口的全局限定名,字段的名称和描述符,方法的名称和描述符。
    为什么需要类和接口的全局限定名呢?系统引用类或者接口的时候不是通过内存地址进行操作吗?这里大家仔细想想,java虚拟机在没有将类加载到内存的时候根本都没有分配内存地址,也就不存在对内存的操作,所以java虚拟机首先需要将类加载到虚拟机中,那么这个过程设计对类的定位(需要加载A包下的B类,不能加载到别的包下面的别的类中),所以需要通过全局限定名来判别唯一性。这就是为什么叫做全局,限定的意思,也就是唯一性。

在进行具体常量池分析之前,我们先来了解一下常量池的项目类型表:
image.png

上面的表中描述了11中数据类型的结构,其实在jdk1.7之后又增加了3种(CONSTANT_MethodHandle_info,CONSTANT_MethodType_info以及CONSTANT_InvokeDynamic_info)。这样算起来一共是14种。接下来我们按照Demo的字节码进行逐一翻译。

0×0015:由于常量池的数量不固定(n+2),所以需要在常量池的入口处放置一项u2类型的数据代表常量池数量。因此该16进制是21,表示有20项常量,索引范围为1~20。明明是21,为何是20呢?因为Class文件格式规定,设计者就讲第0项保留出来了,以备后患。从这里我们知道接下来我们需要翻译出20项常量。
Constant #1 (一共有20个常量,这是第一个,以此类推…)
0x0a-:从常量类型表中我们发现,第一个数据均是u1类型的tag,16进制的0a是十进制的10,对应表中的MethodRef_info。
0x-00 04-:Class_info索引项#4
0x-00 11-:NameAndType索引项#17
Constant #2
0x-09: FieldRef_info
0×0003 :Class_info索引项#3
0×0012:NameAndType索引项#18
Constant #3
0×07-: Class_info
0x-00 13-: 全局限定名常量索引为#19
Constant #4
0x-07 :Class_info
0×0014:全局限定名常量索引为#20
Constant #5
0×01:Utf-8_info
0x-00 01-:字符串长度为1(选择接下来的一个字节长度转义)
0x-61:”a”(十六进制转ASCII字符)
Constant #6
0×01:Utf-8_info
0x-00 01:字符串长度为1
0x-49:”I”
Constant #7
0×01:Utf-8_info
0x-00 06:字符串长度为6
0x-3c 696e 6974 3e-:”
Constant #8
0×01 :UTF-8_info
0×0003:字符串长度为3
0×2829 56:”()V”
Constant #9
0x-01:Utf-8_info
0×0004:字符串长度为4
0x436f 6465:”Code”
Constant #10
0×01:Utf-8_info
0×00 0f:字符串长度为15
0x4c 696e 654e 756d 6265 7254 6162 6c65:”LineNumberTable”
Constant #11
ox01: Utf-8_info
0×00 12字符串长度为18
0x-4c 6f63 616c 5661 7269 6162 6c65 5461 626c 65:”LocalVariableTable”
Constant #12
0×01:Utf-8_info
0×0004 字符串长度为4
0×7468 6973 :”this”
Constant #13
0×01:Utf-8_info
0x0f:字符串长度为15
0x4c 636f 6d2f 6465 6d6f 2f44 656d 6f3b:”Lcom/demo/Demo;”
Constant #14
0×01:Utf-8_info
0×00 0a:字符串长度为10
ox74 6573 744d 6574 686f 64:”testMethod”
Constant #15
0×01:Utf-8_info
0x000a:字符串长度为10
0x536f 7572 6365 4669 6c65 :”SourceFile”
Constant #16
0×01:Utf-8_info
0×0009:字符串长度为9
0x-44 656d 6f2e 6a61 7661 :”Demo.java”
Constant #17
0x0c :NameAndType_info
0×0007:字段或者名字名称常量项索引#7
0×0008:字段或者方法描述符常量索引#8
Constant #18
0x0c:NameAndType_info
0×0005:字段或者名字名称常量项索引#5
0×0006:字段或者方法描述符常量索引#6
Constant #19
0×01:Utf-8_info
0×00 0d:字符串长度为13
0×63 6f6d 2f64 656d 6f2f 4465 6d6f:”com/demo/Demo”
Constant #20
0×01:Utf-8_info
0×00 10 :字符串长度为16
0x6a 6176 612f 6c61 6e67 2f4f 626a 6563 74 :”java/lang/Object”
到这里为止我们解析了所有的常量。接下来是解析访问标志位。

  • Access_Flag 访问标志
    访问标志信息包括该Class文件是类还是接口,是否被定义成public,是否是abstract,如果是类,是否被声明成final。通过上面的源代码,我们知道该文件是类并且是public。
    image.png

3. 从 Java 字节码 到 机器码

当源代码转化为字节码之后,其实要运行程序,有两种选择。一种是使用 Java 解释器解释执行字节码,另一种则是使用 JIT 编译器将字节码转化为本地机器代码。
这两种方式的区别在于,前者启动速度快但运行速度慢,而后者启动速度慢但运行速度快。至于为什么会这样,其原因很简单。因为解释器不需要像 JIT 编译器一样,将所有字节码都转化为机器码,自然就少去了优化的时间。而当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。所以在实际情况中,为了运行速度以及效率,我们通常采用两者相结合的方式进行 Java 代码的编译执行。