第 2 章 多執行緒程式設計常用功能類
本章我們將開始學習 Java 多執行緒程式設計的進階內容,透過學習常用的多執行緒程式設計常用的同步功能、執行緒鎖、同步屏障等功能,然後進行多種執行緒安全的功能類知識的學習,初步掌握在效能測試中遇到的各種執行緒安全問題解決思路,為接下來的效能測試實戰打好基礎。
2.1 執行緒安全
只要談起 Java 多執行緒,就繞不開一個最重要的核心問題 “執行緒安全”。什麼是執行緒安全呢?那麼咱們再來一個小故事來說明問題。
故事主角叫 “小八”,他有一個非常要好的小夥伴,名字叫小七,倆人都很喜歡爬山。
今年立秋之後的某個週末,小七想約小八去爬山,就給小八發個訊息:“小八,週末有空嘛?” 小八回覆:“有空啊。” 然後倆人就週末去爬山了。
這就是正常的單執行緒溝通的場景,小八和小七進行一對一的溝通。
加入小八還有一個關係很好的朋友,名字叫小九,倆人都很喜歡釣魚。於是立秋之後的同一天想約小八釣魚。於是便發生以下的對話:
頻道 1,小九 to 小八:小八,週末有空嘛?
頻道 2,小七 to 小八:小八,週末有空嘛?
頻道 1,小八 to 小九:有啊
頻道 2,小八 to 小七:有啊
頻道 1,小九 to 小八:咱們去釣魚呀
頻道 1,小八 to 小九:好啊,走起
頻道 2,小七 to 小八:咱們去爬山呀
頻道 2,小八 to 小七:啊!我週末跟小九去釣魚。
頻道 2,小七 to 小八:你剛才不是還說有空嘛?
頻道 2,小八 to 小七:……
實際生活中經常會發生類似的情況,原因在於小八週末的狀態在整個對話過程中是變化的,但是這個變化只告訴了第一個約他釣魚的小九,小七並沒有及時得知最新情況。
在 Java 多執行緒程式設計中也是相同的情況,當一個執行緒改變了一個物件的狀態,其他執行緒並沒有及時得知最新情況,持有的還是物件的舊狀態,就會導致實際結果與預期不符的現象發生。
那麼聰明的你一定想到了解決辦法,例如:
- (1)讓小七、小八和小九在一個房間和聊天群,這樣相互之間可以看到聊天內容,就不會產生尷尬的情況。
- 讓兩個朋友打電話約釣魚或者爬山,這樣小八在跟小九打電話約釣魚的時候,就不會接小七的電話約爬山了。
這兩個辦法其實隱含了解決 Java 執行緒安全問題的兩個思路:一是使用執行緒安全的類同步狀態,二是將多執行緒轉成單執行緒。
在實際效能測試當中,我們遇到的執行緒安全問題會比較複雜,解決問題的辦法也多種多樣,下面分享 Java 解決執行緒安全常見的幾種思路。
2.2 synchronized 關鍵字
在解決 Java 執行緒安全問題的答案中,關鍵字 synchronized 無疑是最直接、最簡單的。
synchronized 是 Java 語言非常重要的一個關鍵字,主要作用是解決執行緒安全的問題。synchronized 主要用法分成兩大類:
2.2.1 synchronized 基礎語法
synchronized 關鍵字用於控制多個執行緒對共享資源的訪問,以避免不一致性的問題。使用 synchronized 關鍵字可以使一段程式碼變成同步程式碼塊,這段程式碼執行就會變成執行緒安全的程式碼。
基本的語法展示如下:
Object object = new Object();
synchronized (object) {
doSomething();//同步的程式碼
}
如果你留意過這個,應該還會經常看到這樣的程式碼:
synchronized (SynchronizedDemo.class) {
doSomething();//同步的程式碼
}
那麼這兩者的區別在哪裡呢?且聽我娓娓道來。
當你使用 synchronized 同步一個物件,那麼程式會在多個執行緒之間建立一個互斥區,確保只有一個執行緒可以執行同步程式碼塊。這種用法通常在多執行緒修改某個物件屬性場景中,用來保障執行緒安全。
如果你使用 synchronized 同步一個類,他會同步類的 class 物件,保證只有一個執行緒可以執行同步程式碼塊。這種用法通常用在多執行緒修改多個例項共享的類級別的資源,例如:計數器、快取等等。
這麼說或許你還會有疑惑,下面透過演示程式碼來說明兩者的區別。
2.2.2 synchronized 同步物件
首先我們先看一下演示程式碼:
package org.funtester.performance.books.chapter02.section2;
public class SynchronizedDemoFirst {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
SynchronizedDemoFirst demoFirst = new SynchronizedDemoFirst();
new Thread(() -> {
demoFirst.test();
}).start();
}
}
public void test() {
synchronized (this) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " Hello FunTester! " + Thread.currentThread().getName());
}
}
}
這個例子中,我定義了個例項方法,該方法同步物件是當前的類物件,休眠 100 毫秒之後列印一些資訊。在 main 方法中,我寫了一個次數為 3 的 for 迴圈,每次迴圈建立 1 個新的類物件,然後在非同步執行緒中執行改物件的 test() 方法。
控制檯輸出內容如下:
1698459271749 Hello FunTester! Thread-2
1698459271749 Hello FunTester! Thread-1
1698459271749 Hello FunTester! Thread-0
可以看到 3 個執行緒同一時間執行了 test()
方法,列印了資訊。這說明當synchronized
關鍵字同步物件為例項物件時,是無法保障多個例項物件執行改例項方法的執行緒安全的。
那麼這類場景我們應該如何正確設計程式碼,實現執行緒安全呢?請看下面這個例子。
package org.funtester.performance.books.chapter02.section2;
public class SynchronizedDemoSecond {
public static void main(String[] args) {
SynchronizedDemoSecond first = new SynchronizedDemoSecond();
for (int i = 0; i < 3; i++) {
new Thread(() -> {
first.test();
}).start();
}
}
public void test() {
synchronized (this) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " Hello FunTester! " + Thread.currentThread().getName());
}
}
}
在這個例子中,我們使用只建立 1 個例項物件,多執行緒均呼叫改例項的 test() 方法。其中內在含義就是synchronized
關鍵字同步的物件 “this” 本質都是同一個物件。
控制檯輸出:
1698459581053 Hello FunTester! Thread-0
1698459581154 Hello FunTester! Thread-2
1698459581258 Hello FunTester! Thread-1
3 個執行緒時間戳相差約 100 毫秒,說明這個場景下多執行緒是安全的。
相信聰明的你一定能看出來其中的差別,當 synchronized 同步的物件是同一個,那麼執行緒就是安全的,反之則不安全。那麼問題來了,使用 synchronized 關鍵字可不可以在不同物件訪問某段程式碼塊的時候也保障執行緒安全呢?
當然是可以的。在效能測試工作實戰中,有兩種方式可以實現這個需求。下面我們先來看第一種方式:
package org.funtester.performance.books.chapter02.section2;
public class SynchronizedDemoThird {
static Object object = new Object();
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
SynchronizedDemoThird demoSecond = new SynchronizedDemoThird();
new Thread(() -> {
demoSecond.test();
}).start();
}
}
public void test() {
synchronized (object) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " Hello FunTester! " + Thread.currentThread().getName());
}
}
}
這裡將 synchronized 同步的物件手動設定為同一個物件,這樣就不用再考慮多個例項物件在多執行緒場景下的執行緒安全問題了。
控制檯輸出內容如下:
1698460561473 Hello FunTester! Thread-0
1698460561574 Hello FunTester! Thread-2
1698460561679 Hello FunTester! Thread-1
第二種方式就用到了上一節提到的 snchronize 第二種用法,將 synchronized 物件設定為類 class 物件。演示程式碼如下:
package org.funtester.performance.books.chapter02.section2;
public class SynchronizedDemoFoutth {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
SynchronizedDemoFoutth demoSecond = new SynchronizedDemoFoutth();
new Thread(() -> {
demoSecond.test();
}).start();
}
}
public void test() {
synchronized (SynchronizedDemoFirst.class) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " Hello FunTester! " + Thread.currentThread().getName());
}
}
}
這裡只將第二個演示程式碼的 test() 方法中 synchronize 物件做了修改。
控制檯輸出內容:
1698460826400 Hello FunTester! Thread-0
1698460826500 Hello FunTester! Thread-1
1698460826605 Hello FunTester! Thread-2
同樣地,這種方式也可以解決多個例項物件在多執行緒場景下的執行緒安全。
2.2.3 synchronized 同步方法
經過上一節 4 個例子,你已經對synchronized
使用有了初步瞭解。對於 synchronized 同步物件使用方法基本掌握了。
如果你留意過 JDK 裡面對於synchronized
使用,還可以把synchronized
關鍵字寫到方法定義的修飾符位置上。那麼synchronized
同步方法是如何保障執行緒安全的呢?下面用兩個例子說明。
1.synchronized 同步例項方法
package org.funtester.performance.books.chapter02.section2;
public class SynchronizedDemoFifth {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
SynchronizedDemoFifth demoSecond = new SynchronizedDemoFifth();
new Thread(() -> {
demoSecond.test();
}).start();
}
}
public synchronized void test() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " Hello FunTester! " + Thread.currentThread().getName());
}
}
控制檯輸出:
1698461487751 Hello FunTester! Thread-0
1698461487751 Hello FunTester! Thread-1
1698461487751 Hello FunTester! Thread-2
2.synchronized 同步靜態方法
package org.funtester.performance.books.chapter02.section2;
public class SynchronizedDemoSixth {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new Thread(() -> {
test();
}).start();
}
}
public static synchronized void test() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(System.currentTimeMillis() + " Hello FunTester! " + Thread.currentThread().getName());
}
}
控制檯輸出:
1698461629191 Hello FunTester! Thread-0
1698461629295 Hello FunTester! Thread-2
1698461629395 Hello FunTester! Thread-1
總結來說,synchronized 同步例項方法,並能保障多例項訪問的執行緒安全;synchronized 同步靜態方法是可以保障多執行緒的執行緒安全的。這麼說比較繞,下面是個簡化之後的結論:
- (1)synchronized 同步例項方法等效於 synchronized 同步 this 物件
- (2)synchronized 同步靜態方法等效於 synchronized 同步改類的 class 物件
這樣是不是很明瞭,在實際的效能測試工作中,synchronized 同步方法是比較少用到的。原因兩點:一是不夠靈活,顆粒度是方法級別的,太粗了;二是效能較差,特別在高併發場景下,可能會導致效能大幅下降。
對於新手來說,synchronized 關鍵字使用起來很容易上手,寫出來的程式碼可讀性也雖然 synchronized 也可以用來編寫較為複雜的執行緒安全場景,但是對於測試人員程式碼能力要求比較高,也存在可讀性差、排查困難等問題。綜上,建議大家在使用 synchronized 時,儘量編寫功能邏輯簡單的執行緒安全程式碼。若功能邏輯複雜,可以拋開 synchronized,尋求其他簡單、可靠、已經驗證的解決方案。
2.2.4 synchronized 最佳實戰
synchronized 有一個重要的使用場景,就是在雙重檢查鎖。雙重檢查鎖是針對單例物件的一種執行緒安全實戰,使用 synchronized 關鍵字實現,旨在保證執行緒安全的前提下,提高應用程式的效能和併發能力。
下面是 synchronized 雙重檢查鎖的演示程式碼:
package org.funtester.performance.books.chapter02.section2;
package org.funtester.performance.books.chapter02.section2;
public class DoubleCheckedLocking {
private static DoubleCheckedLocking driver;
public static DoubleCheckedLocking getDriver() {
if (driver == null) {
synchronized (DoubleCheckedLocking.class) {
if (driver == null) {
driver = new DoubleCheckedLocking();
}
}
}
return driver;
}
}
這裡用到 synchronized 同步類的 class 物件用法,保障所有訪問該方法的執行緒在進行第二次檢查的時候是執行緒安全的,從而保障 driver 物件只被初始化一次。當初始化完成之後,再訪問該方法的執行緒又不會執行 synchronized 同步方法,提升了程式的效能。
書的名字:從 Java 開始做效能測試 。
如果本書內容對你有所幫助,希望各位多多讚賞,讓我可以貼補家用。讚賞兩位數可以提前閱讀未公開章節。我也會嘗試製作本書的影片教程,包括必要的答疑。
FunTester 原創精華
- 混沌工程、故障測試、Web 前端
- 服務端功能測試
- 效能測試專題
- Java、Groovy、Go
- 白盒、工具、爬蟲、UI 自動化
- 理論、感悟、影片