本文從計算機系統層面來講述在提升性能的過程中,引發的一系列問題。讀完本文你將get到並發編程過程中的原子性,可見性,有序性三大問題的來源。
隨着硬件發展速度的放緩,摩爾定律已經不在生效,各個硬件似乎已經到了瓶頸;然而隨着互聯網的普及,網民數量不斷增加,對系統的性能帶來了巨大的挑戰。因此我們要通過各種方式來壓榨硬件的性能,從而提高系統的性能進而提升用戶體驗,提升企業的競爭力。
由於CPU,內存,IO三者之間速度差異,為了提高系統性能,計算機系統對這三者速度進行平衡。
- CPU 增加了緩存,以均衡與內存的速度差異;
- 操作系統增加了進程、線程,以分時復用 CPU,進而均衡 CPU 與 I/O 設備的速度差異;
- 編譯程序優化指令執行次序,使得緩存能夠得到更加合理地利用。
緩存導致得可見性的問題
一個線程對共享變量的修改,另外一個線程能夠立刻看到,我們稱為可見性。
多核時代,每顆 CPU 都有自己的緩存,這時 CPU 緩存與內存的數據一致性就沒那么容易解決了,當多個線程在不同的 CPU 上執行時,這些線程操作的是不同的 CPU 緩存。
線程切換帶來的原子性問題
由於IO和cpu執行速度的差別巨大,所以在早期操作系統中就發明的多線程,即使在單核的cpu上我們也可以一遍聽着歌,一邊寫着bug,這就是多線程。
早期操作系統基於進程來調度cpu, 不同進程間是不共享內存空間的,所以進程要做任務切換要切換內存映射地址,而一個進程創建的所有線程都是共享一個內存空間,所以線程做任務切換成本很低。現代操作系統都基於更輕量級的線程來調度,現在我們提到的“任務切換”都是指“線程切換”。
因為式單核cpu,所以同一時刻只能執行一個任務,所以多線程通常使用搶占的方式來獲取操作系統的時間片。
Java的並發編程中都是基於多線程,線程的切換時機通常在一條cpu指令執行完畢之后,而Java作為一門高級編程語言,通常一條語句可能由多個cpu指令來完成。例如:count += 1, 至少需要三條指令。
-
指令1:首先,需要把變量count從該內存加載到cpu的寄存器
-
指令2:之后,在寄存器中執行+1操作
-
指令3: 最后,將結果寫入內存(忽略緩存機制)
假設count = 0, 有2個線程同時執行count+=1這段代碼。線程A執行完指令1將count = 0加載到cpu寄存器,進行了任務切換到了線程B執行,線程B執行完之后將count = 1寫入到內存,然后再切換到線程A執行,此時線程A獲取寄存器中的count=0進行+1操作得到結果也是1,所以最終內存中的count = 1,而我們所期望的是2.CPU層面的原子操作僅僅是在指令級別的,既一條cpu指令不可中斷。在高級語言中,我們為了避免以上情況發生,我們把一個或多個操作在cpu執行過程中不被中斷的特性稱為原子性。
編譯優化帶來的有序性問題
為了提高程序的執行效率,編譯器有時會在編譯過程中對程序的進行優化,從而改變程序的執行順序。如程序“a = 4; b = 5”,在優化后執行順序可能變成“b = 5; a = 4”。通常進行一項優化過程中可能會帶來另一項問題,改變程序的執行順序通常也會導致讓人意想不到的bug。
Java領域中一個經典的案例就是利用雙重檢查創建單例對象,代碼如下:在獲取實例getInstance()方法中,我們首先判斷instance是否為空,如果為空則鎖住Singleton.class對象並再次檢查instance是否為空,如果仍然為空則創建Singleton的一個實例。
public class Singleton {
static Singleton instance;
/**
* 獲取Singleton對象
*/
public static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
假設有兩個線程 A、B 同時調用 getInstance() 方法,他們會同時發現 instance == null,於是同時對 Singleton.class 加鎖,此時 JVM 保證只有一個線程能夠加鎖成功(假設是線程 A),另外一個線程則會處於等待狀態(假設是線程 B);線程 A 會創建一個 Singleton 實例,之后釋放鎖,鎖釋放后,線程 B 被喚醒,線程 B 再次嘗試加鎖,此時是可以加鎖成功的,加鎖成功后,線程 B 檢查 instance == null 時會發現,已經創建過 Singleton 實例了,所以線程 B 不會再創建一個 Singleton 實例。
以上過程僅僅是我們的理想情況下,但是實際過程中往往會創建多次Singleton實例。原因是創建一個對象需要多條cpu指令,且編譯器可能對這幾條指令進行了排序。在執行new語句創建一個對象時,通常會包含一下三個步驟(此處進行了簡化,實際實現過程會比此過程復雜):
- 在堆內存為對象分配一塊內存M
- 在內存M區域進行Singleton對象的初始化
- 將內存M地址賦值給instance變量。
但是實際優化后的執行順序可能時以下這種情況: - 在堆內存為對象分配一塊內存M
- 將內存M地址賦值給instance變量。
- 在內存M區域進行Singleton對象的初始化
假設A,B線程同時執行到了getInstance()方法,線程A執行完instance = $M(將內存M地址賦值給instance變量,但是未將對象進行初始化)后切換到B線程,當B線程執行到instance == null時,由於instance已經指向了內存M的地址,所以會返回false,直接返回instance,如果我們這是訪問instance中的成員變量或者方法時就可能會出現NullPointException。
總結
在操作系統平衡CPU,內存,IO三者速度差異過程中進行了一系列的優化。
- CPU 增加了緩存,以均衡與內存的速度差異;
- 操作系統增加了進程、線程,以分時復用 CPU,進而均衡 CPU 與 I/O 設備的速度差異;
- 編譯程序優化指令執行次序,使得緩存能夠得到更加合理地利用。
這三個不同方面的優化也帶來了可見性,原子性,有序性等問題,他們通常是並發程序的bug的源頭。
筆者的個人博客網站