了解这些坑,再也不会出现诡异的BUG了~
前言
在高并發的情況下,你的程序是不是經常出現一些詭異的BUG,每次都是花費大量時間排查,但是你有沒有思考過這一切罪惡的源頭是什么呢?
?
幕后那些事
CPU、內存、I/O設備的速度差異越來越大,這也是程序性能的瓶頸,根據木桶理論,最終決定程序的整體性能取決于最慢的操作-讀寫I/O設備,單方面的提高CPU的性能是無用的。
為了平衡三者的差距,大牛前輩們不斷努力,最終做出了卓越的貢獻:
CPU增加了緩存,平衡與內存之間的速度差異
操作系統增加了進程、線程,以分時復用 CPU,進而均衡 CPU 與 I/O 設備的速度差異;
編譯程序優化指令執行次序,使得緩存能夠得到更加合理地利用。
注意:正是硬件前輩們做的這些貢獻,額外的后果需要軟件工程師來承擔,太坑了。
?
坑一:CPU緩存導致的可見性問題
在單核CPU的時代,所有的線程都在單個CPU上執行,不存在CPU數據和內存的數據的一致性。
一個線程對共享變量的修改,另外一個線程能夠立刻看到,我們稱為可見性。
因為所有的線程都是在同一個CPU緩存中讀寫數據,一個線程對緩存的寫,對于另外一個線程肯定是可見的。如下圖:
單核CPU與內存關系從上圖可以很清楚的了解,線程A對于變量的修改都是在同一個CPU緩存中,則線程B肯定是可見的。
但是多核時代的到來則意味著每個CPU上都有一個獨立的緩存,信息不再互通了,此時保證內存和CPU緩存的一致性就很難了。如下圖:
雙核CPU與內存關系從上圖可以很清楚的了解,線程A和線程B對變量A的改變是不可見的,因為是在兩個不同的CPU緩存中。
最簡單的證明方式則是在多核CPU的電腦上跑一個循環相加的方法,同時開啟兩個線程運行,最終得到的結果肯定不是正確的,如下:
public?class?TestThread?{private?Long?total=0L;//循環一萬次相加private?void?add(){for?(int?i?=?0;?i?<?10000;?i++)?{total+=1;}}//開啟兩個線程相加public?static?void?calc()?throws?InterruptedException?{TestThread?thread=new?TestThread();//創建兩個線程Thread?thread1=new?Thread(thread::add);Thread?thread2=new?Thread(thread::add);//啟動線程thread1.start();thread2.start();//阻塞主線程thread1.join();thread2.join();System.out.println(thread.total);}上述代碼在單核CPU的電腦上運行的結果肯定是20000,但是在多核CPU的電腦上運行的結果則是在10000~20000之間,為什么呢?
原因很簡單,第一次在兩個線程啟動后,會將total=0讀取到各自的CPU緩存中,執行total+1=0后,各自將得到的結果total=1寫入到內存中(理想中應該是total=2),由于各自的CPU緩存中都有了值,因此每個線程都是基于各自CPU緩存中的值來計算,因此最終導致了寫入內存中的值是在10000~20000之間。
注意:如果循環的次數很少,這種情況不是很明顯,如果次數設置的越大,則結果越明顯,因為兩個線程不是同時啟動的。
?
坑二:線程切換導致的原子性問題
早期的操作系統是基于進程調度CPU,不同進程間是共享內存空間的,比如你在IDEA寫代碼的同時,能夠打開QQ音樂,這個就是多進程。
操作系統允許某個進程執行一段時間,比如40毫秒,過了這個時間則會選擇另外一個進程,這個過程稱之為任務切換,這個40毫秒稱之為時間片,如下圖:
任務切換在一個時間片內,如果一個進程進行IO操作,比如讀文件,這個時候該進程可以把自己標記為休眠狀態并讓出CPU的使用權,待文件讀進內存,操作系統會將這個休眠的進程喚醒,喚醒后的進程就有機會重新獲得CPU的使用權。
現代的操作系統更加輕量級了,都是基于線程調度,現在提到的任務切換大都指示線程切換。
注意:操作系統進行任務切換是基于CPU指令。
基于CPU指令是什么意思呢?Java作為高級編程語言,一條簡單的語句可能底層就需要多條CPU指令,例如total+=1這條語句,至少需要三條CPU指令,如下:
指令1:將total從內存讀到CPU寄存器中
指令2:在寄存器中執行+1
指令3:將結果寫入內存(緩存機制可能導致寫入的是CPU緩存而不是內存)
基于CPU指令是什么意思呢?簡單的說就是任務切換的時機可能是上面的任何一條指令完成之后。
我們假設在線程A執行了指令1后做了任務切換,此時線程B執行,雖然執行了total+=1,但是最終的結果卻不是2,如下圖:
非原子操作我們把一個或者多個操作在CPU執行過程中不被中斷的特性稱之為原子性。
注意:CPU僅僅能保證CPU指令執行的原子性,并不能保證高級語言的單條語句的原子性。
此處分享一道經典的面試題:Long類型的數據在32位操作系統中加減是否存在并發問題?答案:是,因為Long類型是64位,在32位的操作系統中執行加減肯定是要拆分成多個CPU指令,因此無法保證加減的原子性。
?
坑三:編譯優化帶來的有序性問題
編譯優化算是最詭異的一個難題了,雖然高級語言規定了代碼的執行順序,但是編譯器有時為了優化性能,則會改變代碼執行的順序,比如a=4;b=3;,在代碼中可能給人直觀的感受是a=4先執行,b=3后執行,但是編譯器可能為了優化性能,先執行了b=3,這種對于我們肉眼是不可見的,上面例子中雖然不影響結果,但是有時候編譯器的優化可能導致意想不到的BUG。
雙重校驗鎖實現單例不知大家有沒有聽說過,代碼如下:
public?class?Singleton?{static?Singleton?instance;static?Singleton?getInstance(){if?(instance?==?null)?{synchronized(Singleton.class)?{if?(instance?==?null)instance?=?new?Singleton();}}return?instance;} }這里我去掉了volatile關鍵字,那么此時這個代碼在并發的情況下有問題嗎?
上述代碼看上去很完美,但是最大的問題就在new Singleton();這行代碼上,預期中的new操作順序如下:
分配一塊內存N
在內存N上初始化Singleton對象
將內存N的地址賦值給instance變量
但是實際上編譯優化后的執行順序如下:
分配一塊內存N
將內存N的地址賦值給instance變量
在內存N上初始化Singleton對象
很多人問了,優化后影響了什么?
將內存N的地址提前賦值給instance變量意味著instance!=null是成立的,一旦是高并發的情況下,線程A執行第二步發生了任務切換,則線程B執行到了if (instance == null)這個判斷,此時不成立,則直接返回了instance,但是此時的instance并沒有初始化過,如果此時訪問其中的成員變量則會發生空指針異常,執行流程如下圖:
單例NPE?
總結
并發編程是區分高低手的門檻,只有深刻理解三大特性:可見性、原子性、有序性才能解決詭異的BUG。
本文分析了帶來這三大特性源頭,如下:
CPU緩存導致的可見性問題
線程切換帶來的原子性問題
編譯優化帶來的有序性問題
有道無術,術可成;有術無道,止于術
歡迎大家關注Java之道公眾號
好文章,我在看??
新人創作打卡挑戰賽發博客就能抽獎!定制產品紅包拿不停!總結
以上是生活随笔為你收集整理的了解这些坑,再也不会出现诡异的BUG了~的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: JAVA 设计的七大原则
- 下一篇: 第2课第3节_Java面向对象编程_继承