摘要:如果編寫(xiě)的并發(fā)程序出現(xiàn)問(wèn)題時(shí),很難通過(guò)調(diào)試來(lái)解決相應(yīng)的問(wèn)題,此時(shí),需要一行行的檢查代碼,這個(gè)時(shí)候,如果充分理解并掌握了Java的內(nèi)存模型,你就能夠很快分析并定位出問(wèn)題所在。


本文分享自華為云社區(qū)??《 【高并發(fā)】如何解決可見(jiàn)性和有序性問(wèn)題?這次徹底懂了!》??,作者:冰 河 。


今天,我們先來(lái)看看在Java中是如何解決線(xiàn)程的可見(jiàn)性和有序性問(wèn)題的,說(shuō)到這,就不得不提一個(gè)Java的核心技術(shù),那就是——Java的內(nèi)存模型。


如果編寫(xiě)的并發(fā)程序出現(xiàn)問(wèn)題時(shí),很難通過(guò)調(diào)試來(lái)解決相應(yīng)的問(wèn)題,此時(shí),需要一行行的檢查代碼,這個(gè)時(shí)候,如果充分理解并掌握了Java的內(nèi)存模型,你就能夠很快分析并定位出問(wèn)題所在。

什么是Java內(nèi)存模型?

在內(nèi)存里,Java內(nèi)存模型規(guī)定了所有的變量都存儲(chǔ)在主內(nèi)存(物理內(nèi)存)中,每條線(xiàn)程還有自己的工作內(nèi)存,線(xiàn)程對(duì)變量的所有操作都必須在工作內(nèi)存中進(jìn)行。不同的線(xiàn)程無(wú)法訪(fǎng)問(wèn)其他線(xiàn)程的工作內(nèi)存里的內(nèi)容。我們可以使用下圖來(lái)表示在邏輯上 線(xiàn)程、主內(nèi)存、工作內(nèi)存的三者交互關(guān)系。


掌握J(rèn)ava的內(nèi)存模型,你就是解決并發(fā)問(wèn)題最靚的仔_java內(nèi)存模型


現(xiàn)在,我們都理解了緩存導(dǎo)致了可見(jiàn)性問(wèn)題,編譯優(yōu)化導(dǎo)致了有序性問(wèn)題。也就是說(shuō)解決可見(jiàn)性和有序性問(wèn)題的最直接的辦法就是禁用緩存和編譯優(yōu)化。但是,如果只是簡(jiǎn)單的禁用了緩存和編譯優(yōu)化,那我們寫(xiě)的所謂的高并發(fā)程序的性能也就高不到哪去了!甚至?xí)蛦尉€(xiàn)程程序的性能沒(méi)什么兩樣!有時(shí),由于競(jìng)爭(zhēng)鎖的存在,可能會(huì)比單線(xiàn)程程序的性能還要低。


那么,既然不能完全禁用緩存和編譯優(yōu)化,那如何解決可見(jiàn)性和有序性的問(wèn)題呢?其實(shí),合理的方案應(yīng)該是按照需要禁用緩存和編譯優(yōu)化。什么是按需禁用緩存和編譯優(yōu)化呢?簡(jiǎn)單點(diǎn)來(lái)說(shuō),就是需要禁用的時(shí)候禁用,不需要禁用的時(shí)候就不禁用。有些人可能會(huì)說(shuō),這不廢話(huà)嗎?其實(shí)不然,我們繼續(xù)向下看。


何時(shí)禁用和不禁用緩存和編譯優(yōu)化,可以根據(jù)編寫(xiě)高并發(fā)程序的開(kāi)發(fā)人員的要求來(lái)合理的確定(這里需要重點(diǎn)理解)。所以,可以這么說(shuō),為了解決可見(jiàn)性和有序性問(wèn)題,Java只需要提供給Java程序員按照需要禁用緩存和編譯優(yōu)化的方法即可。


掌握J(rèn)ava的內(nèi)存模型,你就是解決并發(fā)問(wèn)題最靚的仔_編譯優(yōu)化_02


