JAVA並發之多線程引發的問題剖析以及如何保證線程安全


JAVA多線程中的各種問題剖析

首先開始之前 需要提及一下前置章節

能夠更加深入了解本節所講

  1. JAVA並發之基礎概念篇
  2. JAVA並發之進程VS線程篇

首先我們來說一下並發的優點,根據優點特性,引出並發應當注意的安全問題

1並發的優點

技術在進步,CPU、內存、I/O 設備的性能也在不斷提高。但是,始終存在一個核心矛盾:CPU、內存、I/O 設備存在速度差異。CPU 遠快於內存,內存遠快於 I/O 設備。

根據木桶短板理論可知,一只木桶能裝多少水,取決於最短的那塊木板。程序整體性能取決於最慢的操作——I/O,即單方面提高 CPU 性能是無效的。

為了合理利用 CPU 的高性能,平衡這三者的速度差異,計算機體系機構、操作系統、編譯程序都做出了貢獻,主要體現為:

  • CPU 增加了緩存,以均衡與內存的速度差異;
  • 操作系統增加了進程、線程,以分時復用 CPU,進而均衡 CPU 與 I/O 設備的速度差異;
  • 編譯程序優化指令執行次序,使得緩存能夠得到更加合理地利用。

其中,進程、線程使得計算機、程序有了並發處理任務的能力,它有兩個重要優點

  • 提升資源利用率
  • 降低程序響應時間

1.1提升資源利用率

image.png

​ 從磁盤中讀取文件的時候,大部分的 CPU 時間用於等待磁盤去讀取數據。在這段時間里,CPU 非常的空閑。它可以做一些別的事情。通過改變操作的順序,就能夠更好的使用 CPU 資源 ,使用並發方式不一定就是磁盤IO,也可以是網絡IO和用戶輸入等,但是不管是哪種IO 都比CPU 和內存IO慢的多.線程並不能提高速度,而是在執行某個耗時的功能時,在還可以做其它的事。多線程使你的程序在處理文件時不必顯得已經卡死.

1.2降低程序響應時間

​ 為了使程序的響應時間變的更短,使用多線程應用程序也是常見的一種方式將一個單線程應用程序變成多線程應用程序的另一個常見的目的是實現一個響應更快的應用程序。設想一個服務器應用,它在某一個端口監聽進來的請求。當一個請求到來時,它去處理這個請求,然后再返回去監聽。

服務器的流程如下所述:

public class SingleThreadWebServer {
    public static void main(String[] args) throws IOException{
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            Socket connection = socket.accept();
            handleRequest(connection);
        }
    }
}

如果一個請求需要占用大量的時間來處理,在這段時間內新的客戶端就無法發送請求給服務端。只有服務器在監聽的時候,請求才能被接收。另一種設計是,監聽線程把請求傳遞給工作者線程(worker thread),然后立刻返回去監聽。而工作者線程則能夠處理這個請求並發送一個回復給客戶端。這種設計如下所述:

public class ThreadPerTaskWebServer {
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            final Socket connection = socket.accept();
            Runnable workerThread = new Runnable() {
                public void run() {
                    handleRequest(connection);        
                }    
            };    
         }  
     } 
}

這種方式,服務端線程迅速地返回去監聽。因此,更多的客戶端能夠發送請求給服務端。這個服務也變得響應更快。

桌面應用也是同樣如此。如果你點擊一個按鈕開始運行一個耗時的任務,這個線程既要執行任務又要更新窗口和按鈕,那么在任務執行的過程中,這個應用程序看起來好像沒有反應一樣。相反,任務可以傳遞給工作者線程(worker thread)。當工作者線程在繁忙地處理任務的時候,窗口線程可以自由地響應其他用戶的請求。當工作者線程完成任務的時候,它發送信號給窗口線程。窗口線程便可以更新應用程序窗口,並顯示任務的結果。對用戶而言,這種具有工作者線程設計的程序顯得響應速度更快。

2並發帶來的安全性問題

並發安全是指 保證程序在並發處理時的結果 符合預期
並發安全需要保證3個特性:

原子性:通俗講就是相關操作不會中途被其他線程干擾,一般通過同步機制(加鎖:sychronizedLock)實現。

有序性:保證線程內串行語義,避免指令重排等

