並發編程中的三個問題:
可見性(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