暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

浅析Java程序运行原理

葛瑞士的技术博客 2021-04-29
1053

本文讨论一个Java程序是如何运行的。
Java是编译型语言,先将源代码编译成.class文件,然后由JVM加载进行解释执行。

字节码文件简介

将下面的Java代码使用 javac
命令进行编译。

  1. public class Demo{

  2. public static void main(String[] args){

  3. int x = 500;

  4. int y = 100;

  5. int a = x / y;

  6. int b = 50;

  7. System.out.println(a + b);

  8. }

  9. }

执行 javacDemo.java
 后会在当前目录生成 Demo.class
 文件,用16进制编辑器将其打开。

.class文件的数据是严格按照格式紧凑排列的二进制流,中间没有任何分隔符,称为字节码文件。在每个Java字节码文件开头有一个 0xcafebabe
 (16进制) 特殊标志。文件内包含了版本信息、访问标志、常量池、当前类信息、父类、接口、字段、方法、属性等信息。

分析字节码文件内容

使用编辑器直接打开字节码文件是看不懂的,可以借助Java提供的 javap
 命令。在终端执行:javap-vDemo.class>Demo.txt
 ,打开Demo.txt就可以看到解析后的内容。

版本信息

文件开始几行会有版本信息,版本信息包含有主版本号、次版本号,同时还会有一个访问标志。

版本号是指JDK的版本,数字49,50,51,52 分别对应 jdk 5,6,7,8。访问标志对应类的修饰符和作用于类的一些关键字,详见下表:

常量池

再往下有一个常量池(Constant pool)的定义:

这个常量池和String常量池有一定区别,这里存储的信息是类信息包含的静态常量,这些值是编译这个类之后需要的一些常量,比如方法名称、类的名称,这些是类本身就需要的一些常量。这里面会有很多类型,详见下表:

构造方法

再往下,是一个构造函数:

在源代码文件中并没有写构造函数,但是在编译后的字节码文件中,却有一个public的无参构造函数。由此可见,当我们没有定义构造函数时,会有隐式的无参构造函数。

程序入口main方法

最后是main方法:

Demo.java的代码逻辑就是进行除法和加法运算,最后输出55。方法描述包含了以下内容(序号对应于图中序号):

  1. 访问控制:标识方法是静态的,是public的等等。


    • locals:本地变量数量,这里是 args
        x
        y
        a
        b
        这5个变量。

    • args_size:方法参数数量

    • stack :方法对应栈帧中操作数栈的深度

  2. JVM执行引擎去执行这些指令码。需要注意的是这些通过 javap
     命令翻译出来的操作符,实际在class文件内不会存在这些字符,而是存储的指令码,关于指令码可以详细查看“JVM指令码表”。前面的数字是指令的偏移量(字节),JVM根据这个去区分不同的指令。

JVM运行时数据区

在具体分析程序运行原理之前,先了解一下JVM运行时数据区的概念。Java源代码被编译成.class字节码文件后,这个字节码文件就会被JVM加载,然后分配一个内存区域去存储它的信息。JVM将内存划分为几个数据区,包含有方法区、堆内存、虚拟机栈、本地方法栈、程序计数器。这些数据区从线程角度又可以划分为两类:线程共享部分和线程独占部分,其中方法区和堆内存属于线程共享部分,虚拟机栈、本地方法栈和程序计数器属于线程独占部分。当然一个Java程序执行还需要JVM执行引擎、本地方法库等,本文不探讨JVM底层实现,只涉及到下图中红框内容。

线程独占:每个线程都有它独立的内存空间,随线程生命周期而创建和销毁。线程共享:所有线程能访问这块内存数据,随虚拟机或者GC而创建和销毁。

方法区

方法区用来存储加载的类信息、常量、静态变量、编译后的代码等数据。在虚拟机规范中是一个逻辑区域,根据不同的虚拟机有不同的具体实现。比如 oracle 的 HotSpot 虚拟机在Java 7及以前方法区放在永久代,Java 8及以后放在元数据空间,并且由GC机制进行管理。其他的一些虚拟机可能有不同的实现。

堆内存

堆内存在JVM启动时创建,用来存放程序中创建的对象实例。堆内存又进一步细分为老年代和新生代。新生代里又分为3个区域:Eden、From Survivor、To Survivor。垃圾回收器主要就是管理堆内存。如果这块内存不够用了,就会抛出 OutOfMemroyError
异常。

虚拟机栈

虚拟机栈属于线程独占部分,每个线程都在这个空间有一个私有的内存区域。每个线程栈由多个栈帧(Stack Frame)组成,一个栈帧就对应到一个方法,栈帧中包含局部变量表、操作数栈、动态链接、方法返回地址、附加信息等内容。栈内存默认最大是1M,超出则会抛出 StackOverflowError
异常。

本地方法栈

本地方法栈也是线程独占的空间,和虚拟机栈功能相似,虚拟机栈是为虚拟机执行Java方法而准备的,本地方法栈是为虚拟机执行Native本地方法而准备的。在虚拟机规范中没有规定具体的实现,由虚拟机厂商去具体实现,比如在 HotSpot 虚拟机中本地方法栈和虚拟机栈的实现方式是一样的,超出大小也会抛出 StackOverflowError
异常。可以将虚拟机栈和本地方法栈作为同一个概念去理解, 只是执行的方法不同。

