cxuan 連這 10 個(gè)問(wèn)題都不會(huì)...
Hey guys ,我是 cxuan,歡迎你閱讀我最新一期的技術(shù)文章。這一篇文章我要和你聊一聊 Java 并發(fā)中關(guān)于內(nèi)存模型的那些事情,我會(huì)通過(guò)向你問(wèn)問(wèn)題的形式來(lái)展開(kāi),如果你有思路,可以先不要看我的答案,看看你的回答和我的答案是不是有出入,如果你有任何疑問(wèn),歡迎在這篇文章下方留言,下面開(kāi)始我們的正文!
究竟什么是內(nèi)存模型?
在多處理系統(tǒng)中,每個(gè) CPU 通常都包含一層或者多層內(nèi)存緩存,這樣設(shè)計(jì)的原因是為了加快數(shù)據(jù)訪問(wèn)速度(因?yàn)閿?shù)據(jù)會(huì)更靠近處理器) 并且能夠減少共享內(nèi)存總線上的流量(因?yàn)榭梢詽M足許多內(nèi)存操作)來(lái)提高性能。內(nèi)存緩存能夠極大的提高性能。
但是同時(shí),這種設(shè)計(jì)方式也帶來(lái)了許多挑戰(zhàn)。
比如,當(dāng)兩個(gè) CPU 同時(shí)對(duì)同一內(nèi)存位置進(jìn)行操作時(shí)會(huì)發(fā)生什么?在什么情況下這兩個(gè) CPU 會(huì)看到同一個(gè)內(nèi)存值?
現(xiàn)在,內(nèi)存模型登場(chǎng)了!?。≡谔幚砥鲗用?,內(nèi)存模型明確定義了其他處理器的寫(xiě)入是如何對(duì)當(dāng)前處理器保持可見(jiàn)的,以及當(dāng)前處理器寫(xiě)入內(nèi)存的值是如何使其他處理器可見(jiàn)的,這種特性被稱為可見(jiàn)性,這是官方定義的一種說(shuō)法。
然而,可見(jiàn)性也分為強(qiáng)可見(jiàn)性和弱可見(jiàn)性,強(qiáng)可見(jiàn)性說(shuō)的是任何 CPU 都能夠看到指定內(nèi)存位置具有相同的值;弱可見(jiàn)性說(shuō)的是需要一種被稱為內(nèi)存屏障的特殊指令來(lái)刷新緩存或者使本地處理器緩存無(wú)效,才能看到其他 CPU 對(duì)指定內(nèi)存位置寫(xiě)入的值,寫(xiě)入后的值就是內(nèi)存值。這些特殊的內(nèi)存屏障是被封裝之后的,我們不研究源碼的話是不知道內(nèi)存屏障這個(gè)概念的。
內(nèi)存模型還規(guī)定了另外一種特性,這種特性能夠使編譯器對(duì)代碼進(jìn)行重新排序(其實(shí)重新排序不只是編譯器所具有的特性),這種特性被稱為有序性。如果兩行代碼彼此沒(méi)有相關(guān)性,那么編譯器是能夠改變這兩行代碼的編譯順序的,只要代碼不會(huì)改變程序的語(yǔ)義,那么編譯器就會(huì)這樣做。
我們上面剛提到了,重新排序不只是編譯器所特有的功能,編譯器的這種重排序只是一種靜態(tài)重排序,其實(shí)在運(yùn)行時(shí)或者硬件執(zhí)行指令的過(guò)程中也會(huì)發(fā)生重排序,重排序是一種提高程序運(yùn)行效率的一種方式。
比如下面這段代碼
Class Reordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}
當(dāng)兩個(gè)線程并行執(zhí)行上面這段代碼時(shí),可能會(huì)發(fā)生重排序現(xiàn)象,因?yàn)?x 、 y 是兩個(gè)互不相關(guān)的變量,所以當(dāng)線程一執(zhí)行到 writer 中時(shí),發(fā)生重排序,y = 2 先被編譯,然后線程切換,執(zhí)行 r1 的寫(xiě)入,緊接著執(zhí)行 r2 的寫(xiě)入,注意此時(shí) x 的值是 0 ,因?yàn)?x = 1 沒(méi)有編譯。這時(shí)候線程切換到 writer ,編譯 x = 1,所以最后的值為 r1 = 2,r2 = 0,這就是重排序可能導(dǎo)致的后果。
所以 Java 內(nèi)存模型為我們帶來(lái)了什么?
Java 內(nèi)存模型描述了多線程中哪些行為是合法的,以及線程之間是如何通過(guò)內(nèi)存進(jìn)行交互的。Java 內(nèi)存模型提供了兩種特性,即變量之間的可見(jiàn)性和有序性,這些特性是需要我們?cè)谌粘i_(kāi)發(fā)中所注意到的點(diǎn)。Java 中也提供了一些關(guān)鍵字比如 volatile、final 和 synchronized 來(lái)幫助我們應(yīng)對(duì) Java 內(nèi)存模型帶來(lái)的問(wèn)題,同時(shí) Java 內(nèi)存模型也定義了 volatile 和 synchronized 的行為。
其他語(yǔ)言,比如 C++ 會(huì)有內(nèi)存模型嗎?
其他語(yǔ)言比如 C 和 C++ 在設(shè)計(jì)時(shí)并未直接支持多線程,這些語(yǔ)言針對(duì)編譯器和硬件發(fā)生的重排序是依靠線程庫(kù)(比如 pthread )、所使用的編譯器以及運(yùn)行代碼的平臺(tái)提供的保證。
JSR - 133 是關(guān)于啥的?
在 1997 年,在此時(shí) Java 版本中的內(nèi)存模型中發(fā)現(xiàn)了幾個(gè)嚴(yán)重的缺陷,這個(gè)缺陷經(jīng)常會(huì)出現(xiàn)詭異的問(wèn)題,比如字段的值經(jīng)常會(huì)發(fā)生改變,并且非常容易削弱編譯器的優(yōu)化能力。
所以,Java 提出了一項(xiàng)雄心勃勃的暢想:合并內(nèi)存模型,這是編程語(yǔ)言規(guī)范第一次嘗試合并一個(gè)內(nèi)存模型,這個(gè)模型能夠?yàn)榭绺鞣N架構(gòu)的并發(fā)性提供一致的語(yǔ)義,但是實(shí)際操作起來(lái)要比暢想困難很多。
最終,JSR-133 為 Java 語(yǔ)言定義了一個(gè)新的內(nèi)存模型,它修復(fù)了早期內(nèi)存模型的缺陷。
所以,我們說(shuō)的 JSR - 133 是關(guān)于內(nèi)存模型的一種規(guī)范和定義。
JSR - 133 的設(shè)計(jì)目標(biāo)主要包括:
保留 Java 現(xiàn)有的安全性保證,比如類型安全,并加強(qiáng)其他安全性保證,比如線程觀察到的每個(gè)變量的值都必須是某個(gè)線程對(duì)變量進(jìn)行修改之后的。
程序的同步語(yǔ)義應(yīng)該盡可能簡(jiǎn)單和直觀。
將多線程如何交互的細(xì)節(jié)交給程序員進(jìn)行處理。
在廣泛、流行的硬件架構(gòu)上設(shè)計(jì)正確、高性能的 JVM 實(shí)現(xiàn)。
應(yīng)提供初始化安全的保證,如果一個(gè)對(duì)象被正確構(gòu)造后,那么所有看到對(duì)象構(gòu)造的線程都能夠看到構(gòu)造函數(shù)中設(shè)置其最終字段的值,而不用進(jìn)行任何的同步操作。
對(duì)現(xiàn)有的代碼影響要盡可能的小。
重排序是什么?
在很多情況下,訪問(wèn)程序變量,比如對(duì)象實(shí)例字段、類靜態(tài)字段和數(shù)組元素的執(zhí)行順序與程序員編寫(xiě)的程序指定的執(zhí)行順序不同。編譯器可以以優(yōu)化的名義任意調(diào)整指令的執(zhí)行順序。在這種情況下,數(shù)據(jù)可以按照不同于程序指定的順序在寄存器、處理器緩存和內(nèi)存之間移動(dòng)。
有許多潛在的重新排序來(lái)源,例如編譯器、JIT(即時(shí)編譯)和緩存。
重排序是硬件、編譯器一起制造出來(lái)的一種錯(cuò)覺(jué),在單線程程序中不會(huì)發(fā)生重排序的現(xiàn)象,重排序往往發(fā)生在未正確同步的多線程程序中。
舊的內(nèi)存模型有什么錯(cuò)誤?
新內(nèi)存模型的提出是為了彌補(bǔ)舊內(nèi)存模型的不足,所以舊內(nèi)存模型有哪些不足,我相信讀者也能大致猜到了。
首先,舊的內(nèi)存模型不允許發(fā)生重排序。再一點(diǎn),舊的內(nèi)存模型沒(méi)有保證 final 的真正 不可變性,這是一個(gè)非常令人大跌眼睛的結(jié)論,舊的內(nèi)存模型沒(méi)有把 final 和其他不用 final 修飾的字段區(qū)別對(duì)待,這也就意味著,String 并非是真正不可變,這確實(shí)是一個(gè)非常嚴(yán)重的問(wèn)題。
其次,舊的內(nèi)存模型允許 volatile 寫(xiě)入與非 volatile 讀取和寫(xiě)入重新排序,這與大多數(shù)開(kāi)發(fā)人員對(duì) volatile 的直覺(jué)不一致,因此引起了混亂。
什么是不正確同步?
當(dāng)我們討論不正確同步的時(shí)候,我們指的是任何代碼
一個(gè)線程對(duì)一個(gè)變量執(zhí)行寫(xiě)操作,
另一個(gè)線程讀取了相同的變量,
并且讀寫(xiě)之間并沒(méi)有正確的同步
當(dāng)違反這些規(guī)則時(shí),我們說(shuō)在這個(gè)變量上發(fā)生了數(shù)據(jù)競(jìng)爭(zhēng)現(xiàn)象。具有數(shù)據(jù)競(jìng)爭(zhēng)現(xiàn)象的程序是不正確同步的程序。
同步(synchronization)都做了哪些事情?
同步有幾個(gè)方面,最容易理解的是互斥,也就是說(shuō)一次只有一個(gè)線程可以持有一個(gè)監(jiān)視器(monitor),所以在 monitor 上的同步意味著一旦一個(gè)線程進(jìn)入一個(gè)受 monitor 保護(hù)的同步代碼塊,其他線程就不能進(jìn)入受該 monitor 保護(hù)的塊直到第一個(gè)線程退出同步代碼塊。
但是同步不僅僅只有互斥,它還有可見(jiàn),同步能夠確保線程在進(jìn)入同步代碼塊之前和同步代碼塊執(zhí)行期間,線程寫(xiě)入內(nèi)存的值對(duì)在同一 monitor 上同步的其他線程可見(jiàn)。
在進(jìn)入同步塊之前,會(huì)獲取 monitor ,它具有使本地處理器緩存失效的效果,以便變量將從主內(nèi)存中重新讀取。在退出一個(gè)同步代碼塊后,會(huì)釋放 monitor ,它具有將緩存刷新到主存的功能,以便其他線程可以看到該線程所寫(xiě)入的值。
新的內(nèi)存模型語(yǔ)義在內(nèi)存操作上面制定了一些特定的順序,這些內(nèi)存操作包含(read、write、lock、unlock)和一些線程操作(start 、join),這些特定的順序保證了第一個(gè)動(dòng)作在執(zhí)行之前對(duì)第二個(gè)動(dòng)作可見(jiàn),這就是 happens-before 原則,這些特定的順序有
線程中的每個(gè)操作都 happens - before 按照程序定義的線程操作之前。
Monitor 中的每個(gè) unlock 操作都 happens-before 相同 monitor 的后續(xù) lock 操作之前。
對(duì) volatile 字段的寫(xiě)入都 happens-before 在每次后續(xù)讀取同一 volatile 變量之前。
對(duì)線程的 start() 調(diào)用都 happens-before 在已啟動(dòng)線程的任何操作之前。
線程中的所有操作都 happens-before 在任何其他線程從該線程上的 join() 成功返回之前。
需要注意非常重要的一點(diǎn):兩個(gè)線程在同一個(gè) monitor 之間的同步非常重要。并不是線程 A 在對(duì)象 X 上同步時(shí)可見(jiàn)的所有內(nèi)容在對(duì)象 Y 上同步后對(duì)線程 B 可見(jiàn)。釋放和獲取必須進(jìn)行
匹配(即,在同一個(gè) monitor 上執(zhí)行)才能有正確的內(nèi)存語(yǔ)義,否則就會(huì)發(fā)生數(shù)據(jù)競(jìng)爭(zhēng)現(xiàn)象。
另外,關(guān)于 synchronized 在 Java 中的用法,你可以參考這篇文章 synchronized 的超多干貨!
final 在新的 JMM 下是如何工作的?
通過(guò)上面的講述,你現(xiàn)在已經(jīng)知道,final 在舊的 JMM 下是無(wú)法正常工作的,在舊的 JMM 下,final 的語(yǔ)義就和普通的字段一樣,沒(méi)什么其他區(qū)別,但是在新的 JMM 下,final 的這種內(nèi)存語(yǔ)義發(fā)生了質(zhì)的改變,下面我們就來(lái)探討一下 final 在新的 JMM 下是如何工作的。
對(duì)象的 final 字段在構(gòu)造函數(shù)中設(shè)置,一旦對(duì)象被正確的構(gòu)造出來(lái),那么在構(gòu)造函數(shù)中的 final 的值將對(duì)其他所有線程可見(jiàn),無(wú)需進(jìn)行同步操作。
什么是正確的構(gòu)造呢?
正確的構(gòu)造意味著在構(gòu)造的過(guò)程中不允許對(duì)正在構(gòu)造的對(duì)象的引用發(fā)生 逃逸,也就是說(shuō),不要將正在構(gòu)造的對(duì)象的引用放在另外一個(gè)線程能夠看到它的地方。下面是一個(gè)正確構(gòu)造的示例:
class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
}
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
}
執(zhí)行讀取器的線程一定會(huì)看到 f.x 的值 3,因?yàn)樗?final 的。不能保證看到 y 的值 4,因?yàn)樗皇?final 的。如果 FinalFieldExample 的構(gòu)造函數(shù)如下所示:
public FinalFieldExample() {
x = 3;
y = 4;
// 錯(cuò)誤的構(gòu)造,可能會(huì)發(fā)生逃逸
global.obj = this;
}
這樣就不會(huì)保證讀取 x 的值一定是 3 了。
這也就說(shuō)是,如果在一個(gè)線程構(gòu)造了一個(gè)不可變對(duì)象(即一個(gè)只包含 final 字段的對(duì)象)之后,你想要確保它被所有其他線程正確地看到,通常仍然需要正確的使用同步。
volatile 做了哪些事情?
我寫(xiě)過(guò)一篇 volatile 的詳細(xì)用法和其原理的文章,你可以閱讀這篇文章 volatile 的用法和實(shí)現(xiàn)原理
新的內(nèi)存模型修復(fù)了雙重檢查鎖的問(wèn)題嗎?
也許我們大家都見(jiàn)過(guò)多線程單例模式雙重檢查鎖的寫(xiě)法,這是一種支持延遲初始化同時(shí)避免同步開(kāi)銷的技巧。
class DoubleCheckSync{
private static DoubleCheckSync instance = null;
public DoubleCheckSync getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new DoubleCheckSync();
}
}
return instance;
}
}
這樣的代碼看起來(lái)在程序定義的順序上看起來(lái)很聰明,但是這段代碼卻有一個(gè)致命的問(wèn)題:它不起作用。
??????
雙重檢查鎖不起作用?
是的!
為毛?
原因就是初始化實(shí)例的寫(xiě)入和對(duì)實(shí)例字段的寫(xiě)入可以由編譯器或緩存重新排序,看起來(lái)我們可能讀取了初始化了 instance 對(duì)象,但其實(shí)你可能只是讀取了一個(gè)未初始化的 instance 對(duì)象。
有很多小伙伴認(rèn)為使用 volatile 能夠解決這個(gè)問(wèn)題,但是在 1.5 之前的 JVM 中,volatile 不能保證。在新的內(nèi)存模型下,使用 volatile 會(huì)修復(fù)雙重檢查鎖定的問(wèn)題,因?yàn)檫@樣在構(gòu)造線程初始化 DoubleCheckSync 和返回其值之間將存在 happens-before 關(guān)系讀取它的線程。
完
往期推薦
??
計(jì)算機(jī)網(wǎng)絡(luò)的 89 個(gè)核心概念
另外,cxuan 肝了六本 PDF,公號(hào)回復(fù) cxuan ,領(lǐng)取作者全部 PDF 。

