深入淺出 Java 併發程式設計 (1)

huangyz0918發表於2018-09-06

【本文轉載自】蔣古申

本文目錄

  • 故事緣由
  • synchronized 關鍵字學習
    • 什麼是 synchronized ?
    • synchronized 關鍵字的作用域
    • 為什麼要使用 synchronized 關鍵字?
    • synchronized 關鍵字的特性
    • synchronized 同步鎖使用的優化

故事緣由

前一陣面試完了 MSRA IEG 組的實習,技術面試的時候由於我在時間內提早寫完了程式碼,所以面試官又加問了幾個問題。其中一個是關於 Java 執行緒安全的,以前在工程上很少遇到這類問題,但是這類問題在面試的時候出現的概率是非常高的,題目不難,大概是這個樣子:

// 這段程式碼是否是執行緒安全的?如果不是,怎麼修改?

public class A {
      int a = 0;

      void funA(){
           a++;
      }

      int funB(){
           int b = a + 1;
           return b;
      }
}
複製程式碼

這個題目是很典型的併發問題,面試官叫我現場給他修改看看,於是我把方法改成了:

public class A {
      int a = 0;

      synchronized void funA(){
           a++;
      }

      synchronized int funB(){
           int b = a + 1;
           return b;
      }
}
複製程式碼

後來思考,感覺自己當時的回答其實不好,這兩個方法裡面,容易造成併發問題的其實只有一個變數 a ,考慮到程式的效率, synchronized 關鍵字實際上是很重的,對於這種情況可以直接使用 Java 裡面自帶的一些原子類,在這裡可以使用 AtomicInteger,改進的程式碼如下:

public class A {
      AtomicInteger a = new AtomicInteger(0);

      void funA(){
           count.incrementAndGet();
      }

      int funB() {
           int b = a.addAndGet(1);
           return b;
     }
}

複製程式碼

在面完這個問題之後,我覺得自己在 Java 併發程式設計上面的基礎仍然薄弱,還需要系統的學習和提升,畢竟這個還是屬於程式語言的基礎,對於面試來說,即使其他的演算法解答和專案介紹得非常好,僥倖通過了面試,但是基礎不牢固還是沒法在今後的工作和研究中走遠。

這幾日無聊,打算重新系統地對 Java 併發程式設計進行一個複習,同時記錄在此也算當作個人的筆記,如果有不足或者是錯誤的地方,歡迎大家指正。

synchronized 關鍵字學習

什麼是 synchronized ?

在介紹這個關鍵字的時候,我想先說一個生活中的場景,假如有一個衛生間,一次只能一個人使用,如果兩個人同時擠到衛生間裡面就會出問題 (誤)。有些程式碼也是一樣,在多執行緒的環境下,如果多個執行緒同時呼叫了某段程式碼,這些程式碼處理的結果在不同執行緒裡面就有可能出現不同步的問題。所以,我們使用了 synchronized ,它是一個同步鎖,它的作用就是保證同一時間程式碼呼叫的同步性。

換句話來說,synchronized 就像是衛生間包廂前面的鎖,一個人進去了以後,他拿到了這把鎖,把自己鎖在包廂裡面,這樣同一個時間就只有他能夠享用衛生間了,沒有鎖的其他人是無法訪問這個衛生間的,就只能在衛生間門口排隊,等待裡面的那個人上完衛生間出來,釋放鎖,把鎖交給下一個排隊的人。

那麼,說了半天,什麼是鎖呢?(感覺很抽象) 我們先來閱讀一段程式碼:

public class SynchronizedUse {
    private int count = 10;
    // 鎖物件
    private Object o = new Object();

    public void m() {
        synchronized (o) { // 想要執行下面一段程式碼,必須先拿到 o 的鎖
            count--;
            System.out.println(Thread.currentThread().getName() + " count= " + count);
        }
    }
}
複製程式碼

在這一段程式碼裡面,我們新建了一個 Object 物件,並且使用 synchronized 作用在了了這個物件 o 上。很多人理解鎖概念的時候出現了誤解,認為 synchronized “鎖”住的是 synchronized 作用的程式碼塊,其實這是不對的,synchronized 鎖著的是物件。想要執行上述程式碼中被 synchronized 修飾的程式碼塊,只有拿到物件o的鎖才行。

由於物件只有一個,所以程式碼保證了每次只會有一個執行緒能夠拿到這把鎖,只有一個執行緒能夠執行被鎖著的程式碼。

