Java並發編程實戰(chapter_1)(原子性、可見性)


混混噩噩看了很多多線程的書籍,一直認為自己還不夠資格去閱讀這本書。有種要高登大堂的感覺,被各種網絡上、朋友、同事一頓外加一頓的宣傳與傳頌,多多少少再自我內心中產生了一種敬畏感。2月28好開始看了之后,發現,其實完全沒這個必要。除了翻譯的爛之外(一大段中文下來,有時候你就會罵娘:這tm想說的是個shen me gui),所有的,多線程所必須掌握的知識點,深入點,全部涵蓋其中,只能說,一書在手,萬線程不愁那種!當然,你必須要全部讀懂,並融匯貫通之后,才能有的效果。我推薦,看這本書的中文版本,不要和哪一段的冗長的字句在那過多的糾纏,盡量一段一段的讀,然后獲取這一段中最重要的那句話,否則你會陷入中文閱讀理解的怪圈,而懷疑你的高中語文老師是不是體育老師客串的!!我舉個例子:13頁第八段,我整段讀了三遍硬是沒想明白前面那么多的文字,是干什么用的,就是最后一句話才是核心:告訴你,線程安全性,最正規的定義應該是什么!(情允許我,向上交的幾個翻譯此書的,所謂的“教授”致敬,在你們的引領下,使我們的意志與忍受力更上了一個台階,人生更加完美!)

## 一、多線程開發所要平衡的幾個點

看了很多次的目錄,外加看了第一部分,發現,要想做好多線程的開發,無非就是平衡好以下的幾點

  • 安全性
  • 活躍性
    • 無限循環問題
    • 死鎖問題
    • 飢餓問題
    • 活鎖問題(這個還沒具體的了解到)
  • 性能要求
    • 吞吐量的問題
    • 可伸縮性的問題
## 二、多線程開發所要關注的開發點

要想平衡好以上幾點,書中循序漸進的將多線程開發最應該修煉的幾個點,娓娓道來:

  • 原子性
    • 先檢查后執行
    • 原子類
    • 加鎖機制
  • 可見性
    • 重排
    • 非64位寫入問題
    • 對象的發布
    • 對象的封閉
  • 不變性

在一本國人自己寫的,介紹線程工具api的書中,看到了這么一句話:外練原子,內練可見。感覺這幾點如果在多線程中尤為重要。我在有贊,去年還記得上線多門店的那天凌晨,最后項目啟動報一個類加載的錯誤,一堆人過來看問題,基德大神站在攀哥的后面,最后淡淡的說了句:已經很明顯是可見性問題了,加上volatile,不行的話,我把代碼吃了!!可以見得,多線程這幾個點,在“居家旅行”,生活工作中是多么的常見與重要!不出問題不要緊,只要一出,就會是頭痛的大問題,因為你根本不好排查根本原因在這。所以我們需要平時就練好功底,盡量避免多線程問題的出現!而不是一味的用框架啊用框架、摞代碼啊摞代碼!

## 三、原子性下面的安全問題
**1. 下面代碼有什么問題呢?**
public class UnsafeConuntingFactorizer implements Servlet{
    private long count = 0;
    private long getCount(){
        return count;
    }
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        ++count;
        encodeIntoResponse(resp,factor);
    }
}

思考:如何讓一個普普通通的類變得線程安全呢?一個類什么叫做有狀態,而什么又叫做無狀態呢?

**2. 上面代碼分析**
  • 一個請求的方法,實例都是一個,所以每次請求都會訪問同一個對象
  • 每個請求,使用一個線程,這就是典型的多線程模型
  • count是一個對象狀態屬性,被多個線程共享
  • ++count並非一次原子操作(分成:復制count->對復制體修改->使用復制體回寫count,三個步奏)
  • 多個線程有可能多次修改count值,而結果卻相同
**3. 使用原子類解決上面代碼問題**
public class UnsafeConuntingFactorizer implements Servlet{
    private final AtomicLong count = new AtomicLong(0);
    private long getCount(){
        return count.get();
    }
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count.incrementAndGet();//使用了新的原子類的原子方法
        encodeIntoResponse(resp,factor);
    }
}
**4. 原子類也不是萬能的**
//在復雜的場景下,使用多個原子類的對象
public class UnsafeConuntingFactorizer implements Servlet{
    private final AtomicReference<BigInteger> lastNumber 
        = new AtomicReference<BigInteger>();
    private final AtomicReference<BigInteger[]> lastFactors 
        = new AtomicReference<BigInteger[]>();


    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        if(i.equals(lastNumber.get())){//先判斷再處理,並沒有進行同步,not safe!
            encodeIntoResponse(resp,lastFactors.get());
        }else{
            BigInteger[] factors = factor(i);
            lastNumer.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp, factors);
        }
    }
}

思考:什么叫做復合型操作?

**5. 先列舉一個我們常見的復合型操作**
public class LazyInitRace {
    private ExpensiveObject instace = null;
    public ExpensiveObject getInstace(){
        if(instace == null){
            instace = new ExpensiveObject();
        }
        return instace;
    }
}