Java內(nèi)存模型是一個(gè)非常復(fù)雜的規(guī)范,網(wǎng)上關(guān)于Java內(nèi)存模型的文章很多,但是大多數(shù)說(shuō)的都是理論,理論說(shuō)多了就成了廢話(huà)。這里,我不會(huì)太多的介紹Java內(nèi)存模型那些晦澀難懂的理論知識(shí)。 其實(shí),作為開(kāi)發(fā)人員,我們可以這樣理解Java的內(nèi)存模型:Java內(nèi)存模型規(guī)范了Java虛擬機(jī)(JVM)如何提供按需禁用緩存和編譯優(yōu)化的方法。


掌握J(rèn)ava的內(nèi)存模型,你就是解決并發(fā)問(wèn)題最靚的仔_編譯優(yōu)化_03


說(shuō)的具體一些,這些方法包括:volatile、synchronized和final關(guān)鍵字,以及Java內(nèi)存模型中的Happens-Before規(guī)則。


掌握J(rèn)ava的內(nèi)存模型,你就是解決并發(fā)問(wèn)題最靚的仔_編譯優(yōu)化_04

volatile為何能保證線(xiàn)程間可見(jiàn)?

volatile關(guān)鍵字不是Java特有的,在C語(yǔ)言中也存在volatile關(guān)鍵字,這個(gè)關(guān)鍵字最原始的意義就是禁用CPU緩存。

例如,我們?cè)诔绦蛑惺褂胿olatile關(guān)鍵字聲明了一個(gè)變量,如下所示。


volatile int count = 0


此時(shí),Java對(duì)這個(gè)變量的讀寫(xiě),不能使用CPU緩存,必須從內(nèi)存中讀取和寫(xiě)入。


掌握J(rèn)ava的內(nèi)存模型,你就是解決并發(fā)問(wèn)題最靚的仔_共享變量_05


藍(lán)色的虛線(xiàn)箭頭代表禁用了CPU緩存,黑色的實(shí)線(xiàn)箭頭代表直接從主內(nèi)存中讀寫(xiě)數(shù)據(jù)。

接下來(lái),我們一起來(lái)看一個(gè)代碼片段,如下所示。

【示例一】

class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 1;
v = true;
}

public void reader() {
if (v == true) {
//x的值是多少呢?
}
}
}


以上示例來(lái)源于:http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#finalWrong

這里,假設(shè)線(xiàn)程A執(zhí)行writer()方法,按照volatile會(huì)將v=true寫(xiě)入內(nèi)存;線(xiàn)程B執(zhí)行reader()方法,按照volatile,線(xiàn)程B會(huì)從內(nèi)存中讀取變量v,如果線(xiàn)程B讀取到的變量v為true,那么,此時(shí)的變量x的值是多少呢??


這個(gè)示例程序給人的直覺(jué)就是x的值為1,其實(shí),x的值具體是多少和JDK的版本有關(guān),如果使用的JDK版本低于1.5,則x的值可能為1,也可能為0。如果使用1.5及1.5以上版本的JDK,則x的值就是1。


看到這個(gè),就會(huì)有人提出問(wèn)題了?這是為什么呢?其實(shí),答案就是在JDK1.5版本中的Java內(nèi)存模型中引入了Happens-Before原則。

Happens-Before原則

我們可以將Happens-Before原則總結(jié)成如下圖所示。


掌握J(rèn)ava的內(nèi)存模型,你就是解決并發(fā)問(wèn)題最靚的仔_java_06


接下來(lái),我們就結(jié)合案例程序來(lái)說(shuō)明Java內(nèi)存模型中的Happens-Before原則。

【原則一】程序次序規(guī)則

在一個(gè)線(xiàn)程中,按照代碼的順序,前面的操作Happens-Before于后面的任意操作。

例如【示例一】中的程序x=1會(huì)在v=true之前執(zhí)行。這個(gè)規(guī)則比較符合單線(xiàn)程的思維:在同一個(gè)線(xiàn)程中,程序在前面對(duì)某個(gè)變量的修改一定是對(duì)后續(xù)操作可見(jiàn)的。

【原則二】volatile變量規(guī)則

對(duì)一個(gè)volatile變量的寫(xiě)操作,Happens-Before于后續(xù)對(duì)這個(gè)變量的讀操作。

也就是說(shuō),對(duì)一個(gè)使用了volatile變量的寫(xiě)操作,先行發(fā)生于后面對(duì)這個(gè)變量的讀操作。這個(gè)需要大家重點(diǎn)理解。

