天天看點

Java多線程程式設計(2)之線程同步

  • volatile

    先看個例子

    class Test {
    		// 定義一個全局變量
        private boolean isRun = true;
    
    	  // 從主線程調用發起
        public void process() {
            test();
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            stop();
        }
    		// 啟動一個子線程循環讀取isRun
        private void test() {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    while (isRun) {
    									// 疑問,如果我這裡有一些列印的語句或者線程睡眠的語句,子線程在
    									// 主線程将isRun改為false的時候,就會跳出死循環,反之,如果循環體
    									// 内是空的,就算在主線程改了isRun的值,也無法及時跳出循環,why?
    									// 當然,如果将isRun變量使用volatile修飾就沒有此問題
                    }
                }
            }).start();
        }
    
        private void stop() {
            isRun = false;
        }
    }
               

    有一點是一定的,就是子線程通路isRun的時候會拷貝一份放到自己的線程(工作記憶體)裡,這樣在讀寫的時候可能就不會和外面isRun的值實時是比對上的。是以就會出現意想不到的問題。

    是以我們使用volatile修飾,這樣當有多線程同時通路一個變量時,都會自動同步一下。顯然這樣會帶來一定的性能損失,但是如果确實需要還是要這麼做的。

    但是,有一個問題來了,使用volatile一定能就可解決多線程同步的問題了嗎?那我們看下面這個例子:

    class TestSynchronize {
    
    		// 使用volatile修飾的變量
        private volatile int x = 0;
    
        private void add() {
            x++;
        }
    
        public void test() {
    				// 啟動第一個線程,進行100萬次自加
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i=0; i< 1_000_000; i++) {
                        add();
                    }
                    System.out.println("第一個線程x=" + x);
                }
            }).start();
    				// 啟動第二個線程,進行100萬次自加
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i=0; i< 1_000_000; i++) {
                        add();
                    }
                    System.out.println("第二個線程x=" + x);
                }
            }).start();
        }
    }
               
    我們希望的結果是,最後一個執行完的線程應該是在2_000_000,但是隻要你實際測下就發現并不是這樣,因為volatile隻能保證可見性,但是隻要涉及多線程我們一定還聽說過原子性這個概念。什麼是可見性:
    可見性:對于多個線程都在通路的變量,當有個線程在修改的時候,它會保證會将修改的值更新到記憶體中,而不是隻在工作線程中修改,這樣當别的線程通路的時候也會去記憶體中取最新的值,這樣就能保證通路到的值是最新的。
    那什麼又是原子性呢:
    原子性:就是一個操作或者多個操作要麼都執行,要麼都不執行,不會存在執行一半會被打斷。
    在Java中,對基本資料類型變量的讀取和指派操作是原子性的。但是上述代碼中的

    x++;

    顯然不是原子操作,可以拆解為:
    int temp = x + 1;
    x = temp;
               

    那麼這就為多線程操作帶來不确定性,

    1、開始x初始值為0,

    2、當線程A調用add()函數時,執行到

    temp=x+1;

    這一行時被中斷了,

    3、此時切換到線程B的add()函數,線程B完整執行完兩行代碼後,x = 1了,

    4、這個時候線程B又完整的執行了一遍add方法,那麼x=2了,

    5、此時發生了線程切換,切換到A執行,A接着上次的執行的語句,temp = 1了,接下來執行

    x = temp;

    語句将1指派給了x。

    可是本來x都被B線程加到2了,這下又回去了,經曆A和B線程一共三次add()操作,結果x的值隻是1。

    這就解釋了上面那段代碼中,兩個線程分别加了100萬次後,結果最後一個執行完的線程列印的卻并不是200萬。原因就是add()裡面的操作并不是原子性的,而volatile隻能保證可見性,不能保證原子性

    當然,僅針對上面的按理我們可以将

    int x = 0;

    換一種類型聲明,比如使用

    AtomicInteger x = new AtomicInteger(0);

    然後将

    x++

    改成

    x.incrementAndGet();

    這樣也能保證原子性,確定多線程操作後資料是符合期望的。

    除了針對基本資料類型的,還有對引用操作原子化的,AtomicReference<V>

  • synchronized

    當synchronized修飾一個方法時,那麼同一時間隻有一個線程可以通路此方法,如果有多個方法都被synchronized修飾的話,當一個線程通路了其中一個方法,别的線程就無法通路其他被synchronized修飾的方法。

  • Java多線程程式設計(2)之線程同步

    相當于有一個螢幕,當一個線程通路某個方法,其他線程想通路别的方法時,需要和同一個螢幕做确認,這麼做看起來不太合理,其實也是合理的,比如有兩方法都可能對同一個變量做操作,兩個線程能同時通路兩個方法,這樣資料還是會發生錯亂。

    當然,我們就有兩個方法支援同步通路的場景的,隻要我們自己确認兩個方法不會存在資料上的錯亂,我們可以為每個方法指定自己的螢幕,在預設情況下是目前類的對象(this)。

  • Java多線程程式設計(2)之線程同步
    我們分别為

    setName();

    和其他兩個方法指定了不同的monitor(螢幕),這樣當線程A通路上面兩個方法的時候,線程B想通路方法setName也是不受影響的:
  • Java多線程程式設計(2)之線程同步
    接下來我們看我們經常寫的另一個例子,單例模式:
    class TestInstance {
        private TestInstance(){}
        
        private static TestInstance sInstance;
        
        public static TestInstance newInstance() {
    				**// ② 這裡判空的目的?**
            if (sInstance == null) {
    						**// ① 為什麼鎖加在這裡?**
                synchronized (TestInstance.class) {
    								**// ③ 這裡判空的目的?**
                    if (sInstance == null) {
                        sInstance = new TestInstance();
                    }
                }
            }
            return sInstance;
        }
    }
               

    我們來依次搞清楚上面的三個問題,

    ①鎖為什麼加在裡面而不是在方法上加鎖,因為加鎖後會帶來性能上的損失的,單例對象隻會建立一次,沒必要在執行個體已經有的時候擷取單例時還加鎖,對性能是浪費。

    ②第一個判空的目的就是在已經建立過執行個體之後的擷取操作,不用再經過synchronized判斷,這樣更快。

    ③最後一個判空就是防止多個線程都會調到建立執行個體的操作。

繼續閱讀