Java多線程 | 02 可重入鎖與Synchronized的其他特性

目錄

 

第02課 可重入鎖與 Synchronized 的其他特性

Synchronized 鎖重入

Synchronized 的其他特性

volatile 與 synchronized 的區別

volatile 的使用


第02課 可重入鎖與 Synchronized 的其他特性

上一節中基本介紹了進程和線程的區別、實現多線程的兩種方式、線程安全的概念以及如何使用 Synchronized 實現線程安全。下邊介紹一下關於 Synchronized 的其他基本特性。

Synchronized 鎖重入

1、關鍵字 Synchronized 擁有鎖重入的功能,也就是在使用 Synchronized 的時候,當一個線程得到一個對象的鎖後,在該鎖裏執行代碼的時候可以再次請求該對象的鎖時可以再次得到該對象的鎖。

2、也就是說,當線程請求一個由其它線程持有的對象鎖時,該線程會阻塞,而當線程請求由自己持有的對象鎖時,如果該鎖是重入鎖,請求就會成功,否則阻塞。

3、一個簡單的例子就是:在一個 Synchronized 修飾的方法,或代碼塊的內部調用本類的其他 Synchronized 修飾的方法或代碼塊時,永遠可以得到鎖,示例代碼 A 如下:

public class SyncDubbo {

    public synchronized void method1() {
        System.out.println("method1-----");
        method2();
    }

    public synchronized void method2() {
        System.out.println("method2-----");
        method3();
    }

    public synchronized void method3() {
        System.out.println("method3-----");
    }

    public static void main(String[] args) {
        final SyncDubbo syncDubbo = new SyncDubbo();
        new Thread(new Runnable() {
            @Override
            public void run() {
                syncDubbo.method1();
            }
        }).start();
    }
}

執行結果:

method1-----
method2-----
method3-----

示例代碼 A 向我們演示了,如何在一個已經被 Synchronized 關鍵字修飾過的方法再去調用對象中其他被 Synchronized 修飾的方法。

4、那麼,爲什麼要引入可重入鎖這種機制?

我們上一篇文章中介紹了一個“對象一把鎖,多個對象多把鎖”,可重入鎖的概念就是:自己可以獲取自己的內部鎖。

假如有一個線程 T 獲得了對象 A 的鎖,那麼該線程 T 如果在未釋放前再次請求該對象的鎖時,如果沒有可重入鎖的機制,是不會獲取到鎖的,這樣的話就會出現死鎖的情況。

就如代碼 A 體現的那樣,線程 T 在執行到method1()內部的時候,由於該線程已經獲取了該對象 syncDubbo 的對象鎖,當執行到調用method2() 的時候,會再次請求該對象的對象鎖,如果沒有可重入鎖機制的話,由於該線程 T 還未釋放在剛進入method1() 時獲取的對象鎖,當執行到調用method2() 的時候,就會出現死鎖。

5、那麼可重入鎖到底有什麼用呢?

正如上述代碼 A 和第4條解釋的那樣,最大的作用是避免死鎖。假如有一個場景:用戶名和密碼保存在本地 txt 文件中,則登錄驗證方法和更新密碼方法都應該被加 synchronized,那麼當更新密碼的時候需要驗證密碼的合法性,所以需要調用驗證方法,此時是可以調用的。

6、關於可重入鎖的實現原理,是一個大論題,在這裏篇幅有限不再學習,有興趣可以移步至:cnblogs 進行學習。

7、可重入鎖的其他特性:父子可繼承性

可重入鎖支持在父子類繼承的環境中,示例代碼如下:

public class SyncDubbo {

