併發編程和線程安全

 

JAVA內存模型是一種規範,其規定了一個線程如何和何時可見由其他線程修改過後的共享變量的值,以及在必須時如何同步的訪問共享變量,他要求調用棧和本地變量放在線程棧上,對象存放在堆上。線程之間的通信必須經過主內存.目的是解決由於多線程通過共享內存進行通信時,存在的本地內存數據不一致、編譯器會對代碼指令重排序、處理器會對代碼亂序執行等帶來的問題。

首先介紹幾個比較重要的概念:

1 as-if-serial 語義:不管怎麼重排序(編譯器和處理器爲了提高並行度),單線程程序的執行結果都不能被改變。這是保證程序的執行順序的看起來像是代碼的編碼順序一樣的一個核心保證,任何的編譯器,runtime和處理器都必須遵守這個語義。

2 happened-before原則

 happened-before JMM的最核心概念,對於java程序員來說,理解happened-before原則是理解JMM的關鍵

  1. 程序次序原則:一個線程內,按照代碼順序,書寫在前面的操作,先行發生於書寫在後後面的操作。(只是看起來而已)
  2. 鎖定規則:一個unlock操作先行發生於對同一個鎖的lock操作
  3. Volatile變量規則:對一個變量的寫操作先行發生於對後面對這個變量的讀操作
  4. 傳遞規則:如果操作A先行發生於操作B,操作B先行發生與操作C,那可以推導出來操作A先行發生於操作C

 

JAVA內存模型-同步的八種操作

(1)lock(鎖定):作用於主內存的變量,把一個變量標記爲一條線程獨佔狀態

(2)unlock(解鎖):作用於主內存的變量,把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定

(3)read(讀取):作用於主內存的變量,把一個變量值從主內存傳輸到線程的工作內存中,以便隨後的load動作使用

(4)load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中

(5)use(使用):作用於工作內存的變量,把工作內存中的一個變量值傳遞給執行引擎

(6)assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收到的值賦給工作內存的變量

(7)store(存儲):作用於工作內存的變量,把工作內存中的一個變量的值傳送到主內存中,以便隨後的write的操作

(8)write(寫入):作用於工作內存的變量,它把store操作從工作內存中的一個變量的值傳送到主內存的變量中

 

如果要把一個變量從主內存中複製到工作內存中,就需要按順序地執行readload操作,如果把變量從工作內存中同步到主內存中,就需要按順序地執行storewrite操作。但Java內存模型只要求上述操作必須按順序執行,而沒有保證必須是連續執行。

 

同步規則分析:

1)不允許一個線程無原因地(沒有發生過任何assign操作)把數據從工作內存同步會主內存中

2)一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load或者assign)的變量。即就是對一個變量實施use和store操作之前,必須先自行assign和load操作。

3)一個變量在同一時刻只允許一條線程對其進行lock操作,但lock操作可以被同一線程重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變量纔會被解鎖。lock和unlock必須成對出現。

4)如果對一個變量執行lock操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量之前需要重新執行load或assign操作初始化變量的值。

5)如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他線程鎖定的變量。

6)對一個變量執行unlock操作之前,必須先把此變量同步到主內存中(執行store和write操作)

 

 

併發編程的問題

1原子性問題

2可見性問題

3有序性問題。

爲了解決上述的三個問題,Java提供了一系列和併發處理相關的關鍵字,比如volatilesynchronizedfinalconcurren包等

可見性(Volatile,Synchronized,Lock)

有序性(Volatile,Synchronized, Lock)

原子性(Synchronized, Lock,actomic包)

volatile關鍵字的兩層內存語義

  1)保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。

2)禁止進行指令重排序。

JVM通過在對volatile變量的讀寫前後都會插入內存屏障來實現有序性和可見性。

JMM對於volatile變量採用保守策略:

1 在每個volatile寫操作的前面插入一個 StoreStore屏障

2 在每個volatile寫操作的後面插入一個 StoreLoad屏障

3 在每個volatile讀操作的後面插入一個 LoadLoad屏障

4 在每個volatile 讀操作的後面插入一個LoadStore屏障

 

java內存屏障

  • java的內存屏障通常所謂的四種即LoadLoad,StoreStore,LoadStore,StoreLoad實際上也是上述兩種的組合,完成一系列的屏障和數據同步功能。
  • LoadLoad屏障:對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的數據被訪問前,保證Load1要讀取的數據被讀取完畢。
  • StoreStore屏障:對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。
  • LoadStore屏障:對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被刷出前,保證Load1要讀取的數據被讀取完畢。
  • StoreLoad屏障:對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的。在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種內存屏障的功能

Volatile變量僅僅保證了可見性與有序性,所有在併發編程中使用volatile並不是絕對安全的。(部分代碼演示,以及簡單的使用場景)

Lock的java內存語義

1 當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效,從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量

2 當線程釋放鎖時,JMM會把該線程對應的本地內存的共享變量刷新到主內存中。

