Java基礎--synchronized原理詳解
- 1. 多線程特性
- 1.1 原子性(Atomicity)
- 1.2 可見性(Visibility)
- 1.3 有序性(Ordering)
- 1.4 Happen-Before原則
- 2. 鎖定義
- 2.1 為什麼需要鎖
- 2.2 鎖存在的意義
- 3. synchronized
- 3.1 synchronized的使用場景
- 3.2 synchronized原理
- 3.2.1 Java對象在JVM中的結構
- 3.2.2 monitor指令
- 3.2.3 monitor指令過程
- 4. synchronized 對類對象和執行個體對象的差別
- 4.1 static修飾和沒有static修飾的差別
- 4.2 synchronized 不同使用場景
- 4.3 不使用synchronized 同步
- 4.4 synchronized 同步代碼塊--類對象
- 4.5 synchronized 同步代碼塊--執行個體對象
- 4.6 synchronized 同步代碼塊--任意執行個體對象
- 4.7 synchronized 同步方法--類方法
- 4.8 synchronized 同步方法--執行個體方法
- 5. synchronized 的缺陷
- 6. synchronized的鎖處理
- 7. synchronized 處理過程
1. 多線程特性
1.1 原子性(Atomicity)
原子性是指一個操作是不可中斷的。即使是在多個線程一起執行的時候,一個操作一單開始,就不會被其他線程幹擾。
1.2 可見性(Visibility)
可見性是指當一個線程修改了某一個共享變量的值,其他線程是否能夠立即知道這個修改。
1.3 有序性(Ordering)
程式在執行時,編譯器可能會進行指令重排,重排後的指令原指令的順序未必一緻。
指令重排可以保證串行語義一緻,但是沒有義務保證多線程間的語義也一緻。
為什麼要進行指令重排?
CPU執行指令,需要進行這幾步(不同的指令集可能不同,一般認知中是這樣的)
- 取指
- 譯碼和取操作數
- 執行或計算
- 存儲器通路
- 寫回
-
CPU執行是一個一個的周期進行的,CPU組成中需要有晶振,晶振産生固定頻率的脈沖,每一次脈沖就是一次時鐘周期。CPU的一個時鐘周期内隻能進行一個操作。
那麼完成一次指令需要5個時鐘周期。
仔細觀察這5個操作,分别都是CPU不同的區域。是以,在一個時鐘周期内,可以進行多個不同的操作。
比如:
-
在上圖中執行了3條指令,如果是串行的,那麼需要15個時鐘周期才能執行完成。
這就是CPU執行指令流水線執行,指令的執行效率高。
CPU流水線執行指令,雖然效率高,但是依然存在問題。
假設藍色的指令計算的資料,依賴綠色指令的計算結果,在第5個時鐘周期進行計算時,綠色的計算結果,還沒有寫到寄存器,此時就需要藍色指令等待綠色指令的計算結果寫入寄存器才能繼續進行。
-
發現因為藍色需要等待綠色指令執行完畢,才能執行藍色指令。
但是在代碼邏輯中,綠色後面就是藍色,而藍色後面是橙色。
如果橙色和藍色沒有強烈的先後關系,那麼可以調整指令執行順序。
-
就可以避免CPU指令執行的中斷停頓。
指令重排提高了CPU執行效率,但是也帶來了指令亂序的問題。
相比之下,指令亂序的問題是可以接受的。

