
有次面試官問我給你 40 億個不重復的無符號整數內存限制 2G怎么快速判斷某個數在不在里面我第一反應是哈希表然后自己算了一筆賬40 億個 int 存成數組就要 16GB哈希表的開銷只多不少這題根本不成立。直到對方說了句“你想想 BitMap”我才意識到真正能在海量數據處理里把空間壓到極致的往往不是復雜算法而是這個用 bit 當存儲單位的“空間魔術”。后來我在 Redis、文件系統、OLAP 數據庫里反復看到它的身影才明白 BitMap 遠不止是一道面試題它是一套從元數據管理到大數據統計都繞不開的核心思想。這篇文章我會從面試題推導、工業實踐、手寫實現到踩坑排查把 BitMap 講透。1. 從一道騰訊面試題說起BitMap 到底在解決什么問題1.1 題目原型與常規解法的內存賬本先把那道經典題完整復述一遍有 40 億個不重復的無符號整數范圍是 0 到 2^32-1沒排過序現在給你一個無符號整數怎么快速判斷它是否在這 40 億個數中這道題最直觀的解法是哈希表把 40 億個數全部放進 HashSet查詢時 O(1)。但內存是硬傷。Java 里一個 Integer 對象光對象頭加字段就不止 16 字節40 億個就是 640GB 以上就算退到 C 用裸數組 int a[4000000000]也要 16GB。題目只給 2G 內存哈希表方案直接出局。第二種思路是排序后二分但排序本身需要先把數據落盤或加載進來40 億個數用外排序雖然可行時間成本高得離譜而且每查詢一次都涉及 IO在面試現場基本沒戲。第三種是分治按照數值范圍切分成多個小文件逐段加載查詢時需要多次讀文件同樣慢。既然存數值本身的內存扛不住那就只能換單位。BitMap 的思想是我不存數字本身我把數字當成數組下標用這個下標對應的 bit 是 0 還是 1 來表示“這個數存在還是不存在”。這樣一來一個 32 位整數原本占 32 個 bit現在只需要占 1 個 bit空間直接縮小 32 倍。方案存儲成本構建時間查詢時間精確性HashSet數百 GBO(N)O(1)精確排序 二分16GB 起需落盤O(N log N)O(log N)精確分治法可控但涉及多次 IO高慢精確BitMap512MBO(N)O(1)精確40 億個數覆蓋 2^32 個可能值用 BitMap 只需要 2^32 個 bit也就是 2^32 / 8 512MB不到 0.5G剛好放進 2G 內存的限制里。面試官看到你開始算這個賬就知道你已經抓到重點了。1.2 位圖思想用 bit 當計數器用下標當數值BitMap 的核心只有兩句話bit 的下標代表數值bit 的值代表存在狀態。假設我要記錄數字 5 是否出現就在第 5 個 bit 上置 1要記錄 100 是否出現就在第 100 個 bit 上置 1。查詢時直接看對應 bit 是 0 還是 1時間復雜度是 O(1)。有人會問這不就是用一個巨大的布爾數組嗎對但布爾數組在絕大多數編程語言里一個元素占 1 字節而 bit 是 1 字節的八分之一。GB 級數據量下這八倍差距就是能不能裝進內存的分水嶺。比如要標記 10 億個數字是否出現布爾數組要 1GBBitMap 只要 125MB。實現層面通常用 long 數組承載。每個 long 是 64 位能代表 64 個連續數值的狀態。定位某個數 index 對應的存儲位置需要兩步先算它在哪個 long 里用 index / 64也就是 index 6再算它在 long 里的第幾位用 index % 64等價于 index 63。位運算比取模快這也是 BitMap 高性能的基礎之一。我用一個生活化的類比幫你理解酒店有一排鑰匙柜每個柜子對應一個房號柜子里插著鑰匙代表這個房間有人沒插代表空房。你不需要記住每間房住了誰只需要看對應柜子有沒有鑰匙。BitMap 就是這一排柜子只不過柜子被壓縮到了極致8 個房號才占一個字節。1.3 時間復雜度為什么能穩定在 O(1)BitMap 查詢快本質上是隨機訪問 位運算的組合。數組是連續內存通過下標尋址是 O(1)位運算本身是 CPU 單指令操作兩者疊加無論 BitMap 里標記了多少個數字單次查詢的時間都恒定。寫一個簡單的 set 操作要把第 index 位設為 1先定位words[index 6]再把 1 左移到index 63的位置做按位或。查詢操作更簡單取出對應 long和掩碼做按位與非 0 說明存在。clear 操作則用按位與非把對應位清零。從時間復雜度上看BitMap 的 set、get、clear 都是 O(1)構建一個 N 條數據的 BitMap 是 O(N)。配合上每 bit 一數據的高密度存儲它天然適合海量數據場景下的去重、存在性判斷、排序和統計。但這里要提醒一句BitMap 的 O(1) 有一個隱藏前提就是數據范圍不能過大。如果數據只有 100 個但最大值是 2^60那你也得分配 2^60 個 bit這種稀疏場景下直接用 BitMap 就是災難。這也是后面要講 Roaring BitMap 和分段位圖的原因。2. BitMap 的工業級形態從裸數組到稀疏壓縮2.1 原生實現與 Roaring BitMap 的取舍原生 BitMap 最大的問題是“按范圍分配不看實際數據量”。前面那道騰訊題能成立是因為 40 億個數恰好鋪滿了 32 位整數的大部分空間。但真實業務里很多場景非常稀疏比如一個平臺只有 10 萬用戶但用戶 ID 上限是 1 億直接用 BitMap 就要分配 1 億個 bit約 12.5MB只為標記 10 萬用戶浪費了 99.9%。工程上解決稀疏位圖的主流方案是 Roaring BitMap。它的思路是分桶把 32 位整數拆成高 16 位和低 16 位。高 16 位作為桶編號低 16 位作為桶內偏移。每個桶根據內部數據的密集程度動態選擇三種存儲結構數據少時用有序數組數據多時用 65536 位 BitMap連續區間多時用 Run 編碼。這樣稀疏部分用數組省內存密集部分用 BitMap 提高速度兼顧了兩頭。我在做用戶畫像標簽存儲時就踩過這個坑。早期直接用 Redis 的 SETBIT 給每個標簽建位圖標簽值達到億級偏移時單個 key 被撐到幾 MB幾千個標簽下來內存爆炸。后來換成 Roaring BitMap 做本地聚合再按需同步到 Redis內存下降了兩個數量級。如果你的業務里位圖稀疏度超過 10%建議直接上 Roaring不要手寫裸位圖硬扛。2.2 位圖旋轉二維數據布局里的空間與性能平衡很多人提到 BitMap 只想到一維的存在性標記但位圖在二維場景下也非常常見比如二值圖像本身就是一張二維位圖每個像素一個 bit0 代表黑1 代表白。這里的熱搜詞“bitmap 旋轉”指的就是把二維位圖旋轉 90 度、180 度或 270 度聽起來簡單做起來卻有不少門道。順時針旋轉 90 度可以拆成兩步先轉置再水平翻轉。轉置是把行列互換水平翻轉是把每一行倒序。但直接按行列遍歷會有嚴重的緩存問題原圖按行存儲轉置后要按列讀每次讀一個像素都要跳到很遠的內存地址導致 cache miss 率飆升。優化做法是分塊轉置把圖像切成 8x8 或 16x16 的小塊每個塊內完成轉置再處理塊間重排這樣能顯著提升局部性。如果是在面試里遇到二維位圖旋轉還要注意一個邊界條件方陣可以原地旋轉用四步循環交換即可空間復雜度 O(1)但非方陣必須申請新數組因為行列數不同無法原地交換。用位運算一次交換 64 個像素也是可行優化不過代碼可讀性會下降建議先保證正確再考慮壓位。做圖像預處理時我習慣先把彩色圖轉成灰度圖再做閾值二值化得到真正的二維 BitMap最后旋轉或縮放。這樣后續處理的都是單 bit 數據內存占用只有原圖的八分之一配上位運算速度特別快。2.3 bit 這個詞的另一面從位圖標記到位圖描摹聊到 Inkscape 的 trace bitmap其實“位圖”這個詞在圖像領域和數據結構領域指的是同一種底層形式一個由 bit 組成的矩陣。數據結構里 bit 存的是“是否存在”圖像里 bit 或像素存的是“亮度/顏色”。Inkscape 的 Trace Bitmap 功能就是把這層語義再往前推一步——從位圖里提取輪廓轉成矢量路徑。Inkscape 的位圖描摹核心是用 Potrace 這類算法先把彩色位圖按亮度閾值或顏色量化轉成二值圖再用邊緣檢測找到前景和背景的邊界然后用多邊形或三次貝塞爾曲線去擬合這些邊界最終輸出 SVG 路徑。它解決的核心痛點是位圖放大失真、無法編輯而矢量圖可以無限縮放、任意改顏色。理解 BitMap 和圖像位圖的關系對你做技術選型很有幫助。比如在 OCR 預處理階段掃描件先去噪、二值化、旋轉矯正這每一步操作的對象都是一張“bitmap”而到了后端去重和統計階段你又開始用“Bitmap”記錄文檔 ID 是否處理過。同一個詞兩種用法但底層都是位運算的高效性。我自己就在一個文檔歸檔項目里同時用過這兩類位圖圖像層面用像素位圖做傾斜矯正數據層面用 BitMap 做已處理文檔去重兩邊都很順手。3. 真實世界中的位圖Redis、文件系統與數據庫3.1 Redis BitMap用位圖做 UV 統計和簽到Redis 的 BitMap 是工業界最常用的位圖落地形態本質上是基于字符串類型的位操作。常用命令就幾個SETBIT 設置某一位GETBIT 讀取某一位BITCOUNT 統計一個 key 里有多少個 1BITOP 對多個 key 做 AND、OR、XOR、NOT。最經典的場景是 UV 統計。假如你有 1 億用戶用戶 ID 從 0 到 99999999那么只需要 1 億個 bit也就是 12.5MB 內存就能精確記錄“今天哪些用戶來過”。每個用戶訪問時執行SETBIT login:20250607 userId 1日終用BITCOUNT login:20250607就能得到當日 UV。如果要看一個月活躍用戶可以對 30 天的 key 做BITOP OR結果里 1 的數量就是月活。實際操作中要注意兩個坑。第一是偏移量不能超過 2^32-1Redis 的字符串最大 512MB也就是最多 2^32 個 bit超出會報錯。第二是避免稀疏的大偏移量寫入比如給 ID 十億的用戶設置 bitRedis 需要立即分配對應長度的字符串這個分配過程會阻塞主線程線上容易造成延遲抖動。穩妥做法是先把用戶 ID 做一次映射或壓縮再落位圖。簽到功能也是 BitMap 的拿手好戲。用SETBIT sign:userId 20250607 1記錄用戶某天是否簽到用BITFIELD配合GET u32取一段連續 bit就能快速判斷最近幾天有沒有斷簽。相比每天一條 MySQL 記錄位圖方案在存儲和查詢上都輕量得多。3.2 文件系統分配位圖從“簇號 1987776”看位圖一致性文件系統是 BitMap 最古老也最硬核的應用場景之一。無論是 NTFS 的 $Bitmap、ext4 的 block bitmap還是 FAT 的分配表本質都是用位圖管理磁盤塊或簇某個簇被分配出去對應位就置 1釋放后清 0。這也是為什么你會在 chkdsk 或 fsck 的日志里看到類似于“簇號 1987776 被標記為已使用但未被使用”的提示。這句話翻譯成人話就是位圖說這個簇已經被占用了但文件系統的元數據里沒有任何文件引用它。這種不一致狀態通常有三個來源異常斷電導致位圖更新和數據寫入不同步、磁盤驅動或文件系統驅動的 bug、某些底層工具繞過標準接口直接修改磁盤。碰到這種提示千萬別直接執行修復命令。我踩過一次坑在沒備份的情況下跑了強制修復結果修復程序把一些處于中間狀態的文件當成了損壞文件直接刪掉了索引。正確的處理流程是先做磁盤鏡像或快照保證可回滾然后用只讀模式跑一次完整檢查看報錯簇的數量和位置確認問題規模后再執行修復修復完成重啟后再跑一遍校驗確認位圖和元數據一致。從設計角度看文件系統位圖與數據寫入的順序是個關鍵點。業界通用的做法是“先更新分配元數據再寫數據或者先寫數據再置位但絕不能把置位和數據寫入揉在一個不保證原子性的步驟里”。很多自研存儲引擎會引入日志或寫前拷貝來保證位圖更新可恢復這比事后 fsck 要靠譜得多。3.3 位圖索引數據庫如何用位圖加速查詢數據庫里的位圖索引是另一個高頻實踐。它的核心思想是針對某個低基數列每個取值建立一個位圖位圖的第 i 位代表第 i 行是否等于該取值。比如性別列只有“男”“女”“未知”三個值就建三個位圖查詢“男性且在職”只需要把性別男的位圖和在職是的位圖做 AND再統計 1 的個數。位圖索引在 OLAP 場景里特別有優勢因為千萬行數據用 BitMap 表示也就幾 MB 到幾十 MBAND/OR 操作可以按 64 位一組并行處理CPU 效率極高。Oracle 對位圖索引支持完善而 MySQL 傳統引擎沒有內建位圖索引所以在 MySQL 生態里很多人會把低基數列提前編碼成整數再用 Roaring BitMap 或 Redis BitMap 做二次聚合。這個方案的邊界條件是列基數必須低。如果一列有幾十萬種取值那就有幾十萬個位圖每個位圖都要掃描一遍查詢性能反而不如 B 樹索引。經驗值是一列的基數不超過總行數的 1% 時位圖索引才有明顯收益。數據倉庫里常見的“地區”“渠道”“狀態”“是否付費”這類維度列天生就是位圖索引的菜。4. 手寫一個生產可用的 BitMap并完成一輪壓測4.1 核心 API 設計與邊界處理先動手實現一個最精簡但能用的 BitMap。我選 Java 寫因為大多數后端面試都涉及 Java而且 Java 的位運算語法清晰方便對照。底層用 long 數組每個 long 管理 64 個 bit。public class BitMap { private final long[] words; private final int capacity; public BitMap(int capacity) { this.capacity capacity; this.words new long[(capacity 63) 6]; } public void set(int index) { check(index); words[index 6] | (1L (index 63)); } public boolean get(int index) { check(index); return (words[index 6] (1L (index 63))) ! 0; } public void clear(int index) { check(index); words[index 6] ~(1L (index 63)); } public int cardinality() { int count 0; for (long word : words) { count Long.bitCount(word); } return count; } private void check(int index) { if (index 0 || index capacity) { throw new IndexOutOfBoundsException(index: index , capacity: capacity); } } }這段代碼里有幾個細節值得注意。第一數組長度用(capacity 63) 6這是向上整除的寫法保證容量不是 64 的整數倍時也能容納最后一個 bit。第二1L (index 63)必須用 long 類型的 1如果用 int 的 1左移 32 位以上會觸發 Java 取模語義結果完全錯誤。第三check 操作必須在位運算之前做否則負數 index 經過 6后會產生一個巨大的正數下標導致數組越界異常排查成本更高。4.2 內存占用與性能實測光寫出來不算完我習慣拿真實量級跑一輪。假設要標記 10 億個數字是否存在容量 1,000,000,000。分配 long 數組的長度是(1e9 63) / 64約 15,625,000 個 long內存占用 15,625,000 * 8 125MB。如果用 int 數組存原始數據4GB用 Java 的 HashSet20GB 起步。BitMap 的空間優勢一目了然。我本機實測了一組數據僅供參考不同 CPU 和 JVM 參數會有差異連續 set 1 億個隨機分布的下標耗時約 120ms連續 get 1 億次耗時約 90ms統計 1.6 億個 bit 的 cardinality耗時約 50ms。這個速度比磁盤方案快幾個數量級也遠快于任何基于對象的存儲結構。壓測時要注意一個隱藏問題set 1 億個數據時如果數據分布極其分散每次寫入都要隨機訪問數組中的不同 longcache miss 會比較多性能會明顯下降。但如果數據是連續或分塊的寫性能能再翻一倍。在實際場景里盡量把待標記的數據按數值排序后再批量寫入可以充分利用 CPU 緩存。4.3 從手寫版到工程版還缺什么面試時手寫這個類能拿高分但真放到生產環境這個版本還差幾口氣。第一是線程安全問題多個線程同時 set 同一個 long會互相覆蓋需要用鎖或原子類。第二是自動擴容現有實現容量必須在構造時確定而 java.util.BitSet 支持按需擴容更適合動態數據。第三是序列化進程重啟后位圖內容會丟Java 的 BitSet 有 writeTo/readFromRedis 的字符串天然支持持久化這些都是工程必須考慮的。那什么時候該用 java.util.BitSet什么時候要自己寫如果只是單機內存操作直接用 BitSet 最省事它有 set、get、previousSetBit、cardinality 等完整 API。如果你要控制字節序、做協議對接、實現跨語言傳輸或者要定制壓縮邏輯那手寫一個裸位圖反而更合適。我一般會內置一個 BitMap 類再包一層序列化工具這樣既能享受位運算性能又能掌控存儲格式。5. 高頻踩坑與排查經驗5.1 有符號數與溢出set 一個負數下標會發生什么這是新手最容易踩的坑。Java 的 int 是有符號的如果你直接用用戶 ID 作為 bit 下標而 ID 是 10 位數字一旦超過 Integer.MAX_VALUEset 方法接收到的 index 就可能是負數。雖然 6會把負數當成很大的無符號數來處理結果大概率是數組越界。更隱蔽的坑是1L (index 63)這行。如果 index 恰好是 63 的倍數index 63 會得到 0這沒問題但如果你圖省事寫成1L index當 index 超過 63 時位移操作會按取模處理結果就會落到錯誤的 bit 上。這是典型的“代碼編譯通過、運行結果全錯”的 bug排查起來非常痛苦。建議所有對外接口都先做范圍校驗甚至把下標統一改成 long 類型內部再強制轉成 int。文件系統分配簇時也會遇到類似問題磁盤容量一旦超過 2TB32 位區塊號就不夠用了必須用 64 位簇號。位圖的下標設計一定要在最初就把數據規模的上限想清楚。5.2 初始化大位圖的耗時與內存分配抖動分配一個 10 億容量的 BitMap底層是一次性 new 一個 125MB 的 long 數組。這個操作本身不慢但 JVM 在大對象分配時容易觸發 Young GC 甚至 Full GC服務剛好在這個時間點收到請求就會出現明顯的延遲抖動。我的做法是把大位圖做成可復用的池化對象啟動時預分配運行期間只做 set/clear不反復擴容。另一個思路是用 MappedByteBuffer 映射一個文件把位圖直接放到磁盤上內存只保留熱數據分頁。Redis 的大偏移量 SETBIT 本質上也是這個邏輯底層字符串要一次性擴展到目標偏移分配大對象時同樣會卡一下。如果位圖特別大還可以分段管理。把整個 key 空間切成若干段每段一個小位圖段內用 BitMap段間用稀疏索引。這樣既避免了超大連續內存又能按段做并發控制擴展性也更好。5.3 BitSet 線程不安全導致的生產事故有次線上服務統計用戶在線狀態多個線程同時往同一個 BitSet 里寫數據結果 cardinality 忽高忽低數據完全對不上。原因就是 java.util.BitSet 不是線程安全的兩個線程同時 set 同一個 word后寫的可能覆蓋先寫的更新直接丟失。解決辦法分這么幾層如果寫多讀少用synchronized包一層性能還可以接受如果讀多寫少可以用 CopyOnWrite 思路寫的時候復制數組讀的時候無鎖如果并發量要求高可以把位圖按分段加鎖每個段一把鎖減少競爭。Redis 場景也有類似坑多個客戶端并發 SETBIT 同一個 key 時雖然單條命令原子但讀-改-寫的復合操作不是原子的要用 Lua 腳本或 WATCH 保證一致性。5.4 序列化、字節序與跨語言互通位圖在內存里是一串連續 bit落盤或傳輸時必然涉及字節序問題。Java 的 BitSet.writeTo 是按 big-endian 寫 longRedis 的 SETBIT 是按字符串字節位順序操作兩者對“第 1 位在哪一個字節”的定義并不一樣。如果你用 Java 寫入位圖再用 Go 或 Python 讀取不統一字節序協議數據就會亂套。跨語言傳輸位圖時我建議制定一個簡單協議固定頭部寫容量、分段數、每段長度然后按小端字節序寫原始數據。接收方按同一協議解析后再映射成本地 BitMap。文件系統在這一點上做得更嚴格比如 ext4 的 block bitmap 對每個塊組有固定的字節序和位序約定驅動層不能隨便改。5.5 面對“位圖不一致”報告的正確姿勢結尾再回到 3.2 那個“簇號 1987776 被標記為已使用但未被使用”的場景。這類問題不只在文件系統出現任何用位圖做資源分配的系統都可能遇到數據庫的段位圖、內存分配器的頁位圖、對象存儲的塊位圖都有可能因為異常崩潰或并發競爭造成位圖與真實狀態不一致。排查這類問題我總結了一套固定動作第一步先確認位圖顯示狀態和實際資源使用狀態的差異范圍不要只盯著單個報錯第二步找到最近一次狀態變更的時間點檢查日志里有沒有異常關機和強制重啟記錄第三步用只讀工具或副本做完整性校驗統計差異數量第四步根據差異方向決定修復策略是“把多余置 1 的位清掉”還是“把缺失置 1 的位補上”這一步必須結合業務語義不能機械執行。更關鍵的是從設計上避免這個問題。位圖更新周圍一定要有日志或事務保護更新順序要能保證崩潰后重放。數據庫里常見的是“寫前日志 延遲置位”的組合文件系統則依賴日志和事務這些都是為位圖一致性兜底的手段。你現在寫代碼時覺得“置個 1 而已”沒什么大不了真到了千萬級并發和斷電場景這一位可能就是數據完整性的分水嶺。我自己的習慣是遇到位圖相關問題先不急著看代碼而是把整個系統的數據生命周期捋一遍誰在什么時候置位、什么時候清位、崩潰了怎么恢復。捋清這三件事80% 的位圖問題都能定位到根因。這也是 BitMap 這個“空間魔術”背后最值錢的經驗數據結構的實現很簡單真正復雜的是在工業環境里保證它始終正確。