看好了,這就是我們深惡痛絕的一段代碼!如果這段代碼還分析不了的,對不起,出門左轉~

**6. 提高“先判斷再處理”的警覺性**
  • 如果沒有同步措施,直接對一個狀態進行判斷,然后設值的,都是不安全的
  • if操作和下面代碼快中的代碼,遠遠不是原子的
  • 如果if判斷完之后,接下來線程掛起,其他線程進入判斷流程,又是同樣的狀態,同樣進入if語句塊
  • 當然,只有一個線程執行的程序,請忽略(那還叫能用的程序嗎?)
**7. 性能的問題來了**
//在復雜的場景下,使用多個原子類的對象
public class UnsafeConuntingFactorizer implements Servlet{
    private final AtomicReference<BigInteger> lastNumber 
        = new AtomicReference<BigInteger>();
    private final AtomicReference<BigInteger[]> lastFactors 
        = new AtomicReference<BigInteger[]>();

    //這下子總算同步了!
    public synchronized void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        if(i.equals(lastNumber.get())){//先判斷再處理,並沒有進行同步,not safe!
            encodeIntoResponse(resp,lastFactors.get());
        }else{
            BigInteger[] factors = factor(i);
            lastNumer.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp, factors);
        }
    }
}

思考:有沒有種“關公揮大刀,一砍一大片”的感覺?

**8. 上訴代碼解析** - 加上了``synchronized``關鍵字的確解決了多線程訪問,類安全性問題 - 可是每次都是一個線程進行計算,所有請求變成了串行 - 請求量低於100/s其實都還能接受,可是再高的話,這就完全有問題的代碼了 - 性能問題,再網絡里面,是永痕的心病~
**9. 一段針對原子性、性能問題的解決方案**
//在復雜的場景下,使用多個原子類的對象
public class CacheFactorizer implements Servlet{
    private BigInteger lastNumber;
    private BigInteger[] lastFactors ;
    private long hits;
    private long cacheHits;

    public synchronized long getHits(){
        return hits;
    }
    public synchronized double getCacheHitRadio(){
        return (double) cacheHits / (double) hits;
    }

    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = null;
        synchronized (this){
            ++hits;
            if(i.equals(lastNumber)){
                ++cacheHits;
                factors = lastFactors.clone();
            }
        }
        if (factors == null){
            factors = factor(i);
            synchronized (this){
                lastNumer = i;
                lastFactors = factors.clone();
            }
        }
        encodeIntoResponse(resp, factors);
    }
}

在修改狀態值的時候,才進行加鎖,平時對狀態值的讀操作可以不用加鎖,當然,最耗時的計算過程,也是要同步的,這種情況下,才會進一步提高性能。

## 四、可見性下面的對象共享

可見性這個話題,在多線程的環境下,是相當棘手的。可能多年之后,你成為萬人心中的老鳥,也同樣會對這個問題,悵然若失!我自己總結了幾點,可見性問題的難處:

  • 道理簡單,真實場景代碼錯中復雜,你根本不知道是可見性導致的
  • 小概率事件,往往可能只有百分之一,甚至千分之一的出事概率,容易被我們“得過且過”
  • 容易直接扔到一個synchronized加鎖塊里面,進行“大刀”式的處理,而忽略了高效性
  • 可見性+原子性的綜合考慮

針對這些問題,我們只能先從基本功抓起,然后在日積月累的開發工作中,多多分析程序運行的場景,多多嘗試,才能大有裨益。

插曲:昨天看了《戀愛回旋》這部日本電影,其中有個場景讓我記憶深刻:女主是小時候被魔鬼母親常年訓練的乒乓球少年運動員,后來總總原因,放棄了乒乓球,當起了OL,這一別就是15年。當再次碰到男主的時候,男主向女主發起乒乓球挑戰,以為女主是個菜逼,然后賭一些必須要讓女主完成的事情。(女主本人也是覺得乒乓球對自己是一種心理的負擔,並且放棄這么久了,所以沒啥子自信)沒想到,女主一拿球拍,在接發球的那一剎那。。。。。大家應該都懂了。我當時就在影院中說出聲來:基本功太重要了。

**1. 可見性的發生的必要條件**

可見性,無非就是再多線程環境下,對共享變量的讀寫導致的。可能一個線程修改了共享變量的值,而另一個線程讀取的還是老的值,差不多就是這么大白話的解釋了下來。其中發生的必要條件有:

  • 多線程環境訪問同一個共享變量
  • 服務器模式下啟動程序
  • 共享變量並沒有做什么處理,代碼塊也沒有同步

當然,要分析為什么會有可見性的問題,要結合JVM虛擬機內存模型分析。以后會在《深入理解Java虛擬機》的學習中,做詳細的分析,敬請期待。

**2. 不多說上代碼**
public class NoVisibility{
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread{
        public void run(){
            while(!ready){
                Thread.yield();
            }
            System.out.println(number);
        }
    }

