java 併發——內置鎖



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

1、由單例模式引入

引用一下百度百科的定義——
線程安全是多線程編程時的計算機程序代碼中的一個概念。在擁有共享數據的多條線程並行執行的程序中,線程安全的代碼會經過同步機制保證各個線程均可以正常且正確的執行,不會出現數據污染等意外狀況。

文字定義老是很含糊,舉個反例就很清楚了,想起以前總結過單例模式,就從單例模式開始吧。若是不清楚單例模式的新同窗,能夠看一下這篇總結:
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 這兩個關鍵字。安全

2、同步與 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());
    }
}

執行結果:
框架

從結果能夠清楚地看到一個線程進入同步代碼塊以後,另外一個線程阻塞了,須要等到前者釋放鎖以後,它得到鎖了才能進入同步代碼塊。jvm

上面代碼中,咱們建立的 lock 對象做爲鎖。用 synchronized 修飾方法又是什麼充當了鎖呢?ide

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 static 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() 方法時,是子類對象調用父類方法。

3、同步關鍵字 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 編程思想 第四版》

相關文章
相關標籤/搜索