1. 背景

因为工作关系,需要实现一个APM系统。调研期间了解下了最为成熟的Java生态中的相关基础设施,发现为了达成对业务的非侵入性,会用到字节码增强技术。于是就对Java虚拟机和字节码产生了兴趣。查阅资料期间又发现一本书:《自己动手实现Java虚拟机》[1]。基于费曼的教诲[2]与Atwood定律,我就尝试用JS(TS)来实现一个Java虚拟机[3]。

2. 过程

虽然书中对JVM实现的内容做了许多简化,但整个工程实际的耗时还是远超预期。一方面因为要带娃,业余时间有限,另一方面由于并非直接拷贝而是类似于移植,遇到了一些问题,导致在上述APM系统完成很久之后,本工程才算基本完成。

最大的问题来自JS中空值的定义。如果说null是个十亿美元级的错误[4],那么JS中除了null以外还有个undefined可用是不是代价就更高了?诚然,多一种值可使用确实能够增加表达能力,比如我原本就计划用undefined来表示JVM内存单元中未分配的单元,用null来表示内存单元中存储的空值,但因为实现上的疏忽,导致该返回null的时候返回了undefined。这就相当于把虚拟机实现的细节暴露给了应用程序,在执行到诸如ifnull/ifnonnull[5]之类的指令时就会出错。

至于为什么会疏忽,是因为尽管实现上用了TypeScript,可以进行类型检查,但还是用了旧工程的编译器配置。而旧工程为了与一些旧代码兼容,并没有打开strict选项。这样TypeScript中nullundefined就都可以被void类型的函数返回了。对于一般的工程来说可能问题不大,一个if判断就糊弄过去了,但在这个场合下就是致命的。所以对于新的TS工程一定要打开strict(或者,在这个场合中,strictNullChecks[6]),这个教训还是挺深刻的。

此外为了调试这个问题,我还实现了一个简单的调试器,用于单步跟踪虚拟机执行时的内部状态。由于一个简单的Java程序大部分时间都是在执行JDK中的代码,我还翻了JDK的源码对照来看,这过程还是挺有乐趣的。

2.1 重构

书里的代码是Go实现的,而Go并不是一门面向对象的语言。TypeScript既可以面向过程也可以面向对象,但混在一起多了就有些难看。于是就尝试重构成面向对象的。

重构之前代码的一个问题是有些类违反了单一职责原则(SRP)。比如普通的对象和数组对象都在一个对象定义里,普通的类、数组的类和原始类型的包装类也都在一个类定义里。所以就尝试把它们都拆出来。另外JVM内存单元的实现也有这个问题。JVM的每个内存单元里存放的都是32位整数,可以用来表示各种基本数据类型,以及对象引用的地址。在自己实现的虚拟机中,基本数据类型直接存没什么问题,但如果对象引用也这样存的话,就意味着需要维护一个对象池,并管理对象的生命周期。原书中为了简化实现,另外单独保存了对应Go对象的引用,这样就能借助Go的GC,不用自己实现一套GC。TS里也可以这样做,不过代价就是需要对引用和非引用分别处理。

2.2 优化

第一版的JVM实现执行速度非常慢,甚至比原书中Go的实现慢了一个数量级。虽说Node.js似乎没有标榜过自己能跑多快,工业界的应用也大多是做做BFF,并没有什么CPU密集的应用,能差那么多似乎也不大应该。平日里能给Node做profile的机会不多,就趁机做了一下。

似乎写解释器的都喜欢用递归求斐波那契数列[7]来测执行效率,这里我们也可以测:

$ time node dist/index.js --cp java jvmgo.book.ch07.FibonacciTest
832040n
node dist/index.js --cp java jvmgo.book.ch07.FibonacciTest  53.62s user 3.80s system 143% cpu 39.877 total

可见这次运行花了53.62s,将近1分钟的时间。

参照官方文档[8]进行profile:

 [Summary]:
   ticks  total  nonlib   name
   9819   30.1%   31.0%  JavaScript
  21508   65.9%   67.9%  C++
   6634   20.3%   20.9%  GC
    948    2.9%          Shared libraries
    350    1.1%          Unaccounted

 [C++ entry points]:
   ticks    cpp   total   name
   8857   56.1%   27.1%  T v8::internal::Builtin_ArrayBufferConstructor(int, v8::internal::Object**, v8::internal::Isolate*)
   2046   13.0%    6.3%  T v8::internal::Runtime_DefineAccessorPropertyUnchecked(int, v8::internal::Object**, v8::internal::Isolate*)
   1962   12.4%    6.0%  T v8::internal::Runtime_BigIntBinaryOp(int, v8::internal::Object**, v8::internal::Isolate*)

