1、線程安全多個線程對同一個共享變量進(jìn)行讀寫操作時可能產(chǎn)生不可預(yù)見的結(jié)果,這就是線程安全問題。 線程安全的核心點(diǎn)就是共享變量,只有在共享變量的情況下才會有線程安全問題。這里說的共享變量,是指多個線程都能訪問的變量,一般包括成員變量和靜態(tài)變量,方法內(nèi)定義的局部變量不屬于共享變量的范圍。 線程安全問題示例:
運(yùn)行上述代碼三次的結(jié)果如下:
在上述代碼中,線程t1中count進(jìn)行5000次自增操作,而線程t2中count則進(jìn)行5000次自減操作。在兩個線程都運(yùn)行結(jié)束后,按照預(yù)期結(jié)果,count的值應(yīng)為0。但由打印結(jié)果可知,count的值并不為0,且每次運(yùn)行的結(jié)果都不一樣。這就是多線程對共享變量進(jìn)行操作出現(xiàn)的不可預(yù)見的結(jié)果,即常說的線程安全問題。 而線程安全,則指的是在多線程環(huán)境下,程序可以始終執(zhí)行正確的行為,符合預(yù)期的邏輯。具體到上述代碼,就是不論執(zhí)行多少次,在t1、t2線程執(zhí)行完畢后,count的值都應(yīng)該始終符合預(yù)期的結(jié)果0。上述代碼明顯是線程不安全的。 2、出現(xiàn)線程安全的原因線程安全是使用多線程必定會面臨的問題,導(dǎo)致線程不安全的主要原因有以下三點(diǎn):
2.1、原子性2.1.1 什么是原子性問題原子性問題,其實(shí)說的是原子性操作。即一個或多個操作,應(yīng)該是一個不可分的整體,這些操作要么全部執(zhí)行并且不被打斷,要么就都不執(zhí)行。 以上述代碼中的count的自增(
count++:
count--:
由此可知,count自增或自減的操作,并不是一個原子操作,即中間過程是有可能被打斷的。 count自增自減操作需要四個步驟(指令)才能完成,這意味著如果這執(zhí)行這四個步驟的某一步時,線程發(fā)生了上下文切換,那么自增自減操作將被打斷暫停。 如果使用單線程來執(zhí)行自增自減操作,這實(shí)際上并無問題: 上圖為單線程執(zhí)行count自增自減的一次過程,可以看出在沒有線程上下文切換的情況下,即使自增自減不是原子操作,count的最后結(jié)果都會是0。 但在多線程環(huán)境下,就會出現(xiàn)問題了:
這結(jié)果明顯是不符合我們的預(yù)期的,實(shí)際上,上述圖片展示的只是一種可能的結(jié)果。還有可能是t2寫入count的步驟是最后執(zhí)行的,那么最后count的值將為-1。 這就是由于非原子操作帶來的多線程訪問共享變量出現(xiàn)不符合預(yù)期的結(jié)果,即由于原子性帶來的線程安全問題。 上面示例中兩個線程t1、t2分別執(zhí)行count++和count--出現(xiàn)的問題,就是由于原子性帶來的線程安全問題。 2.1.2、原子性問題解決辦法解決辦法就是將count++和count--的操作變?yōu)樵硬僮鳎琂ava中的實(shí)現(xiàn)方法是: ①上鎖:使用synchronized 只需要創(chuàng)建一個對象作為鎖,并在訪問count時用synchronized進(jìn)行加鎖即可。
上鎖后,執(zhí)行自增自減的示意圖如下:
因此使用synchronized鎖可以解決原子性帶來的線程安全問題。 ②、循環(huán)CAS操作 其基本思路就是循環(huán)進(jìn)行CAS操作(compare and swap,比較并交換)。即對共享變量進(jìn)行計算前,線程會先將該共享變量保存一份舊值a,計算完畢后得出結(jié)果值b。在將b從線程的本地內(nèi)存刷新回主內(nèi)存前,會先比較主內(nèi)存中的值是否和a一致。如果一致,則將b刷新回主內(nèi)存。若不一致,則一直循環(huán)比較,直到主內(nèi)存中的值與a一致,才把共享變量的值設(shè)為b,操作才結(jié)束。 在Java中,使用CAS操作保證原子性的具體實(shí)現(xiàn)就是Lock和原子類(AtomicInteger)。它們都是通過使用unsafe的compareAndSwap方法實(shí)現(xiàn)CAS操作保證原子性的。 Lock的使用:
原子類的使用:
以上都是Java中可以保證原子操作的具體方法,它們各有優(yōu)缺點(diǎn),要看具體的場景來選擇最佳的使用,以此來解決原子性帶來的線程安全問題。 2.2、可見性2.2.1、什么是可見性問題可見性實(shí)際上指的是內(nèi)存可見性問題??偟膩碚f就是一個線程對共享變量的修改,另外一個線程不能立刻看到,從而產(chǎn)生的線程安全問題。 在上一篇筆記【JAVA并發(fā)第三篇】線程間通信 中的通過共享內(nèi)存進(jìn)行通信實(shí)際上講的就是內(nèi)存可見性問題。這里再從線程安全的角度講述一遍。 我們知道,CPU要從內(nèi)存中讀取出數(shù)據(jù)來進(jìn)行計算,但實(shí)際上CPU并不總是直接從內(nèi)存中讀取數(shù)據(jù)。由于CPU和內(nèi)存間(常稱之為主存)的速度不匹配(CPU的速度比主存快得多),為了有效利用CPU,使用多級cache的機(jī)制,如圖 因此,CPU讀取數(shù)據(jù)的順序是:寄存器-高速緩存-主存。主存中的部分?jǐn)?shù)據(jù),會先拷貝一份放到cache中,當(dāng)CPU計算時,會直接從cache中讀取數(shù)據(jù),計算完畢后再將計算結(jié)果放置到cache中,最后在主存中刷新計算結(jié)果。所以每個CPU都會擁有一份拷貝。 以上只是CPU訪問內(nèi)存,進(jìn)行計算的基本方式。實(shí)際上,不同的硬件,訪問過程會存在不同程度的差異。比如,不同的計算機(jī),CPU和主存間可能會存在三級緩存、四級緩存、五級緩存等等的情況。 為了屏蔽掉各種硬件和操作系統(tǒng)的內(nèi)存訪問差異,實(shí)現(xiàn)讓 Java 程序在各種平臺下都能達(dá)到一致的內(nèi)存訪問效果,定義了Java的內(nèi)存模型(Java Memory Model,JMM)。 JMM 的主要目標(biāo)是定義程序中各個變量的訪問規(guī)則,即在虛擬機(jī)中將變量存儲到主存和從主存中取出變量這樣的底層細(xì)節(jié)。這里的變量指的是能夠被多個線程共享的變量,它包括了實(shí)例字段、靜態(tài)字段和構(gòu)成數(shù)組對象的元素,方法內(nèi)的局部變量和方法的參數(shù)為線程私有,不受JMM的影響。 Java的內(nèi)存模型如下:
JMM規(guī)定:將所有共享變量放到主內(nèi)存中,當(dāng)線程使用變量時,會把其中的變量復(fù)制到自己的本地內(nèi)存,線程讀寫時操作的是本地內(nèi)存中的變量副本。一個線程不能訪問其他線程的本地內(nèi)存。 這樣的情況下,如果有一個變量i在線程A、B的本地內(nèi)存中都有一份副本。此時,若線程A想修改i的值,在線程A將修改后的值放入到本地內(nèi)存,但又未刷新回主內(nèi)存時,如果線程B讀取變量i的值,則讀到的是未修改時的值,這就造成了讀寫共享變量出現(xiàn)不可預(yù)期的結(jié)果,產(chǎn)生線程安全問題。 有代碼如下:
從運(yùn)行結(jié)果發(fā)現(xiàn),即使在主線程中修改了共享變量run的值,My_Thread線程依然在循環(huán)并不會停止:
2.2.2、可見性問題解決辦法解決辦法就是保證共享變量的可見性,具體實(shí)現(xiàn)就是任何對共享變量的訪問都要從共享內(nèi)存(主內(nèi)存)中獲取。在Java中的實(shí)現(xiàn)方法是: ①加鎖,synchronized和Lock都可以保證 線程在加鎖時,會清空本地內(nèi)存中共享變量的值,共享變量的使用需要從主內(nèi)存中重新獲取。而在釋放鎖資源時,則必須先把此共享變量同步回主內(nèi)存中。 由于鎖的存在,未持有鎖的線程并不能操作共享變量,而當(dāng)阻塞的線程獲得鎖時,主內(nèi)存中共享變量的值已經(jīng)刷新過了,因此線程修改共享變量對其他線程是可見的。這保證了共享變量的可見性,可以解決內(nèi)存可見性產(chǎn)生的線程安全問題。 ②使用volatile修飾共享變量 當(dāng)一個變量被聲明為volitale時,線程在寫入變量時,不會把值緩存本地內(nèi)存,而是會立即把值刷新回主存,而當(dāng)要讀取該共享變量時,線程則會先清空本地內(nèi)存中的副本值,從主存中重新獲取。這些也都保證了內(nèi)存的可見性。 優(yōu)先使用volatile關(guān)鍵字來解決可見性問題,加鎖消耗的資源更多。 2.3、有序性2.3.1、什么是有序性問題有序性,實(shí)際上是指令的重排序問題。 我們知道,CPU的執(zhí)行速度是比內(nèi)存要快出很多個數(shù)量級的。CPU為了執(zhí)行效率,會把CPU指令進(jìn)行重新排序。即我們編寫的Java代碼并不一定按照順序一行一行的往下執(zhí)行,處理器會根據(jù)需要重新排序這些指令,稱為指令并行重排序。 同時,JIT編譯器也會在代碼編譯的時候?qū)Υa進(jìn)行重新整理,最大限度的去優(yōu)化代碼的執(zhí)行效率,稱為編譯器的重排序。 而又由于處理器與主存之間會使用緩存和讀/寫緩沖機(jī)制,因此從主存加載和存儲操作也有可能是經(jīng)過指令重排序的,稱為內(nèi)存系統(tǒng)重排序。 綜上所述,在執(zhí)行程序時,為了提高性能,編譯器和處理器常常會對指令進(jìn)行重排序,再加上主內(nèi)存和處理器的緩存,Java源碼經(jīng)過層層的重排序,最后才得出最終結(jié)果。 由圖可知,從Java源碼到最后的執(zhí)行指令,會經(jīng)歷3種重排序的優(yōu)化。若有ava代碼如下:
經(jīng)過上述3種重排序后,語句A和語句B的執(zhí)行順序是可能互換的,并且這種互換并不影響代碼的正確性。但是我們發(fā)現(xiàn)語句C則不能和A、B互換,否則得出的結(jié)果將不正確,因?yàn)樗麄冎g存在著數(shù)據(jù)依賴關(guān)系,即語句C的數(shù)據(jù)依賴A和B得出。 由此,我們可以發(fā)現(xiàn),以上3種指令的重排序并不能隨意排序,他們需要遵守一定的規(guī)則,以保證程序的正確性。 ①as-if-serial語義 as-if-serial語義是指:不管怎么樣重排序,單線程程序的執(zhí)行結(jié)果都不能被改變。即不會對存在數(shù)據(jù)依賴關(guān)系的操作進(jìn)行重排序。 編譯器、處理器進(jìn)行指令重排序優(yōu)化時都必須遵守as-if-serial語義。即在單線程的情況下,指令重排序只能對不影響處理結(jié)果的部分進(jìn)行重排序。 以上述語句A、B、C為例,存在數(shù)據(jù)依賴關(guān)系的語句C和A或B不能被重排序:
as-if-serial語義的核心思想是:不會對存在數(shù)據(jù)依賴關(guān)系的操作進(jìn)行重排序。 那么數(shù)據(jù)依賴類型有哪些呢?如下表所示:
以上三種依賴關(guān)系,一旦重排序兩個操作的執(zhí)行順序,其結(jié)果就會改變,所以依照as-if-serial語義,Java在單線程的情況下不會對這三種依賴關(guān)系進(jìn)行重排序(多線程情況不符合此情況)。 as-if-serial語義是基于數(shù)據(jù)依賴關(guān)系的,但它無法保證多線程環(huán)境下,重排序之后程序執(zhí)行結(jié)果的正確性。 有代碼如下:
關(guān)于上述代碼,我們先忽略內(nèi)存可見性的問題(即線程t2修改了a和finish,但t1可能看不到的緩存問題)。在此前提下如果成功打印a*a的值,那么結(jié)果應(yīng)該為4。 但實(shí)際上a*a打印的結(jié)果還可能為0,這是由于指令重排序的存在導(dǎo)致的。 在線程t2中,由于 如果在先執(zhí)行 可以看出,即使在假設(shè)沒有內(nèi)存可見性問題的前提下,上述代碼的結(jié)果也是不可預(yù)期的,因此上述代碼也是線程不安全的,其原因就是重排序破壞了多線程程序的語義。 ②happens-before規(guī)則 既然是重排序出現(xiàn)問題,那么解決思路就是禁止重排序。但是也要注意不能全部禁用重排序,重排序的目的是為了提升執(zhí)行效率,如果全部禁用那么Java程序的性能將會很差。所以,應(yīng)該做到的是部分禁用,Java的內(nèi)存模型提供了一個可用于多線程環(huán)境,也適用于單線程環(huán)境的規(guī)則:happens-before規(guī)則。 happens-before規(guī)則的定義如下:A happens-before B,那么操作A的執(zhí)行結(jié)果對操作B是可見的,且操作A的執(zhí)行順序排在操作B之前。這里的操作A和操作B可以在同一個線程中,也可以在不同線程中。
happens-before保證操作A的執(zhí)行結(jié)果對B可見,依靠這個原則,可以解決多線程環(huán)境下內(nèi)存可見性和有序性問題。 回到代碼:
一共有四個操作
要達(dá)到這一目的,就需要上圖中,①和②所示的happens-before關(guān)系。 那要如何達(dá)到呢?這就需要了解happens-before的六大具體規(guī)則了(兩個操作,只需要符合其中任何一條就可以認(rèn)為是happens-before關(guān)系):
以上就是happens-before的六大常用規(guī)則(全部有八種,但后面兩種應(yīng)該很少用到) 2.3.2、有序性問題解決辦法解決有序性問題,實(shí)際上就是要運(yùn)用以上提到的兩種規(guī)則,as-if-serial語義解決了單線程程序的有序性問題,而happens-before關(guān)系則能解決多線程程序的有序性問題。 再回顧一下原始代碼,這是一段存在有序性問題線程不安全的代碼,我們要利用happens-before關(guān)系解決有序性問題:
提取一下關(guān)鍵的操作,如下嗷:
我們的目標(biāo)是運(yùn)用happens-before的六大常用規(guī)則達(dá)到如下圖的happens-before關(guān)系,以實(shí)現(xiàn)上訴代碼的線程安全
使用到happens-before規(guī)則中的程序順序規(guī)則、volatile變量規(guī)則和傳遞性。 首先,按照程序順序規(guī)則,可以知道如下的happens-before關(guān)系:
這由線程中的代碼很容易就能得出。接下來運(yùn)用volatile變量規(guī)則,需要用volatile修飾一個變量,我們選變量 那么根據(jù)volatile變量規(guī)則,可知對 因此給
也就是說,volatile關(guān)鍵字不僅可以解決可見性問題,還可以解決有序性問題。 最后,通過傳遞性??芍?/p> 可知,圖示的三和五,就是我們的目標(biāo)。到此,我們利用happens-before關(guān)系保證了代碼的可見性和有序性問題。 雖然分析的過程比較長,但是在原代碼中,我們實(shí)際上只改動了一行代碼。即將 這就是運(yùn)用volatile修飾變量來解決線程安全的辦法。volatile直接通過禁止相關(guān)的重排序來達(dá)到有序性的目的。 ②、方法二:加鎖,synchronized 這個應(yīng)該比較容易理解,對相關(guān)代碼加鎖后,同一時刻就只有一個線程在執(zhí)行,也就相當(dāng)于對相關(guān)變量的操作,是保證有序的。 不過synchronized并不像volatile一樣禁止指令重排序,實(shí)際上synchronized塊內(nèi)部的代碼指令依然是可以進(jìn)行重排序優(yōu)化的。 3、小結(jié)
|
|