synchronized 關鍵字的作用域

那我是不是每次想要同步一段程式碼,都得新建一個 Object 物件呢?

不是的,閱讀下面這段程式碼會發現,其實我們可以直接使用 this 物件來進行程式碼的鎖定,這是一種簡化的寫法。

public class SynchronizedUse02 {
    private int count = 10;

    private void m() {
        synchronized (this) {
            count--;
            System.out.println(Thread.currentThread().getName() + " count= " + count);
        }
    }
}
複製程式碼

synchronized 關鍵字除了使用花括號{}修飾一個程式碼塊(同步程式碼塊)以外,還可以直接修飾一個方法,被修飾的方法也被稱為同步方法,其實和直接修飾一段程式碼塊相同,修飾方法鎖定的也是 this 物件,如下面程式碼所示,這個寫法等同於SynchronizedUse02(上一個demo)。

public class SynchronizedUse03 {

    private int count = 10;

    public synchronized void m() { // 等同於 synchronized(this){ ...}
        count--;
        System.out.println(Thread.currentThread().getName() + " count= " + count);
    }
}
複製程式碼

同時,synchronized 關鍵字還可以修飾一個靜態的方法,其作用的範圍是整個靜態方法,作用的物件是這個類的所有物件。瞭解 Java static 關鍵字的同學們都知道,作用在 static 方法上面的 synchronized 關鍵字實際上沒有作用在任何例項化的物件上,而是直接作用在類物件上面。如下面程式碼所示:

public class SynchronizedUse04 {

    private static int count = 10;

    // synchronized 使用在靜態方法上的時候,相當於鎖定了 class
    public synchronized static void m() {
        count--;
        System.out.println(Thread.currentThread().getName() + " count= " + count);
    }

    // 相當於這個方法
    public static void mm() {
        // 實際上是反射
        synchronized (SynchronizedUse04.class) {
            count--;
        }
    }
}
複製程式碼

這麼理解,即使我例項化了不同的SynchronizedUse04物件,在不同的執行緒裡面呼叫靜態方法 m() ,它仍然會保持同步,因為靜態方法是屬於類的,而不是屬於物件的,它對該類的所有物件都會保持同步。

為什麼要使用 synchronized 關鍵字?

之前說了那麼多,我們一直都在強調一個"同步",那麼為什麼在高併發程式中,同步那麼重要呢,我們用一個小的demo來說明:

public class SynchronizedUse05 implements Runnable {

    private int count = 10;

    // 如果不加鎖,那麼容易出現重複的數字,且得不到順序列印的數字。
    // 每個 synchronized 的程式碼塊,都代表一個原子操作,是最小的一部分,不可分。
    public synchronized void run() {
        count--;
        System.out.println(Thread.currentThread().getName() + " count = " + count);
    }

    /*
     * main 方法裡面,實際上是一個物件只啟動了一個方法。
     * 但是在 for 迴圈裡面新建了多個執行緒來訪問一個物件 t 。
     * */
    public static void main(String[] args) {
        SynchronizedUse05 t = new SynchronizedUse05();
        for (int i = 0; i < 8; i++) {
            // 新建的8個執行緒都去訪問 t 裡面的 run() 方法。
            new Thread(t, "THREAD" + i).start();
        }
    }
}
複製程式碼

我們在這個例子裡面啟動了8個不同的執行緒,每個執行緒都會呼叫t裡面的run()方法,而每個執行緒都對變數count進行了自減操作。原始碼為這個類的run()方法加了同步鎖,如果把鎖去掉,我們就很容易在每次之中得到不同的執行結果,或者說出現重複的數字,且每次執行得不到順序列印的數字,如下圖所示,我們可以這樣理解:

不加同步鎖的後果

Thread 1Thread 2 幾乎同時執行程式碼,它們都拿到了值為10的count並對其進行了修改,所以這兩個執行緒就會輸出一樣的count,之後由於執行緒對於資源的搶佔式得到,所以陸陸續續輸出結果的執行緒也不會是按照順序的,這也是為什麼Thread 1執行完畢以後Thread 4接下去執行的原因了,而加了鎖之後可以消除這樣的問題。

不加鎖的執行結果 (每次未必一致):

THREAD5 count = 4
THREAD6 count = 3
THREAD7 count = 2
THREAD3 count = 6
THREAD2 count = 7
THREAD0 count = 9
THREAD1 count = 8
THREAD4 count = 5
複製程式碼

