2.8.6 程序运行原理
业务逻辑的优化很大程度上都是围绕着以下环节展开的:如何利用好CPU缓存命中率、如何减少内存分配和卸载次数,以及如何利用好多线程,让多个线程协作顺畅,并且能分担任务。为了更好地优化我们编写的程序,应该深入底层去了解计算机是如何执行我们编写的程序的。这次我们得脱离C#语言,因为C#为我们包装了太多东西,使用起来很方便,同时也蒙蔽了我们的眼睛,让我们看不清底层的原理。
计算体系结构比较复杂,也脱离了我们的主题内容,这里简单陈述程序运行的原理,帮助我们理解。
计算机最终能识别的都是机器码,那么机器码是怎么产生的呢?机器码是通过编译器产生的,机器码太难记,汇编就是用来帮助我们记忆机器码的,每个汇编指令都对应一个机器指令,所有由1和0组成的机器指令码都能一一对应到汇编指令上。这么来看,一个可执行文件或一个库文件通常都可以转化成汇编代码,很多黑客也是通过这种方式来查看我们编写的程序的,大部分厉害的黑客都精通汇编。
一个程序在内存中运行时,通常包含几个内存块,其中一个是指令内存块,里面存储的都是已经编写设计好的执行指令,需要执行的指令都会从指令内存块中去取,指令计数器也会不断跳跃在这些指令中。另一个是数据内存块,里面存放的都是我们设置好的数据以及分配过的内存。数据块中有一部分内容可以称为静态数据块,里面通常存放的是不变的数据,比如字符串常量、常量整数、常量浮点数及一些静态数据,这些数据在程序启动时最先被放入内存中。
数据内存块中的数据,除了静态内存数据外,还有堆内存数据。所有的动态内存申请都来自堆内存,我们可以认为它是一个很长的byte数组,当申请内存时,会从数组中找出一块我们指定大小的内存,这个内存不一定是空的,因为内存回收从来不会对内存单位进行清理操作,那样太浪费算力了,它所做的是将这段数据的指针回收或偏移。所以实际上,我们申请的内存块,在没有初始化前都是未知的,有可能刚好前面用过与我们相似的内容,如果不进行初始化,就有可能出现逻辑问题。当然,这里还有一个系统层,我们都是在系统层面上运行程序的,所以遵循的是系统层面的逻辑。操作系统为我们提供了虚拟地址,以此来避免程序直接与硬件打交道。现代操作系统都不直接和物理内存打交道,而是与虚拟内存打交道,包括iOS和安卓,日常分配的内存都是虚拟内存,如malloc,只存在malloc是不会增加物理内存的使用的,只有读/写时虚拟内存才会关联到物理内存里。
我们用惯了类对象,很容易以为内存就是某个类的实例内存,其实在机器指令和内存中并没有这个说法,它只是块连续的内存,具体其代表哪个类的实例都是我们想象的,这些都是先贤们创造出编译器的功劳,让我们不需要关心某个内存块到底指的是什么,我们只需要知道程序里类是怎么写就可以了,高级语言让我们更方便,也让我们更“傻瓜”。其中,最容易混淆的就是类的方法,很多人认为它也被放入了对象实例中,其实并不是这样,类方法或函数会被编译成指令序列,放在指令内存块中,所有的方法、函数都在那里集中存放着,随时能取到。
因此一个可执行文件或程序库里,几乎都是指令机器码,以及指令附带的常量数据。我们运行这段程序时,可执行文件和库被装入内存,成为指令段内存,里面装着所有类的方法或者函数,包括静态的、公共的、私有的等,只是名字上不同,我们可以认为,它以名字来区别是公共的还是私有的,比如可以认为Class_A_public_GetData()是类对象A的public方法的GetData,这个函数只是代表指令的地址,并没有任何公共和私有的分类,在机器码的世界里没有边界和限制,但你仍然得遵守操作系统的规则,因为我们受限于操作系统。
除了上述这些,栈内存块也是比较重要的内容,它通常都是函数方法执行的重要部分,与堆内存不同的是,它是有秩序的,遵守先进后出的规则,每分配一块内存,回收时也必须按照先进后出的秩序回收,这个规则使得栈内存永远是连续的,不会因为使用很多次后出现很多内存碎片,进而导致有内存而无法分配的现象。我们所说的值类型数据大多在栈中分配,除非它被放置于其他类型中,如类和数组中。
上述其实就是在讲解汇编里的数据段、代码段、栈段这三个段,它们分别使用了段地址和偏移量来表示数据和指令内容。当指令数据需要数据段内容时,就用“数据段地址+偏移量”存取数据内存中的数据;当指令跳转时,则使用“代码段地址+偏移量”来指向新的指令内存地址;当需要用到栈时,则使用pop和push的汇编指令来偏移栈顶指针,从而存取栈上的数据。除了内存,寄存器是离CPU最近也最快的存储单元,它一般都用来临时存放数据,当然,我们也可以自己写汇编,让某些寄存器长期存放一些数据,以加快读取某数据的速度。