1. 程式人生 > >Java 併發:內建鎖 Synchronized

Java 併發:內建鎖 Synchronized

  摘要:在多執行緒程式設計中,執行緒安全問題是一個最為關鍵的問題,其核心概念就在於正確性,即當多個執行緒訪問某一共享、可變資料時,始終都不會導致資料破壞以及其他不該出現的結果。而所有的併發模式在解決這個問題時,採用的方案都是序列化訪問臨界資源 。在 Java 中,提供了兩種方式來實現同步互斥訪問:synchronized 和 Lock。本文針對 synchronized 內建鎖 詳細討論了其在 Java 併發 中的應用,包括它的具體使用場景(同步方法、同步程式碼塊、例項物件鎖 和 Class 物件鎖)、可重入性 和 注意事項。

一. 執行緒安全問題

  在單執行緒中不會出現執行緒安全問題,而在多執行緒程式設計中,有可能會出現同時訪問同一個 共享、可變資源

的情況,這種資源可以是:一個變數、一個物件、一個檔案等。特別注意兩點,

  • 共享: 意味著該資源可以由多個執行緒同時訪問;
  • 可變: 意味著該資源可以在其生命週期內被修改。

     所以,當多個執行緒同時訪問這種資源的時候,就會存在一個問題:

       由於每個執行緒執行的過程是不可控的,所以需要採用同步機制來協同對物件可變狀態的訪問。   

舉個 資料髒讀 的例子:

//資源類
class PublicVar {

    public String username = "A";
    public String password = "AA";

    //同步例項方法
    public
synchronized void setValue(String username, String password) { try { this.username = username; Thread.sleep(5000); this.password = password; System.out.println("method=setValue " +"\t" + "threadName=" + Thread.currentThread().getName() + "\t"
+ "username=" + username + ", password=" + password); } catch (InterruptedException e) { e.printStackTrace(); } } //非同步例項方法 public void getValue() { System.out.println("method=getValue " + "\t" + "threadName=" + Thread.currentThread().getName()+ "\t" + " username=" + username + ", password=" + password); } } //執行緒類 class ThreadA extends Thread { private PublicVar publicVar; public ThreadA(PublicVar publicVar) { super(); this.publicVar = publicVar; } @Override public void run() { super.run(); publicVar.setValue("B", "BB"); } } //測試類 public classTest { public static void main(String[] args) { try { //臨界資源 PublicVar publicVarRef = new PublicVar(); //建立並啟動執行緒 ThreadA thread = new ThreadA(publicVarRef); thread.start(); Thread.sleep(200);// 列印結果受此值大小影響 //在主執行緒中呼叫 publicVarRef.getValue(); } catch (InterruptedException e) { e.printStackTrace(); } } }/* Output ( 資料交叉 ): method=getValue threadName=main username=B, password=AA method=setValue threadName=Thread-0 username=B, password=BB *///:~
  • 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
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73

  由程式輸出可知,雖然在寫操作進行了同步,但在讀操作上仍然有可能出現一些意想不到的情況,例如上面所示的 髒讀。發生 髒讀 的情況是在執行讀操作時,相應的資料已被其他執行緒 部分修改 過,導致 資料交叉 的現象產生。

  這其實就是一個執行緒安全問題,即多個執行緒同時訪問一個資源時,會導致程式執行結果並不是想看到的結果。這裡面,這個資源被稱為:臨界資源。也就是說,當多個執行緒同時訪問臨界資源(一個物件,物件中的屬性,一個檔案,一個數據庫等)時,就可能會產生執行緒安全問題。

  不過,當多個執行緒執行一個方法時,該方法內部的區域性變數並不是臨界資源,因為這些區域性變數是在每個執行緒的私有棧中,因此不具有共享性,不會導致執行緒安全問題。

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

  實際上,所有的併發模式在解決執行緒安全問題時,採用的方案都是 序列化訪問臨界資源 。即在同一時刻,只能有一個執行緒訪問臨界資源,也稱作 同步互斥訪問。換句話說,就是在訪問臨界資源的程式碼前面加上一個鎖,當訪問完臨界資源後釋放鎖,讓其他執行緒繼續訪問。

  在 Java 中,提供了兩種方式來實現同步互斥訪問:synchronized 和 Lock。本文主要講述 synchronized 的使用方法,Lock 的使用方法我的另一篇博文《Java 併發:Lock 框架詳解》中闡述。

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

  在瞭解 synchronized 關鍵字的使用方法之前,我們先來看一個概念:互斥鎖,即 能到達到互斥訪問目的的鎖。舉個簡單的例子,如果對臨界資源加上互斥鎖,當一個執行緒在訪問該臨界資源時,其他執行緒便只能等待。

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

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

1) synchronized方法

