Java併發編程-常見問題

1、常見問題

  從小的方面講, 併發編程最多見的問題就是可見性、原子性和有序性問題。html

  從大的方面講, 併發編程最多見的問題就是安全性問題、活躍性問題和性能問題。算法

  下面主要從微觀上分析問題。數據庫

2、可見性問題

  可見性:一個線程對共享變量的修改,另一個線程可以立馬看到,這個稱之爲可見性。知道了可見性那麼你就知道可見性問題了.編程

  可見性問題:一個線程對共享變量的修改,但另外一個線程感知不到其修改值的操做,讀取的仍是原來的值,這樣會引發數據紊亂。緩存

  場景案例分析:以咱們現實生活中爲例,好比電影院賣票系統,假設一個電影院的座位有10000張,此時有兩個影迷(同時)過來分別各買了5000張電影票,那麼它還剩多少餘票呢?下面咱們看下代碼實現:安全

public class VisibilityProblemTest {

    /**
     * 電影票總數
     */
    private int movieTicketAmount = 10000;

    /**
     * 售票
     */
    public void saleTicket(int n) {
        /**
         * 爲了讓問題可以明顯一點,使用減1的操做,重複n次
         */
        int i = 0;
        while (i++ < n) {
            movieTicketAmount -= 1;
        }
    }

    /**
     * 返回剩餘電影票
     * @return int
     */
    public int getMovieTicketAmount() {
        return movieTicketAmount;
    }

    public static void main(String[] args) throws InterruptedException {

        final VisibilityProblemTest ticket = new VisibilityProblemTest();

        // 假設如今有兩個用戶分別購買5000張電影票
        Thread user1 = new Thread(() -> ticket.saleTicket(5000));
        Thread user2 = new Thread(() -> ticket.saleTicket(5000));
        user1.start();
        user2.start();

        // 等待用戶購買完成
        user1.join();
        user2.join();

        // 售了10000張電影票後查驗餘數,理應還剩0張
        System.out.println(ticket.getMovieTicketAmount());
        Assert.assertEquals(ticket.getMovieTicketAmount(), 0);
    }
}

  你們應該都猜到了,最終的餘票不必定爲0,有可能會大於0。由於其存在數據可見性問題(其實還存在原子性問題,後續說)數據結構

  問題緣由:線程之間的共享變量存儲在主內存中,每一個線程都有一個私有的本地內存,本地內存存儲了該線程以讀/寫共享變量的副本(本地內存是JMM的抽象概念,並不真實存在)。併發

  解決方法:從上面已經知道致使可見性的問題是由於緩存緣由,那有什麼方法能夠禁用緩存呢。首先你得了解Java內存模型及其規範,而後瞭解volatile關鍵字的用法就能夠解決可見性的問題(由於上面案例還存在原子性問題,解決可見性問題後還不能使其結果變正確)性能

3、有序性問題

  有序性:程序按照代碼的前後順序執行,稱之爲有序性。優化

  有序性問題:沒有按照代碼的前後順序執行,致使很詭異的事情。

  場景案例分析:先看下面的簡單案例:

a = 1;
b = 2;

  上面代碼有可能執行的順序爲b = 2; a = 1;  這雖然不影響結果,但足以說明編譯器有時調整語句的順序。

  經典案例:利用雙重檢測機制建立單例對象。以下代碼,在getInstance()方法中,先判斷singleton實例是否爲空,若是爲空則鎖定Singletonl類,再次判斷singleton實例是否爲空,爲空則建立對象,最終返回實例。

public class Singleton {
    private static Singleton singleton;

    private Singleton() {}