加了鎖的執行結果 (執行結果每次一致):

THREAD0 count = 9
THREAD7 count = 8
THREAD6 count = 7
THREAD5 count = 6
THREAD4 count = 5
THREAD3 count = 4
THREAD2 count = 3
THREAD1 count = 2
複製程式碼

同時,對多執行緒讀寫程式碼加上同步鎖,還可以避免常見的 “髒讀問題” (Dirty Read),所謂髒讀問題,指的是對業務寫方法加鎖,對業務讀方法不加鎖,那麼同一個時間寫入的執行緒只能有一個,但是讀取卻不受限制,這樣,在寫入的同時另外一個執行緒進行讀取,就容易讀取到錯誤的資料,輕則報出空指標異常,重則讀取到匪夷所思的錯誤資料。我們可以通過下面的這個demo來體會一下:

public class SynchronizedUse07 {
    String name;
    double balance;

    private synchronized void set(String name, double balance) {
        this.name = name;

        // 寫入的時候加鎖,要是寫入時間中還在執行一些其他的程式,這時候讀程式在另外一個執行緒中
        // 讀取資訊,寫入工作還沒完成,就容易讀取到錯誤的資訊。
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        this.balance = balance;
    }

    private /* synchronized */ double getBalance(String name) {
        return this.balance;
    }

    public static void main(String[] args) {
        SynchronizedUse07 a = new SynchronizedUse07();

        new Thread(() -> a.set("zhangsan", 100.0)).start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(a.getBalance("zhangsan"));

        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(a.getBalance("zhangsan"));
    }
}
複製程式碼

在上述程式碼中,我們把讀取賬戶餘額的方法 getBalance(String name) 上面的同步鎖 synchronized 註釋掉了,保留了寫入(初始化)方法的同步鎖,這樣執行就會產生髒讀問題。為了讓程式碼問題突出,我們在set()裡面加入了一段延時程式:

     try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
複製程式碼

在業務邏輯中,寫入資料可能是一個耗時操作(這裡我們使用了2秒的延時),而如果讀取操作不上鎖,在寫入操作還未完成的時候就開始讀取,就會讀取到錯誤資料(這裡我們在延時1秒後開始讀取,這時候寫入操作尚未完成),所以第一次我們讀取到的資料是 0.0(未初始化值),而當寫入操作完成以後,我們再次進行讀取操作,這時候才讀取到正確的資料 100.0。

程式輸出結果如下:

0.0 // 1秒的時候開始讀取,寫入未完成
100.0 // 3秒的時候讀取,寫入(耗時約2秒)已完成
複製程式碼

通過上面兩個例子,我們現在對 synchronized 關鍵字的作用和用法有了一個初步的瞭解。

synchronized 關鍵字的特性

synchronized 關鍵字修飾的同步方法能不能和非同步方法同時呼叫?

從之前髒讀問題的demo可以很輕易地得到答案:可以。 為此我們編寫了一個demo,感興趣的朋友可以嘗試執行體會一下:

public class SynchronizedUse06 {
    // m1 是同步方法,請問在執行m1 的過程之中,m2能不能被執行? 回答:當然可以
    private synchronized void m1() {
        System.out.println(Thread.currentThread().getName() + " m1 start...");

        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(Thread.currentThread().getName() + " m1 end.");
    }

    private void m2() {
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(Thread.currentThread().getName() + " m2.");
    }

    public static void main(String[] args) {
        SynchronizedUse06 t = new SynchronizedUse06();

//        new Thread(() -> t.m1(), "t1").start();
//        new Thread(() -> t.m2(), "t1").start();

        new Thread(t::m1, "t1:").start();
        new Thread(t::m2, "t2:").start();
    }
}
複製程式碼

這裡的m1()是一個同步方法,其中我們為它加入了一個長達10秒的延時,m2()是一個非同步方法。我們新建了兩個執行緒,執行緒t1在執行方法m1()的時候,我們啟動執行緒t2執行方法m2(),可以看到,m1()m2()是可以同時執行的:

t1: m1 start...
t2: m2.
t1: m1 end.
複製程式碼

在啟動執行緒這裡我們使用了 Java8 的新特新:Lambda表示式,這樣的作用主要是簡化寫法(語法糖),感興趣的同學可以另外瞭解。

