1. 程式人生 > >java 併發——內建鎖

java 併發——內建鎖

堅持學習,總會有一些不一樣的東西。

一、由單例模式引入

引用一下百度百科的定義—— 執行緒安全是多執行緒程式設計時的計算機程式程式碼中的一個概念。在擁有共享資料的多條執行緒並行執行的程式中,執行緒安全的程式碼會通過同步機制保證各個執行緒都可以正常且正確的執行,不會出現資料汙染等意外情況。 文字定義總是很含糊,舉個反例就很清楚了,想起之前總結過單例模式,就從單例模式開始吧。如果不清楚單例模式的新同學,可以看一下這篇總結: java中全面的單例模式多種實現方式總結 單例模式中,懶漢式的實現方案如下:

public class Singleton {
    private Singleton() {
    }

    private static Singleton sSingleton;

    public static Singleton getInstance() {
        if (sSingleton == null) {
            sSingleton = new Singleton();
        }
        return sSingleton;
    }
}

該方法在單執行緒中執行是沒有問題的,但是在多執行緒中,某些情況下,多個執行緒同時都判斷到 sSingleton == null,然後又都執行 sSingleton = new Singleton(),這樣就不能保證單例了,我們說它不是執行緒安全的。 一種改進方法:

public class Singleton {
    private Singleton() {
    }

    private static Singleton sSingleton;

    public synchronized static Singleton getInstance() {
        if (sSingleton == null) {
            sSingleton = new Singleton();
        }
        return sSingleton;
    }
}

上面這種實現,實際上效率非常低,是完全不推薦使用的。主要是因為加了 sychronized 關鍵字,意為同步的,也就是內建鎖。使用 synchronized 關鍵字修飾方法, 是對該方法加鎖,這樣在同一時刻,只有一個執行緒能進入該方法,這樣保證了執行緒安全,但是也正因為如此,效率變得很低,因為當物件建立之後,再次呼叫該方法的時候,直接使用物件就可以了,無需再同步了。於是有了下面改進的實現方式—— DCL(雙重檢查鎖):

public class Singleton {
    private Singleton() {
    }

    /**
     * volatile is since JDK5
     */
    private static volatile Singleton sSingleton;

    public static Singleton getInstance() {
        if (sSingleton == null) {
            synchronized (Singleton.class) {
                // 未初始化,則初始instance變數
                if (sSingleton == null) {
                    sSingleton = new Singleton();
                }
            }
        }
        return sSingleton;
    }
}

sSingleton = new Singleton() 不是一個原子操作。故須加 volatile 關鍵字修飾,該關鍵字在 jdk1.5 之後版本才有。下面就來說說 synchronizedvolatile 這兩個關鍵字。

二、同步與 synchronized

synchronized 鎖程式碼塊

java 提供了一種一種內建鎖,來實現同步程式碼塊,同步程式碼塊包含兩個部分:一個作為鎖的物件引用,一個由鎖保護的程式碼塊。形式如下:

synchronized (lock) {
    // 由鎖保護的程式碼塊
}

每個 java 物件都可以作為實現同步的鎖,java 的內建鎖也稱為互斥鎖。同一時刻只能有一個執行緒獲得該鎖,獲得該鎖的執行緒才能進入由鎖保護的程式碼塊,其它執行緒只能等待該執行緒執行完程式碼塊之後,釋放該鎖後,再去獲得該鎖。例子:

public class SynchronizedDemo1 {
    private Object lock = new Object();

