Java多線程(2)——併發訪問控制

這章主要介紹一下synchronized關鍵字相關的用法,順帶也介紹一下volatile關鍵字。這兩個關鍵字在 java 的併發訪問控制中都很重要。

1、synchronized使用範圍及加鎖規則

synchronized這個關鍵字可以有很多用法,每種用法所加的鎖都有不同的鎖範圍,下面一一介紹。

a、加在實例方法上作爲關鍵字 
b、加在靜態方法上作爲關鍵字 
c、同步語句塊,這塊分兩種,一種是使用對象,一種是使用class

1.1、synchronized加在實例方法上

舉個簡單的例子吧。

public class A {
    public synchronized void xxx(){
        // do something
    }
}

這樣就是把關鍵字加在實例方法上,爲什麼要特別強調實例呢,因爲我們對應的還有靜態方法。實例方法需要new出對象來調用,而靜態方法可以直接類名調用(這塊就當廢話)。

由於調用這個xxx方法需要實例化出來一個對象,所以,多個線程調用這同一個對象的xxx方法,他們的調用就會是同步的了。下面看段代碼實例。

public class AThread extends Thread {
    private A a;
    public AThread(A a) {
        this.a = a;
    }
    @Override
    public void run() {
        a.xxx();
    }
}
public static void main(String[] args) {
    A a = new A();
    AThread t1 = new AThread(a);
    AThread t2 = new AThread(a);
    t1.start();
    t2.start();
}

正常情況,這兩個線程應該是併發異步執行的(即t2的run的內容不需要等t1結束在運行),但是由於線程調用了A的xxx方法,這個方法被synchronized關鍵字修飾了,這時候這個xxx方法變成了同步方法,所以t2的run在調用a的xxx的時候,會被阻塞,知道t1裏面的內容執行完。

另外提兩句:如果t1和t2兩個線程鎖傳入的對象,是兩個不同的對象的話(例如new出兩個A,a1、a2)則不會產生這個阻塞;如果synchronized這個關鍵字同時修飾了A類的兩個實例方法xxx與yyy,t1裏面調用的還是xxx,而t2裏面調用的是yyy,那麼仍然是同步的,和當前的運行結果沒有什麼差異。 
所以結論就是:synchronized關鍵字修飾在實例方法上,會對實例出來的對象加鎖。

以上的內容大家可以自己嘗試一下加深印象。

1.2、synchronized關鍵字加在靜態方法上

這裏只舉例一下修飾的例子,就不詳細介紹調用的示例了。

public class A {
    public synchronized static void xxx(){
        // do something
    }
}

這塊其實很簡單,就是把synchronized加在裏一個靜態方法上面。這種情況下,就是對這個A類的所有靜態方法加鎖了。當然同時也鎖了下面要講的一個synchronized同步語句塊的一個情況的鎖。

這裏還要額外補充一點,實例方法的鎖,會鎖同一實例的所有加了synchronized關鍵字的實例方法;靜態方法的鎖,會鎖同一類(class)所有加了synchronized關鍵字的靜態方法。

對於1.1和1.2兩部分,下面在舉個例子,某個類X有這四個方法。

synchronized void a();
synchronized void b();
synchronized static void sa();
synchronized static void sb();

現在有X類的兩個實例x、y,對於下面的四種情況,我們分別說一下結果。

1)x.a與x.b,這種情況就是我們1.1裏面說過的,由於是同一個對象,所以是同步訪問。 
2)x.a與y.a,由於實例方法的鎖是針對對象的,所以這裏兩個線程的訪問會是異步非阻塞的。 
3)x.sa與y.sb(其實應該這麼寫X.sa與X.sb),這裏由於是修飾的靜態方法,所以這個鎖是針對class的,所以他們會阻塞,是同步的。 
4)x.a與X.sa,這裏大家可以去實驗一下,會是異步的。我們可以這麼理解,對象鎖和class的類鎖是互不相干的,他們只管自己的事。

1.3、synchronized同步語句塊

接下來我們來介紹同步語句塊,爲什麼可以修飾在方法的關鍵字上之後,還要同步語句塊呢?首先synchronized修飾在方法上其實易用性很強,我們不用管太多東西,只要方法結束或者方法中間拋出異常,這個同步鎖就會解開結束。缺點是什麼呢,不靈活、效率低。由於這個關鍵字加在了方法上,所以鎖的是整個方法。加入一個方法a需要運行2s,那麼同時過來3個線程,就是6s。

假如有個投票的方法,這個方法會加票、寫庫、然後各種記錄操作、扣錢(假設投票需要虛擬幣)、通知 前端 、發消息什麼的。一堆操作肯定很耗時,但是爲了保持我們的投票數據準確不能出現髒讀的情況,所以我們還必須加鎖。假設這個投票方法要運行2s,那麼在投票的快要結束的時間,同時1000個人來投票就要2000s時間來處理啊,半個多小時。

實際上我們真正需要加鎖的地方在哪,並不是上面提到的所有的情況都要鎖起來,我們只需要在增加投票數那一塊鎖起來,後面的一些無關的操作並不一定需要是同步的。所以synchronized在方法上修飾就沒那麼靈活了。