對於同步方法來說,它可以和非同步方法同時呼叫,那麼對於同步方法之間的相互呼叫來說,這是否可行?

我們先來看一段小程式:

/**
 * synchronized 關鍵字的使用08
 * 一個同步方法可以呼叫另外一個同步方法
 * 一個執行緒已經擁有某個物件的鎖,再次申請的時候仍然會得到該物件的鎖。
 * 也就是說 synchronized 獲得的鎖是可以重入的。
 *
 * @author huangyz0918
 */
public class SynchronizedUse08 {
    private synchronized void m1() {
        System.out.println("m1 start...");

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        m2();
    }

    private synchronized void m2() {
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("m2.");
    }

    public static void main(String[] args) {
        SynchronizedUse08 t = new SynchronizedUse08();
        t.m1();
    }
}
複製程式碼

執行結果:

m1 start...
m2.
複製程式碼

在程式碼中,我們呼叫了同步方法m1()m2()也得到了呼叫,說明同步方法直接是可以相互呼叫的。方法m1()m2()需要的是同一把鎖,所以當m2()m1()中被呼叫的時候,m1()在持有鎖的前提下再次申請獲得這把鎖來執行m2(),這是可以的,因為synchronized獲得的鎖是可以重入的。

那麼對於繼承的同步方法來說,子類方法是否也是同步方法?

不是的,synchronized 關鍵字不能被繼承。

雖然可以使用synchronized 來定義方法,但 synchronized 並不屬於方法定義的一部分,因此,synchronized 關鍵字不能被繼承。如果在父類中的某個方法使用了 synchronized 關鍵字,而在子類中覆蓋了這個方法,在子類中的這個方法預設情況下並不是同步的,而必須顯式地在子類的這個方法中加上synchronized關鍵字才可以。當然,還可以在子類方法中呼叫父類中相應的方法,這樣雖然子類中的方法不是同步的,但子類呼叫了父類的同步方法,因此,子類的方法也就相當於同步了。具體可以見下面的 demo:

public class SynchronizedUse09 {

    public static void main(String[] args) {
        T t = new T();
        t.m();
    }

    synchronized void m() {
        System.out.println("m start...");

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("m end.");
    }
}

class T extends SynchronizedUse09 {
    @Override
    synchronized void m() {
        System.out.println("child m start...");
        super.m();
        System.out.println("child m end.");
    }
}
複製程式碼

這裡的m()顯式地加上了關鍵字synchronized,所以它也是一個同步方法。如果不加這個關鍵字,m()就不會得到同步,當然,在呼叫到父類super.m()的時候,仍然需要獲得鎖才能夠執行,否則方法將一直在super.m()上面等待。

那麼對於一個程式,在獲得了鎖開始執行的時候,忽然在同步程式碼塊裡面出現了異常,跳出了同步程式碼,那麼鎖是否會釋放?

程式在執行的過程中,如果出現異常,預設情況下鎖會被釋放。

我們可以試著建立一個 demo 研究一下:

public class SynchronizedUse10 {
    int count = 0;

    synchronized void m() {
        System.out.println(Thread.currentThread().getName() + " start...");
        while (true) {
            count++;
            System.out.println(Thread.currentThread().getName() + " count = " + count);

            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            if (count == 5) {
                int i = 1 / 0; // 此處丟擲異常,鎖將被釋放,若是不想鎖被釋放可以進行 catch 使迴圈繼續。
            }
        }
    }

    public static void main(String[] args) {
        SynchronizedUse10 t = new SynchronizedUse10();
        new Thread(t::m, "t1").start();

        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        new Thread(t::m, "t2").start();
    }
}
複製程式碼

執行結果:

t1 start...
t1 count = 1
t1 count = 2
t1 count = 3
t1 count = 4
t1 count = 5
t2 start...
Exception in thread "t1" java.lang.ArithmeticException: / by zero
t2 count = 6
	at learn.multithreading.synchronizedlearn.SynchronizedUse10.m(SynchronizedUse10.java:32)
	at java.base/java.lang.Thread.run(Thread.java:844)
t2 count = 7
t2 count = 8
t2 count = 9
t2 count = 10
t2 count = 11
t2 count = 12
複製程式碼