public classTest {

    public static void main(String[] args)  {
        final InsertData insertData = new InsertData();
        // 啟動執行緒 1  
        new Thread() {
            public void run() {
                insertData.insert(Thread.currentThread());
            };
        }.start();

        // 啟動執行緒 2
        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);
        }
    }
}/* Output: 
        Thread-0在插入資料0
        Thread-1在插入資料0
        Thread-0在插入資料1
        Thread-0在插入資料2
        Thread-1在插入資料1
        Thread-1在插入資料2
 *///:~
  • 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
  • 36
  • 37
  • 38
  • 39
  • 40

  根據執行結果就可以看出,這兩個執行緒在同時執行insert()方法。而如果在insert()方法前面加上關鍵字synchronized 的話,執行結果為:

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);
        }
    }
}/* Output: 
        Thread-0在插入資料0
        Thread-0在插入資料1
        Thread-0在插入資料2
        Thread-1在插入資料0
        Thread-1在插入資料1
        Thread-1在插入資料2
 *///:~
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

  從以上輸出結果可以看出,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 程式碼塊類似於以下這種形式:

synchronized (lock){
    //訪問共享可變資源
    ...
}
  • 1
  • 2
  • 3
  • 4

  當在某個執行緒中執行這段程式碼塊,該執行緒會獲取物件lock的鎖,從而使得其他執行緒無法同時訪問該程式碼塊。其中,lock 可以是 this,代表獲取當前物件的鎖,也可以是類中的一個屬性,代表獲取該屬性的鎖。特別地, 例項同步方法synchronized(this)同步塊 是互斥的,因為它們鎖的是同一個物件。但與 synchronized(非this)同步塊 是非同步的,因為它們鎖的是不同物件。

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

// this 監視器
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);
            }
        }
    }
}

// 物件監視器
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);
            }
        }
    }
}
  • 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

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

3) class 物件鎖

  特別地,每個類也會有一個鎖,靜態的 synchronized方法 就是以Class物件作為鎖。另外,它可以用來控制對 static 資料成員 (static 資料成員不專屬於任何一個物件,是類成員) 的併發訪問。並且,如果一個執行緒執行一個物件的非static synchronized 方法,另外一個執行緒需要執行這個物件所屬類的 static synchronized 方法,也不會發生互斥現象。因為訪問 static synchronized 方法佔用的是類鎖,而訪問非 static synchronized 方法佔用的是物件鎖,所以不存在互斥現象。例如,