可見性:一個線程修改了某個共享變量,其狀態能夠立即被其他線程知曉,通常被解釋為將線程本地狀態反映到主內存上,volatile 就是負責保證可見性的

Ps:對於volatile這個關鍵字,需要單獨寫一篇文章來講解,后續更新 請持續關注公眾號:JAVA寶典

2.1 原子性問題

​ 早期,CPU速度比IO操作快很多,一個程序在讀取文件時,可將自己標記為"休眠狀態"並讓出CPU的使用權,等待數據加載到內存后,操作系統會喚醒該進程,喚醒后就有機會重新獲得CPU使用權.
​ 這些操作會引發進程的切換,不同進程間是不共享內存空間的,所以進程要做任務切換就要切換內存映射地址.
而一個進程創建的所有線程,都是共享一個內存空間的,所以線程做任務切換成本就很低了
所以我們現在提到的任務切換都是指線程切換

高級語言里一條語句,往往需要多個 CPU 指令完成,如:

count += 1,至少需要三條 CPU 指令

  • 指令 1:首先,需要把變量 count 從內存加載到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中執行 +1 操作;
  • 指令 3:最后,將結果寫入內存(緩存機制導致可能寫入的是 CPU 緩存而不是內存)。

原子性問題出現:

​ 對於上面的三條指令來說,我們假設 count=0,如果線程 A 在指令 1 執行完后做線程切換,線程 A 和線程 B 按照下圖的序列執行,那么我們會發現兩個線程都執行了 count+=1 的操作,但是得到的結果不是我們期望的 2,而是 1。
image.png

我們把一個或者多個操作在 CPU 執行的過程中不被中斷的特性稱為原子性。CPU 能保證的原子操作是 CPU 指令級別的,而不是高級語言的操作符,這是違背我們直覺的地方。因此,很多時候我們需要在高級語言層面保證操作的原子性。

2.2有序性問題

​ 顧名思義,有序性指的是程序按照代碼的先后順序執行。編譯器為了優化性能,有時候會改變程序中語句的先后順序

舉個例子:

​ 雙重檢查創建單例對象,在獲取實例 getInstance() 的方法中,我們首先判斷 instance 是否為空,如果為空,則鎖定 Singleton.class 並再次檢查 instance 是否為空,如果還為空則創建 Singleton 的一個實例.

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

​ 線程A,B如果同時調用getInstance()方法獲取實例,他們會同時檢查到instance 為null ,這時會將Singleton.class進行加鎖操作,此時jvm保證只有一個鎖上鎖成功,另一個線程會等待狀態;假設線程A加鎖成功,這時線程A會new一個實例之后釋放鎖,線程B被喚醒,線程B會再次加鎖此時加鎖成功,線程B檢查實例是否為null,會發現已經被實例化,不會再創建另外一個實例.

這段代碼和邏輯看上去沒有問題,但實際上getInstance()方法還是有問題的,問題在new的操作上,我們認為的new操作應該是:

1.分配內存

2.在這塊內存上初始化Singleton對象

3.將內存地址給instance變量

但是實際jvm優化后的操作是這樣的:

1分配內存

2將地址給instance變量

3在內存上初始化Singleton對象

image.png

優化后會導致 我們這個時候另一個線程訪問 instance 的成員變量時獲取對象不為null 就結束實例化操作 返回instance 會觸發空指針異常。

2.3可見性問題

一個線程對共享變量的修改,另外一個線程能夠立刻看到,稱為 可見性

現代多核心CPU,每個核心都有自己的緩存,多個線程在不同的CPU核心上執行時,線程操作的是不同的CPU緩存,

image.png

線程不安全的示例

下面的代碼,每執行一次 add10K() 方法,都會循環 10000 次 count+=1 操作。在 calc() 方法中我們創建了兩個線程,每個線程調用一次 add10K() 方法,我們來想一想執行 calc() 方法得到的結果應該是多少呢?

class Test {
    private static long count = 0;
    private void add10K() {
        int idx = 0;
        while(idx++ < 10000) {
            count += 1;
        }
    }

    public static  long getCount(){
        return count;
    }
    public static void calc() throws InterruptedException {
        final Test test = new Test();
        // 創建兩個線程,執行 add() 操作
        Thread th1 = new Thread(()->{
            test.add10K();
        });
        Thread th2 = new Thread(()->{
            test.add10K();
        });
        // 啟動兩個線程
        th1.start();
        th2.start();
        // 等待兩個線程執行結束
        th1.join();
        th2.join();
    }