通過對比可以發現,獲取鎖與volatile讀具有相同的語義。釋放鎖與volatile寫具有相同的語義,這樣Lock就可以保證了可見性與有序性。而java中的鎖(以ReentrantLock爲例),都藉助了CAS原理來保證了原子性。

 

 
Synchronized的內存語義

原子性

synchronized 修飾的代碼在同一時間只能被一個線程訪問,在鎖未釋放之前,無法被其他線程訪問到。因此,在 Java 中可以使用 synchronized 來保證方法和代碼塊內的操作是原子性的。

可見性

對一個變量解鎖之前,必須先把此變量同步回主存中。這樣解鎖後,後續線程就可以訪問到被修改後的值。

有序性

synchronized 本身是無法禁止指令重排和處理器優化的,但是由於synchronized的鎖機制,導致同一時間只能被同一線程執行,加上提到的as-if-serial 語義。所以,可以保證其有序性。

使用場景

1 修飾代碼塊:大括號括起來的代碼,作用於調用的對象

2 修飾方法:整個方法,作用於調用的對象

3 修飾靜態方法: 整個靜態方法,作用於所有對象

4 修飾類: 括號括起來的部分,作用於所有對象

 

JAVA中,synchronized 關鍵字,Lockvolatile關鍵字都有實現線程安全的作用,

但是從性能上來說,volatile要優於Lock synchronized,但是volatile如果使用不當,也會出現線程不安全的情況,synchronized的使用相對於Lock要簡單,在併發度相對低的情況,synchronized性能要高於Lock,但是在併發量大的情況下,性能上要差一些,Lock還提供了一些synchronized無法滿足的特性,1嘗試非阻塞的獲取鎖,2能被中斷的獲取鎖 3 超時獲取鎖

 

 

 

先來介紹一下原子變量類

Java中提供的原子變量類主要在JUCactomic包中。提供了ActomicXXX等多個類來保證線程安全性。

主要介紹一下ActomicLongLongAddr(部分代碼演示) 以及使用ActomicStampReference來解決CAS中遇到的ABA問題。

 

接下來介紹一下如何的安全發佈對象(結合N多種單例模式進行講解)

1 在靜態初始化函數中初始化一個對象應用

2 將對象的應用保存到volatile類型域或者ActomicReference對象中

3 將對象的引用保存到某個正確構造對象的final類型域中

4 將對象的應用保存到一個由鎖保護的域中

線程安全的策略:

不可變對象

 使用final關鍵字

1 將類聲明爲final

2 將所有的成員聲明爲私有的,private修飾

3 對變量不提供set方法

4 將所有可變的成員聲明爲final

5 通過構造器初始化所有成員進行深度拷貝

6 get方法中不直接返回對象本身,而是拷貝對象,返回對象的拷貝

Collections.unmodifiableXXX: Collection List ,Mpa Set …

GuavaImmutableXXX

 

線程封閉

1 局部變量

2使用ThreadLocal(具體案例可以參考brain或者是web項目中的一些關於用戶狀態的一些標識)

 

同步容器

1 ArrayList -> Vector(如果使用不當,容易出錯,多線程情況下出錯概率更高),Stack

2 HashMap -> HashTable(K,V不能爲空)

3 Collections.synchronizedXXX(List,Set,Map)

由於同步容易採用的是synchronized 關鍵字做的線程安全處理,所以在性能上會有一定的損失,在高併發的場景下,同步容器並不推薦使用。

 

