閱讀766 返回首頁    go 阿裏雲 go 技術社區[雲棲]


J.U.C係列-線程安全的理論講解

引文:

在J U C裏麵,要談到並發,就必然就存在可見性問題,其實對於程序來講,要說到鎖,首先要確保可見性,也就是要在這個基礎上才能做到,而CAS也是基於這種原理來完成,我們在文章:Java JUC之Atomic係列12大類實例講解和原理分解 中關於Atomic的介紹中有提到通過unsafe調用底層的compareAndSwapXXX的三個方法,都是基於可見性變量才會有效。

 

談到可見性,首先要明白一下內存和CPU以及多個CPU之間數據修改的基本原理,我們不要談及CPU上太深的東西,我隻需要明白,要將數據進行運算,就需要將數據拷貝到CPU上麵去計算,那麼就會有內存和CPU之間的通信、CPU的計算、寫回到內存一些動作,此時基於線程的私有棧中會保存這些數據;而可見性會體現在:當另一線程對共享數據進行修改的時候,另一個線程未必能看到或者未必能馬上看到這個數據。那什麼叫看到這個數據呢?說起來蠻抽象的,並且這些情況通常不好模擬,在不同的CPU下也會模擬出來不同的效果或者根本模擬不出來(所以本文隻會給出很多理論,因為給你的代碼你可能會認為他們是無法將場景實現的),我們下麵用簡短的一段例子描述下大概:

當一個線程創建多個子線程去做很多任務的時候,在每個子線程內部的都有一個狀態區域設置(例如:初始化、運行中、執行完成、執行失敗等),主線程會不斷去讀取子線程的狀態,從而做進一步的操作;上麵所提到的可見性就是體現在當主線程去讀取子線程的數據的時候,有可能會導致數據的還是“”的值或“失效”的值的情況,但是並不是任何時候都出現,隻是一些偶然的情況會發生,由於某些CPU的優化或當JVM被調節為-server模式下運行時,允許很多信息被優化後才會發生;所以你經常在本地調試一些並發程序發生沒有什麼問題,當你發布到server下後,經常會出現一些稀奇古怪的問題,這是為什麼呢,程序的優化和CPU的優化,它認為這裏應該是安全的,可以被優化或轉換,如果你不想讓他變化,你就需要告訴他們,你的數據是存在多線程安全隱患的。

 

文章中會介紹很多關於線程安全的知識理論分享,也許你第一遍看下來頭暈腦脹,但是通過理解後再看看,也許你就會有很多自己的理解,從而在多線程編程時對於線程的安全有新的認識。

 

什麼是線程安全?

從上麵的信息可以發現,問題通常出現在多個線程之間的共享數據的訪問,也就是沒有“共享”就不會出現征用;當多個線程並發得讀或寫一些共享的數據的時候,我們就可能會產生各種各樣的問題,例如上麵提到的可見性問題,但是可見性並不代表原子性,因為原子性要求讀、修改、寫入三個動作要一致,所以原子性要求更高,而原子性代表不了鎖,鎖要求這個片段的執行或相關片段的執行都是相互隔離的,也就是他不僅僅是單個步驟或某個變量操作需要原子的,而是整個這些步驟操作都是相互隔離的。

 

棧隔離:

要讓線程安全,最簡單的方法就是棧隔離,有些翻譯為棧封閉,也就是每個線程之間的信息都是局部變量,相互之間是不存在讀寫的,有本地的局部屬性,也有可能是ThreadLocal的延伸,他們都是線程隔離的,通常WEB應用的係統業務代碼都是棧隔離的,並不是代表WEB應用是棧隔離的,因為WEB容器幫我們把複雜的線程分派等工作處理掉了,業務代碼大部分情況下無需關心多線程處理而已。

 

可見性:

為了說明可見性,我們來寫一個例子程序,代碼如下,複製到你的機器上就可以運行:

public class NoVisiability {
	
	private static class ReadThread extends Thread {
		
		private boolean ready;
		
		private int number;
		
		public void run() {
			while(!ready) {
				number++;	
			}
			System.out.println(ready);
		}
		
		public void readyOn() {
			this.ready = true;
		}
	}
	
	public static void main(String []args) throws InterruptedException {
		ReadThread readThread = new ReadThread();
		readThread.start();
		Thread.sleep(2000);
		readThread.readyOn();
		System.out.println(readThread.ready);
	}
}
這個代碼很簡單吧,就是一個線程對另一個線程的數據進行了修改,然後看下結果;可能你覺得很無聊,這個結果很明顯,然後拿到IDE工具下一運行結果是延遲兩秒後就輸出來是兩個true;但是不然,你要運行這段代碼,你需要將運行設置為-server狀態,要麼在命令行下運行,要麼要設置IDE工具運行這個java程序時需要攜帶的命令,eclipse就是可以在Run Configurations->Arguments->VM arguments裏麵增加-server即可;

