This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,342 @@
<audio id="audio" title="31 | 内存计算:对海量数据做计算,到底可以有多快?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5f/15/5f0a3569aa9188ddc2b0dc701ce12715.mp3"></audio>
内存计算是近十几年来在数据库和大数据领域的一个热点。随着内存越来越便宜CPU的架构越来越先进整个数据库都可以放在内存中并通过SIMD和并行计算技术来提升数据处理的性能。
**我问你一个问题:**做1.6亿条数据的汇总计算,需要花费多少时间呢?几秒?几十秒?还是几分钟?如果你经常使用数据库,肯定会知道,我们不会在数据库的一张表中保存上亿条的数据,因为处理速度会很慢。
但今天我会带你采用内存计算技术提高海量数据处理工作的性能。与此同时我还会介绍SIMD指令、高速缓存和局部性、动态优化等知识点。这些知识点与编译器后端技术息息相关掌握这些内容会对你从事基础软件研发工作有很大的帮助。
## 了解SIMD
本节课所采用的CPU支持一类叫做SIMDSingle Instruction Multiple Data的指令**它的字面意思是:**单条指令能处理多个数据。相应的你可以把每次只处理一个数据的指令叫做SISDSingle Instruction Single Data
SISD使用普通的寄存器进行操作比如加法
```
addl $10, %eax
```
这行代码是把一个32位的整型数字加到%eax寄存器上在x86-64架构下这个寄存器一共有64位但这个指令只用它的低32位高32位是闲置的
这种一次只处理一个数据的计算,**叫做标量计算;**一次可以同时处理多个数据的计算,**叫做矢量计算。**它在一个寄存器里可以并排摆下4个、8个甚至更多标量构成一个矢量。图中ymm寄存器是256位的可以支持同时做4个64位数的计算xmm寄存器是它的低128位
<img src="https://static001.geekbang.org/resource/image/e0/57/e0b3f9d6a0726021f224b1b7910e9257.png" alt="">
如果不做64位整数而做32位整数计算一次能计算8个如果做单字节8位数字的计算一次可以算32个
<img src="https://static001.geekbang.org/resource/image/61/46/612e429fe459db3d80e13970e4194046.png" alt="">
1997年Intel公司推出了奔腾处理器带有MMX指令集意思是多媒体扩展。当时让计算机能够播放多媒体比如播放视频是一个巨大的进步。但播放视频需要大量的浮点计算依靠原来CPU的浮点运算功能并不够。
所以Intel公司就引入了MMX指令集和容量更大的寄存器来支持一条指令同时计算多个数据这是在PC上最早的SIMD指令集。后来SIMD又继续发展陆续产生了SSE流式SIMD扩展、AVX高级矢量扩展指令集处理能力越来越强大。
2017年Intel公司发布了一款至强处理器支持AVX-512指令也就是它的一个寄存器有512位。每次能处理8个64位整数或16个32位整数或者32个双精度数、64个单精度数。你想想一条指令顶64条指令几十倍的性能提升是不是很厉害
那么你的电脑是否支持SIMD指令又支持哪些指令集呢在命令行终端打下面的命令你可以查看CPU所支持的指令集。
```
sysctl -a | grep features | grep cpu //macOs操作系统
cat /proc/cpuinfo //Linux操作系统
```
现在想必你已经知道了SIMD指令的强大之处了。**而它的实际作用主要有以下几点:**
<li>
SIMD有助于多媒体的处理比如在电脑上流畅地播放视频或者开视频会议
</li>
<li>
在游戏领域图形渲染主要靠GPU但如果你没有强大的GPU还是要靠CPU的SIMD指令来帮忙
</li>
<li>
在商业领域数据库系统会采用SIMD来快速处理海量的数据
</li>
<li>
人工智能领域机器学习需要消耗大量的计算量SIMD指令可以提升机器学习的速度。
</li>
<li>
你平常写的程序编译器也会优化成尽量使用SIMD指令来提高性能。
</li>
所以我们所用到的程序其实天天在都在执行SIMD指令。
**接下来我来演示一下如何使用SIMD指令**与传统的数据处理技术做性能上的对比并探讨如何在编译器中生成SIMD指令这样你可以针对自己的项目充分发挥SIMD指令的优势。
Intel公司为SIMD指令提供了一个标准的库可以生成SIMD的汇编指令。我们写一个简单的程序参考[simd1.c](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/simd1.c)来对两组数据做加法运算每组8个整数
```
#include &lt;stdio.h&gt;
#include &quot;immintrin.h&quot;
void sum(){
//初始化两个矢量 8个32位整数
__m256i a=_mm256_set_epi32(20,30,40,60,342,34523,474,123);
__m256i b=_mm256_set_epi32(234,234,456,78,2345,213,76,88);
//矢量加法
__m256i sum=_mm256_add_epi32(a, b);
//打印每个值
int32_t* s = (int32_t*)&amp;sum;
for (int i = 0; i&lt; 8; i++){
printf(&quot;s[%d] : %d\n&quot;, i, s[i]);
}
}
```
把矢量加法运算翻译成汇编语言的话采用的指令是vpaddd其中的p是pack的意思对一组数据操作。寄存器的名字是ymmy开头意思是256位的
```
vpaddd %ymm0, %ymm1, %ymm0
```
在这个示例中,我们构建了两个矢量数据,这个计算很简单。**接下来我们挑战一个有难度的题目把1.6亿个64位的整数做加法**
1.6亿个64位整数要占据大约1.2G的内存你要把这1.2G的数据全部汇总一遍要实现这个功能你首先要申请一块1.2G大小的内存并且要是32位对齐的因为后面加载数据到寄存器的指令需要内存对齐这样加载速度更快
```
unsigned totalNums = 160000000;
//申请一块32位对齐的内存。
//注意aligned_alloc函数C11标准才支持
int64_t * nums = aligned_alloc(32, totalNums * sizeof(int64_t));
//初始化sum值
__m256i sum=_mm256_setzero_si256();
__m256i * vectorptr = (__m256i *) nums;
for (int i = 0; i &lt; totalNums/4; i++) {
//从内存加载256位进来
__m256i a = _mm256_load_si256(vectorptr+i);
//矢量加法
sum=_mm256_add_epi64(sum,a);
}
```
**完整的代码见[simd2.c](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/simd2.c)。**
最后,要用下面的命令,编译成可执行文件(-mavx2参数是告诉编译器要使用CPU的AVX2特性
```
gcc -mavx2 simd2.c -o simd2
clang -mavx2 simd2.c -o simd2
```
你可以运行一下,看看用了多少时间。
我的MacBook Pro大约用了0.15秒。**注意,**这还是只用了一个内核做计算的情况。我提供的simd3.c示例程序是计算1.6亿个双精度浮点数,所用的时间也差不多,都是亚秒级。而计算速度之所以这么快,**主要有两个原因:**
- 采用了SIMD
- 高速缓存和数据局部性所带来的帮助。
我们先把SIMD讨论完然后再讨论高速缓存和数据局部性。
矢量化功能可以一个指令当好几个用但刚才编写的SIMD示例代码使用了特别的库这些库函数本身就是用嵌入式的汇编指令写的所以相当于我们直接使用了SIMD的指令。
如果我们不调用这几个库直接做加减乘除运算能否获得SIMD的好处呢也可以。不过要靠编译器的帮助所以接下来来看看LLVM是怎样帮我们使用SIMD指令的。
## LLVM的自动矢量化功能Auto-Vectorization
各个编译器都在自动矢量化功能上下了功夫以LLVM为例它支持循环的矢量化Loop Vectorizer和SLP矢量化功能。
**循环的矢量化很容易理解。**如果我们处理一个很大的数组,肯定是顺序读取内存的,就如[loop1()](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/loop.c)函数的代码:
```
int loop1(int totalNums, int * nums){
int sum = 0;
for (int i = 0; i&lt; totalNums; i++){
sum += nums[i];
}
return sum;
}
```
不过,如果你用不同的参数去生成汇编代码,**结果会不一样:**
- clang -S loop.c -o [loop-scalar.s](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/loop-scalar.s)
这是最常规的汇编代码老老实实地用add指令和%eax寄存器做加法。
- clang -S -O2 loop.c -o [loop-O2.s](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/loop-O2.s)
它在使用paddd指令和xmm寄存器这已经在使用SIMD指令了。
- clang -S -O2 -fno-vectorize loop.c -o [loop-O2-scalar.s](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/loop-O2-scalar.s)
这次带上了-O2参数要求编译器做优化但又带上了-fno-vectorize参数要求编译器不要通过矢量化做优化。那么生成的代码会是这个样子
```
addl (%rsi,%rdx,4), %eax
addl 4(%rsi,%rdx,4), %eax
addl 8(%rsi,%rdx,4), %eax
addl 12(%rsi,%rdx,4), %eax
addl 16(%rsi,%rdx,4), %eax
addl 20(%rsi,%rdx,4), %eax
addl 24(%rsi,%rdx,4), %eax
addl 28(%rsi,%rdx,4), %eax
```
也就是它一次循环就做了8次加法计算减少了循环的次数也更容易利用高速缓存来提高数据读入的效率所以会导致性能上的优化。
- clang -S -O2 -mavx2 loop.c -o [loop-avx2.s](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/loop-avx2.s)
这次带上-mavx2参数编译器就会使用AVX2指令来做矢量化你查看代码会看到对vpaddd指令和ymm寄存器的使用。
**其实,**在simd2.c中我们有[一段循环语句](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/simd2.c#L45),对标量数字进行加总。这段代码在缺省的情况下,也会被编译器矢量化(你可以看看汇编代码[simd2-O2-avx2.s](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/simd2-O2-avx2.s)确认一下)。
在做自动矢量化的时候,编译器要避免一些潜在的问题,看看[loop2()](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/loop.c#L19)函数的代码:
```
void loop2(int totalNums, int * nums1, int * nums2){
for (int i = 0; i&lt; totalNums; i++){
nums2[i] += nums1[i];
}
}
```
代码中的nums1和nums2是两个指针指向内存中的两个整数数组的位置。但我们从代码里看不出nums1和nums2是否有重叠一旦它们有重叠的话矢量化的计算结果会出错。
**所以,编译程序会生成矢量和标量两个版本的目标代码,**在运行时检测nums1和nums2是否重叠从而判断是否跳转到矢量化的计算代码。**从这里你也可以看出:**写编译器真的要有工匠精神,要把各种可能性都想到。
实际上,在编译器里有很多这样的实现。你可以将循环次数改为一个常量,看一下[loop3()](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/loop.c#L32)函数,它所生成的汇编代码会根据常量的值做优化,甚至完全不做循环:
```
int loop3(int * nums){
int sum = 0;
for (int i = 0; i&lt; 160; i++){
sum += nums[i];
}
return sum;
}
```
**除了循环的矢量化器LLVM还有一个SLP矢量化器**它能在做全局优化时寻找可被矢量化的代码来做转换。比如下面的代码对A[0]和A[1]的操作非常相似,可以考虑按照矢量的方式来计算:
```
void foo(int a1, int a2, int b1, int b2, int *A) {
A[0] = a1*(a1 + b1)/b1 + 50*b1/a1;
A[1] = a2*(a2 + b2)/b2 + 50*b2/a2;
}
```
所以LLVM确实在自动矢量化方面做了大量工作。**在你设计一个新的编译器的时候,可以充分利用这些已有的成果。**否则,在每个优化算法上,你都需要投入大量的精力,还不一定能做得足够稳定。
到目前为止我们针对SIMD和矢量化谈得足够多了。2011年左右我第一次做内存计算方面的编程时被如此快的处理速度吓了一跳。因为如果你经常操作数据库肯定会知道从数据库里做1.6亿个数据的汇总是什么概念。
一般来说一张表有上亿条数据之前我们就已经要做分拆了。大多数情况下表中的数据要比1.6亿低一个数量级,就算是这样,你对一个有着一两千万行数据表做统计,仍然要花费不少的时间。
**而毫不费力地进行海量数据的计算,就是内存计算的魅力。**当然了,这里面有高速缓存和局部性的帮助。所以,我们继续讨论一下,跟内存计算有关的第二个问题:高速缓存和局部性。
## 高速缓存和局部性
我们知道,计算机的存储是分成多个级别的:
- 速度最快的是寄存器通常在寄存器之间复制数据只需要1个时钟周期。
- 其次是高速缓存,它根据速度和容量分为多个层级,读取所花费的时间从几个时钟周期到几十个时钟周期不等。
- 内存则要用上百到几百个时钟周期。
<img src="https://static001.geekbang.org/resource/image/26/3c/2666961ee1728d4b77e9dd8b0fc9693c.png" alt="">
在图中的存储层次结构中越往下存取速度越慢但是却可以有更大的容量从寄存器的K级到高速缓存的M级到内存的G级到磁盘的T级灰色标的数据是Intel公司的[Ice Lake](https://software.intel.com/sites/default/files/managed/9e/bc/64-ia-32-architectures-optimization-manual.pdf)架构的CPU的数据
一般的计算机指令1到几个时钟周期就可以执行完毕。所以如果等待内存中读取获得数据的话CPU的性能可能只能发挥出1%。不过由于高速缓存的存在读取数据的平均时间会缩短到几个时钟周期这样CPU的能力可以充分发挥出来。所以我在讲程序的运行时环境的时候让你关注CPU上两个重要的部件**一个是寄存器,另一个就是高速缓存。**
在代码里,我们会用到寄存器,并且还会用专门的寄存器分配的算法来优化寄存器。可是对于高速缓存,我们没有办法直接控制。
因为当你用mov指令从内存中加载数据到寄存器时或者用add指令把内存中的一个数据加到寄存器中一个已有的值上面时CPU会自动控制是从内存里取还是在高速缓存中取并控制高速缓存的刷新。
那我们有什么办法呢?答案是**提高程序的局部性locality**这个局部性又分为两个:
<li>
一是时间局部性。一个数据一旦被加载到高速缓存甚至寄存器,我们后序的代码都能集中访问这个数据,别等着这个数据失效了再访问,那就又需要从低级别的存储中加载一次。
</li>
<li>
第二个是空间局部性。当我们访问了一条数据之后很可能马上访问跟这个数据挨着的其他数据。CPU在一次读入数据的时候会把相邻的数据都加载到高速缓存这样会增加后面代码在高速缓存中命中的概率。
</li>
提高局部性这件事情,更多的是程序员的责任,编译器能做的事情不多。不过,有一种编译优化技术,**叫做循环互换优化loop interchange optimization**可以让程序更好地利用高速缓存和寄存器。
下面的例子中有内循环和外循环内循环次数较少外循环次数很大。如果内循环里的临时变量比较多需要占用寄存器和高速缓存那么i就可能被挤掉等下一次用到i的时候需要重新从低一级的存储中获取从而造成性能的降低
```
for(i=0; i&lt;1000000; i++)
for(j=0; j&lt;10; j++){
a[i] *= b[i]
...
}
```
编译器可以把内外层循环交换,这样就提高了局部性:
```
for(j=0; i&lt;10; j++)
for(i= 0; i&lt;1000000; i++){
a[i] *= b[i]
...
}
```
不过在大多数情况下i和j循环的次数不是一个常量而是一个变量在编译时不知道内层循环次数更多还是外层循环。这样的话可能就需要生成两套代码在运行时根据情况决定跳转到哪个代码块去执行**这样会导致目标代码的膨胀。**
如果不想让代码膨胀又能获得优化的目标代码你可以尝试在运行时做动态的优化也就是动态编译这也是LLVM的设计目标之一。因为在静态编译期我们确实没办法知道运行时的信息从而也没有办法生成最优化的目标代码。
作为一名优秀的程序员,你有责任让程序保持更好的局部性。比如,假设你要设计一个内存数据库,并且经常做汇总计算,那么你会把每个字段的数据按行存储在一起,还是按列存储?当然是后者,因为这样才具备更好的数据局部性。
最后除了SIMD和数据局部性促成内存计算这个领域发展的还有两个因素
<li>
多内核并行计算。现在的CPU内核越来越多特别是用于服务器的CPU。多路CPU几十上百个内核能够让单机处理能力再次提升几十甚至上百倍。
</li>
<li>
内存越来越便宜。在服务器上配置几十个G的内存已经是常规配置配置上T的内存也不罕见。这使得大量与数据处理有关的工作可以基于内存而不是磁盘。除了要更新数据几乎可以不访问相对速度很低的磁盘。
</li>
在这些因素的共同作用下,内存计算的使用越来越普遍。在你的项目里,你可以考虑采用这个技术,来加速海量数据的处理。
## 课程小结
本节课,我带你了解了内存计算的特点,以及与编译技术的关系,我希望你能记住几点:
<li>
SIMD是一种指令级并行技术它能够矢量化地一次计算多条数据从而提升计算性能在计算密集型的需求中比如多媒体处理、海量数据处理、人工智能、游戏等领域你可以考虑充分利用SIMD技术。
</li>
<li>
充分保持程序的局部性,能够更好地利用计算机的高速缓存,从而提高程序的性能。
</li>
<li>
SIMD加上数据局部性和多个CPU内核的并行处理能力再加上低价的海量的内存推动了内存计算技术的普及它能够同时满足计算密集和海量数据的需求。
</li>
<li>
有时候,我们必须在运行期,根据一些数据来做优化,生成更优的目标代码,在编译期不可能做到尽善尽美。
</li>
我想强调的是,熟悉编译器的后端技术将会有利于你参与基础平台的研发。如果你想设计一款内存数据库产品,一款大数据产品,或者其他产品,将计算机的底层架构知识,和编译技术结合起来,会让你有机会发挥更大的作用!
## 一课一思
你是否在自己的领域里使用过内存计算技术?它能带来什么好处?欢迎分享你的观点。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
**示例代码我放在文末,供你参考。**
- lab/31-simd示例代码目录 [码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/lab/31-simd) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/lab/31-simd)
- simd1.c两个矢量常数相加 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/31-simd/simd1.c) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/simd1.c)
- simd2.c1.6亿个32位整数汇总 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/31-simd/simd2.c) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/simd2.c)
- simd3.c1.6亿个双精度浮点数汇总) [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/31-simd/simd3.c) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/simd3.c)
- loop.c测试对循环的自动矢量化 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/31-simd/loop.c) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/loop.c)
- loop.avx2.s自动矢量化成AVX2指令后的汇编代码 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/31-simd/loop.avx2.s) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/31-simd/loop-avx2.s)

