Synchronized鎖的是什麼?

那肉香腸發表於2020-09-19

Synchronized鎖的是什麼?

臨界區與鎖

併發程式設計中不可避免的會出現多個執行緒共享同一個資源的情況,為了防止出現資料不一致情況的發生,人們引入了臨界區的概念。臨界區是一個用來訪問共享資源的程式碼塊,同一時間內只執行一個執行緒進入。

那麼如何實現這個臨界區呢?這就用到我們的鎖了,當程式想要訪問一個臨界區時,它先會去看看是否已經有其他執行緒進入了,也就是看是否能獲得鎖。如果沒有其他執行緒進入,那麼它就進入臨界區,其他執行緒就無法進入,相當於加鎖。反之,則會被掛起,處於等待狀態,直到其他執行緒離開臨界區,且本執行緒被JVM選中才可進入(因為可能有其他執行緒也在等待)。

利用Synchronized解決併發問題

Synchronize是一個重量級鎖,它會降低程式效能,因此如果對資料一致性沒有要求,就不要使用它。如果方法被Synchronize關鍵字宣告,那麼該方法的程式碼塊被視為臨界區。當某個執行緒呼叫該物件的synchronized方法或者訪問synchronized程式碼塊時,這個執行緒便獲得了該物件的鎖,其他執行緒暫時無法訪問這個方法,只有等待這個方法執行完畢或者程式碼塊執行完畢,這個執行緒才會釋放該物件的鎖,其他執行緒才能執行這個方法或者程式碼塊。

下面我們將建立兩個執行緒A,B來同時訪問一個物件:A從賬戶裡取錢,B從賬戶裡存錢。首先是不使用Synchronized關鍵字。

建立賬戶類

它擁有一個私有變數balance表示金額,addAmount和subtractAmount分別對金額執行加減操作。

public class Account {
    private double balance;

    public double getBalance() {
        return balance;
    }

    public void setBalance(double balance) {
        this.balance = balance;
    }

    public void addAmount(double amount){
        System.out.println("addAmount start");
        double temp=balance;
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        temp+=amount;
        balance=temp;
        System.out.println("addAmount end");
    }

    public void subtractAmount(double amount){
        System.out.println("subtractAmount start");
        double temp=balance;
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        temp-=amount;
        balance=temp;
        System.out.println("subtractAmount end");
    }
}

建立A,B倆執行緒,分別對賬戶存錢和取錢。

public class A implements Runnable {
    private Account account;
    public A(Account account){
        this.account=account;
    }
    @Override
    public void run() {
        for(int i=0;i<10;i++){
            account.addAmount(1000);
        }
    }
}
public class B implements Runnable  {
    private Account account;
    public B(Account account){
        this.account=account;
    }
    @Override
    public void run() {
        for(int i=0;i<10;i++){
            account.subtractAmount(1000);
        }
    }
}

最後在main裡面測試