這裡我們使用了一個死迴圈,在count == 5地時候,我們手動的丟擲了一個異常,使得執行緒t1從同步方法m()中跳出,並且釋放了鎖,我們可以看到,在t1丟擲異常以後,t2迅速獲得了鎖並且開始執行。說明程式在執行的過程中,如果出現異常,預設情況下鎖會被釋放。 所以,在併發處理的過程中,如果出現了異常一定要多加小心,不然可能會發生不一致的情況。比如在一個 Web App 處理的過程中,多個 servlet 執行緒共同訪問一個資源,這時候如果異常處理不合適, 在第一個執行緒裡面丟擲異常,其他執行緒就會進入同步程式碼區,有可能會訪問到異常時產生的資料。

synchronized 同步鎖使用的優化

雖然現在 Java 已經對 synchronized 鎖進行了很大幅度的優化,不過相對與其他同步機制來說,synchronized 的效率還是比較低的,所以在使用這個關鍵字的時候,我們要注意鎖的粒度,避免不必要的計算資源浪費。

舉個例子:

public class SynchronizedUse11 {
    private int count = 0;

    synchronized void m1() {
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        count++; // 業務邏輯中只有這句話需要同步,這時候不需要給整個方法上鎖。

        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    void m2() {
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 業務邏輯中只有這一段語句需要同步,這時不應該給整個方法上鎖
        // 採用細粒度的鎖,可以使執行緒爭用的時間變短,從而提高效率。
        synchronized (this) {
            count++;
        }

        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
複製程式碼

在這裡,我們只有count++ 不符合原子性,所以容易出現同步問題,沒有必要對整個方法進行上鎖,所以說synchronized關鍵字的使用還是很靈活的,在編碼的時候要時時刻刻考慮到效率的問題。

同時,我們應該理解synchronized鎖定的本質,synchronized其實鎖定的是堆記憶體中的物件,所以當一個鎖定的物件的屬性發生了改變,或者說,鎖定物件的引用指向了堆記憶體中的一個新的物件的時候,鎖也會改變。在實際使用當中,我們應該避免發生這樣的情況:

public class SynchronizedUse12 {

    public static void main(String[] args) {
        SynchronizedUse12 t = new SynchronizedUse12();
        new Thread(t::m, "t1").start();

        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread t2 = new Thread(t::m, "t2");

        t.o = new Object(); // 鎖的物件改變,所以 t2 得以執行,不然 t2 永遠得到不了執行的機會。
        t2.start();
    }

    Object o = new Object();

    void m() {
        synchronized (o) { // 本質上說明了: 鎖的位置是鎖在堆記憶體的物件上,而不是棧記憶體物件的引用裡面。
            while (true) {
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                System.out.println(Thread.currentThread().getName());
            }
        }
    }
}
複製程式碼

輸出結果:

t1
t1
t2 // 從這裡開始,t2執行緒也開始了執行
t1
t2
t1
t2
t1
....
複製程式碼

在這個小程式裡面,我們可以看到我們使用t.o = new Object();將鎖的物件指向了堆記憶體裡面的一個新的物件,這個時候執行緒t2其實已經和t1所需要的不是同一把鎖了,所以執行緒t2也開始執行。不然處於死迴圈方法m()裡面,不等到t1執行完成,t2永遠也得不到執行的機會。

**最後,我們在實際的開發過程中,經常要盡力避免使用字串物件進行鎖定。**為什麼呢?如果你使用了某個類庫,它鎖定了字串物件 A ,這時候你在自己的原始碼裡面又鎖定了字串物件 A ,兩段程式碼不經意之間使用了同一把鎖,這樣就會出現很詭異的死鎖阻塞,而且對於你來說這樣的問題很難被排查出來。並且對於字串來說,兩個相同的字串其實指向的是同一個記憶體地址,所以看似使用的不是同一把鎖,實際上不然:

public class SynchronizedUse13 {

    private String s1 = "Hello";
    private String s2 = "Hello";

    // 兩個字串s1和s2實際上指向了同一個堆記憶體的物件
    // 棧記憶體裡面存放原始變數和物件的引用控制程式碼
    // 堆記憶體裡面存放的是物件的例項

    void m1() {
        synchronized (s1) {
        }
    }

    void m2() {
        synchronized (s2) {
        }
    }
}
複製程式碼

總之,Java 同步鎖 synchronized 的使用是很靈活的,需要在實踐中不斷總結和反覆記憶。

相關閱讀:

本教程純屬原創,轉載請宣告 本文提供的連結若是失效請及時聯絡作者更新

相關文章