啃碎併發(五):Java線程安全特性與問題

前言

在單線程中不會出現線程安全問題,而在多線程編程中,有可能會出現同時訪問同一個 共享、可變資源 的狀況,這種資源能夠是:一個變量、一個對象、一個文件等。特別注意兩點:數據庫


簡單的說,若是你的代碼在單線程下執行和在多線程下執行永遠都能得到同樣的結果,那麼你的代碼就是線程安全的。那麼,當進行多線程編程時,咱們又會面臨哪些線程安全的要求呢?又是要如何去解決的呢?
編程

1 線程安全特性

1.1 原子性

跟數據庫事務的原子性概念差很少,即一個操做(有可能包含有多個子操做)要麼所有執行(生效),要麼所有都不執行(都不生效)緩存

關於原子性,一個很是經典的例子就是銀行轉帳問題:安全




1.2 可見性

可見性是指,當多個線程併發訪問共享變量時,一個線程對共享變量的修改,其它線程可以當即看到。可見性問題是好多人忽略或者理解錯誤的一點。多線程

CPU從主內存中讀數據的效率相對來講不高,如今主流的計算機中,都有幾級緩存。每一個線程讀取共享變量時,都會將該變量加載進其對應CPU的高速緩存裏,修改該變量後,CPU會當即更新該緩存,但並不必定會當即將其寫回主內存(實際上寫回主內存的時間不可預期)。此時其它線程(尤爲是不在同一個CPU上執行的線程)訪問該變量時,從主內存中讀到的就是舊的數據,而非第一個線程更新後的數據。併發

這一點是操做系統或者說是硬件層面的機制,因此不少應用開發人員常常會忽略。app

1.3 有序性

有序性指的是,程序執行的順序按照代碼的前後順序執行。如下面這段代碼爲例:工具



從代碼順序上看,上面四條語句應該依次執行,但實際上JVM真正在執行這段代碼時,並不保證它們必定徹底按照此順序執行。
性能

處理器爲了提升程序總體的執行效率,可能會對代碼進行優化,其中的一項優化方式就是調整代碼順序,按照更高效的順序執行代碼學習

講到這裏,有人要着急了——什麼,CPU不按照個人代碼順序執行代碼,那怎麼保證獲得咱們想要的效果呢?實際上,你們大可放心,CPU雖然並不保證徹底按照代碼順序執行,但它會保證程序最終的執行結果和代碼順序執行時的結果一致

2 線程安全問題

2.1 競態條件與臨界區

線程之間共享堆空間,在編程的時候就要格外注意避免競態條件。危險在於多個線程同時訪問相同的資源並進行讀寫操做。當其中一個線程須要根據某個變量的狀態來相應執行某個操做的以前,該變量極可能已經被其它線程修改





2.2 死鎖

死鎖:指兩個或兩個以上的進程(或線程)在執行過程當中,因爭奪資源而形成的一種互相等待的現象,若無外力做用,它們都將沒法推動下去。此時稱系統處於死鎖狀態或系統產生了死鎖,這些永遠在互相等待的進程稱爲死鎖進程。

關於死鎖發生的條件:



2.3 活鎖

活鎖:是指線程1可使用資源,但它很禮貌,讓其餘線程先使用資源,線程2也可使用資源,但它很紳士,也讓其餘線程先使用資源。這樣你讓我,我讓你,最後兩個線程都沒法使用資源

關於「死鎖與活鎖」的比喻




2.4 飢餓

飢餓:是指若是線程T1佔用了資源R,線程T2又請求封鎖R,因而T2等待。T3也請求資源R,當T1釋放了R上的封鎖後,系統首先批准了T3的請求,T2仍然等待。而後T4又請求封鎖R,當T3釋放了R上的封鎖以後,系統又批准了T4的請求......,T2可能永遠等待

也就是,若是一個線程由於CPU時間所有被其餘線程搶走而得不到CPU運行時間,這種狀態被稱之爲「飢餓」。而該線程被「飢餓致死」正是由於它得不到CPU運行時間的機會

關於「飢餓」的比喻



在Java中,下面三個常見的緣由會致使線程飢餓,以下:

1.高優先級線程吞噬全部的低優先級線程的CPU時間



2.線程被永久堵塞在一個等待進入同步塊的狀態,由於其餘線程老是能在它以前持續地對該同步塊進行訪問



3.線程在等待一個自己(在其上調用wait())也處於永久等待完成的對象,由於其餘線程老是被持續地得到喚醒



2.5 公平

解決飢餓的方案被稱之爲「公平性」 – 即全部線程均能公平地得到運行機會。在Java中實現公平性方案,須要:



在Java中實現公平性,雖Java不可能實現100%的公平性,依然能夠經過同步結構在線程間實現公平性的提升

首先來學習一段簡單的同步態代碼:



若是有多個線程調用doSynchronized()方法,在第一個得到訪問的線程未完成前,其餘線程將一直處於阻塞狀態,並且在這種多線程被阻塞的場景下,接下來將是哪一個線程得到訪問是沒有保障的

改成使用鎖方式替代同步塊,爲了提升等待線程的公平性,咱們使用鎖方式來替代同步塊:



注意到doSynchronized()再也不聲明爲synchronized,而是用lock.lock()和lock.unlock()來替代。下面是用Lock類作的一個實現:



注意到上面對Lock的實現,若是存在多線程併發訪問lock(),這些線程將阻塞在對lock()方法的訪問上。另外,若是鎖已經鎖上(校對注:這裏指的是isLocked等於true時),這些線程將阻塞在while(isLocked)循環的wait()調用裏面。要記住的是,當線程正在等待進入lock() 時,能夠調用wait()釋放其鎖實例對應的同步鎖,使得其餘多個線程能夠進入lock()方法,並調用wait()方法

這回看下doSynchronized(),你會注意到在lock()和unlock()之間的註釋:在這兩個調用之間的代碼將運行很長一段時間。進一步設想,這段代碼將長時間運行,和進入lock()並調用wait()來比較的話。這意味着大部分時間用在等待進入鎖和進入臨界區的過程是用在wait()的等待中,而不是被阻塞在試圖進入lock()方法中

在早些時候提到過,同步塊不會對等待進入的多個線程誰能得到訪問作任何保障,一樣當調用notify()時,wait()也不會作保障必定能喚醒線程。所以這個版本的Lock類和doSynchronized()那個版本就保障公平性而言,沒有任何區別。

但咱們可以改變這種狀況,以下:



下面將上面Lock類轉變爲公平鎖FairLock。你會注意到新的實現和以前的Lock類中的同步和wait()/notify()稍有不一樣。重點是,每個調用lock()的線程都會進入一個隊列,當解鎖時,只有隊列裏的第一個線程被容許鎖住FairLock實例,全部其它的線程都將處於等待狀態,直到他們處於隊列頭部。以下:



首先注意到lock()方法不在聲明爲synchronized,取而代之的是對必需同步的代碼,在synchronized中進行嵌套



還需注意到,QueueObject實際是一個semaphore。doWait()和doNotify()方法在QueueObject中保存着信號。這樣作以免一個線程在調用queueObject.doWait()以前被另外一個線程調用unlock()並隨之調用queueObject.doNotify()的線程重入,從而致使信號丟失。queueObject.doWait()調用放置在synchronized(this)塊以外,以免被monitor嵌套鎖死,因此另外的線程能夠解鎖,只要當沒有線程在lock方法的synchronized(this)塊中執行便可。

最後,注意到queueObject.doWait()在try – catch塊中是怎樣調用的。在InterruptedException拋出的狀況下,線程得以離開lock(),並需讓它從隊列中移除

3 如何確保線程安全特性

3.1 如何確保原子性

3.1.1 鎖和同步

經常使用的保證Java操做原子性的工具是鎖和同步方法(或者同步代碼塊)。使用鎖,能夠保證同一時間只有一個線程能拿到鎖,也就保證了同一時間只有一個線程能執行申請鎖和釋放鎖之間的代碼。



與鎖相似的是同步方法或者同步代碼塊。使用非靜態同步方法時,鎖住的是當前實例;使用靜態同步方法時,鎖住的是該類的Class對象;使用靜態代碼塊時,鎖住的是synchronized關鍵字後面括號內的對象。下面是同步代碼塊示例:



不管使用鎖仍是synchronized,本質都是同樣,經過鎖或同步來實現資源的排它性,從而實際目標代碼段同一時間只會被一個線程執行,進而保證了目標代碼段的原子性。這是一種以犧牲性能爲代價的方法

3.1.2 CAS(compare and swap)

基礎類型變量自增(i++)是一種常被新手誤覺得是原子操做而實際不是的操做。Java中提供了對應的原子操做類來實現該操做,並保證原子性,其本質是利用了CPU級別的CAS指令。因爲是CPU級別的指令,其開銷比須要操做系統參與的鎖的開銷小。AtomicInteger使用方法以下:



3.2 如何確保可見性

Java提供了volatile關鍵字來保證可見性。當使用volatile修飾某個變量時,它會保證對該變量的修改會當即被更新到內存中,而且將其它線程緩存中對該變量的緩存設置成無效,所以其它線程須要讀取該值時必須從主內存中讀取,從而獲得最新的值。

volatile適用場景:volatile適用於不須要保證原子性,但卻須要保證可見性的場景。一種典型的使用場景是用它修飾用於中止線程的狀態標記。以下所示:



在這種實現方式下,即便其它線程經過調用stop()方法將isRunning設置爲false,循環也不必定會當即結束。能夠經過volatile關鍵字,保證while循環及時獲得isRunning最新的狀態從而及時中止循環,結束線程

3.3 如何確保有序性

上文講過編譯器和處理器對指令進行從新排序時,會保證從新排序後的執行結果和代碼順序執行的結果一致,因此從新排序過程並不會影響單線程程序的執行,卻可能影響多線程程序併發執行的正確性



除了從應用層面保證目標代碼段執行的順序性外,JVM還經過被稱爲happens-before原則隱式地保證順序性。兩個操做的執行順序只要能夠經過happens-before推導出來,則JVM會保證其順序性,反之JVM對其順序性不做任何保證,可對其進行任意必要的從新排序以獲取高效率。

happens-before原則(先行發生原則),以下:



4 關於線程安全的幾個爲何

1.平時項目中使用鎖和synchronized比較多,而不多使用volatile,難道就沒有保證可見性?



2.鎖和synchronized爲什麼能保證可見性?



3.既然鎖和synchronized便可保證原子性也可保證可見性,爲什麼還須要volatile?



4.既然鎖和synchronized能夠保證原子性,爲何還須要AtomicInteger這種的類來保證原子操做?



5.還有沒有別的辦法保證線程安全?


6.synchronized便可修飾非靜態方式,也可修飾靜態方法,還可修飾代碼塊,有何區別?

相關文章
相關標籤/搜索