運行結果可能有多重,看機器、看OS、和VM版本;

如果你用的hotspot的VM,可能出現的結果有:

1、正常輸出兩個true,說明正好被趕上了或OS和機器未做一些處理;

2、主線程輸出了一個false,子線程正常退出,看到了true;

3、主線程輸出了true,子線程未看到,始終在死循環;

真的假的,你試一試就知道了,嗬嗬;我的機器上出現的是第三種情況,上述代碼中如果將while循環內部寫為yield就不會出現死循環的情況,他空閑出對CPU的使用,在獲取變量時會重新進行一次拷貝。


其實我們在剛開始引文中已經大概說到了可見性的問題,我們具體來說說什麼情況會出現,例如,在一個類中有多個屬性,其中一個屬性來標示狀態(status),其他的屬性來標示這個屬性的值(name、number等),某一個線程正在等待這個類的值被填充,填充的標誌位status,可能線程的代碼為:

name= “aaa”;

number= 12;

status= true;

也就是將name和number寫入完後開始寫入status,這表麵上看上去沒有什麼問題,是的,但是隨著編譯器發現這三個賦值完全是沒有任何順序關係的,所以在運行一段時間後,隨著JIT和CPU的優化,會導致他們執行順序的亂序,也就是他們三條代碼的執行順序未必是一致的,當status的值被先被賦予true,而name和number可能還未被賦值,所以另一個線程可能會得到的name是null或以前賦值過的信息

而還有什麼可能呢,在某些特殊的情況下,status可能被賦值了true,而另一個線程一直看不到,那麼等待這個對象被賦值的線程會出現死等的情況。

 

再深入一下,對於jvm來講,很多時候他並不認為這個線程賦值不是安全的,因為它並不知道你有多個線程要操作這個對象,所以他通常在對long、double類型的賦值或讀取的時候,會按照32個bit(4個字節)一個基本操作為基本單位,這樣可能會造成的是,當讀取了前麵4個字節後,這個內存單元被修改,此時後麵4個字節發生了變化,那麼讀取出來的數據可想而知。

 

那麼如何保證可見性呢?volatile,這就是volatile真正的意義,要保證原子性,首先要保證可見性,因為你看到的都不是真的,就沒法保證數據是原子的;volatile有三大特征:

1、  要求編譯器對指令是順序的,優化器對相關變量賦值的順序是不改變的;CPU不做相關的指令順序

2、  每次訪問volatile會向縱向發起一個簡單的lock,用於做add(0)的操作,一個輕量級的鎖,並從內存中獲取最新的數據;

3、  對於long、double類型的數據,讀取他們的時候,會是原子的,也就是兩個步驟會產生一個簡單的鎖。

 

volatile由於在讀寫時發生一個短暫的鎖,所以他的性能會比普通的變量稍微低一點,所以你在後麵提到的很多情況下,無需將所有的內容都設置為volatile,因為這樣會降低係統的性能。


volatile變量僅僅能保證可見性,也就是你在讀取的一瞬間這個數據是不會被修改的,但是要達到原子或鎖的目的是不行的,接下來,我們再看一個線程安全,但是可能很多人不想看的final,但是他在線程安全中的確有一些重要的作用:

 

final使用:

在很多應用中,經常發現定義的變量出現了final,但是自己不知道怎麼用,除了他不可改變以外,其實他另一種重要用途就是線程是絕對安全的,當一個引用或一個變量被定為final,他在多線程中自然隻有讀的操作,而沒有寫的操作;但是這並不意味著這個對象本身內部的所有屬性的訪問是線程安全的,如果某些屬性是被多個線程所訪問的,如果可以被認為他們是不會改變的,那麼屬性也應當是final的;

 

在很多係統的代碼中經常會出現init()initialize()這些方法,他們如果沒有被類似構造方法或某些特殊的基於鎖的方法調用的話,就會出現一些問題;由於他們的調用可能會是被並發調用的,如果你沒有加鎖的情況下,內部的某些屬性,你又想讓他被初始化一次,這就是不可能的了;當然你在構造方法中可以去調用,那麼就涉及到外部的一個線程安全,此時對於很多場景來講,是推薦使用final,因為它在初始化的時候強製要求被賦值或必須在構造方法中被賦值,不是final類型的,即使你沒有對它做任何操作,它在構造父親類Object的時候會給所有的屬性做一次初始化操作,使得這些變量的值是“”值;當某個線程獲取到對象的引用後,調用相關的初始化方法來初始化,而第二個線程進來的時候,發現還未被賦值,繼續初始化,等等會產生各種問題。

 

