JIT 实践 (Practice Just In Time)
JIT全称Just In Time,也就是“即时”的意思,即时做什么事情呢?即时编译,它被广泛应用在编译器、编程语言实现、虚拟机、模拟器等等产品上。这篇文章就从头讲一下JIT是怎么回事以及为自己动手实现JIT做一个简单引导。
大家都知道,生产处理器芯片的公司有很多家,比较流行的处理器体系按粗略的分法都有x86、ARM、MIPS、PowerPC等等很多种,而且每一种下又可以进一步细分,比如x86除了基本的8086指令集之外还添加了32位扩展、64位扩展、还有一系列针对多媒体和多数据流等等应用的专门的指令集,如MMX、SSE等等,这些指令集能执行的程序,往往只能包含它支持指令的子集,比如你购买了一个64位的程序就不能用一个ARM的处理器来运行。
在有源代码并且源代码是语言实现是编译型的情况下,可以使用编译器针对多个平台分别编译,但是,两三个平台还可以,10个平台呢?每个平台之后又出现了新的扩展指令集呢?未来出现的新平台呢?没完没了。
于是有人采取这样一种做法,就是假想一个不存在的体系结构,包含一些栈或寄存器,以及对应的指令集,然后编译生成针对这个假想机器生成代码,又被称作“字节码”。以实现编程语言为例,这样的做法在直接解释编程语言源代码和完全的编译到机器码再执行之间,取了一个平衡点。这个平衡点相对于完全编译,获得了“一次编译到处运行”的好处;相对于直接解释执行,又获得了节省每次运行时都要做词法语法分析和简单优化的时间。这样的做法最著名的例子就是Java,Java开发时包含一个编程语言标准和一个虚拟机标准,虚拟机标准为以上这种执行模式打下了基础。要注意的是,不要以为Java是第一个想到这种做法的人,早在Java诞生之前的20世纪80年代甚至更早之前就有人这么做过,只是那时候的计算机性能非常差,差到不足以支撑这种模式的流行。
人们对性能的追求是无限的,但是又不甘心舍弃“一次编译到处运行”的优点,有什么办法能在字节码解释运行和编译到机器码之间再取一个平衡点呢?于是JIT也就是即时编译技术产生了。它的思想是,在保证字节码能通过解释运行在很多平台的基础上,针对个别流行的平台(比如x86和ARM),再进行一次编译,编译到机器码后再执行。这个再编译的过程确实要消耗一点点时间,但是想象一下,每一条指令都由虚拟机解释执行变为直接由CPU的电路执行,性能提升是非常显著的。
Java实现JIT后,据说.NET设计时还特地以实现JIT作为目标之一,除了编译器和运行时/虚拟机之外,JIT还被广泛应用在游戏机模拟器这样的真实硬件虚拟软件上,以获得更好的性能。
接下来是动手时间,需要的知识包括对C语言的基本理解和对操作系统的基本理解、会在终端下输入一点命令就可以了。
假设我们有以下C程序:
int inc(
int a)
{
return a + 1;
}
{
return a + 1;
}
我们的目标就是“即时编译”这段程序并且执行。
这个程序的功能显而易见就是输入一个整数参数,将其加上1后返回。将这个程序保存为obj1.c,进行编译。
在x86 32bit的环境下,使用GCC编译:
$ gcc -c obj1.c -o obj1.o -g
我们就会得到一个带有调试符号的目标文件,如果是x86_64的机器,我们为了下文统一,指定输出32位的代码:
$ gcc -c obj1.c -o obj1.o -g -m32
这样我们就会得到一个32bit的带有上面C程序功能的目标文件,看看这个文件的属性:
$ file obj1.o
obj1.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
obj1.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped
ELF是Linux下流行的可执行文件格式,类似Windows下的PE。32-bit说明我们正确得到了32bit的目标文件。
接下来使用objdump指令解析刚才得到的目标文件:
$ objdump -S obj1.o
obj1.o: file format elf32-i386
Disassembly of section .text:
00000000 <inc>:
int inc( int a)
{
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
return a + 1;
3: 8b 45 08 mov 0x8(%ebp),%eax
6: 83 c0 01 add $0x1,%eax
}
9: 5d pop %ebp
a: c3 ret
obj1.o: file format elf32-i386
Disassembly of section .text:
00000000 <inc>:
int inc( int a)
{
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
return a + 1;
3: 8b 45 08 mov 0x8(%ebp),%eax
6: 83 c0 01 add $0x1,%eax
}
9: 5d pop %ebp
a: c3 ret
看到了吗,我们得到了源代码和汇编指令的参照,更加方便的是,我们直接得到了每一条汇编指令对应的机器码,这些机器码在CPU中直接得到执行,执行性能是最好的。
如果你要问,不是即时“编译”吗?怎么不讲机器码是如何产生的?嗯……这篇文章的内容还是集中在描述JIT的原理,至于输出针对寄存器机器的机器码,那学问就大了,不过在这里可以稍微提一下,这些代码浅显的规律。如果你经常看这种编译器生成的x86汇编,就可以发现,通常进入一个函数,都会有以下两条指令:
0: 55 push %ebp
1: 89 e5 mov %esp,%ebp
1: 89 e5 mov %esp,%ebp
这两条指令的意思是,先把%ebp寄存器压进栈,然后把%esp的值覆盖进%ebp,这么做的结果是,EBP里直接存放了当前栈帧的“底部”,这样我们可以轻易根据%ebp(实际上就是%esp)引用传入的参数。
之后的:
3: 8b 45 08 mov 0x8(%ebp),%eax
你要问为什么传入的变量a的位置在0x8(%ebp)的位置,其实不为什么,这就是编译器默认的参数传递约定,对于这个版本的GCC来说,第一个参数就在这个位置上。
接下来是给%eax增加1,这个没什么好解释的:
6: 83 c0 01 add $0x1,%eax
最后是:
9: 5d pop %ebp
a: c3 ret
a: c3 ret
也就是把之前压入栈以保护的%ebp的值弹出放回到%ebp上,最后ret返回,需要说的是,把eax寄存器里的值作为每个函数的返回值,这也是默认的调用约定。
为了实现这么个函数,我们用到的机器码装进数组里,就是:
uint8_t machine_code[] = {
0x55,
0x89, 0xe5,
0x8b, 0x45, 0x08,
0x83, 0xc0, 0x01,
0x5d,
0xc3 };
0x55,
0x89, 0xe5,
0x8b, 0x45, 0x08,
0x83, 0xc0, 0x01,
0x5d,
0xc3 };
接下来是一些人直觉上想不明白的问题,这些代码放进数组里,不是数据吗?数据不是代码如何能执行?再原始一点的模型来说,只要CPU的IP(指令指针)或者PC(程序计数器)指向的地方的数据,都会被当作指令执行,但是我们目前的计算机有所不同,真实的内存被操作系统用虚拟内存包装起来,哪些地址的内存放的数据能被当作指令执行是操作系统说了算,所以你需要申请一段内存,明确告诉操作系统“我需要一段内存,请将这段内存给我并且标记为可执行”。
在Linux上,可以用mmap函数申请这样的可执行的内存,该函数的原型是:
#include <sys/mman.h>
void *mmap( void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
void *mmap( void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
其中flags参数要带有PROT_EXEC标记即可执行了,比如:
mmap(0, size, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANON, -1, 0);
释放mmap得到的内存使用munmap函数。
Windows也有类似的函数VirtualAlloc,原型是:
LPVOID WINAPI VirtualAlloc(
_In_opt_ LPVOID lpAddress,
_In_ SIZE_T dwSize,
_In_ DWORD flAllocationType,
_In_ DWORD flProtect
);
比如:
VirtualAlloc(0, size, MEM_RESERVE|MEM_COMMIT,
PAGE_EXECUTE_READWRITE);
_In_opt_ LPVOID lpAddress,
_In_ SIZE_T dwSize,
_In_ DWORD flAllocationType,
_In_ DWORD flProtect
);
比如:
VirtualAlloc(0, size, MEM_RESERVE|MEM_COMMIT,
PAGE_EXECUTE_READWRITE);
释放VirtualAlloc得到的内存使用VirtualFree函数。
得到了可执行的内存后,我们将之前准备好的机器码复制进去就行了:
memcpy(executable_code, machine_code,
sizeof(machine_code));
接下来,要使用C语言函数指针,将指向一段代码的内存空间指针的类型,强行转换为一个函数并且执行:
int (*func)(
int) = (
int (*)(
int))executable_code;
复杂的C类型表示方法有时候是有点奇怪,但它就是这么写的。
以整数2为参数运行这段代码:
int result = (*func)(2);
需要再说明一点的是,因为以上我们生成的是32bit的机器码,如果你的操作系统是64bit的,编译后运行可能会缺乏一些32bit的库,以Debian GNU/Linux为例,用以下命令安装32bit的库:
# dpkg --add-architecture i386
# apt- get update
# apt- get install ia32-libs
# apt- get install libc6-dev-i386
# apt- get update
# apt- get install ia32-libs
# apt- get install libc6-dev-i386
最后,别忘了释放申请的内存:
munmap(executable_code, 0);
下面列出最终的完整程序:
#include <stdlib.h>
#include <stdio.h>
#include < string.h>
#include <stdint.h>
#include <sys/mman.h>
int main( int argc, char *argv[])
{
uint8_t machine_code[] = {
0x55,
0x89, 0xe5,
0x8b, 0x45, 0x08,
0x83, 0xc0, 0x01,
0x5d,
0xc3 };
uint8_t *executable_code = NULL;
executable_code = (uint8_t *)mmap(0, sizeof(machine_code), PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANON, -1, 0);
if (executable_code == NULL)
{
fprintf(stderr, "mmap failed\n");
exit(1);
}
memcpy(executable_code, machine_code, sizeof(machine_code));
int (*func)( int) = ( int (*)( int))executable_code;
int result = (*func)(2);
printf("%d\n", result);
munmap(executable_code, 0);
return 0;
}
#include <stdio.h>
#include < string.h>
#include <stdint.h>
#include <sys/mman.h>
int main( int argc, char *argv[])
{
uint8_t machine_code[] = {
0x55,
0x89, 0xe5,
0x8b, 0x45, 0x08,
0x83, 0xc0, 0x01,
0x5d,
0xc3 };
uint8_t *executable_code = NULL;
executable_code = (uint8_t *)mmap(0, sizeof(machine_code), PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANON, -1, 0);
if (executable_code == NULL)
{
fprintf(stderr, "mmap failed\n");
exit(1);
}
memcpy(executable_code, machine_code, sizeof(machine_code));
int (*func)( int) = ( int (*)( int))executable_code;
int result = (*func)(2);
printf("%d\n", result);
munmap(executable_code, 0);
return 0;
}
接下来编译并运行该程序,保存为jit.c:
$ gcc jit.c -o jit
$ ./jit
3
$ ./jit
3
看来已经执行成功了。
这就是JIT的雏形了,实际应用中的JIT还需要做什么工作呢?以一个编程语言实现为例,已经开发出了能生成字节码的编译器,还要做的事情有:把字节码再一次编译为x86或者其它指令集的机器码,而这其中又包括寄存器分配,指令的选择以及其它一系列常见的编译器后端优化,而进一步深入挖掘还有程序热点统计这样就可以实现根据运行时信息优化,这么多话题可能可以写好几本书。