View File

@@ -0,0 +1,229 @@
<audio id="audio" title="32 | 字节码生成为什么Spring技术很强大" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cc/e9/cca3ca182dcdceb9449b681d7db62de9.mp3"></audio>
**Java程序员几乎都了解Spring。**它的IoC依赖反转和AOP面向切面编程功能非常强大、易用。而它背后的字节码生成技术在运行时根据需要修改和生成Java字节码的技术就是一项重要的支撑技术。
Java字节码能够在JVMJava虚拟机上解释执行或即时编译执行。其实除了JavaJVM上的Groovy、Kotlin、Closure、Scala等很多语言也都需要生成字节码。另外playscript也可以生成字节码从而在JVM上高效地运行
**而且,字节码生成技术很有用。**你可以用它将高级语言编译成字节码,还可以向原来的代码中注入新代码,来实现对性能的监测等功能。
目前我就有一个实际项目的需求。我们的一个产品需要一个规则引擎解析自定义的DSL进行规则的计算。这个规则引擎处理的数据量比较大所以它的性能越高越好。因此如果把DSL编译成字节码就最理想了。
既然字节码生成技术有很强的实用价值,那么本节课,我就带你掌握它。
我会先带你了解Java的虚拟机和字节码的指令然后借助ASM这个工具生成字节码最后再实现从AST编译成字节码。通过这样一个过程你会加深对Java虚拟机的了解掌握字节码生成技术从而更加了解Spring的运行机制甚至有能力编写这样的工具
## Java虚拟机和字节码
字节码是一种二进制格式的中间代码它不是物理机器的目标代码而是运行在Java虚拟机上可以被解释执行和即时编译执行。
在讲后端技术时我强调的都是如何生成直接在计算机上运行的二进制代码这比较符合C、C++、Go等静态编译型语言。但如果想要解释执行除了直接解释执行AST以外我没有讲其他解释执行技术。
而目前更常见的解释执行的语言是采用虚拟机其中最典型的就是JVM它能够解释执行Java字节码。
而虚拟机的设计又有两种技术:**一是基于栈的虚拟机;二是基于寄存器的虚拟机。**
标准的JVM是基于栈的虚拟机后面简称“栈机”
每一个线程都有一个JVM栈每次调用一个方法都会生成一个栈桢来支持这个方法的运行。栈桢里面又包含了本地变量数组包括方法的参数和本地变量、操作数栈和这个方法所用到的常数。这种栈桢的设计跟之前我们学过C语言的栈桢的结构其实有很大的相似性你可以通过[21讲](https://time.geekbang.org/column/article/146635)回顾一下。
<img src="https://static001.geekbang.org/resource/image/38/6b/38974ba1d07fc5dfe4e72ebfa2cedf6b.jpg" alt="">
**栈机是基于操作数栈做计算的。**以“2+3”的计算为例只要把它转化成逆波兰表达式“2 3 +”,然后按照顺序执行就可以了。**也就是:**先把2入栈再把3入栈再执行加法指令这时要从栈里弹出2个操作数做加法计算再把结果压入栈。
<img src="https://static001.geekbang.org/resource/image/2b/4d/2b58a2c9a363e7060f142ddfd7ce514d.jpg" alt="">
你可以看出栈机的加法指令是不需要带操作数的就是简单的“iadd”就行**这跟你之前学过的IR都不一样。**为什么呢因为操作数都在栈里加法操作需要2个操作数从栈里弹出2个元素就行了。
也就是说,指令的操作数是由栈确定的,我们不需要为每个操作数显式地指定存储位置,所以指令可以比较短,**这是栈机的一个优点。**
接下来,我们聊聊字节码的特点。
**字节码是什么样子的呢?**我编写了一个简单的类[MyClass.java](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/32-bytecode/src/main/java/MyClass.java)其中的foo()方法实现了一个简单的加法计算,你可以看看它对应的字节码是怎样的:
```
public class MyClass {
public int foo(int a){
return a + 3;
}
}
```
在命令行终端敲入下面两行命令,生成文本格式的字节码文件:
```
javac MyClass.java
javap -v MyClass &gt; MyClass.bc
```
打开MyClass.bc文件你会看到下面的内容片段
```
public int foo(int);
Code:
0: iload_1 //把下标为1的本地变量入栈
1: iconst_3 //把常数3入栈
2: iadd //执行加法操作
3: ireturn //返回
```
其中foo()方法一共有四条指令前三条指令是计算一个加法表达式a+3。**这完全是按照逆波兰表达式的顺序来执行的:**先把一个本地变量入栈再把常数3入栈再执行加法运算。
**如果你细心的话,应该会发现:**把参数a入栈的第一条指令用的下标是1而不是0。这是因为每个方法的第一个参数下标为0是当前对象实例的引用this
我提供了字节码中,一些常用的指令,增加你对字节码特点的直观认识,完整的指令集可以参见[JVM的规格书](https://docs.oracle.com/javase/specs/jvms/se12/html/jvms-6.html)
<img src="https://static001.geekbang.org/resource/image/c6/af/c6d81160a05835619ae5d9feef1453af.jpg" alt="">
其中每个指令都是8位的占一个字节而且iload_0、iconst_0这种指令甚至把操作数变量的下标、常数的值压缩进了操作码里可以看出字节码的设计很注重节省空间。
根据这些指令所对应的操作码的数值MyClass.bc文件中你所看到的那四行代码变成二进制格式就是下面的样子
<img src="https://static001.geekbang.org/resource/image/0c/e2/0cbaed1b5f15b78ea9634a17e75eece2.jpg" alt="">
你可以用“hexdump MyClass.class”显示字节码文件的内容从中可以发现这个片段就是橙色框里的内容
<img src="https://static001.geekbang.org/resource/image/20/6f/20b0e2798c15eccc3d440e084af3a76f.png" alt="">
现在,你已经初步了解了基于栈的虚拟机,**与此对应的是基于寄存器的虚拟机。**这类虚拟机的运行机制跟机器码的运行机制是差不多的,它的指令要显式地指出操作数的位置(寄存器或内存地址)。**它的优势是:**可以更充分地利用寄存器来保存中间值,从而可以进行更多的优化。
例如当存在公共子表达式时这个表达式的计算结果可以保存在某个寄存器中另一个用到该公共子表达式的指令就可以直接访问这个寄存器不用再计算了。在栈机里是做不到这样的优化的所以基于寄存器的虚拟机性能可以更高。而它的典型代表是Google公司为Android开发的Dalvik虚拟机和Lua语言的虚拟机。
**这里你需要注意,**栈机并不是不用寄存器,实际上,操作数栈是可以基于寄存器实现的,寄存器放不下的再溢出到内存里。只不过栈机的每条指令,只能操作栈顶部的几个操作数,所以也就没有办法访问其它寄存器,实现更多的优化。
现在,你应该对虚拟机以及字节码有了一定的了解了。那么,如何借助工具生成字节码呢?你可能会问了:为什么不纯手工生成字节码呢?当然可以,只不过借助工具会更快一些。
就像你生成LLVM的IR时也曾获得了LLVM的API的帮助。所以接下来我会带你认识ASM这个工具并借助它为我们生成字节码。
## 字节码生成工具ASM
其实有很多工具会帮我们生成字节码比如Apache BCEL、Javassist等选择ASM是因为它的性能比较高并且它还被Spring等著名软件所采用。
[ASM](https://asm.ow2.io/)是一个开源的字节码生成工具。Grovvy语言就是用它来生成字节码的它还能解析Java编译后生成的字节码从而进行修改。
ASM解析字节码的过程有点像XML的解析器解析XML的过程先解析类再解析类的成员比如类的成员变量Field、类的方法Mothod。在方法里又可以解析出一行行的指令。
你需要掌握两个核心的类的用法:
- [ClassReader](https://asm.ow2.io/javadoc/org/objectweb/asm/ClassReader.html),用来解析字节码。
- [ClassWriter](https://asm.ow2.io/javadoc/org/objectweb/asm/ClassWriter.html),用来生成字节码。
这两个类如果配合起来用,就可以一边读入,做一定修改后再写出,从而实现对原来代码的修改。
我们先试验一下用ClassWriter生成字节码看看能不能生成一个跟前面示例代码中的MyClass一样的类我们可以称呼这个类为MyClass2里面也有一个一模一样的foo函数。相关代码参考[genMyClass2()](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/32-bytecode/src/main/java/GenClass.java#L21)方法,这里只拿出其中一段看一下:
```
//////创建foo方法
MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, &quot;foo&quot;,
&quot;(I)I&quot;, //括号中的是参数类型,括号后面的是返回值类型
null, null);
//添加参数a
mv.visitParameter(&quot;a&quot;, Opcodes.ACC_PUBLIC);
mv.visitVarInsn(Opcodes.ILOAD, 1); //iload_1
mv.visitInsn(Opcodes.ICONST_3); //iconst_3
mv.visitInsn(Opcodes.IADD); //iadd
mv.visitInsn(Opcodes.IRETURN); //ireturn
//设置操作数栈最大的帧数,以及最大的本地变量数
mv.visitMaxs(2,2);
//结束方法
mv.visitEnd();
```
从这个示例代码中,你会看到两个特点:
<li>
ClassWriter有visitClass、visitMethod这样的方法以及ClassVisitor、MethodVistor这样的类。这是因为ClassWriter用了visitor模式来编程。你每一次调用visitXXX方法就会创建相应的字节码对象就像LLVM形成内存中的IR对象一样。
</li>
<li>
foo()方法里的指令,跟我们前面看到的字节码指令是一样的。
</li>
执行这个程序就会生成MyClass2.class文件。
把MyClass2.class变成可读的文本格式之后你可以看到它跟MyClass的字节码内容几乎是一样的只有类名称不同。当然了你还可以写一个程序调用MyClass2验证一下它是否能够正常工作。
发现了吗只要熟悉Java的字节码指令在ASM的帮助下你可以很方便地生成字节码想要了解更多ASM的用法可以参考它的一个[技术指南](https://asm.ow2.io/asm4-guide.pdf)。
既然你已经能生成字节码了那么不如趁热打铁把编译器前端生成的AST编译成字节码在JVM上运行因为这样你就能从前端到后端完整地实现一门基于JVM的语言了
## 将AST编译成字节码
基于AST生成JVM的字节码的逻辑还是比较简单的比生成针对物理机器的目标代码要简单得多为什么这么说呢**主要有以下几个原因:**
<li>
首先你不用太关心指令选择的问题。针对AST中的每个运算基本上都有唯一的字节码指令对应你直白地翻译就可以了不需要用到树覆盖这样的算法。
</li>
<li>
你也不需要关心寄存器的分配因为JVM是使用操作数栈的
</li>
<li>
指令重排序也不用考虑,因为指令的顺序是确定的,按照逆波兰表达式的顺序就可以了;
</li>
<li>
优化算法,你暂时也不用考虑。
</li>
按照这个思路你可以在playscript-java中增加一个[ByteCodeGen](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ByteCodeGen.java)的类,针对少量的语言特性做一下字节码的生成。最后,我们再增加一点代码,能够加载并执行所生成的字节码。运行下面的命令,可以把[bytecode.play](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/bytecode.play)示例代码编译并运行。
```
java play.PlayScript -bc bytecode.play
```
当然了我们只实现了playscript的少量特性不过如果在这个基础上继续完善你就可以逐步实现一门完整的基于JVM的语言了。
## Spring与字节码生成技术
我在开篇提到Java程序员大部分都会使用Spring。Spring的IoC依赖反转和AOP面向切面编程特性几乎是Java程序员在面试时必被问到的问题了解Spring和字节码生成技术的关系能让你在面试时更轻松。
Spring的AOP是基于代理proxy的机制实现的。在调用某个对象的方法之前要先经过代理在代理这儿可以进行安全检查、记日志、支持事务等额外的功能。
<img src="https://static001.geekbang.org/resource/image/ef/25/efaca94bf71023d66352f17b3f8d9025.jpg" alt="">
**Spring采用的代理技术有两个**一个是Java的动态代理dynamic proxy技术一个是采用cglib自动生成代理cglib采用了asm来生成字节码。
<img src="https://static001.geekbang.org/resource/image/00/69/00d79a0d34441fe5962aa50682be4869.jpg" alt="">
Java的动态代理技术只支持某个类所实现的接口中的方法。如果一个类不是某个接口的实现那么Spring就必须用到cglib从而用到字节码生成技术来生成代理对象的字节码。
## 课程小结
本节课我主要带你了解了字节码生成技术。字节码生成技术是Java程序员非常熟悉的Spring框架背后所依赖的核心技术之一。如果想要掌握这个技术你需要对Java虚拟机的运行原理、字节码的格式以及常见指令有所了解。**我想强调的重点如下:**
- 运行程序的虚拟机有两种设计:一个是基于栈的;一个是基于寄存器的。
基于栈的虚拟机不用显式地管理操作数的地址,因此指令会比较短,指令生成也比较容易。而基于寄存器的虚拟机,则能更好地利用寄存器资源,也能对代码进行更多的优化。
<li>
你要能够在大脑中图形化地想象出栈机运行的过程,从而对它的原理理解得更清晰。
</li>
<li>
ASM是一个字节码操纵框架它能帮你修改和生成字节码如果你有这方面的需求可以采用这样的工具。
</li>
相信有了前几课的基础你再接触一种新的后端技术时学习速度会变得很快。学完这节课之后你可能会觉得字节码就是另一种IR而且比LLVM的IR简单多了。如果你有这个感受那么你已经在脑海里建立了相关的知识体系达到了举一反三的效果。
在这里我也建议Java程序员多多了解JVM的运行机制和Java字节码这样会更好地把握Java语言的底层机制从而更利于自己职业生涯的发展。
## 一课一思
你是否想为自己写的语言生成字节码呢?或者生成字节码的技术,能否帮你解决现有项目中的难点问题呢?欢迎在留言区分享你的观点。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。
**示例代码链接,我放在文末,供你参考。**
- GenClass.java用asm工具生成字节码 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/32-bytecode/src/main/java/GenClass.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/32-bytecode/src/main/java/GenClass.java)
- MyClass.java一个简单的java类 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/32-bytecode/src/main/java/MyClass.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/32-bytecode/src/main/java/MyClass.java)
- MyClass.bc文本格式的字节码 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/32-bytecode/src/main/java/MyClass.bc) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/32-bytecode/src/main/java/MyClass.bc)
- ByteCodeGen.java基于AST生成字节码 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ByteCodeGen.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/main/play/ByteCodeGen.java)
- bytecode.play示例用的playscript脚本 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/playscript-java/src/examples/bytecode.play) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/playscript-java/src/examples/bytecode.play)