JAVA如何解決并發(fā)問(wèn)題
首先,我們要知道并發(fā)要解決的是什么問(wèn)題?并發(fā)要解決的是單進(jìn)程情況下硬件資源無(wú)法充分利用的問(wèn)題。而造成這一問(wèn)題的主要原因是CPU-內(nèi)存-磁盤三者之間速度差異實(shí)在太大。如果將CPU的速度比作火箭的速度,那么內(nèi)存的速度就像火車,而最慘的磁盤,基本上就相當(dāng)于人雙腿走路。
這樣造成的一個(gè)問(wèn)題,就是CPU快速執(zhí)行完它的任務(wù)的時(shí)候,很長(zhǎng)時(shí)間都會(huì)在等待磁盤或是內(nèi)存的讀寫。
計(jì)算機(jī)的發(fā)展有一部分就是如何重復(fù)利用資源,解決硬件資源之間效率的不平衡,而后就有了多進(jìn)程,多線程的發(fā)展。并且演化出了各種為多進(jìn)程(線程)服務(wù)的東西:
CPU增加緩存機(jī)制,平衡與內(nèi)存的速度差異 增加了多個(gè)概念,CPU時(shí)間片,程序計(jì)數(shù)器,線程切換等,用以更好得服務(wù)并發(fā)場(chǎng)景 編譯器的指令優(yōu)化,希望在內(nèi)部充分利用硬件資源但是這樣一來(lái),也會(huì)帶來(lái)新的并發(fā)問(wèn)題,歸結(jié)起來(lái)主要有三個(gè)。
由于緩存導(dǎo)致的可見性問(wèn)題 線程切換帶來(lái)的原子性問(wèn)題 編譯器優(yōu)化帶來(lái)的有序性問(wèn)題我們分別介紹這幾個(gè):
緩存導(dǎo)致的可見性CPU為了平衡與內(nèi)存之間的性能差異,引入了CPU緩存,這樣CPU執(zhí)行指令修改數(shù)據(jù)的時(shí)候就可以批量直接讀寫CPU緩存的內(nèi)存,一個(gè)階段后再將數(shù)據(jù)寫回到內(nèi)存。
但由于現(xiàn)在多核CPU技術(shù)的發(fā)展,各個(gè)線程可能運(yùn)行在不同CPU核上面,每個(gè)CPU核各有各自的CPU緩存。前面說(shuō)到對(duì)變量的修改通常都會(huì)先寫入CPU緩存,再寫回內(nèi)存。這就會(huì)出現(xiàn)這樣一種情況,線程1修改了變量A,但此時(shí)修改后的變量A只存儲(chǔ)在CPU緩存中。這時(shí)候線程B去內(nèi)存中讀取變量A,依舊只讀取到舊的值,這就是可見性問(wèn)題。
線程切換帶來(lái)的原子性為了更充分得利用CPU,引入了CPU時(shí)間片時(shí)間片的概念。進(jìn)程或線程通過(guò)爭(zhēng)用CPU時(shí)間片,讓CPU可以更加充分得利用。
比如在進(jìn)行讀寫磁盤等耗時(shí)高的任務(wù)時(shí),就可以將寶貴的CPU資源讓出來(lái)讓其他線程去獲取CPU并執(zhí)行任務(wù)。
但這樣的切換也會(huì)導(dǎo)致問(wèn)題,那就是會(huì)破壞線程某些任務(wù)的原子性。比如java中簡(jiǎn)單的一條語(yǔ)句count += 1。
映射到CPU指令有三條,讀取count變量指令,變量加1指令,變量寫回指令。雖然在高級(jí)語(yǔ)言(java)看來(lái)它就是一條指令,但實(shí)際上確是三條CPU指令,并且這三條指令的原子性無(wú)法保證。也就是說(shuō),可能在執(zhí)行到任意一條指令的時(shí)候被打斷,CPU被其他線程搶占了。而這個(gè)期間變量值可能會(huì)被修改,這里就會(huì)引發(fā)數(shù)據(jù)不一致的情況了。所以高并發(fā)場(chǎng)景下,很多時(shí)候都會(huì)通過(guò)鎖實(shí)現(xiàn)原子性。而這個(gè)問(wèn)題也是很多并發(fā)問(wèn)題的源頭。
編譯器優(yōu)化帶來(lái)的有序性因?yàn)楝F(xiàn)在程序員編寫的都是高級(jí)語(yǔ)言,編譯器需要將用戶的代碼轉(zhuǎn)成CPU可以執(zhí)行的指令。
同時(shí),由于計(jì)算機(jī)領(lǐng)域的不斷發(fā)展,編譯器也越來(lái)越智能,它會(huì)自動(dòng)對(duì)程序員編寫的代碼進(jìn)行優(yōu)化,而優(yōu)化中就有可能出現(xiàn)實(shí)際執(zhí)行代碼順序和編寫的代碼順序不一樣的情況。
而這種破壞程序有序性的行為,在有些時(shí)候會(huì)出現(xiàn)一些非常微妙且難以察覺的并發(fā)編程bug。
舉個(gè)簡(jiǎn)單的例子,我們常見的單例模式是這樣的:
public class Singleton { private Singleton() {} private static Singleton sInstance; public static Singleton getInstance() { if (sInstance == null) {//第一次驗(yàn)證是否為null synchronized (Singleton.class) { //加鎖 if (sInstance == null) { //第二次驗(yàn)證是否為null sInstance = new Singleton(); //創(chuàng)建對(duì)象 } } } return sInstance; }}
即通過(guò)兩段判斷加鎖來(lái)保證單例的成功生成,但在極小的概率下,可能會(huì)出現(xiàn)異常情況。原因就出現(xiàn)在sInstance = new Singleton();這一行代碼上。這行代碼,我們理解的執(zhí)行順序應(yīng)該是這樣:
為Singleton象分配一個(gè)內(nèi)存空間。 在分配的內(nèi)存空間實(shí)例化對(duì)象。 把Instance 引用地址指向內(nèi)存空間。但在實(shí)際編譯的過(guò)程中,編譯器有可能會(huì)幫我們進(jìn)行優(yōu)化,優(yōu)化完它的順序可能變成如下:
為Singleton對(duì)象分配一個(gè)內(nèi)存空間。 把instance 引用地址指向內(nèi)存空間。 在分配的內(nèi)存空間實(shí)例化對(duì)象。按照優(yōu)化完的順序,當(dāng)并發(fā)訪問(wèn)的時(shí)候,可能會(huì)出現(xiàn)這樣的情況
A線程進(jìn)入方法進(jìn)行第1次instance == null判斷。 此時(shí)A線程發(fā)現(xiàn)instance 為null 所以對(duì)Singleton.class加鎖。 然后A線程進(jìn)入方法進(jìn)行第2次instance == null判斷。 然后A線程發(fā)現(xiàn)instance 為null,開始進(jìn)行對(duì)象實(shí)例化。 為對(duì)象分配一個(gè)內(nèi)存空間。 .把Instance 引用地址指向內(nèi)存空間(而就在這個(gè)指令完成后,線程B進(jìn)入了方法)。 B線程首先進(jìn)入方法進(jìn)行第1次instance == null判斷。B線程此時(shí)發(fā)現(xiàn)instance 不為null ,所以它會(huì)直接返回instance (而此時(shí)返回的instance 是A線程還沒有初始化完成的對(duì)象)最終線程B拿到的instance 是一個(gè)沒有實(shí)例化對(duì)象的空內(nèi)存地址,所以導(dǎo)致instance使用的過(guò)程中造成程序錯(cuò)誤。解決辦法很簡(jiǎn)單,可以給sInstance對(duì)象加上一個(gè)關(guān)鍵字,volatile,這樣編譯器就不會(huì)亂優(yōu)化,有關(guān)volatile的具體內(nèi)容后續(xù)再細(xì)說(shuō)。
主要解決辦法通過(guò)上面的介紹,其實(shí)可以歸納無(wú)論是CPU緩存,線程切換還是編譯器優(yōu)化亂序,出現(xiàn)問(wèn)題的核心都是因?yàn)槎鄠€(gè)線程要并發(fā)讀寫某個(gè)變量或并發(fā)執(zhí)行某段代碼。那么我們可以控制,一次只讓一個(gè)線程執(zhí)行變量讀寫就可以了,這就是互斥。
而在某些時(shí)候,互斥還不夠,還需要一定的條件。比如一個(gè)生產(chǎn)者一個(gè)消費(fèi)者并發(fā),生產(chǎn)者向隊(duì)列存東西,消費(fèi)者向隊(duì)列拿東西。那么生產(chǎn)者寫的時(shí)候要保證存的時(shí)候隊(duì)列不是滿的,消費(fèi)者要保證拿的時(shí)候隊(duì)列非空。這種線程與線程間需要通信協(xié)作的情況,稱為同步,同步可以說(shuō)是更復(fù)雜的互斥。
既然知道了并發(fā)編程的根源以及同步和互斥,那我們來(lái)看看有哪些解決的思路。其實(shí)一共也就三種:
避免共享 Immutability(不變性) 管程及其他工具下面我們分別說(shuō)說(shuō)這三種方案的優(yōu)缺點(diǎn)
避免共享我們先來(lái)說(shuō)說(shuō)避免共享,其實(shí)避免共享說(shuō)是線程本地存儲(chǔ)技術(shù),在java中指的一般就是Threadlocal。ThreadLocal會(huì)為每個(gè)線程提供一個(gè)本地副本,每個(gè)線程都只會(huì)修改自己的ThreadLocal變量。這樣一來(lái)就不會(huì)出現(xiàn)共享變量,也就不會(huì)出現(xiàn)沖突了。
其實(shí)現(xiàn)原理是在ThreadLocal內(nèi)部維護(hù)一個(gè)ThreadLocalMap,每次有線程要獲取對(duì)應(yīng)變量的時(shí)候,先獲取當(dāng)前線程,然后根據(jù)不同線程取不同的值,典型的以空間換時(shí)間。
所以ThreadLocal還是比較適用于需要共享資源,且資源占用空間不大的情況。比如一些連接的session啊等等。但是這種模式應(yīng)用場(chǎng)景也較為有限,比如需要同步情況就難以勝任。
Immutability(不變性)Immutability在函數(shù)式中用得比較多,函數(shù)式編程的一個(gè)主要目的是要寫出無(wú)副作用的代碼,有關(guān)什么是無(wú)副作用可以參考我以前的文章Scala函數(shù)式編程指南(一) 函數(shù)式思想介紹。而無(wú)副作用的一個(gè)主要特點(diǎn)就是變量都是Immutability即不可變的,即創(chuàng)建對(duì)象后不會(huì)再修改對(duì)象,比如scala默認(rèn)的變量和數(shù)據(jù)結(jié)構(gòu)都是不可變的。而在java中,不變性變量即通過(guò)final修飾的變量,如String,Long,Double等類型都是Immutability的,它們的內(nèi)部實(shí)現(xiàn)都是基于final關(guān)鍵字的。
那這又和并發(fā)編程有什么關(guān)系呢?其實(shí)啊,并發(fā)問(wèn)題很大部分原因就是因?yàn)榫€程切換破壞了原子性,這又導(dǎo)致線程隨意對(duì)變量的讀寫破壞了數(shù)據(jù)的一致性。而不變性就不必?fù)?dān)心這個(gè)問(wèn)題,因?yàn)樽兞慷际遣蛔?,不可寫只能讀的。在這種編程模式下,你要修改一個(gè)變量,那么只能新生成一個(gè)。這樣做的好處很明顯,但壞處也是顯而易見,那就是引入了額外的編程復(fù)雜度,喪失了代碼的可讀性和易用性。
因?yàn)槿绱?,不變性的并發(fā)解決方案其實(shí)相對(duì)而已沒那么廣泛,其中比較有代表性的算是Actor并發(fā)編程模型,我以前也有討論過(guò),有興趣可以看看Actor模型淺析 一致性和隔離性,這種編程模型和常規(guī)并發(fā)解決方案有很顯著的差異。按我的了解,Acctor模式多用在分布式系統(tǒng)的一些協(xié)調(diào)功能,比如維持集群中多個(gè)機(jī)器的心跳通信等等。如果在單機(jī)并發(fā)環(huán)境下,還是下面要介紹的管程類工具才是利器。
管程及其他工具其實(shí)最早的操作系統(tǒng)中,解決并發(fā)問(wèn)題用的是信號(hào)量,信號(hào)量通過(guò)兩個(gè)原子操作wait(S),和signal(S)(俗稱P,V操作)來(lái)實(shí)現(xiàn)訪問(wèn)資源互斥和同步。比如下面這個(gè)小例子:
//整型信號(hào)量定義int S;//P操作wait(S){ while(S<=0); S--;}//V操作signal(S){ S++;}
雖然信號(hào)量方便有效,但信號(hào)量要對(duì)每個(gè)共享資源都實(shí)現(xiàn)對(duì)應(yīng)的P和V操作,這使得并發(fā)編程中可能要出現(xiàn)大量的P,V操作,并且這部分內(nèi)容難以抽象出來(lái)。
為了更好地實(shí)現(xiàn)同步互斥,于是就產(chǎn)生了管程(即Monitor,也有翻譯為監(jiān)視器),值得一提的是,管程也有幾種模型,分別是:Hasen模型,Hoare模型和MESA模型。其中MESA模型應(yīng)用最廣泛,java也是參考自MESA模型。這里簡(jiǎn)單介紹下管程的理論知識(shí),這部分內(nèi)容參考自進(jìn)程同步機(jī)制-----為進(jìn)程并發(fā)執(zhí)行保駕護(hù)航,希望了解更多管程理論知識(shí)的童鞋可以看看。
我們來(lái)通過(guò)一個(gè)經(jīng)典的生產(chǎn)-消費(fèi)隊(duì)列來(lái)解釋,如下圖
我們先解釋下圖中右半部分的內(nèi)容,右上角有一個(gè)等待調(diào)用的線程隊(duì)列,管程中每次只能有一個(gè)線程在執(zhí)行任務(wù),所以多個(gè)任務(wù)需要等待。然后是各個(gè)名詞的意思,生產(chǎn)-消費(fèi)需要往隊(duì)列寫入和取出東西,這里的隊(duì)列就是共享變量,對(duì)共享資源進(jìn)行操作稱之為過(guò)程(入隊(duì)和出隊(duì)兩個(gè)過(guò)程)。而向隊(duì)列寫入和取出是有條件的,寫入的時(shí)候隊(duì)列必須是非滿的,取出的時(shí)候隊(duì)列必須是非空的,這兩個(gè)條件被稱為條件變量。
然后再來(lái)看看左半部分的內(nèi)容,假設(shè)線程T1讀取共享變量(即隊(duì)列),此時(shí)發(fā)現(xiàn)隊(duì)列為空(條件變量之一),那么T1此時(shí)需要等待,去哪里等呢?去條件變量隊(duì)列不能為空對(duì)應(yīng)的隊(duì)列中去等待。此時(shí)另一個(gè)線程T2向共享變量隊(duì)列寫數(shù)據(jù),通過(guò)了條件變量隊(duì)列不能滿,那么寫完后就會(huì)通知線程T1。但因?yàn)楣艹痰南拗?,管程中只能有一個(gè)線程在執(zhí)行,所以T1線程不能立即執(zhí)行,它會(huì)回到右上角的線程等待隊(duì)列等待(不同的管程模型在這里是有分歧的,比如Hasen模型是立即中斷T2線程讓隊(duì)列中下一個(gè)線程執(zhí)行)。
解釋完這個(gè)圖,管程的概念也就呼之欲出了,
hansen對(duì)管程的定義如下:一個(gè)管程定義了一個(gè)數(shù)據(jù)結(jié)構(gòu)和能力為并發(fā)進(jìn)程所執(zhí)行(在該數(shù)據(jù)結(jié)構(gòu)上)的一組操作,這組操作能同步進(jìn)程和改變管程中的數(shù)據(jù)。
本質(zhì)上,管程是對(duì)共享資源以及對(duì)共享資源的操作抽象成變量和方法,要操作共享變量?jī)H能通過(guò)管程提供的方法(比如上面的入隊(duì)和出隊(duì))間接訪問(wèn)。所以你會(huì)發(fā)現(xiàn)管程其實(shí)和面向?qū)ο蟮睦砟钍鞘窒嘟?,在java中,主要提供了低層次了synchronized關(guān)鍵字和wait(),notify()等方法。同時(shí)還提供了高層次的ReenTrantLock和Condition來(lái)實(shí)現(xiàn)管程模型。
以上就是JAVA如何解決并發(fā)問(wèn)題的詳細(xì)內(nèi)容,更多關(guān)于JAVA 并發(fā)的資料請(qǐng)關(guān)注好吧啦網(wǎng)其它相關(guān)文章!
相關(guān)文章:
1. HTTP協(xié)議常用的請(qǐng)求頭和響應(yīng)頭響應(yīng)詳解說(shuō)明(學(xué)習(xí))2. idea設(shè)置提示不區(qū)分大小寫的方法3. .NET SkiaSharp 生成二維碼驗(yàn)證碼及指定區(qū)域截取方法實(shí)現(xiàn)4. ASP.NET MVC通過(guò)勾選checkbox更改select的內(nèi)容5. css代碼優(yōu)化的12個(gè)技巧6. IntelliJ IDEA創(chuàng)建web項(xiàng)目的方法7. 原生JS實(shí)現(xiàn)記憶翻牌游戲8. Django使用HTTP協(xié)議向服務(wù)器傳參方式小結(jié)9. CentOS郵件服務(wù)器搭建系列—— POP / IMAP 服務(wù)器的構(gòu)建( Dovecot )10. django創(chuàng)建css文件夾的具體方法
