Java併發編程學習三:線程同步的關鍵字以及理解

上篇文章中介紹了Java線程的帶來的問題與內存模型中介紹了線程可能會引起的問題以及對應Java的內存模型,順帶介紹了Volatile和Sychronized關鍵字。今天對Java中涉及到的常見的關鍵類和關鍵字進行一個總結。html


Volatile

與鎖相比,Volatile提供了一種更加輕量級的同步機制,使用Volatile的變量在多線程中是不會發生上下文切換或者線程調度等操做的。當一個變量定義成爲一個Volatile的時候,這個變量具有了兩種特性:java

  • 第一是保證了該變量對全部線程的可見性。
  • 第二是禁止指令重排序優化。

Volatile變量不會緩存在工做內存(對應物理寄存器)當中,在線程A中修改了一個共享變量的值,修改後當即從A的工做內存中同步給了主內存更新值,同時其餘線程每次使用該共享變量值時,保證從主內存中獲取。不過Volatile也有必定的侷限性,雖然提供了類似的可見性保證,但不能用於構建原子的複合操做,所以當一個變量依賴其餘變量,或者當前變量依賴與舊值時候,就不能使用Volatile變量,由於Volatile不保證代碼的原子性。最多見的就是自增操做的問題。編程

因爲Java中的運算並不是原子操做,因此在多線程的狀況下進行運算同樣是不安全的。示例Demo以下:緩存

class ThreadTest {


    private volatile int count = 0;

