栈式虚拟机和寄存器式虚拟机?

这两者究竟有什么大的区别?为什么JVM是基于前者,Lua是后者呢?是因为作者当初自己按喜欢决定?还是另有原因?

作者:RednaxelaFX 链接:https://www.zhihu.com/question/35777031/answer/64575683 来源:知乎

太长不读(TL;DR)版定性分析:

对于解释器来说,解释器开销主要来自解释器循环(fetch-decode/dispatch-execute循环)中的fetch与decode/dispatch,反而真正用于执行程序逻辑的execute部分并不是大头。每条指令都要经历一轮FDX循环。因而减少指令条数可以导致F与D的开销减少,于是就提升了解释器速度。

基于栈与基于寄存器的指令集,用在解释器里,笼统说有以下对比:

  • 从源码生成代码的难度:基于栈 < 基于寄存器,不过差别不是特别大

  • 表示同样程序逻辑的代码大小(code size):基于栈 < 基于寄存器

  • 表示同样程序逻辑的指令条数(instruction count):基于栈 > 基于寄存器

  • 简易实现中数据移动次数(data movement count):基于栈 > 基于寄存器;不过值得一提的是实现时通过栈顶缓存(top-of-stack caching)可以大幅降低基于栈的解释器的数据移动开销,可以让这部分开销跟基于寄存器的在同等水平。请参考另一个回答:寄存器分配问题? – RednaxelaFX 的回答

  • 采用同等优化程度的解释器速度:基于栈 < 基于寄存器

  • 交由同等优化程度的JIT编译器编译后生成的代码速度:基于栈 === 基于寄存器

因而,笼统说可以有以下结论:要追求

  • 尽量实现简单:选择基于栈

  • 传输代码的大小尽量小:选择基于栈

  • 解释执行的解释器的速度:选择基于寄存器

  • 带有JIT编译器的执行引擎的速度:随便,两者一样;对简易JIT编译器而言基于栈的指令集可能反而更便于生成更快的代码,而对比较优化的JIT编译器而言输入是基于栈还是基于寄存器都无所谓,经过parse之后就变得完全一样了。

===========================================

JVM的选择

JVM当初设计的时候非常重视代码传输和存储的开销,因为假定的应用场景是诸如手持设备(PDA)、机顶盒之类的嵌入式应用,所以要代码尽量小;外加基于栈的实现更简单(无论是在源码编译器的一侧还是在虚拟机的一侧),而且主要设计者James Gosling的个人经验上也对这种做法非常熟悉(例如他之前实现过PostScript的虚拟机,也是基于栈的指令集),所以就选择了基于栈。

回头看,这个决定也还算OK,可惜的是基于栈的设计并没有让Java的代码传输大小减小多少。

这是因为:Java代码是以Class文件为单位来传输与存储的。Java从设计之初就非要支持分离编译(separate compilation)与按需动态类加载(on-demand dynamic class loading),导致Java的Class文件必须独立的(self-contained)——每个Class文件必须自己携带自己的常量池,其主要信息是字符串与若干其它常量的值,以及用于符号链接的符号引用信息(symbolic reference)。

如果大家关注过Class文件的内容的话,会知道其实通常Class文件里表示程序逻辑的代码部分——“字节码”——只占Class文件大小的小头;而大头都被常量池占了。而且多个Class文件的常量池内容之间常常有重叠,所以当程序涉及多个Class文件时,就容易有冗余信息,不利于减少传输/存储代码的大小。

大家或许还记得Google在Google I/O 2008的

Dalvik VM Internals

演讲里,Dan得意的介绍到Dalvik的Dex格式在未压缩的情况下都比压缩了的JAR文件还小么?

(下面数据引用自演示稿第22页)

common system libraries (U) 21445320 — 100% (J) 10662048 — 50% (D) 10311972 — 48%

web browser app (U) 470312 — 100% (J) 232065 — 49% (D) 209248 — 44%

alarm clock app (U) 119200 — 100% (J) 61658 — 52% (D) 53020 — 44%

