Java 程序的构造与执行
引子
本文将从高级语言与机器指令的差异讲起,聊聊 Java 技术体系中,程序的构造与执行原理。目标是能勾勒出一条知识路线,认识日常繁杂的业务代码运行背后那些默默支持的底层技术。文章中部分章节为读书笔记,其原理性的描述参考的书籍,放在了文章末尾处。
C 语言与机器指令
一段 C 语言代码
我们先从一段 C 语言代码,通过编译、反编译工具认识一下高级语言与机器指令的差异。为后续 Java 程序的组织结构做一个铺垫。
机器级代码
通过 gcc -O1 -c code.c 编译汇编之后:生成 code.o 二进制文件,其以 16 进制打开后展示为:
本次编译环境为:Intel Core I5 8500B,MacOS 10.15.7,Apple clang version 12.0.0 (clang-1200.0.32.28)
汇编代码
通过反汇编工具,将 code.o 逆向出来则其汇编代码如下:
以上,代码展示了一个简单的 C 程序形态、二进制程序文件、汇编指令,从汇编指令可以看到具体编译环境所对应的指令格式与寄存器名称。这种基于寄存器设计的指令格式,与 Java 面向栈的指令结构存在较大差异。后边我们将会讲解。
计算机原理回顾
再让我们回顾一下计算机系统相关的一些原理,这对后边理解 Java 虚拟机的原理有很大帮助,Java 虚拟机在设计上借鉴了很多 C 程序设计思路。因此存在很多相似之处。
编译的过程
【图】C 语言的编译过程
预处理阶段:修改原始 C 程序,根据以 #开头的命令插入相应内容,比如 #include<stdio.h>命令告诉预处理器读取系统头文件 stdio.h 的内容并把它直接插入到程序文本中。结果就得到了另一个 C 程序,通常以.i 为扩展名。
编译器阶段:将.i 文本文件翻译成汇编文件.s 汇编语言程序中的每条语句都以一种标准的文本格式确切地描述了一条低级机器语言指令。汇编语言为不同高级语言编译器提供了通用的输出语言。比如 C 编译器和 Fortran 编译器产生的输出文件用的都是同样的汇编语言。
汇编阶段:汇编器(as)将 xxx.s 翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式并将结果保存在 xxx.o 中。
连接阶段:合并函数调用,汇编阶段产生的仅包含目标函数的“符号引用”。链接就是将对应的函数进行合并,结果就得到可执行目标文件,可被加载到内存中,由系统执行。
计算机系统典型硬件组成
【图】计算机系统典型硬件组成
该图展示了计算机系统的典型组织原理架构图。其包含了处理器、IO 系统、存储器(内存)、磁盘、输入输出设备以及其它外设。
指令格式
【图】指令格式示意
该图展示了指令格式在概念上的形态,从第一部分我们可以看到,C 语言编译汇编后的指令格式仅有两个地址,目的地址与源地址。
指令寻址方式有:直接寻址,间接寻址,绝对寻址,寄存器寻址,立即数寻址,变址寻址,比例变址寻址。
寄存器举例
【图】IA32 的整数寄存器
IA32 整数寄存器文件包含 8 个命名位置,分别存储 32 位的值。这些寄存器可以存储地址(C 的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器则用来保存临时数据,例如程序运行过程的局部变量和函数返回值。
%ax,%cx,%dx,%bx 这些是对 16 位操作,%ah,%al...这是对字节(8 位)操作
Java 技术体系
下图展示了 JavaSE 相关的技术体系,从图中可以看到,以最上层的 Java 语言,以及下部的 Java 虚拟机两大技术为基础,构成了 Java 技术体系。
【图】Java 技术体系包括内容(基于 Java8)
图片引用:https://docs.oracle.com/javase/8/docs/ 在此基础上增加了操作系统的底座
JVM 所处的位置
【图】Java 与 C/C++跨平台的差异
JVM 是用 C++构建的应用,它是沟通不同操作系统的桥梁,也是实现跨平台的关技术组件。
Java 的跨平台与 C/C++的区别:Java 一处编译到处运行;C/C++则需要在不同的平台上多次编译。
为什么需要跨平台
处理器指令集不止一种、操作系统不止一种,如果要实现一处编译到处运行,则需要一个中间技术解决这个难题。Java 除了跨平台以外,还帮程序员解决了内存回收管理问题,降低了软件开发难度。
实现跨平台
【图】Java 实现跨平台的无关性基石
不同平台的 JVM 与结构稳定的 Class 字节码文件格式构成了语言无关性和平台无关性基石。
《Java 虚拟机规范》定义了 JVM 的技术规范与 java 字节码 Class 文件的结构,各厂商按照规范实现 Java 虚拟机,目前比较流行的虚拟机有 Oracle 的 HotSpot,和 IBM 的 J9(这两大虚拟机的历史与纠葛,可参看文章末尾处的书籍)。由于 hotspot 是 JDK 默认的虚拟机,因此很多时候人们讨论 JVM 相关技术时说的都是 HotSpot 的相关内容。实际上各 JVM 之间存在着诸多差异
虚拟机的另一种中立特性:语言无关性,在 Java 语言发展之初,设计者们就已经考虑过并实现了让其他语言运行在 Java 虚拟机之上的可能性,因此发布规范文档的时候,刻意把 Java 的规范拆分成了《Java 语言规范》及《Java 虚拟机规范》。
商业企业和开源机构已经在 Java 语言之外发展出了一大批运行在 Java 虚拟机之上的语言,如 Kotlin(曾传言为 Java 的替代者,https://cloud.tencent.com/developer/article/1188029)、Clojure、Groovy、JRuby、JPython、Scala 等。
JVM 对内存的管理
【图】JVM 虚拟机运行时数据区
程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
从《Java 虚拟机规范》中无规定任何 OutOfMemoryError
Java 虚拟机栈
每个方法被执行的时候,Java 虚拟机会同步创建一个栈帧(Stack Frame),用于储存局部变量表、操作数栈、动态连接、方法出口等信息。每个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowErr 异常;
如果虚拟机栈容量可以动态扩展(hotspot 不行),扩展时无法申请到足够的内存会抛出 OutOfMemoryError 异常
本地方法栈
与虚拟机栈作用非常相似,其区别是虚拟机栈为虚拟机执行 Java 字节码服务,而本地方法栈则是为虚拟机使用到的 Native 方法服务。
HotSpot 虚拟机直接将本地方法栈与虚拟机栈合二为一。
Java 堆
Java 虚拟机管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的是存放内存对象实例,在《Java 虚拟机规范》中对 Java 堆的描述是:“所有对象实例以及数组都应当在堆上分配”。随着技术的发展,这个情况也变得不是那么绝对了。
垃圾回收管理器管理的内存区域。
根据《Java 虚拟机规范》的规定,Java 堆可以处于物理上不连续的内存空间中,但在逻辑上应该被视为连续的。其大小可通过-Xmx 和-Xms 设定。
如果 Java 堆中没有完成内存实例的分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError 异常。
方法区
各线程共享的内存区域,存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
虽然《Java 虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是他却有一个别名叫做“非堆”(Non-Heap),目的是与 Java 堆区分开来。
“永久带” (Permanent Generation)不等价于“方法区”,“永久带”是 HotSpot 虚拟机设计团队选择把垃圾收集器的分带设计扩展至“方法区”,或者说使用“永久带”替代方法区,省去专门为方法区编写内存管理代码的工作。其他虚拟机譬如 BEA JRockit、IBM J9 等,是不存在永久带的概念的。这种设计导致了 Java 应用程序更容易遇到内存溢出的问题(永久带有-XX:MaxPermSize 的上限,即使不设置也有默认大小,而 J9 和 JRockit 只要不触碰到进程可用的内存上限,就不会出问题)
JDK6 的时候 HotSpot 团队就有放弃永久带,逐步改为本地内存(Native Memory)来实现方法区的计划,JDK7 的 HotSpot,已经将原本放在永久带的字符串常量池、静态变量等移植到 Java 堆中,到了 JDK8 放弃永久带,改用与 JRockit|J9 一样在本地内存中实现的元空间(Meta-space)替代。
《Java 虚拟机规范》规定,如果方法区无法满足新内存分配需求时,将抛出 OutOfMemoryError 异常
运行时常量池
运行时常量池(Runtime Constant Pool)是“方法区”的一部分。
在类加载后,保存 Class 文件中除类版本、字段、方法、接口等描述信息外的“常量池”表中的编译期产生的各种字面量与符号引用(编译概念)。
《Java 虚拟机规范》对这部分没有做任何细节要求,一般来说,除了保存 Class 文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储道运行时常量池中。
与方法区相同,存在 OutOfMemoryError 异常。
直接内存
并不是虚拟机运行时数据区的一部分,也不是《Java 虚拟机规范》中定义的内存区域。
JDK1.4 中新加了 NIO(New Input/Output)类,引入了基于通道与缓冲区的 I/O 方式,它可以直接使用 Native 函数直接分配堆外内存,通过 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样避免在 Java 堆与 Native 中来回复制数据,能在某些场景显著提高性能。
在根据主机内存设置-Xmx 等参数信息时,需要考虑这部分内存,防止超过物理内存限制,避免 OutOfMemoryError 异常。
Class 文件结构
一段 java 程序
Class 文件的 16 进制展示形态
Class 文件结构中只有两种数据类型:“无符号数”和“表”
排列不像 xml 等有明显的分隔符号,Class 是连续线性的紧凑结构
Class 文件包含内容列举
【表格】class 文件所包含的内容结构
将 TestClass 的源文件产生的.class 文件通过工具解析为可读的文本:
共有设计私有实现
按照《Java 虚拟机规范》实现其中要求的内容,也可以在其约束下对具体实现做出修改和优化。只要优化以后的 Class 文件依然可以被正确读取。并且包含其中的语义能得到完整保持。
将输入的 Java 虚拟机代码在加载时或执行时翻译成另一种虚拟机的指令集
将输入的 Java 虚拟机代码在加载时或执行时翻译成宿主机处理程序的本地指令集(即即时编译器代码生成技术)
Class 文件结构的发展
Class 文件结构自《Java 虚拟机规范》初版定立以来,已经有超过二十年的历史。
相对于语言 API 以及 Java 技术体系中其他方面的变化,Class 文件结构一直处于一个相对比较稳定的状态,Class 文件的主体结构、字节码指令的语义和数量几乎没有出现过变动
所有变动都集中在访问标志、属性表这些设计上原本就是可扩展的数据结构中添加新内容
Class 文件格式所具备的平台中立(不依赖于特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是 Java 技术体系实现平台无关、语言无关两项特性的重要支柱。
字节码指令
长度为 1 个字节、代表某种特定操作含义的数字(Opcode),以及紧随其后的零至多个代表操作所需的参数(Operand)构成
由于 Java 虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大部分不包含操作数,只有一个操作码,指令参数都在操作数栈中。
面向操作数栈与面向寄存器的指令执行设计的区别见最后章节分解
【表格】JVM 字节码指令列举
类的加载
类加载的时机
遇到 new,getstatic,putstatic,invokestatic 指令时
使用 java.lang.reflect 包的方法对类进行反射调用时
初始化类时,但是父类还没初始化,则会先触发父类初始化
虚拟机启动时,对包含 main()方法的类初始化
使用 JDK7 中新加入的动态语言支持时:REF_getStatic,REF_putStatic,REF_invokeStatic,REF_newInvokeSpecial 四种方法句柄对应的类如果没有初始化
接口中定义了 JDK8 新加入的默认方法,如果实现类发生了初始化,那么这个接口要在其之前被初始化
类的生命周期
【图】类的生命周期
加载
获取字节流
将字节流所代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口
验证
主要目的是为了安全
文件格式验证:保证输入字节流能正确解析并存储与方法区之内,格式上符合 Java 类型信息要求。后续阶段就依赖方法区结构了。
元数据验证:语义校验,保证不存在与《Java 语言规范》相悖的元数据信息。
字节码验证:保证方法体在运行时不会做出危害虚拟机安全的行为
符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。如果无法通过,则会抛出异常,典型的异常:java.lang.IllegalAccessError,java.lang.NoSuchFieldError,java.lang.NoSuchMethodError 等。可以通过-Xverify:none 关闭,缩短类加载时间。
准备
为类中定义的静态变量,分配内存并设置初始值。此处的初始值是 0 值,真正的赋值动作是在<clinit>()方法中,所以源码中指定的值是在初始化阶段才会被执行。但是如果是 final 修饰的,则会在编译时生成 ConstantValue 属性,在准备阶段会直接赋值为源码中的值。
解析
Java 虚拟机将常量池内的符号引用替换为直接引用的过程。
初始化
直到初始化阶段,Java 虚拟机才开始执行类编写的 Java 程序代码,将主导权交给应用程序(有点让我干活,我要先对这个活进行分析的感觉,这活能干就开始照办,如果有问题就抛异常,我们日常的开发工作是不是这样?)
准备阶段,变量已经被赋过一次系统要求的零值,初始化阶段会根据程序员通过编码制定的主观计划去初始化类变量和其他资源。(执行<clinit>()方法的过程)
类加载器
【图】Java 中类加载的委派模型
在 Java 9 之前,启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。除了启动类加载器之外,另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器(application class loader),均由 Java 核心类库提供。
扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。
应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。
Java9 之后,有了模块系统,也就不用加载 lib/ext 目录中的 jar 包了,自然不需要扩展类加载器了。
Java9 之后,在委派父类加载器前,需要判断该类是否归属某个系统模块中,如果有就要优先委派给模块中的类加载器完成加载。
Java 的类加载器设计思路,有如下几个问题值得我们思考:
我们说的双亲委派中的双亲是哪双亲?
启动类加载器(C++实现)& ClassLoader 抽象类
为什么 Java 虚拟机要选择委派模型??这样做有什么好处?
跟随着他的类加载器一起具备了一种带有优先级的层次关系,以保证类的唯一,避免造成混乱
委派模型在 Java 虚拟机中使用的是组合模式还是继承模式?
组合模式
有哪些技术破坏了委派模型?
JDK1.2 之前的老代码;
OSGI 热部署;
JNDI(ThreadContextClassLoader)解决 JDK 中 rt 包中基础类回调用户类的问题(父类加载器去请求了子类加载器);
Java9 的模块化的特性引入。
字节码执行
《Java 虚拟机规范》制定了 Java 虚拟机字节码执行的概念模型,在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,或者也可能两者兼备,还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。
运行时栈帧
方法是 Java 虚拟机最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的元素。
【图】栈帧的概念结构
局部变量表
一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。Java 程序在被编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量
操作数栈
也常被称为操作栈,他是一个后入先出栈,最大深度在 Code 属性中的 max_stacks 数据项中。编译时,编译器必须严格保证操作数栈中元素的数据类型必须与操作字节码指令的序列严格匹配。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属的方法引用,以支持方法调用过程中的动态连接。
字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用时被转化为直接引用,这种叫静态解析,另一部分将在每一次运行期间都转化为直接引用,这部分叫动态连接。
方法返回地址
执行引擎遇到任意一个方法返回的字节码指令,将返回值传递给上层方法调用者。
异常调用完成,不会给上层调用者提供任何返回值。异常退出时,指调方法的 PC 计数器的值就可以作为返回地址。
方法调用
方法调用,不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定调用哪一个方法。一切方法调用在 Class 文件里存储的都是符号引用,而不是方法在实际运行时的内存布局中的入口地址(直接引用)这既带来了强大的动态扩展能力,也增加了调用过程的复杂度。某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
解析
所有方法调用的目标方法在 Class 文件里都是一个常量池中的符号引用,在类加载解析阶段,会转换一部分为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可变的。包括静态方法和私有方法两大类。
具体的方法调用指令,可见前边章节里的字节码指令表
分派
面向对象程序语言的多态在 Java 虚拟机层面的实现
静态分派
典型应用表现为方法的“重载”
发生在编译阶段
依赖静态类型做判定依据
举个例子:“人”对象,是静态类型,“男人”对象为实际类型(运行时类型)。声明“人”这个变量时,编译器在编译阶段是不知道实际是“男人”还是“女人”。Javac 编译器根据参数的静态类型决定会使用那个重载版本。
很多时候重载版本并不是唯一的,往往只能确定一个“相对更合适的”版本:考虑“自动类型转换”,“多次类型自动转换”,自动装箱,自动装箱+继承类型,自动装箱+Object 父类(越上层的优先级越低)可变长参数的优先级最低。
动态分派
与 Java 语言里的“重写”密切相关,是面向对象多态的实现,根源在 invokevirtual 的执行逻辑
分派过程如下:
通过 aload 将创建的对象指令压入操作数栈顶
找到栈顶元素所指向的对象的实际类型,记作 C
如果类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,不通过则返回 java.lang.AccessError
否则,按照继承关系从下往上依次对 C 的各个父类进行第 b 步中的搜索和验证
如果始终没有找到合适方法,则抛出 java.lang.AbstractMethodError 异常
invaokevirtual 指令并不吧常量池中方法的符号引用解析到直接引用上就结束,还会根据方法接收者的实际类型来选择方法版本,这个过程就是 java 语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称作动态分派。
字段不存在多态:子类字段会遮蔽父类同名字段
java 的动态分派,是一个静态多分派、动态单分派语言。
虚拟机动态分派的实现
基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据,一种常见且基础的优化手段是为类型在方法区中建立一个虚方法表 虚方法表中存放着各个方法分实际入口地址。如果某个方法在子类中没有重写,那子类的虚方法表中的地址入口地址和父类相同方法的地址入口是一致的。如果发生重写,则替换为子类实现版本
其他优化手段,进一步提高性能:类型继承关系分析(Class Hierarchy Analysis,CHA)守护内联(Guarded Inlining)内联缓存(Inline Cache)
基于栈的字节码执行
Java 发展出可以直接生成本地代码编译器之后,就已不再是单纯的“解释执行”语言。
【图】编译过程
语言差异
Java 语言:半独立的编译器,javac 完成了词法分析,语法分析到抽象语法树,再遍历语法树生成线性字节码指令流的过程。而后续则在 Java 虚拟机内部
C/C++语言:词法、语法分析以致后面的的优化器和目标代码生成器都可以独立于执行引擎,形成完整的编译器。
JavaScript 语言:将编译器+执行引擎封装在一个封闭黑盒中
基于栈的指令集与基于寄存器的指令集差异
基于栈的指令集
iconst_1
iconst_1 //两条 iconst_1 指令连续把两个常量 1 压入栈
iadd //iadd 指令把栈顶的两个值出栈、相加后把结果放回栈顶。
istore_0 //把栈顶值存入局部变量表第 0 个 slot 中
基于寄存器的指令集
mov eax,1 //将 eax 寄存器址设为 1
add eax,1 //将 eax 寄存器与 1 相加结果依旧存入 eax 寄存器
各自的优缺点
面向操作数栈
优点:1)指令简短;2)平台无关,
缺点:1)同样的操作需要多个指令,存在频繁的出入栈动作;2)基于内存,性能不如基于寄存器的实现
面向寄存器
优点:1)一个指令能完成一次动作,快速,
缺点:1)不同的处理器寄存器设计不一样,指令集不同,需要定制化编译,无法跨平台;2)指令较长
Java 程序执行过程
通过一段代码,来演示 Java 虚拟机解释执行时的过程
字节码内容还原:
执行过程示意图
相关文章
C/C++与 Java 的跨平台差异:https://blog.csdn.net/gsdggggggg/article/details/116244441
Bruce Eckel:我最喜欢 Python,Kotlin 或将取代 Java:https://cloud.tencent.com/developer/article/1188029
参考资料
周志明著《深入理解 Java 虚拟机-JVM 高级特性与最佳实践》
极客时间-郑雨迪《深入拆解 Java 虚拟机》
Java 虚拟机规范:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html
周志明译《Java 虚拟机规范》
版权声明: 本文为 InfoQ 作者【Liin】的原创文章。
原文链接:【http://xie.infoq.cn/article/ff565e7f93503c4b972deebb8】。文章转载请联系作者。
评论