併發編程—併發編程中的基礎概念

計算機的存儲系統結構

計算機的存儲系統以一種分層次的結構構造,以下圖所示
java

  1. 存儲器的頂層是CPU的寄存器。它們用與CPU相同的材料和工藝製成,其存取速度和CPU的運行速度同樣快。
    寄存器是什麼?
    CPU工做時須要從內存中取出指令並執行,可是經過訪問內存獲得指令和數據的時間要比執行花費的時間長得多,因此CPU都有一些用來保存關鍵變量和臨時結果的寄存器。另外還有程序計數器、堆棧指針寄存器和程序狀態字寄存器等,都有各自的功能。
  2. 高速緩存。高速緩存被分割成高速緩存行(Cache Line),其典型大小爲64字節,地址0~63對應高速緩存行0,地址64~127對應高速緩存行1,以此類推。當CPU須要某個數據時,會先去查找所須要的高速緩存行是否在高速緩存中,若是在,稱爲高速緩存命中,也就不須要經過總線把訪問請求送往內存。高速緩存若是未命中則須要訪問內存,這將會付出很大的時間代價。

    以下是最簡單的高速緩存配置圖:
    高速緩存示意圖
    早期的一些系統就是相似的架構。在這種架構中,CPU核心再也不直連到主存。數據的讀取和存儲都通過高速緩存。CPU核心與高速緩存之間是一條特殊的快速通道。在簡化的表示法中,主存與高速緩存都連到系統總線上,這條總線同時還用於與其它組件(好比硬盤控制器、鍵盤控制器等)通訊。算法

    在高速緩存出現後不久,系統變得更加複雜。高速緩存與主存之間的速度差別進一步拉大,直到加入了另外一級緩存。新加入的這一級緩存比第一級緩存更大,可是更慢。因爲加大一級緩存的作法從經濟上考慮是行不通的,因此有了二級緩存,甚至如今的有些系統擁有三級緩存,以下圖:
    多級高速緩存示意圖編程

  3. 內存(又稱主存),這是存儲系統的主力。全部不能在高速緩存中命中的訪問請求都會轉往內存。
  4. 磁盤(硬盤),也稱爲外存。因爲磁盤的機械結構致使其訪問速度很慢。

什麼是上下文切換?

《Java併發編程的藝術》中是這樣描述的:
即便是單核處理器也支持多線程執行代碼,CPU經過給每一個線程分配CUP時間片來實現這個機制。時間片是CPU分配給各個線程的時間,由於時間片很是短,因此CPU經過不停地切換線程執行,讓咱們感受多個線程時同時執行的,時間片通常是幾十毫秒(ms)。
CPU經過時間片分配算法來循環執行任務,當前任務執行一個時間片後會切換到下一個任務。可是,在切換前會保存上一個任務的狀態,以便下次切換回這個任務時,能夠再加載這個任務的狀態。因此任務從保存到再加載的過程就是一次上下文切換api

內存模型的相關概念

計算機在執行程序時,每條指令都是由CPU執行,執行指令過程當中,一定會涉及到數據的讀取和寫入。程序運行過程當中的臨時數據是存放在主存(物理內存)當中的,這時就存在一個問題,在九十年代之後,現代計算機的CPU執行速度愈來愈快,而從內存讀取數據和向內存寫入數據的過程跟CPU執行指令的速度比起來要慢的多,所以在CPU和內存之間加入了工做內存(working memory,是高速緩存和寄存器的一個抽象,這個解釋源於《Concurrent Programming in Java: Design Principles and Patterns, Second Edition》§2.2.7,原文:Every thread is defined to have a working memory (an abstraction of caches and registers) in which to store values. ),注意工做內存並非主存的某個部分緩存

當程序在運行過程當中,會將運算須要的數據從主存複製一份到CPU的工做內存當中,那麼CPU進行計算時就能夠直接從它的工做內存讀取數據和向其中寫入數據,當運算結束以後,再將工做內存中的數據刷新到主存中。舉個簡單的例子,好比下面的這段代碼:安全

