Java學習之路 併發編程

考慮併發問題的前提:

  1. 多線程
  2. 存在可讀可變的共享變量
  3. 有多個線程讀取這些共享變量的需求

其核心問題在於共享變量狀態不一致,不一致的基本表現是信息的失效,根本原因是https://www.cnblogs.com/dolphin0520/p/3920373.html

比如我們要讓一個變量自增1.

緊湊寫法是 x++;

實際上這個自增語句被解釋爲

temp=x;               ①

temp=temp+1;     ②

x=temp;               ③

分析來說,①號語句的問題在於:這個x變量是否已經失效或即將失效?即將失效的原因很好理解:在②語句執行前可能有其他線程改變了x,已經失效該如何理解呢?就是說,①語句讀出的x甚至無法保證是上一個更新的值-----而可能是一個無效值,比如0或負無窮。失效尚且還存在一些可能性上的意義,而無效數據卻是完全沒有意義的。③語句存在的問題是,改變x的值後,其他線程可能已經在使用失效的數據(正如①語句分析)

這裏要引入一個概念:內存可見性

所謂內存可見性,即我們不僅希望防止某個線程正在使用對象狀態而另一個線程在同時修改該狀態, 而且希望確保一個線程修改了對象狀態後,其他線程能夠看到發生的狀態變化。

乍一看好像前後意思一樣,其實前後兩句完全是不同的情況,其原因在於我們所依賴的編譯器、處理器很有可能有自己的一套優化算法->這些算法提高了運行效率,但是不可避免地導致指令重排序,這種重排序不受我們控制,所以永遠不能相信一個讀數據的線程能正確獲得剛剛結束的寫線程更新的正確值(即使這個讀線程緊跟着寫線程執行,從直覺上來說,這本不可能存在問題),一種可能的情況是:出於運行時的某種全局的調度算法考量,處理器可能將寫線程的值暫時存在一個特殊的地方,不能被其他指令訪問到,又或者根本不執行寫線程-----單純地往後推移。

甚至還有這麼一種特殊情況:學過cpu的都知道,long和double類型往往是存放在高位和低位組合起來的一個數,那麼由於讀取高低位不是原子性的操作,就可能導致不一致。比如讀了高位,低位卻被修改了。

如果把線程的不安全性完全推給JAVA語言或者CPU算法的編寫者,未免有失偏頗-----重排序或者調度優化能幫助我們利用多處理器的強大性能,給予充分自由度的同時,安全問題隨之而來。

通過上述分析我們可以進一步抽象出同步問題的表現:依賴於訪問一個或多個共享變量來正確完成任務。

爲了保證線程間正確訪問共享變量,有以下解決方法:

  1. 使用java.util.concurrent.atomic包中封裝好的原子變量類,這種原子類保證對類實例的讀取、更改等操作是線程安全地。許多情況下,使用一些原子變量類來創建我們的共享變量是足夠的。然而,一旦共享變量間的關係並非完全獨立,問題就產生了。考慮下面這種情況
    private final AtomicVal serving_people;//服務的總顧客人數
    private final AtomicVal serving_fat;   //服務的胖子人數
    public void service(){
       if(serving_people.get() > 5){
            if(...)
            ...
       }
       if(serving_fat.get() > 2){
            serving_fat.decrese();    //1
            serving_people.decrese(); //2
       }
    }

    假設我們在開一家自助餐館,一旦肥胖的顧客多於2,我們就會請其中一位肥胖顧客離席。由於聲明時使用了原子變量類,這裏對肥胖顧客和總顧客單獨操作都是沒問題的,可惜其問題在於胖子人數是總顧客人數的子概念,他們之間存在聯繫--->請離胖子必然導致總顧客人數也要減少1。現在假設當前線程運行完語句1,還沒進入語句2,另一個線程卻已經在拿更新好的胖子人數和理應過時的總顧客人數去進行判斷,顯然不一致問題就出錯了。的確,語句1執行結束前別的線程都沒法訪問serving_fat,可是卻能先訪問serving_people,一旦語句1執行完還沒進入語句2,別的線程可不會傻傻地等當前線程語句②執行完畢。所以說原子變量類只能解決一些簡單情形下的同步問題,更實用的做法是使用同步鎖住複合操作代碼塊。

  2. 同步。使用同步的一個簡單出發點是原子性,我們希望編譯器和處理器不要亂動我們的執行順序,因此用synchronized(lock){}包住我們的代碼塊。這下,我們的代碼塊是不可分割的(也就具有了原子性)。爲了不讓別的線程提交,我們還要給代碼塊上鎖,也就是lock參數,一般都用類自身this作爲鎖,進入同步代碼塊時線程要獲取鎖,離開時釋放鎖。沒有鎖就只能被拒之門外等待。所以理所當然地,同一時間只能有一個線程進入同步代碼塊。而且注意,同一個類內線程進入某個函數獲取鎖後同樣會鎖住其他所有函數內的同步代碼塊,因爲----一個類只有一把鎖,而同步的本質在於保護類的共享變量而非僅僅一個代碼塊。
    對函數整體直接添加synchronized大部分情況下不好,因爲一般來說,有的耗時操作並不需要鎖住(比如根據函數的參數申請網絡資源、進行復雜計算),這時候在函數內部分段進行同步會更好,當然分段鎖會增加開銷,所以需要我們在安全性有保障的基礎上權衡,獲得一個良好的性能。
    前面說到線程去獲取鎖,如果線程在一個同步代碼塊內部又進入另一個代碼同步塊會發生什麼?答案是重入,也就是說,線程已經持有鎖了, 不需要再去獲得一把就能進入,但是JVM會記下鎖的持有者,並且把計數器加1.當線程一個個退出的時候,計數器依次減1,最後爲0時釋放鎖。比較常見的場景在於從子類同步代碼塊重入父類同步代碼塊,如果沒有重入機制,父類的鎖早就被線程申請佔有,卻無法獲得新的一把鎖,會形成死鎖。
    同步的引入給我們高併發編程帶來了安全,且並沒有什麼安全上的副作用(性能上當然有副作用),可惜,爲了使最終程序的安全性完備,我們必須全面地思考選擇用同步上鎖的代碼塊,這就顯得有些繁瑣。同步本身是好的,可是要想假設使用同步的情況下代碼運行完全正確,還是帶來了編程上的複雜度。
    同步顯然不僅僅是爲了提供互斥----還有內存可見性的保證,往往這一點容易被人忽略。

  3. volatile關鍵字修飾的變量。volatile是輕量級的同步變量,他的開銷很小,而且能保證內存可見性,也就是說,這種變量保證每次從內存讀取到的一定是最新有效的值,但是使用限制也很多,他不能保證互斥,所以多於一個線程進行寫操作就不適合volatile(讀是可以的)。而內存可見性加上互斥纔是完整的同步問題,所以一般只用來做某個操作完成、發生中斷或者狀態的標誌。如果非要用,其實原子變量更好。

