Java併發之synchronized關鍵字

     上篇文章我們主要介紹了併發的基本思想以及線程的基本知識,通過多線程我們可以實現對計算機資源的充分利用,但是在最後我們也說明了多線程給程序帶來的兩種典型的問題,針對它們,synchronized關鍵字可以很好的解決問題。對於synchronized的介紹主要包含以下一些內容:

  • synchronized修飾實例方法
  • synchronized修飾靜態方法
  • synchronized修飾代碼塊
  • 使用synchronized解決競態條件問題
  • 使用synchronized解決內存可見性問題

一、使用synchronized關鍵字修飾實例方法
     在我們的Java中,每個對象都有一把鎖和兩個隊列,一個用於掛起未獲得鎖的線程,一個用於掛起條件不滿足而不得不等待的線程。而我們的synchronized實際上也就是一個加鎖和釋放鎖的集成。先看個例子:

/*定義一個計數器類*/
public class Counter {
    private int count;

    public synchronized int getCount(){return this.count;}

    public synchronized void addCount(){this.count++;}
}
/*定義一個線程類*/
public class MyThread extends Thread{

    public static Counter counter = new Counter();

    @Override
    public void run(){
        try {
            Thread.sleep((int)(Math.random()*100));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        counter.addCount();
    }
}
/*main方法啓動100個線程*/
public static void main(String[] args){
        Thread[] threads = new Thread[100];
        for (int i=0;i<100;i++){
            threads[i] = new MyThread();
            threads[i].start();
        }

        for (int j=0;j<100;j++){
            threads[j].join();
        }

        System.out.println(MyThread.counter.getCount());
    }

上述程序無論運行多少次,結果都是一樣的。

這裏寫圖片描述

這是一個典型的使用synchronized關鍵字修飾實例方法來解決競態條件問題的示例。首先在我們定義的線程類中,我們定義了一個Counter實例,然後讓以後的每個線程在運行的時候都先隨機睡眠,然後調用這個公共變量count的自增方法,只不過該自增方法是有synchronized關鍵字修飾的。我們說過每個對象都有鎖和兩個隊列,這裏的count實例就是一個對象,這一百個線程每次在睡醒之後都要調用count的addCount方法,而所有要調用addCount方法的線程都必須先獲得count這個對象的鎖,也就是說,如果有一個線程獲取了count對象的鎖並開始調用addCount方法時,其他線程都得阻塞在該對象的一個隊列上,等待獲得鎖的線程執行結束釋放鎖。

所以,在同一時刻,只可能有一個線程獲得count的鎖並對其進行自增操作,其他的線程都在該對象的阻塞隊列上進行等待,自然是不會出現多個線程在某個時間段同時操作同一個變量而引起該變量數據值不正確的情況。

二、使用synchronized關鍵字修飾靜態方法
     對於靜態方法,其實和實例方法是類似的。只不過synchronized關鍵字對實例方法而言,它獲得的是實例對象的鎖,所有共享相同該對象的線程都必須先獲得該對象的鎖。而對於靜態方法而言,synchronized關鍵字獲得的是類的鎖,也就是對於所有需要訪問相同類的線程都是需要先獲得該類的鎖的,否則將需要在某個阻塞隊列上進行等待。

/*定義一個線程類*/
public class MyThread extends Thread{

    public static int count;

    public synchronized static void addCount(){
        count++;
    }
    @Override
    public void run(){
        try {
            Thread.sleep((int)(Math.random()*100));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        addCount();
    }
}
/*啓動100個線程*/
public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[100];
        for (int i=0;i<100;i++){
            threads[i] = new MyThread();
            threads[i].start();
        }

        for (int j=0;j<100;j++){
            threads[j].join();
        }

        System.out.println(MyThread.count);
    }

程序基本和我們的第一個例子相差無幾,在線程類中我們定義了一個靜態變量和一個靜態方法,該方法被synchronized關鍵字修飾,然後run方法依然是讓當前線程隨機睡眠,然後調用這個被synchronized關鍵字修飾的靜態方法。我們可以看到,無論運行多少次的程序,結果都是一樣。

這裏寫圖片描述

每個線程在睡醒之後,都要去調用addCount方法,而調用該方法前提是要獲取到類Count的鎖,如果獲取不到就必須在該對象的阻塞隊列上進行等待。所以一次只會有一個線程調用addCount方法,自然是無論運行多少次,結果都會是100。

三、使用synchronized關鍵字修飾代碼塊
     使用synchronized關鍵字修飾一段代碼塊和上述介紹的兩種情況略微有點不同。對於實例方法,synchronized關鍵字總是嘗試去獲取某個對象的鎖,對於靜態方法,synchronized關鍵字始終嘗試去獲取某個類的鎖,而對於我們的代碼塊,它就需要顯式指定以誰爲鎖了。例如:

/*定義一個線程類*/
public class MyThread extends Thread{