i = i + 1;

當線程執行這個語句時,會先從主存當中讀取i的值,而後複製一份到工做內存當中,而後CPU執行指令對i進行加1操做,而後將執行完的結果寫入工做內存,最後將工做內存中i最新的值刷新到主存中。多線程

這個代碼在單線程中運行是沒有任何問題的,可是在多線程中運行就會有問題了。在多核CPU中,每條線程可能運行於不一樣的CPU中,所以每一個線程運行時有本身的工做內存。架構

好比同時有2個線程執行這段代碼,假如初始時i的值爲0,那麼咱們但願兩個線程執行完以後i的值變爲2。可是事實會是這樣嗎?併發

咱們知道整個過程應該是:兩個線程首先都要從主存中讀取i的值存入到各自所在的CPU的工做內存中,而後兩個線程各自進行加1操做,最後各自把i的最新值寫入到內存。可能存在這樣一種狀況:,線程1首先讀取了i的值是0,而後進行加1,在沒有將結果刷新的主存以前,若是線程2此時讀取主存中的值也會是0,線程2一樣本身進行加1操做。最終兩個線程計算的結果都是1,無論是線程1仍是線程2先將結果刷新到主存中,主存中的i最終的值都是1,而不是2。這就是著名的緩存一致性問題。一般稱這種被多個線程訪問的變量爲共享變量。(注意:這段過程分析是以i = i + 1是非原子性操做爲前提的,關於原子性操做的概念在後面有解釋)app

也就是說,若是一個變量在多個CPU中都存在緩存(通常在多線程編程時纔會出現),那麼就可能存在緩存不一致的問題。
爲了解決緩存不一致性問題,一般來講有如下2種解決方法:

  1. 經過在總線加LOCK#鎖的方式
  2. 經過緩存一致性協議

這2種方式都是硬件層面上提供的方式。
在早期的CPU當中,是經過在總線上加LOCK#鎖的形式來解決緩存不一致的問題。由於CPU和其餘部件進行通訊都是經過總線來進行的,若是對總線加LOCK#鎖的話,也就是說阻塞了其餘CPU對其餘部件訪問(如內存),從而使得只能有一個CPU能使用這個變量的內存。

好比上面例子中 若是一個線程在執行 i = i +1,若是在執行這段代碼的過程當中,在總線上發出了LCOK#鎖的信號,那麼只有等待這段代碼徹底執行完畢以後,其餘CPU才能從變量i所在的內存讀取變量,而後進行相應的操做。這樣就解決了緩存不一致的問題。

可是上面的方式會有一個問題,因爲在鎖住總線期間,其餘CPU沒法訪問內存,致使效率低下。
因此就出現了緩存一致性協議。最出名的就是Intel 的MESI協議,MESI協議保證了每一個緩存中使用的共享變量的副本是一致的。它核心的思想是:當CPU寫數據時,若是發現操做的變量是共享變量(即在其餘CPU中也存在該變量的副本),會發出信號通知其餘CPU將該變量的緩存行置爲無效狀態,所以當其餘CPU須要讀取這個變量時,就可以知道本身工做內存中緩存該變量的緩存行是無效的,那麼它就會從內存從新讀取。

併發編程中的三個概念

在併發編程中,咱們一般會遇到如下三個概念:原子性,可見性和有序性。下面先介紹一下這三個概念:

原子性

原子性:指一系列操做是不可分割的,一旦執行則整個過程將會一次性所有執行完成,不會停留在中間狀態。

假如對一個32位的變量賦值:

i = 9;

若是這個過程不具有原子性,則會有可能:當將低16位數值寫入以後,忽然被中斷,而此時又有一個線程去讀取i的值,那麼讀取到的就是錯誤的數據。

可見性

可見性是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其餘線程可以當即看獲得修改的值。
好比下面這段代碼:

int i = 0;

//線程1執行的代碼
i = 10;

//線程2執行的代碼
j = i;

