併發程式設計中的三個問題:
可見性(Visibility)
是指一個執行緒對共享變數進行修改,另一個先立即得到修改後的最新值。
程式碼演示:
public class Test01Visibility {
public static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (flag) {
}
}).start();
Thread.sleep(2000);
new Thread(() -> {
flag = false;
System.out.println("修改了flag");
}).start();
}
}
小結:併發程式設計時,會出現可見性問題,當一個執行緒對共享變數進行了修改,另外的執行緒並沒有立即看到修改
後的最新值。
原子性(Atomicity)
在一次或多次操作中,要麼所有的操作都執行並且不會受其他因素干擾而中斷,要麼所有的操作都不執行
程式碼演示:
public class Test02Atomicity {
public static int num = 0;
public static void main(String[] args) throws InterruptedException {
// 建立任務
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
num++;
}
};
ArrayList<Thread> threads = new ArrayList<>();
//建立執行緒
for (int i = 0; i < 5; i++) {
Thread t = new Thread(increment);
t.start();
threads.add(t);
}
for (Thread thread : threads) {
thread.join();
}
System.out.println(num);
}
}
通過 javap -p -v Test02Atomicity
對class 檔案進行反彙編:發現++ 操作是由4條位元組碼指令組成,並不是原子操作
小結:併發程式設計時,會出現原子性問題,當一個執行緒對共享變數操作到一半時,另外的執行緒也有可能來操作共享變數,干擾了前一個執行緒的操作
有序性(Ordering)
是指程式中程式碼的執行順序,Java在編譯時和執行時會對程式碼進行優化,會導致程式最終的執行順序不一定就是我們編寫程式碼時的順序。
程式碼演示:
@JCStressTest
@Outcome(id={"1","4"},expect=Expect.ACCEPTABLE,desc="ok")
@Outcome(id="0",expect=Expect.ACCEPTABLE_INTERESTING,desc="danger")
@State
public class Test03Orderliness {
int num=0;
boolean ready=false;
//執行緒一執行的程式碼
@Actor
public void actor1(I_Resultr){
if(ready){
r.r1=num+num;
}else{
r.r1=1;
}
}
//執行緒2執行的程式碼
@Actor
public void actor2(I_Resultr){
num=2;
ready=true;
}
}
執行的結果有:0、1、4
小結:程式程式碼在執行過程中的先後順序,由於Java在編譯期以及執行期的優化,導致了程式碼的執行順序未必
就是開發者編寫程式碼時的順序。
Java記憶體模型(JMM)
計算機結構簡介
根據馮諾依曼體系結構,計算機由五大組成部分,輸入裝置,輸出裝置,儲存器,控制器,運算器。
CPU:
中央處理器,是計算機的控制和運算的核心,我們的程式最終都會變成指令讓CPU去執行,處理程式中的資料。
記憶體:
我們的程式都是在記憶體中執行的,記憶體會儲存程式執行時的資料,供CPU處理。
快取:
CPU的運算速度和記憶體的訪問速度相差比較大。這就導致CPU每次操作記憶體都要耗費很多等待時間。於是就有了在
CPU和主記憶體之間增加快取的設計。CPU Cache分成了三個級別: L1, L2, L3。級別越小越接近CPU,速度也更快,同時也代表著容量越小。
Java記憶體模型
Java記憶體模型是一套規範,描述了Java程式中各種變數(執行緒共享變數)的訪問規則,以及在JVM中將變數儲存到記憶體和從記憶體中讀取變數這樣的底層細節,具體如下。
主記憶體
主記憶體是所有執行緒都共享的,都能訪問的。所有的共享變數都儲存於主記憶體。
工作記憶體
每一個執行緒有自己的工作記憶體,工作記憶體只儲存該執行緒對共享變數的副本。執行緒對變數的所有的操作(讀,取)都必須在工作記憶體中完成,而不能直接讀寫主記憶體中的變數,不同執行緒之間也不能直接訪問對方工作記憶體中的變數。
小結
Java記憶體模型是一套規範,描述了Java程式中各種變數(執行緒共享變數)的訪問規則,以及在JVM中將變數儲存到記憶體和從記憶體中讀取變數這樣的底層細節,Java記憶體模型是對共享資料的可見性、有序性、和原子性的規則和保障。
主記憶體與工作記憶體之間的互動
注意:1. 如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值
- 對一個變數執行unlock操作之前,必須先把此變數同步到主記憶體中
synchronized保證三大特性
synchronized保證可見性
while(flag){
//增加物件共享資料的列印,println是同步方法
System.out.println("run="+run);
}
小結:
synchronized保證可見性的原理,執行synchronized時,lock原子操作會重新整理工作記憶體中共享變數的值。
synchronized保證原子性
for(int i = 0; i < 1000; i++){
synchronized(Test01Atomicity.class){
number++;
}
}
小結:
synchronized保證原子性的原理,synchronized保證只有一個執行緒拿到鎖,能夠進入同步程式碼塊。
synchronized保證有序性
synchronized(Test01Atomicity.class){
num=2;
ready=true;
}
小結
synchronized保證有序性的原理,我們加synchronized後,依然會發生重排序,只不過,我們有同步程式碼塊,可以保證只有一個執行緒執行同步程式碼中的程式碼,保證有序性。
synchronized的特性
可重入特性
public class Demo01 {
public static void main(String[] args) {
new MyThread().start();
new MyThread().start();
}
}
class MyThread extends Thread {
@Override
public void run() {
synchronized (MyThread.class) {
System.out.println(Thread.currentThread().getName() + "獲取了鎖1");
synchronized (MyThread.class) {
System.out.println(Thread.currentThread().getName() + "獲取了鎖2");
}
}
}
}
可重入原理:
synchronized的鎖物件中有一個計數器(recursions變數)會記錄執行緒獲得幾次鎖。
可重入的好處:
-
可以避免死鎖
-
可以讓我們更好的來封裝程式碼
小結:
synchronized是可重入鎖,內部鎖物件中會有一個計數器記錄執行緒獲取幾次鎖啦,獲取一次鎖加+1,在執行完同步程式碼塊時,計數器的數量會-1,直到計數器的數量為0,就釋放這個鎖。
不可中斷特性
什麼是不可中斷?
一個執行緒獲得鎖後,另一個執行緒想要獲得鎖,必須處於阻塞或等待狀態,如果第一個執行緒不釋放鎖,第二個執行緒會一直阻塞或等待,不可被中斷。
public class Uninterruptible {
private static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
Runnable run = () -> {
synchronized (obj) {
String name = Thread.currentThread().getName();
System.out.println(name + "執行同步程式碼塊");
try {
Thread.sleep(888888);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
Thread t1 = new Thread(run);
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(run);
t2.start();
Thread.sleep(1000);
System.out.println("停止執行緒2前");
System.out.println(t2.getState());
t2.interrupt();
System.out.println("停止執行緒2後");
System.out.println(t1.getState());
System.out.println(t2.getState());
}
}
synchronized是不可中斷,處於阻塞狀態的執行緒會一直等待鎖。
ReentrantLock可中斷演示
public class Interruptible {
private static Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
test01();
}
private static void test01() throws InterruptedException {
Runnable run = () -> {
boolean flag = false;
String name = Thread.currentThread().getName();
try {
flag = lock.tryLock(3, TimeUnit.SECONDS);
if (flag) {
System.out.println(name + "獲得鎖,進入鎖執行");
Thread.sleep(888888);
} else {
System.out.println(name + "沒有獲得鎖");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (flag) {
lock.unlock();
System.out.println(name + "釋放鎖");
}
}
};
Thread t1 = new Thread(run);
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(run);
t2.start();
}
}
小結:
synchronized屬於不可被中斷
Lock的lock方法是不可中斷的
Lock的tryLock方法是可中斷的
synchronized 的原理
monitorenter:
每一個物件都會和一個監視器monitor關聯。監視器被佔用時會被鎖住,其他執行緒無法來獲取該monitor。當JVM執行某個執行緒的某個方法內部的monitorenter時,它會嘗試去獲取當前物件對應的monitor的所有權。其過程如下:
-
若monior的進入數為0,執行緒可以進入monitor,並將monitor的進入數置為1。當前執行緒成為monitor的owner(所有者)
-
若執行緒已擁有monitor的所有權,允許它重入monitor,則進入monitor的進入數加1
-
若其他執行緒已經佔有monitor的所有權,那麼當前嘗試獲取monitor的所有權的執行緒會被阻塞,直到monitor的進入數變為0,才能重新嘗試獲取monitor的所有權。
monitorenter小結:
synchronized的鎖物件會關聯一個monitor, 這個monitor不是我們主動建立的, 是JVM的執行緒執行到這個同步程式碼塊,發現鎖物件
有monitor就會建立monitor, monitor內部有兩個重要的成員變數owner擁有這把鎖的執行緒,recursions會記錄執行緒擁有鎖的次數,
當一個執行緒擁有monitor後其他執行緒只能等待。
monitorexit:
-
能執行monitorexit 指令的執行緒一定是擁有當前物件的monitor的所有權的執行緒。
-
執行monitorexit 時會將monitor的進入數減1。當monitor的進入數減為0時,當前執行緒退出monitor,不再擁有monitor的所有權,此時其他被這個monitor阻塞的執行緒可以嘗試去獲取這個monitor的所有權
monitorexit釋放鎖。
monitorexit插入在方法結束處和異常處,JVM保證每個monitorenter必須有對應的monitorexit。
面試題synchroznied出現異常會釋放鎖嗎?
:會釋放鎖。
同步方法
同步方法在反彙編後,會增加ACC_SYNCHRONIZED修飾。會隱式呼叫monitorenter 和monitorexit。在執行同步方法前會呼叫
monitorenter,在執行完同步方法後會呼叫monitorexit 。
小結:
通過javap反彙編可以看到synchronized 使用了monitorentor和monitorexit兩個指令。每個鎖物件都會關聯一個monitor(監視
器,它才是真正的鎖物件),它內部有兩個重要的成員變數owner會儲存獲得鎖的執行緒,recursions會儲存執行緒獲得鎖的次數, 當執行到
monitorexit時, recursions會-1, 當計數器減到0時這個執行緒就會釋放鎖。
面試題:synchronized與Lock的區別
1、synchronized 是關鍵字,lock 是一個介面
2、synchronized 會自動釋放鎖,lock 需要手動釋放鎖。
3、synchronized 是不可中斷的,lock 可以中斷也可以不中斷。
4、通過lock 可以知道執行緒有沒有拿到鎖,而synchronized 不能。
5、synchronized 能鎖住方法和程式碼塊,而lock 只能鎖住程式碼塊。
6、lock 可以使用讀鎖提高多執行緒讀效率。
7、synchronized 是非公平鎖,ReentrantLock 可以控制是否是公平鎖。
CAS
cas的概述和作用:
compare and swap,可以將比較和交換轉為原子操作,這個原子操作直接由cpu保證,cas可以保證共享變數賦值時的原子操作,cas依賴3個值:記憶體中的值v,舊的預估值x,要修改的新值b。根據atomicInteger的地址加上偏移量offset的值可以得到記憶體中的值,將記憶體中的值和舊的預估值進行比較,如果相同,就將新值儲存到記憶體中。不相同就進行重試。
Java物件的佈局
在JVM中,物件在記憶體中的佈局分為三塊區域:物件頭、例項資料和對齊填充。如下圖所示:
HotSpot採用instanceOopDesc和arrayOopDesc來描述物件頭,arrayOopDesc物件用來描述陣列型別。
從instanceOopDesc程式碼中可以看到 instanceOopDesc繼承自oopDesc。
_mark表示物件標記、屬於markOop型別,也就是Mark World,它記錄了物件和鎖有關的資訊
_metadata表示類元資訊,類元資訊儲存的是物件指向它的類後設資料(Klass)的首地址,其中Klass表示普通指標、compressed_klass表示壓縮類指標。
Mark Word
鎖狀態 | 儲存內容 | 鎖標誌位 |
---|---|---|
無鎖 | 物件的hashcode、物件分代年齡、是否是偏向鎖(0) | 01 |
偏向鎖 | 偏向執行緒id、偏向時間戳、物件分代年齡、是否是偏向鎖(1) | 01 |
輕量級鎖 | 指向棧中鎖記錄的指標 | 00 |
重量級鎖 | 指向互斥量(重量級鎖)的指標 | 10 |
klass pointer
用於儲存物件的型別指標,該指標指向它的類後設資料,JVM通過這個指標確定物件是哪個類的例項。通過-XX:+UseCompressedOops開啟指標壓縮,
在64位系統中,Mark Word = 8 bytes,型別指標 = 8bytes,物件頭 = 16 bytes = 128bits;
例項資料
就是類中定義的成員變數。
對齊填充
由於HotSpot VM的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,物件的大小必須是8位元組的整數倍。因此,當物件例項資料部分沒有對齊時,就需要通過對齊填來補全。
檢視Java物件佈局
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
小結
Java物件由3部分組成,物件頭,例項資料,對齊資料,物件頭分成兩部分:Mark World + Klass pointer
偏向鎖
什麼是偏向鎖?
鎖會偏向於第一個獲得它的執行緒,會在物件頭儲存鎖偏向的執行緒ID,以後該執行緒進入和退出同步塊時只需要檢查是否為偏向鎖、鎖標誌位以及ThreadID即可。
不過一旦出現多個執行緒競爭時必須撤銷偏向鎖,所以撤銷偏向鎖消耗的效能必須小於之前節省下來的CAS原子操作的效能消耗,不然就得不償失了。
偏向鎖原理
當執行緒第一次訪問同步塊並獲取鎖時,偏向鎖處理流程如下:
- 虛擬機器將會把物件頭中的標誌位設為“01”,即偏向模式。
- 同時使用CAS操作把獲取到這個鎖的執行緒的ID記錄在物件的Mark Word之中,如果CAS操作成功,持有偏向鎖的執行緒以後每次進入這個鎖相關的同步塊時,虛擬機器都可以不再進行任何同步操作,偏向鎖的效率高。
偏向鎖的撤銷
-
偏向鎖的撤銷動作必須等待全域性安全點
-
暫停擁有偏向鎖的執行緒,判斷鎖物件是否處於被鎖定狀態
-
撤銷偏向鎖,恢復到無鎖(標誌位為01)或輕量級鎖(標誌位為00)的狀態
偏向鎖是自適應的
小結:
偏向鎖的原理是什麼?
當鎖物件第一次被執行緒獲取的時候,虛擬機器將會把物件頭中的標誌位設為“01”,即偏向模式。同時使用CAS操作把獲取到這個鎖的執行緒的ID記錄在物件的MarkWord之中,如果CAS操作成功,持有偏向鎖的執行緒以後每次進入這個鎖相關的同步塊時,虛擬機器都可以不再進行任何同步操作,偏向鎖的效率高。
偏向鎖的好處是什麼?
偏向鎖是在只有一個執行緒執行同步塊時進一步提高效能,適用於一個執行緒反覆獲得同一鎖的情況。偏向鎖可以提高帶有同步但無競爭的程式效能。
輕量級鎖
什麼是輕量級鎖?
輕量級鎖是JDK 6之中加入的新型鎖機制,輕量級鎖並不是用來代替重量級鎖的。
引入輕量級鎖的目的:在多執行緒交替執行同步塊的情況下,儘量避免重量級鎖引起的效能消耗,但是如果多個執行緒在同一時刻進入臨界區,會導致輕量級鎖膨脹升級為重量級鎖,所以輕量級鎖的出現並非是要代替重量級鎖。
輕量級鎖原理:
當關閉偏向鎖或多個執行緒競爭偏向鎖導致偏向鎖升級為輕量級鎖,則會嘗試獲取輕量級鎖,其步驟如下:
- 判斷當前物件是否處於無鎖狀態,如果是,則JVM 首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word 的拷貝,將物件的Mark Word 複製到棧幀中的Lock Record 中,將Lock Record中的owner指向當前物件。
- JVM 利用CAS 操作嘗試將物件的Mark Word 更新為指向Lock Record 的指標,如果成功表示競爭到鎖,則將鎖標誌位變成00,執行同步操作。
- 如果失敗則判斷當前物件的Mark Word 是否指向當前執行緒的棧幀,如果是則表示當前執行緒已經持有當前物件的鎖,則直接執行同步程式碼塊;否則只能說明該鎖物件已經被其他執行緒搶佔了,這時輕量級鎖需要膨脹為重量級鎖,鎖標誌位變成10,後面等待的執行緒將會進入阻塞狀態。
輕量級鎖的釋放:
輕量級鎖的釋放也是通過CAS操作來進行的,主要步驟如下:
- 取出在獲取輕量級鎖時儲存在Mark Word 中的資料;
- 用CAS 操作將取出的資料替換當前物件的Mark Word 中,如果成功,則說明釋放鎖成功。
- 如果CAS 操作替換失敗,說明有其他執行緒獲取該鎖,則需要將輕量級鎖膨脹升級為重量級鎖。
對於輕量級鎖,其效能提升的依據是“對於絕大部分的鎖,在整個生命週期內都是不會存在競爭的”,如果打破這個依據則除了互斥的開銷外,還有額外的CAS 操作,因此在有多執行緒競爭的情況下,輕量級鎖比重量級鎖更慢。
輕量級鎖好處:
在多執行緒交替執行同步塊的情況下,可以避免重量級鎖引起的效能消耗。
自旋鎖
monitor 實現鎖的時候, monitor 會阻塞和喚醒執行緒,執行緒的阻塞和喚醒需要CPU 從使用者態轉為核心態,頻繁的阻塞和喚醒對CPU 來說是一件負擔很重的工作,這些操作給系統的併發效能帶來了很大的壓力。同時,共享資料的鎖定狀態可能只會持續很短的一段時間,為了這段時間阻塞和喚醒執行緒並不值得。如果有一個以上的處理器,能讓兩個或以上的執行緒同時並行執行,就可以讓後面請求鎖的那個執行緒“稍微等一下”,但不放棄處理器的執行時間,看看持有鎖的執行緒是否釋放了鎖。為了讓執行緒等待,我們只需讓執行緒執行一個迴圈(即自旋),這就是自旋鎖。
自旋鎖在JDK 1.4.2中就已經引入,只不過預設是關閉的,可以使用-XX:+UseSpinning引數來開啟,在JDK 6中就已經改為預設開啟了。自旋等待不能代替阻塞,且先不說對處理器數量的要求,自旋等待本身雖然避免了執行緒切換的開銷,但它是要佔用處理器時間的,因此,如果鎖被佔用的時間很短,自旋等待的效果就會非常好,反之,如果鎖被佔用的時間很長。那麼自旋的執行緒只會白白消耗處理器資源,而不會做任何有用的工作,反而會帶來效能上的浪費。因此,自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起執行緒了。自旋次數的預設值是10次,使用者可以使用引數-XX : PreBlockSpin來更改。
適應性自旋鎖
在JDK 6 中引入了自適應的自旋鎖。如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間。如果對於某個鎖,自旋很少成功獲得過,那在以後要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。
平時寫程式碼如何對synchronized優化
減少synchronized的範圍:
同步程式碼塊中儘量短,減少同步程式碼塊中程式碼的執行時間,減少鎖的競爭。
synchronized(Demo01.class){
System.out.println("aaa");
}
降低synchronized鎖的粒度:
將一個鎖拆分為多個鎖提高併發度,如HashTable:鎖定整個雜湊表,一個操作正在進行時,其他操作也同時鎖定,效率低下。ConcurrentHashMap:區域性鎖定,只鎖定桶。
讀寫分離:
讀取時不加鎖,寫入和刪除時加鎖
ConcurrentHashMap,CopyOnWriteArrayList和ConyOnWriteSet