(U) uncompressed jar file (J) compressed jar file (D) uncompressed dex file

Dan准确的介绍了Dex体积更小的原因:一个Dex相当于一个或多个JAR包,里面可以包含多个Class文件对应的内容。一个Dex文件里的所有Class都共享同一个常量池,因而不会像Class文件那样在多个常量池之间有冗余。这样Dex文件就等同于在元数据层面上对JAR文件做了压缩,所以前者比后者更小。

但是很明显后来有不少群众不明就里,以为Dalvik的Dex文件更小是跟基于寄存器的选择相关的——其实正好相反,在字节码部分,Dalvik的字节码其实比JVM的字节码更大。只要仔细读了同一个演示稿就会看到

(第37页)

The Register Machine

  • 30% fewer instructions

  • 35% fewer code units

  • 35% *more* bytes in the instruction stream

    • but we get to consume two at a time

而其实Java世界里早就发现了这个问题,并且有一个传输/存储格式跟Dex文件应用了类似的压缩思路:

pack200

格式。它也是把JAR包里的所有Class文件的常量池汇总为一个,以此压缩掉其中的冗余。以pack200格式打包的Java应用就不会比用Dex格式打包大了。

之前在Oracle的HotSpot JVM组工作时,跟同事们讨论一些与此相关的话题,大家的共识是如果JVM的指令集是在20年后的今天设计的话,很有可能也会跟现在的潮流相似,基于寄存器。不过就算保持现在这样用基于栈的指令集也挺好。

目前Class文件/JAR文件的真正让大家头疼的问题并不是基于栈还是基于寄存器的设计,而是别的地方,例如:

Class文件方面:

  • 各种人为的大小限制都跟不上时代了,例如每个方法的字节码最多65535字节;

  • 要生成StackMapTable太闹心;

  • 常量池的组织方式不便于直接从文件映射到内存然后高效执行;可以有更高效的组织方式。

JAR文件方面:

  • 如前文提的,多个Class文件之间的常量池冗余

  • 缺少带有强语义的描述模块的信息;

  • 等等…

一切都有待未来版本的Java继续改进。

===========================================

Lua的选择

官方版Lua的设计可以从经典文章

The Implementation of Lua 5.0

一窥究竟。

它提到:

For ten years (since 1993, when Lua was first released), Lua used a stack-based virtual machine, in various incarnations. Since 2003, with the release of Lua 5.0, Lua uses a register-based virtual machine.

也就是说Lua 5.0之前的Lua其实是用基于栈的指令集,到5.0才改为用基于寄存器的。

接下来:

Register-based code avoids several “push” and “pop” instructions that stack-based code needs to move values around the stack. Those instructions are particularly expensive in Lua, because they involve the copy of a tagged value, as discussed in Section 3. So, the register architecture both avoids excessive copying of values and reduces the total number of instructions per function. Davis et al. [6] argue in defense of register-based virtual machines and provide hard data on the improvement of Java bytecode. Some authors also defend register-based virtual machines based on their suitability for on-the-fly compilation (see [24], for instance).

所以Lua 5.0开始改为选用基于寄存器的指令集设计,主要是出于 (1) 减少数据移动次数,降低由数据移动带来的拷贝开销,和 (2) 减少虚拟指令条数 这两点考虑。

从纯解释器的角度看,这两点考虑是非常合理的。

不过如果官方版Lua有JIT编译器的话,它就没必要这么做了——基于栈和基于寄存器的指令集只要经过合理的编译,得到的结果会是一模一样的。

至于LuaJIT,

LuaJIT 1.x的字节码设计源于Lua 5.1.x系列,因而也是基于寄存器的;

LuaJIT 2.x系列的字节码虽然重新设计了,但应该还是受到之前设计的影响而继续采用了基于寄存器的设计。像Mike Pall那种想榨干一切性能的思路,即便有优化的JIT编译器,也还是想让解释器尽量快的心情也是可以理解的。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片