JAVA多線程·併發問題及解決思路

一、概述

1. 線程

線程允許在同一個進程中存在多個程序控制流。線程可以共享進程的資源,但是每個線程都有自己的程序計數器、棧和局部變量表。同一進程中的不同線程能夠訪問相同的變量,並且在同一個堆上分配對象。

2. 多線程

多線程的優勢/作用

  • 提高程序的運行性能。
  • 充分利用系統的處理能力,提高系統的資源利用率。
  • 提高系統響應性,即線程可以在運行現有任務的情況下立即開始處理新的任務。

多線程通信

多線程之間需要進行通信,線程的通信依賴共享內存和線程方法的調用來實現。Java內存模型分爲主內存和工作內存,通過內存之間的數據交換實現線程之間的通信;主動調用線程的wait()、notify()方法也可以實現線程之間的通信。

多線程引發的問題

多線程併發執行可能會導致一些問題:

安全性問題:在單線程系統上正常運行的代碼,在多線程環境中可能會出現意料之外的結果。

活躍性問題:不正確的加鎖、解鎖方式可能會導致死鎖or活鎖問題。

性能問題:多線程併發即多個線程切換運行,線程切換會有一定的消耗並且不正確的加鎖。

名詞概念:

  • 安全性:即正確性,指“程序得到正確的結果”。
  • 活躍性:指”正確的是最終會發生“。
  • 性能:即程序的服務時間、延遲時間(響應速度)、吞吐率、可伸縮性、容量、效率等。

多線程問題的深層原因

  1. 分時調度模型
  1. JAVA內存模型
  2. 指令重排

二、併發問題(安全性問題)

核心

要編寫線程安全的代碼,核心在於對狀態訪問操作進行管理。特別是對共享的和可變的狀態的訪問。

解決思路

當發生安全性問題時。有三種解決問題的角度:

  • 不在線程之間共享變量
  • 將狀態變量修改爲不可變
  • 訪問狀態變量時使用同步機制。

前兩種方式從根本上避免了多線程併發問題的原因:對共享和可變狀態的訪問。

1. 不在線程之間共享變量

即限制變量只能在單個線程中訪問。

實現方式:

  1. 線程封閉

    保證變量只能被一個線程可以訪問到。可以通過Executors.newSingleThreadExecutor()實現。

  2. 棧封閉

    棧封閉即使用局部變量。局部變量只會存在於本地方法棧中,不能被其他線程訪問,因此也就不會出現併發問題。所以如果可以使用局部變量就優先使用局部變量。

  3. ThreadLocal封閉

    ThreadLocal是Java提供的實現線程封閉的一種方式,ThreadLocal內部維護了一個Map,Map的key是各個線程,而Map的值就是要封閉的對象。每個線程中的對象都對應着Map中一個值,也就是ThreadLocal利用Map實現了對象的線程封閉。

2. 將狀態變量修改爲不可變

即使用不可變對象。

不可變對象:當一個對象構造完成後,其狀態就不再變化,我們稱這樣的對象爲不可變對象(Immutable Object),這些對象關聯的類爲不可變類(Immutable Class)。

比如Java中的String、Integer、Double、Long等所有原生類型的包裝器類型,都是不可變的。

大多數時候,線程間是通過使用共享資源實現通信的。如果該共享資源誕生之後就完全不再變更(猶如一個常量),多線程間共同併發讀取該共享資源是不會產生線程衝突的,因爲所有線程無論何時讀取該共享資源,總是能獲取到一致的、完整的資源狀態,這樣也能規避多線程衝突。不可變對象就是這樣一種誕生之後就完全不再變更的對象,該類對象天生支持在多線程間共享。

3. 使用同步機制

關注一個併發問題,有3個基本的關注點:

  • 原子性,即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。
  • 可見性,當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值。
  • 有序性,有序性指的是數據不相關的變量在併發的情況下,實際執行的結果和單線程的執行結果是一樣的,不會因爲重排序的問題導致結果不可預知。