    public static void main(String[] args){
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

思考:上面的打印number的值,有可能會有幾種結果呢?什么情況下出現的這些個結果

**3. 上訴代碼分析**
  • number最終打印結果有可能出現42、0,或者根本就不會打印
  • 42:這種情況是運行正確的結果
  • 0:這種情況發生了指令重排(五星級的問題)
  • 不會打印:主線程對ReaderThread線程出現了共享變量不可見
**4. “愚鈍”的聊聊指令重排**

之所以說是“愚鈍”,原因是重排問題,是一個很底層很考驗計算機基礎能力的一個問題,小弟不才,當年分析計算機組成原理與指令結構的時候,枯燥極致,都睡過去了。現在回頭,才知道其重要性。現階段,對重排的分析,我只能舉例個簡單的例子,進行說明,更進一步的分析,同樣是要結合JVM的機制(六大Happens-before)來分析,以后再做進一步,詳盡的分析。下面就是那個簡單的例子:

//簡單例子
public class OrderConfuse{
    public static void main(String[] args){
        int a = 1;
        int b = 2;
        int c = a+b;
        int d = c+a;
        System.out.println(c);
        System.out.println(d);
    }
}
  • 上面程序是正確的,也能正確輸出
  • 對a和b的賦值操作,並非先賦值a再賦值b的
  • 原因是JVM底層會對指令進行優化,保證程序的快速執行,其實就是一種效率優化
  • 變量c會用到a和b變量,所以a和b的操作必須要發生在c之前(happens-before)
  • 有可能b進行了賦值,而a還是初始化的狀態,就是值為0

所以結合前面的代碼段:

public class NoVisibility{
    ......
    public static void main(String[] args){
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}
  • number和ready之后,並沒有使用它們的變量了
  • number和ready會被進行指令重排
  • 結果就是:ready已經賦值變成了true,可是number還是0

這就是為啥會為零的原因所在!

**5. 針對可見性,還是要上JVM的內存模型進行簡單分析**
  • 每個線程都會有自己的線程虛擬機棧
  • 棧上面存儲原始類型和對象類型的引用
  • 每次啟動一個線程,都會在對共享數據進行一次復制,復制到每個線程的虛擬機棧中
  • 上面的number是在主線程中,同時在ReaderThread線程的虛擬機棧中有一個副本
  • 各個虛擬機棧最終要進行同步,才能保持一致
  • 所以每次修改一個共享變量(原始類型)其實是在本地線程空間里面修改
  • number在主線程里面修改了,可是在ReaderThread線程里面並沒有修改,因為兩個線程訪問的空間並不一樣,一個線程對另一個線程空間並不可見。
**6. volatile關鍵字橫空出世**

volatile關鍵字的作用,主要有一下幾點:

  • 能把對變量的修改,馬上同步到主存中
  • 各個線程立馬更新自己線程棧中的變量值
  • 防止指令重排
  • 無法保證原子性

對於最底層如何做到這些個點的,具體還可以分析,例如什么內存屏障、狀態過期等等,完全可以聊一個專題,今天再次先不聊,同樣放到《深入理解JVM虛擬機》的學習中來詳盡分析。所以,上面程序可以改成下面這個樣子:

public class Visibility{
    private static volatile boolean ready;//注意這個類型
    private static volatile int number;//注意這個類型

    private static class ReaderThread extends Thread{
        public void run(){
            while(!ready){
                Thread.yield();
            }
            System.out.println(number);
        }
    }

    public static void main(String[] args){
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}
**7. synchronized關鍵字同樣可以保證可見性**
public class Visibility{
    private static boolean ready;
    private static int number;

    private static Object lock = new Object();

    private static class ReaderThread extends Thread{
        public void run(){
            while(!ready){
                Thread.yield();
            }
            System.out.println(number);
        }
    }

    public static void main(String[] args){
        new ReaderThread().start();
        synchronized(lock){//這里進行了加鎖
            number = 42;
            ready = true;
        }
    }
}
  • 加鎖可以同時保證可見性與原子性
  • 加鎖同樣可以防止指令重排,內部代碼都會照順序執行
**8. volatile不是萬能的**
public class VisibleNotAtomic{
    private static volatile int number = 1;

    private static class ReadThread extends Thread{
        public void run(){
            if(number == 2){
                System.out.println("correct!");
            }else{
                System.out.println("error!");
            }
        }
    }
    public static void main(String[] args){
        number++;
    }
}
  • number是對主線程和ReadThread線程都可見的
  • 可是number++不是原子操作
  • 加加到了一半,主線程掛起,ReadThread線程運行,number的值還是1,輸出error
## 五、第一部分總結

我們主要講了線程的原子性和可見性,結合代碼,不知不覺就講了一堆,而且感覺還可以在講~~多線程的話題真的是太恐怖了!未來的可預見性的規划如下:

  • 對象的安全發布
  • 對象的不變性
  • 對象的合理加鎖
  • 生產者消費者模型
  • 構建高效可伸縮的緩存

恩,敬請期待~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM