併發和高併發

併發和高併發

併發包含了高併發,併發有可能發生在單個機器上,也有可能發生在分佈式的場景下,發生在分佈式場景下的一般就是高併發了。Java併發包下的java.util.concurrent的類都是解決在單機情況下的併發問題的,我們常見和常用的類包括:AtomicInteger、AtomicLong、ReentrantLock、ConcurrentHashMap以及ExecutorService等,這種問題相對簡單,而我們在業務實現的時候大部分關注的是高併發的場景,即請求在有限的資源的前提下請求打在多臺機器上的場景。應對高併發的已經不是一個類、一個技術甚至一種語言的問題了,高併發需要從網絡、前端、後端、運維各個方面進行保障。這篇文章是前邊架構系列文章的延伸,後續將討論高併發場景下的具體應用,本文的重點梳理清楚併發和高併發的區別。

劃重點:以下內容大部分來自這兩篇知乎文章,大家儘量閱讀原文

https://zhuanlan.zhihu.com/p/64988344

https://zhuanlan.zhihu.com/p/34805082

單機併發

這篇文章解釋了單機併發的根源所在,concurrent併發包是如何解決這些問題的,可以自行研究源碼。

問題根源之一:緩存導致的可見性問題

 

CPU的執行操作數據的過程一般是這樣的,CPU首先會從內存把數據拷貝到CPU緩存區。

然後CPU再對緩存裏面的數據進行更新等操作,最後CPU把緩存區裏面的數據更新到內存。


磁盤、內存、CPU緩存會按如下形式協作。

緩存導致的可見性問題就是指我們在操作CPU緩存過程中,由於多個CPU緩存之間獨立不可見的特性,導致共享變量的操作結果無法預期。

在單核CPU時代,因爲只有一個核心控制器,所以只會有一個CPU緩存區,這時各個線程訪問的CPU緩存也都是同一個,在這種情況一個線程把共享變量更新到CPU緩存後另外一個線程是可以馬上看見的,因爲他們操作的是同一個緩存,所以他們操作後的結果不存在可見性問題。

 

 

而隨着CPU的發展,CPU逐漸發展成了多核,CPU可以同時使用多個核心控制器執行線程任務,當然CPU處理同時處理線程任務的速度也越來越快了,但隨之也產生了一個問題,多核CPU每個核心控制器工作的時候都會有自己獨立的CPU緩存,每個核心控制器都執行任務的時候都是操作的自己的CPU緩存,CPU1與CPU2它們之間的緩存是相互不可見的。

這種情況下多個線程操作共享變量就因爲緩存不可見而帶來問題,多線程的情況下線程並不一定是在同一個CUP上執行,它們如果同時操作一個共享變量,但因爲在不同的CPU執行所以他們只能查看和更新自己CPU緩存裏的變量值,線程各自的執行結果對於別的線程來說是不可見的,所以在併發的情況下會因爲這種緩存不可見的情況會導致問題出現。

 

比如下面的程序:

兩個線程同時調用addNumber() 方法對number屬性進行+1 ,循環10W次,等兩個線程執行結束後,我們的預期結果number的值應該是20000,可是我們在多核CPU的環境下執行結果並非我們預期的值。

public class TestCase {

 private  int number=0;

 public void addNumber(){
 for (int i=0;i<100000;i++){
 number=number+1;
        }

    }

 public static void main(String[] args) throws Exception {
        TestCase testCase=new TestCase();
         Thread threadA=new Thread(new Runnable() {
 @Override
 public void run() {
 testCase.addNumber();
             }
         });

        Thread threadB=new Thread(new Runnable() {
 @Override
 public void run() {
 testCase.addNumber();
            }
        });
        threadA.start();
        threadB.start();
        threadA.join();
        threadB.join();
       System.out.println("number="+testCase.number);
    }
}

 

 


問題根源之二:CPU切換線程執導致的原子性問題

 

首先我們先理解什麼叫原子性,原子性就指是把一個操作或者多個操作視爲一個整體,在執行的過程不能被中斷的特性叫原子性。

因爲IO、內存、CPU緩存他們的操作速度有着巨大的差距,假如CPU需要把CPU緩存裏的一個變量寫入到磁盤裏面,CPU可以馬上發出一條對應的指令,但是指令發出後的很長時間CPU都在等待IO的結束,而在這個等待的過程中CPU是空閒的。

