一、Show me your code
先考大家一個問題,如下這段代碼,多線程執(zhí)行有沒有問題。
public class Main {
static int num = 0;
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int i1 = 0; i1 < 5000; i1++) {
num++; // 共享資源
}
}).start();
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(num); // 打印結(jié)果
}
}
有經(jīng)驗(yàn)的人很容易知道,num 值的打印結(jié)果是不確定的。這就是 Java 多線程訪問共享資源 的問題。接下來試著解釋一下,為什么會出現(xiàn)問題。
1.1 現(xiàn)象解釋
Java多線程內(nèi)存模型,是Java屏蔽掉各種硬件和操作系統(tǒng)的內(nèi)存訪問差異,以實(shí)現(xiàn)讓Java程序在各種平臺下都能達(dá)到一致的內(nèi)存訪問效果。(說白了就是定義程序中變量的訪問規(guī)則)

工作內(nèi)存:每個線程都有自己的工作內(nèi)存,工作內(nèi)存保存了線程需要的變量在主內(nèi)存中的副本。線程對主內(nèi)存變量的修改必須在線程的工作內(nèi)存中進(jìn)行,不能直接讀寫主內(nèi)存中變量。
關(guān)于主內(nèi)存 和 工作內(nèi)存的交互 協(xié)議,java虛擬機(jī)定義了8中操作來完成,且每種協(xié)議必須是 原子性 的。
- lock: 作用于主內(nèi)存變量,它把一個變量標(biāo)識為一個線程獨(dú)占的狀態(tài)。
- unlock:作用于主內(nèi)存變量,它把一個處于 lock 狀態(tài)的變量釋放出來,釋放后才能被其它變量鎖定。
lock 和 unlock 對應(yīng)的字節(jié)碼層面就是 monitorcenter、monitorexit 指令,對應(yīng)Java層面就是 synchronized 代碼塊)
- read:作用于主內(nèi)存變量,把一個變量從主內(nèi)存?zhèn)鬏數(shù)骄€程工作內(nèi)存,以便以后的 load 使用
- load:作用于工作內(nèi)存變量,把read到的變量放入工作內(nèi)存的變量副本中
- use:作用于工作內(nèi)存變量,把工作內(nèi)存中的變量傳遞給執(zhí)行引擎。(每當(dāng)虛擬機(jī)遇到一個需要使用變量值的字節(jié)碼指令時會執(zhí)行這個操作)
- assign:作用于工作內(nèi)存變量,把一個從執(zhí)行引擎收到的值賦值給工作內(nèi)存中的變量。(每當(dāng)虛擬機(jī)遇到一個給變量賦值的字節(jié)碼指令時執(zhí)行這個操作)
- store:作用于工作內(nèi)存變量,把值從工作內(nèi)存?zhèn)鬏數(shù)街鲀?nèi)存,以便后續(xù)write操作使用
- write:作用于主內(nèi)存變量,把store 得到的值賦值給主內(nèi)存中的變量
jvm還定義了很多規(guī)則,比如:對一個變量執(zhí)行 unlock之前,必須先把變量同步回主內(nèi)存中(執(zhí)行store、write)。
通過以上講解,應(yīng)該知道示例代碼為什么多線程是不安全的:因?yàn)榭赡軙卸鄠€線程同時把 num 讀入到了 工作線程,這樣在其它線程把新的 num 值寫回主內(nèi)存的時候,有些線程的工作內(nèi)存的值還是舊的,所以就會出問題。
Java內(nèi)存模型是圍繞著在并發(fā)開發(fā)過程中如何處理 原子性、 可見性 和 有序性 這3個特征來建立的。
原子性:體現(xiàn)在 read/load/assign/use/store/write/lock/unlock
可見性: 體現(xiàn)在volatile 、synchronized(對一個變量執(zhí)行 unlock之前,必須先把變量同步回主內(nèi)存中) 、final
有序性:volatile(放棄指令重排,因?yàn)閮?nèi)存屏障保證了,必須先把工作內(nèi)存寫回主內(nèi)存才可以進(jìn)行接下來操作) synchronized(一個時刻只允許一條線程對其進(jìn)行l(wèi)ock操作)
二、解決辦法
知道了,示例代碼的問題所在,Java提供了哪些同步機(jī)制呢。
2.1 synchronized
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int i1 = 0; i1 < 5000; i1++) {
synchronized (Main.class){ // synchronized
num++;
}
}
}).start();
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(num);
}
Java層面的synchronized,在字節(jié)碼層面是 monitorcenter 和 monitorexit 指令,虛擬機(jī)層面是 lock 和 unlock,保證了代碼塊的原子性、可見性和 有序性,即線程安全。(根據(jù)前面的描述,這里應(yīng)該很好理解)synchronized 會讓多個線程去爭搶某一個對象的鎖,誰先爭搶上就是誰的。(如本例中的 Main.class 類對象。如果兩個線程爭搶的不是同一個對象的鎖,那就不會互斥同步。)
需要注意的是:synchronized 的實(shí)現(xiàn)時 互斥同步,即同步鎖只能有一個線程獲得,未獲得鎖的線程會阻塞,所以又叫阻塞同步。是一種 悲觀鎖,悲觀的認(rèn)為程序中并發(fā)情況很嚴(yán)重。但是 synchronized涉及線程的阻塞和喚起,涉及用戶模式和內(nèi)核模式的轉(zhuǎn)換( java線程會映射成操作系統(tǒng)線程 ),代價高。在一些簡單的同步操作中(如本例的 num++,且此時假設(shè)并發(fā)低的情況下),可能稍微等一下就能獲取對象鎖,這時候如果進(jìn)行線程的 阻塞 和 喚醒 的話,消耗就相對比較不劃算。這時候就有另外一種同步機(jī)制,可以解決這個問題,那就是 CAS(Compare and swap),CAS 也是 原子操作,涉及到的API,就是java.util.concurrent 包下的 atomic 類。我們來看一下怎么使用這些類實(shí)現(xiàn)線程安全。
CAS 是后來隨著硬件指令集的發(fā)展,才有的一個另外的選擇。因?yàn)槲覀冃枰?compare&swap 這個操作具備原子性。如果再使用互斥同步來保證,那就失去意義了,所以只能靠硬件來保證。
2.2 Atomic 原子類
static AtomicInteger aNum = new AtomicInteger();
private static void threadUpdate2() {
for (int i = 0; i < 3; i++) {
new Thread(() -> {
for (int i1 = 0; i1 < 5000; i1++) {
aNum.incrementAndGet(); //原子操作
}
}).start();
}
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(aNum.get());
}
CAS 是 樂觀鎖,樂觀認(rèn)為程序中并發(fā)情況不嚴(yán)重。 CAS 指令需要有3個操作數(shù),分別是內(nèi)存位置(V)、舊的預(yù)期值(A) 和 新值(B)。當(dāng)且僅當(dāng)V 符合舊預(yù)期值 A時,才會用B去更新V的值,否則不執(zhí)行更新。這個操作是個原子操作。
關(guān)于 非同步阻塞:基于沖突檢測的樂觀并發(fā)策略,就是先進(jìn)行操作,如果沒有其他線程爭用共享數(shù)據(jù),那就操作成功了;如果共享數(shù)據(jù)有爭用,產(chǎn)生了沖突,那就再采用其他的補(bǔ)償措施(最常見的就是不斷重試,直到成功為止),這種樂觀并發(fā)策略不需要把線程掛起,因此成為非阻塞同步。
2.3 synchronized 和 CAS 區(qū)別
2.3.1 synchronized 是悲觀鎖,悲觀的認(rèn)為程序中并發(fā)情況嚴(yán)重; CAS 是樂觀鎖,樂觀認(rèn)為程序中并發(fā)情況不嚴(yán)重。
2.3.2 CAS 只能保證一個變量的原子性操作,對于代碼塊無能為力。
2.3.3 CAS優(yōu)點(diǎn):在低并發(fā)的環(huán)境下,使用效率高;缺點(diǎn):占用CPU資源。 synchronized 缺點(diǎn):涉及線程的阻塞和喚起,涉及用戶模式和內(nèi)核模式的轉(zhuǎn)換,代價高。
2.3.4、CAS ABA 的問題。
ABA問題描述:此時共享內(nèi)存區(qū)域的值為A, Thread1 想把的值由 A 變?yōu)?C (操作一); Thread2 有兩個原子操作: 1、想把值由 A 變?yōu)?B (操作二) 2、把值由 B 變?yōu)?A (操作三)。 在線程競爭資源的過程中,有可能出現(xiàn): 操作二 、 操作三 、 操作一 的順序。 對于操作一來說 compareAndSet() 來說沒有任何問題,因?yàn)榇藭r內(nèi)存值確實(shí)是A, 但 操作一 不知道在這之前可能有人已經(jīng)動過了內(nèi)存的值。 在一般情況下ABA也不會影響最終的結(jié)果,在特殊的情況下,例如:單鏈表 會出問題。
ABA的問題參考: https://hesey.wang/2011/09/resolve-aba-by-atomicstampedreference.html?spm=a2c6h.13066369.0.0.5d5036d3vV3QAZ
Java 虛擬機(jī)提供的同步機(jī)制:volatile 、互斥同步(阻塞同步)、非阻塞同步(CAS機(jī)制) 等。volatile 可以說是 Java 虛擬機(jī)提供的 最輕量級 的同步機(jī)制,但它并不容易完全被正確、完整的理解,以至于很多程序員都不習(xí)慣去使用它,而是一律使用 synchronized。
volatile語義:
1、保證此變量對所有線程的可見性。 當(dāng)一條線程修改了這個變量的值,新值對于其他線程來說是可以立即得知的。普通變量做不到這一點(diǎn),需要一個線程從工作內(nèi)存同步到主內(nèi)存,再由另外一個線程從主內(nèi)存讀取到工作內(nèi)存才可見。(其實(shí)volatile變量也會存在不一致的情況,但由于每次使用之前都要先刷新,執(zhí)行引擎看不到不一致的情況)
2、禁止指令重排優(yōu)化 有volatile 修飾的變量,變量賦值后會插入一些其他的匯編指令(字節(jié)碼和匯編指令可能會有很多不同),這相當(dāng)于一個內(nèi)存屏障,不會把后邊的指令重排序到內(nèi)存屏障之前。內(nèi)存屏障的處理是 使本CPU的Cache寫入內(nèi)存,該寫入動作會引起別的CPU的cache無效。意味著所有之前的操作都已經(jīng)執(zhí)行完成,這樣便形成了"指令重排排序無法越過的內(nèi)存屏障",可以參考 單例模式中 Double Check的實(shí)現(xiàn)。
volatile 與 鎖之中選擇唯一的依據(jù)僅僅是 volatile 語義能否滿足使用場景的需求。