字節(jié)一面,靠 volatile 這波回答,穩(wěn)住了!
前言
網(wǎng)友上周面字節(jié)后端一面,之前面騰訊時(shí)栽過volatile的坑——當(dāng)時(shí)被問 DCL 單例為什么加 volatile,只說了 “防重排序”,沒講清底層原理,直接掛了。
這次特意補(bǔ)了內(nèi)存屏障、MESI 協(xié)議的細(xì)節(jié),連x86和ARM的屏障差異都查了。還好提前把“作用+項(xiàng)目場(chǎng)景”捋順了,這次總算能穩(wěn)著答,順利進(jìn)入二面。
面試現(xiàn)場(chǎng)
面試官:“好,我們接下來聊 volatile。首先,你能先說說 volatile 關(guān)鍵字的核心作用是什么嗎?”
候選人:“volatile 主要有兩個(gè)核心作用:一是保證變量的內(nèi)存可見性,二是禁止指令重排序,但它不保證原子性。實(shí)際項(xiàng)目中,我們常用來修飾狀態(tài)標(biāo)記(比如線程停止信號(hào)),但會(huì)結(jié)合 CAS 或 synchronized 解決原子性問題(埋點(diǎn))?!?/span>
面試官:“那你提到的‘內(nèi)存可見性’具體怎么理解?volatile 是如何保證可見性的?”(追問 1)
候選人:“可見性是指一個(gè)線程修改 volatile 變量后,其他線程能立刻看到最新值。底層依賴 CPU 的MESI 緩存一致性協(xié)議:當(dāng)線程修改 volatile 變量時(shí),CPU 會(huì)將該變量所在的緩存行標(biāo)記為‘修改態(tài)’,并通過總線嗅探機(jī)制通知其他 CPU,讓它們緩存的該變量副本失效,后續(xù)讀取必須從主存重新加載,從而保證可見性?!?/span>
面試官:“OK,那‘禁止指令重排序’又是怎么實(shí)現(xiàn)的?底層涉及到什么關(guān)鍵技術(shù)?”(追問 2)
獨(dú)白:“應(yīng)該是說清楚啦”
候選人:“靠?jī)?nèi)存屏障(Memory Barrier) 實(shí)現(xiàn)。JVM 會(huì)為 volatile 變量的讀寫操作插入特定的內(nèi)存屏障,阻止屏障前后的指令重排序。比如寫 volatile 變量后,會(huì)插入 StoreLoad 屏障,確保寫操作先刷新到主存,再執(zhí)行后續(xù)指令;讀 volatile 變量前,會(huì)插入 LoadLoad 和 LoadStore 屏障,確保先讀完主存最新值,再執(zhí)行后續(xù)讀 / 寫操作,避免重排序?qū)е碌倪壿嬪e(cuò)亂?!?/span>
面試官:“你剛才說 volatile 不保證原子性,能舉個(gè)具體例子說明嗎?為什么會(huì)出現(xiàn)原子性問題?”(追問 3)
候選人:“比如經(jīng)典的volatile int i = 0;,多線程執(zhí)行i++操作,最終結(jié)果會(huì)小于預(yù)期。因?yàn)閕++拆成‘讀 i→加 1→寫 i’三個(gè)指令,volatile 只能保證讀和寫的可見性;
但中間的‘加 1’操作沒有被保護(hù) —— 線程 A 讀 i=0 后,線程 B 可能也讀 i=0,兩者都加 1 后寫回主存,最終 i=1 而非 2。這就是原子性缺失,所以我們會(huì)用 AtomicInteger 這類原子類解決,它底層是 CAS 機(jī)制(埋點(diǎn)引導(dǎo))?!?/span>
面試官:“那 tomicInteger 和 volatile 的核心區(qū)別是什么?為什么 AtomicInteger 能保證原子性?”(追問 4)
候選人:“核心區(qū)別是 AtomicInteger 通過CAS(Compare and Swap)機(jī)制保證原子性,而 volatile 只保證可見性和禁止重排序。AtomicInteger 的incrementAndGet()方法,會(huì)調(diào)用 Unsafe 類的compareAndSwapInt(),底層是 CPU 的原子指令(比如 x86 的 cmpxchg),能把‘讀 - 改 - 寫’三個(gè)操作打包成一個(gè)原子操作,不會(huì)被線程打斷;而 volatile 沒有這個(gè)機(jī)制,所以處理復(fù)合操作時(shí)會(huì)出問題?!?/span>
面試官:“回到內(nèi)存屏障,JVM 為 volatile 變量插入的內(nèi)存屏障具體有哪些規(guī)則?比如讀操作和寫操作分別插入什么屏障?”(追問 5)
獨(dú)白:“應(yīng)該是說清楚啦”
候選人:“JVM 有明確的內(nèi)存屏障插入規(guī)則,核心是‘四屏障兩操作’:
- 對(duì) volatile 變量的寫操作后,必須插入 StoreStore 屏障(確保前面的普通寫先執(zhí)行)和 StoreLoad 屏障(確保寫操作刷新到主存);
- 對(duì) volatile 變量的讀操作前,必須插入 LoadLoad 屏障(確保前面的普通讀先執(zhí)行)和 LoadStore 屏障(確保讀操作完成后再執(zhí)行普通寫)。
這樣就能完全禁止讀寫操作與其他指令的重排序,同時(shí)保證可見性?!?/span>
面試官:“那你有沒有了解過,不同 CPU 架構(gòu)(比如 x86、ARM)對(duì)內(nèi)存屏障的支持不一樣,JVM 是怎么適配這種差異的?”(追問 6)
候選人:“JVM 會(huì)根據(jù) CPU 架構(gòu)做‘屏障優(yōu)化’,因?yàn)椴煌?CPU 的內(nèi)存模型(比如 x86 的 TSO、ARM 的弱內(nèi)存模型)對(duì)重排序的限制不同。比如 x86 架構(gòu)本身禁止‘寫 - 讀’重排序,且支持緩存一致性;
所以 JVM 在 x86 上對(duì) volatile 寫操作,只需要插入 StoreLoad 屏障(唯一需要顯式指令的屏障),其他屏障(如 StoreStore)可以省略;而 ARM 架構(gòu)更弱,需要插入更多屏障指令,JVM 會(huì)通過底層的 Unsafe類或匯編指令適配,保證跨架構(gòu)的一致性?!?/span>
面試官:“我們聊個(gè)實(shí)際場(chǎng)景 —— 雙重檢查鎖(DCL)單例模式中,為什么 instance 要加 volatile?如果不加會(huì)出現(xiàn)什么問題?”(追問 7)
候選人:“因?yàn)?nbsp;instance = new Singleton() 會(huì)被 JVM 重排序成 ‘1. 分配內(nèi)存→2. 賦值 instance→3. 初始化對(duì)象’。如果不加 volatile,線程 A 執(zhí)行到步驟 2 時(shí),instance 已非 null,但對(duì)象還沒初始化;
此時(shí)線程 B 進(jìn)入 DCL 的第一層檢查(if (instance == null)),會(huì)直接返回未初始化的 instance,導(dǎo)致空指針異常。加 volatile 后,禁止‘賦值’和‘初始化’的重排序,同時(shí)保證可見性,線程 B 能看到要么是 null,要么是完全初始化的對(duì)象?!?/span>
面試官:“最后一個(gè)問題,volatile 的開銷和 synchronized 比起來怎么樣?在什么場(chǎng)景下會(huì)優(yōu)先選 volatile 而非 synchronized?”(追問 8)
候選人:“volatile 開銷更低,因?yàn)樗恍枰渔i(無鎖操作),也沒有 synchronized 的‘偏向鎖→輕量級(jí)鎖→重量級(jí)鎖’的鎖升級(jí)過程,僅通過內(nèi)存屏障和緩存一致性協(xié)議保證語義,執(zhí)行速度接近普通變量。
優(yōu)先選 volatile 的場(chǎng)景是:變量操作是‘單次讀 / 寫’(非復(fù)合操作),且需要可見性或禁止重排序,比如線程狀態(tài)標(biāo)記(isRunning)、配置參數(shù)(configFlag);如果涉及原子性操作(如計(jì)數(shù)),則用 synchronized 或原子類,避免 volatile 的局限性?!?/span>


