同步語句塊要解決的就是這麼個情況,可以在方法的中間需要加鎖的地方加鎖,只鎖那一塊。下面舉個例子。

public class A {
    public void xxx(){
        synchronized (this) {
            // 加票
        }
        // 記錄明細、扣錢...
    }
}

上面的代碼就只對加票這塊做了同步處理,可能加票這部操作只需要1ms,就要快了很多。

下面在說一下同步語句塊的兩種情況,一種是以對象爲鎖,一種是加類鎖。上面的例子實際上是對象鎖,this也是個對象嘛。當然這裏也可以寫成synchronized(A.class),這樣就是在A這個類上加鎖。對於我們這次的業務需求來說都一樣沒啥差別(實際上應該加類鎖,但是我們這塊一般都會做成單例去處理這樣的業務,就問題不大了)。

其實同步語句塊與上面的synchronized修飾於方法上面還是有互斥的,對應的情況就是如果同步語句塊的參數是this的話,就是代表這個實例對象,所以會和1.1中所講情況產生同步;如果是class的話,代表類,會與1.2中所講內容產生同步。

當然同步語句塊的參數還可以使用其他對象,一般爲了處理像是投票那種比較獨立的需求,我們可以這樣加鎖。

public class A {
    private Object lock = new Object();
    public void xxx(){
        synchronized (lock) {
            // 加票
        }
        // 記錄明細、扣錢...
    }
}

單獨聲明一個對象作爲鎖,這樣鎖的是這個實例對象,當在別的地方需要用到這個鎖的時候也在這個實例對象上加鎖就行,不會和A這個類的對象鎖和類鎖衝突。

最後說一點,就是這個參數還可以是String字符串,但是一般儘量不要使用,由於String字符串在Java中存在常量池的問題,所以有時候雖然是兩個變量,但是隻要內容一樣就會產生同步鎖。

1.4、鎖重入、繼承問題

最後再說一個鎖重入與繼承所產生的問題。鎖重入,就是synchronized代碼塊中又有一個synchronized代碼塊,或者同步方法中調用了同步方法。

同步代碼塊能否進鎖就還是看能不能獲取鎖,有沒有相同的鎖內容正在執行。

同步方法調用同步方法這裏,只要是自己對象的鎖,那麼可以無限重新進鎖。舉上面1.2對比的那個例子,a裏面如果調用了b,那麼b中的方法也是可以執行的,並不會因爲a方法持有了鎖,而到裏面的b會出現問題。但是直接調用b方法肯定還是會產生鎖的。

繼承這塊就兩個重點,一個是繼承就當是子類全部繼承就好了,沒有父子類關係。另外一個就是重寫的時候,synchronized關鍵字也需要重新聲明,否則重寫方法不加synchronized關鍵字這個方法就不會是同步方法了。

1.5、死鎖

多線程 的鎖,很容易產生死鎖問題,下面舉個例子。

public synchronized static void a() {
    // ...
    b();
    // ...
}

public synchronized static void b() {
    // ...
    a();
    // ...
}

這裏是個簡單明瞭的例子,兩個線程同時進入a、b方法(他們肯定不能是一個類的了,不然b都進不去),這時候,a在等待進入b的鎖,b在等待進入a的鎖,就會產生互相等待,也就是死鎖了。

分析一個Java進程有沒有死鎖,可以通過運行 jstack -l 進程ID 來發現是否存在死鎖。

2、volatile關鍵字

其實對於同步來說,見的最多的雙重校驗單例的實現。裏面其實也有用到了volatile關鍵字。這個關鍵字一般還是挺少用的,他有兩個作用,一個是可見性,一個是禁止指令重排序。

可見性這個問題,在一般情況下不容易見到,但是當運行server版Java進行的話,就會出現。當然也可以通過 jvm 增加-server參數來實現。

線程內都會保存一個變量的內存副本,這個內存副本只會在初始化的時候讀取,之後就是在線程內做了修改,回去寫入和更新。但是如果外部去修改了這個變量,那麼線程內的副本是不會主動更新的,這就是可見性的問題。所以如果給變量增加了volatile的關鍵字,那麼就可以保證這個變量每次都去主內存中讀寫變量,不管內存副本了。

指令重排序,這個大家可能會比較陌生,由於jvm有自己對代碼的優化,比如一段代碼的4行內容,他們互不影響,那麼在jvm實際執行的時候有可能並不是按照順序執行的,可能是1、3、2、4這樣的順序執行,這就是指令重排序。當然這和我們這次講的鎖沒有關係,只是提一下,volatile還有禁止指令重排序的作用。

3、小結

其實多線程的問題有點類似於我們一開始學習 數據庫 ,髒讀啊,不可重複讀什麼的,都是併發引起的。當然這裏就涉及到鎖了。本次介紹了synchronized關鍵字的處理鎖和同步的問題,其實Java中還有更靈活的方式也就是lock來處理鎖和同步的問題,這個我們之後會講到。

©原創文章, 轉載 請註明來源: 趙伊凡's Blog 

發佈了10 篇原創文章 · 獲贊 6 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章