当今流行的编程语言,大多具备垃圾回收(Garbage Collection,以下简称GC)功能。它能够将不再使用的内存区域收回并重新分配。
这一功能可以说,将程序员的注意力从内存的分配/释放工作中解放了出来,可以专注于业务逻辑的实现。但这并不意味着说,程序员在写代码的时候就可以无所顾忌了。
因为他们面对的环境里,资源毕竟是有限的,而GC也不能包办一切工作。尤其是程序需要运行时性能的时候,对代码的编写就有更高的要求了。
而在优化程序性能时,也不能凭着猜想去实施,这就需要对编程语言的内存布局与管理有清楚的了解。这样才能做到有的放矢,事半而功倍。
下面我们先从编译技术的基本概念说起。
编译器方式,这种方式是将代码经过预处理、编译、汇编、链接之后,得到一个可执行文件。这个文件里面包含的都是二进制的机器指令,它的优点是程序执行速度快,能将硬件性能充分发挥出来。
它的缺点则是编译过程需要耗费时间,程序修改之后必须重新编译才能使用。在早些年硬件性能不高的时候,编译一个大型的程序需要一两个小时是很平常的事。
此类语言的典型代表是C/C++,以及现在十分流行的Go语言。
解释器方式,程序代码直接运行在一个解释器中,没有编译的过程。优点则是可以立即运行,且可移植性好,代码编写一次即可在任何平台上运行,而且预期效果也一样。而编译器方式则要麻烦的多,它需要为每一个平台单独编译一次。
不过解释器方式的缺点也同样明显,就是它的性能受限。毕竟是隔着一层解释器去执行,远远比不了翻译成机器指令的二进制可执行文件。
此类语言的代表则有Python、ruby、php、JAVAscript等。可以认为,脚本类语言都属于解释器方式执行。
中间代码方式,这是一种折衷式的方案,它会先对代码有一次编译过程,但不是编译成可执行文件,而是一份中间代码。然后这份中间代码会放到一个虚拟机里去执行。以这样的方式既获得了良好的可移植性,也能够拥有高于解释器的速度。
java语言即是最佳代表。它会先编译出一个字节码文件,然后Java Virtual machine(JVM)通过读取字节码来运行程序。
微软的.NET也是类似的结构,它使用的是Common Language Runtime(CLR),以此支持多种语言。例如C#、VB.net等。
不论一个程序用何种语言编写,它的运行时内存布局都是一致的。我们先从一个程序的三种基本内存区域说起。
静态区:这个区域主要存放的是程序的全局变量、常量数据,以及编译成二进制指令的代码。可以看到,这个区域存放的,主要是贯穿于程序整个生命周期所要使用到的数据与指令。
栈区:熟悉数据结构的朋友们都知道,栈(stack)是一个后入先出(LIFO)的队列。在程序运行中,它用来实现函数的调用。程序执行函数调用时,会在栈上依次压入参数,局部变量、返回位置等,执行完成后再依次将数据出栈。所以,栈上的数据都是临时性的,只在调用时可用。
堆区:所有动态申请的内存都从堆区分配。在使用C/C++语言时,程序员对待内存的申请与释放就必须特别小心,一个疏忽就会造成内存泄漏。而后来的java、C#等,语言内置了GC技术,情况相对改善,但也要养成良好的编程习惯。
对于程序来说,静态区和堆区都是全局存在的,即所有线程共享这二者。而栈区则是为每个线程单独准备一个,这一点程序员要记住。因为栈区的数据在函数调用之后就会失效,如果还引用栈区的数据,则会产生不可预料的问题。
程序运行时内存布局
因为现在市场上面向对象编程语言(OOP)占据主流地位,所以接下来的讨论也将以OOP语言的典型内存结构进行讲解。我们了解清楚对象的存储区域,方法的调用之后,就会更加明白编程时应当注意哪些方面。
我们以使用较为广泛的Java语言进行说明,先要厘清一个总是争论不休的问题。就是Java语言中究竟有没有指针?
Java中的一系列逻辑功能,都是通过对象的间的消息传递和方法调用来实现的。对象是实现功能的最小单元,而一个对象是怎么来的,它存放在哪里?
先看一段派生对象的代码:
MyCar one = new MyCar()
Java语言中的new的实质是动态创建内存,用以存放对象实例。根据上节的知识,我们知道new操作的结果是从堆区申请了一块内存,它将这块内存的地址返回,变量one就可以通过这个地址实现对象的操作了。
所以,变量one中存储的不是对象本身,而是指向对象所在内存的地址。好吧,简单说就两个字:指针。在Java的术语体系里,它也叫引用。不过不管怎么称呼,这种内存结构就是典型的指针式操作。
既然我们知道Java语言中所有的对象都生成在堆区,那么需要注意之处就来了:堆区的存储空间是有限的,不能将运行时环境想象成内存无限的场景,要对自己使用的对象所占空间做到心中有数。
接下来还要注意的,就是对象复制的操作,示例代码:
MyCar one = new MyCar()
MyCar two = one; one.SetSpeed(100);
two.SetSpeed(0);
有了上面的知识,我们清楚地知道,MyCar two = one;这条语句并没有复制一个对象给two变量,它和one指向的都是同一个对象实例。所以代码执行的结果,就是这辆车以百公里时速狂奔的下一秒就减速到零,想想都挺吓人的吧。
那么,对象的方法代码是存放在哪里呢?答案是在静态区。因为方法是可以在编译时就形成二进制指令的,因此编译后放在静态区就可以了。
类的信息是存放在静态区的,它会包含一张方法表(有的语言中也称为虚函数表)。方法表中的方法名实际上是一个函数指针,它在运行时是指向静态区的方法代码的。有了方法表,OOP语言就可以实现多态机制了。
这种方式可以节省程序存储空间,所以从本质上说,所有的对象实例都是在共用同一段方法代码。只是在调用时通过压入不同的参数以实现对象个性化的操作。
对象的属性变量又是存放在哪里?答案是在堆区,所以我们现在知道,一个对象实例里,属性变量的大小决定了它实际占用的存储空间。
需要注意的事项又来了:不要在类的声明中,将属性变量定义的过大。例如为了图方便,定义个超大的数组。这样带来的问题,一是会影响对象生成的效率,因为动态分配一段大内存是很耗时的;二是会导致内存空间急剧减少。
GC的运行并不是实时清理的,它会有延时判断策略,那么大量闲置的内存还来不及回收,新的对象又得不到可用空间,这只会降低程序的运行时性能了。
通过方法表,继承结构也得以实现。对于超类中的方法,子类中无需再存储相同的副本,它只要在自己的方法表中增加一条指向超类的方法引用即可。
对象通过方法表调用方法
通过上述几节的知识,我们知道GC要处理的肯定是在堆区上动态分配的对象实例。那是不是有了这个原则,我们就可以高枕无忧了呢?并不是,这要从GC的回收原理上说起。
GC的实现基础,必定是通过引用计数来判定对象是否被使用,未被使用的对象则会进入回收工作中。但是如果对象变量是在静态区或者栈区,那么这个对象永远都不会被回收。
静态区的对象,在Java中就是以static定义的类变量。程序员对此一定要心中有数,一定要记住类变量生成的对象,它的生命周期是和程序本身一样的。
而栈上所引用的对象,它的存活周期则和方法调用一致。也就是说如果方法退出,那么期间所产生的对象不再使用了,是会被回收的。
在多线程环境中,程序员要注意,如果一个方法是长期后台运行的,则不要进行频繁地创建对象的工作,以避免内存无法回收。
被栈区和静态区引用的对象是不会被回收的
经过了解编程语言的内存布局与管理,我们发现还是有很多细节处不注意的话,很容易掉到坑里去的。那时候,代码功能看着都正常,但程序运行一段时间后性能就下降。不得不来一次万能的重启以解决问题,这显然不是最佳解决办法。
所以,我将文中涉及到的注意事项,整理出来再列举如下。希望可以帮助遇到性能问题的程序员们。