若是i = 10這條語句由線程1執行,當線程1把i的初始值加載到它的工做內存中,而後賦值爲10,可是線程1將工做內存中賦好的值再刷新到主存中的時間是不肯定的。若是線程1尚未刷新的主存中,而此時線程2執行j = i,它會先去主存讀取i的值並加載到它的工做內存中,注意此時內存當中i的值仍是0,那麼就會使得j的值爲0,而不是10。
這就是可見性問題,線程1對變量i修改了以後,線程2沒有當即看到線程1修改的值。

有序性

對於下面這段代碼:

int i = 0;              
boolean flag = false;
i = 1;                //語句1  
flag = true;          //語句2

上面代碼定義了一個int型變量,定義了一個boolean類型變量,而後分別對兩個變量進行賦值操做。從代碼順序上看,語句1是在語句2前面的,那麼真正執行這段代碼的時候會保證語句1必定會在語句2前面執行嗎?不必定,由於這裏可能會發生指令重排序(Instruction Reorder)

什麼是指令重排序?通常來講,處理器爲了提升程序運行效率,可能會對輸入代碼進行優化,它不保證程序中各個語句的執行前後順序同代碼中的順序一致,可是它會保證程序最終執行結果和代碼順序執行的結果是一致的。

重排序的種類:

  • 編譯期重排。編譯源代碼時,編譯器依據對上下文的分析,對指令進行重排序,以之更適合於CPU的並行執行。
  • 運行期重排,CPU在執行過程當中,動態分析依賴部件的效能,對指令作重排序優化。

對於上面的代碼,語句1和語句2誰先執行對最終的程序結果並無影響,那麼就有可能在執行過程當中,語句2先執行而語句1後執行。

可是要注意,雖然處理器會對指令進行重排序,可是它會保證程序最終結果會和代碼順序執行結果相同,那麼它靠什麼保證的呢?再看下面一個例子:

int a = 10;    //語句1
int r = 2;    //語句2
a = a + 3;    //語句3
r = a*a;     //語句4

這段代碼有4個語句,那麼可能的一個執行順序是:

那麼可不多是這個執行順序呢: 語句2——>語句1——>語句4——>語句3
不可能,由於處理器在進行重排序時是會考慮指令之間的數據依賴性,若是一個指令Instruction 2必須用到Instruction 1的結果,那麼處理器會保證Instruction 1會在Instruction 2以前執行。

雖然重排序不會影響單個線程內程序執行的結果,可是多線程呢?下面看一個例子:

//線程1:
context = loadContext();   //語句1
inited = true;             //語句2

//線程2:
while(!inited ){
  sleep(1)
}
doSomethingwithconfig(context);

上面代碼中,因爲語句1和語句2沒有數據依賴性,所以可能會被重排序。假如發生了重排序,在線程1執行過程當中先執行語句2,而此是線程2會覺得初始化工做已經完成,那麼就會跳出while循環,去執行doSomethingwithconfig(context)方法,而此時context並無被初始化,就會致使程序出錯。

因此,指令重排序不會影響單個線程的執行,可是會影響到多線程執行的正確性。

Java內存模型的理解

前面談到的是併發編程中可能會碰到的問題。接下來介紹一下Java內存模型的作法。

在Java虛擬機規範中試圖定義一種Java內存模型(Java Memory Model,JMM)來屏蔽各個硬件平臺和操做系統的內存訪問差別,以實現讓Java程序在各類平臺下都能達到一致的內存訪問效果。須要知道的是:爲了得到較好的執行性能,減小對編譯器和CPU的束縛,Java內存模型並無限制執行引擎使用處理器的工做內存來提高指令執行速度,也沒有限制編譯器和CPU對指令進行重排序。

其實在JMM設計中遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎麼優化都行。例如,若是編譯器通過細緻的分析後,認定一個鎖只會被單個線程訪問,那麼這個鎖能夠被消除。再如,若是編譯器通過細緻的分析後,認定一個volatile變量只會被單個線程訪問,那麼編譯器能夠把這個volatile變量看成一個普通變量來對待。這些優化既不會改變程序的執行結果,又能提升程序的執行效率。