    public static Integer count = 0;

    @Override
    public void run(){
        try {
            Thread.sleep((int)(Math.random()*100));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (count){
            count++;
        }
    }
}

在我們定義的線程類中,我們定義了一個靜態變量count,而每個線程在醒來之後都會去嘗試着去獲取該對象的鎖,如果得不到就阻塞在該對象的阻塞隊列上等待鎖的釋放。實際上這裏的synchronized關鍵字利用的就是對象count的鎖,我們上述介紹的兩種形式,synchronized關鍵字修飾在實例方法和靜態方法上,默認利用的是類對象的鎖和類的鎖。例如:

public synchronized void show(){....} 

調用show方法等價於:

synchronized(this){
    public void show(){...}
}

而對於靜態方法:

public class A{
    public synchronized static void show(){....}
}

等價於:

synchronized(A.class){
    public static void show(){....}
}

四、使用synchronized關鍵字解決內存可見性問題
     通過了解了synchronized應用的三種不同場景,我們對它應該有了大致的一個瞭解。下面我們使用它解決上篇提到的多線程的一個問題 —– 內存可見性問題。至於競態條件問題已經在第一小節間接的進行介紹了,此處不再贅述。這裏我們再簡單重複下內存可見性問題,因爲我們的CPU是有緩存的,所以當一個線程在運行的時候,有些變量值的修改並沒有立馬寫回內存,而是緩存在各級緩存中,這就導致其他線程訪問這個公共變量的時候就拿不到最新的值,因此導致數據的值偏差,計算結果不準確。我們看看一個例子:

/*定義一個線程類,並定義一個共享的變量count*/
public class MyThread extends Thread{

    public static int count = 0;

    @Override
    public void run(){
        while (count==0){
            //running
        }
        System.out.println("mythread exit");
    }
}
/*main函數啓動一個線程*/
public static void main(String[] args) throws InterruptedException {
        Thread thread = new MyThread();
        thread.start();

        Thread.sleep(1000);

        MyThread.count = 1;
        System.out.println(MyThread.count);
        System.out.println("exit main");

    }

我們在定義的線程類中定義了一個共享變量,run方法主要的工作是循環等待count不爲0,而我們在main線程中修改了這個count的值,由於循環這個操作是比較頻繁的判斷條件的,所以該線程並不會每次都從內存中取出count的值,而是在它的緩存中取,所以主線程對count的修改,在thread線程中是始終看不見的。所以我們的程序輸出的結果如下:

這裏寫圖片描述

主線程在修改count的值之後,輸出顯示的確count的值爲1,然後主線程退出,但是我們發現程序卻沒有結束,thread的退出信息也沒有被打印。也就是說線程thread還被困在了while循環中,雖然main線程已經修改了count的值。這就是內存可見性問題,主要是由於多線程之間進行通訊的橋樑是內存,而各個線程內部又有各自的緩存,如果對公共變量的的修改沒有及時更新到內存的話,那麼就很容易導致其他線程訪問的是數據不是最新的。

我們使用synchronized關鍵字解決上述問題:

public class MyThread extends Thread{

    public static int count = 0;

    public synchronized static int returnCount(){return count;}

    @Override
    public void run(){
        while(returnCount()==0){

        }
        System.out.println("mythread exit");
    }
}

我們使用synchronized關鍵修飾了一個方法,該方法返回count的值。jvm對synchronized的兩條規定,其一是線程在解鎖之前必須把所有共享變量刷新到內存中,其二是線程在釋放鎖的時候將清空所有的緩存迫使本線程在使用該共享變量的時候從內存中去讀取。這樣就可以保證每次對共享變量的讀取都是最新的。

當然如果僅僅是爲了解決內存可見性問題而使用synchronized關鍵字的話,會有點大材小用。畢竟synchronized的成本開銷相對而言是較大的。Java中提供了一個volatile關鍵字用於解決這種內存可見性問題。例如:

public static volatile int count = 0;

像這樣,我們只需要在某個變量前面加上修飾符 volatile 即可讓該變量在被讀的時候從內存去取,也就是保持最新數據值以實現對內存可見性問題的解決。

至此,我們簡單的介紹了synchronized關鍵字的一些基本用法,介紹了它可以修飾的場景,以及使用它來解決我們的兩個典型的多線程問題。下篇文章我們將着重介紹線程間的協作機制。

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