開篇
我們嘗試從2個方面來進行介紹:
1. 社招實際面試問題
2. 問題涉及的基礎點梳理
社招面試題
米哈游
1. Go 里面使用 Map 時應注意問題和數據結構
2.?Map 擴容是怎么做的?
3.?Map 的 panic 能被 recover 掉嗎?了解 panic 和 recover 的機制嗎?
4.?Map 怎么知道自己處于競爭狀態?是 Go 編碼實現的還是底層硬件實現的?
5.?有對比過 sync.Map 和加鎖的區別嗎?
富途牛牛
1.?Golang 標準庫中 map 的底層數據結構是什么樣子的
2.?Map 的查詢時間復雜度如何分析?
3.?極端情況下有很多哈希沖突,Golang 標準庫如何去避免最壞的查詢時間復雜度?
4.?Golang map Rehash 的策略是怎樣的?什么時機會發生 Rehash?
5.?Rehash 具體會影響什么?哈希結果會受到什么影響?
6.?Rehash 過程中存放在舊桶的元素如何遷移?
7.?sync.Map 的 Load() 方法流程?
8.?sync.Map Store() 如何保持緩存層和底層 Map 數據是相同的? 是不是每次執行修改都需要去加鎖?
基礎點梳理
常見的基礎數據結構,包括數組、map,鏈表、棧和隊列、堆、樹、圖,不過在Golang日常的使用和面試問題中,其實主要就只有slice和map。
在面試場景中更重中之重,幾乎一定會考察的就是map的數據結構,一方面是在Golang應用開發中比較廣泛,另一方面是這里有幾個相對好考察的點,基本上我們掌握如下一些問題,對于面試來說基本也就夠了,畢竟就算大廠面試,一般最多也就一個小時,這還包括算法題,所以大體上不會在這方面非常的細節。
Slice
一個slice是一個數組某個部分的引用。在內存中,它是一個包含3個域的結構體:指向slice中第一個元素的指針,slice的長度,以及slice的容量。
理解了這個概念,我們大體去了解這么幾件事:
1. 數組是定長的,但是切片是非常靈活的,所以可以動態擴容,那動態擴容如何去擴,可以直接參考這里的文章。
切片的容量是怎樣增長的 | Go 程序員面試筆試寶典
2. 很多人在寫函數傳參中,很多人知道Golang所有的函數參數傳遞都是值傳遞,那我們不免有一個擔心,如果我們直接把slice傳入進去到底會不會因為數據量過大,導致出問題。
其實不會,因為對于傳參來說,就是一個普通的結構體,包括三個參數,長度/容量/底層數據的地址,所以這幾個拷貝是比較快的。
Map
Golang面試數據結構中,Map是重中之重,所以我們必須把這里的問題都弄清楚,
1. Map的底層數據結構是什么樣子的
2. Map的日常使用中需要注意哪些問題
3. Map的擴容是怎么做的
4.?sync.Map的實現
我們總結下來,關于Map基本上就是如下幾個場景:
1. 先說說Map的底層數據結構:
map 的實現原理 | Go 程序員面試筆試寶典
map的實現 · 深入解析Go
這兩篇文章都可以參考:
重點是對這張圖有一些概念,map的底層實現就是Hash,bmap就是我們常說的桶,桶里面會最多裝8個key,這些 key 之所以會落入同一個桶,是因為它們經過哈希計算后,哈希結果是“一類”的。在桶內,又會根據 key 計算出來的 hash 值的高 8 位來決定 key 到底落入桶內的哪個位置。
另外注意一個細節是Bucket中key/value的放置順序,是將keys放在一起,values放在一起,為什么不將key和對應的value放在一起呢?如果那么做,存儲結構將變成key1/value1/key2/value2… 設想如果是這樣的一個map[int64]int8,考慮到字節對齊,會浪費很多存儲空間。不得不說通過上述的一個小細節,可以看出Go在設計上的深思熟慮。
2.?Map的日常使用中需要注意哪些問題
1.?未初始化時寫操作會panic,記得使用make方法進行初始化
2.?并發讀寫導致panic,這個是map日常非常需要注意的地方,尤其是在日常開發中,因為很多時候我們可能不會刻意為之,但是不經意的會在程序中出現這個問題。
3.?Map的擴容是怎么做的
擴容觸發條件:
-
負載因子 > 6.5(平均每個桶元素超過6.5個)
-
溢出桶過多(當
B < 15
時溢出桶超過2^B
;當B >= 15
時溢出桶超過2^15
)
負載因子 = 元素總數量(map元素個數) / 桶數量(Bucket數量)
-
桶數量(Bucket Count):
由hmap
結構體的B
字段決定,桶數量 =?2^B
-
例:
B=3
?→ 桶數量 = 8
-
-
元素數量(Element Count):
即當前map中存儲的鍵值對總數,對應hmap.count
-
負載因子閾值:
默認閾值 = 6.5(硬編碼在Go源碼中)-
當?
負載因子 > 6.5
?時觸發增量擴容(桶數量翻倍)
-
-
為什么閾值是6.5?
-
平衡空間與性能:
-
過低(如4):擴容頻繁 →?內存浪費
-
過高(如10):哈希沖突激增 →?查找性能下降
-
-
經驗值:
經過性能測試,6.5能在沖突率和內存占用間取得最佳平衡。
hash的擴容過程:https://golang.design/go-questions/map/extend/
核心點在于:
擴容流程(增量擴容為例):
-
分配新桶數組(大小為原來的2倍)
-
逐步遷移舊桶數據到新桶(每次寫操作遷移1-2個桶)
-
遷移期間讀取:優先查舊桶,未找到再查新桶
-
遷移完成后釋放舊桶
4.?sync.Map的實現
type Map struct {
? ? mu Mutex ? ? ? ? ? ? ? // 寫操作鎖(保護 dirty 字段)
? ? read atomic.Value ? ? ?// 只讀緩存(atomic 訪問,無鎖)
? ? dirty map[any]*entry ? // 寫操作目標(需 mu 保護)
? ? misses int ? ? ? ? ? ? // read 未命中次數(觸發 dirty 提升)
}
type entry struct {
? ? p unsafe.Pointer ?// 指向實際值(特殊標記:nil、expunged)
}
最核心的優化點,主要是用來了一個dirty的map和一個只讀的map.
read好比整個sync.Map的一個“高速緩存”,當goroutine從sync.Map中讀數據時,sync.Map會首先查看read這個緩存層是否有用戶需要的數據(key是否命中),如果有(key命中),則通過原子操作將數據讀取并返回,這是sync.Map推薦的快路徑(fast path),也是sync.Map的讀性能極高的原因。
- 寫操作:直接寫入dirty(負責寫的map)
- 讀操作:先讀read(負責讀操作的map),沒有再讀dirty(負責寫操作的map)
sync.Map 的實現原理可概括為:
- 通過 read 和 dirty 兩個字段實現數據的讀寫分離,讀的數據存在只讀字段 read 上,將最新寫入的數據則存在 dirty 字段上
- 讀取時會先查詢 read,不存在再查詢 dirty,寫入時則只寫入 dirty
- 讀取 read 并不需要加鎖,而讀或寫 dirty 則需要加鎖
- 另外有?misses?字段來統計 read 被穿透的次數(被穿透指需要讀 dirty 的情況),超過一定次數則將 dirty 數據更新到 read 中(觸發條件:misses=len(dirty))
與 Mutex+Map 的底層差異
特性 | sync.Map | Mutex + Map |
---|---|---|
讀性能 | ??? 無鎖(atomic) | ? 每次讀需加鎖 |
寫性能 | ? 需鎖升級(可能觸發 dirty 提升) | ?? 單一鎖保護 |
內存占用 | 高(雙份數據 + entry 包裝) | 低(直接存儲) |
刪除開銷 | 低(邏輯刪除 + 延遲清理) | 高(立即刪除 + 縮容開銷) |
適用場景 | 讀 >> 寫(如配置存儲、緩存) | 讀寫均衡或寫多讀少 |
再回頭看一下這些面試題:
1.?Golang 標準庫中 map 的底層數據結構是什么樣子的
?這個上面已經介紹過了,不再介紹
2.?Map 的查詢時間復雜度如何分析?
-
平均情況:O(1)
-
通過哈希值定位桶:O(1)
-
桶內遍歷(最多8個元素+溢出桶):O(1)
-
-
最壞情況:O(N)
-
所有元素哈希沖突到同一桶鏈
-
需遍歷整個桶鏈(N為元素總數)
-
這道問題主要考察的就是Map的底層數據結構,了解的話,其實這個很容易得知。
3.?極端情況下有很多哈希沖突,Golang 標準庫如何去避免最壞的查詢時間復雜度?
-
桶分裂策略:
-
每個桶最多存8個鍵值對,超限創建溢出桶
-
-
漸進式擴容:
-
負載因子>6.5時觸發擴容(桶數量×2)
-
-
哈希隨機化:
// 初始化時生成隨機種子 h.hash0 = fastrand()
-
SIMD 優化:
-
使用AVX2指令并行比對tophash(Go 1.20+)
-
4.?Golang map Rehash 的策略是怎樣的?什么時機會發生 Rehash?
這個上面也說過了,不再贅述,重要的點,還是理解了底層結構是hash,所以我們就知道必須去做Rehash的原因
5.?Rehash 具體會影響什么?哈希結果會受到什么影響?
6.?Rehash 過程中存放在舊桶的元素如何遷移?
7.?sync.Map 的 Load() 方法流程?
func (m *Map) Load(key any) (value any, ok bool) {// Step 1: 原子讀取readOnlyread, _ := m.read.Load().(readOnly)e, ok := read.m[key]if !ok && read.amended { // read不存在且dirty有數據m.mu.Lock()// Step 2: 雙檢查(避免鎖競爭期間read更新)read, _ = m.read.Load().(readOnly)e, ok = read.m[key]if !ok && read.amended {// Step 3: 查詢dirty(加鎖)e, ok = m.dirty[key]// Step 4: 更新miss計數器m.missLocked() }m.mu.Unlock()}if !ok { return nil, false }// Step 5: 從entry加載實際值return e.load()
}
8.?sync.Map Store() 如何保持緩存層和底層 Map 數據是相同的? 是不是每次執行修改都需要去加鎖?
func (m *Map) Store(key, value any) {// 情況1:read中存在可直接更新的條目if e, ok := read.m[key]; ok && e.tryStore(&value) {return // 無鎖CAS更新}m.mu.Lock()// 情況2:條目在read中但被標記刪除if e.unexpungeLocked() { m.dirty[key] = e // 重新加入dirty}// 情況3:新增條目if !read.amended {m.dirtyLocked() // 惰性初始化dirtym.read.Store(readOnly{m: read.m, amended: true // 標記dirty有額外數據})}m.dirty[key] = newEntry(value) // 寫入dirtym.mu.Unlock()
}
總結
作為一名工程師,其實需要對各類數據結構都應該有深入的一些理解,這個算是一個基本功,包括樹、圖等結構體。但是從大環境來說,一般業務上用到樹和圖的場景也是比較少,用的比較多的就是slice+map,考察點上,基本也就是上面的問題,把這些弄清楚,面試上基本不會有太大問題。但是日常來說還有一些是需要注意的:
1. slice的范圍判斷,避免出現panic問題。
2. slice和map有很多常用的使用方式,可以進行統一封裝
希望大家面試順利