深入Synchronized各種使用方法

2022-08-08 06:00:37

深入學習Synchronized各種使用方法

在Java當中synchronized通常是用來標記一個方法或者程式碼塊。在Java當中被synchronized標記的程式碼或者方法在同一個時刻只能夠有一個執行緒執行被synchronized修飾的方法或者程式碼塊。因此被synchronized修飾的方法或者程式碼塊不會出現資料競爭的情況,也就是說被synchronized修飾的程式碼塊是並行安全的。

Synchronized關鍵字

synchronized關鍵字通常使用在下面四個地方:

  • synchronized修飾實體方法。
  • synchronized修飾靜態方法。
  • synchronized修飾實體方法的程式碼塊。
  • synchronized修飾靜態方法的程式碼塊。

在實際情況當中我們需要仔細分析我們的需求選擇合適的使用synchronized方法,在保證程式正確的情況下提升程式執行的效率。

Synchronized修飾實體方法

下面是一個用Synchronized修飾實體方法的程式碼範例:

public class SyncDemo {

  private int count;

  public synchronized void add() {
    count++;
  }

  public static void main(String[] args) throws InterruptedException {
    SyncDemo syncDemo = new SyncDemo();
    Thread t1 = new Thread(() -> {
      for (int i = 0; i < 10000; i++) {
        syncDemo.add();
      }
    });

    Thread t2 = new Thread(() -> {
      for (int i = 0; i < 10000; i++) {
        syncDemo.add();
      }
    });
    t1.start();
    t2.start();
    t1.join(); // 阻塞住執行緒等待執行緒 t1 執行完成
    t2.join(); // 阻塞住執行緒等待執行緒 t2 執行完成
    System.out.println(syncDemo.count);// 輸出結果為 20000
  }
}

在上面的程式碼當中的add方法只有一個簡單的count++操作,因為這個方法是使用synchronized修飾的因此每一個時刻只能有一個執行緒執行add方法,因此上面列印的結果是20000。如果add方法沒有使用synchronized修飾的話,那麼執行緒t1和執行緒t2就可以同時執行add方法,這可能會導致最終count的結果小於20000,因為count++操作不具備原子性。

上面的分析還是比較明確的,但是我們還需要知道的是synchronized修飾的add方法一個時刻只能有一個執行緒執行的意思是對於一個SyncDemo類的物件來說一個時刻只能有一個執行緒進入。比如現在有兩個SyncDemo的物件s1s2,一個時刻只能有一個執行緒進行s1add方法,一個時刻只能有一個執行緒進入s2add方法,但是同一個時刻可以有兩個不同的執行緒執行s1s2add方法,也就說s1add方法和s2add是沒有關係的,一個執行緒進入s1add方法並不會阻止另外的執行緒進入s2add方法,也就是說synchronized在修飾一個非靜態方法的時候「鎖」住的只是一個範例物件,並不會「鎖」住其它的物件。其實這也很容易理解,一個範例物件是一個獨立的個體別的物件不會影響他,他也不會影響別的物件。

Synchronized修飾靜態方法

Synchronized修飾靜態方法:

public class SyncDemo {

  private static int count;

  public static synchronized void add() {
    count++; // 注意 count 也要用 static 修飾 否則編譯通過不了
  }

  public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
      for (int i = 0; i < 10000; i++) {
        SyncDemo.add();
      }
    });

    Thread t2 = new Thread(() -> {
      for (int i = 0; i < 10000; i++) {
        SyncDemo.add();
      }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(SyncDemo.count); // 輸出結果為 20000
  }
}

上面的程式碼最終輸出的結果也是20000,但是與前一個程式不同的是。這裡的add方法用static修飾的,在這種情況下真正的只能有一個執行緒進入到add程式碼塊,因為用static修飾的話是所有物件公共的,因此和前面的那種情況不同,不存在兩個不同的執行緒同一時刻執行add方法。

你仔細想想如果能夠讓兩個不同的執行緒執行add程式碼塊,那麼count++的執行就不是原子的了。那為什麼沒有用static修飾的程式碼為什麼可以呢?因為當沒有用static修飾時,每一個物件的count都是不同的,記憶體地址不一樣,因此在這種情況下count++這個操作仍然是原子的!

Sychronized修飾多個方法

synchronized修飾多個方法範例:

public class AddMinus {
  public static int ans;

  public static synchronized void add() {
    ans++;
  }

  public static synchronized void minus() {
    ans--;
  }

  public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
      for (int i = 0; i < 10000; i++) {
        AddMinus.add();
      }
    });

    Thread t2 = new Thread(() -> {
      for (int i = 0; i < 10000; i++) {
        AddMinus.minus();
      }
    });

    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(AddMinus.ans); // 輸出結果為 0
  }
}

在上面的程式碼當中我們用synchronized修飾了兩個方法,addminus。這意味著在同一個時刻這兩個函數只能夠有一個被一個執行緒執行,也正是因為addminus函數在同一個時刻只能有一個函數被一個執行緒執行,這才會導致ans最終輸出的結果等於0。

