如果我來設計 C++ 的 內存堆 , 我會這樣設計 :
進程 首先會跟 操作系統 要 一塊大內存區域 , 我稱之為 Division , 簡稱 div 。
然后 , 將這塊 div 作為 堆 , 就可以開始 從堆里分配 內存 了 。
堆里 未分配 可使用 的 內存區域 稱之為 Free Space , 一開始的時候 , div 里 只有一個 Free Space , 就是 整個 div 。
如果 只分配 不回收 的話 , div 里 永遠都只有一個 Free Space 。 隨着 分配 和 回收 , div 里會產生多個 Free Space 。
我們需要建立一張 堆表 來 記錄 Free Space , 這樣才能知道 每一次分配 應該 到 哪個 Free Space 里 分配 。
堆表 應該是一個 鏈表 , 便於 插入 和 刪除 表項 。 表項 就是 Free Space , 或者說 表項 描述 Free Space 。 所以 表項 會包含 2 個 字段 , 一個是 Free Space 的 起始地址 , 另一個是 Free Space 的 結束地址 。
同時 還應該有一個 指針 , 指向 當前在用的 表項 , 一次分配 就是 在 當前表項 指向的 Free Space 里分配 , 如果 當前 Free Space 的 大小 不足以分配本次申請的 內存塊大小 , 則 將指針 指向 當前 Free Space 的 下一個 Free Space 。 如果 下一個 Free Space 的 大小也不夠 , 那么 就繼續指向 下一個 Free Space 。 如此循環 。
那如果 最后一個 Free Space 的大小也不夠的話 , 就需要向 操作系統 要 一個 新的 div 。 注意 , Free Space 只能屬於一個 div , 不能跨 div 。
如果 堆里的 Free Space 比較多 , 那么 如果 Free Space 大小不夠 , 有可能會連續找多個 Free Space 才找到 足夠大小的 Free Space , 這里就產生了一個 性能問題 。
最壞的情況 , “從頭找到尾” , 到最后一個 Free Space 才足夠大小 。 但 , 這還不是最壞的 ^^ , 如果最后一個 Free Space 的大小也不夠的話 , 就要跟操作系統要一個 新的 div , 這好像要 “更壞” 一點 。 ^^
還有一個重要的問題需要考慮 , 就是 如果 跟操作系統要了 1 個以上的 div , 如果長期占用 , 這是一個不小的空間 。 那么 , 要怎樣在 div 中的內存全部都已經回收 (整個 div 是一個 Free Space) 的時候 , 將 div 歸還操作系統呢 ?
可以通過一個 計數器 。 可以為每個 div 設置一個 計數器 , 同時在 堆表項 里增加一個 字段 : Free Space 所在的 div 。
這樣 , 每次 分配 的時候 就在 計數器 里 加 1 , 每次 回收 就讓 計數器 減 1 , 如果 減 1 以后 計數器 的 值 是 0 , 那么就說明 div 已經全部回收 , 可以將 div 歸還 操作系統 。
最后 , 我很好奇 , C++ 是怎么解決 內存碎片 的問題的 。 哈哈哈哈
突然發現 堆 的 管理算法 有點 小復雜 , 如果 堆表 本身占用的內存空間是 固定 的 , 那么如果 Free Space 的數量超出了 對表 的空間所能存儲的數量 , 這就有問題 , 如果舍棄一些 比較小的 Free Space , 會造成 內存泄露 。
如果 堆表 的存儲空間也是通過 堆 的方式來分配 , 那么 , 當應用程序申請了一塊內存 , 此時產生了一個 新的 Free Space , 為了記錄這個 Free Space , 需要為描述這個 Free Space 的 堆表項 也 申請一塊內存 , 這樣 Free Space 又會發生變化 , 可能產生 1 個新的 Free Space, 或者 要記錄的這個 Free Space 發生變化 , 需要把這些情況也考慮進去 。
還有一種情況是 歸還 內存塊 的時候 , 這個內存塊剛好在 2 個 Free Space 中間 , 那么歸還這個內存塊就不是簡單的在 堆表 里添加一個 堆表項 , 而是要和 前后 2 個 FreeSpace “合並” 起來 。 這 3 個 Free Space 會 合並成 1 個 Free Space , 在 堆表 里 會 刪除 原來的 2 個 Free Space 表項 , 同時在 這 2 個 表項 的位置 添加入 合並后的 新表項 。
問題是 , 要怎么知道 歸還的內存塊 在 某 2 個 Free Space 中間 ? 好像只能 遍歷 。 但這意味着 每次 歸還的時候都要 遍歷 。
然后 。
實際上 , 不僅僅 內存塊 在 2 個 Free Space 之間會存在這個問題 , 只要 歸還的內存塊 的 任一邊(前 或 后) 和 1 個 Free Space 相連 , 都需要 “合並” 。
如果要快速的找到 和 自己鄰近的 Free Space , 可能需要建立 索引 。 可以建立 不止一個 的 索引 。
比如 可以 按 起始位置 建立索引 , 同時還可以按 Free Space 的大小 建立索引 。 前者可以快速的尋找 和當前 歸還的內存塊 相鄰的 Free Space 。 后者可以快速的尋找接近指定大小的 Free Space , 這可以用在 分配 的 時候 , 尋找接近 申請內存塊大小 的 Free Space 進行分配 有利於提高 內存利用率 , 減少碎片 。
索引 也可以排序 , 如果要 優先從 小的 Free Space 或者 大的 Free Space 來 分配 的話 , 索引的 排序作用 也可以派上用場 。
關於索引 , 我在 《我發起了一個 .Net 開源數據庫項目 SqlNet》 https://www.cnblogs.com/KSongKing/p/9501739.html 中有一些論述 。 實際上 , 我正是考慮 數據庫 中 Data Block 的 Free Space 如何管理 , 所以才繼續思考 內存堆 的 管理問題 , 然后就產生了上面的一些思考結果 。
可以設想一下具體的做法 :
如果不考慮 堆 的 無限增長 的話 , 設計起來並不太難 :) 所謂 無限增長 , 主要是指 堆表 的無限增長 。 堆表 為什么會無限增長呢 ? 堆表 是保存 Free Space 的 , 如果 Free Space 無限增長 , 那么 堆表 就會無限增長 。 Free Space 的數量是不確定的 , 但理論上 , 似乎不能給出一個限制 。 如果我們給定 堆表 的長度是 1萬 , 那么就只能記錄 1萬 個 Free Space , 超出 1萬 個的 Free Space 會因為不能記錄而處於 “遺棄” 的狀態 , 既不能 分配 也不能回收 。 這就造成了 內存泄漏 。
如果在 堆表 達到上限的時候 拋出 異常 “堆表超出最大范圍” , 就像 StackOverflow 或者 OutOfMemory , 但這可能會限制了應用程序的能力 。
如果按照上文的說法 , 堆表 的 存儲 本身也完全通過 堆分配 進行 , 這樣可以很靈活 , 看起來只要內存空間足夠 , 那么 , 堆表 可以無限增長 。
但這種做法 是 “自己描述自己” 的一個 循環 , 會導致算法復雜 , 循環 , 或者 無解 。 所以我們放棄了這種方式 。
問題出在哪里呢 ? 堆表項 自身對於 內存空間的 占用不能 計算到 堆 的分配里 。 堆表應該是單獨占用一塊空間 , 堆表項 及 索引項 的 添加刪除 在這個空間也會造成 空閑空間 (Free Space) , 但這些 Free Space 不能 計算到 堆 里 , 而應該是 獨立 於 堆 的 存在 。 否則就會陷入上述的 “自己描述自己” 的 循環 。 總之情況很復雜 , 可能無解 。 當然也許有解 , 但我不想繼續思考下去了 :)
所以 , 回到開始 , 如果不考慮 堆 的 無限增長 的話 , 就是說 給定一個 堆表 的 固定大小 , 我們這樣來設計 堆 試試看 。 經過上面的論述 , 實際上 , 如果要設計 無限增長的 堆表 , 那么 , 在 固定大小 的 堆表 基礎上 , 增加一點 : 當 當前堆表 空間不夠時 , 再申請一塊 堆表空間 用於 繼續存放 堆表 , 這樣 堆表 就能繼續增長了 。
我們提供一塊 連續的 內存空間 來 存儲 堆表 , 這塊 內存空間 我們 稱之為 堆表空間 。 按照上面說的 , 我們先嘗試實現 一個固定大小 的 堆表空間 的 堆 。
堆表 的內容 包括 Free Space 項 和 索引 。 索引 由 索引項 組成 , 索引項 最終會指向 堆表項 , Free Space 項 之間通過 鏈表 的方式 相連 。 Free Space 項 和 索引項 都 存儲在 堆表空間 里 。
堆表 還 包括一個 指針 , 指向 堆表 的最后一個元素的結束地址的下一個地址 , 我們將這個指針 稱為 “Append 指針” 。
所有 新建 的 堆表項(Free Space 項 和 索引項) 都 添加至 Append 指針 指示的 地址 , 每添加完一個 堆表項 , Append 指針 會指向這個 堆表項 的 結束地址 的 下一個地址 。 當 Append 指針指向的 地址 到 堆表 的結束地址 之間的空間 不夠 存放新的 堆表項 時 , 會檢查 “堆表空閑空間計數器” , —— 等 —— 什么是 “堆表空閑空間計數器” ? 在 堆表 的使用過程中 , 隨着 Free Space 項 和 索引項 的 添加 刪除 , 當然也會出現 “空閑空間” , 我們會用一個 整數變量 , 來記錄空閑空間有多少(以 Byte 為單位) , 每次刪除 堆表項 (Free Space 項 和 索引項) 的時候 , 會 將 回收 的 空閑空間 累計 到 這個 整數變量 里 。 這個變量 就是 “堆表空閑空間計數器” 。 注意 , “堆表空閑空間計數器” 記錄的是 Append 指針指向的地址之前 “已使用的空間” 中 因 堆表項 的 刪除 而 “空出來” 的 空閑空間 。 這些 空閑空間 平時不會去動它 , 只有上面說的 “當 Append 指針指向的 地址 到 堆表 的結束地址 之間的空間 不夠 存放新的 堆表項 時” , 才會去關心 它 。 怎么關心呢 ? 這個時候 , 會做一次 “垃圾回收” , 就是把 這些 空閑空間 后面 的數據 向前移動 , 填補這些 空閑空間 , 就可以了 。 當然 , 會先檢查 “堆表空閑空間計數器” , 如果 計數器 值為 0 , 表明沒有空閑空間 , 不需要 垃圾回收 , 大於 0 表示 有空閑空間 , 需要 垃圾回收 。 如果沒有要 回收的 空閑空間 , 或者 回收了 空閑空間 以后 Append 指針指向的 地址 到 堆表 的結束地址 之間的空間 仍然不夠 存放新的 堆表項 , 怎么辦呢 ? 對於 固定大小的 堆表 , 則 拋出異常 “堆表超出最大范圍” , 就像 StackOverflow 或者 OutOfMemory 。 對於 可以無限增長的 堆表 , 則 新申請一塊 堆表 空間 , 繼續工作 。 新的 堆表空間 和 原來的 堆表 空間之間 通過 鏈表 的 方式 相連 。
一個 堆表空間 包括 3 個部分 組成 :
1 一塊連續的內存空間
2 Append 指針
3 堆表空閑空間計數器
要 申請新的 堆表空間 , 需要提前進行 , 不要等到 空間不夠用 的時候再進行 。 這是因為 新的 堆表空間 的申請 同樣也是 通過 堆 的方式進行 , 同樣需要在 堆表 里 記錄 堆表項 (Free Space 項 和 索引項)。 當某一次 申請 或 回收 需要記錄 堆表項(Free Space 項 和 索引項) 而 空間不夠時 再去 申請 新的堆表空間 , 則 本次應用程序的申請或者回收 所產生 的 堆表項 (Free Space 項 和 索引項) 和 申請 新的 堆表空間 所產生 的 堆表項 (Free Space 項 和 索引項) 要放在一起計算 和 存儲 , 這樣情況很復雜 。
所以 , 應用程序的申請和回收 內存塊 , 和 申請 新的 堆表空間 , 應該是 2 次 獨立操作 。 所以需要 提前進行 “未雨綢繆” 。 提前到什么程度呢 ? 在 原來的 堆表空間 的剩余空間 還 足夠 存儲 一次 申請內存塊 產生的 可能的 最大數量的 堆表項 (Free Space 項 和 索引項) 的時候 。
申請一次 內存塊 可能產生多少 堆表項 (Free Space 項 和 索引項) ? Free Space 項容易理解 , 上文也分析過 。 那么會產生多少 索引項 ?
上文中提到可以 創建 2 個索引 : 1 Free Space 起始地址 作為檢索條件 的索引 , 2 Free Space Size(空間大小) 作為檢索條件 的 索引 。
索引 1 可以用做 回收時 查詢 和 回收的內存塊 相鄰的 Free Space , 如果 2 者是 相接 的 , 則會進行 合並 。
索引 2 可以用做 分配時 查找 Size(空間大小) 最接近 申請內存塊大小 的 Free Space 。
但實際上 , 索引 的 創建 也是 比較消耗時間的 , 分配 可以采用前文最早提出的 先在 當前 Free Space 中分配 , 若當前 Free Space 的空間大小不足以分配 , 則 查找下一個 Free Space 分配 , 以此遞推 。 在 內存空間 充裕的條件下 , 這種方式比查找 索引 快 , 同時避免了 創建索引 消耗的時間 。
我們接下來就來 分析 索引的 創建 和 查詢 :
根據上述 , 我們只會建立和使用 索引 1 , 用於 回收 時 合並 相接 的 Free Space 。
索引 1 在 分配時 創建(更新) , 在 回收時 查詢 並 更新 。
索引 1 的 索引項 是 這樣 : 最高位字節 用來保存 索引項的值 , 只會用到 低位 的 2 位 ,表示 4 種情況 : 00 , 01 , 10 , 11 。 后面再跟 4 個字節 或 8 個字節 表示 指向的 子索引項 或者 Free Space 項 的 地址 。 如果是 32 位 或 “Any CPU” 應用程序 , 則是 4 個字節 , 如果是 64 位 應用程序 , 則是 8 個字節 。
在 分配 時 , 用於 分配的 Free Space 的 大小(Size) 和 起始地址 會發生變化 。 對於 索引 1 , 只需根據 起始地址 來 更新索引 即可 。
Free Space 的 起始地址 字段 表示 空閑空間 的 起始地址 。 同上 , 如果是 32 位 或 “Any CPU” 應用程序 , 則是 4 個字節 , 如果是 64 位 應用程序 , 則是 8 個字節 。 根據 《我發起了一個 .Net 開源數據庫項目 SqlNet》 https://www.cnblogs.com/KSongKing/p/9501739.html 文中對於 索引 的 論述 , 對於 32 位的數據 , 會建立 32 / 2 = 16 個索引項 -_- , 對於 64 位的數據 , 會建立 64 / 2 = 32 個索引項 -_- 。
所以 , 對於 32 位 或 “Any CPU” 應用程序 , 分配時 Free Space 起始地址 發生變化 需要修改 索引 最多需要 約 16 個索引項 , 或者說 時間花費是 O(16) 。 因為 檢索 1 個 索引項 需要 判斷 4 種情況 : 00 , 01 , 10 , 11 。 所以我們可以假設 1 次操作的時間是 4ns (4 納秒) , 那么 O(16) 的時間就是 16 * 4 = 64 ns (64 納秒) 。 而 回收 需要查找索引找到 和 回收的內存塊 相鄰的 Free Space , 同時 回收后 可能更新相鄰 Free Space 的 起始地址(合並) , 或者 產生一個 新的 Free Space , 對於前者 , 需要修改索引 , 對於后者 , 需要創建索引 , 但不管是哪種 , 最多需要檢索(修改)的 索引項 約 16 個 , 可以認為 時間花費 是 O(16) , 而 回收 時查找索引尋找相鄰 Free Space 的 時間花費 也可以認為是 O(16) , 所以 加起來就是 回收 的 時間花費 是 O(16) + O(16) = O(32) , 同上 , 假設 1 次操縱的時間是 4ns , 則 回收 的時間花費是 32 * 4 = 128 ns (128 納秒) 。 當然 分配 和 回收 具體花費的時間還會 包括 修改 Free Space 起始地址 , Next 指針 , 合並時 刪除 多余的 Free Space 項 等 , 這些先忽略不計 , 在下面估算的時候會酌情估算進去 。
一次 分配 的時間是 64ns , 再加上 分配 時 可能發生的一些遍歷 (在 當前 Free Space 的大小不夠時 , 訪問下一個 Free Space 嘗試分配 , 以此遞推) , 就按 80ns 算 , 1 秒鍾 大概可以進行 1200萬次 分配 。 如何 ? 還行吧 , 呵呵 。 不過比起我想象中的 new , 還是 慢了一點 , 我想象中的 new 應該是 1ns new 一個嘛 ! P: new 就是 分配 。
一次 回收 的時間是 128ns , 就按 150ns 算 , 1 秒鍾 大概可以進行 600萬次 回收 。 能不能再快一點 ? ^^
對於 64 位 應用程序 , 時間花費 是 32 位 的 2 倍 , 所以 1 秒鍾 可以分配 600萬次 , 回收 300萬次 。 如何 ? 哎 ? 為什么 64 位 反而慢了 ?
上面的 分配 和 回收 的 執行速度 是 針對 1 個 CPU 核 分析的 , 但對於多核 , 分配 和 回收 的 執行速度 也是 如此 。 因為 堆 是進程內所有 線程 共享的 , 堆表 也是共享的 , 在進行 分配 和 回收 時要修改 堆表 , 此時需要對 堆表 進行 同步/互斥 (Lock) , 所以 , 對於多核 , 分配 和 回收 的 執行速度 也是 如此 。
從這里可以看出 , 堆 的這一特性會成為 瓶頸 。 在 高頻 高密度 計算的 場合 。 比如 高並發 實時 響應式 系統 。 說的直接一點 , 就是跟現在的 互聯網 大規模 計算 有關 。
這一類型的 瓶頸 也表現在 其它方面 。 比如 套接字(Socket) , Socket 對於每個網卡只會有一個 線程 負責從 網卡 讀寫數據 。 這是我的 推測 。 一個 端口(Port) 的 Socket 由一組線程組成 : 1 負責從網卡讀寫數據的線程(1 個網卡 對應 1 個線程) , 2 處理和分發數據給應用程序的線程們(有若干個線程 , 線程數 和 CPU 的 核數對應 , 可以包括 虛擬線程(超線程) 數) 。 在 線程 1 和 線程 2 們 協作 的時候 , 會有一個共享數據區 , 線程 1 會把從 網卡 讀取到的 數據 放到 共享數據區 , 線程 2 們 會從 共享數據區 取出數據處理分發 。 顯然 , 線程 1 和 線程 2 們 的協作需要 同步/互斥(Lock) ,
我們可以看一下這篇文章《面向對象編程的弊端是什么?》 https://www.zhihu.com/question/20275578/answer/136886316?utm_source=com.tencent.tim&utm_medium=social&utm_oi=697587017629851648
文中有一幅圖 :
如圖 紅線 所示 , Mutex(同步 / 互斥 Lock) 的時間是 17ns (17 納秒) 。 這個時間是一個 不太能忽視 的 時間 。
所以 , 這會成為 利用 並行計算 大幅提升計算能力的 瓶頸 。 而 利用 並行計算 大幅提升計算能力 正是 當下和未來 的 主題 。
另外就是 , 一個網卡只有一個 IO 線程 , 這也可能成為 瓶頸 。 當網絡技術發展到 5G 或 6G 的時候 , 會不會有 NPU(Net Process Unit)出現 ? 就像 GPU 一樣 。 ^^
實際上 , 對於 堆表 的無限增長 , 有一個 “終極” 的解決辦法 , 或者說 更好的辦法 。 就是 GC (垃圾回收器) 。
在 現代 , 或者說 “當代” 的 語言 , 如 C# , Java 里都有 GC 。 GC 可以將 Free Space 的 數量 控制在 有限 和 很少 的 范圍 。 這樣就不存在 堆表 的 無限增長 了。
然后 。
當然 , GC 要登記 所有變量 , 並定期遍歷 , 移動數據 , 這些也要花費時間的 。
堆表 的 無限增長 , 這是一個問題 。 堆表 增長 , 表示 Free Space 增多 , 碎片 也增多 , 這樣 在 分配 時可能會遍歷 比較多的 Free Space 。
對於 64 位 應用程序 , 64 位 理論上的 尋址空間 可以達到 16eb , 如果 應用程序 對於 存儲空間 的使用是沒有限制的 , 那么 , 一段時間之后 , 堆表 , 或者說 Free Space (包括碎片) 的 數量 可能會達到 很大的 數量 。
假想一下 , 如果 Free Space 很多 , 碎片也很多 , 那么可能要遍歷 很多次 才能找到 大小足夠的 Free Space 進行分配 。 這個時候 , 我們可以考慮加入這樣的算法 , 最多遍歷 10 個 Free Space , 遍歷了 10 個 Free Space 還找不到大小足夠的 Free Space , 則 向操作系統 申請 1 個 新的 div , 並將 div 作為 新的 Free Space 插入到當前位置 , 並從這個 div(新的 Free Space) 中分配 。 分配以后 , 下一次分配當然也會從這個 div 開始 , 如果這個 div 的 剩余空間 不夠 , 則 訪問下一個 Free Space 。 如果訪問了 10 個 Free Space 也找不到足夠大小的 Free Space , 則 重復上述流程 , 向操作系統 申請 1 個 新的 div , 並將 div 作為 新的 Free Space 插入到當前位置 , 並從這個 div(新的 Free Space) 中分配 。 以此遞推 。
這種方式 , 可能會浪費一些空間 , 或者說 , 會向 操作系統 申請多一些的 空間(div) , 但是在 時間 上提高了效率 。 這也算是 “空間換時間” 吧 。 在 現在來講 , 硬件容易擴充 , 提升計算速度 是一個主要目標 。
根據以上 , 我們再來整理一下 具體的 做法 。
我們 以 64位 應用程序 的 標准 來實現 :
當進程啟動時 , 會分配一塊 固定大小 的 連續空間 ,作為 堆 的 基礎元數據區 , 基礎元數據區 包括 5 部分 :
1 Append 指針 , 指向 堆表 可插入 堆表項 的 地址 (當前 最后一個 堆表項 之后) , 插入 堆表項 后 , Append 指針 會 指向 堆表項 結束地址 的 下一個地址 。 Append 指針 的 初始值 應指向 第 5 個 堆表項 的 起始位置 。 因為會在 堆表 中 預先建立 4 個 1 級 索引項 , 見 下面 第 4 部分 。
2 堆表 的 Free Space 項 鏈表 頭指針 , 指向 Free Space 項 鏈表 的 頭 。 (Free Space 項 之間通過 鏈表 的方式連接起來)
3 當前 Free Space 項 指針 , 指向上一次用於 分配 的 Free Space 項 。 下一次 分配 會先嘗試在 上一次 分配 的 Free Space 中進行 , 若 Free Space 的 大小不夠 , 會 訪問 下一個 Free Space 嘗試分配 。 分配 成功后 , 當前 Free Space 項 指針 會指向 分配 成功的 Free Space 項 。 當然這里面還有些具體的邏輯 , 比如 訪問 超過 10 個 Free Space 項 仍然找不到 大小足夠 的 Free Space , 則 會向操作系統 申請 新的 div , 作為 Free Space 加入進來 , 然后在這個新的 div 中 分配 。
4 堆表 的 初始空間 。 堆表 的 初始空間 可以是 1 MB 。 進程啟動 時 , 會初始化 基礎元數據區 , 此時應在 堆表 的 第 1 ~ 4 個 堆表項 位置 預先建立 1 級 索引項 (00 , 01 , 01 , 11) 。 所謂 初始空間 是指這部分是 固定不變 的 , 之后 堆表空間 不夠用時 , 會在 堆 中申請新的 堆表 空間 。 這些新申請的 堆表空間 空出來的時候會 歸還 堆 , 但 初始空間 是 不變的 , 不變是指 一直存在 , 大小不變 。 且 初始空間 不屬於 堆 。
5 Next 指針 , 指向 下一個 堆表 空間 。 隨着 堆 的規模的增長 , 堆表 大小不夠時 , 會從 堆 里 申請 新的 堆表 空間 , 新的 堆表空間 會和 初始空間 用 鏈表 的方式連接起來 , 可以 申請 多個 堆表空間 , 如 : 初始空間 -> 第 1 個新申請空間 -> 第 2 個新申請空間 -> 第 3 個新申請空間 -> …… 第 n 個新申請空間 -> ……
當 堆 的規模縮小時 , 會釋放 空閑 的 堆表空間 (歸還 堆) 。
初始空間 不屬於 堆 , 當然永遠不會釋放 。
接下來 , 我們這樣來定義堆表項 :
堆表項 分為 2 種 :
1 索引項
2 Free Space 項
具體規則是 :
1) 索引項 和 Free Space 項 都占用 34 個字節 。 第 1 個字節 是 標識字節 , 為 1 表示 索引項 , 為 2 表示 Free Space 項 , 為 0 表示 已刪除 。
2) 對於 索引項 , 第 2 個 字節表示 索引值 , 就是 00 , 01 , 10 , 11 這 4 種值中的一種 , 實際上這 4 種值只用到了 2 位 , 不過我們還是用一個字節來存儲 。 如果是 十進制 表示這 4 個值 , 就是 0 , 1 , 2 , 3 。 我們設計的是 4 階索引 , 第 3 ~ 10 個字節存儲 第 1 個 子索引項 或 Free Space 項 的 地址 (64 位地址 用 8 個字節存儲), 第 11 ~ 18 個字節存儲 第 2 個 子索引項 的 地址 , 第 19 ~ 26 個字節存儲 第 3 個 子索引項 的 地址 , 第 27 ~ 34 個 字節存儲 第 4 個 子索引項 的 地址 。 若 8 個字節表示的 64 位地址 (ulong 無符號長整型 uInt64) 為 0 , 表示 子項 不存在 。 有關 索引 和 4 階索引 , 我在 《我發起了一個 .Net 開源數據庫項目 SqlNet》 https://www.cnblogs.com/KSongKing/p/9501739.html 一文中有論述 。
所以 , 可以看出 , 索引項 長度 是 1 + 1 + 8 + 8 + 8 + 8 = 34 個字節 。
3) 對於 Free Space 項 , 第 2 ~ 9 個字節 表示 起始地址 , 第 10 ~ 17 個字節 表示 結束地址 。 第 18 ~ 25 個字節 表示 所在的 div 的起始地址 。 第 26 ~ 33 個字節 表示 Next 指針 指向 下一項 Free Space 項 (Free Space 項 之間會通過 Next 指針來用 鏈表 的方式連接起來) 。 Free Space 項 的 長度 是 1 + 8 + 8 + 8 + 8 = 33 個字節 。
為了便於管理 , Free Space 項的長度也定義為 34 個字節 , 和 索引項 一樣 。 多出來的 1 個字節 不會用到 。
將 索引項 和 Free Space 項都定義為 34 位 是 便於管理 , 或者說 便於算法處理 。 堆表 進行垃圾回收的時候 , 只需要每隔 34 個字節檢查一次 標識字節 , 就可以知道 堆表項 是否已刪除 , 若 已刪除 則將后面的 堆表項 移動上來 , 填補 已刪除 的 空閑空間 。 這就是 堆表 的 垃圾回收 。
div , 接下來說明 div 的定義規則 。 div 是 進程向 操作系統 申請 的一塊 大的 內存區域 , 用於作為 堆空間 。
第 1 次 分配 內存塊 時 會申請 第 1 塊 div 。 如果從來沒有 申請 過 內存塊 , 則不會申請 div 。
div 分為 3 個部分 :
1 結束地址 , div 的 結束地址 , 用 8 個字節表示 (ulong 無符號長整型 uInt64)
2 分配計數器 useCount , 用於記錄 分配 的內存塊 數量 , 若 計數器 的值為 0 , 表示 div 完全空閑 , 即沒有 分配 任何空間 , 可以 歸還 操作系統 。 當然 剛申請到 div 的時候 , 計數器 的值也是 0 , 不過那時會接着用於 分配 。 計數器 也用 8 個字節表示 (ulong 無符號長整型 uInt64)
3 剩余的空間 用於 分配 。
接下來說明 運行邏輯 :
我們先 估算一下 , 1 MB 的 堆表 空間 夠存放多少個 Free Space 項 (包含 索引項) ?
Free Space 項 的 地址是 64 位地址 , 要為 64 位地址 建立 索引 , 需要 64 / 2 = 32 個 索引項 。 每個 索引項 占據的空間是 34 個字節 , 再加上 Free Space 項 占據 的 34 個 字節 , 1 個 Free Space 需要的 存儲空間 是 (32 + 1) * 34 = 1122 個字節 。 實際中會比 1122 小 , 因為 索引 的 父節點 存在共用的現象 。 我們可以按 1024 來算 , 存儲一個 Free Space 需要 1024 個字節(包含 索引項) , 那么 1 MB 可以 存儲 1024 個 Free Space(包含 索引項) 。
所以 , 1 MB 的 堆表 可以記錄 1024 個 Free Space , 如果 應用程序 申請 和 歸還 內存塊 產生的 Free Space 不超過 1024 個的話 , 1 MB 的 堆表就夠了 。 如果超過 , 則需要 申請 新的 堆表 空間 。 新的 堆表 空間 在 堆 中申請 。 可以仍然申請 1 MB 。 如果 新申請 的 1 MB 堆表空間 用完了 , 可以繼續申請 1 MB , 以此遞推 。 當然 , 實際中 不會等到 堆表空間 不夠用時才去申請新的 堆表空間 , 上文分析過 , 如果這樣的話 , 會陷入 “自己描述自己” 的 循環中 , 所以 , 應該在 快用完(至少還足夠保存一次申請產生的 最大的 Free Space 變化 ( 包含 索引項 ) ) 的 堆表 空間 時 就申請 新的 堆表空間 。
當 應用程序 第 1 次 申請 內存塊 時 , 堆管理程序 會 檢查 基礎元數據區 的 第 1 個 div 的 起始地址 , 若 為 0 (div 不存在) , 就向 操縱系統 申請 div , 申請到后將 div 的 起始地址 記錄到 基礎元數據區 的 “第 1 個 div 的 起始地址” 。
然后 , 將 div 的 第 3 部分 (用於 分配 的空間) 作為 1 個 Free Space 記錄入 堆表 (這是 第 1 個 Free Space) 。 當然 , 記錄的操作 包括 了 建立 索引 。 注意 , 1 級索引項 (00 , 01 , 10 , 11) 固定存儲在 堆表 的 第 1 ~ 4 個 堆表項 位置 。 應用程序啟動 , 初始化 基礎元數據區 時應預先建好這 4 個 索引項 。
接下來 , 就開始在 堆表 中訪問 Free Space 進行分配 , 當然 現在只有 1 個 Free Space , 就是上面剛添加進去的 Free Space 。 分配的話 , 就從 Free Space 的 起始地址 開始分配 。 比如 , 要 申請 1 KB 的 內存塊 , 那么就把 Free Space 起始地址 ~ Free Space 起始地址 + 1 K - 1 這塊內存 分配 給 應用程序 。 如果 申請的 內存塊大小 比 這個 第 1 個 Free Space 都大 , 那么應該拋出異常 “只允許申請大小在 xx 范圍內的內存塊” 。
分配 的 具體工作 : 修改當前 Free Space 的 起始地址 , 修改為 Free Space 起始地址 + 1 K , 同時 修改索引 , 根據 Free Space 原來的 起始地址 遍歷 索引項 , 遍歷到 和 新的 起始地址 不同 的 索引項 就修改 索引項 。 這么說好像不知道在說什么 。好吧 , 我們舉個具體的例子 :
我們的設計是 64 位地址 , 舉例的話 就 簡單一點 , 我們 以 8 位地址 為例 , 假設 Free Sapce 的 起始地址 是 0 (0000 0000), 申請 4 個字節大小的內存塊 。
申請前 Free Space 的 索引是這樣的 : 00 -> 00 -> 00 -> 00 , 申請后 Free Sapce 的 起始地址 會變成 4 (0000 0100) , 相應的 , 索引會變成 : 00 -> 00 -> 01 -> 00 , 可以看到 , 從 第 3 個索引項 開始 , 新的索引 和 舊的索引 變得不同 , 所以 我們 從 第 3 個 索引項 開始修改 為 新的索引項 就可以了 。
整個修改索引的過程 會 遍歷 全部的索引項 (包含了 修改) , 64 位地址 是 32 個 索引項 , 所以 分配 的 時間復雜度 約大於 O(32) (還要考慮其它的操作 , 所以是 約大於) , 我們上文中就是這樣估算的 。
其它還有什么操作呢 , 好像沒有了 。 ^^
分配就 2 步操作 : 1 修改 Free Space 起始地址 , 2 修改索引 。
接下來是 歸還 , 歸還 分為 4 種情況 :
1 歸還 的 內存塊 的 前后 不和 已有的 Free Space 相接 , 這樣 歸還 會產生 一個 新的 Free Space 。
2 歸還 的 內存塊 和 前面 或者 后面 已有的 Free Space 相接 , 這樣 需要 和 相接的 Free Space 合並 。
3 歸還 的 內存塊 和 前面 和 后面 已有的 Free Space 相接 , 這樣 需要 和 前后 2 個 Free Space 合並 。
4 歸還 的 內存塊 沒有 相鄰 的 Free Space , 這種情況比較特殊 , 這種情況就是 整個 div 的 內存 完全被 分配 出去的 情況 。
具體 流程 是這樣 :
應用程序 將 內存塊 的 起始地址 提供給 堆 來 歸還 這塊內存塊 。 堆 根據 內存塊 的 起始地址 查找索引 , 查找 和 內存塊 前相鄰 的 Free Space 。 前相鄰 , 是指 相鄰 且 在 前面 。 什么是 前面 ? Free Space 的 起始地址 小於 內存塊 的 起始地址 叫 前面 , 大於 叫 后面 。
根據 索引 查找到 前相鄰 的 Free Space , 還不一定是 真正 的 前相鄰 的 Free Space , 還要加一個 判斷條件 : Free Space 所在的 div 和 內存塊 所在的 div 是 同一個 div , 這樣才是 前相鄰 的 Free Space 。
我們這樣來 定義 前相鄰 后相鄰 :
前相鄰 : 起始地址 小於 內存塊 的 起始地址 , 且 和 內存塊 屬於同一個 div , 則為 前相鄰 。
后相鄰 : 起始地址 大於 內存塊 的 起始地址 , 且 和 內存塊 屬於同一個 div , 則為 前相鄰 。
如果 查找不到 前相鄰 , 那么就根據 基礎元數據區 里的 Free Space 鏈表 頭指針 找到 頭指針 指向 的 Free Space 項 , 這個 Free Space 項 就是 內存塊 的 后相鄰 。
如果 Free Space 鏈表 頭指針 為 空 (0) , 也表示 沒有 相鄰 (既沒有 前相鄰 , 也沒有 后相鄰) 。
什么情況下 Free Space 鏈表 頭指針 為 空 (0) 呢 ? 在 應用程序 初始化 后 , 還沒有 分配 的時候 。 以及 分配 以后 , 整個 div 都被分配出去 。 如果有多個 div , 所有 div 都被完全的分配出去 , 頭指針 也為 空 (0) 。
頭指針 不空 , 可以找到 起始地址 大於 或 小於 內存塊 起始地址 的 Free Space , 但 Free Space 和 內存塊 不在同一個 div 的話 , 也不是 相鄰 。
怎么判斷 Free Space 和 內存塊 在不在 同一個 div ? Free Space 項 有一個字段 是 所在 div 的 起始地址 , div 的 第 1 個 部分 是 div 的 結束地址(見上文對 div 的定義) , 根據 div 的 起始地址 可以找到 div 的 結束地址 , 根據 div 的 起始地址 和 結束地址 可以判斷 內存塊 在不在 div 里 。
找到 前相鄰 后 , 判斷 前相鄰 的 結束地址 + 1 和 內存塊 的 起始地址 是否相等 , 若相等 , 則 兩者應合並 。 但這里還要進一步的判斷 , 是 情況 2 還是 情況 3 , 所以 還需要 根據 前相鄰 的 Next 指針 找到 下一個 Free Space 項 , 這就是 后相鄰 。 判斷 后相鄰 的 起始地址 和 內存塊 的 結束地址 + 1 是否相等 , 若相等 , 表示是 情況 3 , 若不等 , 表示是 情況 2 。
如果 沒有 相鄰的 Free Space , 就是 情況 4 。 如果有 相鄰的 Free Space , 但既不是 情況 2 , 也不是 情況 3 , 就是 情況 1 。
對於 情況 1 , 需要 新建一個 Free Space 項 , 插入到 Free Space 項 鏈表 里 , 插入位置是 內存塊 的 前相鄰 之后 , 或者說 , 后相鄰 之前 。 當然 , 新建 Free Space 項 需要建立 相應 的 索引 。 索引 有 32 個 索引項 , 所以 新建 Free Space 的時間復雜度 約大於 O(32) 。再加上 查找 前相鄰 的時間復雜度 O(32) , 所以 情況 1 的 時間復雜度 約大於 O(32) + O(32) = O(64) , 約大於 O(64) 。 上文就是這樣估算的 。
對於 情況 2 , 如果和 前相鄰 相接 , 就 修改 前相鄰 的 結束地址 和 索引 就可以 , 如果和 后相鄰 相接 , 修改 后相鄰 的 起始地址 和 索引 就可以 , 這個和 分配 的 操作方法 一樣 , 參考上文 分配 的部分 就可以 。
對於 情況 3 , 可以 修改 前相鄰 的 結束地址 和 索引 , 同時 刪除 后相鄰 , 相應的 , 后相鄰 的 索引 也要刪除 。 刪除索引 的 步驟是 : 根據 后相鄰 的 起始地址 遍歷 索引項 , 對於只有 1 個子索引項 的 索引項 刪除 即可 。 只有一個 子索引項 表示 從 當前索引項 開始的 索引路徑 僅僅指向 要刪除的這個 后相鄰 。
對於 情況 4 , 直接按照 內存塊 的 起始地址 結束地址 新建一個 Free Space 項 , 添加到 Free Space 堆表 , 當然會建立相應的 索引 。 同時 , 還要將 Free Space 項 插入 Free Space 項 鏈表 里 。 插入位置 在 —— 根據 索引 查找出 起始地址 小於 自己 的 Free Space 項 , 插入到這一項之后就行 。 注 : 因為不在同一個 div , 所以 不能叫 前相鄰 或者 后相鄰 。 如果 查找不到 起始地址 小於自己的 , 就插入到 頭 , 即 基礎元數據區 里的 Free Space 鏈表 頭指針 指向 自己 , 自己 的 Next 指針 指向 原來 頭指針 指向 的 那一項 。 如果 頭指針 原來是 空 (0) , 那就 讓 頭指針 指向 自己 就可以了 。
Free Space 項 鏈表 不是一個 獨立 的 東西 , 而是 堆表 里的 Free Space 項 之間會通過 Next 指針來用 鏈表 的 方式 連接起來 。 因為只有 Next 指針 , 所以是 單向鏈表 。 現在看起來 , 單向鏈表 夠用了 。 -_- '
每次 申請 和 歸還 后會檢查是否進行 垃圾回收 , 當滿足以下 2 個條件時進行 垃圾回收 :
1 Append 指針 到 堆表 結束地址 的 內存空間 小於 1500 個字節時 ,
2 堆表 的 空閑空間 超過 堆表空間 的 2/3 的時候
每次 垃圾回收 后會檢查是否需要 擴充 堆表, 當滿足以下條件時 擴充 堆表 :
Append 指針 到 堆表 結束地址 的 內存空間 小於 1500 個字節時 ,
擴充 堆表 就是 申請新的 堆表空間 和 初始空間 用 鏈表 的方式 連接起來 , 當然 , 隨着 堆 的規模的擴大 , 可以 申請 第 2 個 、 第 3 個 、第 n 個 …… 堆表空間 , 用 鏈表 的方式連起來就是 : 初始空間 -> 第 1 個新申請空間 -> 第 2 個新申請空間 -> 第 3 個新申請空間 -> …… 第 n 個新申請空間 -> ……
這一點的意義上面已經多次分析過 , 為了避免陷入 “自己描述自己” 的 陷阱 , 所以需要在 堆表 空間 快用完時 , 擴充 堆表 空間 。 堆表 空間最少要能夠存儲一次 分配 (包含 可能 申請 div 的 情況) 所產生的 Free Space 項 (包含 索引項) 。 一般的 分配 只需 修改 Free Space 項 的 起始地址 和 索引 , 當有 申請 div 的 情形 時 , 會新建 Free Space 項 及 完整的 索引 (32 個 索引項) , 這應該是 分配 時 占用空間 最大的情況 , 我們按這種情況來計算 。 上面說過 , 1 個 Free Space (包含 索引項) 會占用 1122 個 字節 , 我們放寬松一點 , 在 堆表 剩余空間 只有 1500 個字節 時 就 擴充 堆表 。
那什么時候 “壓縮” 或者說 釋放 空閑出來的 堆表 空間 呢 ?
在 垃圾整理 后 , 檢查 最后一個 “不空” 的 堆表空間 , 即 最后一個 存儲了至少 1 個 堆表項 的 堆表空間 , 如果 這個 堆表空間 的 空閑空間 超過 堆表空間 的 2/3 , 那么將 釋放 這個 堆表空間 之后 所有的 堆表空間 。 釋放 就是 將 堆表空間 歸還 堆 。 上文說了 , 初始空間 以外 的 堆表空間 都是 從 堆 里申請的 。
初始空間 不屬於 堆 , 顯然 , 永遠不會釋放 。
說到這里 , 顯然 , “堆表” 是一個 可擴充的 , 由若干個 線性表 通過 鏈表 的 方式 連接起來的 數據結構 。
Append 指針 指向的是 最后一個 堆表項 , 這個 堆表項 可能在 初始空間 , 也可能在 新申請 的 第 n 個 堆表空間 。
在 分配 時 , 會從當前 Free Space 項 指針 指向的 Free Space 項 開始 嘗試分配 , 如果 當前項 大小不夠 , 會 訪問 下一個 Free Space 項 , 如果 訪問超過 10 個 Free Space 項 還找不到大小足夠的 Free Space , 則 會向操作系統 申請 新的 div , 作為 Free Space 加入進來 , 然后在這個新的 div (新的 Free Space) 中 分配 。
這主要是從 執行速度 的角度考慮 。 這也算是 “空間換時間” 。
這邏輯真的 亂 , 煩 。
我們可以用 文件 的方式來模擬實現這個 堆管理 算法 。
就是用 一個文件 模擬 一塊內存區域 , 來實現這個 堆算法 。
我們會先實現一個 EnLargableList 的數據結構 , EnLargableList 是一個 線性表 通過 鏈表 的方式連接起來的 可擴充的 數據結構 , 用來實現 堆表 。
堆 的 復雜來自於 堆表 的 動態增長(無限增長) , 如果 堆表 是 固定大小 的 , 那么 堆 並不太難 。
上面有一個地方的邏輯有漏洞 , 向操作系統申請了一個 div 之后 , 除了 將 div 可分配的空間作為一個 Free Space 項 加入 Free Space 項 鏈表 外 , 還應該新建一個 “空的” Free Space 項 加入 。 這個 “空的” Free Space 項 的 起始地址 和 結束地址 都是 div 的 可分配空間 的 起始地址 。 因為 起始地址 和 結束地址 相等 , 所以是 “空的” 。 因為 大小 是 0 , 總是 小於 申請的內存塊的大小 , 所以 , 在 分配 的時候不會分配這個 Free Space 。
這個 空的 Free Space 有什么用呢 ? 這是為了解決 整個 div 都被完全的分配出去的情況 , 上文分析過了 , 整個 div 都被完全的分配出去的話 , Free Space 鏈表 里就沒有這個 div 的 Free Space , 這樣 當 這個 div 里的 內存塊 歸還時 , 會找不到 前相鄰 和 后相鄰 , 從而不知道這個 內存塊 是 哪個 div 的 , 這樣 歸還 的邏輯就有問題 , 就算不管是哪個 div 而直接將內存塊作為 Free Space 歸還 , 最終也會導致即使這個 div 已經全部空閑(所有 分配 出去的 內存塊 都 歸還 了) , 但是無法將 這個 div 歸還 操作系統 。 相當於這個 div 處於 “半遺棄” 的狀態 。因為 它的 Free Space 仍然可以繼續 分配 和 歸還 , 但 這個 div 已經不在 正式名單 上了 , 無法在 全部空閑 時 歸還 操作系統 。 當然 , 實際中 這樣的操作是 不允許的 , 因為 Free Space 項最后一個字段就是 指向 自己所在 div 的 起始地址 , 就是說 Free Space 項 應該 知道 自己所在的 div , 如果不知道 , 程序不能運行下去 。
所以 , 每個 div 一定會有一個 空的 Free Space , 不管 div 的空間如何分配 , 這個 空的 Free Space 會一直存在下去 , 直到 div 歸還操作系統 , 這個 空的 Free Space 才會被刪除 。
因為我們沒有專門的表 來記錄 div , 所以這個 空的 Free Space 相當於 div 的代表 , 或者 占位 。
上面的做法還是有一點問題 。 用一個 “空的” Free Space 來表示 div 會有一些問題 。 實際上 “空的” Free Space 不是空的 , 是大小為 1 個字節 的 空間 。 起始地址 和 結束地址 相等 , Free Space 的大小 = 結束地址 - 起始地址 + 1 = 1 。 所以 , 在 歸還 Free Space 時 , 如果 歸還的 Free Space 和 這個 “空的” Free Space 相接 , 會和 “空的” Free Space 合並 , 這又會引出合並后下次分配時 第 1 個字節 不能分配(作為 “空的” Free Space) 之類的判斷 , 會把算法邏輯變復雜 。
所以 , 我們放棄了這種方式 。 正統的做法應該還是把 div 記錄到 堆表 里 , 也會為 div 建立索引 。 也就是說 , 增加一種 堆表項 : div 項 。 標識字節(第 1 個字節) 為 3 表示 div 項 。 div 項的第 2 ~ 9 個字節存儲 div 的起始地址 。 當然 div 項的長度也是 34 (和 索引項 Free Space 項 相同) , 多余的字節不會用到 。
這樣 , 在 歸還 內存塊 時 , 如果找不到 前相鄰 , 也找不到 后相鄰 , 說明 div 被完全分配出去了 , 此時就會根據索引查找 div , 找到 起始地址 小於 內存塊的起始地址 且相鄰 的 div , 這就是 內存塊 所在的 div 。
歸還 內存塊 后 , div 的 分配計數器 會 減 1 , 減 1 后檢查 計數器值 是否為 0 , 若為 0 則 div 的空間已完全空閑 , 於是將 div 歸還操作系統 。
但這樣的做法還是有問題 , 要為 div 建立索引 , 這有一點額外的麻煩 , 比如 現在的 堆表項 開始的 4 個項位置 存儲的是 4 個 1 級索引項 , 如果要為 div 建立索引 , 需要專門再為 div 建立 4 個 1 級索引項 , 這些會增加算法內容 , 會變得復雜或者麻煩 。
所以 , 我們還是回到用 一個 “空的” Free Space 來表示 div , 或者 占位 的做法 。 在 申請一個新的 div 的時候 , 會創建 2 個 Free Space , 一個是 “空的” Free Space , 另一個是 可用的 Free Space 。 div 的開頭會用 8 + 8 = 16 個字節分別表示 結束地址 和 分配計數器 use Count , “空的” Free Space 就是 第 17 個字節 , 起始地址 和 結束地址 都是 第 17 個字節 , 從第 18 個字節開始就是 可用空間 了 , 可用的 Free Space 就是 第 18 個字節 開始到 div 的 結束地址 。
我們可以給 Free Space 項 增加一個 字節 來表示 Free Space 的 “Type” , 在 標識字節 之后 。 第 1 個字節是 標識字節 , 我們用 第 2 個字節來表示 Free Space Type , 0 表示 “空的” Free Space , 1 表示 普通的 Free Space 。 這樣的話 , Free Space 項 和 索引項 一樣 , 都是 34 個字節了 。
在 分配 和 回收 時 需要判斷 Free Space 時 “空的” Free Space 還是 普通的 Free Space 。 上文中定義過 , 標識字節 為 2 表示 普通的 Free Space 。
在 分配 時 判斷 , 如果 是 “空的” Free Space , 就不進行分配 , 而是 訪問下一個 Free Space 嘗試分配 。
在 回收 時會尋找 前相鄰 , 如果 前相鄰 是 “空的” Free Space , 則不進行 判斷是否相接若相接則合並的邏輯 。
EnLargableList (用於 堆表) 會設定這樣一些參數:
1 whenRecycleFragment , 這是一個 整數 , 表示 碎片數量 超過多少 應開始 碎片回收 , 可以設置為 1萬 , 碎片數量 是 以 對表項 為 單位 。 假設 堆表空間 是 1MB , 每個 堆表項 占用 34 個字節 , 可以存約 3 萬個 堆表項 , 約表示 1024 個 Free Space (每個 Free Space 最多由 33 個 堆表項 表示 , 包含 32 個 索引項 + 1 個 Free Space 項) 。
如果 設置 whenRecycleFragment 為 1 萬 , 相當於是 一個 堆表空間 中有 1/3 的 空閑空間 , 此時回收 。 效果怎么樣 ? 不知道 。
或者說 相當於 一個 堆表 空間中 記錄了 600 個 Free Space 項 , 還有 300 個 Free Space 的位置可以記錄 , 此時回收 。 效果怎么樣 ? 不知道 。
上文中提到 當 Append 指針 到 堆表空間 的 結束位置 的 空間 小於 1500 時 回收 , 但現在放棄了這種做法 。
因為 這種做法 好像不太科學 , 在應對 規模很大 的 堆 時候 , 好像不太適用 。 堆 的 規模很大 , 是指可以無限制的 使用 地址空間 , 內存塊 數量 和 Free Space 數量(包含 碎片) 可能 持續增長 。 大小 1MB 的 堆表 可以存約 3 萬個 堆表項 , 以 堆表項 為 單位 遍歷 一遍 需要 遍歷 3 萬個 堆表項 。 3 萬 是一個不小的數量 , 所以我們想 當 碎片(空閑出來的 項位置) 達到 1 萬 的時候回收 可能會比較好 。
2 whenEnLarge , 這是一個整數 , 表示 append 指針 到 堆表 末尾 的 空間還有多少時 擴充 堆表 容量 , 擴充 堆表 容量 就是 申請新的 堆表空間 , 新申請的 堆表 空間 以 鏈表 的方式連接到 當前 堆表空間 。
3 heapTableSpace : 就是每一個 堆表 空間的 大小 , 可以設為 1MB , 每次申請 新的 堆表空間 就是 申請 heapTableSpace 大小的 一個 內存塊 。
EnLargableList 還會 保存這樣一些 字段 :
1 appendPtr , append 指針 , 存儲一個 64位地址 , EnLargableList 寫入數據時從 append指針 指向的數據開始寫 , 每寫入一段數據 , append 指針會移動到這段數據之后的位置 。
2 currentHeapTableSpace , 當前 堆表空間 , 即 append 指針 指向的 位置 所在的 堆表空間 。 這個字段用來 歸還 堆表空間 。 歸還 是指 , 當 末尾一個 堆表空間 , 即 當前 堆表空間 的 空間 全部 空閑出來時候 , 會將 堆表空間 歸還 堆 。 僅僅憑 append 指針 不能知道 append 指針 所在的 堆表空間 , 所以還需要這個字段來記錄 append 指針 所在的 堆表空間 , 即 當前 堆表空間 。
3 recycleFreeItem , 碎片回收 時 指向 空閑的項位置 , 即 “碎片” , 或者說 “已刪除”的項 。
4 recycleScanItem , 碎片回收 時 會先掃描 “碎片” , 掃描到一個 “碎片” 之后 , 會將 recycleFreeItem 指向這個 “碎片” 的位置 。 然后會掃描 堆表項 , 每掃描一個 堆表項 , 會檢查 堆表項 的 子項 (子索引項 Free Space項) , 若 子項 的 位置 大於 recycleFreeItem 指向的位置 , 則將 子項 移動到 recycleFreeItem 指向的位置 , “填補”這個碎片 , 同時修改 當前掃描的 堆表項 中保存的 該 子項 的位置 。 這樣就完成一個 “碎片” 的 回收 (“填補”) 。
然后就繼續 掃描下一個 “碎片” , 掃描到 “碎片” 后 , 又接着掃描 上一次 掃描的 堆表項 。 怎么知道 上一次掃描的 堆表項 ? 就是 recycleScanItem 指向的堆表項 。 不過這樣看起來 , 還要加一個 字段 , 來表示 掃描到了 堆表項 里的 哪個子項 , 如下 :
5 recycleScanSubItem , 表示 掃描到的 堆表項 的 子項 。 這個字段只要 8 位整數 就可以了 。
6 fragmentCount , 表示 “碎片” 數量 , 每次 刪除 堆表項 時 加 1 , 在 碎片回收 “填補” 碎片 的時候 減 1 , 這個字段用於上文中 如果 fragmentCount 的數量達到 whenRecycleFragment 的值 的 時候 , 就開始 碎片回收 。
7 堆表空間 的 useCount , 這個 字段 是 每個 堆表空間 保存 1 個 , 就是 堆表空間 的 useCount , 就是 堆表空間 使用的計數(以 堆表項 為單位) 。 每寫入 1 個堆表項 , 就在 堆表空間 的 useCount 加 1 , 每刪除 1 個 堆表項 , useCount 就 減 1 。 useCount 為 0 表示 堆表空間
每次 分配 和 回收 之后會 檢查 fragmentCount , 當 fragmentCount 超過 whenRecycleFragment 時 會開始回收 。 由於不希望回收占用太多時間 , 可以設定 一個參數比如 recycleItemCount , 比如 300 , 表示 不管有沒有回收完 , 只 掃描 300 個堆表項 。
但這樣會有一個問題 , 本身要 fragmentCount 超過 whenRecycleFragment 時才開始回收 , 而且每次又不回收完 , 空閑出來的 碎片空間 得不到重復利用 , append 指針 只能 一直向后移動 , 所以可能導致 永遠回收 不完 , 堆表 持續 增長 。
所以 ……
我們這里有了一個 突破 , 即對於 堆表 的 碎片回收 , 我們采用了一個 新的算法 , 就是在 堆表項 里 增加 1 個字段 : fragmentNext 。
就是把 已刪除的堆表項(碎片) 用鏈表的方式 連接 起來 , 這樣每次寫入 堆表項 的時候從 這個 鏈表 的 頭 取出 一個 碎片 , 作為 新的堆表項 的 寫入位置 。 fragmentNext 表示 下一個 碎片 的 位置 , 或者說 , fragmentNext 是一個指針 , 指向下一個 碎片 。
實際上 是一個用 鏈表 實現 的 隊列 。
所以 , 需要在 基礎元數據區 里增加 2 個字段 fragmentListHead , fragmentListTail , 用於保存 碎片鏈表(隊列) 的 頭指針 和 尾指針。
每次 刪除 堆表項 時 , 將 被刪除的 堆表項 的 標識字節 更新為 0 , 表示 已刪除 , 同時將 堆表項 添加到 碎片隊列 的 尾部 。
如果是 第一次 刪除 , 那么 碎片隊列 里 還沒有 元素 , 則 將 頭指針 和 尾指針 都指向 堆表項 。
每次寫入 堆表項 的時候 , 會先從 碎片隊列 里 取得碎片 , 作為寫入位置 , 如果 碎片隊列 為空 , 才會將 append 指針 作為寫入位置 。
fragmentNext 指針也是一個 64位無符號整數 ( uInt64 ) , 所以也占用 8 個字節 。 這樣的話 , 索引項 和 Free Space 項 的 大小 都是 34 + 8 = 42 個字節了 。
好的 , 現在我們再來看看在這種算法下 , 如何 回收 碎片 。 (這里的 “碎片” 是指 堆表 里的 碎片 , 不是 堆 里的 碎片)
實際上 , 在這個算法下 , 碎片可以得到充分的利用 (每次 寫入 都優先 從 碎片隊列 中取得 碎片 作為寫入位置 , 碎片隊列 為空才會用 append 指針 的方式) , 所以 看起來 堆表 不會無理增長 。 但又一些特殊的情況 , 比如 應用程序 先申請了 大量的 小塊內存 , 造成了 大量 的 Free Space , 為了存儲這些 Free Space , 堆表 會變得很大 , 之后 應用程序 又歸還了 所有 或者 大部分 內存塊 , 也是 Free Space 會變得 很少 , 此時 堆表 中就會產生大量 空閑空間(碎片) , 這些 空閑空間 如果 長時間不用又不歸還 堆 , 也是一種浪費 。
我們可以這樣來設計 堆表 的 碎片回收 算法 :
首先 , 只有 碎片數量 大於 某個值 的時候 , 才會開始回收 。 比如 大於 1000 個碎片(約 1 MB) 。
從 初始空間 開始 , 向后遍歷每一個 堆表空間 , 如果 堆表空間 的 useCount 為 0 , 則可以考慮 釋放 這個 堆表空間(歸還 堆) 。
注意 , 這里是 考慮 , 不是一定要歸還 。 還要判斷一個條件 , 就是 堆表 的 可用空間 usableSpace 是否足夠 , 若 足夠 則 釋放(歸還)堆表空間 , 否則不釋放 。 注意 usableSpace 是 整個堆表 的 可用空間 (包括 所有的 堆表空間) 。
堆表 的 初始空間 不屬於 堆 , 屬於 基礎元數據區 , 永遠不會釋放。
所以在 基礎元數據區 中要增加一個字段 usableSpace , 上文的一些算法邏輯也要做一些修改 。
usableSpace 初始值 等於 初始空間 的 大小 。 之后 每申請一個 新的 堆表 空間 , 則 加上 新的 堆表空間 的大小 , 若 歸還 堆表空間 , 則 減去 歸還的 堆表空間 的 大小 。
每次 向 堆表 寫入數據 , usableSpace 加上寫入數據的長度 , 比如 1 個 堆表項 長度是 34 個字節 , 那么 寫入一個 堆表項 的話 , usableSpace += 34; 。
每次 從 堆表 中 刪除數據 , usableSpace 減去刪除數據的長度 , 比如 刪除 1 個 堆表項 , 則 usableSpace -= 34; 。
上文中的 append 指針 到 堆表 末尾 的 空間 小於 1500 時 應 擴充 堆表 (申請 新的 堆表空間) 這一段 需要改成 :
usableSpace 小於 1500 時 , 應 擴充 堆表 (申請 新的 堆表空間) 。 上文中也提到 如果一個 堆表空間 的 useCount 為 0 , 則 可以考慮 釋放 這個 堆表空間 , 但要判斷一個條件 , 即 堆表 的 可用空間 usableSpace 是否足夠 。 我們可以設定比如 當 usableSpace - 當前考慮釋放的堆表空間的大小 > 50 萬個字節(可以存儲約 500 個 Free Space 項 (包含 索引項)) 時 , 可以 釋放 這個 堆表空間 。
我們上文 設定的 1 個 堆表空間 的 大小是 1MB , 所以 50萬個字節 約等於 0.5 MB , 上面的條件相當於是 釋放了 這個 堆表空間 后 , 堆表 的 可用空間 還有 0.5 MB , 也就是 相當於 還有 半個 堆表空間 。
這些參數 可以 根據需要 進行設定 , 上面給出的是 參考數值, 也是 舉例 。
歸納一下 , 就是 usableSpace 小於 1500 時 應擴充 堆表 , usableSpace - 考慮釋放的堆表空間大小 大於 50萬 時 可以釋放 堆表空間 。
是不是 更清晰 了 ?
碎片回收 應放在一個 另外的 線程 里 進行 。 (是不是 想起了 GC -_- ' ) , 每隔一段時間運行一次(比如 每秒運行一次) , 如果 堆表空間 的數量很大 , 可以每次只遍歷 幾個 堆表空間 (比如 10 個) , 后面的 下次 繼續遍歷 。 這樣可以不影響 分配 和 回收 內存塊 的 執行速度 。
(這里的 “碎片” 是指 堆表 里的 碎片 , 不是 堆 里的 碎片)
為了能在 更新索引 時 只上溯到 索引項值 不同的 索引項 , 需要再在 索引項 和 Free Space 項 里再增加 一個 字段 , parentItem , 保存 上一級 索引項 的 地址 , 是一個 ulong 無符號長整型 , 占 8 個 字節 , 這樣 , 索引項 和 Free Space 項 的 長度 就是 42 + 8 = 50 了 。
更新索引 時 只上溯到 索引項值 不同的 索引項 , 可以避免 為了 更新一個 Free Space 項 的 索引項 而 刪除 這個 Free Space 項 的 全部索引項 並 重建全部索引項 。 刪除全部索引項 再重建 可能會比較省事一些 , 但效率上可能會低一點 。
上溯 的 邏輯 是 檢查 上一級 索引項 的 索引值 和 新索引 在 這一層級 的 索引項 的 索引值 是否相等 , 如果 相等, 則 在 這一級 索引項 上 開始 向下建立 新索引 的 索引項 , 如果不等 , 則 檢查 這個 “上一級” 索引項 除了 當前 索引項 以外 還有沒有 其它 子項 , 如果沒有 , 則 刪除 這個 “上一級” 索引項 之后 繼續 上溯 , 如果有 , 則 直接繼續 上溯 。 刪除 “上一級” 索引項 當然 包括了 刪除 當前 索引項 , 實際上 , 上溯 是從 Free Space 項 開始, Free Space 項 是 索引樹 的 最底層 , 也可以說是 葉子節點 , 也可以說是 索引 最終指向 的 數據 , 或者說 數據項 。
實際上 “上溯” 這個邏輯好像 行不通 , 因為 上溯 到 索引值 和 新索引 在 這一層級 的 索引值 相同 這並不能說明 更上層 的 索引值 和 新索引 的 對應相同 。要知道 更上層 (或者 說 每一層) 的 索引值 是否 和 新索引 的 對應相同 , 需要 一直 上溯 到 頂層(一級索引), 但這和 從 一級索引 自頂而下 好像沒什么區別 。 啊哈哈
為了簡單起見, 我們采用 刪除舊索引, 建立新索引 的方式 。 即 更新索引 采用 刪除舊索引 建立新索引 的方式 。
我們來看一下這樣的做法的 時間花費 :
對於 申請 內存塊(new), 需要更新用於分配內存塊的 Free Space 的 索引, 按照上述的做法, 更新包括了 刪除舊索引 和 建立新索引, 刪除舊索引 和 建立新索引 的 時間復雜度 都可以認為是 O(32) , 加起來就是 O(32) + O(32) = O(32 + 32) = O(64) 。 按照我們在上面的 估算方法, O(1) 的時間按 4ns (4納秒) 算 , 那么 申請內存塊(new) 的 時間花費 就是 64 * 4 = 256 ns 。 256 ns 我們按 300ns 算的話, 1 微秒 就可以 執行 3.3 次 new 操作, 1 秒就可以執行 330 萬次 new 操作 。 因為我們將 256 ns 近似為 300 ns 計算, 所以可以認為 1 秒可以執行 330 萬次 以上 的 new 操作 。
對於 歸還 內存塊(delete), 分為 4 種 情況:
情況 1 : 歸還的 內存塊 前面 和 后面 都 不和 已有的 Free Space 相接, 所以 不需要 “合並”, 這樣只需要 新建 索引 就行, 時間復雜度是 O(32) , 時間花費 是 32 * 4 = 128 ns , 可以估算為 1 微秒 可以執行 7 次, 那么 1 秒可以執行 700 萬次 。
情況 2 : 歸還的 內存塊 前面 和 已有的 Free Space 相接, 需要 “合並”。 合並 只需 更新 相接 的 Free Space 的 結束地址 就行 。 因為 索引 是 按 Free Space 的 起始地址 建立的, 所以 更新 結束地址 不需要 更新索引, 所以 情況 2 的 時間復雜度 是 O(1) , 由於只是更新結束地址, 可以認為 O(1) 的 時間花費 是 1 * 1ns = 1ns , 1 秒 可以執行 10 億次 。 我也有點懷疑, 真的這么簡單嗎 ?
情況 3 : 歸還的 內存塊 后面 和 已有的 Free Space 相接, 需要 “合並”。 合並 只需 更新 后面相接的 Free Space 的 起始地址, 由於 索引 是 按 起始地址 建立的, 所以需要更新索引, 和 申請內存塊 一樣, 更新索引 包含 刪除舊索引 和 建立新索引, 時間復雜度 是 O(64) , 時間花費是 64 * 4 = 256ns , 1 秒可以執行 330 萬次 以上 。
情況 4 : 歸還的 內存塊 前面 和 后面 都和 已有的 Free Space 相接, 需要將 前面 后面 的 Free Space “合並” 為一個 。 合並 需要 修改 前面的 Free Space 的 結束地址, 刪除后面的 Free Space 。 修改 結束地址 不需要 更新索引, 所以 只需要 刪除 后面的 Free Space 的索引就行 。 所以 時間復雜度 是 O(32) , 和 情況 1 一樣, 時間花費 是 32 * 4 = 128 ns , 1 秒可以執行 700 萬次 。
哎 ? 我剛又想到一個 好主意, 申請內存塊 的 時候為什么不從 Free Space 的 結束地址 分配呢? 如果 從 Free Space 的 結束地址 分配的話, 就不用 更新索引, 只要修改 Free Space 的 結束地址 就可以了。 這樣 就和 歸還 的 情況 2 一樣, 時間復雜度 是 O(1) , 時間花費 是 1 * 1ns = 1ns , 1 秒 可以執行 10 億次 。 (1 秒 可以 new 10 億次)
上面的 討論 是 從 起始地址 開始 分配 內存塊 的, 所以 每次 new 的時候 會 更新 起始地址, 也就會 更新索引 。
如果 換成 從 結束地址 一端 來 分配內存塊 的話, 就不需要 更新 起始地址, 也就 不需要 更新索引, 可以大大提高效率 。
當然 這是在 Free Space 的大小足夠分配的情況下, 如果 Free Space 的大小不夠, 會向后尋找 Free Space, 若尋找了 10 個 Free Space 還未找到 大小足夠 的 Free Space, 則會向 操作系統 申請 div 。 在這些情況下, 還需要考慮這些 時間花費 。
因為 不需要 上溯, 所以 索引項 和 Free Space 項 不需要 保存 上一級索引項 的 位置(地址), 也就是不需要 parentItem 這個字段, 這樣的話, 索引項 和 Free Space 項 的 長度 就 從 50 個字節 變回 50 - 8 = 42 個字節了。
實際上, 我們在 索引項 里 設計了一個字段 用來保存 索引值, 但后來發現, 由上一級索引保存的 4 個 子索引項 的 指針字段 可以 直接指向 子索引項, 子索引項 好像不需要 保存 索引值 。
我 這個 設計 是 不會 回收 堆里 的 碎片 的 。 這 跟 C# Java 之類 有 GC 的 不同 。 我想 C++ 也不會 回收 堆 里的 碎片 。 上文提到的 “碎片回收” 是 回收 堆表 里的 碎片 , 不是 回收 堆 的 碎片 。 所以 不存在 “全盤整理” 。 每次 歸還內存塊 的時候 會檢查 div 的 useCount , 每次 分配 內存塊 的時候, 這個 內存塊 所在 的 div 會 useCount ++ , 每次 歸還內存塊 , 這個 內存塊 所在的 div 會 useCount -- 。 如果 useCount == 0 , 則將 div 歸還 操作系統 。 但 這種情況 概率 可能 不大 , 因為 一旦 div 投入使用后, 分配出去 的 內存塊 必須 全部 釋放, div 才會空(useCount == 0) , 才能 歸還 操作系統 。 但 在 實際使用中, div 投入使用后, 有 申請 有歸還, 全部清空 的 概率 可能不大, 很長時間后, 可能 還有一些 “零碎” 的 內存塊 占據着, 即使 是 少量的 內存塊, 也 導致 div 不能歸還 。 這就是 C++ 這一類 靜態 做法 的 局限 。 可能導致 大塊 內存 區域(div) 被 進程占據, 無法 回歸到 操作系統 層面, 造成 資源的 浪費 。
所以, 要解決 這種 靜態做法 的 局限, 就需要 引入 GC 這樣的 動態特性 。 我想, 當初 GC 的 出現 (以 Java 為 代表) , 不僅僅 是 為了解決 “內存泄漏” 的問題 , 其實 也 隱藏了 上述 靜態做法 的 種種 局限 的 原因 吧 !
當然, GC 的做法 會增加 工作量, 會 花費 時間, 但是, GC 確實 可以有效 的 控制 堆碎片 數量 和 堆表大小 。 就是說, GC 可以 使 堆碎片 控制在一個 有限的 范圍內, 使 堆表大小 控制在 一個 有限的 范圍內 , 這 本身 就 簡化了問題, 減少了 管理 開銷 和 復雜度 。 從 這個 角度來講, GC 又是 減小了 時間花費, 提升了 效率 的 。
所以, 從 技術 進步 或者 進化 的 角度 來看, GC 是一次 進化, 使得 可以用 更現代 更高級 的 方法 來 管理 存儲資源 。
相較之下, C++ 的 靜態做法, 是 早期 和 朴素 的 。
在 現代 存儲資源 可以 大幅 甚至 無限擴展 的 情形下, 或許 確實 需要 GC 這樣 “動態” 的 方式 來 管理 存儲資源 。 靜態的 方式 面對 大幅 存儲資源 可能 會 有 局限 。
當然, 在本文中設計的這種 “靜態”做法, 實際上 也是 利用了 現代 存儲資源 大幅提升 的 特點, 比較多 的 應用了 “空間換時間” 。
但確實 存在一個 問題, 就是 靜態的 做法 無法 控制 碎片 的 增長, 包括 堆碎片, 甚至 堆表碎片, 或者說 不能有效控制 堆表大小 的 增長 。 本文的做法 可以回收 堆表 碎片, 但是 效果如何, 不知道 。 只要 堆表空間 里還有一個 堆表項, 就不能 釋放 堆表空間(歸還堆), 這是一個 概率問題 。
所以, 要 准確 有效 的 管理 存儲資源, 還是 需要 GC 這樣的 “動態” 做法 。
所謂 “動態”, 套用一個術語, GC 建立了一個 “抽象層” 。
因為 有 這個 “抽象層”, GC 可以 移動 進程中 的 變量位置, 而 對於 程序來講, 沒有感覺到 變化 。
也正因為這樣, GC 可以 有效的 控制 堆碎片 的 數量 和 堆表大小 在一個 有限 的 范圍 。
在 C++ 里, 由於 C++ 比較 直接 的 面向 “底層”(操作系統), 所以, C++ 不能提供 GC 這樣的 “抽象層”, 對於 堆管理, 也就只能使用 “靜態”的做法, 如上所述 。
但 到 目前為止, 上面說的 設計 解決了 基本 的 分配 和 回收 (包括 索引機制, 索引機制 確保了 檢索操作的 時間花費 在一個 已知的范圍內), 但還存在一個重要的問題, 就是 “碎片占據 div” 的問題 。 就是說, div 里只要還有一個 內存塊 沒有 歸還, div 就會被 進程 一直占用, 不能 歸還 操作系統 。 這就導致 大塊內存空間 的 浪費 。 這是一個 大問題 。
有 網友 查了 資料, 說 Linux 有一塊 3G 的 用戶空間, 進程可以使用, 使用這個 用戶空間 不需要 系統調用(不需要切換到系統進程, 即不需要跨進程) 。 我的理解是 這是 操作系統 提供的 系統級 的 一個 “公共堆”, 可供所有進程使用 。 這樣在 3G 的范圍內, 進程可以共用 這個 公共堆, 這樣可以解決 “碎片 占據 div” 的 問題 。
所以, 我說 這是個 重大發現 。
但 后來一想 , 這樣 又有一個 問題, 就是 地址訪問 的 時候 不能 或者 難於 作 安全檢測 了, 所謂 安全檢測, 是指 檢查 訪問 的 地址 是否 越界 。 越界 指 訪問了 其它進程 的 內存 。
資料顯示, 現在的 安全檢測 是 在 存儲管理部件 中 完成的 。 這是一個 硬件, 是 CPU 的 一部分 。
操作系統 為 存儲管理部件 設置 頁表, 然后 存儲管理部件 就可以工作了 。
看起來, 公共堆 沒有 “段” 的 概念, 大概 很難 實施 判斷 是否越界 的 安全檢查 。
呀, 這可怎么辦 ?
碎片, 分為 2 個 層面 ,
1 物理內存, 頁文件
2 虛擬內存, 虛擬地址
對於 1 , 操作系統 可以進行整理, 可以將 多個頁 上的 零碎 的 數據 整理 到 一個 頁, 再把 虛擬地址 映射到 新的頁 就行 。 這樣可以避免 頻繁的 載入 載出 頁 。
對於 2 , 需要 程序 自己管理 。 比如 GC , 內存池 。
但 上面的說法 也有一點問題, 操作系統(虛擬內存) 也不能 整理 數據層面 的 碎片, 因為 虛擬內存 管理的是 虛擬頁 和 物理頁 之間的 對應關系, 並沒有細化到 虛擬地址 和 物理頁 之間的 對應關系, 所以 虛擬內存 也不能 整理 數據層面 的 碎片, 上面說的 “將 多個頁 上的 零碎 的 數據 整理 到 一個 頁” 這是 不能 做到的 。
操作系統(虛擬內存) 只能 刪除 空頁(沒有數據在用 的 頁) 。
而 只要 頁上還有 數據 在用, 那么, 即使 數據 占用的空間 很小, 這個頁也不能被刪除 。
所以, 從這個角度來看, 如果 程序 產生了 很多的 碎片, 那么可能導致 操作系統(虛擬內存) 頻繁 的 載入載出 頁 。
堆 在 計算機系統結構 里的 地位 等同於 虛擬內存 和 文件系統 。