Java鎖系列——1、Synchronized 簡單介紹及使用

概述

在前面的多線程專欄中,我們提到線程併發所帶來的安全性問題。導致安全性問題的主要原因是多個線程同時操作同一塊內存空間。在如何解決該問題時,我們提到:通過 的方式強制同時只能有一個線程操作共享內存,其他線程在該線程釋放鎖後進行搶鎖,只有搶到鎖的線程才能向下執行,否則就阻塞等待。在JAVA語言中,Synchronized關鍵字就可以保證同一時刻,只能有一個線程執行某段代碼。在某些併發場景中,也是通過它保證線程的安全性。


Synchronized

本篇主要從以下五個模塊展開

  1. Synchronized 簡單介紹
  2. Synchronized 如何使用
  3. 可重入鎖
  4. 非公平鎖
  5. 死鎖

1、Synchronized 簡單介紹

我們可以抽象的理解 Synchronized 是一種鎖,通過它可以鎖定一段代碼。當線程執行到被 Synchronized 鎖定的代碼塊時,首先試着去獲取鎖資源,如果能獲取到,就繼續向下執行,如果獲取失敗,線程阻塞等待其他線程釋放鎖資源。其中線程在執行完被鎖定的代碼塊時纔會釋放鎖資源。

舉個不恰當的例子:遊樂場新開了蹦牀項目。因爲害怕小朋友之間出現碰撞發生危險,遊樂場規定每次只能一位小朋友玩耍,每個小朋友玩幾分鐘後換下一位。沒有搶到的小朋友只能在外面等待,當換下一位小朋友時:所有小朋友一擁而上,誰先搶到就是誰玩。

在上述案例中,小朋友就類似線程,蹦牀就類似被鎖定的代碼塊,遊樂場的規定就類似 Synchronized鎖。下面我們具體分析其中隱含的特性:

  • 每次只能有一位小朋友玩耍,也就是說最多隻能有一個線程執行代碼,線程之間是 互斥關係。因此Synchronized 也是一種 互斥鎖

  • 所有小朋友一擁而上,誰先搶到就是誰玩,也就是說線搶搶鎖這個過程是隨機的,因此 Synchronized 也是一種 非公平鎖

  • 我們來說一種特殊情況:假如一個小朋友越玩越起勁,不想出來了,這種場景下,其他小朋友也只能在外面等着。也就是說,線程在佔有鎖的時候,可以再嘗試獲取鎖,也就是說鎖對象是可以重複獲取的。因此 Synchronized 也是一種 可重入鎖

有了上面的介紹,我們再來看在 JAVA 代碼中 Synchronized 如何使用


2、Synchronized 如何使用

在 JAVA 代碼中,Synchronized有以下三種使用方法:

  1. 修飾實例方法
  2. 修飾靜態方法
  3. 修飾代碼塊

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鎖對象,他們請求的資源分別被對方所佔有,產生死鎖,線程永遠無法執行。


參考:
https://blog.csdn.net/javazejian/article/details/72828483
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章