## 17 資源有限,請排隊等候—Synchronized使用、原理及缺陷
> 人生的旅途,前途很遠,也很暗。然而不要怕,不怕的人的面前才有路。
> ——?魯?迅
到現在為止,本專欄已經發布了近一半的內容。我還記得早在第三節就有同學留言預測下一節要講synchronized。確實,很多講解Java并發編程的書籍會比較早的安排講解 synchronized 關鍵字。其實synchronized 使用起來非常簡單,并且幾乎不用做什么思考,需要確保線程安全的部分直接用就好了。所以我覺得只是使用的話沒什么好講的,隨著其它內容一并介紹下就可以了。在介紹了并發的三大特性,順便學習完Atomic 和 volatile 后,再深入學習 synchronized。我認為這樣的安排更能幫助讀者理解。
## 1、synchronized 的作用
前兩節我們學習了能保證原子性的 Atomic 變量以及保證可見性和有序性的 volatile 關鍵字。這兩種方式是輕量級的同步方式,不過存在其局限性,前面已經做過總結。那么“重量級”的同步如何做呢?synchronized 代碼塊就是一種實現方式。在 synchronized 代碼塊中的代碼在多線程中會同步執行,同步執行的意思就是——排隊。這就像我們去體檢,每個人可以并行從家里到醫院,并行拿表、填表,并行走到各個檢查室門口。但是,一旦要做檢查了,我們就需要在檢查室門口排隊。這是因為只有一個大夫做檢查,大夫是共享資源。對共享資源的訪問我們要保證同步,否則就會出現問題。
synchronized 作用域中的代碼為同步執行的,也就是并發的情況下,執行到對同一個對象加鎖的 synchronized 代碼塊時,為串行執行的。這里注意,并不是同一個同步代碼塊,而是對同一個對象上鎖的同步代碼塊。這意味著范圍更廣。此外 synchronized 可以確保可見性,在一個線程執行完 synchronized 代碼后,所有代碼中對變量值的變化都能立即被其它線程所看到。
由于 synchronized 關鍵字會使得代碼串行執行,這就喪失了多線程的優勢。并且 synchronized 關鍵字的使用也有相應成本。所以我們代碼中能不用 synchronized 就不用。當不得不用的時候,需要盡量控制 synchronized 代碼塊中的代碼行數。這就像高速公路上,本來三車道,所有車輛開得很快,但是突然遇到檢查點,車輛只能一輛一輛通過,那么速度一下就慢了下來,必然造成堵車。我們應該盡量減少這種人為堵點。