    static class Main {
        public int i = 5;
        public synchronized void operationSup() {
            i--;
            System.out.println("Main print i =" + i);
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    static class Sub extends Main {
        public synchronized void operationSub() {
            while (i > 0) {
                i--;
                System.out.println("Sub print i = " + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        new Thread(new Runnable() {
            public void run() {
                Sub sub = new Sub();
                sub.operationSub();
            }
        }).start();
    }
}

Synchronized 的其他特性

  • 出現異常時,鎖自動釋放

就是說,當一個線程執行的代碼出現異常的時候,其所持有的鎖會自動釋放,示例如下:

public class SyncException {

    private int i = 0;

    public synchronized void operation() {
        while (true) {
            i++;
            System.out.println(Thread.currentThread().getName() + " , i= " + i);
            if (i == 10) {
                Integer.parseInt("a");
            }
        }
    }

    public static void main(String[] args) {
        final SyncException se = new SyncException();
        new Thread(new Runnable() {
            public void run() {
                se.operation();
            }
        }, "t1").start();
    }
}

執行結果如下:

t1 , i= 2
t1 , i= 3
t1 , i= 4
t1 , i= 5
t1 , i= 6
t1 , i= 7
t1 , i= 8
t1 , i= 9
t1 , i= 10
java.lang.NumberFormatException: For input string: "a"
    at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
    //其他輸出信息

可以看出,當執行代碼報錯的時候,程序不會再執行,即釋放了鎖。

  • 將任意對象作爲監視器 monitor

    public class StringLock {

    private String lock = "lock";
    
    public void method() {
        synchronized (lock) {
            try {
                System.out.println("當前線程: " + Thread.currentThread().getName() + "開始");
                Thread.sleep(1000);
                System.out.println("當前線程: " + Thread.currentThread().getName() + "結束");
            } catch (InterruptedException e) {
    } } 
    } public static void main(String[] args) { final StringLock stringLock = new StringLock(); new Thread(new Runnable() { public void run() { stringLock.method(); } }, "t1").start();
    new Thread(new Runnable() {
        public void run() {
            stringLock.method();
        }
    }, "t2").start();
    
    }

    }

執行結果:

當前線程: t1開始
當前線程: t1結束
當前線程: t2開始
當前線程: t2結束
  • 單利模式-雙重校驗鎖:

普通加鎖的單利模式實現:

public class Singleton {

    private static Singleton instance = null; //懶漢模式
    //private static Singleton instance = new Singleton(); //餓漢模式

    private Singleton() {

    }

    public static synchronized Singleton newInstance() {
        if (null == instance) {
            instance = new Singleton();
        }
        return instance;
    }
}

使用上述的方式可以實現多線程的情況下獲取到正確的實例對象,但是每次訪問newInstance()方法都會進行加鎖和解鎖操作,也就是說該鎖可能會成爲系統的瓶頸,爲了解決這個問題,有人提出了“雙重校驗鎖”的方式,示例代碼如下:

public class DubbleSingleton {

    private static DubbleSingleton instance;

    public static DubbleSingleton getInstance(){
        if(instance == null){
            try {
                //模擬初始化對象的準備時間...
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //類上加鎖,表示當前對象不可以在其他線程的時候創建
            synchronized (DubbleSingleton.class) { 
                //如果不加這一層判斷的話,這樣的話每一個線程會得到一個實例
                //而不是所有的線程的到的是一個實例
                if(instance == null){ 
                    instance = new DubbleSingleton();
                }
            }
        }
        return instance;
    }
}

但是,需要注意的是,上述的代碼是錯誤的寫法,這是因爲:指令重排優化,可能會導致初始化單利對象和將該對象地址賦值給 instance 字段的順序與上面 Java 代碼中書寫的順序不同。

例如:線程 A 在創建單例對象時,在構造方法被調用之前,就爲該對象分配了內存空間並將對象設置爲默認值。此時線程 A 就可以將分配的內存地址賦值給 instance 字段了,然而該對象可能還沒有完成初始化操作。線程 B 來調用 newInstance() 方法,得到的 就是未初始化完全的單例對象,這就會導致系統出現異常行爲。

爲了解決上述的問題,可以使用volatile關鍵字進行修飾 instance 字段。volatile 關鍵字在這裏的含義就是禁止指令的重排序優化(另一個作用是提供內存可見性),從而保證 instance 字段被初始化時,單例對象已經被完全初始化。

最終代碼如下:

public class DubbleSingleton {

    private static volatile DubbleSingleton instance;

    public static DubbleSingleton getInstance(){
        if(instance == null){
            try {
                //模擬初始化對象的準備時間...
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //類上加鎖,表示當前對象不可以在其他線程的時候創建
            synchronized (DubbleSingleton.class) { 
                //如果不加這一層判斷的話,這樣的話每一個線程會得到一個實例
                //而不是所有的線程的到的是一個實例
                if(instance == null){ 
                    instance = new DubbleSingleton();
                }
            }
        }
        return instance;
    }
}

那麼問題來了,爲什麼 volatile 關鍵字可以實現禁止指令的重排序優化以及什麼是指令重排序優化呢?

在 Java 內存模型中我們都是圍繞着原子性、有序性和可見性進行討論的。爲了確保線程間的原子性、有序性和可見性,Java 中使用了一些特殊的關鍵字申明或者是特殊的操作來告訴虛擬機,在這個地方,要注意一下,不能隨意變動優化目標指令。關鍵字 volatile 就是其中之一。

指令重排序是 JVM 爲了優化指令,提高程序運行效率,在不影響單線程程序執行結果的前提下,儘可能地提高並行度(比如:將多條指定並行執行或者是調整指令的執行順序)。編譯器、處理器也遵循這樣一個目標。注意是單線程。可想而知,多線程的情況下指令重排序就會給程序員帶來問題。

最重要的一個問題就是程序執行的順序可能會被調整,另一個問題是對修改的屬性無法及時的通知其他線程,已達到所有線程操作該屬性的可見性。

根據編譯器的優化規則,如果不使用 volatile 關鍵字對變量進行修飾的,那麼這個變量被修改後,其他線程可能並不會被通知到,甚至在別的想愛你城中,看到變量修改順序都會是反的。一旦使用 volatile 關鍵字進行修飾的話,虛擬機就會特別小心的處理這種情況。

volatile 與 synchronized 的區別

volatile 關鍵字的作用就是強制從公共堆棧中取得變量的值,而不是線程私有的數據棧中取得變量的值。

enter image description here

  1. 關鍵字 volatile 是線程同步的輕量級實現,性能比 synchronized 要好,並且 volatile 只能修於變量,而 synchronized 可以修飾方法,代碼塊等。

  2. 多線程訪問 volatile 不會發生阻塞,而 synchronized 會發生阻塞。

  3. 可以保證數據的可見性,但不可以保證原子性,而 synchronized 可以保證原子性,也可以間接保證可見性,因爲他會將私有內存和公共內存中的數據做同步。

  4. volatile 解決的是變量在多個線程之間的可見性,而 synchronized 解決的是多個線程之間訪問資源的同步性。

volatile 的使用

很不幸的是,我個人比較熟悉的 MyBatis 框架沒有用到任何 volatile 相關的知識,只能拿自己目前還不是很熟悉的 Spring 簡單截圖,不多做解釋,示意圖如下:

enter image description here

雖然部分內容看不懂,但是它確實用到了。因此,我們只有很清楚的瞭解 volatile 關鍵字的作用,以後再看 Spring 源代碼的時候我們纔可以很清楚的知道它的作用,而不是雲裏霧裏,不知所云。

有一點我們還是可以看懂的,volatile 修飾的都是屬性而不是方法,Spring 使用 volatile 來保證變量在多個線程之間的可見性!

然後,我們再看一道牛客網上的筆試題:

enter image description here

出於運行速率的考慮,Java 編譯器會把經常經常訪問的變量放到緩存(嚴格講應該是工作內存)中,讀取變量則從緩存中讀。但是在多線程編程中,內存中的值和緩存中的值可能會出現不一致。volatile 用於限定變量只能從內存中讀取,保證對所有線程而言,值都是一致的。但是 volatile 不能保證原子性,也就不能保證線程安全。 因此答案就是 A。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章