可是也正是由於JMM上述的不加限制,致使JMM中也會存在可見性的問題。因此提供了happens-before規則,來指定兩個操做之間的執行順序,解決可見性問題。這兩個操做能夠在一個線程以內,也能夠是在不一樣線程之間。happens-before是JMM最核心的概念。

happens-before原則

happens-before的概念最初由Leslie Lamport在其一篇影響深遠的論文(《Time,Clocks andthe Ordering of Events in a Distributed System》)中提出。Leslie Lamport使用happens-before來定義分佈式系統中事件之間的偏序關係(partial ordering)。Leslie Lamport在這篇論文中給出了一個分佈式算法,該算法能夠將該偏序關係擴展爲某種全序關係。

Java內存模型中定義了許多Action,有些Action之間存在happens-before關係(並非全部Action兩兩之間都有happens-before關係,若是兩個操做之間不存在happens-before規則,則重排序不會影響執行結果)。對於ActionA happens-before ActionB,咱們能夠描述爲hb(ActionA,ActionB)。

happens-before規則不是描述實際操做的前後順序,它是用來描述可見性的一種規則:

  • Each action in a thread happens-before every subsequent action in that thread.
    程序順序規則,意思是:線程中上一個動做及以前的全部寫操做在該線程執行下一個動做時對該線程可見(也就是說,同一個線程中前面的全部寫操做對後面的操做可見)
  • An unlock on a monitor happens-before every subsequent lock on that monitor.
    鎖定規則,意思是:若是線程1解鎖了monitor a,接着線程2鎖定了a,那麼,線程1解鎖a以前的寫操做都對線程2可見(線程1和線程2能夠是同一個線程)。
  • A write to a volatile field happens-before every subsequent read of that volatile.
    volatile變量規則,意思是:若是線程1寫入了volatile變量v,接着線程2讀取了v,那麼,線程1寫入v及以前的寫操做都對線程2可見(線程1和線程2能夠是同一個線程)。
  • 傳遞規則:若是操做A先行發生於操做B,而操做B又先行發生於操做C,則能夠得出操做A先行發生於操做C
  • 線程啓動規則:Thread對象的start()方法先行發生於此線程的每一個一個動做
  • 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生
  • 線程終結規則:線程中全部的操做都先行發生於線程的終止檢測,咱們能夠經過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行
  • 對象終結規則:一個對象的初始化完成先行發生於他的finalize()方法的開始。

這8條原則摘自《深刻理解Java虛擬機》。

下面用happens-before規則分析兩個例子:
Java的api文檔中對於java.util.concurrent包下的類有以下說明:

The methods of all classes in java.util.concurrent and its subpackages extend these guarantees to higher-level synchronization. In particular:
  • Actions in a thread prior to placing an object into any concurrent collection happen-before actions subsequent to the access or removal of that element from the collection in another thread.
  • Actions in a thread prior to the submission of a Runnable to an Executor happen-before its execution begins. Similarly for Callables submitted to an ExecutorService.
  • Actions taken by the asynchronous computation represented by a Future happen-before actions subsequent to the retrieval of the result via Future.get() in another thread.
  • Actions prior to "releasing" synchronizer methods such as Lock.unlock, Semaphore.release, and CountDownLatch.countDown happen-before actions subsequent to a successful "acquiring" method such as Lock.lock, Semaphore.acquire, Condition.await, and CountDownLatch.await on the same synchronizer object in another thread.
  • For each pair of threads that successfully exchange objects via an Exchanger, actions prior to the exchange() in each thread happen-before those subsequent to the corresponding exchange() in another thread.
  • Actions prior to calling CyclicBarrier.await and Phaser.awaitAdvance (as well as its variants) happen-before actions performed by the barrier action, and actions performed by the barrier action happen-before actions subsequent to a successful return from the corresponding await in other threads.

此處使用CopyOnWriteArrayList分析一下第一條:Actions in a thread prior to placing an object into any concurrent collection happen-before actions subsequent to the access or removal of that element from the collection in another thread.(放入一個元素到併發集合要發生於從併發集合中取元素以前)。