public classTest {

    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 { 

    // 非 static synchronized 方法
    public synchronized void insert(){
        System.out.println("執行insert");
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("執行insert完畢");
    }

    // static synchronized 方法
    public synchronized static void insert1() {
        System.out.println("執行insert1");
        System.out.println("執行insert1完畢");
    }
}/* Output: 
        執行insert
        執行insert1
        執行insert1完畢
        執行insert完畢
 *///:~
  • 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
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43

  根據執行結果,我們可以看到第一個執行緒裡面執行的是insert方法,不會導致第二個執行緒執行insert1方法發生阻塞現象。下面,我們看一下 synchronized 關鍵字到底做了什麼事情,我們來反編譯它的位元組碼看一下,下面這段程式碼反編譯後的位元組碼為:

public classInsertData {
    private Object object = new Object();

    public void insert(Thread thread){
        synchronized (object) {}
    }

    public synchronized void insert1(Thread thread){}

    public void insert2(Thread thread){}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

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

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

四. 可重入性

  一般地,當某個執行緒請求一個由其他執行緒持有的鎖時,發出請求的執行緒就會阻塞。然而,由於 Java 的內建鎖是可重入的,因此如果某個執行緒試圖獲得一個已經由它自己持有的鎖時,那麼這個請求就會成功。可重入鎖最大的作用是避免死鎖。例如:

public classTestimplementsRunnable {

    // 可重入鎖測試
    public synchronized void get() {
        System.out.println(Thread.currentThread().getName());
        set();
    }

    public synchronized void set() {
        System.out.println(Thread.currentThread().getName());
    }

    @Override
    public void run() {
        get();
    }

    public static void main(String[] args) {
        Test test = new Test();
        new Thread(test,"Thread-0").start();
        new Thread(test,"Thread-1").start();
        new Thread(test,"Thread-2").start();
    }
}/* Output: 
        Thread-1
        Thread-1
        Thread-2
        Thread-2
        Thread-0
        Thread-0
 *///:~
  • 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

五. 注意事項

1). 內建鎖與字串常量

  由於字串常量池的原因,在大多數情況下,同步synchronized程式碼塊 都不使用 String 作為鎖物件,而改用其他,比如 new Object() 例項化一個 Object 物件,因為它並不會被放入快取中。看下面的例子:

//資源類
class Service {
    public void print(String stringParam) {
        try {
            synchronized (stringParam) {
                while (true) {
                    System.out.println(Thread.currentThread().getName());
                    Thread.sleep(1000);
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

//執行緒A
class ThreadA extends Thread {
    private Service service;

    public ThreadA(Service service) {
        super();
        this.service = service;
    }

    @Override
    public void run() {
        service.print("AA");
    }
}

//執行緒B
class ThreadB extends Thread {
    private Service service;

    public ThreadB(Service service) {
        super();
        this.service = service;
    }

    @Override
    public void run() {
        service.print("AA");
    }
}

//測試
public classRun {
    public static void main(String[] args) {

        //臨界資源
        Service service = new Service();

        //建立並啟動執行緒A
        ThreadA a = new ThreadA(service);
        a.setName("A");
        a.start();

        //建立並啟動執行緒B
        ThreadB b = new ThreadB(service);
        b.setName("B");
        b.start();

    }
}/* Output (死鎖): 
        A
        A
        A
        A
        ...
 *///:~
  • 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
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71

  出現上述結果就是因為 String 型別的引數都是 “AA”,兩個執行緒持有相同的鎖,所以 執行緒B 始終得不到執行,造成死鎖。進一步地,所謂死鎖是指:     不同的執行緒都在等待根本不可能被釋放的鎖,從而導致所有的任務都無法繼續完成。

b). 鎖的是物件而非引用

  在將任何資料型別作為同步鎖時,需要注意的是,是否有多個執行緒將同時去競爭該鎖物件:   1).若它們將同時競爭同一把鎖,則這些執行緒之間就是同步的;   2).否則,這些執行緒之間就是非同步的。

看下面的例子:

//資源類
class MyService {
    private String lock = "123";

    public void testMethod() {
        try {
            synchronized (lock) {
                System.out.println(Thread.currentThread().getName() + " begin "
                        + System.currentTimeMillis());
                lock = "456";
                Thread.sleep(2000);
                System.out.println(Thread.currentThread().getName() + "   end "
                        + System.currentTimeMillis());
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

//執行緒B
class ThreadB extends Thread {

    private MyService service;

    public ThreadB(MyService service) {
        super();
        this.service = service;
    }

    @Override
    public void run() {
        service.testMethod();
    }
}

//執行緒A
class ThreadA extends Thread {

    private MyService service;

    public ThreadA(MyService service) {
        super();
        this.service = service;
    }

    @Override
    public void run() {
        service.testMethod();
    }
}

//測試
public classRun1 {
    public static void main(String[] args) throws InterruptedException {

        //臨界資源
        MyService service = new MyService();

        //執行緒A
        ThreadA a = new ThreadA(service);
        a.setName("A");

        //執行緒B
        ThreadB b = new ThreadB(service);
        b.setName("B");

        a.start();
        Thread.sleep(50);// 存在50毫秒
        b.start();
    }
}/* Output(迴圈): 
       A begin 1484319778766
       B begin 1484319778815
       A   end 1484319780766
       B   end 1484319780815
 *///:~
  • 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
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77

  由上述結果可知,執行緒 A、B 是非同步的。因為50毫秒過後, 執行緒B 取得的鎖物件是 “456”,而 執行緒A 依然持有的鎖物件是 “123”。所以,這兩個執行緒是非同步的。若將上述語句 “Thread.sleep(50);” 註釋,則有:

//測試
public classRun1 {
    public static void main(String[] args) throws InterruptedException {

        //臨界資源
        MyService service = new MyService();

        //執行緒A
        ThreadA a = new ThreadA(service);
        a.setName("A");

        //執行緒B
        ThreadB b = new ThreadB(service);
        b.setName("B");

        a.start();
        // Thread.sleep(50);// 存在50毫秒
        b.start();
    }
}/* Output(迴圈): 
       B begin 1484319952017
       B   end 1484319954018
       A begin 1484319954018
       A   end 1484319956019
 *///:~
  • 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

  由上述結果可知,執行緒 A、B 是同步的。因為執行緒 A、B 競爭的是同一個鎖“123”,雖然先獲得執行的執行緒將 lock 指向了 物件“456”,但結果還是同步的。因為執行緒 A 和 B 共同爭搶的鎖物件是“123”,也就是說,鎖的是物件而非引用。

六. 總結

  用一句話來說,synchronized 內建鎖 是一種 物件鎖 (鎖的是物件而非引用), 作用粒度是物件 ,可以用來實現對 臨界資源的同步互斥訪問 ,是 可重入 的。特別地,對於 臨界資源 有:

  • 若該資源是靜態的,即被 static 關鍵字修飾,那麼訪問它的方法必須是同步且是靜態的,synchronized 塊必須是 class鎖;

  • 若該資源是非靜態的,即沒有被 static 關鍵字修飾,那麼訪問它的方法必須是同步的,synchronized 塊是例項物件鎖;   

實質上,關鍵字synchronized 主要包含兩個特徵:

  • 互斥性:保證在同一時刻,只有一個執行緒可以執行某一個方法或某一個程式碼塊;

  • 可見性:保證執行緒工作記憶體中的變數與公共記憶體中的變數同步,使多執行緒讀取共享變數時可以獲得最新值的使用。

引用