从名字上猜测,大部分时间都花在V8创建ArrayBuffer上了。而本JVM实现中唯一需要分配Buffer的地方在把各种数据转换为整型的地方,利用Buffer提供的方法进行数据转换:

  static floatToBits(n: number): number {
    const buf = Buffer.alloc(4)
    buf.writeFloatBE(n)
    return buf.readInt32BE()
  }

而根据文档[9],Buffer.allocUnsafe()会利用一个事先分配好的内存池,而Buffer.alloc()则不会,并且在返回buffer前会将其内容清零。所以Buffer.allocUnsafe()会比Buffer.alloc()快很多。实际测下来也确实是这样:

$ time node dist/index.js --cp java jvmgo.book.ch07.FibonacciTest
832040n
node dist/index.js --cp java jvmgo.book.ch07.FibonacciTest  32.51s user 2.91s system 161% cpu 21.941 total

一下子缩短到了32.51s。再做次profile:

 [Summary]:
   ticks  total  nonlib   name
   7941   45.4%   46.3%  JavaScript
   8929   51.0%   52.0%  C++
   2461   14.1%   14.3%  GC
    349    2.0%          Shared libraries
    288    1.6%          Unaccounted

这次JS和C++的占比就比较接近了,似乎不大好确定下一步的优化方向。这时想到旧工程用的还是Node v10,已经是三年以前的版本了,新的Node版本应该会有新的优化,于是就试了下Node v14:

$ time node dist/index.js --cp java jvmgo.book.ch07.FibonacciTest
832040n
node dist/index.js --cp java jvmgo.book.ch07.FibonacciTest  25.24s user 3.16s system 147% cpu 19.294 total

这下缩短到了25.24s,简直是躺赢。profile:

 [Summary]:
   ticks  total  nonlib   name
   2982   19.9%   20.4%  JavaScript
  11510   76.9%   78.7%  C++
   2258   15.1%   15.4%  GC
    350    2.3%          Shared libraries
    134    0.9%          Unaccounted

 [C++ entry points]:
   ticks    cpp   total   name
   7906   82.6%   52.8%  t __ZN2v88internal12_GLOBAL__N_132InsertCodeIntoOptimizedCodeCacheEPNS0_24OptimizedCompilationInfoE
   1110   11.6%    7.4%  T __ZN2v88internal25Builtin_BigIntConstructorEiPmPNS0_7IsolateE

C++的占比再次突出。从名字上猜测,可能是V8的JIT在起作用。看具体调用的话,似乎还是在转换数据的地方存在瓶颈:

   ticks parent  name
  10458   69.8%  t __ZN2v88internal12_GLOBAL__N_132InsertCodeIntoOptimizedCodeCacheEPNS0_24OptimizedCompilationInfoE
   4741   45.3%    t __ZN2v88internal12_GLOBAL__N_132InsertCodeIntoOptimizedCodeCacheEPNS0_24OptimizedCompilationInfoE
   1783   37.6%      LazyCompile: *setLong /Users/qinsi/dev/jvm-ts/dist/thread/Slot.js:36:19
    659   37.0%        LazyCompile: *execute /Users/qinsi/dev/jvm-ts/dist/instruction/loads/lload.js:20:12
    659  100.0%          LazyCompile: *loop /Users/qinsi/dev/jvm-ts/dist/Interpreter.js:28:15
    659  100.0%            LazyCompile: ~interpret /Users/qinsi/dev/jvm-ts/dist/Interpreter.js:16:20

尝试了一下在JS中实现内存池,来避免使用原生内存池的开销,效果并不明显。然后就想到其实并不需要用一个很大的内存池。因为只有单线程在跑,并且每次操作的数据不会超过64位,所以用TypedArrayDataView[10]就可以了(实测下来两者差距不大,DataView的功能更全,使用DataView实现的代码会更清晰),不需要有大内存分配。profile一下:

$ time node dist/index.js --cp java jvmgo.book.ch07.FibonacciTest
832040n
node dist/index.js --cp java jvmgo.book.ch07.FibonacciTest  14.09s user 2.16s system 167% cpu 9.714 total

