并发问题是怎样造成的
我們的程序要運行,離不開CPU、內存、IO設備,但是他們三者之間的執行速度是有差異的。
CPU 的執行速度最快,內存的速度次之,IO設備的速度最慢。
為什么會有內存緩存
CPU 執行一條指令非常快,但是他從內存中讀取某個數據時,就需要等待很長的時間,為了彌補速度上的巨大差異,讓 CPU 不被內存拖垮,所以在 CPU 上增加了緩存。
當 CPU 請求內存中的數據時,會先查看緩存中是否有該數據,如果存在則直接返回該數據;如果不存在,則要先把內存中的數據載入緩存,然后再返回給 CPU。
所以我們的程序在執行時,往往就需要將數據從內存中讀取出來載入到緩存中,然后進行處理,處理完成之后再將數據回寫到內存中去。
除此以外,現代的計算機都是多CPU、多核的,程序也不再只運行在單一線程中,而是有多個線程在運行。
每個線程都會維護一份自己的內存副本,也就是 CPU 緩存,所以線程之間一定會存在數據一致性的問題。
一般來說,導致并發問題的根源不外乎以下這幾個原因:
可見性:一個線程對共享變量的修改,另一個線程是否可見?
原子性:一個或多個操作在 CPU 執行的過程中是否會被中斷?
有序性:程序編譯后的指令是否會按照代碼原本的順序執行?
遺憾的是,以上三個問題的答案都是不確定的,正因為這些不確定所以才會存在并發下的各種問題。
什么是可見性
如果我們的程序是在單個 CPU 上執行的,那么對于一個變量的原子性操作,無論如何都是不會出現問題的,不管是由一個線程還是多個線程來操作該變量,對結果都不會造成影響,因為內存的副本只有一個。
在單個 CPU 上操作雖然不會有問題,但是要強調一點,就是這個操作必須是原子性的。
比如線程A 設置變量 V 的值為10,那線程B獲取到該變量的值就是10,不會出現問題。
但是我們的程序是不可能只在單個 CPU 上運行的,而是要在多個 CPU 上運行的,在多個 CPU 上執行時,就會出現問題。
如線程A 在CPU1 中對變量 V 設置了一個新的值,但是線程B是在 CPU2 中,而 CPU1 對緩存進行了修改,并不會通知到 CPU2,所以這時線程B 拿到的變量 V 的值還是原來的老的值,也就是臟數據。
所以這就是導致并發問題的第一個原因,在一個線程中對共享變量的更改,對其他的線程是不可見的。
一個不可見性的示例
private?static?int?counter; private?static?boolean?stop; private?static?class?Reader?implements?Runnable?{private?int?newestCounter;@Overridepublic?void?run()?{while?(!stop)?{if?(newestCounter?!=?counter)?{newestCounter?=?counter;System.out.println("Reader?has?read?a?new?value="?+?newestCounter);}}System.out.println("Reader?stopped?at:"?+?System.currentTimeMillis());} } private?static?class?Writer?implements?Runnable?{@Overridepublic?void?run()?{for?(int?i?=?1;?i?<=?5;?i++)?{counter?=?i;System.out.println("writer?has?write?a?new?value?to?counter="?+?counter);//?等待?Reader?去讀取?counter?的變化try?{Thread.sleep(1000);}?catch?(InterruptedException?e)?{e.printStackTrace();}}stop?=?true;System.out.println("Writer?set?stop?at:"?+?System.currentTimeMillis());} }有兩個線程,一個 Reader 線程,一個 Writer 線程,并且有兩個共享變量:counter 和 stop 標志位。
啟動完兩個線程之后,打印出如下結果:
writer?has?write?a?new?value?to?counter=1 Reader?has?read?a?new?value=1 writer?has?write?a?new?value?to?counter=2 writer?has?write?a?new?value?to?counter=3 writer?has?write?a?new?value?to?counter=4 writer?has?write?a?new?value?to?counter=5 Writer?set?stop?at:1553871839283Writer 線程每隔一秒更新一次 counter 的值, Reader 線程只讀取到第一次 counter 的變化后的值,后面的值變更,都沒有讀取到,因為此時 Reader 線程已經將 counter 的值緩存在本地的內存副本中了, Writer 線程再怎么修改 counter 的值, Reader 線程也不會知道的,所以說 Writer 線程對于 counter 的修改,對 Reader 線程是不可見的。
同樣的, Reader 線程啟動后,讀取到 stop 變量的值為 false,在后續 Writer 線程將 stop 的值更新為 true 之后, Reader 線程也不會感知到,所以該程序會一直運行下去,因為 Reader 線程中的 stop 狀態永遠是 false。
如果我們將 Writer 線程中的休眠1s的代碼注釋掉,那么 Reader 線程可能會讀取到 stop 為 true。
為了解決這個問題,Java 給我們提供了一個 volatile 關鍵字,用來保證可見性。
當一個變量被 volatile 修飾后,表示著線程本地內存無效,當一個線程修改共享變量后他會立即被更新到主內存中,當其他線程讀取共享變量時,它會直接從主內存中讀取。
將上述的代碼中 counter 改為如下所示:
private?static?volatile?int?counter;即可返回正確的結果,Writer 線程每次對 counter 所做的修改,Reader 線程都能感知到,也就是說 Writer 對變量 counter 做的修改,對 Reader 線程是可見的。
除了 volatile 可以保證可見性之外,synchronized 關鍵字和 Lock 都能保證可見性,但是和 volatile 比起來,加鎖的方式都太重了,涉及到線程的阻塞與喚醒。
為什么會有線程切換
我們的程序都是由非常多的線程來協作執行的,而具體的執行都是給 CPU 下達指令,讓 CPU 去執行的。
那么每個線程該怎么使喚 CPU 讓他為自己干活呢?CPU 又是怎樣接受和處理這么多線程下發給自己的指令的呢?
由于 CPU 的執行非常快,而線程下發給他的任務有可能很快就執行完了,也可能由于其他的原因導致要執行很久。
如果一個任務執行的時間很久,是否需要一直占著 CPU 資源呢?
那 CPU 肯定不會同意的,CPU 為了更高效的處理各種任務,會為每個線程分配一段差不多長的時間用來執行他們的任務,當時間用完了之后,就去執行其他線程的任務了,這個時間就稱為 “時間片” ,執行不同的任務就是線程之間的切換了。
什么是原子性
雖然 CPU 通過時間片和線程切換,提高了程序運行的效率,但是說到線程切換,就可能導致另一種問題。
那么線程切換會在什么時候發生呢,在 CPU 指令執行完成之后的任何時間點都可能發生線程切換。
所以對于非原子操作就可能,操作執行了一半,發生了線程切換,另外的操作沒來得及執行,要等到下一個線程切換時,輪到自己占有 CPU 時,才能完成剩下的操作。
但是這樣明顯是有問題的,你執行了一半的操作,CPU 到別的地方轉了一圈回來之后,你原本的操作結果很可能就不對了,為什么會不對呢,因為你在等待 CPU 的這段時間內,很可能有別的線程也執行了和你相同的事。
我們知道數據庫事務中也有原子性的概念,他主要說的是事務中的多個操作,要么全部執行,要么全部不執行。
但是 Java 中的原子性,并不能保證要么全部執行,要么全部不執行,反而是很可能多個操作只執行了一部分。
說了這么多的 “操作”,Java 中的一條語句難道不就是一條 “操作” 嗎?
Java 中的一條語句還真不一定是一條 “操作”,這里說的 “操作” 是對 CPU 而言的,指的是一條指令。
而我們 Java 中的一條語句可能由一條指令組成,也可能由多條指令組成,操作系統只能保證一條指令的原子性,也就是要么該條指令執行,要么該條指令不執行,但是并不能保證多條指令的原子性。
所以說雖然線程切換解決了性能問題,但是卻帶來了原子性的問題。
Java 中的自增運算是一個典型的非原子性的操作,為什么這么說呢?
自增運算看似是一條語句,但是實際上需要三條 CPU 指令構成,分別是:取值,值加1,回寫值。
假設我們有一個變量 V,初始值是0,當兩個線程都對變量 V 執行自增操作,正常情況下,我們期望的結果是最終變量 V 的值是2,但是很可能由于縣城切換導致,最終被更新到內存中的變量的值是1。
線程 A 從內存中獲取到變量 V 的值為0,然后還沒來得及執行后續的指令,就發生了線程切換,線程 B 這時從內存中獲取到變量 V 的值也為 0,然后執行了后續的指令,將值加1并把值回寫到了內存中,這時內存中的變量 V 的值為1。
然后又發生了線程切換,線程 A 重新獲得了 CPU 資源,繼續執行未完成的指令,最終的也將變量 V 的值更新為1,然后寫入到了內存中。
整個過程由于發生了線程切換,導致非原子性的操作的結果出現了問題,事實上只要線程 A 在執行玩第一步或者第二步指令之后發生了線程切換,都會導致問題的發生。
而當線程 A 在執行完了第三步指令之后,再發生線程切換的話,則不會出現問題,原因是第三步指令執行完之后,內存中的變量值已經更新為最新值了,即便發生了線程切換,其他線程也會從內存中獲取到最新的值。當然啦,假如第三步指令都執行完了,那整個過程就相當于是一個原子性的過程了,那就不存在由于線程切換而導致的問題了。
一個非原子性的示例
private?int?increment?=?10000; private?int?unsafeCounter?=?0; private?void?unsafeIncrease()?{int?idx?=?0;while?(idx++?<?increment)?{unsafeCounter++;} } //?多個線程執行不安全的非原子性操作 Runnable?runnable?=?new?Runnable()?{@Overridepublic?void?run()?{unsafeIncrease();} }; Thread?t1?=?new?Thread(runnable); Thread?t2?=?new?Thread(runnable); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("unsafeCounter="?+?unsafeCounter);執行上述代碼之后,你會發現,unsafeCounter 的值是一個1000~2000之間的數字。
一個原子性的示例
private?int?increment?=?10000; private?AtomicInteger?safeCounter?=?new?AtomicInteger(0); private?void?safeIncrease()?{int?idx?=?0;while?(idx++?<?increment)?{safeCounter.incrementAndGet();} }//?多個線程執行安全的原子性操作 Runnable?runnable?=?new?Runnable()?{@Overridepublic?void?run()?{safeIncrease();} }; Thread?t1?=?new?Thread(runnable); Thread?t2?=?new?Thread(runnable); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("safeCounter="?+?safeCounter);執行上述代碼之后,你會發現,safeCounter 的值確實是2000。
為什么使用 AtomicInteger 就能保證原子性呢,這些 Atomic* 開頭的類都是為了解決原子性的問題而存在的,為什么他們就能保證原子性呢,原因是他們底層是通過 CAS 實現的。
通過 CAS 來設置某個變量的值時,會先檢查該變量內存中的值是否與當前期望的值一致,如果發現不一致則會重新獲取內存中的最新值,直到內存中的值與當前期望的值一致時,才將最新的值更新到內存中去,所以整個過程是原子性的。
復合原子操作是不是原子性的
現在我們知道了一個操作必須是原子性的才能保證在并發的情況下不出問題,具體可以使用原子類 Atomic* 來代替原始的變量。
但是 Atomic* 能否保證永遠不出問題呢?
答案是不會,只要使用的不正確,Atomic* 也會出現問題,例如下面的代碼:
private?int[]?nodes?=?new?int[]{1,?2}; private?AtomicInteger?nodeIndex?=?new?AtomicInteger(0); private?void?unsafeAtomic()?{int?i?=?0;while?(i++?<?100)?{//?獲取當前節點的索引,并將索引加1int?value?=?nodes[nodeIndex.getAndIncrement()];//?如果索引值等于節點的長度了,則設置為0nodeIndex.compareAndSet(nodes.length,?0);System.out.println("Thread="?+?Thread.currentThread().getName()?+?"?current?node?value="?+?value);} }上述代碼是模擬輪詢獲取可用節點的功能,假設有兩個節點,我們希望在多線程下能夠交替返回每一個節點給調用方,這樣可以做到負載均衡。
但是上述代碼無法做到交替返回,原因是 getAndIncreament() 和 compareAndSet() 方法雖然都是原子操作,但是他們放在一起作為一個復合操作就不是原子的了。
為什么會有重排序
編譯器或運行時環境為了優化程序性能,通常會對指令進行重新排序,所以重排序分兩種,分別是編譯期重排序和運行期重排序。
對于我們程序員來說,不要假設指令執行的順序,因為我們無法預知不同線程之間的指令會以何種順序執行。
java 會為了提升程序的性能,將指令進行重排,這又是一種導致并發環境下可能出錯的情況。
什么是有序性
在程序執行過程中,按照代碼的順序先后執行,這就是有序性,但是通過上面的介紹我們知道,不采取措施的話有序性是無法保證的。
因為我們寫的代碼,在編譯期就已經發生了變化,而在最終執行時也可能發生變化,如果我們進行干涉的話,執行的結果很可能會發生不可預知的變化。
一個有序性的示例
一個最經典的有序性的問題就是,獲取單例對象時,通過雙重檢查來保證對象只創建了一次,具體代碼如下:
public?class?Singleton?{private?static?Singleton?instance;public?static?Singleton?getInstance()?{if?(instance?==?null)?{synchronized?(Singleton.class)?{if?(instance?==?null)?{instance?=?new?Singleton();}}}return?instance;} }上述的代碼乍看上去是沒有問題的,如果不是指令重排序的話,也確實不會出現問題,但正是由于重排序的原因導致返回的單例對象可能出現問題。
線程A來獲取單例對象,這時發現instance==null,所以就進入了加鎖創建單例對象的代碼塊。
本來正常情況下,創建了一個對象然后返回就可以了,但是因為重排序的原因,創建對象的過程被重排序了:
正常應該是先初始化對象,然后再將分配好的內存指向該對象,但是重排序后的結果變成了,先將分配好的內存指向了對象,然后再初始化對象。
問題就出在這里,當將分配好的內存指向該對象后,如果發生了線程切換,線程B來獲取單例對象時,發現單例對象已經不為空了,所以直接就拿該對象去操作了,但是該對象并沒有進行過初始化,所以線程B后續再執行時就會出現空指針的問題。
為了解決重排序的問題,需要我們寫代碼時進行人為干預,具體怎么干預呢?那就是通過 volatile 關鍵字,可是上面我們剛說了 volatile 是解決可見性的問題的啊。
沒錯 volatile 除了可以解決可見性問題,也可以解決有序性的問題,通過 volatile 修飾的變量,編譯器和運行時環境不會對他進行指令重排。
并發問題是怎樣造成的
通過上面的分析,我們知道了造成并發問題的原因了,這些都是操作系統或者編譯期為了提升性能而做了一些努力,但是為了享受到這些性能上的優勢,我們就得付出更多的代價來寫出復雜的代碼。
換句話說,硬件上為了最求卓越的性能,而忽略了軟件實現上的復雜度,相當于硬件工程師給軟件工程師挖了一個坑。
CPU上的高速緩存造成了多線程下共享變量的可見性問題,可以通過 volatile 或加鎖的方式來解決。
線程切換造成了多線程下原子性的問題,可以通過原子類或加鎖的方式來解決。
編譯器或者運行環境為了優化程序性能造成了有序性的問題,可以通過 volatile 禁止指令重排。
總結
以上是生活随笔為你收集整理的并发问题是怎样造成的的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 拼车日滴滴派单的那些事
- 下一篇: 数据库为什么不适合搜索引擎的底层存储?