堆與棧的學習(1)

堆(heap)和棧(stack)是C/C++編程不可避免會碰到的兩個基本概念。首先,這兩個概念都可以在講數據結構的書中找到,他們都是基本的數據結構,雖然棧更爲簡單一些。 在具體的C/C++編程框架中,這兩個概念並不是並行的。對底層機器代碼的研究可以揭示,棧是機器系統提供的數據結構,而堆則是C/C++函數庫提供的。 具體地說,現代計算機(串行執行機制),都直接在代碼底層支持棧的數據結構。這體現在,有專門的寄存器指向棧所在的地址,有專門的機器指令完成數據入棧出棧的操作。這種機制的特點是效率高,支持的數據有限,一般是整數,指針,浮點數等系統直接支持的數據類型,並不直接支持其他的數據結構。因爲棧的這種特點,對棧的使用在程序中是非常頻繁的。對子程序的調用就是直接利用棧完成的。機器的call指令裏隱含了把返回地址推入棧,然後跳轉至子程序地址的操作,而子程序中的ret指令則隱含從堆棧中彈出返回地址並跳轉之的操作。C/C++中的自動變量是直接利用棧的例子,這也就是爲什麼當函數返回時,該函數的自動變量自動失效的原因(因爲堆棧恢復了調用前的狀態)。 和棧不同,堆的數據結構並不是由系統(無論是機器系統還是操作系統)支持的,而是由函數庫提供的。基本的malloc/realloc/free函數維護了一套內部的堆數據結構。當程序使用這些函數去獲得新的內存空間時,這套函數首先試圖從內部堆中尋找可用的內存空間,如果沒有可以使用的內存空間,則試圖利用系統調用來動態增加程序數據段的內存大小,新分配得到的空間首先被組織進內部堆中去,然後再以適當的形式返回給調用者。當程序釋放分配的內存空間時,這片內存空間被返回內部堆結構中,可能會被適當的處理(比如和其他空閒空間合併成更大的空閒空間),以更適合下一次內存分配申請。這套複雜的分配機制實際上相當於一個內存分配的緩衝池(Cache),使用這套機制有如下若干原因: 1. 系統調用可能不支持任意大小的內存分配。有些系統的系統調用只支持固定大小及其倍數的內存請求(按頁分配);這樣的話對於大量的小內存分類來說會造成浪費。 2. 系統調用申請內存可能是代價昂貴的。系統調用可能涉及用戶態和核心態的轉換。 3. 沒有管理的內存分配在大量複雜內存的分配釋放操作下很容易造成內存碎片。 堆和棧的對比 從以上知識可知,棧是系統提供的功能,特點是快速高效,缺點是有限制,數據不靈活;而棧是函數庫提供的功能,特點是靈活方便,數據適應面廣泛,但是效率有一定降低。棧是系統數據結構,對於進程/線程是唯一的;堆是函數庫內部數據結構,不一定唯一。不同堆分配的內存無法互相操作。棧空間分靜態分配和動態分配兩種。靜態分配是編譯器完成的,比如自動變量(auto)的分配。動態分配由alloca函數完成。棧的動態分配無需釋放(是自動的),也就沒有釋放函數。爲可移植的程序起見,棧的動態分配操作是不被鼓勵的!堆空間的分配總是動態的,雖然程序結束時所有的數據空間都會被釋放回系統,但是精確的申請內存/釋放內存匹配是良好程序的基本要素 另一篇網摘: 堆:歡樂和痛苦 前言 您是否是動態分配的 c/c++ 對象忠實且幸運的用戶?您是否在模塊間的往返通信中頻繁地使用了“自動化”?您的程序是否因堆分配而運行起來很慢?不僅僅您遇到這樣的問題。幾乎所有項目遲早都會遇到堆問題。大家都想說,“我的代碼真正好,只是堆太慢”。那只是部分正確。更深入理解堆及其用法、以及會發生什麼問題,是很有用的。 什麼是堆? 在程序中,使用堆來動態分配和釋放對象。在下列情況下,調用堆操作: 1. 事先不知道程序所需對象的數量和大小。 2. 對象太大而不適合堆棧分配程序。 堆使用了在運行時分配給代碼和堆棧的內存之外的部分內存。下圖給出了堆分配程序的不同層。 我: 一些堆分配的函數操作: globalalloc/globalfree:microsoft win32 堆調用,這些調用直接與每個進程的默認堆進行對話。 localalloc/localfree:win32 堆調用(爲了與 microsoft windows nt 兼容),這些調用直接與每個進程的默認堆進行對話。 com 的 imalloc 分配程序(或 cotaskmemalloc / cotaskmemfree):函數使用每個進程的默認堆。自動化程序使用“組件對象模型 (com)”的分配程序,而申請的程序使用每個進程堆。 c/c++ 運行時 (crt) 分配程序:提供了 malloc() 和 free() 以及 new 和 delete 操作符。如 microsoft visual basic 和 java 等語言也提供了新的操作符並使用垃圾收集來代替堆。crt 創建自己的私有堆,駐留在 win32 堆的頂部。 windows nt 中,win32 堆是 windows nt 運行時分配程序周圍的薄層。所有 api 轉發它們的請求給 ntdll。 windows nt 運行時分配程序提供 windows nt 內的核心堆分配程序。它由具有 128 個大小從 8 到 1,024 字節的空閒列表的前端分配程序組成。後端分配程序使用虛擬內存來保留和提交頁。 在圖表的底部是“虛擬內存分配程序”,操作系統使用它來保留和提交頁。所有分配程序使用虛擬內存進行數據的存取。 分配和釋放塊不就那麼簡單嗎?爲何花費這麼長時間? 堆實現的注意事項 傳統上,操作系統和運行時庫是與堆的實現共存的。在一個進程的開始,操作系統創建一個默認堆,叫做“進程堆”。如果沒有其他堆可使用, 則塊的分配使用“進程堆”。語言運行時也能在進程內創建單獨的堆。(例如,c 運行時創建它自己的堆。)除這些專用的堆外,應用程序或許多 已載入的動態鏈接庫 (dll) 之一可以創建和使用單獨的堆(dll單獨的堆)。win32 提供一整套 api 來創建和使用私有堆。有關堆函數(英文) 的詳盡指導,請參見 msdn。 當應用程序或 dll 創建私有堆時,這些堆存在於進程空間,並且在進程內是可訪問的。從給定堆分配的數據將在同一個堆上釋放。 (不能從一個堆分配而在另一個堆釋放。) 在所有虛擬內存系統中,堆駐留在操作系統的“虛擬內存管理器”的頂部。語言運行時堆也駐留在虛擬內存頂部。某些情況下,這些堆是操作系統堆 中的層,而語言運行時堆則通過大塊的分配來執行自己的內存管理,不使用操作系統堆,而使用虛擬內存函數更利於堆的分配和塊的使用。(我,使用 函數來分配堆。) 典型的堆實現由前、後端分配程序組成。前端分配程序維持固定大小塊的空閒列表。對於一次分配調用,堆嘗試從前端列表找到一個自由塊。如果失敗, 堆被迫從後端(保留和提交虛擬內存)分配一個大塊來滿足請求。通用的實現有每塊分配的開銷,這將耗費執行週期,也減少了可使用的存儲空間。 knowledge base 文章 q10758,“用 calloc() 和 malloc() 管理內存” (搜索文章編號), 包含了有關這些主題的更多背景知識。另外, 有關堆實現和設計的詳細討論也可在下列著作中找到:“dynamic storage allocation: a survey and critical review”,作者 paul r. wilson、mark s. johnstone、michael neely 和 david boles;“international workshop on memory management”, 作者 kinross, scotland, uk, 1995 年 9 月(http://www.cs.utexas.edu/users/oops/papers.html)(英文)。 windows nt 的實現(windows nt 版本 4.0 和更新版本) 使用了 127 個大小從 8 到 1,024 字節的 8 字節對齊塊空閒列表 和一個“大塊”列表。“大塊”列表(空閒列表[0]) 保存大於 1,024 字節的塊。空閒列表容納了用雙向鏈表鏈接在一起的對象。 默認情況下,“進程堆”執行收集操作。(收集是將相鄰空閒塊合併成一個大塊的操作。)收集耗費了額外的週期,但減少了堆塊的內部碎片。 單一全局鎖保護堆,防止多線程式的使用。(請參見“server performance and scalability killers”中的第一個注意事項, george reilly 所著,在 “msdn online web workshop”上(站點:http://msdn.microsoft.com/workshop/server/iis/tencom.asp (英文)。)單一全局鎖本質上是用來保護堆數據結構,防止跨多線程的隨機存取。若堆操作太頻繁,單一全局鎖會對性能有不利的影響。 什麼是常見的堆性能問題? 以下是您使用堆時會遇到的最常見問題: 1. 分配操作造成的速度減慢。光分配就耗費很長時間。 最可能導致運行速度減慢原因是空閒列表沒有塊,所以運行時分配程序代碼會耗費週期尋找較大的空閒塊,或從後端分配程序分配新塊。 2. 釋放操作造成的速度減慢。釋放操作耗費較多週期,主要是啓用了收集操作。收集期間,每個釋放操作“查找”它的相鄰塊,取出它們並構造成較大塊, 然後再把此較大塊插入空閒列表。在查找期間,內存可能會隨機碰到,從而導致高速緩存不能命中,性能降低。 3. 堆競爭造成的速度減慢。當兩個或多個線程同時訪問數據,而且一個線程繼續進行之前必須等待另一個線程完成時就發生競爭。 競爭總是導致麻煩;這也是目前多處理器系統遇到的最大問題。當大量使用內存塊的應用程序或 dll 以多線程方式運行(或運行於多處理器系統上)時 將導致速度減慢。單一鎖定的使用(常用的解決方案)意味着使用堆的所有操作是序列化的。當等待鎖定時序列化會引起線程切換上下文。可以想象交叉 路口閃爍的紅燈處走走停停導致的速度減慢。競爭通常會導致線程和進程的上下文切換。上下文切換的開銷是很大的,但開銷更大的是數據從處理器高速 緩存中丟失,以及後來線程復活時的數據重建。 4.堆破壞造成的速度減慢。造成堆破壞的原因是應用程序對堆塊的不正確使用。通常情形包括釋放已釋放的堆塊或使用已釋放的堆塊,以及塊的越界重寫 等明顯問題。(破壞不在本文討論範圍之內。有關內存重寫和泄漏等其他細節,請參見 microsoft visual c++(r) 調試文檔 。) 5. 頻繁的分配和重分配造成的速度減慢。這是使用腳本語言時非常普遍的現象。如字符串被反覆分配,隨重分配增長和釋放。不要這樣做,如果可能, 儘量分配大字符串和使用緩衝區。另一種方法就是儘量少用連接操作。 競爭是在分配和釋放操作中導致速度減慢的問題。理想情況下,希望使用沒有競爭和快速分配/釋放的堆。 可惜,現在還沒有這樣的通用堆,也許將來會有。 在所有的服務器系統中(如 iis、msproxy、databasestacks、網絡服務器、 exchange 和其他), 堆鎖定實在是個大瓶頸。 處理器數越多,競爭就越會惡化。(我:這也許是多核處理器的一個瓶頸問題) 儘量減少堆的使用 現在您明白使用堆時存在的問題了,難道您不想擁有能解決這些問題的超級魔棒嗎?我可希望有。但沒有魔法能使堆運行加快—因此不要期望在產品 出貨之前的最後一星期能夠大爲改觀。如果提前規劃堆策略,情況將會大大好轉。調整使用堆的方法,減少對堆的操作是提高性能的良方。 如何減少使用堆操作?通過利用數據結構內的位置(注意!!)可減少堆操作的次數。請考慮下列實例: struct objecta { // objecta 的數據 } struct objectb { // objectb 的數據 } // 同時使用 objecta 和 objectb // // 使用指針 // struct objectb { struct objecta * pobja; // objectb 的數據 } // // 使用嵌入 // struct objectb { struct objecta pobja; // objectb 的數據 } // // 集合 – 在另一對象內使用 objecta 和 objectb // struct objectx { struct objecta obja; struct objectb objb; } 避免使用指針關聯兩個數據結構。如果使用指針關聯兩個數據結構,前面實例中的對象 a 和 b 將被分別分配和釋放。 這會增加額外開銷—我們要避免這種做法。 把帶指針的子對象嵌入父對象。當對象中有指針時,則意味着對象中有動態元素(百分之八十)和沒有引用的新位置。嵌入增加了位置從而減少了 進一步分配/釋放的需求。這將提高應用程序的性能。 合併小對象形成大對象(聚合)。聚合減少分配和釋放的塊的數量。如果有幾個開發者,各自開發設計的不同部分,則最終會有許多小對象需要合併。 集成的挑戰就是要找到正確的聚合邊界。 內存緩衝區能夠滿足百分之八十的需要(aka 80-20 規則)。個別情況下,需要內存緩衝區來保存字符串/二進制數據,但事先不知道總字節數。 估計並內聯一個大小能滿足百分之八十需要的緩衝區。對剩餘的百分之二十,可以分配一個新的緩衝區和指向這個緩衝區的指針。 這樣,就減少分配和釋放調用並增加數據的位置空間,從根本上提高代碼的性能。 在塊中分配對象(塊化)。塊化是以組的方式一次分配多個對象的方法。如果對列表的項連續跟蹤,例如對一個 {名稱,值} 對的列表, 有兩種選擇:選擇一是爲每一個“名稱-值”對分配一個節點;選擇二是分配一個能容納(如五個)“名稱-值”對的結構。 例如,一般情況下,如果存儲四對,就可減少節點的數量,如果需要額外的空間數量,則使用附加的鏈表指針。 塊化是友好的處理器高速緩存,特別是對於高速緩存,因爲它提供了增加的位置 —不用說對於塊分配,很多數據塊會在同一個虛擬頁中。 正確使用 _amblksiz。c 運行時 (crt) 有它的自定義前端分配程序,該分配程序從後端(win32 堆)分配大小爲 _amblksiz 的塊。 將 _amblksiz 設置爲較高的值能潛在地減少對後端的調用次數。這隻對廣泛使用 crt 的程序適用。
發佈了28 篇原創文章 · 獲贊 1 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章