1. 程式人生 > >第六十六條 同步訪問共享的可變資料

第六十六條 同步訪問共享的可變資料

平時開發中,單執行緒的邏輯一般都好控制,但是併發情況下,多執行緒同時操作一個數據,可控性的難度就增加了,因為併發的情況不確定,並且除了問題也不好復現。因此,併發是一個重點,也是一個難點,併發中對資料的控制操作,就成為了併發的重點。同步關鍵字 synchronized ,可以保證同一時刻,只有一個執行緒可以執行某個方法。同步是為了執行緒安全,對此,需要滿足兩個特性,原子性和可見性。 同步的意思是,當一個物件被一個執行緒修改時,可以阻止另外一個執行緒觀察到物件內部不一致的情況,同時,如果有多個執行緒同時訪問它,它就會被鎖定,因此同步可以保證沒有任何方法可以獲取物件不一致的狀態。另外,java中的原子性,舉個例子,是指變數是 double 或 long,對於它們,即使是多執行緒操作,也能保證值不會出錯。

為了提高效能,在讀寫原子資料時,應避免同步。這個建議是很危險並且錯誤的。因為讀寫原子資料是原子操作,但不保證一個執行緒的寫入的值對另外一個執行緒一定是可見的,如果另外一個執行緒無法操作這個資料,那麼,就很容易出問題。執行緒與執行緒間的通訊,是建立在互斥和同步的基礎上。下面舉個例子,暫時跟著例子的思維走即可。(補充,這個例子在 jdk1.6 以下是正確的,但1.8版本及以上,還有在android手機上, 主執行緒是可以訪問子執行緒,子執行緒對主執行緒是可見的)。

如果需要停止一個執行緒,可以使用Thread.stop方法,但這個方法很久以前就不提倡使用了,因為不安全——使用它會使資料遭到破壞。因此,普遍做法是,讓一個執行緒輪詢一個boolean域,另一個執行緒設定這個boolean域即可:

    private boolean stopRequested;

    private void test() {
        try {
            Thread backgroundThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    int i = 0;
                    while (!stopRequested) {
                        i++;
                    }
                }
            });
            backgroundThread.start();
            TimeUnit.MILLISECONDS.sleep(1000);
            stopRequested = true;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

設計的思路很好,我們開了一個子執行緒,在子執行緒中開啟一個迴圈,然後讓 int 型別值 i不停的增長,我們期待一秒後,把 stopRequested 值變為true,然後 while() 條件不成立,終止迴圈,i 不再增加。這個設計的願望很好,但現實很骨感。因為這是兩個不同的執行緒,由於執行緒限制,導致外面的執行緒改變了 stopRequested 的值,但 backgroundThread 執行緒訪問不到 stopRequested 改變後的值,這就相當於把

    while (!stopRequested) {
            i++;
        }            
替換成了

    if(!stopRequested){
            while (true) {
                i++;
            }
        }

所以,問題就出現了,導致執行緒 backgroundThread 中的預想邏輯失效。那麼,我們知道問題的原因了,就可以對症下藥了。起因是執行緒間的互斥問題,就用到了開頭提到的同步鎖,synchronized 。之前是直接呼叫 stopRequested ,此時,我們把它的賦值和取值封裝成方法,同時使用 synchronized 修飾方法,這樣,各個執行緒就同步了。

    private void test1() {

        try {
            Thread backgroundThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    int i = 0;
                    while (!stopRequested()) {
                        i++;
                    }
                }
            });
            backgroundThread.start();
            TimeUnit.MILLISECONDS.sleep(1000);
            requestStop();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

    private synchronized void requestStop() {
        stopRequested = true;
    }

    private synchronized boolean stopRequested() {
        return stopRequested;
    }

賦值和取值的方法都被同步了,我們的功能也就實現了,這是通過同步鎖實現的,我們也可以用另一種方式,就是 volatile ,例如下面的寫法,沒有對 stopRequested 的set和get方法進行同步鎖

    private volatile boolean stopRequested;
    private void test2() {

        try {
            Thread backgroundThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    int i = 0;
                    while (!stopRequested) {
                        i++;
                    }
                }
            });
            backgroundThread.start();
            TimeUnit.MILLISECONDS.sleep(1000);
            stopRequested = true;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }

效果和上面一樣。但我們要注意, volatile 修飾也是有限制的,例如

    private volatile int nextSerialNumber = 0;

    public int generateSerialNumber() {
        return nextSerialNumber++;
    }

nextSerialNumber++,是兩步操作,先是對自身加1,然後再把值賦給自己,所以如果是併發情況呼叫 generateSerialNumber() ,還可能一個執行緒正在執行加1操作,還沒有賦值,另外一個已經把它值給取到,所以會造成執行緒安全問題。解決方法是,加入synchronized並去掉volatile。或者直接使用系統的 AtomicInteger 、 AtomicLong類,自帶執行緒安全。

    private final AtomicLong nextSerialNumber = new AtomicLong(0);

    public Long generateSerialNumber() {
        return nextSerialNumber.incrementAndGet();
    }

注意看上面括號中的話,jdk 1.8 上, 主執行緒是可以訪問子執行緒的,我特意列印了一下,估計是jvm或者最新版做了修正。有了解的大神請留言。

    private boolean stopRequested;

    private void test3() {
        Thread backgroundThread = new Thread(new Runnable() {
            @Override
            public void run() {
                int i = 0;
                while (!stopRequested) {
                    i++;
                    System.out.println(" test i: " + i);
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
        backgroundThread.start();
        try {
            TimeUnit.MILLISECONDS.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        stopRequested = true;

    }

因為每此睡眠10毫秒,所以100毫秒內應該執行10次左右,結果確實列印了1到10。