【高并發】解密導致并發問題的第二個幕后黑手——原子性問題
大家好,我是冰河~~
今天,我們繼續大冰和小菜的故事。
寫在前面
大冰:小菜童鞋,昨天講解的內容復習了嗎?
小菜:復習了大冰哥,昨天的內容干貨滿滿啊,感覺自己收獲很大。
大冰:那你說說昨天都講了哪些內容呢?
小菜:昨天主要講了線程的可見性和可見性問題。可見性是指一個線程對共享變量的修改,另一個線程能夠立刻看到,如果不能立刻看到,就可能會產生可見性問題。在單核CPU上是不存在可見性問題的,可見性問題主要存在于運行在多核CPU上的并發程序。歸根結底,可見性問題還是由CPU的緩存導致的,而緩存導致的可見性問題是導致諸多詭異的并發編程問題的“幕后黑手”之一。
大冰:很好,小菜童鞋,復習的不錯,今天,我們繼續講并發問題的第二個“幕后黑手”——線程切換帶來的原子性問題,這個知識點也是非常重要的,一定要好好聽。
原子性
原子性是指一個或者多個操作在CPU中執行的過程不被中斷的特性。原子性操作一旦開始運行,就會一直到運行結束為止,中間不會有中斷的情況發生。
我們也可以這樣理解原子性,就是線程在執行一系列操作時,這些操作會被當做一個不可拆分的整體執行,這些操作要么全部執行,要么全部不執行,不會存在只執行一部分的情況,這就是原子性操作。
關于原子性操作一個典型的場景就是轉賬,例如,小明和小剛的賬戶余額都是200元,此時小明給小剛轉賬100元,如果轉賬成功,則小明的賬戶余額為100元,小剛的賬戶余額為300元;如果轉賬失敗,則小明和小剛的賬戶余額仍然為200元。不會存在小明賬戶為100元,小剛賬戶為200元,或者小明賬戶為200元,小剛賬戶為300元的情況。
這里,小明給小剛轉賬100元的操作,就是一個原子性操作,它涉及小明賬戶余額減少100元,小剛賬戶余額增加100元的操作,這兩個操作是一個不可分割的整體,要么全部執行,要么全部不執行。
小明給小剛轉賬成功,則如下所示。
小明給小剛轉賬失敗,則如下所示。
不會出現小明賬戶為100元,小剛賬戶為200元的情況。
也不會出現小明賬戶為200元,小剛賬戶為300元的情況。
線程切換
在并發編程中,往往設置的線程數目會大于CPU數目,而每個CPU在同一時刻只能被一個線程使用。而CPU資源的分配采用了時間片輪轉策略,也就是給每個線程分配一個時間片,線程在這個時間片內占用CPU的資源來執行任務。當占用CPU資源的線程執行完任務后,會讓出CPU的資源供其他線程運行,這就是任務切換,也叫做線程切換或者線程的上下文切換。
如果大家還是不太理解的話,我們可以用下面的圖來模擬線程在CPU中的切換過程。
在圖中存在線程A和線程B兩個線程,其中線程A和線程B中的每個小方塊代表此時線程占有CPU資源并執行任務,這個小方塊占有的時間,被稱為時間片,在這個時間片中,占有CPU資源的線程會在CPU上執行,未占有CPU資源的線程則不會在CPU上執行。而每個虛線部分就代表了此時的線程不占用CPU資源。CPU會在線程A和線程B之間頻繁切換。
原子性問題
理解了什么是原子性,再看什么是原子性問題就比較簡單了。
原子性問題是指一個或者多個操作在CPU中執行的過程中出現了被中斷的情況。
線程在執行某項操作時,此時如果CPU發生了線程切換,CPU轉而去執行其他的任務,中斷了當前線程執行的操作,這就會造成原子性問題。
如果你還不能理解的話,我們來舉一個例子:假設你在銀行排隊辦理業務,小明在你前面,柜臺的業務員為小明辦理完業務,正好排到你時,此時銀行下班了,柜臺的業務員微笑著告訴你:實在不好意思,先生(女士),我們下班了,您明天再來吧!此時的你就好比是正好占有了CPU資源的線程,而柜臺的業務員就是那顆發生了線程切換的CPU,她將線程切換到了下班這個線程,執行下班的操作去了。
Java中的原子性問題
在Java中,并發程序是基于多線程技術來編寫的,這也會涉及到CPU的對于線程的切換問題,正是CPU中對任務的切換機制,導致了并發編程會出現原子性的詭異問題,而原子性問題,也成為了導致并發問題的第二個“幕后黑手”。
在并發編程中,往往Java語言中一條簡單的語句,會對應著CPU中的多條指令,假設我們編寫的ThreadTest類的代碼如下所示。
package io.mykit.concurrent.lab01; /** * @author binghe * @version 1.0.0 * @description 測試原子性 */ public class ThreadTest { private Long count; public Long getCount(){ return count; } public void incrementCount(){ count++; } }
接下來,我們打開ThreadTest類的class文件所在的目錄,在cmd命令行輸入如下命令。
javap -c ThreadTest
得出如下的結果信息,如下所示。
d:>javap -c ThreadTest Compiled from "ThreadTest.java" public class io.mykit.concurrent.lab01.ThreadTest { public io.mykit.concurrent.lab01.ThreadTest(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."
這里,我們主要關注下incrementCount()方法對應的CPU指令,如下所示。
public void incrementCount(); Code: 0: aload_0 1: getfield #2 // Field count:Ljava/lang/Long; 4: astore_1 5: aload_0 6: aload_0 7: getfield #2 // Field count:Ljava/lang/Long; 10: invokevirtual #3 // Method java/lang/Long.longValue:()J 13: lconst_1 14: ladd 15: invokestatic #4 // Method java/lang/Long.valueOf:(J)Ljava/lang/Long; 18: dup_x1 19: putfield #2 // Field count:Ljava/lang/Long; 22: astore_2 23: aload_1 24: pop 25: return
可以看到,Java語言中短短的幾行incrementCount()方法竟然對應著那么多的CPU指令。這些CPU指令我們大致可以分成三步。
指令1:把變量count從內存加載的CPU寄存器。
指令2:在寄存器中執行count++操作。
指令3:將結果寫入緩存(可能是CPU緩存,也可能是內存)。
在操作系統執行線程切換時,可能發生在任何一條CPU指令完成后,而不是程序中的某條語句完成后。如果線程A執行完指令1后,操作系統發生了線程切換,當兩個線程都執行count++操作后,得到的結果是1而不是2。這里,我們可以使用下圖來表示這個過程。
由上圖,我們可以看出:線程A將count=0加載到CPU的寄存器后,發生了線程切換。此時內存中的count值仍然為0,線程B將count=0加載到寄存器,執行count++操作,并將count=1寫到內存。此時,CPU切換到線程A,執行線程A中的count++操作后,線程A中的count值為1,線程A將count=1寫入內存,此時內存中的count值最終為1。
所以,如果在CPU中存在正在執行的線程,恰好此時CPU發生了線程切換,則可能會導致原子性問題,這也是導致并發編程頻繁出問題的根源之一。我們只有充分理解并掌握線程的原子性以及引起原子性問題的根源,并在日常工作中時刻注意編寫的并發程序是否存在原子性問題,才能更好的編寫出并發程序。
總結
緩存帶來的可見性問題、線程切換帶來的原子性問題和編譯優化帶來的有序性問題,是導致并發編程頻繁出現詭異問題的三個源頭,我們已經介紹了緩存帶來的可見性問題和線程切換帶來的原子性問題。下一篇中,我們繼續深耕高并發中的有序性問題。
寫在最后
大冰:好了,今天就是我們講的主要內容了,今天的內容同樣最重要,回去后要好好復習。
小菜:好的,大冰哥,一定好好復習。
文末福利
最后,附上并發編程需要掌握的核心技能知識圖,祝大家在學習并發編程時,少走彎路。
推薦閱讀
《要想學好并發編程,關鍵是要理解這三個核心問題》
《實踐出真知:全網最強秒殺系統架構解密,不是所有的秒殺都是秒殺!!》
《一文搞懂線程與線程池》
《Java中線程到底是按什么順序執行的?你了解的可能是錯誤的!》
《學好并發編程,關鍵是要理解這三個核心問題!》
《高并發系統為何使用消息隊列?》
《深入解析Callable接口》
《兩種異步模型與深度解析Future接口(一)》
《兩種異步模型與深度解析Future接口(二)》
《線程的執行順序與你想的不一樣!!》
《SimpleDateFormat類的線程安全問題和解決方案(問題篇)》
《SimpleDateFormat類的線程安全問題和解決方案(解決方案篇一)》
《SimpleDateFormat類的線程安全問題和解決方案(解決方案篇二)》
《不得不說的線程池與ThreadPoolExecutor類淺析》
《深度解析線程池中那些重要的頂層接口和抽象類》
《從源碼角度分析創建線程池究竟有哪些方式》
《通過源碼深度解析ThreadPoolExecutor類是如何保證線程池正確運行的》
《通過ThreadPoolExecutor類的源碼深度解析線程池執行任務的核心流程》
《通過源碼深度分析線程池中Worker線程的執行流程》
《ScheduledThreadPoolExecutor與Timer的區別和簡單示例》
《深度解析ScheduledThreadPoolExecutor類的源代碼》
《由InterruptedException異常引發的思考》
《淺談AQS中的CountDownLatch、Semaphore與CyclicBarrier》
《ReentrantLock、ReentrantReadWriteLock、StampedLock與Condition》
《朋友去面試竟然栽在了Thread類的源碼上》
《如何使用Java7提供的Fork/Join框架實現高并發程序?》
《導致并發編程頻繁出問題的“幕后黑手”》
《一文解密詭異并發問題的第一個幕后黑手——可見性問題》
好了,今天就到這兒吧,我是冰河,我們下期見~~
await Java JDK 任務調度 多線程
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。