一、JMM是什么?
JMM是一個抽象的概念:描述的是一組圍繞原子性、有序性、可見性的規(guī)范。其定義程序中各個變量的訪問規(guī)則,即虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節(jié)。此處的變量是共享變量。
JMM規(guī)定:所有共享變量存儲在主內存中,每條線程有自己的工作內存,線程的工作內存保存了被該線程使用到的變量的主內存副本,線程對變量的所有操作都必須在工作內存上進行,線程不能直接讀寫主內存的共享變量。不同的線程之間也無法訪問對方工作內存中的變量,線程間的變量值的傳遞均需通過主內存來完成。
共享變量:所有實例域,靜態(tài)域和數(shù)組元素都是放在堆內存中(即所有線程均可以訪問到,可共享)。共享數(shù)據會出現(xiàn)線程安全的問題
非共享變量:局部變量,方法定義參數(shù)和異常處理器參數(shù)不會線程共享。非共享數(shù)據不會出現(xiàn)線程安全的問題
二、如何定義一個對象線程安全?
當多個線程訪問同一個對象時,如果不用考慮這些線程在運行時環(huán)境下的調度和交替運行,也不需要額外的同步,或者在調用方式進行任何其他的協(xié)調操作,調用這個對象的行為都可以獲取到正確的結果。出現(xiàn)線程安全的問題一般是因為主存和工作內存數(shù)據不一致性和重排序導致的。
三、線程通信與其問題
并發(fā)編程主要解決兩個問題:
- 線程之間如何通信
- 線程之間如何完成同步
JMM規(guī)定了一個線程對共享變量的寫入何時對其他線程是可見的。如果線程A改了主存中的某一數(shù)據,而線程B不知道同時并發(fā)修改,這樣在寫回主存中就有一些問題。如果線程A要和線程B進行通信,要經過這兩步:
- 線程A從主內存中將共享變量讀入線程A的工作內存后并且進行操作,之后將數(shù)據重新寫回到主存中
- 線程B從主內存中讀取最新的共享變量(線程A修改過的)
但如果線程A更新數(shù)據后并沒有及時寫回到主存,而此時線程B讀到了原本的數(shù)據,也就是過期的數(shù)據,這就出現(xiàn)了臟讀現(xiàn)象。這個問題可以通過同步機制(控制不同線程間操作發(fā)生的相對順序)來解決或者通過volatile關鍵字使得每次修改遍歷都能夠強制刷新到主存,從而對每個線程都可見。
例:
主內存中i = 0
線程1: load i from 主存 // i = 0
i + 1 // i = 1
線程2: load i from主存 // 線程1還沒將i的值寫回主存,所以i還是0
i + 1 //i = 1
線程1: save i to 主存
線程2: save i to 主存
現(xiàn)在主存中的值還是1,可我們的預期值是2
四、內存間的相互操作
JMM定義了8個操作來完成主內存和工作內存的互相操作:
- lock(鎖定):作用于主內存中的變量,把一個變量表示為一個線程獨占的狀態(tài)
- unlock(解鎖):作用于主內存中的變量,把一個處于鎖定狀態(tài)的變量釋放出來,釋放后的變量才可以被其他線程鎖定
- read(讀取):作用于主內存的變量,把一個變量的值從主內存讀取到線程的工作內存中,以便于后面的load操作
- load(載入):作用于工作內存中的變量,把read操作從主存中得到的變量值放入工作內存中的變量副本
- use(使用):作用于工作內存中的變量,把工作內存中的一個變量的值傳遞給執(zhí)行引擎,每當虛擬機遇到一個需要使用變量的值的字節(jié)碼指令時將會執(zhí)行這個操作
- assign(賦值):作用于工作內存中的變量,把一個從執(zhí)行引擎接收到的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節(jié)碼指令時就執(zhí)行這個操作
- store(存儲):作用于工作內存中的變量,把工作內存中一個變量的值傳送給主存中以便于后面的write操作
-
write(寫入):作用于主內存中的變量,把store操作從工作內存中得到的變量的值放入主內存的變量中
image
五、JMM的三大特性
1.原子性(Atomicity)
對于基本數(shù)據類型的讀取和賦值操作都是原子性操作,即這些操作是不可中斷的,要么做完,要么不做 。如果有兩個線程同時對i進行賦值,一個賦值為1,另一個為-1,則i的值要么為1要么為-1。
i = 2; //1
j = i; //2
i++; //3
i = i + 1; //4
其中,1是賦值操作,是原子操作,而234都不是原子操作。
2是讀取賦值
3和4都是讀取,修改,賦值
JMM只是保證了單個操作具有原子性,并不保證整體原子性。synchronize關鍵字具有原子性:
public class AtomicExample {
/*private AtomicInteger cnt = new AtomicInteger();
public void add() {
cnt.incrementAndGet();
}
public int get() {
return cnt.get();
}*/
private int cnt = 0;
public synchronize void add() {
cnt++;
}
public int get() {
return cnt;
}
public static void main(String[] args) throws InterruptedException {
final int threadSize = 1000;
AtomicExample example = new AtomicExample();
final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
ExecutorService executorService = Executors.newCachedThreadPool();
for (int i = 0; i < threadSize; i++) {
executorService.execute(() -> {
example.add();
countDownLatch.countDown();
});
}
countDownLatch.await();
executorService.shutdown();
System.out.println(example.get());
}
}
輸出結果:1000
如果不加synchronize關鍵字,每次輸出結果都小于1000
2.可見性(Visibility)
一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。 JMM是通過在遍歷修改后將新值同步回主存,在遍歷讀取前從主內存刷新遍歷值來實現(xiàn)可見性。
實現(xiàn)方式:
- volatile:通過在指令中添加lock指令,以實現(xiàn)內存可見性。其特殊規(guī)則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新,其保證了多線程操作時變量的可見性。
- synchronized:當線程獲取鎖時會從主內存中獲取共享變量的最新值,釋放鎖的時候會將共享變量同步到主存中。
- final:被final關鍵字修飾的字段在構造器中一旦初始化完成,并且沒有發(fā)生this逃逸(其他線程通過this引用訪問到初始化了一半的對象),那么其他線程就能看見final字段的值。
3.有序性(Ordering):
在本線程內觀察,所有操作都是有序的。在一個線程觀察另一個線程,所有操作都是無序的,無序是因為發(fā)生了指令重排序和工作內存與主內存同步延遲。在JMM中,允許編譯器和處理器對指令進行重排序,重排序的過程不會影響到單線程程序的執(zhí)行,卻會影響到多線程并發(fā)執(zhí)行的正確性。
實現(xiàn)方式:
- volatile關鍵字通過添加內存屏障的方式來禁止指令重排,即重排序時不能把后面的指令放到內存屏障之前。關于內存屏障本篇就不講了,在詳解volatile關鍵字中會說
- synchronized關鍵字同樣可以保證有序性,它保證每個時刻只有一個線程執(zhí)行同步代碼,相當于是讓線程順序執(zhí)行同步代碼
synchronized具有原子性、可見性、有序性
volatile具有有序性和可見性
final具有可見性
六、指令重排序與數(shù)據依賴性
為什么要進行指令重排序?
現(xiàn)在的CPU都是采用流水線來執(zhí)行指令的,一個指令的執(zhí)行有:取指、移碼、執(zhí)行、訪存、寫回五個階段,多條指令可以同時存在流水線中同時被執(zhí)行。流水線是并行的,也就是說不會在一條指令上耗費很多時間而導致后續(xù)的指令都卡在執(zhí)行之前的階段。我們編寫的程序都要經過優(yōu)化后(編譯和處理器對我們編寫的程序進行優(yōu)化后以提高效率)才被運行。優(yōu)化分為很多種,其中一種就是重排序。即重排序就是為了提高性能。
重排序的兩大規(guī)則:as-if-serial規(guī)則和happens-before規(guī)則
1.as-if-serial規(guī)則
定義:不管怎么進行重排序,單線程程序的執(zhí)行結果不能被改變。編譯器,runtime和處理器都必須遵守as-if-serial語義。
為了遵守該規(guī)則,編譯器和處理器不會對存在數(shù)據依賴關系的操作做重排序,如果不存在數(shù)據依賴關系,那么這些操作可能被編譯器和處理器重排序,就比如一個求長方體面積:
int a = 2; //A
int b = 4; //B
int c = a * b; //C
其中,AC存在數(shù)據依賴關系,BC也存在,而AB不存在,所以在最終執(zhí)行指令序列的時候,C不能排在AB的前面(這樣會改變程序的結果),但是AB并沒有數(shù)據依賴性關系。也就是說編譯器和處理器可以重排AB之間的執(zhí)行順序,先B后A,先A后B都可以。as-if-serial規(guī)則把單線程程序保護了起來,這也就就是說遵守as-if-serial語義的編譯器、runtime和處理器給了我們一個幻覺:單線程的程序是按照順序來執(zhí)行的。其實并不是,as-if-serial語義使程序員無需擔心重排序的影響,也無須單行內存可見性的問題。
2.happens-before(先行發(fā)生)規(guī)則
1.JMM對程序員的保證:如果操作A先行發(fā)生與操作B,在操作B發(fā)生之前,操作A的影響(修改主內存中共享變量的值、調用方法等)是操作B可見的。
2.JMM對編譯器和處理器重排序的約束規(guī)則:兩個操作之間存在happens-before關系,并不意味具體實現(xiàn)時必須要按照happens-before關系指定的順序來執(zhí)行。如果重排序之后的執(zhí)行結果與按happens-before關系來執(zhí)行的結果一致。那么這種重排序在JMM之中是被允許的。
總結一下就是:只要不改變程序的執(zhí)行結果(單線程程序和正確同步的多線程程序),編譯器和處理器怎么優(yōu)化都行
這么說來,如果線程A的寫操作write和線程B的讀操作read之間存在happens-before關系,盡管write和read在不同的線程中執(zhí)行,但JMM向程序員保證write操作對read操作可見。
以下是JMM天然的先行發(fā)生關系,如果兩個操作之間沒有下面的關系,并且無法從下面的關系推導,則jvm可以對其隨意的進行重排序:
- 程序次序規(guī)則:在一個線程內,控制流(循環(huán),分支)順序在程序前面的操作先行發(fā)生于后面的操作
- 管程鎖定原則:一個unlock操作先行發(fā)生與后面對用一個鎖的lock操作
- volatile變量規(guī)則:對一個volatile遍歷的寫操作先行發(fā)生于后面對這個變量的讀操作
- 線程啟動規(guī)則(start規(guī)則):Thread對象的start()方法調用先行發(fā)生于此線程的每一個動作
- 線程加入規(guī)則(join規(guī)則):Thread對象的結束先行發(fā)生于join()方法返回
- 線程中斷規(guī)則:對線程interrupt()方法的調用先行發(fā)生于被中斷線程的代碼檢測到中斷事件的發(fā)生,可以通過interrupted()方法檢測到是否有中斷發(fā)生
- 對象終結規(guī)則:一個對象的初始化完成(構造函數(shù)執(zhí)行結束)先行發(fā)生于它的finalize()方法的開始
- 傳遞性:如果操作A先行發(fā)生于操作B,操作B先行發(fā)生與操作C,則操作A先行發(fā)生于操作C。
重排序的分類:
- 編譯器優(yōu)化的重排序:編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執(zhí)行順序
- 指令級并行的重排序:現(xiàn)代處理器采用了指令級并行技術來將多條指令重疊執(zhí)行。如果不存在數(shù)據依賴性,處理器可以改變語句對應機器指令的執(zhí)行順序
- 內存系統(tǒng)的重排序:由于處理器使用緩存和IO緩沖區(qū),這使得加載和存儲操作看上去可能是在亂序執(zhí)行的。
3.數(shù)據依賴性
定義:如果兩個操作訪問同一個變量,且這兩個操作有至少有一個為寫操作,此時這兩個操作就存在數(shù)據依賴性
三種情況:讀后寫、寫后寫、寫后讀。只要重排序兩個操作的執(zhí)行順序,那么程序的執(zhí)行結果將會被改變。
如果重排序會對最終執(zhí)行結果產生影響,編譯器和處理器在重排時,會遵守數(shù)據依賴性,編譯器和處理器不會改變存在數(shù)據依賴性關系的兩個操作的執(zhí)行順序。例如:剛才的計算長方形面積的程序,長寬變量沒有任何關系,執(zhí)行順序改變也不會對最終結果造成任何的影響,所以可以說長寬沒有數(shù)據依賴性。
4.重排序帶來的問題
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a * a; //4
……
}
}
}
我們開兩個線程AB,分別執(zhí)行writer和reader,flag為標志位,用來判斷a是否被寫入,則我們的線程B執(zhí)行4操作時,能否看到線程A對a的寫操作?不一定,12操作并沒有數(shù)據依賴性,編譯器和處理器可以對這兩個操作進行重排序,也就是說可能A執(zhí)行2后,B直接執(zhí)行3,判斷為true,接著執(zhí)行4,而此時a還沒有被寫入。這樣多線程程序的語義就被重排序破壞了。
編譯器和處理器可能會對操作重排序,這個是要遵守數(shù)據依賴性的,即不會改變存在數(shù)據依賴關系的兩個操作的執(zhí)行順序。這里所說的數(shù)據依賴性僅僅針對單個處理器中執(zhí)行的指令序列和單個線程中執(zhí)行的操作,不同處理器之間和不同線程之間的數(shù)據依賴性不被編譯器和處理器考慮。所以在并發(fā)編程下這就有一些問題了。