所有的併發問題的都可以從這三個點進行分析並針對性的進行解決。

a. 可見性問題

概念:

可見性指的是一個線程對變量的寫操作對其他線程後續的讀操作可見

問題分析:

由於Java的內存模型,內存分爲主內存和線程內存,線程讀寫變量時都需要先講主內存的變量拷貝到線程內存中,讀寫操作都在線程內存中進行。可能一個線程寫入了變量的值,但還沒有同步到主內存中,這時另外一個線程讀取變量值就會讀到舊的值,即發生了可見性問題。

解決方式:voliate關鍵字/synchronized關鍵字

原理::每次修改變量後,立即將變量寫會主內存;每次使用變量時,必須從主內存中同步變量的值。

b.原子性問題

概念:

原子性是指某個(些)操作在語意上是原子的。

問題分析:

由於線程的調度模型,每個線程會被分配一定的cpu時間,執行時間結束後切換到下一個線程執行。可能存在一個線程訪問並修改變量,在整個操作完成之前被切出執行,切換到另外一個線程執行,該線程對變量也進行的操作,之後再切回原來線程繼續執行時,變量的值可能已經被修改,無法得到正確的結果。因此需要通過某些方式保證操作的原子性。

原子性變量操作:

根據Java內存模型保證的原子性變量操作包括read load use assign store write,基礎數據類型的訪問、讀取、寫是原子性的(注意long,double),另外還有lock和unlock操作,反映到java代碼中即synchronized操作也是原子性的

解決方式:synchronized關鍵字/其他互斥鎖

原理:保證同一時間只能有一個線程訪問加鎖區域中的代碼,即保證了原子性。

擴展

競態條件:指某個操作由於不同的執行時序而出現不同的結果。專門用來描述原子性問題。

c. 有序性問題

有序性的語意有三層

  1. 保證多線程執行的串行順序
  2. 防止重排序引起的問題
  3. 程序執行的先後順序,比如JMM定義的一些Happens-before規則

解決方式:volatile, final, synchronized,顯式鎖都可以保證有序性。

三、活躍性問題

活躍性問題包括但不限於死鎖、活鎖、飢餓等。

死鎖:死鎖發生在一個線程需要獲取多個資源的時候,這時由於兩個線程互相等待對方的資源而被阻塞,死鎖是最常見的活躍性問題。

活鎖:活鎖指的是線程不斷重複執行相同的操作,但每次操作的結果都是失敗的。儘管這個問題不會阻塞線程,但是程序也無法繼續執行。

飢餓:飢餓指的線程無法訪問到它需要的資源而不能繼續執行時,引發飢餓最常見資源就是CPU時鐘週期。

四、性能問題

1. 性能

概述

性能包括很多方面:服務時間、延遲時間(響應速度)、吞吐率、可伸縮性、容量、效率等。

分類

可以分爲兩大類:

運行速度:服務時間、等待時間。即對於某個指定的任務單元,“多快”才能處理完成。(一般對於單線程)

處理能力:可伸縮性、吞吐量、生產量。即對於計算資源一定的情況下,可以完成“多少”工作。(一般針對於多線程)

性能調優

在進行性能調優時需要明確優化指標、運行環境、測試or驗證方式、優化的代價和影響等多方面因素。

需要考慮多種修改可能造成的影響:安全性、可讀性、可維護性、資源消耗、其他風險等。

針對線程併發進行設計和優化時採用的方法和傳統的性能調優方法不同。

對於傳統的性能調優:已更少的代價完成相同的工作,比如緩存、替換使用低複雜度算法等。

對於併發的性能調優:將問題的計算並行化、從而利用更多的計算資源。

2. 針對併發程序的性能調優

多線程的最主要目的是提高程序的運行性能。使程序充分利用系統的處理能力,提高系統的資源利用率。

在討論併發程序的性能時,一般關注它的可伸縮性。

可伸縮性:當增加計算資源時(CPU、內存、存儲容量或I/O帶寬),程序的吞吐量或處理能力能夠相應的增加。