1.4 Happen-Before原則
Happen-Before原則是不進行指令重排的規則:
- 程式順序原則:一個線程内保證語義的串行性
- volatile規則:volatile變量的寫,先發生于讀,這保證了volatile變量的可見性
- 鎖規則:解鎖(unlock)先發生于加鎖(lock)
- 傳遞性:A先于B,B先于C,那麼A一定先于C
- 線程的start方法先于線程的任務
- 線程的任務先于線程的終止
- 線程的中斷先于中斷前的代碼
- 對象的構造函數先于對象finalize方法
2. 鎖定義
2.1 為什麼需要鎖
因為在CPU執行指令的時候,會進行指令重排,指令重排在串行上可以保證程式語義一緻,但是在多線程情況下,就無法保證語義一緻了。
舉個例子:
一個全局變量,每個線程對全局變量進行1w次自增操作。如果有10個線程,那麼最終的全局變量的值應該是10W。
串行:
public class Main {
private static Long sum = 0L;
public static void main(String[] args) {
System.out.println("main start sum = " + sum);
ExecutorService service = new ThreadPoolExecutor(
10, 10, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
for (int i = 0; i < 10; i++) {
new Add().run();
}
service.shutdown();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
System.out.println("main interrupt exception");
}
System.out.println("main end sum = " + sum);
}
static class Add implements Runnable {
@Override
public void run() {
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + thread.getId() + " start add!");
for (int i = 0; i < 10000; i++) {
sum++;
}
System.out.println(thread.getName() + thread.getId() + " add over!");
}
}
}
執行結果:
并發:
每次執行的結果都是不确定的。
是以,在并發情況下,對同一個變量的操作,會出現語義不一緻的并發問題。
那麼,如何解決這個問題呢?
加鎖。
一般來說,Java中鎖的實作有兩種方式:synchronized和Lock.
我們先用synchronized修改
接下來使用Lock進行修改:
public class Main {
private static volatile Long sum = 0L;
private static volatile Lock lock = new ReentrantLock();
public static void main(String[] args) {
System.out.println("main start sum = " + sum);
ExecutorService service = new ThreadPoolExecutor(
10, 10, 0L, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
for (int i = 0; i < 10; i++) {
service.execute(new Add());
}
service.shutdown();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
System.out.println("main interrupt exception");
}
System.out.println("main end sum = " + sum);
}
static class Add implements Runnable {
@Override
public void run() {
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + thread.getId() + " start add!");
try {
while(!lock.tryLock()){
TimeUnit.MILLISECONDS.sleep(200);
}
for (int i = 0; i < 10000; i++) {
sum++;
}
}catch (InterruptedException e){
System.out.println(e);
} finally {
lock.unlock();
}
System.out.println(thread.getName() + thread.getId() + " add over!");
}
}
}
我們增加了一個全局變量,這個全局變量就是sum的鎖,隻有擷取到了鎖的線程,才能進行累加操作。如果沒有擷取鎖,那麼就線程sleep200毫秒。然後重新擷取鎖,直到擷取了鎖,否則就一直循環。
2.2 鎖存在的意義
在2.1 中,我們可以很明顯的看到,這是因為并發情況下,多個線程對全局變量的讀寫,造成語義不一緻。
說簡單點,就是第一個線程和第二個線程等多個線程讀取到了相同的sum初始值,然後對sum初始值進行遞增操作,導緻多個線程遞增和一個線程的一次遞增的結果相同。(不考慮時間先後問題)(線程間指令重排問題)
還有就是第一個線程可能計算的快,已經計算到了 9++=10了,但是第二個線程還是比較慢,才計算到了1++=2。(線程執行,多個核心執行,每個核心的寄存器裡面都有sum的一個副本)(記憶體可見性問題)
鎖的存在就是為了解決這些問題。
3. synchronized
3.1 synchronized的使用場景
分類 | 具體場景 | 被鎖的對象 | 僞代碼 |
方法 | 執行個體方法 | 類的執行個體對象 | public synchronized void method(){} |
方法 | 靜态方法 | 類對象 | public static synchronized void method(){} |
代碼塊 | 執行個體對象 | 類的執行個體對象 | synchronized (this){} |
代碼塊 | class對象 | 類對象 | synchronized(Main.class){} |
代碼塊 | 任意執行個體對象Object | 執行個體對象Object | String x = “”; synchronized(x){} |
3.2 synchronized原理
首先我們将2.1中的synchronized實作的代碼進行編譯
javac Main.java
,然後使用
javap -v
進行反編譯
這裡比較好找,先找遞增操作,ladd的指令,在ladd的指令前後有monitorenter指令。
3.2.1 Java對象在JVM中的結構
通過上面兩張圖檔,可以很直覺的知道,對象在jvm中分為三塊區域:對象頭,對象實際資料,填充資料。
3.2.2 monitor指令
monitor指令分為兩個:monitorenter和monitorexit。
分别代碼開始同步和結束同步。或者開始加鎖,結束加鎖。
可以了解為:在遇到monitorenter指令的時候,進行加鎖,進入同步代碼後,每次進行操作前後,都需要擷取最新的資料,執行完畢,及時的寫回。(這是個人了解)
在執行過程中,遇到monitorenter指令,設定對象的鎖标志以及線程id(重入鎖的核心實作)。
因為第一個争奪到鎖的線程已經将鎖标志置1了,其他線程就無法擷取鎖了(無法在增加了)。
當執行完同步操作後,遇到monitorexit指令,設定對象的鎖标志為0,線程id清空(網上的資料沒有指明不過從重入鎖的定義來分析,應該是清空id的)
這樣其他線程就可以擷取鎖了。
3.2.3 monitor指令過程
在2.3.2.2小節中知道,每一個對象都有自己的對象頭,而在對象頭中有一個鎖标志,隻有線程修改鎖标志成功,才是擷取到了鎖,其他線程隻能等待。
是以,如果有若幹線程同時擷取一個對象的鎖,其中某一個線程得到鎖之後,執行線程的任務,而其他鎖則會進入同步隊列,線程也會進入BLOCKED的狀态。
圖檔來自https://www.jianshu.com/p/d53bf830fa09
4. synchronized 對類對象和執行個體對象的差別
4.1 static修飾和沒有static修飾的差別
首先了解一個關鍵字static,這個關鍵字是區分一個屬性變量是否是類變量,還是執行個體屬性變量。
同樣的,一個方法如果有static就是說,這個這個方法是類方法;如果以一個方法沒有static 就認為這個方法是執行個體方法。
當然,最明顯的是:類方法和類變量,可以直接通過類名調用;而執行個體方法和執行個體變量,必須先建立類的執行個體,然後通過執行個體調用。
還有一點需要注意:非static方法可以調用static方法和非static方法,而static方法隻能調用static方法。
從對象的角度來看:
類對象,類方法不需要使用new執行個體化對象,就可以調用類方法和類變量。
因為類對象在記憶體中隻會存儲一個。
還記得前面說的JVM中對象的結構嗎,在對象頭中,就會存儲類中繼資料:
執行個體對象的對象頭中存儲的這個類中繼資料就是類對象的位址。
也就是說,在記憶體中,這個類的所有執行個體對象的對象頭都會存儲類元資訊,也就是類對象。而且這些執行個體對象的類對象都是相同的。
用最直白的話說:類對象,記憶體中隻有一份;執行個體對象,每new一次,就會有一個。
因為記憶體中隻有一個,是以不管是類變量還是類屬性,,都是同一個,怎麼調用都行。
而執行個體方法或者執行個體對象調用類方法或者類屬性:因為執行個體對象和類對象是多對1的關系,是以執行個體方法調用類方法或者類屬性就是互斥的。在同一時刻,隻能有一個執行個體對象可以調用成功(有鎖,或者有同步邏輯的)。如果是不需要同步的,那無所謂了。
比如:
public class Student {
public static void say() {
System.out.println("static method");
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + thread.getId());
while (!thread.isInterrupted()) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
break;
}
}
System.out.println("static end");
}
public void sing() {
System.out.println("nomal method");
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + thread.getId());
while (!thread.isInterrupted()) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
break;
}
}
System.out.println("nomal method end");
}
}
public class StudentMain {
public static void main(String[] args) {
Student student = new Student();
Thread t1 = new Thread(() -> {
Student.say();
});
t1.start();
Thread t2 = new Thread(() -> {
student.sing();
});
t2.start();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
System.out.println("main interrupt exception");
}
System.out.println("main method , static method and nomal method is BLOCKED");
new Thread(() -> Student.say()).start();
new Thread(() -> student.sing()).start();
new Thread(() -> new Student().sing()).start();
}
}
執行結果:
即使第一次調用的線程現在在方法内阻塞,但是,因為方法不是同步方法,是以,後面建立的線程依然可以通路,依然可以進入。
那麼,把方法修改成需要同步的呢?
這個時候,執行個體同步方法可以進入,但是類同步方法不可以進入。
從這裡也進一步說明,類對象,類方法在記憶體中是一份的。而執行個體方法是每new一次,就會産生一個的。
然後執行個體對象的類元資訊就是類對象的位址。
4.2 synchronized 不同使用場景
這個時候,我們傳回去看下2.3.1的使用場景
其實就是可以分為2類,一種是執行個體對象鎖,一種是類對象鎖。
4.3 不使用synchronized 同步
接下來,在看一個例子:
在多線程的情況下,多個線程對同一個屬性進行操作,會發生并發問題。
我們通過執行個體檢視:
public class People {
private Long sum = 0L;
private static Long all = 0L;
public People(){}
public Long getSum(){
return sum;
}
public void setSum(Long sum){
this.sum = sum;
}
public Long getAll(){
return all;
}
public void setAll(Long all){
People.all = all;
}
}
public class Main {
public static void main(String[] args) {
People people = new People();
Runnable runnable = () -> {
Thread thread = Thread.currentThread();
System.out.println(thread.getName() + thread.getId() + " start ");
for (int i = 0; i < 10000; i++) {
people.setAll(people.getAll() + 1);
people.setSum(people.getSum() + 1);
}
System.out.println(thread.getName() + thread.getId() + " end ");
};
System.out.println("main thread sum = " + people.getSum() + " , all = " + people.getAll());
ExecutorService service = new ThreadPoolExecutor(10, 10, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
for (int i = 0; i < 10; i++) {
service.execute(runnable);
}
service.shutdown();
try {
TimeUnit.SECONDS.sleep(4);
} catch (InterruptedException e) {
System.out.println("main interrupt exception");
}
System.out.println("main end sum = " + people.getSum() + " , all = " + people.getAll());
}
}
我們建立了一個類,類裡面有兩個屬性,一個是static的,另一個是非static的。
也就是說,all是類變量,sum是執行個體變量。
在主線程中,我們一個線程将People的屬性值增加1W,那麼,10個線程就是10W。
我們預期的目标是all和sum都是10W。
4.4 synchronized 同步代碼塊–類對象
為了解決這個問題,有兩種解決方式:加鎖(Lock)或者同步(synchronized)
在這裡隻考慮同步的實作方式。
你可能注意到了,我們的People中的兩個屬性,一個是類屬性,一個是執行個體屬性。
首先,我們使用代碼塊同步類的方式,進行同步:
運作結果:
預期分析:因為使用的是類同步,對于每一個執行個體對象來說,對應的都是同一個類對象。是以當這10個線程的其中某一個線程擷取了類同步的鎖,其他線程就無法擷取類同步的鎖了,其他線程就會被阻塞了。
這樣就保證了同一時間隻會有一個線程操作類變量和執行個體變量。就不存在并發問題了。
4.5 synchronized 同步代碼塊–執行個體對象
接下來,我們使用對象同步呢?
預期分析:經過上面的例子,這個可以很輕松的分析出來,這個例子也能達到我們的目的。
因為這10個線程使用的是同一個執行個體對象,是以使用執行個體對象,也就是10個線程在競争一個執行個體對象的同步鎖。
也能夠保證同一時間内,隻有一個線程操作類變量和執行個體變量。
4.6 synchronized 同步代碼塊–任意執行個體對象
上面兩個小例子是synchronized同步對象的例子,在同步代碼塊的場景中,還有一種,同步任意執行個體對象。
其實同步任意執行個體對象和同步某一個執行個體對象的原理是一樣的:
在這種寫法下,10個線程競争同一個執行個體對象的同步鎖,當然可以保證同一時間内隻有一個線程進行操作。
可是,如果每一個線程使用的都是自己線程内建立的執行個體對象呢?
預期分析:因為我們将執行個體對象放到了線程内,那麼首先這個10個線程對應的是10個執行個體對象,每一個線程同步的都是自己線程内建立的對象,這當然每一個線程都能夠擷取到執行個體對象鎖了,也就是每一個線程在任意時間都可以操作類變量和執行個體變量。
也就無法達到預期目标了。
換個角度想,當我們将執行個體對象的建立移到線程内的時候,對于每一個單個的線程來說,其同步的都是自己線程内的局部變量。
4.7 synchronized 同步方法–類方法
我們看完了synchronized同步代碼塊,接下來看看synchronized同步方法:
預期分析:
因為方法是類方法,在整個記憶體中隻有一個,是以,可以保證同一時間隻有一個線程能夠擷取鎖。
這個可能不太好對比:
我們新增了兩個方法,一個是類方法,一個是執行個體方法。
因為我們在類方法上進行同步,是以類變量符合預期結果,而執行個體方法因為沒有進行同步,是以,執行個體變量不符合預期結果:
4.8 synchronized 同步方法–執行個體方法
接下來我們根據上面的例子,同步執行個體方法,然後不同步類方法,以作對比:
預期分析:因為類方法沒有進行同步,是以類方法應該不符合預期結果。
而執行個體方法進行同步,那麼同步方法應該是符合預期的。
即使這樣調用,類方法也不同步的:
5. synchronized 的缺陷
-
synchronized效率低
如果在同步方法或者同步代碼所需時間較長,那麼其他線程就必須阻塞等待, 而且可能會發生無限等待的情況,synchronized非常影響程式執行效率(Lock有多種鎖,可以根據需要同步的操作選擇不同的鎖)
-
synchronized資源使用率低
一般來說,寫操作和寫操作有沖突,寫操作和讀操作有沖突。
但是讀操作和讀操作是沒有沖突的。
在程式中,我們較多的操作是讀取計算,寫入隻占一部分。
使用synchronized的時候,如果是讀操作,同步了,其他線程也無法進行讀操作。
也就造成資源的浪費。
-
synchronized無法知道是否成功擷取到鎖
使用synchronized我們可以實作同步,但是線程是否成功擷取鎖,這是一個不确定事件。
6. synchronized的鎖處理
在jdk5之後,jvm對synchronized做了優化。
- 預設開啟偏向鎖
-
會進行鎖更新
在jdk5之前synchronized是重量級鎖。
在jdk5之前,使用synchronized是比較耗費資源的。
因為在jdk5之前,synchronized是需要調用OperatorSystem的一些操作,實作鎖的。
這就涉及到線程需要從使用者态切換到核心态。
這個切換過程非常耗費時間。是以,在jdk5之前,synchronized是重量級鎖,耗費性能。
在jdk5之後,jvm對synchronized進行了優化。
在jdk5之後,線程使用synchronized進行同步,首先會使用偏向鎖,如果有第二個線程競争鎖,此時鎖會更新為輕量級鎖,多個線程競争輕量級鎖,未競争到鎖的線程進行自旋等待。如果自旋超過10次還未擷取到鎖,那麼鎖就會更新為重量級鎖。
偏向鎖的機制也比較簡單,在對象的對象頭中寫入了一個線程的id,那麼此時,如果這個線程再次擷取鎖,jvm将對象的對象頭中的線程id與競争鎖的線程id進行對比,如果是一樣的,那麼這個線程就直接擷取鎖。
如果有多于1個線程進行競争鎖,此時偏向鎖隻能記錄一個線程id,就不合适了,此時會更新為輕量級鎖。
偏向鎖的設計思想是:在大多數程式中,我們還是串行處理占多數;并發處理的時間或者操作占比比較低。
輕量級鎖的設計思想是:在大多數并發中,我們需要同步加鎖的操作是比較簡單,快速的操作,占整個線程處理時間的占比很小。是以,每一個線程擷取到鎖之後,大多數是在很短的時間内就會釋放。
重量級鎖的設計思想是:即使并發沖突的機率比較小,但是并發沖突的造成的後果非常的嚴重。當并發沖突無法避免的時候,我們就需要保證并發的安全。
7. synchronized 處理過程
![]()
Java基礎--synchronized原理詳解
- Contention List:競争隊列,所有請求鎖的線程首先被放在這個競争隊列中;
- Entry List:Contention List中那些有資格成為候選資源的線程被移動到Entry List中;
- Wait Set:哪些調用wait方法被阻塞的線程被放置在這裡;
- OnDeck:任意時刻,最多隻有一個線程正在競争鎖資源,該線程被成為OnDeck;
- Owner:目前已經擷取到所資源的線程被稱為Owner;
- !Owner:目前釋放鎖的線程。