概述
在前面的多線程專欄中,我們提到線程併發所帶來的安全性問題。導致安全性問題的主要原因是多個線程同時操作同一塊內存空間。在如何解決該問題時,我們提到:通過 鎖 的方式強制同時只能有一個線程操作共享內存,其他線程在該線程釋放鎖後進行搶鎖,只有搶到鎖的線程才能向下執行,否則就阻塞等待。在JAVA語言中,Synchronized關鍵字就可以保證同一時刻,只能有一個線程執行某段代碼。在某些併發場景中,也是通過它保證線程的安全性。
Synchronized
本篇主要從以下五個模塊展開
- Synchronized 簡單介紹
- Synchronized 如何使用
- 可重入鎖
- 非公平鎖
- 死鎖
1、Synchronized 簡單介紹
我們可以抽象的理解 Synchronized 是一種鎖,通過它可以鎖定一段代碼。當線程執行到被 Synchronized 鎖定的代碼塊時,首先試着去獲取鎖資源,如果能獲取到,就繼續向下執行,如果獲取失敗,線程阻塞等待其他線程釋放鎖資源。其中線程在執行完被鎖定的代碼塊時纔會釋放鎖資源。
舉個不恰當的例子:遊樂場新開了蹦牀項目。因爲害怕小朋友之間出現碰撞發生危險,遊樂場規定每次只能一位小朋友玩耍,每個小朋友玩幾分鐘後換下一位。沒有搶到的小朋友只能在外面等待,當換下一位小朋友時:所有小朋友一擁而上,誰先搶到就是誰玩。
在上述案例中,小朋友就類似線程,蹦牀就類似被鎖定的代碼塊,遊樂場的規定就類似 Synchronized鎖。下面我們具體分析其中隱含的特性:
-
每次只能有一位小朋友玩耍,也就是說最多隻能有一個線程執行代碼,線程之間是 互斥關係。因此Synchronized 也是一種 互斥鎖。
-
所有小朋友一擁而上,誰先搶到就是誰玩,也就是說線搶搶鎖這個過程是隨機的,因此 Synchronized 也是一種 非公平鎖。
-
我們來說一種特殊情況:假如一個小朋友越玩越起勁,不想出來了,這種場景下,其他小朋友也只能在外面等着。也就是說,線程在佔有鎖的時候,可以再嘗試獲取鎖,也就是說鎖對象是可以重複獲取的。因此 Synchronized 也是一種 可重入鎖。
有了上面的介紹,我們再來看在 JAVA 代碼中 Synchronized 如何使用
2、Synchronized 如何使用
在 JAVA 代碼中,Synchronized有以下三種使用方法:
- 修飾實例方法
- 修飾靜態方法
- 修飾代碼塊
2-1、修飾實例方法
修飾實例方法的 JAVA 代碼是這樣的:
public synchronized void methodName();
當使用它修飾實例方法時,鎖是當前實例的對象鎖。也就是說,線程在執行該方法時,會首先獲取 當前對象 的鎖,如果能獲取到才向下執行。下面我們具體看兩組測試用例:
public class SynchronizedTest {
CountDownLatch countDownLatch = new CountDownLatch(2);
static int num = 0;
class Worker implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
incr();
}
countDownLatch.countDown();
}
}
public synchronized void incr() {
num = num + 1;
}
@Test
public void test() {
new Thread(new Worker()).start();
new Thread(new Worker()).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("執行完畢後,num的值爲:" + num);
}
}
執行結果:執行完畢後,num的值爲:200000
在上述代碼中,我們創建兩個工作線程,每個工作線程執行被 Synchronized 修飾的實例方法 incr() 100000 次,並通過 CountDownLatch 對象確定輸出時所有工作線程執行完畢。其中無論執行多少次,結果總是 200000,也就是說上述代碼是線程安全的。
該實例中,Synchronized 鎖定的是 SynchronizedTest 類的實例方法,也就說:它鎖定的是 SynchronizedTest 實例對象,在JUnit測試中,確定實例唯一,因此兩個工作線程競爭的是同一個鎖,因此不會出現線程安全問題。
下面我們測試一下:Synchronized 修飾實例方法,但競爭的不是同一個實例對象鎖時的情況:
public class SynchronizedTest {
CountDownLatch countDownLatch = new CountDownLatch(2);
static int num = 0;
class Worker implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
incr();
}
countDownLatch.countDown();
}
public synchronized void incr() {
num = num + 1;
}
}
@Test
public void test() {
new Thread(new Worker()).start();
new Thread(new Worker()).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("執行完畢後,num的值爲:" + num);
}
}
執行結果:執行完畢後,num的值爲:116730 (每次運行結果不一致,幾乎無法達到 200000)
在上述代碼中,我們將 incr() 方法 移動到 Worker 內部類中。此時雖然我們使用 Synchronized修飾,但代碼還是線程不安全的。
該實例中,Synchronized 鎖定的是 Worker 類的實例方法,也就說:它鎖定的是 Worker 實例對象。而在 JUnit 測試方法中,我們創建了兩個 Worker 對象,也就是說:兩個線程競爭的不是同一把鎖,而是各自對象所對應的鎖,因此纔會出現線程安全問題。
2-2、修飾靜態方法
修飾靜態方法的 Java 代碼是這樣的:
public static synchronized void methodName();
public synchronized static void methodName();
上述兩種寫法都可以。當使用 synchronized 鎖定靜態方法時,鎖對象是當前類的class對象。也就是說,當線程執行到被synchronized修飾的靜態方法時,嘗試獲取當前類class對象的鎖,如果獲取到,才能向下執行。
我們回到上述案例二中的代碼,嘗試將 incr() 方法改爲靜態方法:
public class SynchronizedTest {
static CountDownLatch countDownLatch = new CountDownLatch(2);
static int num = 0;
static class Worker implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
incr();
}
countDownLatch.countDown();
}
public static synchronized void incr() {
num = num + 1;
}
}
@Test
public void test() {
new Thread(new Worker()).start();
new Thread(new Worker()).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("執行完畢後,num的值爲:" + num);
}
}
執行結果:執行完畢後,num的值爲:200000
在上述代碼中,我們將 incr() 方法改爲靜態方法後,代碼變爲線程安全的。
在實例中,Synchronized 鎖定的是當前 Worker 類的class對象鎖。此時雖然有兩個Worker對象,但是它們競爭的是同一個class對象鎖,因此不會出現線程安全問題。
2-3、修飾代碼塊
修飾代碼塊的 Java 代碼是這樣的:
synchronized(ClassName.class){}
synchronized(object){}
上述兩種方式都可以。也就是說:修飾代碼塊既可以使用實例對象鎖,也可以使用類 class 對象鎖。
我認爲代碼塊的方式更優雅和高效。因爲通常情況下,java方法不是每一行代碼都存在線程安全問題,只有少部分操作共享內存的代碼才需要加鎖。因此如果直接給方法加鎖的話,部分本來就安全的代碼也會被阻塞,這樣是很影響效率的。使用代碼塊就可以把需要加鎖的代碼單獨加鎖,提高效率。
下面我給出一個使用代碼塊的簡單案例:
public class SynchronizedTest {
static CountDownLatch countDownLatch = new CountDownLatch(2);
static int num = 0;
static class Worker implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
synchronized (Worker.class) {
num = num + 1;
}
}
countDownLatch.countDown();
}
}
@Test
public void test() {
new Thread(new Worker()).start();
new Thread(new Worker()).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("執行完畢後,num的值爲:" + num);
}
}
執行結果:執行完畢後,num的值爲:200000
在上述代碼中,我們把 synchronized 修飾靜態方法改爲修飾代碼塊,鎖對象還是 Worker 類的 class 對象,因此不會出現線程安全問題。如果我們把 Worker.class 改爲 this,鎖對象就會變成 Worker類實例,此時兩個線程競爭的又變成不同的鎖,又會出現線程安全問題。
3、可重入鎖
在本篇的第一個模塊,我們提出 Synchronized 鎖是一種 可重入鎖。雖然那個案例不是很恰當,但也說明了部分可重入鎖特性:線程請求自身已經佔有的鎖並且不會阻塞。
證明 Synchronized 是可重入鎖也比較簡單:讓線程獲取自己已經佔有的鎖。如果線程沒有阻塞,就說明該鎖是可重入鎖。我們直接上代碼:
public class ReentrantLockTest {
private class Worker implements Runnable {
synchronized void method1() {
System.out.println("當前線程已獲得實例鎖對象");
method2();
}
synchronized void method2() {
System.out.println("當前線程再次獲取實例鎖對象");
}
@Override
public void run() {
method1();
}
}
@Test
public void test() {
new Thread(new Worker()).start();
}
}
運行結果:
當前線程已獲得實例鎖對象
當前線程再次獲取實例鎖對象
在上述代碼中,我們創建了兩個 synchronized 修飾的方法。線程啓動後,首先需要獲取當前實例對象鎖,獲取到鎖後執行 method1() 方法。在執行 method1() 方法期間調用 method2() 方法。此時鎖對象還沒有釋放,又重新獲取鎖對象。從運行結果來看,method2() 方法正常執行。也就是說,Synchronized 鎖是可重入鎖。
那麼可重入鎖有什麼用呢?我認爲可重入鎖最大的好處就是避免了死鎖的可能性。
在實際應用場景中,很可能出現多個業務方法都存在線程安全性問題,都需要加鎖的可能。假設此時我們都使用Synchronized鎖。如果一個業務需要執行多個方法。並且 Synchronized 鎖不是可重入鎖,我們只能如下所示通過串行的方式進行解決:
method1();
method2();
method3();
...
這種做的壞處顯而易見:需要頻繁的加解鎖操作,性能絕大多數被消耗在加解鎖上面。
正是因爲Synchronized鎖是可重入的,我們可以通過更優雅的方式解決:
method1(){
method2(){
method3(){
...
}
}
}
也就是說我們可以在方法內部調用,並且不會造成死鎖,這也正是鎖可重入的意義。
4、非公平鎖
提起非公平鎖,我們首先給出公平鎖的概念:可以按照線程請求鎖的順序依次獲取鎖。那非公平鎖自然就是不會按請求順序獲取鎖。
下面我們通過簡單的案例加以證明:
public class UnFairLockTest {
CountDownLatch countDownLatch = new CountDownLatch(20);
class Worker implements Runnable {
@Override
public void run() {
method();
countDownLatch.countDown();
}
}
synchronized void method() {
String methodName = Thread.currentThread().getName();
System.out.println(methodName + "獲取到鎖");
}
@Test
public void test() {
for (int i = 0; i < 20; i++) {
new Thread(new Worker()).start();
}
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
執行結果:
Thread-0獲取到鎖
Thread-1獲取到鎖
Thread-10獲取到鎖
Thread-3獲取到鎖
...
在上述案例中,我們依次創建編號從0到19的線程請求鎖,但從執行結果來看,並沒有按照線程的請求順序依次獲取鎖。因此也就說明 Synchronized 鎖是非公平鎖。
不過需要明確的一點是:非公平鎖在大多數場景下性能更好。因爲非公平鎖不需要維護數據結構來保證獲取鎖的順序,並且 CPU 調度線程本來就是隨機的,非公平鎖更契合這種模式。
5、死鎖
死鎖是併發編程中最常見的錯誤:簡答來說,就是兩個線程互相佔有對方需要請求的資源。此時兩個線程都不願讓步(釋放資源),導致線程都無法正常執行。當然死鎖產生不單單僅限兩個線程,只要形成環形等待關係都會產生死鎖。
雖然說死鎖很常見,但不是說不避免。一般好的併發編程代碼都不會產生死鎖問題,因爲死鎖問題一旦產生,從外部根本無法解決。如果沒有提前預留解決方法,只能重啓應用。
在 synchronized 鎖中,因爲線程會獨佔鎖資源,因此也有可能產生死鎖問題,作爲開發者我們要儘量避免。下面我給出一個死鎖的例子:
public class DeadLockTest {
CountDownLatch countDownLatch = new CountDownLatch(2);
class Worker1 implements Runnable {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
synchronized (Worker1.class) {
System.out.println(threadName + "獲取到Worker1類鎖");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (Worker2.class) {
System.out.println(threadName + "獲取到Worker2類鎖");
}
}
countDownLatch.countDown();
}
}
class Worker2 implements Runnable {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
synchronized (Worker2.class) {
System.out.println(threadName + "獲取到Worker2類鎖");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (Worker1.class) {
System.out.println(threadName + "獲取到Worker1類鎖");
}
}
countDownLatch.countDown();
}
}
@Test
public void test() {
new Thread(new Worker1()).start();
new Thread(new Worker2()).start();
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
執行結果:
Thread-0獲取到Worker1類鎖
Thread-1獲取到Worker2類鎖
在上述代碼中。線程A首先獲取 Worker1 類的class鎖對象,線程B獲取 Worker2 類的class鎖對象。後面線程A請求 Worker2 類的class鎖對象,線程B請求Worker1 類的class鎖對象,他們請求的資源分別被對方所佔有,產生死鎖,線程永遠無法執行。