《深入理解JVM》笔记 第6、7、8、9章 虚拟机执行子系统

“Write Once,Run anywhere. ”这是Java刚诞生时的口号,于是Java通过虚拟机加+class文件实现了这个目标。另外,JVM设计者曾承诺过要让其它语言也能像Java一样在JVM运行。当Java发展到JDK1.7~1.8的时候,JVM设计者通过JSR-292基本兑现了这个承诺。这就是Java的实干精神。

一、类文件结构

Class文件是一组以8位字节为基础单位的二进制流,各个数据项严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符。当遇到需要占用8位字节以上空间地数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。

按照Java虚拟机规范约定,Class文件格式采用一种类似C语言结构体的伪结构体来存储数据,这种伪结构中只有两种数据类型:无符号数和表

无符号数属于基本的数据类型,以u1、u2、u4、u8分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。

表是由多个无符号数或者其它表作为数据项构成的复合数据类型,所有表习惯性地以_info结尾。表用于描述由层次关系的复合结构的数据,整个Class文件本质就是一张表。

下面我们找一个class文件使用WinHex打开实际感受一下它的结构组成:

 

 具体每个常量怎么解析的,需要知道表类型和实际意义的对应关系,这个我觉得没有必要去强行人工破解,可以使用jdk自带的javap工具帮助解析class文件。

补充下常量池的知识:

常量池中主要存放两大类常量:字面量和符号引用,字面量比较接近Java语言层面的概念,如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括了三类常量:

  • 类和接口的全限定名(Fully Qualified Name)
  • 字段的名称和描述符(Descriptor)
  • 方法的名称和描述符

在常量池之后,分别是访问标志、类索引、父类索引与接口索引集合、字段表集合、方法表集合、属性表集合。了解就行,仔细研究这个除非你想再造一门JVM语言。另外,作者在本章最后一节,还花大篇幅介绍了JVM的字节码指令,这个想要看懂不仅需要有非常扎实的计算机基础知识,还需要花费大量的时间,我暂时不准备去研究了,大家有兴趣可以自行阅读。

二、虚拟机类加载机制

在Java语言里,类型的加载、连接和初始化过程都是在程序运行期间完成的,Java天生可以动态扩展的语言特性就是依赖运行期间动态加载和动态连接这个特点实现的。

用户可以通过Java预定义的和自定义类加载器,让一个本地的应用程序可以在运行时从网络其他地方加载一个二进制流作为程序代码的一部分,这种组装方式已广泛应于Java程序之中。从最基础的Applet、JSP到相对复杂的OSGI。都使用了Java语言运行期间类加载的特性。

1. 类加载的时机

 

 

 什么时候需要加载?无强制规定,但虚拟机规范严格规定了有且只有5种情况必须立即对类进行初始化(加载、验证、准备自然要在这之前):

  • 遇到 new、getstatic、putstatic 或 invokestatic 这4条字节码指令
  • 使用 java.lang.reflect 反射调用
  • 当初始化一个类,发现父类没有初始化,则先触发父类的初始化(接口有所不同,真正使用到父接口时才会初始化)
  • 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个类
  • 当使用Jdk1.7动态语言支持,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法所对应类没有进行过初始化,则需要先触发其初始化。

2. 类加载的过程

2.1 加载

加载阶段,虚拟机需要完成3件事情:

1)通过一个类的全限定名来获取定义此类的二进制字节流。

2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

第1步这个字节流,可以从zip获取(比如jar包),可以从网络获取(比如Applet),可以运行时计算生成(比如动态代理技术,在java.lang.reflect.Proxy中,就是用ProxyGenerator.generateProxyClass来为特定接口生成形式为"*$Proxy"的代理类的二进制字节流),可以由其他文件生成(比如jsp),可以从数据库读取(较为少见)。

第1步中开发人员还可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass方法),这里仅限非数组类,因为数组由Java虚拟机直接创建。但是数组类的元素类型最终还是要靠类加载器去创建。

 

2.2 验证

文件式格式验证、元数据验证、字节码验证、符号引用验证(无法通过可能会抛NoSuchMethodError)

2.3 准备

准备阶段是正式为类变量分配内存并设置初始值的阶段,这些变量所使用的内存都将在方法区进行分配。注意:

  • 这里的变量不包括实例变量,实例变量将会在对象实例化时随对象一起分配在Java堆中
  • 这里说的初始值是数据类型的零值(比如 public static int value=12,这里只会给初始值0,12要在初始化阶段才会执行,但是final字段例外,final字段这里直接就是12)

2.4 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。直接引用和符号引用分别是什么?

  • 符号引用(Symbolic Reference):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用于虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
  • 直接引用可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

2.5 初始化

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说字节码)

初始化阶段是执行类构造器<clinit>()方法的过程。

3. 类加载器

类加载器最初是为了满足Java Applet的需求而开发出来的。虽然目前Java Applet已死,但类加载器却在层次划分、OSGI、热部署、代码加密等领域大放异彩。

从虚拟机角度,加载器只有两种,一种是Bootstrap ClassLoader,这个类加载器使用C++实现,是虚拟机自身的一部分;另一种就i是所有其他的类加载器,这些都是Java语言实现,独立于虚拟机外部。并且全部继承自抽象类ClassLoader

从程序员角度,还可以分得再细点:

  • BootStrap ClassLoader:加载<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机。
  • Extension ClassLoader:由sun.misc.Laucher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库。
  • Application ClassLoader:由sun.misc.Laucher$ApplicationLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库。如果应用程序中没有自定过自己的类加载器,一般情况这个就是程序中默认的类加载器。

双亲委派模型:

 

 

 即一个类加载器收到类加载请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围没有找到所需类)时,子加载器才会尝试自己去加载。

双亲委派模型不是强制的约束模型,到目前为止,双亲委派模型主要出现过3次较为大规模被破坏的情况。

第一次,为了兼容JDK1.2之前已经存在的自定义类加载器的实现代码。第二次,解决基础类回调用户代码,比如JNDI。第三次,为了程序动态性,比如热部署,OSGI实现热部署的关键是它自定义的类加载器机制的实现。OSGI环境下,类加载器不再是树状结构,而是网状结构。

 三、虚拟机字节码执行引擎

执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即使编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还有可能会包含几个不同级别的编译器执行引擎。(Classic VM只有解释器,JRockit内部只存在即使编译器)

1. 运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接、和方法返回地址等信息。每一个方法从调用开始至执行完成的过程、都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

1.1 局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范没有明确指明一个Slot应用占内存的空间大小,只是很有导向性地说到每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存放。

对于64位数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。

2.2 操作数栈

操作数栈也常称为操作栈,它是一个巨大的后入先出(LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Class属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型。

2.3 动态连接

每一个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有该引用是为了支持调用过程中的动态连接。我们知道Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。

这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

2.4 方法返回地址

方法开始执行后,只有两种方式退出这个方法。

  • 执行引擎遇到任意层的方法返回指令
  • 异常(无返回值)返回地址通过异常处理器来确定。

2.5 附加信息(比如调试信息,这里不多讲)

2. 方法调用

 

 

posted @ 2022-03-23 17:27  方山客  阅读(56)  评论(0编辑  收藏  举报