對於一個範例物件來說:

public class AddMinus {
  public int ans;

  public synchronized void add() {
    ans++;
  }

  public synchronized void minus() {
    ans--;
  }

  public static void main(String[] args) throws InterruptedException {
    AddMinus addMinus = new AddMinus();
    Thread t1 = new Thread(() -> {
      for (int i = 0; i < 10000; i++) {
        addMinus.add();
      }
    });

    Thread t2 = new Thread(() -> {
      for (int i = 0; i < 10000; i++) {
        addMinus.minus();
      }
    });

    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(addMinus.ans);
  }
}

上面的程式碼沒有使用static關鍵字,因此我們需要new出一個範例物件才能夠呼叫addminus方法,但是同樣對於AddMinus的範例物件來說同一個時刻只能有一個執行緒在執行add或者minus方法,因此上面程式碼的輸出同樣是0。

Synchronized修飾實體方法程式碼塊

Synchronized修飾實體方法程式碼塊

public class CodeBlock {

  private int count;

  public void add() {
    System.out.println("進入了 add 方法");
    synchronized (this) {
      count++;
    }
  }

  public void minus() {
    System.out.println("進入了 minus 方法");
    synchronized (this) {
        count--;
    }
  }

  public static void main(String[] args) throws InterruptedException {
    CodeBlock codeBlock = new CodeBlock();
    Thread t1 = new Thread(() -> {
      for (int i = 0; i < 10000; i++) {
        codeBlock.add();
      }
    });

    Thread t2 = new Thread(() -> {
      for (int i = 0; i < 10000; i++) {
        codeBlock.minus();
      }
    });

    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(codeBlock.count); // 輸出結果為 0
  }
}

有時候我們並不需要用synchronized去修飾程式碼塊,因為這樣並行度就比較低了,一個方法一個時刻只能有一個執行緒在執行。因此我們可以選擇用synchronized去修飾程式碼塊,只讓某個程式碼塊一個時刻只能有一個執行緒執行,除了這個程式碼塊之外的程式碼還是可以並行的。

比如上面的程式碼當中addminus方法沒有使用synchronized進行修飾,因此一個時刻可以有多個執行緒執行這個兩個方法。在上面的synchronized程式碼塊當中我們使用了this物件作為鎖物件,只有拿到這個鎖物件的執行緒才能夠進入程式碼塊執行,而在同一個時刻只能有一個執行緒能夠獲得鎖物件。也就是說add函數和minus函數用synchronized修飾的兩個程式碼塊同一個時刻只能有一個程式碼塊的程式碼能夠被一個執行緒執行,因此上面的結果同樣是0。

這裡說的鎖物件是this也就CodeBlock類的一個範例物件,因為它鎖住的是一個範例物件,因此當範例物件不一樣的時候他們之間是沒有關係的,也就是說不同範例用synchronized修飾的程式碼塊是沒有關係的,他們之間是可以並行的。

Synchronized修飾靜態程式碼塊

public class CodeBlock {

  private static int count;

  public static void add() {
    System.out.println("進入了 add 方法");
    synchronized (CodeBlock.class) {
      count++;
    }
  }

  public static void minus() {
    System.out.println("進入了 minus 方法");
    synchronized (CodeBlock.class) {
        count--;
    }
  }

  public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
      for (int i = 0; i < 10000; i++) {
        CodeBlock.add();
      }
    });

    Thread t2 = new Thread(() -> {
      for (int i = 0; i < 10000; i++) {
        CodeBlock.minus();
      }
    });

    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(CodeBlock.count);
  }
}

上面的程式碼是使用synchronized修飾靜態程式碼塊,上面程式碼的鎖物件是CodeBlock.class,這個時候他不再是鎖住一個物件了,而是一個類了,這個時候的並行度就變小了,上一份程式碼當鎖物件是CodeBlock的範例物件時並行度更大一些,因為當鎖物件是範例物件的時候,只有範例物件內部是不能夠並行的,範例之間是可以並行的。但是當鎖物件是CodeBlock.class的時候,範例物件之間時不能夠並行的,因為這個時候的鎖物件是一個類。

應該用什麼物件作為鎖物件

在前面的程式碼當中我們分別使用了範例物件和類的class物件作為鎖物件,事實上你可以使用任何物件作為鎖物件,但是不推薦使用字串和基本型別的包裝類作為鎖物件,這是因為字串物件和基本型別的包裝物件會有快取的問題。字串有字串常數池,整數有小整數池。因此在使用這些物件的時候他們可能最終都指向同一個物件,因為指向的都是同一個物件,執行緒獲得鎖物件的難度就會增加,程式的並行度就會降低。

比如在下面的範例程式碼當中就是由於鎖物件是同一個物件而導致並行度下降:

import java.util.concurrent.TimeUnit;

public class Test {