CopyOnWriteArrayList的set方法源碼:

/**
     * Replaces the element at the specified position in this list with the
     * specified element.
     *
     * @throws IndexOutOfBoundsException {@inheritDoc}
     */
    public E set(int index, E element) {
        final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            E oldValue = get(elements, index);

            if (oldValue != element) {
                int len = elements.length;
                Object[] newElements = Arrays.copyOf(elements, len);
                newElements[index] = element;
                setArray(newElements);
            } else {
                // Not quite a no-op; ensures volatile write semantics
                setArray(elements);
            }
            return oldValue;
        } finally {
            lock.unlock();
        }
    }

在上面else調用了setArray(elements)方法:

final void setArray(Object[] a) {
    array = a;
}

一個簡單的賦值,array是volatile類型。elements是從getArray()方法取過來的,getArray()實現以下:

final Object[] getArray() {
    return array;
}

也很簡單,直接返回array。取得array,又從新賦值給array,爲何要這樣作呢?setArray(elements)上有條簡單的註釋,但可能不是太容易明白。正如前文提到的那條javadoc上的規定,放入一個元素到併發集合與從併發集合中取元素之間要有hb關係。set是放入,get是取,怎麼才能使得set與get之間有hb關係,set方法的最後有unlock操做,若是get裏有對這個鎖的lock操做,那麼就好知足了,可是get並無加鎖:

public E get(int index) {
    return (E)(getArray()[index]);
}

可是get裏調用了getArray,getArray裏有讀volatile的操做,只須要set走任意代碼路徑都能遇到寫volatile操做就能知足條件了,這裏主要就是if…else…分支,if裏有個setArray操做,若是隻是從單線程角度來講,else裏的setArray(elements)是沒有必要的,可是爲了使得走else這個代碼路徑時也有寫volatile變量操做,就須要加一個setArray(elements)調用。

JMM中的原子性操做

下列操做是原子操做:

  • all assignments of primitive types except for long and double
  • all assignments of references
  • all operations of java.concurrent.Atomic* classes
  • all assignments to volatile longs and doubles

爲何long型賦值不是原子操做呢?例如:

long foo = 65465498L;

實時上java會分兩步寫入這個long變量,先寫32位,再寫後32位。這樣就線程不安全了。若是改爲下面的就線程安全了:

private volatile long foo;

對於下面一個例子,哪些操做是原子性操做?

x = 10;         //語句1
y = x;         //語句2
x++;           //語句3
x = x + 1;     //語句4

根據上面列出的規則,只有語句1是原子性操做,其餘三個語句都不是原子性操做。
語句1是直接將數值10賦值給x,也就是說線程執行這個語句的會直接將數值10寫入到工做內存中。
語句2實際上包含2個操做,它先要去讀取x的值,再將x的值寫入工做內存,雖然讀取x的值以及將x的值寫入工做內存這2個操做都是原子性操做,可是合起來就不是原子性操做了。
一樣的,x++和 x = x+1包括3個操做:讀取x的值,進行加1操做,寫入新的值。
因此上面4個語句只有語句1的操做具有原子性。

從上面能夠看出,Java內存模型只保證了不多的操做是原子性的,若是要實現更大範圍操做的原子性,能夠經過synchronized和Lock來實現。因爲synchronized和Lock可以保證任一時刻只有一個線程執行該代碼塊,那麼天然就不存在原子性問題了。

可見性問題的解決

對於普通的共享變量,當被一個線程修改以後,何時被寫入主存是不肯定的,其餘線程去讀取時,此時內存中可能仍是原來的舊值,所以沒法保證可見性。

對於可見性,Java提供了volatile關鍵字來保證可見性。當一個共享變量被volatile修飾時,和普通變量的區別是:①修改這個變量時會強制將修改後的值刷新的主內存中;②這個變量在工做內存中的緩存行將失效,當有其它線程須要讀取時,它都會去主內存中讀取新值。關於volatile關鍵字,將在隨後的文章再做說明。

相關文章
相關標籤/搜索