    public static void main(String[] args) {
        SynchronizedDemo1 demo = new SynchronizedDemo1();
        new Thread(() -> {
            try {
                demo.test1();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        try {
            demo.test1();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public void test1() throws InterruptedException {
        System.out.println("--- test1 begin - current thread: " + Thread.currentThread().getId());
        Thread.sleep(1000);
        synchronized (lock) {
            System.out.println("--- test1 synchronized - current thread: " + Thread.currentThread().getId());
            Thread.sleep(5000);
        }
        System.out.println("--- test1 end - current thread: " + Thread.currentThread().getId());
    }
}

執行結果:

從結果可以清楚地看到一個執行緒進入同步程式碼塊之後,另一個執行緒阻塞了,需要等到前者釋放鎖之後,它獲得鎖了才能進入同步程式碼塊。

上面程式碼中,我們建立的 lock 物件作為鎖。用 synchronized 修飾方法又是什麼充當了鎖呢?

synchronized 修飾方法

以關鍵字 synchronized 修飾的方法就是一種橫跨掙個方法的同步程式碼塊,其中該同步程式碼塊的鎖就是呼叫該方法的物件。

class A {
    public synchronized void a(){
        System.out.println("hello");
    }
}

等價於

class A {
    public void a(){
        synchronized(this) {
            System.out.println("hello");
        }
    }
}

靜態方法用 類名.方法名 來呼叫,以關鍵字 synchronized 修飾的靜態方法則以 Class 物件作為鎖。

class A {
    public static synchronized void a(){
        System.out.println("hello");
    }
}

等價於

class A {
    public void a(){
        synchronized(A.class) {
            System.out.println("hello");
        }
    }
}

寫個demo測試一下:

public class A {
    public static void main(String[] args) {
        A obj_a = new A();
        new Thread() {
            @Override
            public void run() {
                try {
                    obj_a.a();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }.start();

        new Thread() {
            @Override
            public void run() {
                try {
                    obj_a.b();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }.start();

        new Thread(){
            @Override
            public void run() {
                try {
                    A.c();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }.start();

        try {
            A.d();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public synchronized void a() throws InterruptedException {
        System.out.println("--- begin a - current Thread " + Thread.currentThread().getId());
        Thread.sleep(8000);
        System.out.println("--- end a - current Thread " + Thread.currentThread().getId());
    }

    public synchronized void b() throws InterruptedException {
        System.out.println("--- begin b - current Thread " + Thread.currentThread().getId());
        Thread.sleep(8000);
        System.out.println("--- end b - current Thread " + Thread.currentThread().getId());
    }

    public synchronized static void c() throws InterruptedException {
        System.out.println("--- begin c - current Thread " + Thread.currentThread().getId());
        Thread.sleep(5000);
        System.out.println("--- end c - current Thread " + Thread.currentThread().getId());
    }

    public synchronized static void d() throws InterruptedException {
        System.out.println("--- begin d - current Thread " + Thread.currentThread().getId());
        Thread.sleep(5000);
        System.out.println("--- end d - current Thread " + Thread.currentThread().getId());
    }
}

執行結果如下:

可以看到,由於方法 a 和 方法 b 是同一個鎖 obj_A,所以當某個執行緒執行其中一個方法是,其他執行緒也不能執行另一個方法。但是方法 c 是由 A.class 物件鎖住的,執行方法 C 的執行緒與另外兩個執行緒沒有互斥關係。

對於某個類的某個特定物件來說,該類中,所有 synchronized 修飾的非靜態方法共享同一個鎖,當在物件上呼叫其任意 synchronized 方法時,此物件都被加鎖,此時,其他執行緒呼叫該物件上任意的 synchronized 方法只有等到前一個執行緒方法呼叫完畢並釋放了鎖之後才能被呼叫。 而對於一個類中,所有 synchronized 修飾的靜態方法共享同一個鎖。

可重入鎖

當某個執行緒請求一個由其他執行緒持有的鎖時,發出請求的執行緒就會阻塞,然而,內建鎖是可重入的,,如果某個執行緒試圖獲得一個已經由它自己持有的鎖,那麼這個請求就會成功。這也意味著獲取鎖的操作粒度是“執行緒”,而不是“呼叫”。當執行緒請求一個未被持有的鎖時,jvm 將記下鎖的持有者(即哪個執行緒),並獲取該鎖的計數值為 1 ,當同一個執行緒再次獲取該鎖時, jvm 將計數值遞增,當執行緒退出同步程式碼塊時,計數值將遞減,當計數值為 0 時,將釋放鎖。

public class B {
    public static void main(String[] args) {
        B obj_B = new B();
        obj_B.b();
    }

    private synchronized void a(){
        System.out.println("---a");
    }

    private synchronized void b(){
        System.out.println("---b");
        a();
    }
}

執行上面這段程式碼,將輸出

---b
---a

假設沒有可重入的鎖,對於物件 obj_B 來說,呼叫 b 方法時,執行緒將會持有 obj_B 這個鎖,在方法 b 中呼叫方法 a 時,將會一直等待方法 b 釋放鎖,造成死鎖的情況。 《Java 併發程式設計實戰》 中舉的可重入鎖的例子:

public class Widget {  
    public synchronized void doSomething() {  
        ...  
    }  
}  
  
public class LoggingWidget extends Widget {  
    public synchronized void doSomething() {  
        System.out.println(toString() + ": calling doSomething");  
        super.doSomething();  
    }  
}  

我第一遍看這段程式碼的時候,在思考,這裡父類子類的方法都有synchronized同步,當呼叫子類LoggingWidget的doSomething()時鎖物件肯定是當時呼叫的那個LoggingWidget例項,可是問題是當執行到super.doSomething()時,要呼叫父類的同步方法,那此時鎖物件是誰?是同一個鎖進入了 2 次,還是獲得了子類物件和父類物件的 2 個不同的鎖? 下面這段程式碼能給出結論:

public class Test {
  public static void main(String[] args) throws InterruptedException {
    final TestChild t = new TestChild();
 
    new Thread(new Runnable() {
      @Override
      public void run() {
        t.doSomething();
      }
    }).start();
    Thread.sleep(100);
    t.doSomethingElse();
  }
 
  public synchronized void doSomething() {
    System.out.println("something sleepy!");
    try {
      Thread.sleep(1000);
      System.out.println("woke up!");
    }
    catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
 
  private static class TestChild extends Test {
    public void doSomething() {
      super.doSomething();
    }
 
    public synchronized void doSomethingElse() {
      System.out.println("something else");
    }
  }
}

這段程式碼輸出了:

something sleepy!
woke up!
something else

而不是

something sleepy!
something else
woke up!

這說明是同一個鎖進入了 2 次,即呼叫子類方法的子類物件。而這也正好符合多型的思想,呼叫 super.doSomething() 方法時,是子類物件呼叫父類方法。

三、同步關鍵字 volatile

原子性

原子是世界上的最小單位,具有不可分割性。 比如 a=0 這個操作不可分割,我們說這是一個原子操作。而 ++i 就不是一個原子操作。它包含了"讀取-修改-寫入"的操作。 同步程式碼塊,可以視作是一個原子操作。Java從JDK 1.5開始提供了java.util.concurrent.atomic包,這個包中的原子操作類提供了一種用法簡單、效能高效、執行緒安全地更新一個變數的方式。比如:AtomicBoolean AtomicInteger AtomicLong 等原子操作類。具體可查閱JDK原始碼或者參考《ava併發程式設計的藝術》第7章。 下面說說複合操作與執行緒安全的問題。 然後說說複合操作與執行緒安全的問題。 我們知道,java 集合框架中的 Vector 類是執行緒安全的,檢視該類的原始碼發現,很多關鍵方法都用了synchronized 加以修飾。但是實際使用時候,稍有不慎,你會發現,它可能並不是執行緒安全的。比如在某個類中拓展一下 Vector 的方法,往 vector 中新增一個元素時,先判斷該元素是否存在,如果不存在才新增,該方法大概像下面這樣:

public class CJUtil{
    public void putElement(Vector<E> vector, E x){
        boolean has = vector.contains(x);
        if(!has){
            vector.add(x);
        }
    }
}

上面這個程式碼肯定是執行緒不安全的,但是為什麼呢?不是說好,Vector 類是執行緒安全的嗎?上網搜了一下,居然發現關於 Vector 類是不是執行緒安全的存在爭議,然後我看到有人說它不是執行緒安全的,給出的理由比如像上面這種先判斷再新增,或者先判斷再刪除,是一種複合操作,然後認真地開啟 JDK 的原始碼看了,發現 Vector 類中 contains 方法並沒有用 synchronized 修飾,然後得出了結論,Vector不是執行緒安全的... 事實到底是怎樣的呢?我們假設 Vector 類的 contains 也用 synchronized 關鍵字加鎖同步了,此時有兩個執行緒 tA 和 tB 同時訪問這個方法,tA 呼叫到 contains 方法的時候,tB 阻塞, tA 執行完 contains 方法,返回 false 後,釋放了鎖,在 tA 執行 add 之前,tB 搶到了鎖,執行了 contains 方法,tA 阻塞。對於同一個元素, tb 判斷也不包含,後面, tA 和 tB 都向 Vector 添加了這個元素。經過分析,我們發現,對於上述複合操作執行緒不安全的原因,並非是其中單個操作沒有加鎖同步造成的。 那如何解決這個問題呢?可能馬上會想到,給 putElement 方法加上 synchronized 同步。

public class CJUtil{
    public synchronized void putElement(Vector<E> vector, E x){
        boolean has = vector.contains(x);
        if(!has){
            vector.add(x);
        }
    }
}

這樣整個方法視為一個原子操作,只有當 tA 執行完整個方法後,tB 才能進入,也就不存在上面說的問題了。其實,這只是假象。這種在加鎖的方法,並不能保證執行緒安全。我們可以從兩個方面來分析一下:

  1. 從上文我們知道,給方法加鎖,鎖物件,是呼叫該方法的物件。這和我們操作 Vector 方法的鎖並不是同一個鎖。我們雖然保證了只有一個執行緒能夠進入到 putElement 方法去操作 vector,但是我們沒法保證其它執行緒通過其它方法不去操作這個 vector 。
  2. 上一條中,只有一個執行緒能夠進入到 putElement 方法,是不準確的,因為這個方法不是靜態的,如果在兩個執行緒中,分別用 CJUtil 的兩個不同的例項物件,是可以同時進入到 putElement 方法的。 正確的做法應該是:
public class CJUtil{
    public void putElement(Vector<E> vector, E x){
        synchronized(vector){
            boolean has = vector.contains(x);
            if(!has){
                vector.add(x);
            }
        }
    }
}

重排序

重排序通常是編譯器或執行時環境為了優化程式效能而採取的對指令進行重新排序執行的一種手段。重排序分為兩類:編譯器重排序和執行期重排序,分別對應編譯時和執行時環境。 不要假設指令執行的順序,因為根本無法預知不同執行緒之間的指令會以何種順序執行。 編譯器重排序的典型就是通過調整指令順序,在不改變程式語義的前提下,儘可能的減少暫存器的讀取、儲存次數,充分複用暫存器的儲存值。

int a = 5;① int b = 10;② int c = a + 1;③ 假設用的同一個暫存器

這三條語句,如果按照順序一致性,執行順序為①②③暫存器要被讀寫三次;但為了降低重複讀寫的開銷,編譯器會交換第二和第三的位置,即執行順序為①③②

可見性

可見性是一種複雜的屬性,因為可見性中的錯誤總是會違揹我們的直覺。通常,我們無法確保執行讀操作的執行緒能適時地看到其他執行緒寫入的值,有時甚至是根本不可能的事情。為了確保多個執行緒之間對記憶體寫入操作的可見性,必須使用同步機制。 可見性,是指執行緒之間的可見性,一個執行緒修改的狀態對另一個執行緒是可見的。也就是一個執行緒修改的結果。另一個執行緒馬上就能看到。比如:用volatile修飾的變數,就會具有可見性。volatile修飾的變數不允許執行緒內部快取和重排序,即直接修改記憶體。所以對其他執行緒是可見的。但是這裡需要注意一個問題,volatile只能讓被他修飾內容具有可見性,但不能保證它具有原子性。 下面這段程式碼:

public class A {
    private static boolean flag = false;

    public static void main(String[] args) {
        new Thread() {
            @Override
            public void run() {
                while (!flag) {
                }
            }
        }.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        flag = true;
    }
}

看執行結果:

可以看到程式並沒有像我們所期待的那樣,在一秒之後,退出,而是一直處於迴圈中。 下面給 flag 加上 volatile 關鍵修飾:

public class A {
    private static volatile boolean flag = false;

    public static void main(String[] args) {
        new Thread() {
            @Override
            public void run() {
                while (!flag) {
                }
            }
        }.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        flag = true;
    }
}

再看結果: 結果表明,沒有用 volatile 修飾 flag 之前,改變了不具有可見性,一個執行緒將它的值改變後,另一個執行緒卻 “不知道”,所以程式沒有退出。當把變數宣告為 volatile 型別後,編譯器與執行時都會注意到這個變數是共享的,因此不會將該變數上的操作與其他記憶體操作一起重排序。volatile 變數不會被快取在暫存器或者對其他處理器不可見的地方,因此在讀取volatile型別的變數時總會返回最新寫入的值。

在訪問volatile變數時不會執行加鎖操作,因此也就不會使執行執行緒阻塞,因此volatile變數是一種比sychronized關鍵字更輕量級的同步機制。

當對非 volatile 變數進行讀寫的時候,每個執行緒先從記憶體拷貝變數到CPU快取中。如果計算機有多個CPU,每個執行緒可能在不同的CPU上被處理,這意味著每個執行緒可以拷貝到不同的 CPU cache 中。

而宣告變數是 volatile 的,JVM 保證了每次讀變數都從記憶體中讀,跳過 CPU cache 這一步。

volatile 修飾的遍歷具有如下特性:

  1. 保證此變數對所有的執行緒的可見性,當一個執行緒修改了這個變數的值,volatile 保證了新值能立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理。但普通變數做不到這點,普通變數的值線上程間傳遞均需要通過主記憶體(詳見:Java記憶體模型)來完成。
  2. 禁止指令重排序優化。
  3. 不會阻塞執行緒。

synchronized 與可見性

細心的人應該發現了,上面程式碼中的迴圈是一個空迴圈,我試著去掉 volatile 關鍵字,在迴圈裡面加了一條列印資訊,如下:

public class A {
    private static boolean flag = false;

    public static void main(String[] args) {
        new Thread() {
            @Override
            public void run() {
                while (!flag) {
                    System.out.println("---");
                }
            }
        }.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        flag = true;
    }
}

結果會是怎樣,會一直列印 "---" 嗎?看結果:

奇怪了,為什麼沒有使用 volatile 關鍵字,一秒之後程式也推出了。點選檢視 System.out.println(String x) 的原始碼:

    public void println(String x) {
        synchronized (this) {
            print(x);
            newLine();
        }
    }

我們發現,該方法加鎖同步了。

那麼問題來了,synchronized 到底幹了什麼。。

按理說,synchronized 只會保證該同步塊中的變數的可見性,發生變化後立即同步到主存,但是,flag 變數並不在同步塊中,實際上,JVM對於現代的機器做了最大程度的優化,也就是說,最大程度的保障了執行緒和主存之間的及時的同步,也就是相當於虛擬機器儘可能的幫我們加了個volatile,但是,當CPU被一直佔用的時候,同步就會出現不及時,也就出現了後臺執行緒一直不結束的情況。 參考書籍: 《Java 併發程式設計實戰》 《Java 程式設計思想 第四版》