【原則三】傳遞規(guī)則

如果A Happens-Before B,并且B Happens-Before C,則A Happens-Before C。

我們結(jié)合【原則一】、【原則二】和【原則三】再來(lái)看【示例一】程序,此時(shí),我們可以得出如下結(jié)論:

(1)x = 1 Happens-Before 寫(xiě)變量v = true,符合【原則一】程序次序規(guī)則。

(2)寫(xiě)變量v = true Happens-Before 讀變量v = true,符合【原則二】volatile變量規(guī)則。


再根據(jù)【原則三】傳遞規(guī)則,我們可以得出結(jié)論:x = 1 Happens-Before 讀變量v=true。

也就是說(shuō),如果線(xiàn)程B讀取到了v=true,那么,線(xiàn)程A設(shè)置的x = 1對(duì)線(xiàn)程B就是可見(jiàn)的。換句話(huà)說(shuō),就是此時(shí)的線(xiàn)程B能夠訪(fǎng)問(wèn)到x=1。

其實(shí),Java 1.5版本的 java.util.concurrent并發(fā)工具就是靠volatile語(yǔ)義來(lái)實(shí)現(xiàn)可見(jiàn)性的。

【原則四】鎖定規(guī)則

對(duì)一個(gè)鎖的解鎖操作 Happens-Before于后續(xù)對(duì)這個(gè)鎖的加鎖操作。

例如,下面的代碼,在進(jìn)入synchronized代碼塊之前,會(huì)自動(dòng)加鎖,在代碼塊執(zhí)行完畢后,會(huì)自動(dòng)釋放鎖。

【示例二】

public class Test{
private int x = 0;
public void initX{
synchronized(this){ //自動(dòng)加鎖
if(this.x < 10){
this.x = 10;
}
} //自動(dòng)釋放鎖
}
}


我們可以這樣理解這段程序:假設(shè)變量x的值為10,線(xiàn)程A執(zhí)行完synchronized代碼塊之后將x變量的值修改為10,并釋放synchronized鎖。當(dāng)線(xiàn)程B進(jìn)入synchronized代碼塊時(shí),能夠獲取到線(xiàn)程A對(duì)x變量的寫(xiě)操作,也就是說(shuō),線(xiàn)程B訪(fǎng)問(wèn)到的x變量的值為10。

【原則五】線(xiàn)程啟動(dòng)規(guī)則

如果線(xiàn)程A調(diào)用線(xiàn)程B的start()方法來(lái)啟動(dòng)線(xiàn)程B,則start()操作Happens-Before于線(xiàn)程B中的任意操作。

我們也可以這樣理解線(xiàn)程啟動(dòng)規(guī)則:線(xiàn)程A啟動(dòng)線(xiàn)程B之后,線(xiàn)程B能夠看到線(xiàn)程A在啟動(dòng)線(xiàn)程B之前的操作。

我們來(lái)看下面的代碼。

【示例三】

//在線(xiàn)程A中初始化線(xiàn)程B
Thread threadB = new Thread(()->{
//此處的變量x的值是多少呢?答案是100
});
//線(xiàn)程A在啟動(dòng)線(xiàn)程B之前將共享變量x的值修改為100
x = 100;
//啟動(dòng)線(xiàn)程B
threadB.start();


上述代碼是在線(xiàn)程A中執(zhí)行的一個(gè)代碼片段,根據(jù)【原則五】線(xiàn)程的啟動(dòng)規(guī)則,線(xiàn)程A啟動(dòng)線(xiàn)程B之后,線(xiàn)程B能夠看到線(xiàn)程A在啟動(dòng)線(xiàn)程B之前的操作,在線(xiàn)程B中訪(fǎng)問(wèn)到的x變量的值為100。

【原則六】線(xiàn)程終結(jié)規(guī)則

線(xiàn)程A等待線(xiàn)程B完成(在線(xiàn)程A中調(diào)用線(xiàn)程B的join()方法實(shí)現(xiàn)),當(dāng)線(xiàn)程B完成后(線(xiàn)程A調(diào)用線(xiàn)程B的join()方法返回),則線(xiàn)程A能夠訪(fǎng)問(wèn)到線(xiàn)程B對(duì)共享變量的操作。

