Java併發程式設計:synchronized

加瓦一枚發表於2018-12-21

雖然多執行緒程式設計極大地提高了效率,但是也會帶來一定的隱患。比如說兩個執行緒同時往一個資料庫表中插入不重複的資料,就可能會導致資料庫中插入了相同的資料。今天我們就來一起討論下執行緒安全問題,以及Java中提供了什麼機制來解決執行緒安全問題。

  以下是本文的目錄大綱:

  一.什麼時候會出現執行緒安全問題?

  二.如何解決執行緒安全問題?

  三.synchronized同步方法或者同步塊

  若有不正之處,請多多諒解並歡迎批評指正。

  請尊重作者勞動成果,轉載請標明原文連結:

  http://www.cnblogs.com/dolphin0520/p/3923737.html

一.什麼時候會出現執行緒安全問題?

  在單執行緒中不會出現執行緒安全問題,而在多執行緒程式設計中,有可能會出現同時訪問同一個資源的情況,這種資源可以是各種型別的的資源:一個變數、一個物件、一個檔案、一個資料庫表等,而當多個執行緒同時訪問同一個資源的時候,就會存在一個問題:

  由於每個執行緒執行的過程是不可控的,所以很可能導致最終的結果與實際上的願望相違背或者直接導致程式出錯。

  舉個簡單的例子:

  現在有兩個執行緒分別從網路上讀取資料,然後插入一張資料庫表中,要求不能插入重複的資料。

  那麼必然在插入資料的過程中存在兩個操作:

  1)檢查資料庫中是否存在該條資料;

  2)如果存在,則不插入;如果不存在,則插入到資料庫中。

  假如兩個執行緒分別用thread-1和thread-2表示,某一時刻,thread-1和thread-2都讀取到了資料X,那麼可能會發生這種情況:

  thread-1去檢查資料庫中是否存在資料X,然後thread-2也接著去檢查資料庫中是否存在資料X。

  結果兩個執行緒檢查的結果都是資料庫中不存在資料X,那麼兩個執行緒都分別將資料X插入資料庫表當中。

  這個就是執行緒安全問題,即多個執行緒同時訪問一個資源時,會導致程式執行結果並不是想看到的結果。

  這裡面,這個資源被稱為:臨界資源(也有稱為共享資源)。

  也就是說,當多個執行緒同時訪問臨界資源(一個物件,物件中的屬性,一個檔案,一個資料庫等)時,就可能會產生執行緒安全問題。

  不過,當多個執行緒執行一個方法,方法內部的區域性變數並不是臨界資源,因為方法是在棧上執行的,而Java棧是執行緒私有的,因此不會產生執行緒安全問題。

二.如何解決執行緒安全問題?

  那麼一般來說,是如何解決執行緒安全問題的呢?

  基本上所有的併發模式在解決執行緒安全問題時,都採用“序列化訪問臨界資源”的方案,即在同一時刻,只能有一個執行緒訪問臨界資源,也稱作同步互斥訪問。

  通常來說,是在訪問臨界資源的程式碼前面加上一個鎖,當訪問完臨界資源後釋放鎖,讓其他執行緒繼續訪問。

  在Java中,提供了兩種方式來實現同步互斥訪問:synchronized和Lock。

  本文主要講述synchronized的使用方法,Lock的使用方法在下一篇博文中講述。

三.synchronized同步方法或者同步塊

  在瞭解synchronized關鍵字的使用方法之前,我們先來看一個概念:互斥鎖,顧名思義:能到達到互斥訪問目的的鎖。

  舉個簡單的例子:如果對臨界資源加上互斥鎖,當一個執行緒在訪問該臨界資源時,其他執行緒便只能等待。

  在Java中,每一個物件都擁有一個鎖標記(monitor),也稱為監視器,多執行緒同時訪問某個物件時,執行緒只有獲取了該物件的鎖才能訪問。

  在Java中,可以使用synchronized關鍵字來標記一個方法或者程式碼塊,當某個執行緒呼叫該物件的synchronized方法或者訪問synchronized程式碼塊時,這個執行緒便獲得了該物件的鎖,其他執行緒暫時無法訪問這個方法,只有等待這個方法執行完畢或者程式碼塊執行完畢,這個執行緒才會釋放該物件的鎖,其他執行緒才能執行這個方法或者程式碼塊。

  下面通過幾個簡單的例子來說明synchronized關鍵字的使用:

  1.synchronized方法

  下面這段程式碼中兩個執行緒分別呼叫insertData物件插入資料:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class Test {
 
    public static void main(String[] args)  {
        final InsertData insertData = new InsertData();
         
        new Thread() {
            public void run() {
                insertData.insert(Thread.currentThread());
            };
        }.start();
         
         
        new Thread() {
            public void run() {
                insertData.insert(Thread.currentThread());
            };
        }.start();
    }  
}
 
class InsertData {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
     
    public void insert(Thread thread){
        for(int i=0;i<5;i++){
            System.out.println(thread.getName()+"在插入資料"+i);
            arrayList.add(i);
        }
    }
}

  此時程式的輸出結果為:

  

  說明兩個執行緒在同時執行insert方法。

  而如果在insert方法前面加上關鍵字synchronized的話,執行結果為:

1
2
3
4
5
6
7
8
9
10
class InsertData {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
     
