減少.NET應(yīng)用程序內(nèi)存占用的一則實(shí)踐
最近一周比較忙,主要的工作內(nèi)容是在做一個(gè)叫“鍵盤(pán)精靈”的東西,簡(jiǎn)單來(lái)講就是將很多數(shù)據(jù)放到內(nèi)存中,對(duì)這些數(shù)據(jù)進(jìn)行快速檢索,然后找出根據(jù)輸入條件最匹配的10條記錄并予以展示。具體和下面兩款炒股軟件的相關(guān)功能類似:
數(shù)據(jù)以文本形式存在文件中,且數(shù)據(jù)量較大,有近20萬(wàn)條,每一條記錄有幾個(gè)字段,以分隔符分割。當(dāng)時(shí)使用的是6萬(wàn)條記錄的測(cè)試數(shù)據(jù),文本文件將近10M,這個(gè)模塊加載到內(nèi)存并建立緩存之后,大概會(huì)占用將近70-80M的內(nèi)存。自我接手以后,主要的任務(wù)就是降低內(nèi)存消耗和提高匹配效率。
一、避免創(chuàng)建不必要的對(duì)象
拿到代碼后,第一步就是看設(shè)計(jì)文檔,然后斷點(diǎn)一步一步的看代碼,大概明白了邏輯之后,發(fā)現(xiàn)思路有一些問(wèn)題。之前的代碼處理流程思路大概是下面這樣的:
- 將文件讀取到內(nèi)存,實(shí)例化
- 根據(jù)條件對(duì)文件進(jìn)行檢索,并存儲(chǔ)到結(jié)果集1中
- 對(duì)結(jié)果集1中的結(jié)果進(jìn)行匹配度計(jì)算,并存儲(chǔ)到結(jié)果集中2
- 按對(duì)結(jié)果集2進(jìn)行匹配度排序,取最匹配的10條記錄,然后返回
這個(gè)過(guò)程中規(guī)中矩。但是其中有很多問(wèn)題,最大的問(wèn)題是,臨時(shí)變量存儲(chǔ)了太多的中間處理結(jié)果,而這些對(duì)象在一次查詢完成后又馬上丟棄,大量的臨時(shí)對(duì)象帶來(lái)了很大的GC壓力。舉例來(lái)說(shuō),當(dāng)用戶在輸入框中輸入1的時(shí)候,假設(shè)使用Contains來(lái)匹配,那么從6萬(wàn)條記錄中找出包含1的記錄可能有4萬(wàn)多條,然后需要把這4萬(wàn)多條記錄存儲(chǔ)在臨時(shí)變量中進(jìn)行處理,進(jìn)一步計(jì)算這4萬(wàn)條記錄的匹配度,然后存儲(chǔ)到一個(gè)類似KeyValuePair的集合中,key為匹配度,然后對(duì)這個(gè)集合按Key進(jìn)行排序,然后取前10條最優(yōu)記錄??梢钥吹?,中間創(chuàng)建了大量的臨時(shí)變量,使得內(nèi)存劇增,大量臨時(shí)對(duì)象創(chuàng)建之后馬上會(huì)被回收,GC 壓力山大。
而在設(shè)計(jì)文檔中,只要求返回最最匹配的10條記錄,之前的解決方案中似乎并沒(méi)有注意到這一點(diǎn)。所以接手后,第一步就是對(duì)上面的處理過(guò)程進(jìn)行精簡(jiǎn)。精簡(jiǎn)后如下:
- 將文件讀取到內(nèi)存,實(shí)例化
- 根據(jù)條件對(duì)文件進(jìn)行檢索,如果存在,則:
- 計(jì)算匹配度。
- 以匹配度為Key,存儲(chǔ)到只有11個(gè)容量的SortList中。
- 如果SortList集合添加記錄后大于10個(gè),則移除最后面一個(gè)元素,始終保持著前10個(gè)最小(匹配度最優(yōu))的記錄。
- 遍歷完成之后,返回這個(gè)集合對(duì)象
經(jīng)過(guò)這一修改,減少了大量臨時(shí)數(shù)據(jù)對(duì)內(nèi)存的占用,整個(gè)過(guò)程中,我只是使用一個(gè)容量為11的SortList結(jié)構(gòu)存儲(chǔ)中間的過(guò)程,每一次插入一個(gè)元素,SortList幫我們排好序,然后移除最不匹配的那一個(gè),也就是最后一個(gè)元素(從小到大排序,越匹配,值越小)。這里面的消耗主要是 SortList的插入,內(nèi)部排序和移除記錄。 說(shuō)到這里在選擇SortList還是SortDictionary的問(wèn)題上糾結(jié)了一下,于是又找了些資料,SortDictionary在內(nèi)部使用紅黑樹(shù)實(shí)現(xiàn),SortList采用有序數(shù)組實(shí)現(xiàn), 在內(nèi)部排序都為O(logn)的前提下,SortDictionary的O(logn)插入及刪除元素的時(shí)間復(fù)雜度高于SortList,但是 SortDictionary會(huì)比SortList占用更多內(nèi)存。基本來(lái)說(shuō)這是一個(gè)查詢速度和內(nèi)存分配之間的平衡,由于這里只需要存儲(chǔ)11個(gè)對(duì)象,所以兩者相差不大。其實(shí)即使沒(méi)有這種結(jié)構(gòu),自己也可以實(shí)現(xiàn)的,無(wú)非就是一個(gè)集合,每次添加一個(gè),排好序,然后將最大的那個(gè)移除。.NET使用起來(lái)方便是因?yàn)橛泻芏噙@些強(qiáng)大的內(nèi)置數(shù)據(jù)結(jié)構(gòu)。
經(jīng)過(guò)上面這個(gè)小小的修改,內(nèi)存占用一下子降低了1倍,從原來(lái)的70-80M,降低到了30-40M,其實(shí)這就是降低內(nèi)存開(kāi)銷的一個(gè)最基本的原則,那就是避免創(chuàng)建不必要的對(duì)象。
二、優(yōu)化數(shù)據(jù)類型及算法
越到后面內(nèi)存的降低越來(lái)越困難。仔細(xì)看了代碼之后,除了上面之外,代碼中也有一些其他問(wèn)題,比如,一開(kāi)始就將大量的對(duì)象實(shí)例化到內(nèi)存中,然后一直保存。每一條記錄中的信息比較多,但真正有用的用于搜索匹配的只有下面四個(gè)字段,但是整體的實(shí)例化會(huì)將其他沒(méi)有用的字段也一并序列化進(jìn)去了。導(dǎo)致很多內(nèi)存被無(wú)用的字段占用。
股票代碼 股票中文名 中文拼音 市場(chǎng)類型 …… 600000 浦發(fā)銀行 PFYH 上證A股 …… |
所以第一步就是在內(nèi)存中只存放需要檢索的上面四個(gè)關(guān)鍵字段,每一條記錄剛開(kāi)始是使用string[]數(shù)據(jù),而不是使用類或者其它結(jié)構(gòu)來(lái)保存,也嘗試使用結(jié)構(gòu)提來(lái)保存,但是由于四個(gè)字段,數(shù)據(jù)量大,中間還要作為參數(shù)傳遞,所以比使用類還大,這里只是簡(jiǎn)單的使用了數(shù)組。
除了上面這些之外,為了提高搜索效率,對(duì)數(shù)據(jù)按照0-9,a-z開(kāi)頭對(duì)數(shù)據(jù)做了切分分塊緩存,這樣當(dāng)用戶輸入0時(shí),直接從以0為key的塊中讀取數(shù)據(jù),這樣速度是加快了,但是大量的緩存也增加了對(duì)內(nèi)存的消耗。緩存的數(shù)據(jù)基本上和加載到內(nèi)存中原始的數(shù)據(jù)一樣大了。并且在搜索的過(guò)程中,也是采用的完全搜索,對(duì)于17萬(wàn)條數(shù)據(jù)的四個(gè)字段,每一次查詢要進(jìn)行170000*4次遍歷比較,才能找出最匹配的10條數(shù)據(jù)來(lái)。
為此,引入了不完全搜索,就是事先對(duì)各類型證券,如 股票,基金,債券分類,對(duì)每一類按證券代碼進(jìn)行排序。當(dāng)用戶設(shè)置了搜索的優(yōu)先級(jí)時(shí),依次在每一類中查找,如果找到滿足條件的10條記錄,則立即返回,因?yàn)閿?shù)據(jù)已經(jīng)事先按照證券類型和代碼排好序了,所以后面找到的肯定沒(méi)有之前找到的匹配度高,這一改進(jìn)直接提高了搜索查詢的效率。對(duì)有序的數(shù)據(jù)進(jìn)行查找效率一般會(huì)大于無(wú)序的數(shù)據(jù)高。我們常見(jiàn)的一些查找算法,比如說(shuō),二分查找法,前提也是待查找的集合有序排列。
#p#
三、采用非托管代碼或者模塊編寫(xiě)數(shù)據(jù)處理邏輯
上面的兩部操作雖然減少了將近50-60%的內(nèi)存占用,但是仍然達(dá)不到領(lǐng)導(dǎo)的要求,于是又嘗試并比較了各種 使用不同的數(shù)據(jù)結(jié)構(gòu)將數(shù)據(jù)載入到內(nèi)存中的內(nèi)存占用大小,包括直接將文件按類型讀成字符串、數(shù)組、結(jié)構(gòu)及類,內(nèi)存占用最小的直接將文件讀成字符串,10M的數(shù)據(jù)文件讀進(jìn)內(nèi)存也會(huì)占用20-30M的空間,還不談對(duì)其進(jìn)行處理過(guò)程中產(chǎn)生的一些臨時(shí)變量對(duì)內(nèi)存的占用。使用dotTrace及CLR Profile等工具檢查之后,發(fā)現(xiàn)內(nèi)存的占用也是這些原始數(shù)據(jù)。然后以” How to reduce the memory usage of .NET applications” 到網(wǎng)上搜了一下減少.NET內(nèi)存占用的一些方法,在StackOverflow上看到了這一回答:
該同學(xué)指出.NET應(yīng)用程序和其他使用本地代碼編寫(xiě)的程序相比會(huì)有較大的內(nèi)存占用,如果對(duì)內(nèi)存開(kāi)銷比較在意,.NET可能不是最好的選擇。.NET應(yīng)用程序的內(nèi)存一定程度上受垃圾回收的影響。并指出,一些數(shù)據(jù)結(jié)構(gòu)如List,系統(tǒng)會(huì)分配多余的空間??梢允褂弥殿愋投皇且妙愋停灰?jiǎng)?chuàng)建大對(duì)象,以免產(chǎn)生內(nèi)存碎片等等降低內(nèi)存占用的建議。
這些都考慮過(guò)之后,內(nèi)存還是達(dá)不到要求,所以開(kāi)始尋找調(diào)用非托管代碼的方式來(lái)自己更靈活的控制內(nèi)存的分配與銷毀。但是整個(gè)程序都是采用.NET編寫(xiě)的,全部切換成C或者C++不現(xiàn)實(shí),所以只有兩種方案,一是使用unsafe 代碼,二是將數(shù)據(jù)加載和檢索模塊采用C或者C++編寫(xiě),在.NET中采用P/Invoke技術(shù)調(diào)用。
剛開(kāi)始想采用unsafe代碼,對(duì)數(shù)據(jù)的加載及檢索直接在放在unsafe 代碼中。后來(lái)覺(jué)得代碼有些亂,不同風(fēng)格的代碼混雜在一起不太好,而且數(shù)據(jù)加載和檢索的邏輯也比較復(fù)雜。所以就直接采用第二種方案,使用C++編寫(xiě)數(shù)據(jù)加載和檢索邏輯。然后在.NET里面調(diào)用。
在開(kāi)始之前,也做了一些評(píng)估,比如將同樣的10M的數(shù)據(jù)加載到內(nèi)存中,都采用字符串的方式存儲(chǔ),.NET中會(huì)占用20-30M的內(nèi)存,而在C++中只有9-10M的樣子,而且變動(dòng)很小。這正是需要的結(jié)果。
由于對(duì)C++不熟,臨時(shí)抱佛腳,翻了下C++ Primier Plus中關(guān)于字符串和STL的相關(guān)章節(jié),并請(qǐng)求其他開(kāi)發(fā)小組給予了一定的協(xié)助,定義了基本的接口。為了演示,我創(chuàng)建了兩個(gè)工程,一個(gè)是名為 SecuData的C++ Win32 DLL工程,一個(gè)是測(cè)試該類庫(kù)的名為SecuDataTest的C# WinForm程序。
我在C++中定義好了4個(gè)方法,一個(gè)初始化加載數(shù)據(jù),一個(gè)設(shè)置搜索優(yōu)先級(jí),一個(gè)查找匹配方法和一個(gè)卸載數(shù)據(jù)方法,具體的算法由于工作原因不便貼出,這里只是舉一個(gè)簡(jiǎn)單的例子,方法名及工程結(jié)構(gòu)如下圖:
然后再在.NET中使用P/Invoke技術(shù)引入C++ DLL中定義的方法。
這樣就可以在.NET中調(diào)用這些方法了,需要說(shuō)明的是,方法的傳入值這里是使用String類型的,第二個(gè)StringBuilder類型的參數(shù)是方法的真正返回值,方法的整體int型返回值表明方法是否執(zhí)行成功。在調(diào)用查找方法時(shí),第二個(gè)StringBuilder參數(shù)必須初始化一個(gè)最大的查詢結(jié)果的大小,因?yàn)樵贑++中會(huì)往這個(gè)對(duì)象中寫(xiě)入結(jié)果,不初始化或者初始化太小都會(huì)拋出異常。當(dāng)然也可以直接返回結(jié)構(gòu)體,這個(gè)就需要額外定義,這里返回的都是字符串。完了自己在.NET里面對(duì)其進(jìn)行解析。
唯一需要注意的是,調(diào)試的時(shí)候,如果需要調(diào)試C++里面的代碼,需要指定DLL的生成目錄及啟動(dòng)目標(biāo),并且將C++項(xiàng)目設(shè)置為啟動(dòng)項(xiàng)目,這里我設(shè)定生成 DLL的目錄為SecuDataTest項(xiàng)目生成的SecuDataTest.exe文件所在的目錄,調(diào)試啟動(dòng)目標(biāo)設(shè)置為 SecuDataTest.exe,這樣在C++項(xiàng)目中設(shè)置斷點(diǎn),啟動(dòng).NET Winform程序,當(dāng)P/Invoke觸發(fā)斷點(diǎn)時(shí)能夠逐步調(diào)試C++代碼。SecuData類庫(kù)項(xiàng)目的屬性設(shè)置如下圖:
改成這種P/Invoke模式之后,10M數(shù)據(jù)載到內(nèi)存中,內(nèi)存占用只有10M左右,較之前采用.NET的30-40M的內(nèi)存又降低了很多,而且內(nèi)存波動(dòng)比較小,滿足了對(duì)內(nèi)存占用的要求。
采用這種“混搭”方式有一些好處,既有.NET的快速開(kāi)發(fā),又有C++的靈活的內(nèi)存分配銷毀模式以及代碼安全性保護(hù)。在很多時(shí)候,可以將一些對(duì)內(nèi)存占用比較敏感,大數(shù)據(jù)量的處理邏輯,放在C++中處理,利用靈活的手動(dòng)內(nèi)存管理模式降低這部分的內(nèi)存占用;將核心的數(shù)據(jù)結(jié)構(gòu)及算法使用C++編寫(xiě),可以提高代碼的安全性,提高程序的反編譯難度。
四、結(jié)語(yǔ)
.NET應(yīng)用程序由于需要加載CLR及一些通用類庫(kù),并且具有垃圾收集機(jī)制,較其他本地語(yǔ)言如C+,C++具有較大的footprint,使用.NET創(chuàng)建一個(gè)簡(jiǎn)單的Winform可能就會(huì)占用近10M的內(nèi)存,所以隨著開(kāi)發(fā)的進(jìn)行,內(nèi)存占用會(huì)比較大。當(dāng)然這些在很多時(shí)候是由于開(kāi)發(fā)者自身對(duì).NET底層機(jī)制不熟悉,比如在有些地方可以使用值類型而使用引用類型;創(chuàng)建了大量的臨時(shí)的周期比較短的對(duì)象;使用了過(guò)多的靜態(tài)變量及成員導(dǎo)致內(nèi)存被長(zhǎng)久占用而得不到回收;以及.NET內(nèi)部的一些機(jī)制,比如集合對(duì)象會(huì)在內(nèi)部預(yù)先分配多余的空間等等。很多時(shí)候因?yàn)橛?NET的GC機(jī)制,使得我們不必去關(guān)注對(duì)象的銷毀而很”大方”的去創(chuàng)建新對(duì)象,去使用一些重型的內(nèi)置對(duì)象,從而導(dǎo)致內(nèi)存占用過(guò)大。解決好這些問(wèn)題,其實(shí)可以降低.NET應(yīng)用程序的相當(dāng)大一部分的不必要的內(nèi)存占用。
除了了解.NET框架的一些內(nèi)部機(jī)制之外,良好的思路,高效數(shù)據(jù)結(jié)構(gòu)和算法也可以使得問(wèn)題變得簡(jiǎn)單而減少內(nèi)存的開(kāi)銷。
最后對(duì)于對(duì)內(nèi)存要求比較敏感,可以利用C/C++的手動(dòng)的靈活的內(nèi)存管理語(yǔ)言來(lái)編寫(xiě)相應(yīng)模塊,在.NET中采用P/Invoke技術(shù)進(jìn)行調(diào)用來(lái)減少一些內(nèi)存。
以上是我對(duì)降低.NET應(yīng)用程序內(nèi)存占用的一點(diǎn)兒實(shí)踐和總結(jié),希望對(duì)您有所幫助。
原文鏈接:http://www.cnblogs.com/yangecnu/archive/2013/03/10/Reduce_the_DotNET_Applications_Memory_Usage.html