所以爲了提升CPU的利用率,操作系統就有了進程和時間片的概念,同一個進程裏的所有線程都共享一個內存空間,CPU每執行一個時間段就會切換到另外一個進程處理指令,而這執行的時間長度是是以時間片(比如每個時間片爲1毫秒)爲單位的,通過這種方式讓CPU切換着不同的進程執行,讓CPU更好的利用起來,同時也讓我們不同的進程可以同時運行,我們可以一邊操作word文檔,一邊用QQ聊天。

後來操作系統又在CPU切換進程執行的基礎上做了進一步的優化,以更細的維度“線程”來切換任務執行,更加提高了CPU的利用率。但正是這種CPU可以在不同線程中切換執行的方式會使得我們程序執行的過程中產生原行性問題。

 

比如說我們以一個變量賦值爲例:

語句1:Int number=0;

語句2:number=number+1;

在執行語句2的時候,我們的直覺number=number+1 是一個不可分割的整體,但是實際CPU操作過程中並非如此,我們的編譯器會把number=number+1 拆分成多個指令交給CPU執行。

 

number=number+1的指令可能如下:

指令1:CPU把number從內存拷貝到CPU緩存。

指令2:把number進行+1的操作。

指令3:把number回寫到內存。

 

在這個時候如果有多線程同時去操作number變量,就很有可能出現問題,因爲CPU會在執行上面任何一個指令的時候切換線程執行指令,這個時候就可能出現執行結果與我們預期結果不符合的情況。

 

比如如果現在有兩個線程都在執行number=number+1,結果CPU執行流程可能會如下:

 

執行細節:

1、CPU先執行線程A的執行,把number=0拷貝到CUP寄存器。

2、然後CPU切換到線程B執行指令。

3、線程B 把number=0拷貝到CUP寄存器。

4、線程B 執行number=number+1 操作得到number=1。

5、線程B把number執行結果回寫到緩存裏面。

6、然後CPU切換到線程A執行指令。

7、線程A執行number=number+1 操作得到numbe=1。

8、線程A把number執行結果回寫到緩存裏面。

9、最後內存裏面number的值爲1。


問題根源之三::編譯器優化帶來的指令重排序問題

 

熟悉單例模式的人應該有了解過 一個叫“雙重檢查鎖”的問題,就是下面的代碼這樣

public class Singleton {
 
 private Singleton() {}

 private static Singleton sInstance;

 public static Singleton getInstance() {

 if (sInstance == null) {	//第一次驗證是否爲null
 synchronized (Singleton.class) {   //加鎖
 if (sInstance == null) {	  //第二次驗證是否爲null
 sInstance = new Singleton();  //創建對象
                }
            }
        }
 return sInstance;
    }

}

這個代碼在極低概率的情況下獲得 Instance 爲null的對象,其核心問題出在 Instance new Singleton(); 這行代碼上,當我們執行Instance new Singleton();這行代碼時會分解成三個指令執行。

1、爲對象分配一個內存空間。

2、在分配的內存空間實例化對象。

3、把Instance 引用地址指向內存空間。

 

如果按正常的順序執行,那麼這個案例代碼永遠不會出問題,而問題就出在我們的編譯器會自作聰明的優化指令順序,就像上面的指令,它會也許優化成下面的順序

1、爲對象分配一個內存空間。

2、把instance 引用地址指向內存空間。

3、在分配的內存空間實例化對象。

 

如果nstance new Singleton()指令優化成上面的順序,當併發訪問的時候,可能會出現這樣的情況

1、A線程進入方法進行第1次instance == null判斷。

2、此時A線程發現instance 爲null 所以對Singleton.class加鎖。

3、然後A線程進入方法進行第2次instance == null判斷。

4、然後A線程發現instance 爲null,開始進行對象實例化。

5、爲對象分配一個內存空間。

6、把Instance 引用地址指向內存空間(而就在這個指令完成後,線程B進入了方法)。

7、B線程首先進入方法進行第1次instance == null判斷。

8、B線程此時發現instance 不爲null ,所以它會直接返回instance (而此時返回的instance 是A線程還沒有初始化完成的對象)

最終線程B拿到的instance 是一個沒有實例化對象的空內存地址,所以導致instance使用的過程中造成程序錯誤。

高併發

高併發向上看需要考慮網路、機器、限流、監控、擴容等各方面

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章