最近一周比较忙,主要的工作内容是在做一个叫“键盘精灵”的东西,
简单来讲就是将很多数据放到内存中,对这些数据进行快速检索,然后找出根据输入条件最匹配的10条记录并予以展示。
具体和下面两款炒股软件的相关功能类似:
数据以文本形式存在文件中,且数据量较大,有近20万条,
每一条记录有几个字段,以分隔符分割。当时使用的是6万条记录的测试数据,文本文件将近10M,
这个模块加载到内存并建立缓存之后,大概会占用将近70-80M的内存。自我接手以后,
主要的任务就是降低内存消耗和提高匹配效率。
一、避免创建不必要的对象 拿到代码后,
第一步就是看设计文档,然后断点一步一步的看代码,大概明白了逻辑之后,发现思路有一些问题。
之前的代码处理流程思路大概是下面这样的:
1.将文件读取到内存,实例化
2.根据条件对文件进行检索,并存储到结果集1中
3.对结果集1中的结果进行匹配度计算,并存储到结果集中2
4.按对结果集2进行匹配度排序,取最匹配的10条记录,然后返回 这个过程中规中矩。
但是其中有很多问题,最大的问题是,临时变量存储了太多的中间处理结果,而这些对象在一次查询完成后又马上丢弃,
大量的临时对象带来了很大的GC压力。举例来说,当用户在输入框中输入1的时候,假设使用Contains来匹配,
那么从6万条记录中找出包含1的记录可能有4万多条,然后需要把这4万多条记录存储在临时变量中进行处理,
进一步计算这4万条记录的匹配度,然后存储到一个类似KeyValuePair的集合中,key为匹配度,
然后对这个集合按Key进行排序,然后取前10条最优记录。可以看到,中间创建了大量的临时变量,使得内存剧增,
大量临时对象创建之后马上会被回收,GC压力山大。 而在设计文档中,只要求返回最最匹配的10条记录,
之前的解决方案中似乎并没有注意到这一点。所以接手后,第一步就是对上面的处理过程进行精简。
精简后如下: 将文件读取到内存,实例化 根据条件对文件进行检索,如果存在,则: 计算匹配度。 以匹配度为Key,
存储到只有11个容量的SortList中。 如果SortList集合添加记录后大于10个,则移除最后面一个元素,始终保持着前10个最小(匹配度最优)的记录。 遍历完成之后,返回这个集合对象 经过这一修改,减少了大量临时数据对内存的占用,整个过程中,
我只是使用一个容量为11的SortList结构存储中间的过程,每一次插入一个元素,SortList帮我们排好序,
然后移除最不匹配的那一个,也就是最后一个元素(从小到大排序,越匹配,值越小)。
这里面的消耗主要是SortList的插入,内部排序和移除记录。 说到这里在选择SortList还是SortDictionary的问题上纠结了一下,
于是又找了些资料,SortDictionary在内部使用红黑树实现,SortList采用有序数组实现, 在内部排序都为O(logn)的前提下,
SortDictionary的O(logn)插入及删除元素的时间复杂度优于SortList,但是SortDictionary会比SortList占用更多内存。基本来说这是一个查
询速度和内存分配之间的平衡,由于这里只需要存储11个对象,所以两者相差不大。其实即使没有这种结构,自己也可以实现的,无非就
是一个集合,每次添加一个,排好序,然后将最大的那个移除。.NET使用起来方便是因为有很多这些强大的内置数据结构。 经过上面这
个小小的修改,内存占用一下子降低了1倍,从原来的70-80M,降低到了30-40M,其实这就是降低内存开销的一个最基本的原则,那就是
避免创建不必要的对象。 二、优化数据类型及算法 越到后面内存的降低越来越困难。仔细看了代码之后,除了上面之外,代码中也有一
些其他问题,比如,一开始就将大量的对象实例化到内存中,然后一直保存。每一条记录中的信息比较多,但真正有用的用于搜索匹配的
只有下面四个字段,但是整体的实例化会将其他没有用的字段也一并序列化进去了。导致很多内存被无用的字段占用。 “股票代码 股票中
文名 中文拼音 市场类型 …… 600000 浦发银行 PFYH 上证A股 ……” 所以第一步就是在内存中只存放需要检索的上面四个关键字段,每一
条记录刚开始是使用string[]数据,而不是使用类或者其它结构来保存,也尝试使用结构提来保存,但是由于四个字段,数据量大,中间还
要作为参数传递,所以比使用类还大,这里只是简单的使用了数组。 除了上面这些之外,为了提高搜索效率,对数据按照0-9,a-z开头对
数据做了切分分块缓存,这样当用户输入0时,直接从以0为key的块中读取数据,这样速度是加快了,但是大量的缓存也增加了对内存的
消耗。缓存的数据基本上和加载到内存中原始的数据一样大了。并且在搜索的过程中,也是采用的完全搜索,对于17万条数据的四个字
段,每一次查询要进行170000*4次遍历比较,才能找出最匹配的10条数据来。 为此,引入了不完全搜索,就是事先对各类型证券,如 股
票,基金,债券分类,对每一类按证券代码进行排序。当用户设置了搜索的优先级时,依次在每一类中查找,如果找到满足条件的10条记
录,则立即返回,因为数据已经事先按照证券类型和代码排好序了,所以后面找到的肯定没有之前找到的匹配度高,这一改进直接提高了
搜索查询的效率。对有序的数据进行查找效率一般会比无序的数据查找效率高。我们常见的一些查找算法,比如说,二分查找法,前提也
是待查找的集合有序排列。 三、采用非托管代码或者模块编写数据处理逻辑 上面的两部操作虽然减少了将近50-60%的内存占用,但是仍
然达不到领导的要求,于是又尝试并比较了各种 使用不同的数据结构将数据载入到内存中的内存占用大小,包括直接将文件按类型读成字
符串、数组、结构及类,内存占用最小的直接将文件读成字符串,10M的数据文件读进内存也会占用20-30M的空间,还不谈对其进行处理
过程中产生的一些临时变量对内存的占用。使用dotTrace及CLR Profile等工具检查之后,发现内存的占用也是这些原始数据。然后以”
How to reduce the memory usage of .NET applications” 到网上搜了一下减少.NET内存占用的一些方法,在StackOverflow上看到了这一
回答: 该同学指出.NET应用程序和其他使用本地代码编写的程序相比会有较大的内存占用,如果对内存开销比较在意,.NET可能不是最
好的选择。.NET应用程序的内存一定程度上受垃圾回收的影响。并指出,一些数据结构如List,系统会分配多余的空间。可以使用值类型
而不是引用类型,不要创建大对象,以免产生内存碎片等等降低内存占用的建议。 这些都考虑过之后,内存还是达不到要求,所以开始寻
找调用非托管代码的方式来自己更灵活的控制内存的分配与销毁。但是整个程序都是采用.NET编写的,全部切换成C或者C++不现实,所
以只有两种方案,一是使用unsafe 代码,二是将数据加载和检索模块采用C或者C++编写,在.NET中采用P/Invoke技术调用。 刚开始想采
用unsafe代码,对数据的加载及检索直接在放在unsafe 代码中。后来觉得代码有些乱,不同风格的代码混杂在一起不太好,而且数据加载
和检索的逻辑也比较复杂。所以就直接采用第二种方案,使用C++编写数据加载和检索逻辑。然后在.NET里面调用。 在开始之前,也做了
一些评估,比如将同样的10M的数据加载到内存中,都采用字符串的方式存储,.NET中会占用20-30M的内存,而在C++中只有9-10M的样
子,而且变动很小。这正是需要的结果。 由于对C++不熟,临时抱佛脚,翻了下C++ Primier Plus中关于字符串和STL的相关章节,并请
求其他开发小组给予了一定的协助,定义了基本的接口。为了演示,我创建了两个工程,一个是名为SecuData的C++ Win32 DLL工程,
一个是测试该类库的名为SecuDataTest的C# WinForm程序。 我在C++中定义好了4个方法,一个初始化加载数据,一个设置搜索优先
级,一个查找匹配方法和一个卸载数据方法,具体的算法由于工作原因不便贴出,这里只是举一个简单的例子,方法名及工程结构如下
图: 然后再在.NET中使用P/Invoke技术引入C++ DLL中定义的方法。 这样就可以在.NET中调用这些方法了,需要说明的是,方法的传入
值这里是使用String类型的,第二个StringBuilder类型的参数是方法的真正返回值,方法的整体int型返回值表明方法是否执行成功。在调用
查找方法时,第二个StringBuilder参数必须初始化一个最大的查询结果的大小,因为在C++中会往这个对象中写入结果,不初始化或者初
始化太小都会抛出异常。当然也可以直接返回结构体,这个就需要额外定义,这里返回的都是字符串。完了自己在.NET里面对其进行解
析。 需要注意的是,调试的时候,如果需要调试C++里面的代码,需要指定DLL的生成目录及启动目标,并且将C++项目设置为启动项
目,这里我设定生成DLL的目录为SecuDataTest项目生成的SecuDataTest.exe文件所在的目录,调试启动目标设置为
SecuDataTest.exe,这样在C++项目中设置断点,启动.NET Winform程序,当P/Invoke触发断点时能够逐步调试C++代码。 在发布的时
候,最好将默认的动态库配置修改为静态库,这样VS会把依赖的相关C++库打包到生成的dll中,部署到客户机器上不会出现问题。
SecuData类库项目的属性设置如下图: 改成这种P/Invoke模式之后,10M数据载到内存中,内存占用只有10M左右,较之前采用.NET的30-
40M的内存又降低了很多,而且内存波动比较小,满足了对内存占用的要求。 采用这种“混搭”方式有一些好处,既有.NET的快速开发,又
有C++的灵活的内存分配销毁模式以及代码安全性保护。在很多时候,可以将一些对内存占用比较敏感,大数据量的处理逻辑,放在C++
中处理,利用灵活的手动内存管理模式降低这部分的内存占用;将核心的数据结构及算法使用C++编写,可以提高代码的安全性,提高程
序的反编译难度。 四、结语 .NET应用程序由于需要加载CLR及一些通用类库,并且具有垃圾收集机制,较其他本地语言如C,C++具有较
大的footprint,使用.NET创建一个简单的Winform可能就会占用近10M的内存,所以随着开发的进行,内存占用会比较大。当然这些在很
多时候是由于开发者自身对.NET底层机制不熟悉,比如在有些地方可以使用值类型而使用引用类型;创建了大量的临时的周期比较短的对
象;使用了过多的静态变量及成员导致内存被长久占用而得不到回收;以及.NET内部的一些机制,比如集合对象会在内部预先分配多余的
空间等等。很多时候因为有.NET的GC机制,使得我们不必去关注对象的销毁而很”大方”的去创建新对象,去使用一些重型的内置对象,从
而导致内存占用过大。解决好这些问题,其实可以降低.NET应用程序的相当大一部分的不必要的内存占用。 除了了解.NET框架的一些内
部机制之外,良好的思路,高效数据结构和算法也可以使得问题变得简单而减少内存的开销。 最后对于对内存要求比较敏感,可以利用
C/C++的手动的灵活的内存管理语言来编写相应模块,在.NET中采用P/Invoke技术进行调用来减少一些内存。 以上是我对降低.NET应用
程序内存占用的一点儿实践和总结,希望对您有所帮助。