程序计数器

程序计数器(Program Counter Register) 记录当前线程执行字节码的位置,它存储的是字节码的指令地址,如果执行Native方法,则计数器值为空。每个线程都在这个空间有一个私有的区域来存储它对应的执行位置,因为CPU同一时间只能执行一个线程中的指令,多线程会轮流切换并分配CPU执行时间,当进行线程切换后,需要通过程序计数器来恢复该线程正确的执行位置。

Demo程序完整运行分析

加载

JVM将编译后的字节码加载到方法区,当然JVM不可能只加载一个Demo类,还有很多其他的类信息都存储在方法区,比如在代码中定义的字符串(String)会以字符串常量的形式存放在常量池里面。

加载完之后JVM就创建线程来执行代码,既然要创建线程,那么就需要在虚拟机栈、程序计数器内存区域中分配线程独占的空间。这里不涉及到本地方法,因为Demo程序全是Java代码。

运行

Demo程序中创建的对象实例将存放到堆里面,线程运行会在程序计数器上面开辟一个小小的空间,用来记录当前线程执行代码的位置。还要在虚拟机栈里面开辟一个空间,虚拟机栈是由多个栈帧组成,一个栈帧对应一个方法。
Demo程序的main方法对应的栈帧有本地变量表、操作数栈。栈帧的其他信息,本文不做探讨。

本地变量表中序号0是方法参数 String[]args
 。程序运行就是将代码转换为指令码的形式运行,如下图所示:

下面对指令一条一条的进行分析。开始执行序号为0的指令 sipush500
 的含义是将500这个数值压入操作数栈,此时程序计数器记录位置0,操作数栈栈顶为500。当前线程内存状态如下图:

继续执行序号3指令 istore_1
 ,这个指令是将操作数栈栈顶弹出,保存到本地变量表1位置。此时操作数栈就没有数据了,数值500存储在本地变量表1位置,程序计数器记录当前指令地址3。
继续执行序号4指令 bipush100
 , 将100这个数值压入操作数栈。这里和序号0用的是不同的指令,不同指令操作的数据类型和数据大小是不同的。此时操作数栈栈顶为100。
继续执行序号6指令 istore_2
 ,弹出操作数栈栈顶100,保存到本地变量表2位置。此时操作数栈空,100存储到本地变量表2位置。
接下来就要做除法运算了,首先要把本地变量表信息读出来,然后进行运算。对应到字节码指令就是首先执行 iload_1
 指令,读取本地变量表1,压入操作数栈。再执行 iload_2
 指令读取本地变量表2,压入操作数栈。此时操作数栈从栈顶到栈底依次为100,500。然后通过指令 idiv
 将操作数栈栈顶前两个int类型数出栈进行除法运算,并将运算结果(500/100=5)入栈。此时操作数栈栈顶为5。
继续执行序号10指令 istore_3
 ,将上一步运算出的结果从操作数栈出栈,保存到本地变量表3位置。
接下来是一条赋值语句,首先执行序号11指令 bipush50
 ,将50压入操作数栈。然后执行序号13指令 istore4
 ,将操作数栈栈顶50出栈,保存到本地变量表4位置。
继续执行序号15指令 getstatic#2
 ,对应到Java代码里是要进行方法调用。这条指令的含义是获取类或接口字段的值并将其压入操作数栈, 指令中的#2对应于常量池中的 Fieldref#15.#16
 。此时操作数栈栈顶为#2。
接下来要进行加法运算,首先 iload_3
 将本地变量表3中数值取出来压入操作数栈,然后 iload4
 将本地变量表4中的数值取出来压入操作数栈,此时操作数栈内容为50、5、#2,然后执行 iadd
 指令将操作数栈栈顶前两个数值出栈进行相加操作,再将结果入栈。最后操作数栈中的内容为55、#2。
执行到这,接下来要进行新的方法调用了,执行序号22指令 invokevirtual#3
 ,JVM会根据这个方法的描述,创建新栈帧,main方法不跑了,程序计数器也会重新开始计数。方法的参数从操作数栈中弹出,成为新栈帧的内容,然后虚拟机会开始执行虚拟机栈栈顶的栈帧。新方法执行完毕后,再继续执行main方法对应的栈帧。
最后执行到序号25指令 return
 ,函数返回,main方法执行结束。

以上就是一个Java程序运作的过程,实际上,Java程序运行就是在对本地变量表、操作数栈等等线程中的信息进行操作,最终实现程序运行的效果。

结语

本文将JVM运行核心逻辑进行了梳理,探讨了运行时数据区、栈帧的一些内容、以及指令码的执行过程等。JVM运行原理中更底层的实现,比如线程是怎么调度的,JVM针对不同的操作系统或处理器,会有不同的实现,这也是Java能够实现“一处编写,处处运行”的原因。如果读者想去了解更深层次,比如用C语言写的线程调度以及执行引擎的内部实现核心原理,可以去详细阅读OpenJDK的开源代码。


文章转载自葛瑞士的技术博客,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论