C++阵营
相信绝大多数人选择了C++,原因是可以自由的控制内存的分配与回收,从而去提高程序运行的效率;或者是怀着“设计者”的心情去不断挑战极限。
对于第一点,先来说明一个名次“托管”,这是由Microsoft的专有概念,主要分为托管代码、托管数据、托管类三大部分,这里主要针对托管数据进行说明。托管数据就是由公共语言运行的垃圾回收器进行分配和释放的数据。即像Java和C#一样,程序员只需要new,而不需要关心delete,统一由系统回收。
但是系统回收资源,必须要有一定的内存回收机制,并且要定期去执行这个机制,势必会占用一定的系统资源:回收之前,垃圾占用内存;回收时,占用CPU资源。(具体我没有研究,只是个人观点)
那么C++的内存管理方式就避免了上述的资源浪费,但是会造成一系列的内存Bug,相信大家都深有体会。
废话有点多,进入主题,既然C++的内存分配有如此好处,那new是不是想用就能用呢?
一个例子
首先,说明环境,下面全部测试代码在Windows 7平台下,工具为Visual Studio 2013,环境为Release Win32。(已验证过Visual Studio 2010,MinGW,内存分配上大同小异)
此例子来自于一个同事针对Excel测试时发现,具体如下:
int main(void)
{
size_t VecSize = 1024 * 1024 * 10;
std::vector<double *> doublePVec(VecSize, nullptr);
system("PAUSE");
for (size_t i = 0; i < VecSize; ++i)
{
double *pDouble = new double;
doublePVec[i] = pDouble;
}
system("PAUSE");
std::for_each(doublePVec.begin(), doublePVec.end(), [](double*pDouble){delete pDouble;});
std::vector<double *>().swap(doublePVec);
system("PAUSE");
return 0;
}
打开你的任务管理器,分别记录三次“PAUSE”时内存大小,可以发现大概如下:
第一次:43088K = 42M
第二次:207656K = 202M
第三次:3424K = 3M
根据第三次的内存为程序占用内存,减去这部分内存,可以发现第一次及第二次内存分别为40M和200M。
问题来了
根据上面的小例子,如果善于思考的同学,你的问题应该来了:
Win32平台下,vector的需要申请1024 * 1024 * 10 * 4 = 40M的空间大小,这个是没有问题的,所以第一次内存大小合理。
第二次内存应该为40M + 1024 * 1024 * 10 *8 =120M的空间大小,但是为什么会多了80M的内存?!
当时队伍里面几位小伙伴讨论了几种可能方式,例如说内存碎片,内存分配算法等各个方面,但是我感觉都不足以彻底解释这个问题,于是继续进行了下面的尝试。
继续尝试
尝试的方法就是改变new的大小,大家都知道内存对齐上一般是4字节对齐(针对这一点也有一个很有趣的事,以后再叙述),那么就按照4的倍数来不断改变申请空间大小,看看具体内存占用情况。
首先,先用int替换double,发现以下结果:
第一次:43088K = 42M
第二次:207656K = 202M
第三次:3424K = 3M
出现了这个结果,着实震惊了一把,在堆上申请空间,int居然和double是一样的?!
既然这样,那么把int再换成char,这样直接去申请数组空间,可以方便控制大小,大小先设为12,执行一遍,结果如下:
第一次:43092K = 42M
第二次:289932K = 283M
第三次:3980K = 3M
内存居然涨到280M了?!这跟预期的40M + 1024 * 1024 * 10 * 12= 160M差了120M,天啊,我怀疑我的测试用例写错了,反复检查了一遍,发现没问题呀。于是继续增加大小,char数组增长到16,结果如下:
第一次:43092K = 42M
第二次:289932K = 283M
第三次:3980K = 3M
一次次的震惊,已经不能说明我的心情了,后面的结果就是不断增长大小,然后进行测试,就不贴结果了,大家可以自己测试一下。
推导公式
根据结果,发现了以下规律:
1. Windows下内存分配最小大小为8Byte。
2. 每次分配都会增加8Byte的额外空间。
根据上述两条结果,假设申请空间为xByte,则实际分配的大小N为:
N = (⌈x/8⌉ + 1) * 8Byte
所以针对上面double的情况,则申请1024 *1024 * 10的空间为:
N = (1 + 1) * 8* 1024 * 1024 * 10 = 160M
进一步思考
第一,Windows的内存分配策略究竟是怎样的?
由于资料不是很全,只能在网上查询,并根据结果来做大概的推断。首先,Windows下Debug和Release的分配方式是不同的,Debug模式下需要占用大得多的内存来记录更多的信息用于调试;其次,Windows下Debug和Release模式下,new最终都是通过调用HeapAlloc等方法来分配内存,这个可以在调试模式下看见,只是Release模式下到HeapAlloc方法就不能再进行跟踪了;第三,Windows将内存划分为8Byte为基准的各个大小段,例如8Byte,16Byte,24Byte…1024Byte,凡超过1024Byte的会单独记录,1024Byte以下的直接在管理区间内进行划分;第四,Windows下一个进程可能会有多个堆空间;第五,通过第三、四点可以发现,一个进程可能存在内碎片,外碎片只可能存在于多个堆空间之间;第六,每次分配会多8Byte的空间,主要用来记录分配的空间记录用,例如分配空间的上一部分和下一部分的地址,分配的大小,空间是否有效等信息。
第二,C++中new也不是想用就能用。
进行大量信息存储时,一定要考虑new时堆空间的开销,32位环境下,每个进程理论可以占用空间大小为2^32,即4G,但是由于操作系统占用最高位,所以实际只能使用2G的空间。如果进行了大量小信息的存储会导致2G空间很快消耗。
如果要考虑构造函数的开销,那么最佳处理方式就是先申请一块连续空间,然后对象在这块连续空间上产生,即Placement new。
第三,一定要考虑Debug的场景。
任何一个人都不能保证一个程序没有Bug,并且随着工程规模不断扩大,Bug也会不断增长,具体数据可以参考《代码大全》,里面叙述的很清楚。那么任何程序必然会有Debug的过程,所以Release下的代码拿到Debug下,可能根本无法调试。这个在项目中已经吃过很大的亏了,要么慢的无法忍受,要么直接崩掉,根本原因是代码写的不够正确,一切都依赖于编译器的优化是不行的。即使一个简简单单的++操作符,也会造成很大的影响。