    /**
     * 獲取實例對象
     * @return
     */
    public static Singleton getInstance() {
        // 第一重檢測
        if (null == singleton) {
       // 加鎖 
synchronized(Singleton.class) { // 第二重檢測 if (null == singleton) {
            // 問題根源 singleton
= new Singleton(); } } } return singleton; } }

  看上去沒啥問題, 那麼在併發的場景中呢? 假想下:假設有兩個線程同時過來獲取對象,一開始都經歷第一重檢測,檢測到爲空則開始對Singleton類加鎖,而JVM會保證只有一個線程獲取到鎖, 咱們假設A線程獲取到鎖,則另外一個線程(B線程)就會等待。A線程執行完後會建立singleton實例,釋放鎖後B線程成功獲取鎖,可是在第二重檢測上會檢測到singleton已經建立則直接返回了。 這樣假設看起來不會存在問題, 但這樣會出問題的。問題出在new 操做上,它其實能夠拆解成三步。

  • 1.先給對象分配內存空間
  • 2.在內存上初始化Singleton對象
  • 3.將實例指向剛分配的內存地址

  若是按照上面順序執行沒有任何問題, 可是編譯器會優化(重排序)指令,可能會獲得這樣的執行的順序:1 -> 3 -> 2;  那麼是這樣的執行順序會有致使什麼樣的結果呢?

  假設A線程先拿到鎖而後執行到 1 -> 3 這步後(實例已經分配地址,但尚未被初始化)發生線程切換,此時進來B線程進來在第一重檢測判斷時,判斷實例不爲空則執行返回了。而此時singleton實例對象是沒有分配內存,若是B線程拿次對象進行後續操做的話就會拋出空指針異常。

  問題緣由:由於編譯器/處理器會重排序執行指令(注意:不是全部指令都會重排),從而引起莫名奇妙的事情。

  解決方法:能夠採起某些手段禁止重排序便可。針對上面案例,能夠採用volatile關鍵字修飾singleton實例(插入內存屏障)。不懂的請多看下Java內存模型及其規範 和 volatile關鍵字

4、原子性問題

  原子性:一個或多個操做在CPU執行過程當中不被中斷的過程稱爲原子性。(與數據庫中的原子性仍是有區別的)。

  原子性問題:多個操做在執行過程當中被中斷(被其餘線程搶走資源),就會引起各類問題。好比第一個例子中就存在原子性問題,從而致使共享數據不許確。

  場景案例分析:在第一個案例中,使用volatile關鍵字修飾movieTicketAmount,解決下可見性問題,以下代碼:

public class AtomicProblemTest {

    /**
     * 電影票總數
     */
    private volatile int movieTicketAmount = 10000;

    /**
     * 售票
     */
    public void saleTicket(int n) {
        int i = 0;
        while (i++ < n) {
            movieTicketAmount -= 1;
        }
    }

    /**
     * 返回剩餘電影票
     * @return int
     */
    public int getMovieTicketAmount() {
        return movieTicketAmount;
    }

    public static void main(String[] args) throws InterruptedException {

        final AtomicProblemTest ticket = new AtomicProblemTest();

        // 假設如今有兩個用戶分別購買5000張電影票
        Thread user1 = new Thread(() -> ticket.saleTicket(5000));
        Thread user2 = new Thread(() -> ticket.saleTicket(5000));

        user1.start();
        user2.start();

        // 等待用戶購買完成
        user1.join();
        user2.join();

        // 售了1000張電影票後查驗餘數,理應還剩0張
        System.out.println(ticket.getMovieTicketAmount());
        Assert.assertEquals(ticket.getMovieTicketAmount(), 0);
    }
}

  那麼上面案例在哪存在問題呢?其實就在movieTicketAmount -= 1 這行代碼上,它實際上是一個複合操做需拆解成三個步驟進行加載:

  • 先會讀取變量的值加載至寄存器;
  • 進行-1操做
  • 而後將值加載至內存(volatile做用)

  因爲有volatile關鍵字修飾,就不須要考慮它會不會重排或者說對其餘線程可不可見了,這裏最主要的緣由是不能保證原子性。假想下:當變量值爲10000時,此時進來A線程且執行完第一步或者第二步的時候,須要讓出資源給B線程執行,當B線程執行完這個複合操做時movieTicketAmount=9999刷新內存值,而後A線程繼續執行(它以前讀取movieTicketAmount=10000)執行完複合操做的結果也是9999則會覆蓋以前內存的值。這樣則會與預期的結果9998不同就會形成數據紊亂了。

  解決方法:將多個操做變成原子性,比方說在saleTicket方法上加鎖。在此案例中還有另外的解決方法:將movieTicketAmount用原子性類修飾-> AmoticInteger。以下:

public class AtomicProblemTest {

    /**
     * 電影票總數,使用volatile修飾,以及使用原子性類
     */
    private volatile AtomicInteger movieTicketAmount = new AtomicInteger(10000);

    /**
     * 售票
     */
    public void saleTicket(int n) {
        int i = 0;
        while (i++ < n) {
            // 注意用法
            movieTicketAmount.getAndDecrement();
        }
    }

    /**
     * 返回剩餘電影票
     * @return int
     */
    public int getMovieTicketAmount() {
        return movieTicketAmount.get();
    }

    public static void main(String[] args) throws InterruptedException {

        final AtomicProblemTest ticket = new AtomicProblemTest();

        // 假設如今有兩個用戶分別購買5000張電影票
        Thread user1 = new Thread(() -> ticket.saleTicket(5000));
        Thread user2 = new Thread(() -> ticket.saleTicket(5000));

        user1.start();
        user2.start();

        // 等待用戶購買完成
        user1.join();
        user2.join();

        // 售了1000張電影票後查驗餘數,理應還剩0張
        System.out.println(ticket.getMovieTicketAmount());
        Assert.assertEquals(ticket.getMovieTicketAmount(), 0);
    }
}
    

5、從宏觀上分析問題

一、安全性問題

  類是否線程安全?是否按照指望的執行獲得正確的結果? 若是知足條件則確定是安全的。可是會存在什麼狀況致使它不是安全的呢?

  • 數據競爭。當多個線程訪問同一個數據而且至少有一個線程對這個數據進行寫操做的狀況,就會存在數據競爭。針對這種狀況若是不加以防禦,那麼就會致使併發的bug(經過上面微觀性方面分析應該知道會致使什麼樣的結果)
  • 競態條件。 程序執行結果依賴程序執行順序,因此這種狀況若是容許全部執行重排就會出現問題。另外特別要注意這種操做:「先檢查後執行」, 這種最容易出現競態條件。

  那麼怎麼解決呢?這兩種均可以採起簡單粗暴的方法:加鎖  

二、性能問題

  在某個場景使用某個類或者使用數據結構的時候須要考慮其性能問題,而衡量性能最重要的指標:吞吐量、延遲、併發量。

  • 吞吐量:指單位時間能處理的請求數量。也叫QPS, 吞吐量越大性能越好。
  • 延遲:指請求從發出到響應的時間。延遲越小性能越好。
  • 併發量:指同時能處理的併發請求。

  因此是全部狀況都須要加鎖嗎?顯然不是,須要具體問題具體分析而後採起具體解決方案。另外使用鎖時要當心,否則就會帶性能問題。

  那麼怎麼避免性能問題呢?

  • 儘可能使用無鎖的算法或數據結果替代。
  • 若是使用鎖,須要減小持有時間,不然會使其餘線程一直等待。注意死鎖的狀況哦

三、活躍性問題

  活躍性問題:指程序是否可否執行下去。那麼從上述分析就能夠看出,死鎖問題就會致使活躍性問題。

  另外除了死鎖,還存在「活鎖」和「飢餓」問題。

  • 活鎖:指線程雖然沒有受到阻塞,可是因爲某些條件沒有知足會致使一直重複嘗試—失敗—嘗試—失敗的過程。能夠採起嘗試指定時間自動取消嘗試。
  • 飢餓:指線程因沒法訪問所須要資源而沒法執行下去。解決此問題:保證資源充足、公平分配資源、避免長時間吃鎖

6、小結  

  併發編程真是個複雜的領域,因此遇到這塊時須要謹慎,多處分析問題,同時多注意上面兩個大方面分析的方面。趕上問題先把問題分析清楚,而後具體問題具體分析。

  上處若有錯誤之處,敬請指處。

  參考文獻:《Java併發編程的藝術》

相關文章
相關標籤/搜索