IJVM的内存模型与指令集

栈内存模型

c58bd4e2-aa12-4d5c-a3c5-9080eee441c3

通常编程语言支持过程(procedure)和方法(method)。过程就像编程里的函数调用,有自己的局部变量,中间结果等。过程在返回后,其局部变量消失。这意味着需要对局部变量有效的管理,容易想到给每个局部变量固定地址,但这是不行的,因为过程支持自己调用自己(递归),这将导致地址冲突。这里引入基于的局部变量管理,简单来说就是划分内存区域放局部变量,用数据结构栈的形式管理。

上图给出了一种编程语言执行算术表达式a1=a2+a3的栈内存的活动情况。在执行表达式前通常会声明a1,a2,a3变量,所以栈底的3个元素为a1,a2,a3。在执行算术计算时,第一步是前后将a2,a3入栈,然后通过算数指令弹出栈顶两个元素,得到其和,然后把和入栈,最后把和赋值给a1(图d在原来a1的位置写了a2+a3,目的是表示a1被赋值)。

在完成算数运算后,栈经过一系列入栈出栈操作后,最终剩下3个变量,其中a2,a3维持原值,a1有了新值a2+a3。这个栈的情况与现实中的程序吻合。

 

JVM内存模型

934a9f11-6a0c-4daa-b3bd-c2a3a6b1a417

b5040a59-e403-42b2-b516-2b8fa29d85c9

这里简单讨论下JVM内存模型。JVM的内存区域划包括两大类。其中堆是线程共享的,用于存放类的实例等等内容。而每个线程都有一个自己的栈,这个栈可被用于执行方法。而每一次对方法的调用,都会在栈中入栈一个新的栈帧。栈帧主要包含4个部分:

1) 局部变量表:存放方法的参数局部变量。每个方法需要多大的局部变量表,在编译时就已确定。编译期会记录方法中声明的所有变量的个数、变量的类型等。所以在创建栈帧时。马上就能划分出一个固定大小的局部变量表,然后再初始化局部变量表中的变量;

2) 操作数栈:用栈的方式执行JVM指令(对应的可能是JAVA的算术、逻辑运算等);

3) 动态连接:与面向对象的方法调用有关,比如在A对象的f方法里调用B对象的g方法,先不管;

4) 返回地址:调用结束后,会恢复和回到调用者的栈帧。返回地址可能是之前的PC值;

 

IJVM内存模型

9f4ce6bf-74b1-41c1-9743-12e0f63d73e5

IJVM中没有面向对象和进程线程的概念,其内存模型包括两个类似JVM堆区的内存区,分别是常量池方法区,其各仅有1个。IJVM栈帧经过了简化,每个栈帧含1个局部变量结构和1个操作数栈,按前者在下后者在上紧挨在一起。具体如上图。这里来看4大内存区的功能:

1) 常量池:该区域存储常量、字符串、指向可被引用的其他内存区域的指针。程序加载进内存时,该区域加载完成,之后不能被程序修改。寄存器CPP保存了该区域首个字的地址;

2) 方法区:保存程序(指令与其他信息)本身;

3) 局部变量结构:存储方法声明的局部变量。同样在编译时可确定大小;

4) 操作数栈:暂存调用中的中间结果等。不可在编译时确定大小,调用的操作数栈大小有上限

然后是4个重要的寄存器,寄存器名称和MIC-1的寄存器对应:

1) CPP:指向固定方法区的基址(有基址就能通过索引/偏移量的访问方法区);

2) PC(程序计数器):指向下个指令/指令的下个字节的地址(回顾MIC-1中用PC寄存器的内存读操作,其每次只能读出1个字节,如果不使用突发读,想要从内存读3字节的指令,需要读3次内存);

3) LV:当前调用(顶部栈帧)的局部变量结构的首个字的地址;

4) SP:栈顶的字的地址

4种区域中的元素大小也是不同的,方法区需被看作字节数组/字节栈,其他区被看作字数组/字栈,所以CPP/LV/SP都指向的是字地址,其偏移量/索引也是以字为单位的,比如用LV、LV+1、LV+2表示局部变量结构的前3个字。PC指向的则是字节地址,PC+1表示的是下一个字节。

可以发现IJVM和JAVA类似,没在指令层提供绝对的内存地址,其访问这4个内存区的方式主要是通过隐含基准地址加上显式偏移量/索引来访问实际内存地址。

 

IJVM指令集

2a7374ef-201b-4b71-ad16-0c1533acb0a4

整个IJVM指令集如上图。第1列是16进制下的操作码,第2列是汇编助记符。其中操作码最大值为0xC4,所以操作码占据1个字节。操作数可能占用0-2个字节。对指令可进一步分类:

1) 栈相关指令:比如把不同来源的数据压栈,从常量池(LDC_W,操作数为该区域的索引)、从局部变量结构(ILOAD,操作数为该区域的索引)、直接把操作数本身压栈(BIPUSH)。弹出栈顶元素并存入局部变量结构(ISTORE)。两个算术指令(IADD/ISUB)和两个逻辑指令(IOR/IAND)都使用栈顶两个字,且都是弹出两个字后把计算结果压栈。另外还可交换栈顶两字的位置(SWAP)、弹出栈顶(POP)、拷贝栈顶(DUP);