現在我們應該知道安全優先級是這樣的:同步->原子變量->volatile變量。當然性能表現可能恰好相反。

使用共享數據就會導致線程不安全,能不能不共享數據?

  1. 只使用局部變量,這是Java語言提供的支持。
  2. 將對象封閉在一個線程中。Java核心庫提供了一些機制來幫助維持線程封閉性,如ThreadLocal類。

線程封閉就是這種概念:只在單線程內訪問變量,因此沒有同步問題

要想把對象封閉在線程內,我們首先要明確防止函數的返回值使這個對象逸出;比如直接返回對象或者返回能訪問該對象的其他對象。


ThreadLocal

一個不錯的講解

使用ThreadLocal的動機:

如connection、session這種性質的變量,我們不希望每次用的時候去創建一個connection,用完就close,這顯然太慢了。也不希望單單放一個全局的connection給不同線程調用,這可能導致混亂。最好的方法是各用各的----你有你的連接/會話,我有我的,互不影響。

ThreadLocal機制在一個Thread內存放一個對應變量,不同Thread之間自然不能互相訪問。

這種機制其實並沒有共享變量,而是每個線程都有一個獨立的變量,用起來像是共享的而已。

不變性

如果共享變量一經聲明就不會改變狀態,那麼就不需要擔心同步問題。

Final域

final關鍵字可以視爲C++中const的一種受限版本,final能確保初始化過程的安全性。因爲final只保證引用不變,可是引用指向的對象是可以改變的。爲了實現不變性,我們可以把引用的對象類中大部分變量聲明爲final,這同樣能簡化我們的狀態判斷,甚至全部變量聲明爲final,那麼這個final變量就是完全不變的----既改不了引用,也沒法通過引用改變類內部的狀態變量。

Volatile實現不變域

關於這個內容感覺書上說的太過特例了。正常的業務邏輯應該是不能用Volatile+不變域實現線程安全,因爲Volatile並沒法保證原子性。(還是用同步鎖比較合適,也好理解。)

將對象的引用和對象內部成員都聲明爲final的確很有效,但是不夠靈活,萬一我們需要對這個對象進行改變呢?

比如前文說過的開餐館,我們可以把serving_people和serving_fat放到一個serving_manager類裏面,並都聲明爲final,這時候我們想要更新服務人數的信息,除了重新創建一個引用進行替換別無他法,可是如果將引用聲明爲final,又沒法更新/改變這個引用,不聲明爲final又沒法保證安全。怎麼辦呢?.

我們記得volatile是保證內存可見性的-----在別的線程讀入前,保證寫操作已完成並正確寫入內存。這下好辦了,將serving_manager的引用聲明爲volatile,線程依然不能直接改變manager裏面的final變量,想要改變必須創建一個新的manager替換原來的引用,而volatile又保證寫操作可見。這下我們就解決了一致性問題。由於對manager的創建要同時對兩個final域初始化,我們不用擔心serving_people和serving_fat不一致,但是多線程寫操作的問題仍然存在-----也就是說,看到的依然可能是失效但狀態一致的引用。(那這種做法還有啥用呢,除非根本不關心信息是否失效,只關心信息邏輯是否一致)

總結來說就是,在一個volatile引用指向的類裏面聲明多個有邏輯關聯的final變量(或者乾脆就是你想安全訪問的變量),更改變量的方式只能通過創建一個新的類實例(引用)來代替原實例。

static關鍵字

除去對一個變量進行操作時產生的同步問題,變量的創建過程可能導致不安全發佈的問題。即其他線程拿到一個還沒初始化完畢的變量。

爲了安全地發佈,我們可以給初始化語句上鎖,或者該變量是不可變對象。不可變對象上面說過了,上鎖可能有開銷問題。除此以外,還可以把對象放到靜態域中:比如聲明爲static,或者在static函數內初始化。還可以把對象作爲一個volatile或者atomic類型或final對象的成員(就像上面實現不變域那樣)。

但是必須注意到,static只能保證安全發佈,並不能保證解決同步問題。

總結來說,要想確保一個對象單獨使用起來是完全線程安全的,只能通過final域修飾。其他方法或多或少都會有不一致的問題存在。有時候我們不需要太在乎一致性,因爲即使不一致也不會造成業務邏輯的錯誤。如果非要強一致性,只能將併發程序退化成單線程運行,這樣效率就不太好了。

對象的組合

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章