    public static void main(String[] args) throws InterruptedException {
        Test.calc();
        System.out.println(Test.getCount());
        //運行三次 分別輸出 11880 12884 14821
    }
}

​ 直覺告訴我們應該是 20000,因為在單線程里調用兩次 add10K() 方法,count 的值就是 20000,但實際上 calc() 的執行結果是個 10000 到 20000 之間的隨機數。為什么呢?

​ 我們假設線程 A 和線程 B 同時開始執行,那么第一次都會將 count=0 讀到各自的 CPU 緩存里,執行完 count+=1 之后,各自 CPU 緩存里的值都是 1,同時寫入內存后,我們會發現內存中是 1,而不是我們期望的 2。之后由於各自的 CPU 緩存里都有了 count 的值,兩個線程都是基於 CPU 緩存里的 count 值來計算,所以導致最終 count 的值都是小於 20000 的。這就是緩存的可見性問題。

​ 循環 10000 次 count+=1 操作如果改為循環 1 億次,你會發現效果更明顯,最終 count 的值接近 1 億,而不是 2 億。如果循環 10000 次,count 的值接近 20000,原因是兩個線程不是同時啟動的,有一個時差

3如何保證並發安全

了解保證並發安全的方法,首先要了解同步是什么:

同步是指在多線程並發訪問共享數據時,保證共享數據在同一時刻只被一個線程訪問

實現保證並發安全有下面3種方式:

1.阻塞同步(悲觀鎖):

阻塞同步也稱為互斥同步,是常見並發保證正確性的手段,臨界區(Critical Sections)、互斥量(Mutex)和信號量(Semaphore)都是主要的互斥實現方式

最典型的案例是使用 synchronizedLock

互斥同步最主要的問題是線程阻塞和喚醒所帶來的性能問題,互斥同步屬於一種悲觀的並發策略,總是認為只要不去做正確的同步措施,那就肯定會出現問題。無論共享數據是否真的會出現競爭,它都要進行加鎖(這里討論的是概念模型,實際上虛擬機會優化掉很大一部分不必要的加鎖)、用戶態核心態轉換、維護鎖計數器和檢查是否有被阻塞的線程需要喚醒等操作。

2.非阻塞同步(樂觀鎖)

基於沖突檢測的樂觀並發策略:先進行操作,如果沒有其它線程爭用共享數據,那操作就成功了,否則采取補償措施(不斷地重試,直到成功為止)。這種樂觀的並發策略的許多實現都不需要將線程阻塞,因此這種同步操作稱為非阻塞同步

樂觀鎖指令常見的有:

  • 測試並設置(Test-amd-Set)
  • 獲取並增加(Fetch-and-Increment)
  • 交換(Swap)
  • 比較並交換(CAS)
  • 加載鏈接、條件存儲(Load-linked / Store-Conditional)

Java 典型應用場景:J.U.C 包中的原子類(基於 Unsafe 類的 CAS (Compare and swap) 操作)

3.無同步

要保證線程安全,不一定非要進行同步。同步只是保證共享數據爭用時的正確性,如果一個方法本來就不涉及共享數據,那么自然無須同步。

Java 中的 無同步方案 有:

  • 可重入代碼 - 也叫純代碼。如果一個方法,它的 返回結果是可以預測的,即只要輸入了相同的數據,就能返回相同的結果,那它就滿足可重入性,程序可以在被打斷處繼續執行,且執行結果不受影響,當然也是線程安全的。
  • 線程本地存儲 - 使用 ThreadLocal 為共享變量在每個線程中都創建了一個本地副本,這個副本只能被當前線程訪問,其他線程無法訪問,那么自然是線程安全的。

4總結

​ 為了並發的優點 我們選擇了多線程,多線程並發給我們帶來了好處 也帶來了問題,處理這些安全性問題我們選擇加鎖讓共享數據同時只能進入一個線程來保證並發時數據安全,這時加鎖也為我們帶來了諸多問題 如:死鎖,活鎖,線程飢餓等問題

下一篇我們將剖析加鎖導致的活躍性問題 盡請期待

關注公眾號:java寶典
a


免責聲明!

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



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