2) 跳转指令:1条无条件转移指令(GOTO)、3条条件转移指令(IFEQ/IFLT/IF_ICMPEQ)。操作数为16位带符号偏移量,用于改变PC。其中条件转移指令的条件判断信息是由栈顶提供的;

3) 调用和返回指令:调用方法(INVOKEVIRTUAL),从被调用方法返回发起调用方法(IRETURN);

4) 其他:比如加宽(WIDE)可以修改其他指令的行为,比如执行“ILOAD X”,索引X最大支持8位。但如果使用“WIDE ILOAD,X”执行ILOAD,那么索引X最大支持到16位;

 

IJVM的调用过程

823050b1-ab13-4750-9354-c00d4c04c3fd

IJVM发起调用的过程:

1) 调用者将指向被调用的对象指针(OBJREF)压入栈。事实上IJVM原本不需要这一步,因为其不允许调用其他对象的方法,但这部分需要和JVM统一,所以还是保留了;

2) 调用者将方法的一或多个参数依次压栈

3) 执行INVOKEVIRTUAL指令;

INVOKEVIRTUAL指令的操作数为16位索引/偏移量,指向常量池的位置,该位置保存了被调用方法方法区地址。每个方法在方法区的结构是这样的,前2个字节构成的16位整数表示该方法的参数数目(OBJREF算第1个参数,其他参数从第2个开始)。后2个字节构成的16位整数表示方法的局部变量空间的大小(可理解为局部变量结构大小+操作数栈大小的上限),第5个字节开始是方法方法区的指令。

上图给出了从发起调用到执行INVOKEVIRTUAL前和执行后的栈的对比的例子。大概有这些子步骤:

1) 由于方法区内存、PC都是按字节编址的,所以需要从方法区内存读出操作数,于是PC自增3次,提供三次字节地址才能完整读出一个INVOKEVIRTUAL指令;

2) 指令一般顺序存储,此时PC+1指向的是程序中INVOKEVIRTUAL的下一条指令的操作码,因为调用返回后需要能够正确执行下个指令,这里先用寄存器OPC存储PC+1;

3) 用INVOKEVIRTUAL的操作数找到方法参数数局部变量空间的大小。根据局部变量空间的大小从栈中的参数区顶部追加整块局部变量空间。把当前OPC追加到栈上,并修改OBJREF使其指向它。继续在栈上追加当前LV地址(旧的图a中的LV),由于局部变量空间的大小已知,可根据SP算出新LV并更新LV寄存器;

上述流程就是执行调用的大致实现,在具体用微体系结构实现调用时,可能在具体细节上有不同。

 

IJVM的返回过程

497fc478-bcd9-4950-b5f5-ee73af393429

这个过程相对的简单,因为调用时指令维护了返回所需的数据,而返回只需要“清空”相关的内容并且顺着这些数据“恢复现场”即可,IRETURN大概有这些子步骤:

1) 经过过程中的一系列计算后,操作数栈入栈出栈,最后在局部变量结构顶部留下一个返回值;

2) IRETURN操作执行,局部变量结构被全部弹出栈,栈顶只剩下一个返回值。并且在该过程中,根据局部变量结构中的数据恢复了LV的指向,并把PC指向原INVOKEVIRTUAL的下一个地址(指令);

 

IJVM的跳转过程

a90cd608-b563-42d1-83b0-68ca77e08677

5aa52730-c466-4314-8eae-966ed8b64c2e

上图为一段JAVA程序和对应的IJVM程序,其包含调用/返回外的很多指令,以及流程控制,可作为代码参考。上图右边每行为一条IJVM指令,每个指令包含1-3个字节。上图也给出了程序执行过程中的栈,栈中没有画出局部变量结构,所以可看作是操作数栈部分。上图汇编中的i,j,k分别表示JAVA程序变量i,j,k在局部变量结构里的索引/偏移量。操作数栈做计算的过程之前提过,重点是看跳转逻辑。

上图右边的程序存储在IJVM内存方法区,并且是按地址顺序存储的。CPU从内存读出其首个字节后,如没有遇到跳转语句,就会逐个字节的读下去并执行。上图汇编中的IF_ICMPEQ指令后的L1指第13行指令在内存方法区的地址(行号左边的L1,L2不影响程序,将其看作和行号一样的注释就行了)。如满足跳转条件,PC不再按+1方式顺序读取,而是+更大偏移量直接跳到L1。GOTO L2同理,其相比IF_ICMPEQ只少了判断逻辑。总之在走到第7行后,IF_ICMPEQ和GOTO构成了这样的流程控制逻辑:

1) 满足等式条件,则执行13-14行;

2) 不满足等式条件,则执行8-12行;

然后这两个流程都会从15行继续执行下去,没有其他的区别。

发表评论

您的电子邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部