而還有一類比較重要的問題,就是當一個對象被定義為final,也就是不可以改變的對象,這個對象內有很多屬性也不可以改變,此時雖然定義成了final,但是如果提供了對該對象的get方法,外部線程獲取到後同樣可以修改內部的屬性,所以要將內部屬性不可改變,同樣需要將其定義為final。

 

某些變量是內部使用的值,子類可能也會被使用,那麼可能會被定義為protected類型的,這些類行的方法和屬性通常是不會被訪問到的,但是通過繼承或內部實例就,可以在內部使用一個匿名塊或方法,然後使用this訪問到這些屬性或方法,從而進一步得到數據,所以protected的一些屬性在java並發編程中也是需要被慎重使用的。

 

ThreadLocal:

 

ThreadLocal已經在專門的文章中講到,請參看文章:

ThreadLocal實現方式&使用介紹---無鎖化線程封閉

 

 

拷貝實現不可變和線程安全:

上麵提及到了某些共享的數據是不可變的,可能是一個對象、數組或某個集合類等,雖然我們在管理這些數據的時候使用了final,但是他們本身內部的屬性並非final,例如數組獲取到後,可以對數組內部的某個下標做修改,而集合類對象也是如此;

 

在這種情況還有一種方式就是拷貝,將數據拷貝一份給使用者,使用者的修改並不會影響原有數據的信息,也許使用者的確會根據這些模板來做一些個性化的調整(Prototype),此時的方法就是利用克隆,而數組也可以使用Arrays.copyOf方法來操作,集合類就使用Collections裏麵的相關方法;但是要注意的是,這些拷貝方法就是拷貝當前這一層,不論是克隆還是下麵的拷貝,如果還有深入引用,需要自己進一步去拷貝才可以達到效果,否則更深一層的內容的修改同樣會影響這些數據;例如,一個數組中每個引用都引用了一個Person對象,那麼拷貝的結果並沒有創建很多新的Person對象,而是隻生成一個新的數組,將原有數組上所有指向Person的地址內容拷貝過來而已。

 

事實不可變:

什麼叫做事實不可變,就是說這個變量雖然我沒有定義為final,而且多線程會訪問,但是他在運行時是不會改變的,也就是語法上允許改變,但是業務代碼不會有對他的寫操作;那麼訪問這些對象或變量是無需加鎖的,他們被任意組裝到數組、集合類或對象中,隻要數組和集合類或對象本身是線程安全的,訪問他們都是線程安全的。

也就是你知道這個對象的內容是不會變化的,你就無需對他進行鎖操作,以提高程序的整體性能,避免不必要的鎖開銷。

 

 

原子性:

這裏提到的原子性,就是指對某個內存單元進行讀寫操作是一致的,類似一次count++的操作,會經曆:獲取count的值、在CPU上計算結果、將count的結果寫回到內存單元;

而volatile隻能保證一個點上的一致性的,不能保證一個過程,所以要保證過程的一致性,就需要有鎖的概念引入,synchronized、Lock係列我們會在後續的文章中介紹,而對於單個內存單元來講,我們實用Atomic係列的功能就足以解決,它采用CAS的方式完成,基於unsafe提供的compareAndSwapXXX三個核心方法,這是CPU上的條件指令,也就是每次修改完後會做一次對比,若一致就認為成功,否則失敗返回falase,那麼對於可見性的volatile加上他們的組合,就可以完成CAS的功能。

關於Atomic係列的文章,在:

Java JUC之Atomic係列12大類實例講解和原理分解

包含老Atomic類對基本變量、引用、數組等內容的一致性修改操作;Atomic係列基於volatile來實現,鎖機製比volatile更加強,對於內存單元的訪問,它的速度比volatile要更低一些,但是內存單元的修改來講,它在並發編程中是最簡單的,除了Lock和synchronized外的一個選擇,大部分情況下他在對單個內存單元上的修改的性能要比Lock和Synchronized要好。

 

在java並發編程中,本文是一個引導性的作用,認識到了多線程訪問的重要性,接下來就是針對問題如何去解決,當然本文也給出了一些基本的變量處理方式,但是JUC中還有很多的內容,需要逐步去挖掘,例如我們即將要介紹的鎖機製和並發集合類的相關操作。

 

本文先介紹到這裏,相信對於以前沒接觸過並發編程的人來講,有點暈,沒事,多理解下就不暈了。

最後更新:2017-04-03 22:30:57

  上一篇:go Python:十年語言之冠
  下一篇:go HBase設計:看上去很美