想要通過線程併發獲得更好的伸縮性有兩個關鍵點:

  1. 有效利用現有的處理資源
  2. 在出現新的系統資源時使程序儘可能利用這些新資源

系統資源:CPU時鐘週期、內存、網絡帶寬、I/O帶寬、數據庫請求、磁盤空間等。

可伸縮性優化的注意點:

  • 併發中的串行操作
  • 線程的額外開銷,包括上下文切換、內存同步、阻塞等
  • 獨佔方式的資源鎖,當鎖的請求頻率*持有時間越大時表明鎖的競爭越激烈。可以通過縮小鎖的範圍(持有時間)、鎖分解(多加鎖、但是會增加死鎖的概率)、鎖分段、避免熱點域、減少使用獨佔鎖等方式來減少鎖的競爭。

五、併發程序的設計

1. 執行策略

執行策略:

  • 在什麼線程中執行任務
  • 以什麼順序執行這些策略,FIFO、LIFO、優先級
  • 有多少個任務可以併發執行
  • 有多少個任務可以等待執行
  • 如果系統因爲過載需要拋棄一個任務,應該選擇哪一個任務來拋棄?進一步,如何通知應用程序任務被拋棄?

執行策略的目的:

更高效地利用系統資源,提高服務質量。避免因爲併發影響了性能。

執行策略的優勢:

將任務的提交過程和執行過程解耦。

2. 線程管理

ThreadExecutor線程池 - 重用已有線程

  • 減少系統開銷,減少線程創建和銷燬時的巨大開銷
  • 提高響應性,避免由於創建線程導致延遲任務執行的時機
  • 充分利用系統資源,創建足夠多的線程使處理器保持忙碌狀態,同時防止多個線程競爭資源導致應用程序耗盡內存或者失敗

線程池注意點:

  • 任務和執行策略之間的耦合性和相關性
  • 線程池大小
  • 配置ThreadPoolExecutor:線程的創建和銷燬、管理任務隊列、飽和策略、線程工廠
  • 其他擴展:日誌、計時、監視、統計信息

3. 中斷、取消和關閉

線程的取消和關閉,可以分爲四個維度:任務、線程、服務、應用程序

針對任務,需要明確取消策略:

取消策略(how、when、what)

  • 其他代碼如何請求取消該任務
  • 任務在何時取消檢查已經請求了取消
  • 響應取消請求時需要應該進行哪些操作

六、擴展

1. java內存模型

java內存模型主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。

Java內存模型分爲主內存和工作內存。駐內存所有線程共享,工作內存每個線程單獨擁有,不共享。

線程工作內存中保存着該線程所用到的變量的主內存副本拷貝。

線程對於變量的所有操作,都必須在工作內存中進行,不能直接讀寫主內存中的變量。

線程無法獲取其他線程工作內存中的變量,線程間變量值的傳遞必須通過主內存完成

  • lock(鎖定):作用於主內存,它把一個變量標記爲一條線程獨佔狀態;
  • unlock(解鎖):作用於主內存,它將一個處於鎖定狀態的變量釋放出來,釋放後的變量才能夠被其他線程鎖定;
  • read(讀取):作用於主內存變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用
  • load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
  • use(使用):作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎,每當虛擬機遇到一個需要使用變量的值的字節碼指令時將會執行這個操作。
  • assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦值給工作內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操作。
  • store(存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨後的write的操作。
  • write(寫入):作用於主內存的變量,它把store操作從工作內存中一個變量的值傳送到主內存的變量中

 

2. 指令重排

目標:提高運行速度。

起因:只要程序的最終結果與嚴格串行環境中執行的結果相同,那麼所有操作都是允許的。

重排序的問題是一個單獨的主題,常見的重排序有3個層面:

  1. 編譯級別的重排序,比如編譯器的優化
  2. 指令級重排序,比如CPU指令執行的重排序
  3. 內存系統的重排序,比如緩存和讀寫緩衝區導致的重排序

 

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