  public void testFunction() throws InterruptedException {
    synchronized ("HELLO WORLD") {
      System.out.println(Thread.currentThread().getName() + "\tI am in synchronized code block");
      TimeUnit.SECONDS.sleep(5);
    }
  }

  public static void main(String[] args) {
    Test t1 = new Test();
    Test t2 = new Test();
    Thread thread1 = new Thread(() -> {
      try {
        t1.testFunction();
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    });

    Thread thread2 = new Thread(() -> {
      try {
        t2.testFunction();
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    });
    thread1.start();
    thread2.start();
  }
}

在上面的程式碼當中我們使用兩個不同的執行緒執行兩個不同的物件內部的testFunction函數,按道理來說這兩個執行緒是可以同時執行的,因為執行的是兩個不同的範例物件的同步程式碼塊。但是上面程式碼的執行首先一個執行緒會進入同步程式碼塊然後列印輸出,等待5秒之後,這個執行緒退出同步程式碼塊另外一個執行緒才會再進入同步程式碼塊,這就說明了兩個執行緒不是同時執行的,其中一個執行緒需要等待另外一個執行緒執行完成才執行。這正是因為兩個Test物件當中使用的"HELLO WORLD"字串在記憶體當中是同一個物件,是儲存在字串常數池中的物件,這才導致了鎖物件的競爭。

下面的程式碼執行的結果也是一樣的,一個執行緒需要等待另外一個執行緒執行完成才能夠繼續執行,這是因為在Java當中如果整數資料在[-128, 127]之間的話使用的是小整數池當中的物件,使用的也是同一個物件,這樣可以減少頻繁的記憶體申請和回收,對記憶體更加友好。

import java.util.concurrent.TimeUnit;

public class Test {

  public void testFunction() throws InterruptedException {
    synchronized (Integer.valueOf(1)) {
      System.out.println(Thread.currentThread().getName() + "\tI am in synchronized code block");
      TimeUnit.SECONDS.sleep(5);
    }
  }

  public static void main(String[] args) {
    Test t1 = new Test();
    Test t2 = new Test();
    Thread thread1 = new Thread(() -> {
      try {
        t1.testFunction();
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    });

    Thread thread2 = new Thread(() -> {
      try {
        t2.testFunction();
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    });
    thread1.start();
    thread2.start();
  }
}

Synchronized與可見性和重排序

可見性

  • 當一個執行緒進入到synchronized同步程式碼塊的時候,將會重新整理所有對該執行緒的可見的變數,也就是說如果其他執行緒修改了某個變數,而且執行緒需要在Synchronized程式碼塊當中使用,那就會重新重新整理這個變數到記憶體當中,保證這個變數對於執行同步程式碼塊的執行緒是可見的。

  • 當一個執行緒從同步程式碼塊退出的時候,也會將執行緒的工作記憶體同步到記憶體當中,保證在同步程式碼塊當中修改的變數對其他執行緒可見。

重排序

Java編譯器和JVM當發現能夠讓程式執行的更快的時候是可能對程式的指令進行重排序處理的,也就是通過調換程式指令執行的順序讓程式執行的更快。

但是重排序很可能讓並行程式產生問題,比如說當一個在synchronized程式碼塊當中的寫操作被重排序到synchronized同步程式碼塊外部了這顯然是有問題的。

在JVM的實現當中是不允許synchronized程式碼塊內部的指令和他前面和後面的指令進行重排序的,但是在synchronized內部的指令是可能與synchronized內部的指令進行重排序的,比較著名的就是DCL單例模式,他就是在synchronized程式碼塊當中存在重排序的,如果你對DCL單例模式還不是很熟悉,你可以閱讀這篇文章DCL單例模式部分。

總結

在本篇文章當中主要介紹了各種synchronized的使用方法,總結如下:

  • Synchronized修飾實體方法,這種情況不同的物件之間是可以並行的。
  • Synchronized修飾實體方法,這種情況下不同的物件是不能並行的,但是不同的類之間可以進行並行。
  • Sychronized修飾多個方法,這多個方法在統一時刻只能有一個方法被執行,而且只能有一個執行緒能夠執行。
  • Synchronized修飾實體方法程式碼塊,同一個時刻只能有一個執行緒執行程式碼塊。
  • Synchronized修飾靜態程式碼塊,同一個時刻只能有一個執行緒執行這個程式碼塊,而且不同的物件之間不能夠進行並行。
  • 應該用什麼物件作為鎖物件,建議不要使用字串和基本型別的包裝類作為鎖物件,因為Java對這些進行優化,很可能多個物件使用的是同一個鎖物件,這會大大降低程式的並行度。
  • 程式在進入和離開Synchronized程式碼塊的時候都會將執行緒的工作記憶體重新整理到記憶體當中,以保證資料的可見性,這一點和volatile關鍵字很像,同時Synchronized程式碼塊中的指令不會和Synchronized程式碼塊之間和之後的指令進行重排序,但是Synchronized程式碼塊內部可能進行重排序。

更多精彩內容合集可存取專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演演算法與資料結構)知識。