    public synchronized void insert(Thread thread){
        for(int i=0;i<5;i++){
            System.out.println(thread.getName()+"在插入資料"+i);
            arrayList.add(i);
        }
    }
}

  

  從上輸出結果說明,Thread-1插入資料是等Thread-0插入完資料之後才進行的。說明Thread-0和Thread-1是順序執行insert方法的。

  這就是synchronized方法。

  不過有幾點需要注意:

  1)當一個執行緒正在訪問一個物件的synchronized方法,那麼其他執行緒不能訪問該物件的其他synchronized方法。這個原因很簡單,因為一個物件只有一把鎖,當一個執行緒獲取了該物件的鎖之後,其他執行緒無法獲取該物件的鎖,所以無法訪問該物件的其他synchronized方法。

  2)當一個執行緒正在訪問一個物件的synchronized方法,那麼其他執行緒能訪問該物件的非synchronized方法。這個原因很簡單,訪問非synchronized方法不需要獲得該物件的鎖,假如一個方法沒用synchronized關鍵字修飾,說明它不會使用到臨界資源,那麼其他執行緒是可以訪問這個方法的,

  3)如果一個執行緒A需要訪問物件object1的synchronized方法fun1,另外一個執行緒B需要訪問物件object2的synchronized方法fun1,即使object1和object2是同一型別),也不會產生執行緒安全問題,因為他們訪問的是不同的物件,所以不存在互斥問題。

  2.synchronized程式碼塊

  synchronized程式碼塊類似於以下這種形式:

1
2
3
synchronized(synObject) {
         
    }

  當在某個執行緒中執行這段程式碼塊,該執行緒會獲取物件synObject的鎖,從而使得其他執行緒無法同時訪問該程式碼塊。

  synObject可以是this,代表獲取當前物件的鎖,也可以是類中的一個屬性,代表獲取該屬性的鎖。

  比如上面的insert方法可以改成以下兩種形式:

1
2
3
4
5
6
7
8
9
10
11
12
class InsertData {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
     
    public void insert(Thread thread){
        synchronized (this) {
            for(int i=0;i<100;i++){
                System.out.println(thread.getName()+"在插入資料"+i);
                arrayList.add(i);
            }
        }
    }
}

 

1
2
3
4
5
6
7
8
9
10
11
12
13
class InsertData {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
    private Object object = new Object();
     
    public void insert(Thread thread){
        synchronized (object) {
            for(int i=0;i<100;i++){
                System.out.println(thread.getName()+"在插入資料"+i);
                arrayList.add(i);
            }
        }
    }
}

  從上面可以看出,synchronized程式碼塊使用起來比synchronized方法要靈活得多。因為也許一個方法中只有一部分程式碼只需要同步,如果此時對整個方法用synchronized進行同步,會影響程式執行效率。而使用synchronized程式碼塊就可以避免這個問題,synchronized程式碼塊可以實現只對需要同步的地方進行同步。

  另外,每個類也會有一個鎖,它可以用來控制對static資料成員的併發訪問。

  並且如果一個執行緒執行一個物件的非static synchronized方法,另外一個執行緒需要執行這個物件所屬類的static synchronized方法,此時不會發生互斥現象,因為訪問static synchronized方法佔用的是類鎖,而訪問非static synchronized方法佔用的是物件鎖,所以不存在互斥現象。

看下面這段程式碼就明白了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class Test {
 
    public static void main(String[] args)  {
        final InsertData insertData = new InsertData();
        new Thread(){
            @Override
            public void run() {
                insertData.insert();
            }
        }.start(); 
        new Thread(){
            @Override
            public void run() {
                insertData.insert1();
            }
        }.start();
    }  
}
 
class InsertData { 
    public synchronized void insert(){
        System.out.println("執行insert");
        try {
            Thread.sleep(5000);
        catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("執行insert完畢");
    }
     
    public synchronized static void insert1() {
        System.out.println("執行insert1");
        System.out.println("執行insert1完畢");
    }
}

  執行結果;

  

  第一個執行緒裡面執行的是insert方法,不會導致第二個執行緒執行insert1方法發生阻塞現象。

  下面我們看一下synchronized關鍵字到底做了什麼事情,我們來反編譯它的位元組碼看一下,下面這段程式碼反編譯後的位元組碼為:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class InsertData {
    private Object object = new Object();
     
    public void insert(Thread thread){
        synchronized (object) {
         
        }
    }
     
    public synchronized void insert1(Thread thread){
         
    }
     
    public void insert2(Thread thread){
         
    }
}

  

  從反編譯獲得的位元組碼可以看出,synchronized程式碼塊實際上多了monitorenter和monitorexit兩條指令。monitorenter指令執行時會讓物件的鎖計數加1,而monitorexit指令執行時會讓物件的鎖計數減1,其實這個與作業系統裡面的PV操作很像,作業系統裡面的PV操作就是用來控制多個執行緒對臨界資源的訪問。對於synchronized方法,執行中的執行緒識別該方法的 method_info 結構是否有 ACC_SYNCHRONIZED 標記設定,然後它自動獲取物件的鎖,呼叫方法,最後釋放鎖。如果有異常發生,執行緒自動釋放鎖。

  

  有一點要注意:對於synchronized方法或者synchronized程式碼塊,當出現異常時,JVM會自動釋放當前執行緒佔用的鎖,因此不會由於異常導致出現死鎖現象。

 

  參考資料:

  《Java程式設計思想》

  http://ifeve.com/synchronized-blocks/

  http://ifeve.com/java-synchronized/

  http://blog.csdn.net/ns_code/article/details/17199201

相關文章