從今天開始,我們將進入 Java 并發學習階段。軟件并發已經成為現代軟件開發的基礎能力,而 Java 精心設計的高效并發機制,正是構建大規模應用的基礎之一,所以考察并發基本功也成為各個公司面試 Java 工程師的必選項。
今天我要問你的問題是,synchronized 和 ReentrantLock 有什么區別?有人說 synchronized 最慢,這話靠譜嗎?
## 典型回答
synchronized 是 Java 內建的同步機制,所以也有人稱其為 Intrinsic Locking,它提供了互斥的語義和可見性,當一個線程已經獲取當前鎖時,其他試圖獲取的線程只能等待或者阻塞在那里。
在 Java 5 以前,synchronized 是僅有的同步手段,在代碼中, synchronized 可以用來修飾方法,也可以使用在特定的代碼塊兒上,本質上 synchronized 方法等同于把方法全部語句用 synchronized 塊包起來。
ReentrantLock,通常翻譯為再入鎖,是 Java 5 提供的鎖實現,它的語義和 synchronized 基本相同。再入鎖通過代碼直接調用 lock() 方法獲取,代碼書寫也更加靈活。與此同時,ReentrantLock 提供了很多實用的方法,能夠實現很多 synchronized 無法做到的細節控制,比如可以控制 fairness,也就是公平性,或者利用定義條件等。但是,編碼中也需要注意,必須要明確調用 unlock() 方法釋放,不然就會一直持有該鎖。
synchronized 和 ReentrantLock 的性能不能一概而論,早期版本 synchronized 在很多場景下性能相差較大,在后續版本進行了較多改進,在低競爭場景中表現可能優于 ReentrantLock。
## 考點分析
今天的題目是考察并發編程的常見基礎題,我給出的典型回答算是一個相對全面的總結。
對于并發編程,不同公司或者面試官面試風格也不一樣,有個別大廠喜歡一直追問你相關機制的擴展或者底層,有的喜歡從實用角度出發,所以你在準備并發編程方面需要一定的耐心。
我認為,鎖作為并發的基礎工具之一,你至少需要掌握:
* 理解什么是線程安全。
* synchronized、ReentrantLock 等機制的基本使用與案例。
更近一步,你還需要:
* 掌握 synchronized、ReentrantLock 底層實現;理解鎖膨脹、降級;理解偏斜鎖、自旋鎖、輕量級鎖、重量級鎖等概念。
* 掌握并發包中 java.util.concurrent.lock 各種不同實現和案例分析。
## 知識擴展
專欄前面幾期穿插了一些并發的概念,有同學反饋理解起來有點困難,尤其對一些并發相關概念比較陌生,所以在這一講,我也對會一些基礎的概念進行補充。
首先,我們需要理解什么是線程安全。
我建議閱讀 Brain Goetz 等專家撰寫的《Java 并發編程實戰》(Java Concurrency in Practice),雖然可能稍顯學究,但不可否認這是一本非常系統和全面的 Java 并發編程書籍。按照其中的定義,線程安全是一個多線程環境下正確性的概念,也就是保證多線程環境下**共享的**、**可修改的**狀態的正確性,這里的狀態反映在程序中其實可以看作是數據。
換個角度來看,如果狀態不是共享的,或者不是可修改的,也就不存在線程安全問題,進而可以推理出保證線程安全的兩個辦法:
* 封裝:通過封裝,我們可以將對象內部狀態隱藏、保護起來。
* 不可變:還記得我們在[專欄第 3 講](http://time.geekbang.org/column/article/6906)強調的 final 和 immutable 嗎,就是這個道理,Java 語言目前還沒有真正意義上的原生不可變,但是未來也許會引入。
線程安全需要保證幾個基本特性:
* **原子性**,簡單說就是相關操作不會中途被其他線程干擾,一般通過同步機制實現。
* **可見性**,是一個線程修改了某個共享變量,其狀態能夠立即被其他線程知曉,通常被解釋為將線程本地狀態反映到主內存上,volatile 就是負責保證可見性的。
* **有序性**,是保證線程內串行語義,避免指令重排等。
可能有點晦澀,那么我們看看下面的代碼段,分析一下原子性需求體現在哪里。這個例子通過取兩次數值然后進行對比,來模擬兩次對共享狀態的操作。
你可以編譯并執行,可以看到,僅僅是兩個線程的低度并發,就非常容易碰到 former 和 latter 不相等的情況。這是因為,在兩次取值的過程中,其他線程可能已經修改了 sharedState。
~~~
public class ThreadSafeSample {
public int sharedState;
public void nonSafeAction() {
while (sharedState < 100000) {
int former = sharedState++;
int latter = sharedState;
if (former != latter - 1) {
System.out.printf("Observed data race, former is " +
former + ", " + "latter is " + latter);
}
}
}
public static void main(String[] args) throws InterruptedException {
ThreadSafeSample sample = new ThreadSafeSample();
Thread threadA = new Thread(){
public void run(){
sample.nonSafeAction();
}
};
Thread threadB = new Thread(){
public void run(){
sample.nonSafeAction();
}
};
threadA.start();
threadB.start();
threadA.join();
threadB.join();
}
}
~~~
下面是在我的電腦上的運行結果:
~~~
C:\>c:\jdk-9\bin\java ThreadSafeSample
Observed data race, former is 13097, latter is 1309
~~~
將兩次賦值過程用 synchronized 保護起來,使用 this 作為互斥單元,就可以避免別的線程并發的去修改 sharedState。
~~~
synchronized (this) {
int former = sharedState ++;
int latter = sharedState;
// …
}
~~~
如果用 javap 反編譯,可以看到類似片段,利用 monitorenter/monitorexit 對實現了同步的語義:
~~~
11: astore_1
12: monitorenter
13: aload_0
14: dup
15: getfield #2 // Field sharedState:I
18: dup_x1
…
56: monitorexit
~~~
我會在下一講,對 synchronized 和其他鎖實現的更多底層細節進行深入分析。
代碼中使用 synchronized 非常便利,如果用來修飾靜態方法,其等同于利用下面代碼將方法體囊括進來:
~~~
synchronized (ClassName.class) {}
~~~
再來看看 ReentrantLock。你可能好奇什么是再入?它是表示當一個線程試圖獲取一個它已經獲取的鎖時,這個獲取動作就自動成功,這是對鎖獲取粒度的一個概念,也就是鎖的持有是以線程為單位而不是基于調用次數。Java 鎖實現強調再入性是為了和 pthread 的行為進行區分。
再入鎖可以設置公平性(fairness),我們可在創建再入鎖時選擇是否是公平的。
~~~
ReentrantLock fairLock = new ReentrantLock(true);
~~~
這里所謂的公平性是指在競爭場景中,當公平性為真時,會傾向于將鎖賦予等待時間最久的線程。公平性是減少線程“饑餓”(個別線程長期等待鎖,但始終無法獲取)情況發生的一個辦法。
如果使用 synchronized,我們根本**無法進行**公平性的選擇,其永遠是不公平的,這也是主流操作系統線程調度的選擇。通用場景中,公平性未必有想象中的那么重要,Java 默認的調度策略很少會導致 “饑餓”發生。與此同時,若要保證公平性則會引入額外開銷,自然會導致一定的吞吐量下降。所以,我建議**只有**當你的程序確實有公平性需要的時候,才有必要指定它。
我們再從日常編碼的角度學習下再入鎖。為保證鎖釋放,每一個 lock() 動作,我建議都立即對應一個 try-catch-finally,典型的代碼結構如下,這是個良好的習慣。
~~~
ReentrantLock fairLock = new ReentrantLock(true);// 這里是演示創建公平鎖,一般情況不需要。
fairLock.lock();
try {
// do something
} finally {
fairLock.unlock();
}
~~~
ReentrantLock 相比 synchronized,因為可以像普通對象一樣使用,所以可以利用其提供的各種便利方法,進行精細的同步操作,甚至是實現 synchronized 難以表達的用例,如:
* 帶超時的獲取鎖嘗試。
* 可以判斷是否有線程,或者某個特定線程,在排隊等待獲取鎖。
* 可以響應中斷請求。
* …
這里我特別想強調**條件變量**(java.util.concurrent.Condition),如果說 ReentrantLock 是 synchronized 的替代選擇,Condition 則是將 wait、notify、notifyAll 等操作轉化為相應的對象,將復雜而晦澀的同步操作轉變為直觀可控的對象行為。
條件變量最為典型的應用場景就是標準類庫中的 ArrayBlockingQueue 等。
我們參考下面的源碼,首先,通過再入鎖獲取條件變量:
~~~
/** Condition for waiting takes */
private final Condition notEmpty;
/** Condition for waiting puts */
private final Condition notFull;
public ArrayBlockingQueue(int capacity, boolean fair) {
if (capacity <= 0)
throw new IllegalArgumentException();
this.items = new Object[capacity];
lock = new ReentrantLock(fair);
notEmpty = lock.newCondition();
notFull = lock.newCondition();
}
~~~
兩個條件變量是從**同一再入鎖**創建出來,然后使用在特定操作中,如下面的 take 方法,判斷和等待條件滿足:
~~~
public E take() throws InterruptedException {
final ReentrantLock lock = this.lock;
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await();
return dequeue();
} finally {
lock.unlock();
}
}
~~~
當隊列為空時,試圖 take 的線程的正確行為應該是等待入隊發生,而不是直接返回,這是 BlockingQueue 的語義,使用條件 notEmpty 就可以優雅地實現這一邏輯。
那么,怎么保證入隊觸發后續 take 操作呢?請看 enqueue 實現:
~~~
private void enqueue(E e) {
// assert lock.isHeldByCurrentThread();
// assert lock.getHoldCount() == 1;
// assert items[putIndex] == null;
final Object[] items = this.items;
items[putIndex] = e;
if (++putIndex == items.length) putIndex = 0;
count++;
notEmpty.signal(); // 通知等待的線程,非空條件已經滿足
}
~~~
通過 signal/await 的組合,完成了條件判斷和通知等待線程,非常順暢就完成了狀態流轉。注意,signal 和 await 成對調用非常重要,不然假設只有 await 動作,線程會一直等待直到被打斷(interrupt)。
從性能角度,synchronized 早期的實現比較低效,對比 ReentrantLock,大多數場景性能都相差較大。但是在 Java 6 中對其進行了非常多的改進,可以參考性能[對比](https://dzone.com/articles/synchronized-vs-lock),在高競爭情況下,ReentrantLock 仍然有一定優勢。我在下一講進行詳細分析,會更有助于理解性能差異產生的內在原因。在大多數情況下,無需糾結于性能,還是考慮代碼書寫結構的便利性、可維護性等。
今天,作為專欄進入并發階段的第一講,我介紹了什么是線程安全,對比和分析了 synchronized 和 ReentrantLock,并針對條件變量等方面結合案例代碼進行了介紹。下一講,我將對鎖的進階內容進行源碼和案例分析。
## 一課一練
關于今天我們討論的 synchronized 和 ReentrantLock 你做到心中有數了嗎?思考一下,你使用過 ReentrantLock 中的哪些方法呢?分別解決什么問題?
- 前言
- 開篇詞
- 開篇詞 -以面試題為切入點,有效提升你的Java內功
- 模塊一 Java基礎
- 第1講 談談你對Java平臺的理解?
- 第2講 Exception和Error有什么區別?
- 第3講 談談final、finally、 finalize有什么不同?
- 第4講 強引用、軟引用、弱引用、幻象引用有什么區別?
- 第5講 String、StringBuffer、StringBuilder有什么區別?
- 第6講 動態代理是基于什么原理?
- 第7講 int和Integer有什么區別?
- 第8講 對比Vector、ArrayList、LinkedList有何區別?
- 第9講 對比Hashtable、HashMap、TreeMap有什么不同?
- 第10講 如何保證集合是線程安全的? ConcurrentHashMap如何實現高效地線程安全?
- 第11講 Java提供了哪些IO方式? NIO如何實現多路復用?
- 第12講 Java有幾種文件拷貝方式?哪一種最高效?
- 第13講 談談接口和抽象類有什么區別?
- 第14講 談談你知道的設計模式?
- 模塊二 Java進階
- 第15講 synchronized和ReentrantLock有什么區別呢?
- 第16講 synchronized底層如何實現?什么是鎖的升級、降級?
- 第17講 一個線程兩次調用start()方法會出現什么情況?
- 第18講 什么情況下Java程序會產生死鎖?如何定位、修復?
- 第19講 Java并發包提供了哪些并發工具類?
- 第20講 并發包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么區別?
- 第21講 Java并發類庫提供的線程池有哪幾種? 分別有什么特點?
- 第22講 AtomicInteger底層實現原理是什么?如何在自己的產品代碼中應用CAS操作?
- 第23講 請介紹類加載過程,什么是雙親委派模型?
- 第24講 有哪些方法可以在運行時動態生成一個Java類?
- 第25講 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError?
- 第26講 如何監控和診斷JVM堆內和堆外內存使用?
- 第27講 Java常見的垃圾收集器有哪些?
- 第28講 談談你的GC調優思路?
- 第29講 Java內存模型中的happen-before是什么?
- 第30講 Java程序運行在Docker等容器環境有哪些新問題?
- 模塊三 Java安全基礎
- 第31講 你了解Java應用開發中的注入攻擊嗎?
- 第32講 如何寫出安全的Java代碼?
- 模塊四 Java性能基礎
- 第33講 后臺服務出現明顯“變慢”,談談你的診斷思路?
- 第34講 有人說“Lambda能讓Java程序慢30倍”,你怎么看?
- 第35講 JVM優化Java代碼時都做了什么?
- 模塊五 Java應用開發擴展
- 第36講 談談MySQL支持的事務隔離級別,以及悲觀鎖和樂觀鎖的原理和應用場景?
- 第37講 談談Spring Bean的生命周期和作用域?
- 第38講 對比Java標準NIO類庫,你知道Netty是如何實現更高性能的嗎?
- 第39講 談談常用的分布式ID的設計方案?Snowflake是否受冬令時切換影響?
- 周末福利
- 周末福利 談談我對Java學習和面試的看法
- 周末福利 一份Java工程師必讀書單
- 結束語
- 結束語 技術沒有終點
- 結課測試 Java核心技術的這些知識,你真的掌握了嗎?