例如,在線(xiàn)程A中進(jìn)行的如下操作。

【示例四】

Thread threadB = new Thread(()-{
//在線(xiàn)程B中,將共享變量x的值修改為100
x = 100;
});
//在線(xiàn)程A中啟動(dòng)線(xiàn)程B
threadB.start();
//在線(xiàn)程A中等待線(xiàn)程B執(zhí)行完成
threadB.join();
//此處訪(fǎng)問(wèn)共享變量x的值為100

【原則七】線(xiàn)程中斷規(guī)則

對(duì)線(xiàn)程interrupt()方法的調(diào)用Happens-Before于被中斷線(xiàn)程的代碼檢測(cè)到中斷事件的發(fā)生。

例如,下面的程序代碼。在線(xiàn)程A中中斷線(xiàn)程B之前,將共享變量x的值修改為100,則當(dāng)線(xiàn)程B檢測(cè)到中斷事件時(shí),訪(fǎng)問(wèn)到的x變量的值為100。

【示例五】

//在線(xiàn)程A中將x變量的值初始化為0
private int x = 0;

public void execute(){
//在線(xiàn)程A中初始化線(xiàn)程B
Thread threadB = new Thread(()->{
//線(xiàn)程B檢測(cè)自己是否被中斷
if (Thread.currentThread().isInterrupted()){
//如果線(xiàn)程B被中斷,則此時(shí)X的值為100
System.out.println(x);
}
});
//在線(xiàn)程A中啟動(dòng)線(xiàn)程B
threadB.start();
//在線(xiàn)程A中將共享變量X的值修改為100
x = 100;
//在線(xiàn)程A中中斷線(xiàn)程B
threadB.interrupt();
}

【原則八】對(duì)象終結(jié)原則

一個(gè)對(duì)象的初始化完成Happens-Before于它的finalize()方法的開(kāi)始。

例如,下面的程序代碼。

【示例六】

public class TestThread {

public TestThread(){
System.out.println("構(gòu)造方法");
}

@Override
protected void finalize() throws Throwable {
System.out.println("對(duì)象銷(xiāo)毀");
}

public static void main(String[] args){
new TestThread();
System.gc();
}
}


?運(yùn)行結(jié)果如下所示。


構(gòu)造方法
對(duì)象銷(xiāo)毀

再說(shuō)final關(guān)鍵字

使用final關(guān)鍵字修飾的變量,是不會(huì)被改變的。但是在Java 1.5之前的版本中,使用final修飾的變量也會(huì)出現(xiàn)錯(cuò)誤的情況,在Java 1.5版本之后,Java內(nèi)存模型對(duì)使用final關(guān)鍵字修飾的變量的重排序進(jìn)行了一定的約束。只要我們能夠提供正確的構(gòu)造函數(shù)就不會(huì)出現(xiàn)問(wèn)題。


例如,下面的程序代碼,在構(gòu)造函數(shù)中將this賦值給了全局變量global.obj,此時(shí)對(duì)象初始化還沒(méi)有完成,此時(shí)對(duì)象初始化還沒(méi)有完成,此時(shí)對(duì)象初始化還沒(méi)有完成,重要的事情說(shuō)三遍!!線(xiàn)程通過(guò)global.obj讀取的x值可能為0。

【示例七】

final x = 0;
public FinalFieldExample() { // bad!
x = 3;
y = 4;
// bad construction - allowing this to escape
global.obj = this;
}


以上示例來(lái)源于:http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#finalWrong

Java內(nèi)存模式的底層實(shí)現(xiàn)

主要是通過(guò)內(nèi)存屏障(memory barrier)禁止重排序的, 即時(shí)編譯器根據(jù)具體的底層體系架構(gòu), 將這些內(nèi)存屏障替換成具體的 CPU 指令。 對(duì)于編譯器而言,內(nèi)存屏障將限制它所能做的重排序優(yōu)化。 而對(duì)于處理器而言, 內(nèi)存屏障將會(huì)導(dǎo)致緩存的刷新操作。 比如, 對(duì)于volatile, 編譯器將在volatile字段的讀寫(xiě)操作前后各插入一些內(nèi)存屏障。


??點(diǎn)擊關(guān)注,第一時(shí)間了解華為云新鮮技術(shù)~??