Java併發專題(二)執行緒安全

GrimMjx發表於2018-12-01

前言

  隨著時代的發展,CPU核數的增加和計算速度的提升,序列化的任務執行顯然是對資源的極大浪費,掌握多執行緒是每個程式設計師必須掌握的技巧。但是同時多執行緒也是一把雙刃劍,帶來了共享資源安全的隱患。在本節會介紹執行緒安全是什麼、最基本的獨佔悲觀式來保證執行緒安全的介紹。隨著章節步步深入。

 

1.1 什麼是執行緒安全?

1.1.1 初識執行緒安全的尷尬

  本人是17年畢業的,剛進第一家公司的時候沒有開發經驗,對接第三方支付公司外部API壓測的時候碰到一個問題:對方要求我5次/s,一共發300s的付款請求。其中一個請求的id,保證當日每一次唯一。我請求的id從1開始遞增,但是總是也達不到300*5=1500。也鬧出了很尷尬的笑話。這是我第一次接觸多執行緒。為了簡化問題,例子如下:2個執行緒對一個數字遞增加2000次,看看是否最後是2000。

/**
 * 多執行緒遞增某一個數字的測試類。
 *
 * @author GrimMjx
 */
public class UnsafeAdd {

    private int i;

    public int getNext() {
        return i++;
    }

    public static void main(String[] args) throws InterruptedException {
        UnsafeAdd multiAdd = new UnsafeAdd();

        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                multiAdd.getNext();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                multiAdd.getNext();
            }
        });

        thread1.start();
        thread2.start();

        //請結合上一章節體會為何寫下面2行
        thread1.join();
        thread2.join();

        System.out.println(multiAdd.i);
    }
}

  執行的結果99%都不是自己想要的結果,說明這邊出現了執行緒安全的問題。除了這個例子相信很多同學都會聽到類似這種話"HashMap不是執行緒安全的"、"不可變物件一定是執行緒安全的"等等。這些都是在說執行緒安全方面的話題,之後的原始碼分析專題會分析為什麼HashMap不是執行緒安全的,取而代之的ConcurrentHashMap如何保證執行緒安全的?同時JDK6引入ConsurrentSkipListMap和ConcurrentSkipListSet分別作為同步的SortedMap和SortedSet的併發替代品,還有用synchronizedxxx()方法包裝的Map。

1.1.2 執行緒安全的概念

  對於執行緒安全的概念,參考《Java Concurrency in Practice》中的一句對執行緒安全的定義:當多個執行緒訪問某個類時,這個類始終都能表現出正確的行為,那麼這個類就是執行緒安全的。

1.1.3 Race condition(競態條件)

  現在我們來分析一下上面的資料不一致問題,這種情況成為競態條件,為什麼會出現這個問題?UnsafeAdd的問題在於,執行緒的執行是由CPU時間片輪詢排程的,如果執行的時機不對,那麼可能在呼叫getNext()方法的時候得到一樣的值、或者某些值被忽略等。主要是i++;看起來是一個原子操作,但是它包含了3個獨立的操作:讀取i,將i+1,並將計算結果寫入i。簡單畫一張圖,如下:

  

  執行緒A和執行緒B可能都讀到i的變數為10,所以可能導致重複的情況,造成達不到2000的效果。

 

1.2 初識保證執行緒安全的基本方法