这次缩短到了14.09s。此时的profile显示大部分时间都在读取zip文件上了:

   ticks parent  name
   5449   69.6%  t __ZN2v88internal12_GLOBAL__N_132InsertCodeIntoOptimizedCodeCacheEPNS0_24OptimizedCompilationInfoE
   2149   39.4%    t __ZN2v88internal12_GLOBAL__N_132InsertCodeIntoOptimizedCodeCacheEPNS0_24OptimizedCompilationInfoE
   1098   51.1%      LazyCompile: *module.exports /Users/qinsi/dev/jvm-ts/node_modules/adm-zip/headers/entryHeader.js:5:27
   1097   99.9%        LazyCompile: *module.exports /Users/qinsi/dev/jvm-ts/node_modules/adm-zip/zipEntry.js:6:27
   1087   99.1%          LazyCompile: *readEntries /Users/qinsi/dev/jvm-ts/node_modules/adm-zip/zipFile.js:46:22
   1087  100.0%            LazyCompile: ~get entries /Users/qinsi/dev/jvm-ts/node_modules/adm-zip/zipFile.js:121:14

查了一下zip文件的格式[11],发现zip文件的文件目录似乎是在文件最后的。也就是说,如果想在一个.jar文件中找到一个.class文件,就必须把.jar文件从头到尾读一遍才行。而加载class的时候通常都是按需加载的,这就意味着可能先在加载java.lang.Object的时候打开rt.jar读了一遍,过了一会要加载java.lang.Class的时候又打开rt.jar读了一遍。原书中提到了这块需要优化,在配套代码中是通过缓存打开状态的zip文件实现的。这里也实现一下:

$ time node dist/index.js --cp java jvmgo.book.ch07.FibonacciTest
832040n
node dist/index.js --cp java jvmgo.book.ch07.FibonacciTest  4.50s user 0.27s system 123% cpu 3.849 total

缩短到了4.50s,这就跟书中Go的实现在一个数量级了。

小结一下这个性能提升一个数量级的优化过程:

  • 未优化:53.62s
  • 使用Buffer.allocUnsafe()32.51s
  • 使用Node v14:25.24s
  • 使用TypedArray14.09s
  • 缓存zip文件:4.50s

3. 总结

原书中的JVM做了很多简化,比如没有实现GC;比如没有实现多线程,所以monitorenter/monitorexit[12]这样的同步指令也没有实现;再比如没有实现invokedynamic指令[13],所以像lambda或是那些动态的JVM语言也没法执行。但这仍不失为一本好书,因为如果没有这本书要自己实现个能跑的JVM的话,就得去翻很多JVM规范的资料了。希望国内能有更多这样的优秀原创技术书籍出现。(作者的另外两本书也已购入)

话说回来,要实现一个工业级强度的虚拟机的话,感觉还是得用C(甚至是汇编)这样接近硬件的语言,这样才更容易针对平台进行优化,执行效率上也更可控。JVM规范在制定时,应该也考虑过能让当时主流平台上的实现充分利用平台提供的特性吧。既然如此,为什么还要实现这么个玩具呢?我想有以下的原因:

  1. 通过实现JVM来更好地理解JVM:
    • class文件解析
    • class loader
    • stack frame
    • 200多条字节码指令
    • (伪)jni
    • 异常处理等
  2. 更好地理解和使用Node.js和TypeScript:
    • 尽可能开启strict选项,或是用deno[14];
    • 如果能确保初始化Buffer的话,使用Buffer.allocUnsafe()来取代Buffer.alloc()
    • 实测TypedArray似乎性能好于Buffer,尚未确认其中的原理。这篇讲DataView优化的博客[15]提到V8会为其生成TurboFan的IR,来绕过调用C++原生代码的开销。在我看来这与Java的字节码增强异曲同工。TypedArray可能也是相同原理,所以会优于有原生调用的Buffer吧;
    • V8的资料还是太少。比如我在Google搜索InsertCodeIntoOptimizedCodeCache,除了源码以外就没有其他结果(本文发布后可能就只有本文)。相比之下JVM已经被研究得很多了,光各种GC的中文书籍就能搜到不少。从一个侧面说明Node还没Java那么卷?
  3. 好玩。包括构建的乐趣与学习的乐趣。

以上。

参考资料