    public void update() {

        for (int i = 0; i < 50; i++) {
            Thread thread = new Thread(() -> {
                for (int k = 0; k < 100; k++) {
                    count++;
                }

            });
            thread.start();
        }
        try {
            Thread.sleep(5000);
            System.out.println(count);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

上面代碼獲取到的值基本上都是小於5000的,由於count++在執行過程當中分三步進行,首先從主存中複製count到工做內存中,工做內存中將count+1,而後在再刷新回主存。因此存在的問題是當一個進行前兩步的時候,其餘的線程已經刷新最新值回主存了,那麼當前線程再刷新回主存的時候形成了值變小的問題。安全

Volatile最多見的場景就是在線程中充當flag變量的標誌,如提供一個方法進行終止線程:多線程

class ThreadTest extends Thread {


    private volatile boolean isCancle;

    public void setCancle(boolean isCancle) {
        this.isCancle = isCancle;
    }

    @Override
    public void run() {
        super.run();
        while (!isCancle) {

        }
        System.out.println("over");
    }
}

當調用setCancle(...)的時候可以立馬結束while循環,從而打印出over。併發

第二個,使用Volatile可以禁止指令重排序的優化。在Java線程的帶來的問題與內存模型(JMM)中咱們解釋了指令重排序的概念,那麼在Java中能夠經過Volatile關鍵字添加內存屏障,從而實現禁止指令重排序的優化,關於Volatile禁止指令重排序的一個在經典的案例就是DCL中的使用:app

public class DoubleCheckedLocking {            
    private static Instance instance;              

    public static Instance getInstance() {           
        if (instance == null) {                        
            synchronized (DoubleCheckedLocking.class) {  
                if (instance == null)                 
                    instance = new Instance();        
            }                                     
        }                                                
        return instance;                              
    }                                          
}

在DCL沒添加Volatile的版本中,在new Instance()該句中會出現問題,因爲new Instance()不是一個原子操做,其操做分爲以下過程:ide

  1. 爲Instance對象初始化內存空間.
  2. 初始化Instance對象.
  3. 將Instance對象賦值給instance引用.

因爲重排序的存在,編譯器能夠將2,3順序進行重排序優化:性能

  1. 爲Instance對象初始化內存空間.
  2. 將Instance對象賦值給instance引用.
  3. 初始化Instance對象.

當線程A再進行new Instance()時候,此時正好執行到第2個步驟,這時候線程B進行判斷instance是否爲null,發現instance引用不爲空,那麼就直接返回了,然而線程A還沒初始化Instance對象,這就形成了線程B引用了一個未初始化的引用,那麼天然會有問題。解決方案就是爲instance變量添加volatile關鍵字,保證禁止指令的重排序,程序就正確了。

關於DCL更詳細的內容能夠閱讀如下這篇文章

最後總結一下Volatile使用的場景:

  • 對變量的寫入操做不依賴變量當前值,或者你能保證只有單個線程更新變量的值。
  • 該變量不會與其餘狀態變量一塊兒歸入不變性條件。
  • 在訪問變量時候不須要加鎖。

Synchronized

Java中最多見到的同步機制就是Synchronized關鍵字了,通常狀況下,若是對性能的要求不是那麼的苛刻,經過Sychronized關鍵字基本上可以解決全部的線程同步問題。通常使用Synchronized方式有以下幾種:

  • 在靜態方法中添加Synchronized
  • 在實例方法中添加Synchronized
  • 對某個對象添加Synchronized
  • 對Class添加Synchronized

在靜態方法中添加Synchronized的方式和對Class添加Synchronized的本質上是同樣的,都是是持有對應的class的鎖,示例以下:

public class Test{
    private static int num=2;

    public static void main(String[] args){
        
    }
    public static synchronized void increaseNum(){
        num++;
        System.out.println("調用increaseNum,當前值爲:"+num);
    }

    public void increseNum2(){
        synchronized(Test.class){
            num++;
             System.out.println("調用increseNum2,當前值爲:"+num);
        }
    }
}

在實例方法中添加Synchronized本質上是持有了當前對象實例的鎖,示例代碼以下:

public synchronized void increseNum3(){
            num++;
            System.out.println("調用increseNum3,當前值爲:"+num);
        
    }

對某個對象添加Synchronized本質上是對持有了當前對象的鎖,示例代碼以下:

public  void increseNum4(){
        synchronized (object) {
            num++;
            System.out.println("調用increseNum4,當前值爲:"+num);
        }
    }

上面代碼中持有了object對象的鎖。

Synchronized稱之爲互斥鎖,使用Synchronized可以保證代碼段的可見性和原子性,多線程操做中在某一個線程A得到互斥鎖的時候,其餘線程只能等待而阻塞等待A的執行完畢後再競爭鎖資源。除此以外,使用Synchronized時候具有了可重入性,即一個線程獲取了互斥鎖以後,該線程其餘的聲明瞭Synchronized的,若是被調用了,而且是同一個鎖的代碼段,則是不須要阻塞,可以一併執行的。示例代碼以下:

public  void increseNum4(){
        synchronized (object) {
            num++;
            increseNum5();
            System.out.println("調用increseNum4,當前值爲:"+num);
        }
    }
    public  void increseNum5(){
        synchronized (object) {
            num++;
            System.out.println("調用increseNum5,當前值爲:"+num);
        }
    }

能夠看到,在increseNum4()方法中咱們是有了object對象的鎖,其內部中調用了increseNum5()方法,因爲increseNum5()中持有相同的object對象鎖,因此方法能夠等同理解爲:

public  void increseNum4(){
        synchronized (object) {
            num++;
            increseNum5();
            System.out.println("調用increseNum4,當前值爲:"+num);
        }
    }
    public  void increseNum5(){
            num++;
            System.out.println("調用increseNum5,當前值爲:"+num);
    }

若是咱們修改increseNum5()中的Synchronized的修飾,改爲以下:

public  void increseNum4(){
        synchronized (object) {
            num++;
            increseNum5();
            System.out.println("調用increseNum4,當前值爲:"+num);
        }
    }
    public synchronized  void increseNum5(){
            num++;
            System.out.println("調用increseNum5,當前值爲:"+num);
    }

那麼因爲上述兩個方法持有不一樣的鎖,若是increseNum5()不被其餘線程使用鎖定,那麼可以正常執行;反之,increseNum4()方法必須等到increseNum5()的線程執行完畢後釋放對應的鎖後纔可以繼續執行代碼段。

上篇文章Java併發編程學習二中講述了底層中JVM針對工做內存與主存的8種交互操做時講述了一個規則:**一個變量在同一時刻只容許一條線程進行lock操做,但lock操做能夠被同一線程重複執行屢次,屢次執行lock後,只有執行相同次數的unlock操做,變量纔會被解鎖。**lock跟unlock操做咱們沒法直接操做,取而代之的是關鍵字monitorenter和monitorexit,這個也在上篇文章中舉例說過了,這裏也不過多敘述。

Java中的同步實現跟操做系統中的管程(監視器,monitor)有關,管程是操做系統實現同步的重要基礎概念。關於對應的介紹能夠看下這個維基百科的[介紹](https://zh.wikipedia.org/wiki/%E7%9B%A3%E8%A6%96%E5%99%A8_(%E7%A8%8B%E5%BA%8F%E5%90%8C%E6%AD%A5%E5%8C%96)。關於更加深刻的知識點,能夠仔細閱讀這篇文章,這裏對底層Synchronized實現作個總結:

  • 經過在方法中添加Synchronized關鍵字實現方法同步的,該方法在常量池結構中會標記上ACC_SYNCHRONIZED用於表示隱式同步,當方法調用時,調用指令將會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設置,若是設置了,執行線程將先持有monitor, 而後再執行方法,最後在方法完成(不管是正常完成仍是非正常完成)時釋放monitor。在方法執行期間,執行線程持有了monitor,其餘任何線程都沒法再得到同一個monitor。若是一個同步方法執行期間拋 出異常,而且在方法內部沒法處理此異常,那這個同步方法所持有的monitor將在異常拋到同步方法以外時自動釋放。
  • 經過Synchronized關鍵字修飾代碼塊的,在字節碼中會添加monitorenter 和 monitorexit 指令保證同步操做,其中其中monitorenter指令指向同步代碼塊的開始位置,而monitorexit表示同步代碼塊結束的位置(異常和非異常,因此字節碼中一個monitorenter都會對應兩個monitorexit)。每一個對象都存在着一個 monitor 與之關聯,對象與其 monitor 之間的關係有存在多種實現方式,當一個線程A持有了monitor後,會在對應對象的對象頭之間記錄持有的信息,其餘線程要獲取時候,則會阻塞。當monitorexit執行後,A解除持有monitor,其餘線程則繼續競爭鎖資源。

Lock和ReentrantLock

在Java5.0以前只有Synchronized和Volatile使用,在5.0以後增長了Lock接口,可以實現Synchronized的全部工做,而且除此以外擁有Synchronized不具備的以下特性:

  • 調用更靈活,須要主動申請/釋放鎖。
  • 提供中斷操做。(Synchronized是不響應中斷的)
  • 提供超時檢測操做。(Synchronized是不提供的)

總而言之,Lock接口比Synchronized更加靈活的控制空間,當Synchronized不能知足咱們的需求的時候,能夠嘗試的考慮使用該接口的實現類,最多見的實現類就是ReentrantLock了,下面就以ReentrantLock做爲Demo例子學習。這裏首先先介紹一下Lock接口:

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

lock()方法的使用跟Synchronized關鍵字一致,若是當前monitor沒有被佔用,則得到monitor,其餘線程會一直阻塞,直到調用lock()的線程調用unlock()方法,,示例代碼以下:

class ThreadTest {
    private static int num = 1;
    private Lock mLock = new ReentrantLock();

    public void increaseNum() {
        try {
            mLock.lock();
            num++;
            System.out.println(timeStamp2Date() + "  調用increaseNum,當前值爲:" + num);
            Thread.sleep(4000);
        } catch (Exception e) {

        } finally {
            mLock.unlock();
        }


    }

    public static String timeStamp2Date() {
        String format = "yyyy-MM-dd HH:mm:ss";
        SimpleDateFormat sdf = new SimpleDateFormat(format);
        return sdf.format(new Date(System.currentTimeMillis()));
    }


    public void increaseNum2() {
        try {
            mLock.lock();
            num++;
            System.out.println(timeStamp2Date() + "  調用increaseNum2,當前值爲:" + num);
        } catch (Exception e) {

        } finally {
            mLock.unlock();
        }
    }
}
//-------------------------------------------------------

fun main(args: Array<String>) {
    val threadTest = ThreadTest()
    val thread1 = Thread(Runnable {
        threadTest.increaseNum()
    })
    val thread2 = Thread(Runnable {
        threadTest.increaseNum2()
    })
    thread1.start()
    thread2.start()
}
//------------------------------------------------
//輸出結果
2018-11-19 16:06:10  調用increaseNum,當前值爲:2
2018-11-19 16:06:14  調用increseNum2,當前值爲:3

increaseNum()中模擬了4秒的耗時操做,能夠看到在結果中increaseNum2()確實等待了4秒左右的時間才進行了調用,調用的方式跟Synchronized一模一樣,只不過增長了手動釋放的代碼。

接下來看看tryLock方法:

  • tryLock()方法是有返回值的,它表示用來嘗試獲取鎖,若是獲取成功,則返回true,若是獲取失敗(即鎖已被其餘線程獲取),則返回false,也就說這個方法不管如何都會當即返回。在拿不到鎖時不會一直在那等待
  • tryLock(long time, TimeUnit unit)方法和tryLock()方法是相似的,只不過區別在於這個方法在拿不到鎖時會等待必定的時間,在時間期限以內若是還拿不到鎖,就返回false。若是一開始拿到鎖或者在等待期間內拿到了鎖,則返回true。

接下來仍是代碼測試,首先測試一下傳遞無參的:

public void increaseNum() {
        if (mLock.tryLock()) {
            try {
                num++;
                System.out.println(timeStamp2Date() + "  調用increaseNum,當前值爲:" + num);
                Thread.sleep(4000);
            } catch (Exception e) {

            } finally {
                mLock.unlock();
            }

        } else {
            System.out.println(timeStamp2Date() + "  increaseNum 獲取鎖失敗");
        }
    }

    public static String timeStamp2Date() {
        String format = "yyyy-MM-dd HH:mm:ss";
        SimpleDateFormat sdf = new SimpleDateFormat(format);
        return sdf.format(new Date(System.currentTimeMillis()));
    }


    public void increaseNum2() {
        if (mLock.tryLock()) {
            try {
                num++;
                System.out.println(timeStamp2Date() + "  調用increaseNum2,當前值爲:" + num);
            } catch (Exception e) {

            } finally {
                mLock.unlock();
            }
        } else {
            System.out.println(timeStamp2Date() + "  increaseNum2 獲取鎖失敗");
        }
    }
	
	------------------------------------------------
	fun main(args: Array<String>) {
    val threadTest = ThreadTest()
    val thread1 = Thread(Runnable {
        threadTest.increaseNum()
    })
    var thread2 = Thread(Runnable {
        threadTest.increaseNum2()
    })
    thread2.start()
    thread1.start()
    Thread.sleep(5000)
    thread2 = Thread(Runnable {
        threadTest.increaseNum2()
    })
    thread2.start()
}
//輸出結果
2018-11-19 16:37:09  increaseNum 獲取鎖失敗
2018-11-19 16:37:09  調用increaseNum2,當前值爲:2
2018-11-19 16:37:14  調用increaseNum2,當前值爲:3

接着測試有形參的:

public void increaseNum2(int time) {
        try {
            if (mLock.tryLock(time, TimeUnit.SECONDS)) {
                try {
                    num++;
                    System.out.println(timeStamp2Date() + "  調用increaseNum2,當前值爲:" + num);
                } catch (Exception e) {

                } finally {
                    mLock.unlock();
                }
            } else {
                System.out.println(timeStamp2Date() + "  increaseNum2 獲取鎖失敗");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
------------------------------------------------------
fun main(args: Array<String>) {
    val threadTest = ThreadTest()
    val thread1 = Thread(Runnable {
        threadTest.increaseNum()
    })
    var thread2 = Thread(Runnable {
        threadTest.increaseNum2(2)
        threadTest.increaseNum2(4)
    })
    thread1.start()
    thread2.start()
}
//輸出結果
2018-11-19 16:43:46  調用increaseNum,當前值爲:2
2018-11-19 16:43:48  increaseNum2 獲取鎖失敗
2018-11-19 16:43:50  調用increaseNum2,當前值爲:3

第一次調用increaseNum2()的時候因爲在2秒的時間內increaseNum()尚未釋放掉鎖,因此獲取鎖失敗;接着第二次調用increaseNum2()的時候,鎖已經釋放了,因此正常獲取到。

除此以外,經過調用 tryLock(long time, TimeUnit unit)方法,可以拋出InterruptedException異常,因此可以正常響應中斷操做,即thread.interrupt(),這是Synchronized沒法作到的。

與上面方法相同的是lockInterruptibly()也可以正常響應中斷操做,方法的描述以下(摘抄來自該篇文章):

  • 請求鎖,除非當前線程被中斷。
  • 若是沒有其餘線程持有鎖,則當前線程獲取到鎖,併爲鎖計數加1,而且當即返回。
  • 若是當前線程已經持有鎖,則爲鎖計數加1,並當即返回。
  • 若是其餘線程持有鎖,則當前線程將處於不可用狀態以達到於線程調度目的,而且休眠直到下面兩個事件中的一個發生:
    1. 當前線程獲取到鎖。
    2. 其餘線程中斷當前線程。
  • 若是當前線程獲取到鎖,則將鎖計數設置爲1。
  • 若是當前線程在方法條目上設置了中斷狀態或者在請求鎖的時候被中斷,將拋出中斷異常。

關於這個方法的用法和理解就比較複雜了,lockInterruptibly()自己拋出InterruptedException異常,能夠類比Thread.sleep()方法,這樣就比較好理解了。下面簡單給個Demo測試一下:

public void increaseNum3() {
        boolean flag = false;
        try {
            mLock.lockInterruptibly();
            flag = true;
        } catch (InterruptedException e) {
            System.out.println("中斷髮生");
        } finally {
            if (flag) {
                mLock.unlock();
            }
        }
    }
    public void increaseNum() {
        if (mLock.tryLock()) {
            try {
                num++;
                System.out.println(timeStamp2Date() + "  調用increaseNum,當前值爲:" + num);
                Thread.sleep(4000);
            } catch (Exception e) {

            } finally {
                mLock.unlock();
            }

        } else {
            System.out.println(timeStamp2Date() + "  increaseNum 獲取鎖失敗");
        }
    }
----------------------------------------------------------------
fun main(args: Array<String>) {
    val threadTest = ThreadTest()
    val thread2 = Thread(Runnable {
        threadTest.increaseNum()
    })
    thread2.start()
    val thread1 = Thread(Runnable {
        threadTest.increaseNum3()
    })
    thread1.start()
    Thread.sleep(2000)
    thread1.interrupt()
}
//結果
2018-11-19 17:25:39  調用increaseNum,當前值爲:2
中斷髮生

上述代碼thread2在increaseNum()方法中獲取到了mLock的鎖,因此在thread1調用increaseNum3()時候阻塞了,過了兩秒後因爲在主線程調用了thread1.interrupt(),因此increaseNum3()中拋出了異常,打印出了中斷髮生的log。這裏只是簡單驗證了一下一種狀況,更多種能夠自主測試一下。


wait/notify

最後一個就是wait/notify機制了,wai()方法介紹以下:

  • wait()方法的做用是將當前運行的線程掛起(即讓其進入阻塞狀態),直到notify或notifyAll方法來喚醒線程.
  • wait(long timeout),該方法與wait()方法相似,惟一的區別就是在指定時間內,若是沒有notify或notifAll方法的喚醒,也會自動喚醒。

wait方法是一個本地方法,其底層也是經過monitor對象來完成的,因此咱們使用wait/notify機制時候必須跟Synchronized一塊兒使用。除了這個,在線程的概念以及使用文章中還說過:

這裏須要區分sleep和wait的區別,wait和notify方法跟sychronized關鍵字一塊兒配套使用,wait()方法在進入等待狀態的時候,這個時候會讓度出cpu資源讓其餘線程使用,與sleep()不一樣的是,這個時候wait()方法是不佔有對應的鎖的。

在使用wait方法時候,最好使用以下模板:

synchronized (obj) {
     while (<condition does not hold>)
           obj.wait(timeout);
          ... // Perform action appropriate to condition
     }

關於wait/notify的例子,這裏就貼一個單生產者-單消費者模型的Demo吧:

private static final int MAX_NUM = 10;
    private static final Object lock = new Object();
    static ArrayList<String> list = new ArrayList<>();
    public static class ProductThread extends Thread {
        @Override
        public void run() {
            super.run();
            while (true) {
                synchronized (lock) {
                    while (list.size() > MAX_NUM) {

                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }

                    list.add("h");
                    System.out.println(getName() + ": 生產者生產一個元素");
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    lock.notify();
                }

            }


        }
    }

    public static class ConsumerThread extends Thread {
        @Override
        public void run() {
            super.run();
            while (true) {
                synchronized (lock) {
                    while (list.size() == 0) {

                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    list.remove(0);
                    System.out.println(getName() + ": 消費者消費一個元素");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    lock.notify();
                }

            }

        }
    }

參考資料

相關文章
相關標籤/搜索