用Typescript实现JVM
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中null
和undefined
就都可以被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位,所以用TypedArray
或DataView
[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
- 使用
TypedArray
:14.09s - 缓存zip文件:4.50s
3. 总结
原书中的JVM做了很多简化,比如没有实现GC;比如没有实现多线程,所以monitorenter
/monitorexit
[12]这样的同步指令也没有实现;再比如没有实现invokedynamic
指令[13],所以像lambda或是那些动态的JVM语言也没法执行。但这仍不失为一本好书,因为如果没有这本书要自己实现个能跑的JVM的话,就得去翻很多JVM规范的资料了。希望国内能有更多这样的优秀原创技术书籍出现。(作者的另外两本书也已购入)
话说回来,要实现一个工业级强度的虚拟机的话,感觉还是得用C(甚至是汇编)这样接近硬件的语言,这样才更容易针对平台进行优化,执行效率上也更可控。JVM规范在制定时,应该也考虑过能让当时主流平台上的实现充分利用平台提供的特性吧。既然如此,为什么还要实现这么个玩具呢?我想有以下的原因:
- 通过实现JVM来更好地理解JVM:
- class文件解析
- class loader
- stack frame
- 200多条字节码指令
- (伪)jni
- 异常处理等
- 更好地理解和使用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那么卷?
- 尽可能开启
- 好玩。包括构建的乐趣与学习的乐趣。
以上。
参考资料
- [1] GitHub - zxh0/jvmgo-book: 《自己动手写Java虚拟机》随书源代码
- [2] Richard Feynman: What I cannot create, I do not understand.
- [3] GitHub - qszhu/jvm-ts
- [4] Null References: The Billion Dollar Mistake
- [5] Chapter 6. The Java Virtual Machine Instruction Set
- [6] TypeScript: TSConfig Reference - Docs on every TSConfig option
- [7] Chunks of Bytecode · Crafting Interpreters
- [8] Easy profiling for Node.js Applications | Node.js
- [9] Buffer | Node.js v16.0.0 Documentation
- [10] TypedArray or DataView: Understanding byte order - Mozilla Hacks - the Web developer blog
- [11] ZIP 格式 - CTF Wiki
- [12] Chapter 6. The Java Virtual Machine Instruction Set
- [13] Chapter 6. The Java Virtual Machine Instruction Set
- [14] Deno
- [15] Improving DataView performance in V8 · V8