1.2.1 synchronized關鍵字

  什麼是synchronized?引入一段來自JDK官網對synchronized關鍵字比較權威的解釋:Synchronized keyword enable a simple strategy for preventing thread interference and memory consistency errors: if an object is visible to more than one thread, all reads or writes to that object's variables are done through synchronized methods. 如果一個物件對多執行緒是可見的,那麼對改物件的讀寫操作都將通過同步的方式進行。網上對他的講解千千萬,很多都是一樣的。接下來講一下我對他的具體表現:

  • synchronized關鍵字用到的是monitor enter和monitor exit兩個JVM指令(請用javap命令自行研究),且遵循happens-before規則。能保證在monitor enter,獲取到鎖之前必須從主記憶體獲取資料,而不是執行緒的本地記憶體。在monitor exit之後變數會重新整理到主記憶體。(這裡和上面的圖都涉及到JMM模型,這是併發的基礎,後面章節會詳細介紹
  • “synchronized是一把鎖”,這種理解是不嚴謹的。準確的來說是某執行緒獲取了物件的monitor鎖,在沒有釋放該鎖之前,其他執行緒在同一時刻無法獲取該鎖
  • synchronized可以用於對程式碼塊或者方法進行修飾,不能對變數進行修飾

  如果要解決之前的問題,那麼在getNext()方法上加上synchronized關鍵字就可以解決了問題。原因就是上面提到的,當某個執行緒獲取了monitor鎖,那麼其他執行緒是無法獲取鎖的。也就是說其他執行緒都無法執行該方法,直到其他執行緒放棄該鎖。每一個內建鎖都有且只能有一個相關聯的條件佇列(這裡的設計是否好呢?),當一個執行緒獲取鎖進行操作的時候,其他執行緒都在這個佇列裡等待該鎖。那麼解決掉問題也瞭解最基本的保證執行緒安全的方法之後,我們來看一下JDK對synchronized的優化以及synchronized的弊端。

1.2.2 synchronized的優化

  • 自旋鎖
    • 自旋鎖在JDK1.4引入,在JDK1.6預設開啟。自旋鎖到底是什麼呢?之前我們說的互斥鎖對效能的影響很大,Java執行緒是對映到作業系統的原生執行緒上的,如果要阻塞或者喚醒一個執行緒就需要作業系統的幫助,因此狀態轉換需要花費很多CPU時間。因為鎖定的狀態一般只會持續很短很短的時間,為了這段時間去掛起然後再喚醒是很不值得的。如果伺服器有多個處理器,我們就可以讓後面的執行緒稍微等等,但是並不放棄CPU執行時間,這個稍微等等的過程就是自旋。
    • 自旋鎖和阻塞鎖很大的區別就是是否要放棄CPU執行時間
  • 鎖消除
    • 鎖消除是JIT編譯器對鎖的具體實現所做的一種優化,如果同步塊所使用的鎖物件通過逃逸分析出只有一個執行緒會訪問,那麼JIT編譯器在編譯這個同步塊的時候會消除同步
  • 鎖粗化
    • 如果在一段程式碼中對一個物件反覆加鎖解鎖,那麼會放寬鎖的範圍,減少效能消耗。

 如以下程式碼:

for(int i=0;i<100000;i++){  
    synchronized(this){  
        do();  
} 

粗化成:

synchronized(this){  
    for(int i=0;i<100000;i++){  
        do();  
} 

1.2.3 synchronized的死穴:鎖是慢的

  雖然內建鎖優化至今已經和顯式鎖相差無幾,但是,它的死穴就是:鎖是慢的。讓我們來做一個實驗,一個單執行緒對一個數字相加1kw次,加鎖和不加鎖的時間的對比。

/**
 * 對比有無鎖的測試類。
 *
 * @author GrimMjx
 */
public class CompareLockTest {

    private int i = 0;

    private int y = 0;

    public void addWithNoLock() {
        i++;
    }

    public synchronized void addWithLock() {
        y++;
    }

    public static void main(String[] args) {
        // no lock
        CompareLockTest noLockTest = new CompareLockTest();
        StopWatch stopWatch = new StopWatch();

        stopWatch.start();
        for (int index = 0; index < 10000000; index++) {
            noLockTest.addWithNoLock();
        }
        stopWatch.stop();
        System.out.println("no lock: " + stopWatch.getTotalTimeMillis());


        // with lock
        stopWatch.start();
        for (int index = 0; index < 10000000; index++) {
            noLockTest.addWithLock();
        }
        stopWatch.stop();
        System.out.println("with lock: " + stopWatch.getTotalTimeMillis());
    }
}

  結果不加鎖的大概是7毫秒,加鎖大概是250毫秒。這還只是單執行緒,如果是多執行緒呢?併發很難而鎖的效能糟糕。執行緒就像是兩兄弟為一個玩具爭吵,作業系統就像是父母來決定他們誰拿玩具。

1.2.4 如何加鎖

  我們碰到最多的問題就是若沒有則新增,我們來看一個例子,先寫一個錯誤的加鎖方式,後寫一個正確的方式。

/**
 * list測試類。
 *
 * @author GrimMjx
 */
public class ListTest {

    public List<String> list = Collections.synchronizedList(new ArrayList<String>());

    /**
     * 非執行緒安全
     *
     * @param element
     * @return
     */
    public synchronized boolean unsafePutIfAbsent(String element) {
        boolean absent = !list.contains(element);
        if (absent) {
            list.add(element);
        }
        return absent;
    }

    /**
     * 執行緒安全
     *
     * @param element
     * @return
     */
    public boolean safePutIfAbsent(String element) {
        synchronized (list) {
            boolean absent = !list.contains(element);
            if (absent) {
                list.add(element);
            }
            return absent;
        }
    }
    
    // ...其他對list操作的方法
}

  第一個方法為何不是執行緒安全的?方法不是也已經用synchronized修飾了麼?這個list也是執行緒安全的。對不對?問題在於在錯誤的鎖上進行了同步,只是帶來了同步的假象,這就意味著該方法相對於List的其他操作來說並不是原子的。因此無法確保當方法執行的時候,另外一個執行緒不會修改連結串列。

  第二個方法是正確的執行緒安全的,最重要的是因為list在外部加鎖時要使用同一個鎖。對於使用list的程式碼,使用list本身用於保護其狀態的鎖來保護這段程式碼。說白了就是你要知道你獲取的什麼鎖,鎖的是什麼物件,這個是一定要搞清楚的。

 

1.3 死鎖

1.3.1 死鎖的介紹

  在多執行緒訪問共享資源的情況下,如果對執行緒駕馭不當很容易引起死鎖的情況發生。死鎖又分:交叉鎖、資料庫鎖等。比如說資料庫鎖,如果A執行緒執行了select xxx for update語句退出了事務,那麼別的執行緒訪問都將陷入死鎖。簡而言之,死鎖說白了就是“我在等你,你也在等我”。還是寫個例子吧。

/**
 * 死鎖測試類。
 *
 * @author GrimMjx
 */
public class DeadLockTest {

    public static void main(String[] args) {
        Object a = new Object();
        Object b = new Object();

        new Thread(()->{
            synchronized (a) {
                System.out.println("已經鎖住a了");
                synchronized (b){
                    System.out.println("同時鎖住a和b了");
                }
            }
        }).start();

        new Thread(()->{
            synchronized (b) {
                System.out.println("已經鎖住b了");
                synchronized (a){
                    System.out.println("同時鎖住a和b了");
                }
            }
        }).start();
    }
}

  如果A執行緒已經獲取a物件的鎖,現在想要獲取b物件的鎖。此時B執行緒已經獲取b物件的鎖,想要獲取a物件的鎖。那麼如果兩個執行緒都不釋放已經持有物件的鎖,大家都無法拿到第二個物件的鎖。如果程式出現死鎖,可以利用jstack等工具進行分析。

  

相關文章