## 2、synchronized 的使用
synchronized 的使用非常簡單,有兩種方式,第一種是同步代碼塊。
我們拿之前例子的代碼片段回顧下:
~~~java
synchronized (tasks) {
if (tasks.size() > 0) {
task = tasks.removeFirst();
sleep(100);
tasks.notifyAll();
} else {
tasks.wait();
}
}
~~~
~~~java
synchronized (tasks) {
if (tasks.size() < MAX) {
Task task = new Task(new Random().nextInt(3) + 1, getPunishedWord());
tasks.addLast(task);
System.out.println(threadName + "留了作業,抄寫" + task.getWordToCopy() + " " + task.getLeftCopyCount() + "次");
tasks.notifyAll();
} else {
System.out.println(threadName+"開始等待");
tasks.wait();
System.out.println("teacher線程 " + threadName + "線程-" + name + "等待結束");
}
}
~~~
這是生產者/消費者那一節的部分代碼。第一段是學生寫作業的代碼,第二段是老師留作業的代碼。可以看到 synchronized 的使用很簡單,把你需要同步的代碼放入 synchronized 關鍵字后面的大括號中即可。
另外你肯定注意到 synchronized (tasks) ,這行代碼小括號里的 tasks 對象。為什么要這么寫呢?這是和 synchronized 實現的方式相關的。你是不是心里在想:這個對象一定是被加鎖的對象,加了鎖之后,別的線程就不能對該對象訪問了。這里理解起來好像非常的自然。其實并不是這樣,小括號里的對象是可以是任意的對像。之前我們講解過這一點,這個對象相當于是同步代碼塊的看門人,每個對其 synchronized 的線程,它都會記錄下來,然后等到同步代碼塊沒有線程執行的時候,它就會通知其它線程來執行同步代碼塊。
所以我們并不是對此對象加鎖,只是讓它來維護秩序。這個人是誰其實并無所謂。但是我們的例子中,并發的線程并不是同樣類型的 Thread,一個是 Student,還一個是 Teacher。對于不同對象的同步控制,一定要選用兩個線程都持有的對象才行。否則各自使用不同的對象,相當于聘用了兩個看門人,各看各的門,毫無瓜葛。那么原本想要串行執行的代碼仍舊會并行執行。
第二種,使用 synchronized 關鍵字修飾方法:
~~~java
public synchronized void eat(){
.......
.......
}
~~~
你是不是會好奇,這里沒有鎖對象,是如何加鎖的呢?其實同步方法的鎖對象就是 this。這和下面代碼把方法中代碼全部用 synchronized(this) 括起來的效果是一樣的:
~~~java
public void eat(){
synchronized(this){
.......
.......
}
}
~~~
如果是 synchroinized 的是靜態方法,如下面代碼:
~~~java
public static synchronized void eat(){
.......
.......
}
~~~
此時同步方法為類的 Class 對象。如果上述靜態方法所在的類為 Test。那么鎖對象就是 Test.class。
構造方法是不能使用 synchronized 關鍵字修飾的。因為同步的構造方法是講不通的,對于一個指定的對象,它只會有唯一的創建線程,所以不需要使用 synchroinzied 修飾。
下面是 synchronized 的使用總結:
1、選用一個鎖對象,可以是任意對象;
2、鎖對象鎖的是同步代碼塊,并不是自己;
3、不同類型的多個 Thread 如果有代碼要同步執行,鎖對象要使用所有線程共同持有的同一個對象;
4、需要同步的代碼放到大括號中。需要同步的意思就是需要保證原子性、可見性、有序性中的任何一種或多種。不要放不需要同步的代碼進來,影響代碼效率。
## 3、synchronized 原理
synchronized 的秘密其實都在同步對象上。就像上文所說,這個對象就是一個看門人,每次只允許一個線程進來,進門后此線程可以做任何自己想做的事情,然后再出來。此時看門人會吼一嗓子:沒人了,可以進來啦!其它線程聽到吼聲,馬上都沖了過來。但總有個敏捷值最高的線程先沖入門內,那么其它線程只好繼續等待。
其實 synchronized 原理基本和上面的例子一樣。下面我們真正來看看其實現原理是什么。相信如果你看懂了上面的例子,對 synchronized 原理的理解不會有任何難度。
我們一直說的同步對象,其實就是任何一個普通的對象。那么一個普通的java對象是如何來做同步這件事的呢?這是因為每個對象都關聯了一個 monitor lock。
當一個線程獲取了 monitor lock 后,其它線程如果運行到獲取同一個 monitor 的時候就會被 block 住。當這個線程執行完同步代碼,則會釋放 monitor lock。在后一個線程獲取鎖后,happens-before 原則生效,前一個線程所做的任何修改都會被這個線程看到。
我們再深入底層一點來分析。每個 Java 對象在 JVM 的對等對象的頭中保存鎖狀態,指向 ObjectMonitor。ObjectMonitor 保存了當前持有鎖的線程引用,EntryList 中保存目前等待獲取鎖的線程,WaitSet 保存 wait 的線程。此外還有一個計數器,每當線程獲得 monitor 鎖,計數器 +1,當線程重入此鎖時,計數器還會 +1。當計數器不為0時,其它嘗試獲取 monitor 鎖的線程將會被保存到EntryList中,并被阻塞。當持有鎖的線程釋放了monitor 鎖后,計數器 -1。當計數器歸位為 0 時,所有 EntryList 中的線程會嘗試去獲取鎖,但只會有一個線程會成功,沒有成功的線程仍舊保存在 EntryList 中。**由此可以看出 monitor 鎖是非公平鎖**。
我們看一下前面例子中 Student 類編譯之后的匯編指令。或者你也可以自己寫一段簡單的帶有 synchronized 關鍵字的代碼。先將其編譯為.class 文件,然后使用 javap -c xxx.class 進行反匯編。我們就可以得到 java 代碼對應的匯編指令。里面可以找到如下兩行指令。
~~~
......
15: monitorenter
......
128: monitorexit
......
~~~
這兩條指令就是上面所講述的獲取鎖和釋放鎖的關鍵指令。我看過使用 zookeepe r實現分布式鎖的 Curator 框架源代碼,Curator 的互斥鎖和 monitor 鎖在原理上一模一樣。
## 4、synchronized 使用注意
1. synchronized 使用的為非公平鎖,如果你需要公平鎖,那么不要使用 synchronized。可以使用 ReentrantLock,設置為公平鎖。關于 ReentrantLock,會在后面章節進行講解;
2. 鎖對象不能為 null。如果鎖對象為 null,何談對象頭,以及保存與其關聯的 monitor 鎖呢?所以代碼中要確保synchronized使用的鎖對象不為 null;
3. 只把需要同步的代碼放入 synchronized 代碼塊。如果不思考,為了線程安全把方法中全部代碼都放入同步代碼塊,那么將會喪失多線程的優勢。再多的線程也只能串行執行,這完全違背了并發的初衷;
4. 只有使用同一個對象作為鎖對象,才能同步。記住是同一個對象,而不是同一個類。有一種常犯的錯誤是,不同線程持有的是同一個類的不同實例。那么該對象實例用作鎖對象的話,多個線程并不會同步。還一種錯誤是使用不同類的實例作為鎖對象,但是期望不同位置的同步代碼塊能夠同步執行。這是不可能達到你想要的效果的。
## 5、總結
本節我們學習了Java多線程領域使用最多的同步方式 synchronized 關鍵字。synchronized 使用方便簡單,但是一定注意其作用范圍不要過大。另外 synchronized 也有其局限性。我們在后面會學習到 Lock 接口及其實現,可以解決 synchronized 存在的問題。
- 前言
- 第1章 Java并發簡介
- 01 開篇詞:多線程為什么是你必需要掌握的知識
- 02 絕對不僅僅是為了面試—我們為什么需要學習多線程
- 03 多線程開發如此簡單—Java中如何編寫多線程程序
- 04 人多力量未必大—并發可能會遇到的問題
- 第2章 Java中如何編寫多線程
- 05 看若兄弟,實如父子—Thread和Runnable詳解
- 06 線程什么時候開始真正執行?—線程的狀態詳解
- 07 深入Thread類—線程API精講
- 08 集體協作,什么最重要?溝通!—線程的等待和通知
- 09 使用多線程實現分工、解耦、緩沖—生產者、消費者實戰
- 第3章 并發的問題和原因詳解
- 10 有福同享,有難同當—原子性
- 11 眼見不實—可見性
- 12 什么?還有這種操作!—有序性
- 13 問題的根源—Java內存模型簡介
- 14 僵持不下—死鎖詳解
- 第4章 如何解決并發問題
- 15 原子性輕量級實現—深入理解Atomic與CAS
- 16 讓你眼見為實—volatile詳解
- 17 資源有限,請排隊等候—Synchronized使用、原理及缺陷
- 18 線程作用域內共享變量—深入解析ThreadLocal
- 第5章 線程池
- 19 自己動手豐衣足食—簡單線程池實現
- 20 其實不用造輪子—Executor框架詳解
- 第6章 主要并發工具類
- 21 更高級的鎖—深入解析Lock
- 22 到底哪把鎖更適合你?—synchronized與ReentrantLock對比
- 23 按需上鎖—ReadWriteLock詳解
- 24 經典并發容器,多線程面試必備—深入解析ConcurrentHashMap上
- 25 經典并發容器,多線程面試必備—深入解析ConcurrentHashMap下
- 26不讓我進門,我就在門口一直等!—BlockingQueue和ArrayBlockingQueue
- 27 倒數計時開始,三、二、一—CountDownLatch詳解
- 28 人齊了,一起行動—CyclicBarrier詳解
- 29 一手交錢,一手交貨—Exchanger詳解
- 30 限量供應,不好意思您來晚了—Semaphore詳解
- 第7章 高級并發工具類及并發設計模式
- 31 憑票取餐—Future模式詳解
- 32 請按到場順序發言—Completion Service詳解
- 33 分階段執行你的任務-學習使用Phaser運行多階段任務
- 34 誰都不能偷懶-通過 CompletableFuture 組裝你的異步計算單元
- 35拆分你的任務—學習使用Fork/Join框架
- 36 為多線程們安排一位經理—Master/Slave模式詳解
- 第8章 總結
- 37 結束語