來源:寒江獨釣的部落格
最近一週比較忙,主要的工作內容是在做一個叫“鍵盤精靈”的東西,簡單來講就是將很多資料放到記憶體中,對這些資料進行快速檢索,然後找出根據輸入條件最匹配的10條記錄並予以展示。具體和下面兩款炒股軟體的相關功能類似:
資料以文字形式存在檔案中,且資料量較大,有近20萬條,每一條記錄有幾個欄位,以分隔符分割。當時使用的是6萬條記錄的測試資料,文字檔案將近10M,這個模組載入到記憶體並建立快取之後,大概會佔用將近70-80M的記憶體。自我接手以後,主要的任務就是降低記憶體消耗和提高匹配效率。
一、避免建立不必要的物件
拿到程式碼後,第一步就是看設計文件,然後斷點一步一步的看程式碼,大概明白了邏輯之後,發現思路有一些問題。之前的程式碼處理流程思路大概是下面這樣的:
- 將檔案讀取到記憶體,例項化
- 根據條件對檔案進行檢索,並儲存到結果集1中
- 對結果集1中的結果進行匹配度計算,並儲存到結果集中2
- 按對結果集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應用程式記憶體佔用的一點兒實踐和總結,希望對您有所幫助。