public class Main {
    public static void main(String[] args) {
        Account account=new Account();
        account.setBalance(1000);
        A a=new A(account);
        Thread ThreadA=new Thread(a);
        B b=new B(account);
        Thread ThreadB=new Thread(b);
        System.out.println("Account Balance:"+account.getBalance());
        ThreadA.start();
        ThreadB.start();
        try {
            ThreadA.join();
            ThreadB.join();
            System.out.println("Account Balance:"+account.getBalance());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

ThreadA往賬戶中執行了10次存入操作,每次存入1000元,ThreadB則是以同樣的金額執行了10次取出操作。那麼按照我們的推測,最後賬戶的金額應該維持不變,但程式的結果卻不是我們想要的數字。這是為什麼呢?因為我們在對資料進行操作的時候,另外一個執行緒可能也在進行操作,邏輯上應該先後執行的方法變成了同時執行,所以出現了錯誤。

現在我們給addAmount和subtractAmount加上synchronized關鍵字,保證資料一致性,這樣程式就不會出問題了。

如果是使用synchronize保護程式碼塊,則需要將物件引用作為引數傳入。一般來說傳入this關鍵字作為引用執行方法的物件就可以了。

鎖的到底是什麼?

或許在上面的例子你因為粗心只為其中一個方法加了關鍵字,那麼你會看到這樣的現象:

保護程式碼塊要將物件傳入,那應該鎖的是物件呀。你可能會想:我執行subtractAmout,按道理應該等我執行完addAmount才能執行,它都沒有account這個物件的鎖,不應該在中間插這麼一段呀。但是,只有加了鎖的方法,執行緒執行該方法時才會去嘗試獲得鎖,看看是否有執行緒進入臨界區。訪問非同步方法無需獲得鎖,你把synchronized去掉跟你只加一個的情況是一樣的,同步方法與非同步遵循的是不同的規則。也就是說你可以在呼叫該物件的加了synchronized方法的同時,呼叫其他的非同步方法。

兩個執行緒怎麼同時訪問了同一個物件的兩個synchronized方法?

你可能在搗鼓這個關鍵字的時候,驚訝的發現靜態方法的與眾不同。如果一個物件中的靜態方法用synchronized修飾,那麼其他執行緒可以在該靜態方法被訪問的同時,訪問該物件中的非靜態方法(當然,該靜態方法同一時間只能被一個執行緒訪問)。換句話說,兩個執行緒可以同時訪問一個物件中的兩個synchronized方法。

等等,不是說鎖物件嗎?到底鎖的是什麼?鎖的確實是物件,但對於靜態方法我們說的是T.class(T 為類名),非靜態方法鎖的是this ,也就是類的例項物件,兩者是不同的。

class T {
  // 修飾非靜態方法
  public synchronized void a() {
    // 臨界區
  }
  // 修飾靜態方法
  public synchronized static void b() {
    // 臨界區
  }
}  

上面那段程式碼相當於:

class T {
  // 修飾非靜態方法
  public synchronized(this) void a() {
    // 臨界區
  }
  // 修飾靜態方法
  public synchronized(T.class) static void b() {
    // 臨界區
  }
}  

實際上加鎖本質就是在鎖物件的物件頭中寫入當前執行緒id。我們可以通過下面的程式碼驗證,每次都傳入new Object()。

class Account {
    private double balance;
    public synchronized void addAmount(double amount){
        synchronized (new Object()){
            System.out.println("addAmount start");
            double temp=balance;
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            temp+=amount;
            balance=temp;
            System.out.println("addAmount end");
        }
    }
    public void subtractAmount(double amount){
    	synchronized (new Object()){
            System.out.println("subtractAmount start");
            double temp=balance;
            try {
                Thread.sleep(100); 
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            temp-=amount;
            balance=temp;
            System.out.println("subtractAmount end");
        }
    }
}

因為執行緒每次呼叫方法鎖的都是新new的物件,所以加鎖無效。甚至編譯器可能會將synchronized給優化掉,因為這相當於多把鎖保護同一個資源,編譯器一看,每個人都弄把鎖就進來了,那我還不如不加,反正都一個樣。

另外需要注意的是,synchronized是可重入鎖。也就是說當執行緒訪問物件的同步方法時,在呼叫其他同步方法時無需再去獲取其訪問權。因為我們實際上鎖的是物件,物件頭裡面紀錄的都是當前執行緒的ID。

總結

  • 修飾函式,鎖的是當前類的例項化物件
  • 修飾靜態方法,鎖的是當前類的Class物件
  • 修飾同步程式碼塊,鎖的是括號裡的物件

加鎖實際上就是在鎖物件的物件頭中寫入當前執行緒id,每個執行緒要想呼叫這個同步方法,都會先去鎖物件的物件頭看看當前執行緒id是不是自己的。

參考

synchronized鎖定的到底是什麼?-知乎

相關文章