併發容器(J.U.C

CopyOnWriteArrayXXX(適合讀多寫少的情景 1 讀寫分離 2 最終一致性) 複製出一個副本,然後進行update操作,完成之後,把變量指向新的對象。(對CopyOnWriteArrayList的讀操作是不加鎖的,寫操作是加鎖的,)
  1. 因爲需要做副本拷貝,所以會消耗內存空間,如果對象過大,容易造成頻繁的GC
  2. 因爲讀操作是在原對象上進行的,所以具有一定的延時性,只能保證最終一致性。
ConcurrentHashMap(這裏不做過多介紹,網上資料多的很,非常詳細)

 

 

ConcurrentSkipListMap(TreeMap的併發容器)1 K有序2 併發量更高,存取時間是和線程數沒有關係(在高併發場景下有非常好的表現)

 

 

AQS

CountDownLatch 計數器向下閉鎖

Semaphore       信號量

CyclicBarrier      同步屏障

 

Lock

在使用阻塞等待獲取鎖的方式中,必須在 try 代碼塊之外,並且在加鎖方法與 try 代 碼塊之間沒有任何可能拋出異常的方法調用,避免加鎖成功後,在 finally 中無法解鎖。

說明一:如果在 lock 方法與 try 代碼塊之間的方法調用拋出異常,那麼無法解鎖,造成其它線程無法成功 獲取鎖。

說明二:如果 lock 方法在 try 代碼塊之內,可能由於其它方法拋出異常,導致在 finally 代碼塊中, unlock 對未加鎖的對象解鎖,它會調用 AQS 的 tryRelease 方法(取決於具體實現類),拋出 IllegalMonitorStateException 異常。

ReentrantLock    可重入鎖

 

J.U.C組件擴展

1 FutureTask(對Callable Future接口的組合封裝)

面試中經常被問到的多種創建線程的方式(Thread Runable Callable FutureTask

2 ForkJoin (java中的MapReduce)

3 BlockQueue java中的阻塞隊列,線程安全

使用不同的方法會有不同的表現,如下圖

 

線程調度- 線程池

new Thread 的弊端

1 每次new Thread新建對象,性能差

2 線程缺乏統一的管理,可能無限制的新建線程,相互競爭,有可能佔用過多的系統資源導致死機或者OOM

3 缺少一些高級功能,如更多執行,定期執行,線程中斷

線程池的好處

1 重用存在的線程,減少對象創建,消亡的開銷,性能高

2 可有效的控制最大併發數,提高系統資源利用率,同時可以避免過多資源競爭,避免阻塞

3 提供定時執行,定期執行,單線程,併發數控制等功能

 

 

ThreadPoolExecutor 屬性:

corePoolSize:核心池的大小

maximumPoolSize:線程池最大線程數

keepAliveTime:表示線程沒有任務執行時最多保持多久時間會終止。默認情況下,只有當線程池中的線程數大於corePoolSize時,keepAliveTime纔會起作用,直到線程池中的線程數不大於corePoolSize

unit:時間單位

workQueue:一個阻塞隊列,用來存儲等待執行的任務,這個參數的選擇也很重要,會對線程池的運行過程產生重大影響,一般來說,這裏的阻塞隊列有以下幾種選擇:ArrayBlockingQueue LinkedBlockingQueue  SynchronousQueue


threadFactory:線程工廠,主要用來創建線程;

handler:表示當拒絕處理任務時的策略

1 ThreadPoolExecutor.AbortPolicy() 直接拋出異常

2 ThreadPoolExecutor.CallerRunsPolicy 直接在主線程中執行

3 ThreadPoolExecutor.DiscardOldestPolicy 丟棄靠前的任務

4 ThreadPoolExecutor.DiscardPolicy 丟棄該任務

 

 

 

 

ThreadPoolExecutor池子的處理流程如下:  

1)當池子大小小於corePoolSize就新建線程,並處理請求

2)當池子大小等於corePoolSize,把請求放入workQueue中,池子裏的空閒線程就去從workQueue中取任務並處理

3)當workQueue放不下新入的任務時,新建線程入池,並處理請求,如果池子大小撐到了maximumPoolSize就用RejectedExecutionHandler來做拒絕處理

4)另外,當池子的線程數大於corePoolSize的時候,多餘的線程會等待keepAliveTime長的時間,如果無請求可處理就自行銷燬 其會優先創建 CorePoolSiz 線程, 當繼續增加線程時,先放入Queue中,當 CorePoolSiz Queue 都滿的時候,就增加創建新線程,當線程達到MaxPoolSize的時候,就會根據配置的拒絕策略處理

 

newSingleThreadExecutor創建一個單線程的線程池。這個線程池只有一個線程在工作,也就是相當於單線程串行執行所有任務。如果這個唯一的線程因爲異常結束,那麼會有一個新的線程來替代它。此線程池保證所有任務的執行順序按照任務的提交順序執行。

newFixedThreadPool創建固定大小的線程池。每次提交一個任務就創建一個線程,直到線程達到線程池的最大大小。線程池的大小一旦達到最大值就會保持不變,如果某個線程因爲執行異常而結束,那麼線程池會補充一個新線程。

newCachedThreadPool創建一個可緩存的線程池。如果線程池的大小超過了處理任務所需要的線程,那麼就會回收部分空閒(60秒不執行任務)的線程,當任務數增加時,此線程池又可以智能的添加新線程來處理任務。此線程池不會對線程池大小做限制,線程池大小完全依賴於操作系統(或者說JVM)能夠創建的最大線程大小。

newScheduledThreadPool創建一個大小無限的線程池。此線程池支持定時以及週期性執行任務的需求。

注:阿里的開發手冊中提到過線程池不允許使用 Executors 去創建,而是通過 ThreadPoolExecutor 的方式,這 樣的處理方式讓寫的同學更加明確線程池的運行規則,規避資源耗盡的風險。

說明:Executors 返回的線程池對象的弊端如下:

1) FixedThreadPool 和 SingleThreadPool: 允許的請求隊列長度爲 Integer.MAX_VALUE,可能會堆積大量的請求,從而導致 OOM。

2) CachedThreadPool: 允許的創建線程數量爲 Integer.MAX_VALUE,可能會創建大量的線程,從而導致 OOM

 

 

附件爲測試用例源碼

https://download.csdn.net/download/qq_32725403/11527587

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